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/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/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
diff --git a/dashboard/backend/routers/transmission.py b/dashboard/backend/routers/transmission.py
new file mode 100644
index 0000000..e8baf10
--- /dev/null
+++ b/dashboard/backend/routers/transmission.py
@@ -0,0 +1,209 @@
+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:
+ 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)}")
+
+
+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://host.docker.internal: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 @@
BitTorrent client status and management
+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))} +
+| Name | +Status | +Progress | +Down | +Up | +Ratio | +Tracker | +Actions | +
|---|---|---|---|---|---|---|---|
| {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} + | ++ + | +
| Tracker | +Total Torrents | +Errors | +Status | +Last Error | +
|---|---|---|---|---|
| {tracker.host} | +{tracker.total} | +{tracker.errors} | ++ {#if tracker.errors > 0} + + Error + + {:else} + + OK + + {/if} + | +{tracker.lastError || '-'} | +