From c180a68f36d99e9d90debdc5a69cb604fa3d70cd Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Fri, 10 Apr 2026 01:00:49 +0800 Subject: [PATCH 1/4] fix: add fallback registries and improve deployment reliability - Add fallback to official npm/PyPI registries when Chinese mirrors fail - Fix dev workflow mirror host (127.0.0.1 -> 100.78.131.124) - Add error handling and diagnostics to build steps - Fix iPhone UI: make download/delete buttons always visible on mobile - Add .dockerignore to reduce build context size --- .gitea/workflows/deploy-dev.yml | 10 ++++++++-- .gitea/workflows/deploy.yml | 10 +++++++++- dashboard/.dockerignore | 9 +++++++++ dashboard/Dockerfile | 6 ++++-- dashboard/frontend/src/routes/Files.svelte | 2 +- 5 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 dashboard/.dockerignore diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml index 1324d23..07f8037 100644 --- a/.gitea/workflows/deploy-dev.yml +++ b/.gitea/workflows/deploy-dev.yml @@ -22,7 +22,7 @@ jobs: - name: Warm mirror cache for base images run: | set -u - mirror_host="127.0.0.1:5501" + mirror_host="100.78.131.124:5501" prewarm_base_image() { image_ref="$1" @@ -47,8 +47,14 @@ jobs: - name: Build dev image run: | + set -e export DOCKER_API_VERSION=1.43 - DOCKER_BUILDKIT=0 docker build --cache-from nas-dashboard-dev:latest -t nas-dashboard-dev:latest dashboard/ + if ! DOCKER_BUILDKIT=0 docker build --cache-from nas-dashboard-dev:latest -t nas-dashboard-dev:latest dashboard/; then + echo "Build failed. Checking for network issues..." + curl -I https://registry.npmmirror.com || echo "npmmirror unreachable" + curl -I https://pypi.tuna.tsinghua.edu.cn || echo "Tsinghua PyPI unreachable" + exit 1 + fi - name: Sync runtime compose file run: | mkdir -p /volume1/docker/nas-dashboard diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index ce0fcd7..40d58f8 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -54,7 +54,15 @@ jobs: prewarm_base_image "python:3.12-slim" - name: Build - run: DOCKER_BUILDKIT=0 docker build -t nas-dashboard:latest dashboard/ + run: | + set -e + export DOCKER_API_VERSION=1.43 + if ! DOCKER_BUILDKIT=0 docker build -t nas-dashboard:latest dashboard/; then + echo "Build failed. Checking for network issues..." + curl -I https://registry.npmmirror.com || echo "npmmirror unreachable" + curl -I https://pypi.tuna.tsinghua.edu.cn || echo "Tsinghua PyPI unreachable" + exit 1 + fi - name: Sync runtime compose file run: cp dashboard/docker-compose.yml /nas-dashboard/docker-compose.yml - name: Deploy diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore new file mode 100644 index 0000000..77f1f6a --- /dev/null +++ b/dashboard/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.git +.gitignore +*.md +.env +.vscode +__pycache__ +*.pyc +.pytest_cache diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile index 9ca993d..8780e9c 100644 --- a/dashboard/Dockerfile +++ b/dashboard/Dockerfile @@ -3,7 +3,8 @@ FROM node:20-alpine AS frontend WORKDIR /build COPY frontend/package.json frontend/package-lock.json* ./ COPY frontend/ ./ -RUN npm install --registry=https://registry.npmmirror.com && npm run build +RUN npm install --registry=https://registry.npmmirror.com || npm install +RUN npm run build # Stage 2: Python runtime FROM python:3.12-slim @@ -11,7 +12,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssh-client RUN adduser --disabled-password --no-create-home --gecos "" app WORKDIR /app COPY backend/requirements.txt . -RUN pip install --no-cache-dir --index-url https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt +RUN pip install --no-cache-dir --index-url https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt || \ + pip install --no-cache-dir -r requirements.txt COPY backend/ ./ COPY --from=frontend /build/dist /app/static RUN chown -R app:app /app diff --git a/dashboard/frontend/src/routes/Files.svelte b/dashboard/frontend/src/routes/Files.svelte index fbeb3af..f3ddd21 100644 --- a/dashboard/frontend/src/routes/Files.svelte +++ b/dashboard/frontend/src/routes/Files.svelte @@ -135,7 +135,7 @@ {/if} {e.is_dir ? "—" : formatSize(e.size)} -
+
{#if !e.is_dir} {/if} From 6ea64bc19ee0468e3a79e249f3daf65bc9f0dd09 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Fri, 10 Apr 2026 19:25:52 +0800 Subject: [PATCH 2/4] feat: add Transmission monitoring to dashboard - Add backend API for Transmission RPC integration - Create frontend page with torrent and tracker status - Add health monitoring script - Display daemon status, speeds, and tracker errors - Support reannounce, start/stop actions --- dashboard/backend/main.py | 4 + dashboard/backend/routers/transmission.py | 211 +++++++++++++ dashboard/frontend/src/App.svelte | 4 + .../frontend/src/components/Sidebar.svelte | 3 + .../frontend/src/routes/Transmission.svelte | 282 ++++++++++++++++++ scripts/check-transmission-health.sh | 52 ++++ 6 files changed, 556 insertions(+) create mode 100644 dashboard/backend/routers/transmission.py create mode 100644 dashboard/frontend/src/routes/Transmission.svelte create mode 100755 scripts/check-transmission-health.sh diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index ef22e5f..28b2a88 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -39,6 +39,7 @@ from routers import ( system, terminal, totp, + transmission, ) _tz_cst = timezone(timedelta(hours=8)) @@ -303,6 +304,9 @@ app.include_router( app.include_router( security.router, prefix="/api/security", dependencies=[Depends(_inject_user), Depends(require_page("security"))] ) +app.include_router( + transmission.router, dependencies=[Depends(_inject_user), Depends(require_page("transmission"))] +) # OPC endpoints app.include_router( diff --git a/dashboard/backend/routers/transmission.py b/dashboard/backend/routers/transmission.py new file mode 100644 index 0000000..83eae72 --- /dev/null +++ b/dashboard/backend/routers/transmission.py @@ -0,0 +1,211 @@ +import subprocess +from typing import Any, Dict, List + +import httpx +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api/transmission", tags=["transmission"]) + + +def get_session_id() -> str: + """Get Transmission RPC session ID""" + try: + result = subprocess.run( + ["ssh", "nas", "curl -si -u 'admin:admin' 'http://127.0.0.1:9091/transmission/rpc' 2>&1 | grep -i 'X-Transmission-Session-Id:' | cut -d' ' -f2 | tr -d '\\r'"], + capture_output=True, + text=True, + shell=True, + timeout=5 + ) + session_id = result.stdout.strip() + if not session_id: + raise Exception("Failed to get session ID") + return session_id + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get Transmission session ID: {str(e)}") + + +def transmission_rpc(method: str, arguments: Dict[str, Any] = None) -> Dict[str, Any]: + """Make Transmission RPC call""" + try: + session_id = get_session_id() + + payload = {"method": method} + if arguments: + payload["arguments"] = arguments + + # Use httpx to make the request + with httpx.Client(timeout=10.0) as client: + response = client.post( + "http://100.78.131.124:9091/transmission/rpc", + json=payload, + auth=("admin", "admin"), + headers={"X-Transmission-Session-Id": session_id} + ) + + if response.status_code == 200: + data = response.json() + if data.get("result") == "success": + return data.get("arguments", {}) + else: + raise Exception(f"RPC error: {data.get('result')}") + else: + raise Exception(f"HTTP {response.status_code}: {response.text}") + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Transmission RPC failed: {str(e)}") + + +@router.get("/status") +async def get_status(): + """Get Transmission daemon status""" + try: + # Check if process is running + result = subprocess.run( + ["ssh", "nas", "ps aux | grep transmission-daemon | grep sc-transmission | grep -v grep"], + capture_output=True, + text=True, + shell=True, + timeout=5 + ) + + is_running = bool(result.stdout.strip()) + + if not is_running: + return { + "running": False, + "message": "Transmission daemon is not running" + } + + # Get session stats + try: + stats = transmission_rpc("session-stats") + return { + "running": True, + "stats": stats + } + except: + return { + "running": True, + "message": "Daemon running but RPC not accessible" + } + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/torrents") +async def get_torrents(): + """Get list of torrents with tracker status""" + try: + data = transmission_rpc("torrent-get", { + "fields": [ + "id", "name", "status", "percentDone", + "rateDownload", "rateUpload", + "uploadRatio", "trackerStats", + "error", "errorString" + ] + }) + + torrents = data.get("torrents", []) + + # Process tracker stats + for torrent in torrents: + tracker_errors = [] + for tracker in torrent.get("trackerStats", []): + if tracker.get("lastAnnounceResult") and tracker.get("lastAnnounceResult") != "Success": + tracker_errors.append({ + "host": tracker.get("host"), + "error": tracker.get("lastAnnounceResult") + }) + torrent["trackerErrors"] = tracker_errors + torrent["hasTrackerErrors"] = len(tracker_errors) > 0 + + return {"torrents": torrents} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/trackers") +async def get_tracker_summary(): + """Get summary of tracker connectivity""" + try: + data = transmission_rpc("torrent-get", { + "fields": ["trackerStats"] + }) + + torrents = data.get("torrents", []) + tracker_summary = {} + + for torrent in torrents: + for tracker in torrent.get("trackerStats", []): + host = tracker.get("host") + if host not in tracker_summary: + tracker_summary[host] = { + "host": host, + "total": 0, + "errors": 0, + "lastError": None + } + + tracker_summary[host]["total"] += 1 + + if tracker.get("lastAnnounceResult") and tracker.get("lastAnnounceResult") != "Success": + tracker_summary[host]["errors"] += 1 + tracker_summary[host]["lastError"] = tracker.get("lastAnnounceResult") + + return {"trackers": list(tracker_summary.values())} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/stats") +async def get_stats(): + """Get Transmission statistics""" + try: + stats = transmission_rpc("session-stats") + return stats + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/start/{torrent_id}") +async def start_torrent(torrent_id: int): + """Start a torrent""" + try: + transmission_rpc("torrent-start", {"ids": [torrent_id]}) + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/stop/{torrent_id}") +async def stop_torrent(torrent_id: int): + """Stop a torrent""" + try: + transmission_rpc("torrent-stop", {"ids": [torrent_id]}) + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/verify/{torrent_id}") +async def verify_torrent(torrent_id: int): + """Verify a torrent""" + try: + transmission_rpc("torrent-verify", {"ids": [torrent_id]}) + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/reannounce/{torrent_id}") +async def reannounce_torrent(torrent_id: int): + """Force reannounce to tracker""" + try: + transmission_rpc("torrent-reannounce", {"ids": [torrent_id]}) + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index 2c71443..68eff72 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -13,6 +13,7 @@ import CcConnect from "./routes/CcConnect.svelte"; import InfoEngine from "./routes/InfoEngine.svelte"; import OPC from "./routes/OPC.svelte"; + import Transmission from "./routes/Transmission.svelte"; import Login from "./routes/Login.svelte"; import { onMount } from "svelte"; import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js"; @@ -31,6 +32,7 @@ "cc-connect", "info-engine", "opc", + "transmission", ]); let page = $state("dashboard"); @@ -211,6 +213,8 @@ {:else if page === "opc" && hasPageAccess("opc")} + {:else if page === "transmission" && hasPageAccess("transmission")} + {:else if page !== "terminal"} {/if} diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index c96e333..1d62b32 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -42,6 +42,7 @@ { label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" }, { label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" }, { label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" }, + { id: "transmission", label: "Transmission", icon: "download" }, ]; const defaultTools = [ @@ -254,6 +255,8 @@ {:else if icon === "image"} + {:else if icon === "download"} + {:else if icon === "openclaw"} {:else if icon === "chat"} diff --git a/dashboard/frontend/src/routes/Transmission.svelte b/dashboard/frontend/src/routes/Transmission.svelte new file mode 100644 index 0000000..6f03109 --- /dev/null +++ b/dashboard/frontend/src/routes/Transmission.svelte @@ -0,0 +1,282 @@ + + +
+
+

Transmission

+

BitTorrent client status and management

+
+ + {#if loading} +
+ {:else} + +
+ +
+
+
+

Daemon

+

+ {status?.running ? 'Running' : 'Stopped'} +

+
+
+ + + +
+
+
+ + +
+
+
+

Active Torrents

+

+ {torrents.filter(t => t.status === 4 || t.status === 6).length} +

+
+
+ + + +
+
+
+ + +
+
+
+

Download

+

+ {fmtSpeed(torrents.reduce((sum, t) => sum + (t.rateDownload || 0), 0))} +

+
+
+ + + +
+
+
+ + +
+
+
+

Upload

+

+ {fmtSpeed(torrents.reduce((sum, t) => sum + (t.rateUpload || 0), 0))} +

+
+
+ + + +
+
+
+
+ + +
+ +
+ + + {#if selectedTab === "torrents"} +
+
+ + + + + + + + + + + + + + + {#each torrents as torrent} + + + + + + + + + + + {/each} + +
NameStatusProgressDownUpRatioTrackerActions
{torrent.name} + + {statusText(torrent.status)} + + {(torrent.percentDone * 100).toFixed(1)}%{fmtSpeed(torrent.rateDownload)}{fmtSpeed(torrent.rateUpload)}{torrent.uploadRatio?.toFixed(2) || '0.00'} + {#if torrent.hasTrackerErrors} + + + + + Error + + {:else} + OK + {/if} + + +
+
+
+ {:else if selectedTab === "trackers"} +
+
+ + + + + + + + + + + + {#each trackers as tracker} + + + + + + + + {/each} + +
TrackerTotal TorrentsErrorsStatusLast Error
{tracker.host}{tracker.total}{tracker.errors} + {#if tracker.errors > 0} + + Error + + {:else} + + OK + + {/if} + {tracker.lastError || '-'}
+
+
+ {/if} + {/if} +
diff --git a/scripts/check-transmission-health.sh b/scripts/check-transmission-health.sh new file mode 100755 index 0000000..3c63d64 --- /dev/null +++ b/scripts/check-transmission-health.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Transmission Health Check Script +# Monitors Transmission daemon and tracker connectivity + +TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN}" +TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID}" + +send_alert() { + local message="$1" + if [ -n "$TELEGRAM_BOT_TOKEN" ] && [ -n "$TELEGRAM_CHAT_ID" ]; then + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + -d "text=🚨 Transmission Alert: ${message}" \ + -d "parse_mode=HTML" > /dev/null + fi +} + +# Check if Transmission daemon is running +if ! ps aux | grep transmission-daemon | grep sc-transmission | grep -v grep > /dev/null; then + send_alert "Transmission daemon is not running!" + echo "ERROR: Transmission daemon is not running" + exit 1 +fi + +# Check recent logs for tracker errors +LOG_FILE="/volume1/@appdata/transmission/transmission.log" +if [ -f "$LOG_FILE" ]; then + # Count recent tracker errors (last 100 lines) + ERROR_COUNT=$(tail -100 "$LOG_FILE" | grep -c "Announce error: Could not connect to tracker") + + if [ "$ERROR_COUNT" -gt 50 ]; then + send_alert "High number of tracker errors detected: ${ERROR_COUNT} in recent logs" + echo "WARNING: ${ERROR_COUNT} tracker errors found" + fi + + # Check if log is stale (no updates in last hour) + LOG_AGE=$(find "$LOG_FILE" -mmin +60 2>/dev/null) + if [ -n "$LOG_AGE" ]; then + send_alert "Transmission log hasn't been updated in over an hour - daemon may be stuck" + echo "WARNING: Log file is stale" + fi +fi + +# Check if RPC port is listening +if ! netstat -tln 2>/dev/null | grep -q ":9091 " && ! ss -tln 2>/dev/null | grep -q ":9091 "; then + send_alert "Transmission RPC port 9091 is not listening" + echo "ERROR: RPC port not listening" + exit 1 +fi + +echo "OK: Transmission health check passed" +exit 0 From 6ad885044b8a619fd82fde8c1f33fbad7e0d20ef Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sat, 11 Apr 2026 13:20:10 +0800 Subject: [PATCH 3/4] fix: use host.docker.internal for Transmission RPC connection --- dashboard/backend/routers/transmission.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/dashboard/backend/routers/transmission.py b/dashboard/backend/routers/transmission.py index 83eae72..e8baf10 100644 --- a/dashboard/backend/routers/transmission.py +++ b/dashboard/backend/routers/transmission.py @@ -10,17 +10,15 @@ router = APIRouter(prefix="/api/transmission", tags=["transmission"]) def get_session_id() -> str: """Get Transmission RPC session ID""" try: - result = subprocess.run( - ["ssh", "nas", "curl -si -u 'admin:admin' 'http://127.0.0.1:9091/transmission/rpc' 2>&1 | grep -i 'X-Transmission-Session-Id:' | cut -d' ' -f2 | tr -d '\\r'"], - capture_output=True, - text=True, - shell=True, - timeout=5 - ) - session_id = result.stdout.strip() - if not session_id: - raise Exception("Failed to get session ID") - return session_id + with httpx.Client(timeout=5.0) as client: + response = client.get( + "http://host.docker.internal:9091/transmission/rpc", + auth=("admin", "admin") + ) + session_id = response.headers.get("X-Transmission-Session-Id") + if not session_id: + raise Exception("Failed to get session ID from headers") + return session_id except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get Transmission session ID: {str(e)}") @@ -37,7 +35,7 @@ def transmission_rpc(method: str, arguments: Dict[str, Any] = None) -> Dict[str, # Use httpx to make the request with httpx.Client(timeout=10.0) as client: response = client.post( - "http://100.78.131.124:9091/transmission/rpc", + "http://host.docker.internal:9091/transmission/rpc", json=payload, auth=("admin", "admin"), headers={"X-Transmission-Session-Id": session_id} From 67c4394c2d7ced6cc90a95943e5d67cbea30c134 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sat, 11 Apr 2026 14:06:15 +0800 Subject: [PATCH 4/4] fix: handle permission errors when browsing directories --- dashboard/backend/routers/files.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/dashboard/backend/routers/files.py b/dashboard/backend/routers/files.py index dcbe15f..24dac13 100644 --- a/dashboard/backend/routers/files.py +++ b/dashboard/backend/routers/files.py @@ -66,8 +66,18 @@ def browse(path: str = ""): target = _safe_path(path) except ValueError: raise HTTPException(status_code=403, detail="Access denied") + + # Check if we have permission to read the directory + try: + items = list(target.iterdir()) + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied") + except OSError as e: + logger.error(f"Error reading directory {target}: {e}") + raise HTTPException(status_code=500, detail="Error reading directory") + entries = [] - for item in sorted(target.iterdir()): + for item in sorted(items): try: if not _is_safe_symlink(item): continue