Merge pull request #43
Fix Transmission RPC and file browser permissions
This commit was merged in pull request #43.
This commit is contained in:
@@ -22,7 +22,7 @@ jobs:
|
|||||||
- name: Warm mirror cache for base images
|
- name: Warm mirror cache for base images
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
mirror_host="127.0.0.1:5501"
|
mirror_host="100.78.131.124:5501"
|
||||||
|
|
||||||
prewarm_base_image() {
|
prewarm_base_image() {
|
||||||
image_ref="$1"
|
image_ref="$1"
|
||||||
@@ -47,8 +47,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Build dev image
|
- name: Build dev image
|
||||||
run: |
|
run: |
|
||||||
|
set -e
|
||||||
export DOCKER_API_VERSION=1.43
|
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
|
- name: Sync runtime compose file
|
||||||
run: |
|
run: |
|
||||||
mkdir -p /volume1/docker/nas-dashboard
|
mkdir -p /volume1/docker/nas-dashboard
|
||||||
|
|||||||
@@ -54,7 +54,15 @@ jobs:
|
|||||||
prewarm_base_image "python:3.12-slim"
|
prewarm_base_image "python:3.12-slim"
|
||||||
|
|
||||||
- name: Build
|
- 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
|
- name: Sync runtime compose file
|
||||||
run: cp dashboard/docker-compose.yml /nas-dashboard/docker-compose.yml
|
run: cp dashboard/docker-compose.yml /nas-dashboard/docker-compose.yml
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.vscode
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
@@ -3,7 +3,8 @@ FROM node:20-alpine AS frontend
|
|||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY frontend/package.json frontend/package-lock.json* ./
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
COPY frontend/ ./
|
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
|
# Stage 2: Python runtime
|
||||||
FROM python:3.12-slim
|
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
|
RUN adduser --disabled-password --no-create-home --gecos "" app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY backend/requirements.txt .
|
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 backend/ ./
|
||||||
COPY --from=frontend /build/dist /app/static
|
COPY --from=frontend /build/dist /app/static
|
||||||
RUN chown -R app:app /app
|
RUN chown -R app:app /app
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from routers import (
|
|||||||
system,
|
system,
|
||||||
terminal,
|
terminal,
|
||||||
totp,
|
totp,
|
||||||
|
transmission,
|
||||||
)
|
)
|
||||||
|
|
||||||
_tz_cst = timezone(timedelta(hours=8))
|
_tz_cst = timezone(timedelta(hours=8))
|
||||||
@@ -303,6 +304,9 @@ app.include_router(
|
|||||||
app.include_router(
|
app.include_router(
|
||||||
security.router, prefix="/api/security", dependencies=[Depends(_inject_user), Depends(require_page("security"))]
|
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
|
# OPC endpoints
|
||||||
app.include_router(
|
app.include_router(
|
||||||
|
|||||||
@@ -66,8 +66,18 @@ def browse(path: str = ""):
|
|||||||
target = _safe_path(path)
|
target = _safe_path(path)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
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 = []
|
entries = []
|
||||||
for item in sorted(target.iterdir()):
|
for item in sorted(items):
|
||||||
try:
|
try:
|
||||||
if not _is_safe_symlink(item):
|
if not _is_safe_symlink(item):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
import CcConnect from "./routes/CcConnect.svelte";
|
import CcConnect from "./routes/CcConnect.svelte";
|
||||||
import InfoEngine from "./routes/InfoEngine.svelte";
|
import InfoEngine from "./routes/InfoEngine.svelte";
|
||||||
import OPC from "./routes/OPC.svelte";
|
import OPC from "./routes/OPC.svelte";
|
||||||
|
import Transmission from "./routes/Transmission.svelte";
|
||||||
import Login from "./routes/Login.svelte";
|
import Login from "./routes/Login.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
|
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"cc-connect",
|
"cc-connect",
|
||||||
"info-engine",
|
"info-engine",
|
||||||
"opc",
|
"opc",
|
||||||
|
"transmission",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let page = $state("dashboard");
|
let page = $state("dashboard");
|
||||||
@@ -211,6 +213,8 @@
|
|||||||
<InfoEngine />
|
<InfoEngine />
|
||||||
{:else if page === "opc" && hasPageAccess("opc")}
|
{:else if page === "opc" && hasPageAccess("opc")}
|
||||||
<OPC />
|
<OPC />
|
||||||
|
{:else if page === "transmission" && hasPageAccess("transmission")}
|
||||||
|
<Transmission />
|
||||||
{:else if page !== "terminal"}
|
{:else if page !== "terminal"}
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
{ label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" },
|
{ label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" },
|
||||||
{ label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" },
|
{ label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" },
|
||||||
{ label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" },
|
{ label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" },
|
||||||
|
{ id: "transmission", label: "Transmission", icon: "download" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultTools = [
|
const defaultTools = [
|
||||||
@@ -254,6 +255,8 @@
|
|||||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 18v-6a9 9 0 0118 0v6M3 18a3 3 0 003 3h0a3 3 0 003-3v-2a3 3 0 00-3-3h0a3 3 0 00-3 3v2zm18 0a3 3 0 01-3 3h0a3 3 0 01-3-3v-2a3 3 0 013-3h0a3 3 0 013 3v2z" /></svg>
|
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 18v-6a9 9 0 0118 0v6M3 18a3 3 0 003 3h0a3 3 0 003-3v-2a3 3 0 00-3-3h0a3 3 0 00-3 3v2zm18 0a3 3 0 01-3 3h0a3 3 0 01-3-3v-2a3 3 0 013-3h0a3 3 0 013 3v2z" /></svg>
|
||||||
{:else if icon === "image"}
|
{:else if icon === "image"}
|
||||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||||
|
{:else if icon === "download"}
|
||||||
|
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" /></svg>
|
||||||
{:else if icon === "openclaw"}
|
{:else if icon === "openclaw"}
|
||||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
|
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
|
||||||
{:else if icon === "chat"}
|
{:else if icon === "chat"}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="text-xs text-surface-400 text-right">{e.is_dir ? "—" : formatSize(e.size)}</span>
|
<span class="text-xs text-surface-400 text-right">{e.is_dir ? "—" : formatSize(e.size)}</span>
|
||||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div class="flex items-center justify-end gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
|
||||||
{#if !e.is_dir}
|
{#if !e.is_dir}
|
||||||
<button onclick={() => handleDownload(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors">DL</button>
|
<button onclick={() => handleDownload(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors">DL</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { get, post } from "../lib/api.js";
|
||||||
|
|
||||||
|
let status = $state(null);
|
||||||
|
let torrents = $state([]);
|
||||||
|
let trackers = $state([]);
|
||||||
|
let stats = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let selectedTab = $state("torrents");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [statusData, torrentsData, trackersData, statsData] = await Promise.all([
|
||||||
|
get("/transmission/status").catch(() => null),
|
||||||
|
get("/transmission/torrents").catch(() => ({ torrents: [] })),
|
||||||
|
get("/transmission/trackers").catch(() => ({ trackers: [] })),
|
||||||
|
get("/transmission/stats").catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
status = statusData;
|
||||||
|
torrents = torrentsData?.torrents || [];
|
||||||
|
trackers = trackersData?.trackers || [];
|
||||||
|
stats = statsData;
|
||||||
|
loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load transmission data:", e);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reannounce(torrentId) {
|
||||||
|
try {
|
||||||
|
await post(`/transmission/reannounce/${torrentId}`);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to reannounce: " + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTorrent(torrentId) {
|
||||||
|
try {
|
||||||
|
await post(`/transmission/start/${torrentId}`);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to start: " + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopTorrent(torrentId) {
|
||||||
|
try {
|
||||||
|
await post(`/transmission/stop/${torrentId}`);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to stop: " + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBytes(b) {
|
||||||
|
if (!b) return "0 B";
|
||||||
|
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(b) / Math.log(1024));
|
||||||
|
return (b / Math.pow(1024, i)).toFixed(1) + " " + u[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSpeed(bytesPerSec) {
|
||||||
|
return fmtBytes(bytesPerSec) + "/s";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(status) {
|
||||||
|
const statusMap = {
|
||||||
|
0: "Stopped",
|
||||||
|
1: "Check waiting",
|
||||||
|
2: "Checking",
|
||||||
|
3: "Download waiting",
|
||||||
|
4: "Downloading",
|
||||||
|
5: "Seed waiting",
|
||||||
|
6: "Seeding"
|
||||||
|
};
|
||||||
|
return statusMap[status] || "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
let interval;
|
||||||
|
onMount(() => { load(); interval = setInterval(load, 10000); });
|
||||||
|
onDestroy(() => clearInterval(interval));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Transmission</h1>
|
||||||
|
<p class="text-sm text-surface-400 mt-1">BitTorrent client status and management</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="h-[400px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
|
||||||
|
{:else}
|
||||||
|
<!-- Status Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<!-- Daemon Status -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-surface-500 dark:text-surface-400">Daemon</p>
|
||||||
|
<p class="text-2xl font-bold mt-1 {status?.running ? 'text-emerald-500' : 'text-rose-500'}">
|
||||||
|
{status?.running ? 'Running' : 'Stopped'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 rounded-full {status?.running ? 'bg-emerald-100 dark:bg-emerald-900/30' : 'bg-rose-100 dark:bg-rose-900/30'} flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 {status?.running ? 'text-emerald-500' : 'text-rose-500'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Torrents -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-surface-500 dark:text-surface-400">Active Torrents</p>
|
||||||
|
<p class="text-2xl font-bold mt-1 text-surface-900 dark:text-white">
|
||||||
|
{torrents.filter(t => t.status === 4 || t.status === 6).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download Speed -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-surface-500 dark:text-surface-400">Download</p>
|
||||||
|
<p class="text-2xl font-bold mt-1 text-surface-900 dark:text-white">
|
||||||
|
{fmtSpeed(torrents.reduce((sum, t) => sum + (t.rateDownload || 0), 0))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Speed -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-surface-500 dark:text-surface-400">Upload</p>
|
||||||
|
<p class="text-2xl font-bold mt-1 text-surface-900 dark:text-white">
|
||||||
|
{fmtSpeed(torrents.reduce((sum, t) => sum + (t.rateUpload || 0), 0))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-surface-200 dark:border-surface-700">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<button
|
||||||
|
onclick={() => selectedTab = "torrents"}
|
||||||
|
class="py-4 px-1 border-b-2 font-medium text-sm {selectedTab === 'torrents' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-surface-500 hover:text-surface-700 hover:border-surface-300 dark:text-surface-400 dark:hover:text-surface-300'}"
|
||||||
|
>
|
||||||
|
Torrents ({torrents.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => selectedTab = "trackers"}
|
||||||
|
class="py-4 px-1 border-b-2 font-medium text-sm {selectedTab === 'trackers' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-surface-500 hover:text-surface-700 hover:border-surface-300 dark:text-surface-400 dark:hover:text-surface-300'}"
|
||||||
|
>
|
||||||
|
Trackers ({trackers.length})
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
{#if selectedTab === "torrents"}
|
||||||
|
<div class="rounded-xl bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-surface-50 dark:bg-surface-900/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Status</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Progress</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Down</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Up</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Ratio</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Tracker</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface-200 dark:divide-surface-700">
|
||||||
|
{#each torrents as torrent}
|
||||||
|
<tr class="hover:bg-surface-50 dark:hover:bg-surface-900/30">
|
||||||
|
<td class="px-6 py-4 text-sm text-surface-900 dark:text-white max-w-md truncate">{torrent.name}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<span class="px-2 py-1 rounded-full text-xs font-medium {torrent.status === 4 || torrent.status === 6 ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-surface-100 text-surface-700 dark:bg-surface-700 dark:text-surface-300'}">
|
||||||
|
{statusText(torrent.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{(torrent.percentDone * 100).toFixed(1)}%</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{fmtSpeed(torrent.rateDownload)}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{fmtSpeed(torrent.rateUpload)}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{torrent.uploadRatio?.toFixed(2) || '0.00'}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
{#if torrent.hasTrackerErrors}
|
||||||
|
<span class="text-rose-500 flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Error
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-emerald-500">OK</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<button
|
||||||
|
onclick={() => reannounce(torrent.id)}
|
||||||
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
title="Reannounce to tracker"
|
||||||
|
>
|
||||||
|
Reannounce
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if selectedTab === "trackers"}
|
||||||
|
<div class="rounded-xl bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-surface-50 dark:bg-surface-900/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Tracker</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Total Torrents</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Errors</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Status</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Last Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface-200 dark:divide-surface-700">
|
||||||
|
{#each trackers as tracker}
|
||||||
|
<tr class="hover:bg-surface-50 dark:hover:bg-surface-900/30">
|
||||||
|
<td class="px-6 py-4 text-sm font-medium text-surface-900 dark:text-white">{tracker.host}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{tracker.total}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{tracker.errors}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
{#if tracker.errors > 0}
|
||||||
|
<span class="px-2 py-1 rounded-full text-xs font-medium bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400">
|
||||||
|
Error
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
|
||||||
|
OK
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{tracker.lastError || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Executable
+52
@@ -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
|
||||||
Reference in New Issue
Block a user