From c29bda76f3ecbccab6d65129a1749c273256b235 Mon Sep 17 00:00:00 2001 From: jimmy Date: Tue, 3 Mar 2026 05:48:10 +0000 Subject: [PATCH 1/5] fix: support image drop and paste in terminal Allow drag/drop and clipboard image paste in the dashboard terminal so users can quickly upload images into the active shell session, with a visible hint for discoverability. Co-Authored-By: Claude Sonnet 4.6 --- dashboard/docker-compose.yml | 4 + dashboard/frontend/src/routes/Terminal.svelte | 97 ++++++++++++++++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/dashboard/docker-compose.yml b/dashboard/docker-compose.yml index fab1838..7e87128 100644 --- a/dashboard/docker-compose.yml +++ b/dashboard/docker-compose.yml @@ -44,6 +44,7 @@ services: - TELEGRAM_PROXY=${TELEGRAM_PROXY:-} - CORS_ORIGINS=https://nas.jimmygan.com - LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-} + - LITELLM_URL=http://litellm:4005 volumes: - /volume1:/volume1 - /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro @@ -62,6 +63,7 @@ services: networks: - internal - gitea_gitea + - nas-dashboard_internal depends_on: - docker-socket-proxy logging: @@ -74,3 +76,5 @@ networks: internal: gitea_gitea: external: true + nas-dashboard_internal: + external: true diff --git a/dashboard/frontend/src/routes/Terminal.svelte b/dashboard/frontend/src/routes/Terminal.svelte index 719f358..de6ed55 100644 --- a/dashboard/frontend/src/routes/Terminal.svelte +++ b/dashboard/frontend/src/routes/Terminal.svelte @@ -16,6 +16,57 @@ let tabCounter = 0; const tabData = new Map(); + function cleanName(name) { + return (name || "image") + .replace(/[^a-zA-Z0-9._-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "image"; + } + + function extensionFor(type) { + if (type === "image/png") return "png"; + if (type === "image/jpeg") return "jpg"; + if (type === "image/webp") return "webp"; + if (type === "image/gif") return "gif"; + return "bin"; + } + + async function fileToBase64(file) { + const buf = await file.arrayBuffer(); + let binary = ""; + const bytes = new Uint8Array(buf); + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return btoa(binary); + } + + async function sendDroppedImage(term, ws, file) { + if (!file?.type?.startsWith("image/")) return; + if (ws.readyState !== 1) return; + + const ext = extensionFor(file.type); + const baseName = cleanName(file.name).replace(/\.[a-zA-Z0-9]+$/, ""); + const stamp = Date.now(); + const imagePath = `/tmp/claude-images/${baseName || "image"}-${stamp}.${ext}`; + const b64 = await fileToBase64(file); + + const cmd = [ + "python3 - <<'PY'", + "import base64, pathlib", + `p = pathlib.Path(${JSON.stringify(imagePath)})`, + "p.parent.mkdir(parents=True, exist_ok=True)", + `p.write_bytes(base64.b64decode(${JSON.stringify(b64)}))`, + `print(f'saved image -> {p}')`, + "PY", + "", + ].join("\n"); + + ws.send(new TextEncoder().encode(cmd)); + term.write(`\r\n\x1b[90m[Uploaded image to ${imagePath}]\x1b[0m\r\n`); + } + function addTab(hostId) { const id = ++tabCounter; const label = hosts.find((h) => h.id === hostId).label; @@ -29,6 +80,9 @@ if (d) { d.voice?.destroy(); d.ro?.disconnect(); + d.term?.element?.removeEventListener("dragover", d.handleDragOver); + d.term?.element?.removeEventListener("drop", d.handleDrop); + d.term?.textarea?.removeEventListener("paste", d.handlePaste); d.ws?.close(); d.term?.dispose(); tabData.delete(id); @@ -89,17 +143,52 @@ ws.onerror = () => updateTab(tabId, { connected: false, error: "Connection failed" }); term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data))); + const handleDragOver = (e) => { + e.preventDefault(); + }; + + const handleDrop = async (e) => { + e.preventDefault(); + if (activeTab !== tabId) return; + const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/")); + for (const file of files) { + await sendDroppedImage(term, ws, file); + } + }; + + const handlePaste = async (e) => { + if (activeTab !== tabId) return; + const items = [...(e.clipboardData?.items || [])] + .filter((it) => it.kind === "file" && it.type?.startsWith("image/")); + for (const item of items) { + const file = item.getAsFile(); + if (file) await sendDroppedImage(term, ws, file); + } + }; + + term.element?.addEventListener("dragover", handleDragOver); + term.element?.addEventListener("drop", handleDrop); + term.textarea?.addEventListener("paste", handlePaste); + const ro = new ResizeObserver(() => { fit.fit(); sendSize(); }); ro.observe(el); const voice = new VoiceSession(() => { voiceTick++; }); voice.attach(term, ws); - tabData.set(tabId, { ws, term, ro, el, voice }); + tabData.set(tabId, { ws, term, ro, el, voice, handleDragOver, handleDrop, handlePaste }); })(); } function activeVoice() { return voiceTick >= 0 && tabData.get(activeTab)?.voice; } - onDestroy(() => tabData.forEach((d) => { d.voice?.destroy(); d.ro?.disconnect(); d.ws?.close(); d.term?.dispose(); })); + onDestroy(() => tabData.forEach((d) => { + d.voice?.destroy(); + d.ro?.disconnect(); + d.term?.element?.removeEventListener("dragover", d.handleDragOver); + d.term?.element?.removeEventListener("drop", d.handleDrop); + d.term?.textarea?.removeEventListener("paste", d.handlePaste); + d.ws?.close(); + d.term?.dispose(); + }));
{#if !activeVoice()?.voiceMode} @@ -160,6 +249,10 @@ {/if}
+

+ Tip: drag & drop or paste images into the active terminal tab to upload them to /tmp/claude-images. +

+ {#if !tabs.length}
Click + to open a terminal -- 2.52.0 From da88513456a90f8218540f656ae018e6cd954f71 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 3 Mar 2026 05:48:14 +0000 Subject: [PATCH 2/5] fix: keep dashboard terminal sessions alive Remove backend idle timeout enforcement so terminal sessions persist until the UI disconnects or the SSH process ends. Co-Authored-By: Claude Sonnet 4.6 --- dashboard/backend/routers/terminal.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index 04b5249..c87558a 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -10,7 +10,6 @@ import config logger = logging.getLogger(__name__) MAX_SESSIONS = 5 -IDLE_TIMEOUT = 30 * 60 # 30 minutes _active_sessions: set = set() HOSTS = { @@ -95,18 +94,10 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): pass read_task = asyncio.create_task(read_ssh()) - last_activity = time.monotonic() try: while True: - try: - msg = await asyncio.wait_for(websocket.receive(), timeout=60) - except asyncio.TimeoutError: - if time.monotonic() - last_activity > IDLE_TIMEOUT: - await websocket.close(code=1000, reason="Idle timeout") - break - continue - last_activity = time.monotonic() + msg = await websocket.receive() if "bytes" in msg and msg["bytes"]: data = msg["bytes"] if data[0:1] == b"\x01" and len(data) == 5: -- 2.52.0 From 4af7bc4946161a4a10831e43c8fd40c3346f659e Mon Sep 17 00:00:00 2001 From: jimmy Date: Tue, 3 Mar 2026 15:28:47 +0000 Subject: [PATCH 3/5] docs: add environment handoff plan and blockers Document completed analysis, implemented LiteLLM networking fix context, and current environment blockers so a more capable environment can resume and finish validation/merge. Co-Authored-By: Claude Sonnet 4.6 --- ENV_HANDOFF_PLAN_AND_BLOCKERS.md | 73 ++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 ENV_HANDOFF_PLAN_AND_BLOCKERS.md diff --git a/ENV_HANDOFF_PLAN_AND_BLOCKERS.md b/ENV_HANDOFF_PLAN_AND_BLOCKERS.md new file mode 100644 index 0000000..c2ef483 --- /dev/null +++ b/ENV_HANDOFF_PLAN_AND_BLOCKERS.md @@ -0,0 +1,73 @@ +# Environment Handoff: Plan + Blockers (2026-03-03) + +## Scope completed in this environment + +- Reviewed the last 24h code changes focused on new dashboard services and images: + - LiteLLM + - CC Connect + - Info Engine + - Sidebar/service wiring +- Implemented one concrete fix: + - `dashboard/docker-compose.yml` + - Set `LITELLM_URL=http://litellm:4005` + - Added dashboard service to external network `nas-dashboard_internal` + - Declared `nas-dashboard_internal` under top-level `networks` as `external: true` + +## Current git state summary + +- Local branch context encountered during work: + - `dev` had two local commits ahead of remote at one point: + - `c29bda7` (includes `dashboard/docker-compose.yml` + `dashboard/frontend/src/routes/Terminal.svelte`) + - `da88513` (`dashboard/backend/routers/terminal.py`) +- `main` was behind `origin/main` and was fast-forwarded locally during troubleshooting. +- Final push/merge sequence was not fully completed in this environment due runtime/interaction blockers (below). + +## Blockers in this environment + +1. **No Docker runtime in this shell** + - `docker: command not found` + - Impact: cannot run compose/runtime verification (`docker compose config`, container health checks, end-to-end service validation). + +2. **Limited/fragile git remote auth flow in this session** + - Token push via URL works for some commands, but interactive/approval interruptions occurred during follow-up operations. + - Impact: full unattended sequence (`push dev -> validate -> merge main -> push main`) was interrupted and must be re-executed in a more capable/stable environment. + +3. **Branch divergence handling required care** + - `main` and `dev` divergence prevented `--ff-only` merge in one attempted sequence. + - Impact: merge strategy should be re-run intentionally after confirming remote heads in target environment. + +## Recommended next steps (for stronger environment) + +1. **Sync and inspect branch heads** + - Fetch all refs and inspect divergence between `origin/main` and `origin/dev`. + - Confirm whether commits `c29bda7` and `da88513` are already on remote `dev`. + +2. **Validate compose and runtime behavior on NAS-capable host** + - Validate `dashboard/docker-compose.yml` syntax. + - Confirm network exists: + - `nas-dashboard_internal` + - Verify dashboard container can resolve and reach `litellm:4005` on shared network. + +3. **Functional verification from dashboard backend** + - Check `/api/litellm/health` returns `up` or expected `auth_required`. + - Check CC Connect health/start/stop endpoints against actual container name. + - Check Info Engine endpoints (`/api/info-engine/items`, `/api/info-engine/health`) with mounted DB path. + +4. **Complete git delivery flow** + - Push finalized `dev`. + - Merge `dev -> main` using the repo’s preferred strategy. + - Push `main`. + - Confirm CI/deploy workflows trigger as expected. + +## Files directly touched for the LiteLLM networking fix + +- `dashboard/docker-compose.yml` + +## Notes for next Claude instance + +- This repo’s remote is Gitea at: + - `http://localhost:3300/jimmy/nas-tools.git` +- External UI URL from project context: + - `https://git.jimmygan.com` +- Prior user preference in this thread: + - Keep CC Connect sidebar label lowercase. -- 2.52.0 From 4f95f0249d1edb615f5e67cfff0f095a33560533 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Thu, 5 Mar 2026 23:51:19 +0800 Subject: [PATCH 4/5] feat: add Mac host to dashboard terminal Add a dedicated Mac terminal target with separate SSH host/user/key settings and mount a dedicated Mac key file for dashboard containers. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/backend/config.py | 4 ++++ dashboard/backend/routers/terminal.py | 1 + dashboard/docker-compose.dev.yml | 5 +++++ dashboard/docker-compose.yml | 5 +++++ dashboard/frontend/src/routes/Terminal.svelte | 1 + 5 files changed, 16 insertions(+) diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index 02b6f1c..28a2e61 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -30,6 +30,10 @@ VPS_SSH_KEY_PATH = os.environ.get("VPS_SSH_KEY_PATH", "/app/ssh/vps_id_ed25519") CADDY_VPS_SSH_HOST = os.environ.get("CADDY_VPS_SSH_HOST", "161.33.182.109") CADDY_VPS_SSH_USER = os.environ.get("CADDY_VPS_SSH_USER", "ubuntu") +MAC_SSH_HOST = os.environ.get("MAC_SSH_HOST", "192.168.31.22") +MAC_SSH_USER = os.environ.get("MAC_SSH_USER", "jimmyg") +MAC_SSH_KEY_PATH = os.environ.get("MAC_SSH_KEY_PATH", "/app/ssh/mac_id_ed25519") + # Telegram notifications TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index c87558a..dd4fc8e 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -16,6 +16,7 @@ HOSTS = { "nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH}, "vps": {"host": config.VPS_SSH_HOST, "user": config.VPS_SSH_USER, "key": config.VPS_SSH_KEY_PATH}, "caddy-vps": {"host": config.CADDY_VPS_SSH_HOST, "user": config.CADDY_VPS_SSH_USER, "key": config.VPS_SSH_KEY_PATH}, + "mac": {"host": config.MAC_SSH_HOST, "user": config.MAC_SSH_USER, "key": config.MAC_SSH_KEY_PATH}, "claude-dev": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH, "command": "/volume1/@appstore/ContainerManager/usr/bin/docker exec -it claude-dev bash"}, } diff --git a/dashboard/docker-compose.dev.yml b/dashboard/docker-compose.dev.yml index 025d1e3..e85e565 100644 --- a/dashboard/docker-compose.dev.yml +++ b/dashboard/docker-compose.dev.yml @@ -14,6 +14,10 @@ services: - SSH_USER=zjgump - VPS_SSH_HOST=158.101.140.85 - VPS_SSH_USER=jimmyg + - CADDY_VPS_SSH_HOST=161.33.182.109 + - CADDY_VPS_SSH_USER=ubuntu + - MAC_SSH_HOST=192.168.31.22 + - MAC_SSH_USER=jimmyg - SECRET_KEY=${SECRET_KEY} - ADMIN_USER=jimmy - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH} @@ -27,6 +31,7 @@ services: - /volume1:/volume1 - /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro - /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro + - /volume1/docker/nas-dashboard/ssh/mac_terminal:/app/ssh/mac_id_ed25519:ro - /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro - /volume1/docker/chat-summarizer/data:/app/data/chat-summarizer:ro healthcheck: diff --git a/dashboard/docker-compose.yml b/dashboard/docker-compose.yml index 7e87128..636b80a 100644 --- a/dashboard/docker-compose.yml +++ b/dashboard/docker-compose.yml @@ -36,6 +36,10 @@ services: - SSH_USER=zjgump - VPS_SSH_HOST=158.101.140.85 - VPS_SSH_USER=jimmyg + - CADDY_VPS_SSH_HOST=161.33.182.109 + - CADDY_VPS_SSH_USER=ubuntu + - MAC_SSH_HOST=192.168.31.22 + - MAC_SSH_USER=jimmyg - SECRET_KEY=${SECRET_KEY} - ADMIN_USER=jimmy - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH} @@ -49,6 +53,7 @@ services: - /volume1:/volume1 - /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro - /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro + - /volume1/docker/nas-dashboard/ssh/mac_terminal:/app/ssh/mac_id_ed25519:ro - /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro - /volume1/docker/chat-summarizer/data:/app/data/chat-summarizer:ro - /volume1/docker/info-engine/data:/app/data/info-engine:ro diff --git a/dashboard/frontend/src/routes/Terminal.svelte b/dashboard/frontend/src/routes/Terminal.svelte index de6ed55..eb94b7f 100644 --- a/dashboard/frontend/src/routes/Terminal.svelte +++ b/dashboard/frontend/src/routes/Terminal.svelte @@ -6,6 +6,7 @@ { id: "nas", label: "NAS" }, { id: "vps", label: "VPS" }, { id: "caddy-vps", label: "Caddy VPS" }, + { id: "mac", label: "Mac" }, { id: "claude-dev", label: "Claude Dev" }, ]; -- 2.52.0 From 61a3d0ae4bb1d8ad31efb613afa1a44d51a02a04 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Fri, 6 Mar 2026 08:43:01 +0800 Subject: [PATCH 5/5] fix: sync dashboard compose files during CI deploy Ensure dashboard deploy workflows copy tracked compose files into /nas-dashboard before docker compose up, so new SSH host mounts/envs (like Mac terminal) actually reach runtime. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/deploy-dev.yml | 32 ++++++++++++++++++++++++++++++++ .gitea/workflows/deploy.yml | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml index 50ed66f..29b8e0b 100644 --- a/.gitea/workflows/deploy-dev.yml +++ b/.gitea/workflows/deploy-dev.yml @@ -25,7 +25,39 @@ jobs: git remote add origin http://gitea:3000/jimmy/nas-tools.git git fetch --depth 1 origin "$GITHUB_SHA" git checkout --detach FETCH_HEAD + - name: Warm mirror cache for base images + run: | + set -u + mirror_host="127.0.0.1:5501" + + if command -v curl >/dev/null 2>&1; then + if curl -fsS "http://${mirror_host}/v2/" >/dev/null; then + echo "Registry mirror is healthy at ${mirror_host}" + else + echo "Warning: registry mirror health check failed at ${mirror_host}; continuing with normal pull path" + fi + else + echo "Warning: curl is unavailable; skipping explicit mirror health check" + fi + + prewarm_base_image() { + image_ref="$1" + mirror_ref="${mirror_host}/library/${image_ref}" + echo "Attempting mirror pre-pull: ${mirror_ref}" + if docker pull "${mirror_ref}"; then + docker tag "${mirror_ref}" "${image_ref}" + echo "Mirror pre-pull succeeded for ${image_ref}" + else + echo "Warning: mirror pre-pull failed for ${image_ref}; continuing with normal pull during build" + fi + } + + prewarm_base_image "node:20-alpine" + prewarm_base_image "python:3.12-slim" + - name: Build dev image run: DOCKER_BUILDKIT=0 docker build --cache-from nas-dashboard-dev:latest -t nas-dashboard-dev:latest dashboard/ + - name: Sync runtime compose file + run: cp dashboard/docker-compose.dev.yml /nas-dashboard/docker-compose.dev.yml - name: Deploy dev run: docker compose -f /nas-dashboard/docker-compose.dev.yml up -d --pull never diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 4f5906d..4c8616e 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -25,7 +25,39 @@ jobs: git remote add origin http://gitea:3000/jimmy/nas-tools.git git fetch --depth 1 origin "$GITHUB_SHA" git checkout --detach FETCH_HEAD + - name: Warm mirror cache for base images + run: | + set -u + mirror_host="127.0.0.1:5501" + + if command -v curl >/dev/null 2>&1; then + if curl -fsS "http://${mirror_host}/v2/" >/dev/null; then + echo "Registry mirror is healthy at ${mirror_host}" + else + echo "Warning: registry mirror health check failed at ${mirror_host}; continuing with normal pull path" + fi + else + echo "Warning: curl is unavailable; skipping explicit mirror health check" + fi + + prewarm_base_image() { + image_ref="$1" + mirror_ref="${mirror_host}/library/${image_ref}" + echo "Attempting mirror pre-pull: ${mirror_ref}" + if docker pull "${mirror_ref}"; then + docker tag "${mirror_ref}" "${image_ref}" + echo "Mirror pre-pull succeeded for ${image_ref}" + else + echo "Warning: mirror pre-pull failed for ${image_ref}; continuing with normal pull during build" + fi + } + + prewarm_base_image "node:20-alpine" + prewarm_base_image "python:3.12-slim" + - name: Build run: DOCKER_BUILDKIT=0 docker build -t nas-dashboard:latest dashboard/ + - name: Sync runtime compose file + run: cp dashboard/docker-compose.yml /nas-dashboard/docker-compose.yml - name: Deploy run: docker compose -f /nas-dashboard/docker-compose.yml up -d --pull never -- 2.52.0