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
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user