add a transmission sidecar and ui
officer-transmission is a new pm2 peer that owns the transmission rpc
connection and exposes a curated /_officer/* contract instead of proxying
raw rpc. it absorbs the three quirks callers otherwise have to know about:
the 409 x-transmission-session-id handshake, failures returned as
{"result": "..."} inside http 200, and basic auth where an empty username
must send no header at all.
/transmission is the ui, on the workspace/panel framework: a filter nav and
three sections (torrents, stats, settings). the torrent list is virtualised
with 30 available columns, multi-select, and a right-click menu; the detail
pane covers general, files as a real tree, peers and trackers. filters and
the open torrent live in the url, so a filtered view is a link.
phase 1 goal was parity with _references/transmission-web. follow-up work is
recorded in TODO.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,36 @@ Deferred work. Context: Officer is collapsing from multi-tenant / open-source-re
|
|||||||
as one of the tabs (main view).
|
as one of the tabs (main view).
|
||||||
- Open question: tabs vs. some other view switcher; which view is default.
|
- Open question: tabs vs. some other view switcher; which view is default.
|
||||||
|
|
||||||
|
## Transmission
|
||||||
|
|
||||||
|
Phase 1 (parity with `_references/transmission-web`) shipped 2026-07-30: the `officer-transmission`
|
||||||
|
sidecar plus `/transmission` (torrents / stats / settings). Everything below is **phase 2** — none of
|
||||||
|
it exists in the reference app, so none of it was in scope for parity.
|
||||||
|
|
||||||
|
### Probably needed regardless
|
||||||
|
|
||||||
|
- [ ] **Resizable detail pane.** `TorrentsView` pins it at `DETAIL_HEIGHT = '45%'`, which is a guess.
|
||||||
|
Either a drag handle or a persisted height in `useLocalStorageState`, alongside the column set.
|
||||||
|
- [ ] **Revisit `DEFAULT_VISIBLE_COLUMNS`.** Eleven of thirty, chosen before ever seeing the table
|
||||||
|
rendered against the real 43-torrent library. Likely wrong in both directions.
|
||||||
|
- [ ] **Files tab on huge torrents.** `detail/FilesTab.tsx` builds the whole tree and renders every
|
||||||
|
node — no virtualisation. Fine at 14 files; unknown at a few thousand. If it stalls, the fix is
|
||||||
|
`useVirtualizer` over a flattened visible-node list, the same shape as `TorrentTable`.
|
||||||
|
|
||||||
|
### Ideas, unprioritised
|
||||||
|
|
||||||
|
- [ ] **Container→host path mapping.** The daemon reports its own namespace (`/downloads/complete`),
|
||||||
|
which is not a path on this host. A mapping table (`/downloads` → `~/hdds/08_TB_01/Torrents`,
|
||||||
|
`/downloadsTV` → `14_TB_02/Torrents`) would make locations clickable through to `/files` and
|
||||||
|
make "Set location" offer real destinations. Deliberately dropped from phase 1: the reference
|
||||||
|
app has no such feature, so it would have been config nothing read.
|
||||||
|
- [ ] **Push instead of poll.** Transmission RPC has no push channel, so `useTransmissionData` polls
|
||||||
|
every 5s. The sidecar could poll once and fan out over a WebSocket, which is both cheaper and
|
||||||
|
what every other live surface in the platform already does.
|
||||||
|
- [ ] **Cross-surface links.** Soulseek and `download-media` both land files in the same library;
|
||||||
|
Transmission is the third door to it. Worth a think about whether they should know about each
|
||||||
|
other at all.
|
||||||
|
|
||||||
## Known bugs
|
## Known bugs
|
||||||
|
|
||||||
- [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale
|
- [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale
|
||||||
|
|||||||
@@ -76,5 +76,11 @@ module.exports = {
|
|||||||
args: 'run src/servers/sidecar/headscale/index.ts',
|
args: 'run src/servers/sidecar/headscale/index.ts',
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'officer-transmission',
|
||||||
|
script: 'bun',
|
||||||
|
args: 'run src/servers/sidecar/transmission/index.ts',
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export function App() {
|
|||||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||||
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
||||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||||
|
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||||
|
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
|
||||||
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
||||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Radio,
|
Radio,
|
||||||
Network,
|
Network,
|
||||||
|
ArrowDownUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||||
@@ -145,6 +146,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
|||||||
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
||||||
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
||||||
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
||||||
|
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
|
||||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { Navigate, useParams } from 'react-router';
|
||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
import {
|
||||||
|
WorkspaceView,
|
||||||
|
DEFAULT_TRANSMISSION_SECTION,
|
||||||
|
transmissionSectionPath,
|
||||||
|
isTransmissionSection,
|
||||||
|
} from 'officerdev';
|
||||||
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
|
// /transmission uses the Workspace/Panel system (like /headscale): a filter nav on the left and the section
|
||||||
|
// view on the right. Both talk to the officer-transmission sidecar through the /api/transmission auth proxy,
|
||||||
|
// which holds no Transmission credentials of its own — the RPC URL and any Basic auth live in the sidecar.
|
||||||
|
//
|
||||||
|
// The open section is :section in the URL and the torrent filters are query params, so both panels read the
|
||||||
|
// URL with useParams/useSearchParams instead of passing state between themselves over a channel. This screen
|
||||||
|
// backs both /transmission and /transmission/:section and is the single place that decides what an absent or
|
||||||
|
// bogus section means.
|
||||||
|
|
||||||
|
const ALLOWED_APP_TYPES = new Set<string | null>(['transmission-nav', 'transmission-view', null]);
|
||||||
|
|
||||||
|
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||||
|
if (node.type === 'panel') {
|
||||||
|
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'transmission-view' };
|
||||||
|
}
|
||||||
|
const children = node.children.map((c) => {
|
||||||
|
const fixed = normalizeLayout(c.node);
|
||||||
|
return fixed === c.node ? c : { ...c, node: fixed };
|
||||||
|
});
|
||||||
|
const changed = children.some((c, i) => c !== node.children[i]);
|
||||||
|
return changed ? { ...node, children } : node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TransmissionScreen = () => {
|
||||||
|
const { section } = useParams();
|
||||||
|
const rawWorkspace = useDashboardState<LayoutNode>('screens/transmission', defaultLayout);
|
||||||
|
|
||||||
|
const workspace = useMemo(() => {
|
||||||
|
const fixed = normalizeLayout(rawWorkspace.value);
|
||||||
|
if (fixed === rawWorkspace.value) return rawWorkspace;
|
||||||
|
return { ...rawWorkspace, value: fixed };
|
||||||
|
}, [rawWorkspace]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||||
|
rawWorkspace.setValue(workspace.value);
|
||||||
|
}
|
||||||
|
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
||||||
|
|
||||||
|
// Bare /transmission, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
||||||
|
// default while the address bar says something else — the nav highlight is derived from the URL.
|
||||||
|
if (!isTransmissionSection(section)) {
|
||||||
|
return <Navigate to={transmissionSectionPath(DEFAULT_TRANSMISSION_SECTION)} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full pt-2">
|
||||||
|
<WorkspaceView workspace={workspace} locked />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'transmission-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'transmission-nav', appType: 'transmission-nav' }, size: 22 },
|
||||||
|
{ node: { type: 'panel', id: 'transmission-view', appType: 'transmission-view' }, size: 78 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './TransmissionScreen';
|
||||||
@@ -13,6 +13,7 @@ export * from './Files';
|
|||||||
export * from './Music';
|
export * from './Music';
|
||||||
export * from './Soulseek';
|
export * from './Soulseek';
|
||||||
export * from './Headscale';
|
export * from './Headscale';
|
||||||
|
export * from './Transmission';
|
||||||
export * from './SystemMonitor';
|
export * from './SystemMonitor';
|
||||||
export * from './Activity';
|
export * from './Activity';
|
||||||
export * from './CodeEditor';
|
export * from './CodeEditor';
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||||
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
|
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
|
||||||
|
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
|
||||||
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
||||||
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
||||||
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
|
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
import { getTransmissionServerUrl } from './sidecar-server';
|
||||||
|
|
||||||
|
// Thin reverse-proxy for /api/transmission/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
||||||
|
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
|
||||||
|
// subpath + query + body to the officer-transmission sidecar, which OWNS the Transmission contract and holds
|
||||||
|
// the daemon credentials.
|
||||||
|
//
|
||||||
|
// A catch-all with no routes of its own. The sidecar exposes only Officer-owned routes under /_officer/,
|
||||||
|
// because Transmission's RPC is a single POST endpoint guarded by a rotating CSRF token — proxying it raw
|
||||||
|
// would push the handshake into the browser. The full contract is documented at the top of
|
||||||
|
// src/servers/sidecar/transmission/index.ts. It is opaque from here: this file must never grow Transmission
|
||||||
|
// logic.
|
||||||
|
|
||||||
|
export const transmissionRouter = createRouter();
|
||||||
|
|
||||||
|
const PREFIX = '/api/transmission';
|
||||||
|
|
||||||
|
transmissionRouter.all('/*', async (ctx) => {
|
||||||
|
const baseUrl = getTransmissionServerUrl();
|
||||||
|
if (!baseUrl) return ctx.text('transmission sidecar not available', 503);
|
||||||
|
|
||||||
|
const url = new URL(ctx.req.url);
|
||||||
|
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||||
|
const target = `${baseUrl}${subpath}${url.search}`;
|
||||||
|
|
||||||
|
const method = ctx.req.method;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const contentType = ctx.req.header('content-type');
|
||||||
|
if (contentType) headers['Content-Type'] = contentType;
|
||||||
|
// Forward the authenticated user id so the sidecar can serve its Officer-owned routes. The sidecar binds
|
||||||
|
// loopback only, so this header is trusted.
|
||||||
|
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||||
|
|
||||||
|
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||||
|
|
||||||
|
let upstream: Response;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(target, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[transmission] proxy fetch failed', { target, error: String(err) });
|
||||||
|
return ctx.text('transmission sidecar unreachable', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
|
|
||||||
|
// The officer-transmission sidecar starts its HTTP server on a random loopback port and reports it here on
|
||||||
|
// connect. We remember it so `/api/transmission/*` always forwards to the current sidecar. The platform
|
||||||
|
// holds NO knowledge of Transmission itself — not its URL, and not its credentials.
|
||||||
|
|
||||||
|
let serverPort: number | null = null;
|
||||||
|
|
||||||
|
sidecar.on('transmission:server', (msg) => {
|
||||||
|
const port = (msg as { port?: number }).port;
|
||||||
|
if (typeof port !== 'number') return;
|
||||||
|
serverPort = port;
|
||||||
|
console.log(`[transmission] sidecar registered on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */
|
||||||
|
export function getTransmissionServerUrl(): string | null {
|
||||||
|
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { musicRouter } from './api/music/router';
|
|||||||
import { vaultRouter } from './api/vault/router';
|
import { vaultRouter } from './api/vault/router';
|
||||||
import { slskdRouter } from './api/slskd/router';
|
import { slskdRouter } from './api/slskd/router';
|
||||||
import { headscaleRouter } from './api/headscale/router';
|
import { headscaleRouter } from './api/headscale/router';
|
||||||
|
import { transmissionRouter } from './api/transmission/router';
|
||||||
import { vpnRouter } from './api/vpn/router';
|
import { vpnRouter } from './api/vpn/router';
|
||||||
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
||||||
import { activityRouter } from './api/activity/router';
|
import { activityRouter } from './api/activity/router';
|
||||||
@@ -30,6 +31,7 @@ import './api/music/sidecar-server'; // side-effect: capture the officer-music a
|
|||||||
import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port
|
import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port
|
||||||
import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy port
|
import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy port
|
||||||
import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port
|
import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port
|
||||||
|
import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port
|
||||||
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||||
import { dockRouter } from './api/dock/dock';
|
import { dockRouter } from './api/dock/dock';
|
||||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||||
@@ -113,6 +115,7 @@ protectedRouter.route('/file-browser', fileBrowserRouter);
|
|||||||
protectedRouter.route('/music', musicRouter);
|
protectedRouter.route('/music', musicRouter);
|
||||||
protectedRouter.route('/slskd', slskdRouter);
|
protectedRouter.route('/slskd', slskdRouter);
|
||||||
protectedRouter.route('/headscale', headscaleRouter);
|
protectedRouter.route('/headscale', headscaleRouter);
|
||||||
|
protectedRouter.route('/transmission', transmissionRouter);
|
||||||
protectedRouter.route('/vpn', vpnRouter);
|
protectedRouter.route('/vpn', vpnRouter);
|
||||||
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
||||||
protectedRouter.route('/activity', activityRouter);
|
protectedRouter.route('/activity', activityRouter);
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export type SidecarEvent =
|
|||||||
| { type: 'slskd:server'; port: number }
|
| { type: 'slskd:server'; port: number }
|
||||||
// Headscale — the sidecar reports where its HTTP server is listening (random port) on connect
|
// Headscale — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
| { type: 'headscale:server'; port: number }
|
| { type: 'headscale:server'; port: number }
|
||||||
|
// Transmission — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
|
| { type: 'transmission:server'; port: number }
|
||||||
// Generic
|
// Generic
|
||||||
| { type: 'error'; id?: string; error: string };
|
| { type: 'error'; id?: string; error: string };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||||
|
import { createSidecarConnector } from '../connect';
|
||||||
|
import { handleOfficerRoute } from './routes';
|
||||||
|
import { probe } from './rpc';
|
||||||
|
import { getTransmissionBase } from './upstream';
|
||||||
|
|
||||||
|
// The officer-transmission sidecar. Owns the whole Transmission contract for Officer: the daemon URL and
|
||||||
|
// credentials, the X-Transmission-Session-Id CSRF handshake, and the translation from Transmission's
|
||||||
|
// single-endpoint RPC vocabulary into real REST paths. The platform API is a thin auth-gated forwarder
|
||||||
|
// (src/servers/api/transmission/router.ts) holding no Transmission credentials.
|
||||||
|
//
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// HTTP CONTRACT — the platform strips its /api/transmission mount prefix before forwarding.
|
||||||
|
//
|
||||||
|
// GET /_health ours. Probes the daemon with a cheap session-get.
|
||||||
|
// GET /_officer/session full session settings + version + rpc-version
|
||||||
|
// POST /_officer/session write a partial settings object (whitelisted), returns it read back
|
||||||
|
// GET /_officer/stats session-stats: cumulative + current-session counters
|
||||||
|
// GET /_officer/torrents the list poll — LIST_FIELDS for every torrent, decorated
|
||||||
|
// GET /_officer/torrents/:id one torrent with files/peers/trackers (DETAIL_FIELDS)
|
||||||
|
// POST /_officer/torrents/add {metainfo|filename, downloadDir?, labels?, paused?, …}
|
||||||
|
// → {status:'added'|'duplicate', torrent}
|
||||||
|
// POST /_officer/torrents/action {ids, action} — start|start-now|stop|verify|reannounce|queue-*
|
||||||
|
// POST /_officer/torrents/set {ids, …whitelisted torrent-set fields}
|
||||||
|
// POST /_officer/torrents/location {ids, location, move}
|
||||||
|
// POST /_officer/torrents/rename {id, path, name}
|
||||||
|
// POST /_officer/torrents/remove {ids, deleteLocalData}
|
||||||
|
// GET /_officer/free-space?path= free space at a path
|
||||||
|
// POST /_officer/port-test is the peer port reachable from outside
|
||||||
|
// POST /_officer/blocklist-update refresh the blocklist, returns the new size
|
||||||
|
// anything else 404
|
||||||
|
//
|
||||||
|
// There is deliberately NO transparent /transmission/rpc passthrough. Transmission multiplexes every verb
|
||||||
|
// through one POST body, reports failures as `{"result": "<error>"}` inside a 200, and demands a CSRF token
|
||||||
|
// that rotates on every daemon restart. Proxying that raw would push all three into the browser. Every
|
||||||
|
// quirk is absorbed here — see rpc.ts.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||||
|
|
||||||
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
|
function getFreePort(): number {
|
||||||
|
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
|
const p = probeServer.port;
|
||||||
|
probeServer.stop(true);
|
||||||
|
if (p == null) throw new Error('failed to acquire a free port');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = getFreePort();
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
// A base64 .torrent for a large multi-file release travels in the request body of /torrents/add.
|
||||||
|
maxRequestBodySize: 256 * 1024 * 1024,
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
if (url.pathname === '/_health') {
|
||||||
|
if (!getTransmissionBase()) {
|
||||||
|
return Response.json({ ok: false, error: 'TRANSMISSION_URL not configured' }, { status: 503 });
|
||||||
|
}
|
||||||
|
const started = Date.now();
|
||||||
|
const result = await probe();
|
||||||
|
return Response.json({ ...result, ms: Date.now() - started }, { status: result.ok ? 200 : 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/_officer/')) {
|
||||||
|
try {
|
||||||
|
const res = await handleOfficerRoute(req, url);
|
||||||
|
if (res) return res;
|
||||||
|
return Response.json({ error: 'not found' }, { status: 404 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[transmission] ${req.method} ${url.pathname} failed`, err);
|
||||||
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ error: 'not found' }, { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[transmission] listening on 127.0.0.1:${port} -> ${getTransmissionBase() ?? '(TRANSMISSION_URL unset)'}`);
|
||||||
|
|
||||||
|
type ReplyFn = (msg: SidecarEvent) => void;
|
||||||
|
|
||||||
|
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||||
|
switch (cmd.type) {
|
||||||
|
case 'ping':
|
||||||
|
reply({ type: 'pong', id: cmd.id });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
reply({
|
||||||
|
type: 'error',
|
||||||
|
id: (cmd as SidecarCommand).id,
|
||||||
|
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = createSidecarConnector({
|
||||||
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
|
name: 'transmission',
|
||||||
|
capabilities: ['transmission'],
|
||||||
|
onCommand(cmd, reply) {
|
||||||
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
|
},
|
||||||
|
onConnected() {
|
||||||
|
connection.send({ type: 'transmission:server', port });
|
||||||
|
console.log(`[transmission] reported server port ${port} to API`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown(signal: string) {
|
||||||
|
console.log(`[transmission] ${signal} received, shutting down...`);
|
||||||
|
try {
|
||||||
|
server.stop(true);
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
connection.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import type { SessionSettings, SessionStats, Torrent } from './types';
|
||||||
|
import { DETAIL_FIELDS, LIST_FIELDS } from './types';
|
||||||
|
import { decorate, rpc, TransmissionError } from './rpc';
|
||||||
|
|
||||||
|
// Officer-owned routes for the transmission sidecar — the entire feature surface lives under /_officer/.
|
||||||
|
//
|
||||||
|
// Nothing here is a passthrough. Transmission speaks a single POST endpoint with a `method` field, which is
|
||||||
|
// a terrible fit for an HTTP cache, for auth scoping and for a browser client: it would mean shipping the
|
||||||
|
// 409 handshake, the `result: 'success'` convention and the whole method vocabulary into the SPA. So the
|
||||||
|
// verbs are re-expressed as real REST paths and every quirk is absorbed in rpc.ts.
|
||||||
|
|
||||||
|
export type OfficerContext = { req: Request; url: URL; userId: number };
|
||||||
|
|
||||||
|
/** 400 with a machine-readable reason. */
|
||||||
|
export const badRequest = (error: string) => Response.json({ error }, { status: 400 });
|
||||||
|
/** 404 for an unknown /_officer/ path or a missing object. */
|
||||||
|
export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 });
|
||||||
|
/** 405 when the path exists but the verb doesn't. */
|
||||||
|
export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 });
|
||||||
|
|
||||||
|
async function readJson(req: Request): Promise<Record<string, unknown> | null> {
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Torrent ids as Transmission wants them: a number array. Rejects anything that isn't a positive integer. */
|
||||||
|
function readIds(body: Record<string, unknown>): number[] | null {
|
||||||
|
const raw = body['ids'];
|
||||||
|
if (!Array.isArray(raw)) return null;
|
||||||
|
const ids = raw.map(Number).filter((n) => Number.isInteger(n) && n > 0);
|
||||||
|
return ids.length === raw.length ? ids : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions that take {ids} and nothing else. Mapping them by name keeps the browser from naming RPC methods.
|
||||||
|
const ACTIONS: Record<string, string> = {
|
||||||
|
start: 'torrent-start',
|
||||||
|
'start-now': 'torrent-start-now',
|
||||||
|
stop: 'torrent-stop',
|
||||||
|
verify: 'torrent-verify',
|
||||||
|
reannounce: 'torrent-reannounce',
|
||||||
|
'queue-top': 'queue-move-top',
|
||||||
|
'queue-up': 'queue-move-up',
|
||||||
|
'queue-down': 'queue-move-down',
|
||||||
|
'queue-bottom': 'queue-move-bottom',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Whitelists, not passthroughs. Transmission silently accepts some nonsense and hard-fails on other, so the
|
||||||
|
// browser is never allowed to name an arbitrary RPC argument — a typo here is a 400, not a silent no-op.
|
||||||
|
const TORRENT_SET_FIELDS = new Set([
|
||||||
|
'bandwidthPriority',
|
||||||
|
'downloadLimit',
|
||||||
|
'downloadLimited',
|
||||||
|
'files-unwanted',
|
||||||
|
'files-wanted',
|
||||||
|
'group',
|
||||||
|
'honorsSessionLimits',
|
||||||
|
'labels',
|
||||||
|
'peer-limit',
|
||||||
|
'priority-high',
|
||||||
|
'priority-low',
|
||||||
|
'priority-normal',
|
||||||
|
'queuePosition',
|
||||||
|
'seedIdleLimit',
|
||||||
|
'seedIdleMode',
|
||||||
|
'seedRatioLimit',
|
||||||
|
'seedRatioMode',
|
||||||
|
'sequential_download',
|
||||||
|
'trackerAdd',
|
||||||
|
'trackerList',
|
||||||
|
'uploadLimit',
|
||||||
|
'uploadLimited',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SESSION_SET_FIELDS = new Set([
|
||||||
|
'alt-speed-down',
|
||||||
|
'alt-speed-enabled',
|
||||||
|
'alt-speed-time-begin',
|
||||||
|
'alt-speed-time-day',
|
||||||
|
'alt-speed-time-enabled',
|
||||||
|
'alt-speed-time-end',
|
||||||
|
'alt-speed-up',
|
||||||
|
'blocklist-enabled',
|
||||||
|
'blocklist-url',
|
||||||
|
'cache-size-mb',
|
||||||
|
'default-trackers',
|
||||||
|
'dht-enabled',
|
||||||
|
'download-dir',
|
||||||
|
'download-queue-enabled',
|
||||||
|
'download-queue-size',
|
||||||
|
'encryption',
|
||||||
|
'idle-seeding-limit',
|
||||||
|
'idle-seeding-limit-enabled',
|
||||||
|
'incomplete-dir',
|
||||||
|
'incomplete-dir-enabled',
|
||||||
|
'lpd-enabled',
|
||||||
|
'peer-limit-global',
|
||||||
|
'peer-limit-per-torrent',
|
||||||
|
'peer-port',
|
||||||
|
'peer-port-random-on-start',
|
||||||
|
'pex-enabled',
|
||||||
|
'port-forwarding-enabled',
|
||||||
|
'queue-stalled-enabled',
|
||||||
|
'queue-stalled-minutes',
|
||||||
|
'rename-partial-files',
|
||||||
|
'script-torrent-done-enabled',
|
||||||
|
'script-torrent-done-filename',
|
||||||
|
'seed-queue-enabled',
|
||||||
|
'seed-queue-size',
|
||||||
|
'seedRatioLimit',
|
||||||
|
'seedRatioLimited',
|
||||||
|
'speed-limit-down',
|
||||||
|
'speed-limit-down-enabled',
|
||||||
|
'speed-limit-up',
|
||||||
|
'speed-limit-up-enabled',
|
||||||
|
'start-added-torrents',
|
||||||
|
'trash-original-torrent-files',
|
||||||
|
'utp-enabled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function pick(body: Record<string, unknown>, allowed: Set<string>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(body)) {
|
||||||
|
if (allowed.has(key)) out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404.
|
||||||
|
*
|
||||||
|
* The platform injects X-Officer-User after authenticating the owner. We bind loopback only, so its presence
|
||||||
|
* is the trust signal — a request without it did not come through the platform.
|
||||||
|
*/
|
||||||
|
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
|
||||||
|
const officerUser = req.headers.get('X-Officer-User');
|
||||||
|
if (!officerUser) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
|
||||||
|
|
||||||
|
const userId = Number(officerUser);
|
||||||
|
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
|
||||||
|
|
||||||
|
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
|
||||||
|
if (segments.length === 0) return null;
|
||||||
|
|
||||||
|
const ctx: OfficerContext = { req, url, userId };
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (segments[0]) {
|
||||||
|
case 'session':
|
||||||
|
return await handleSession(ctx, segments.slice(1));
|
||||||
|
case 'stats':
|
||||||
|
return await handleStats(ctx);
|
||||||
|
case 'torrents':
|
||||||
|
return await handleTorrents(ctx, segments.slice(1));
|
||||||
|
case 'free-space':
|
||||||
|
return await handleFreeSpace(ctx);
|
||||||
|
case 'port-test':
|
||||||
|
return await handlePortTest(ctx);
|
||||||
|
case 'blocklist-update':
|
||||||
|
return await handleBlocklistUpdate(ctx);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TransmissionError) return Response.json({ error: err.message }, { status: err.status });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSession(ctx: OfficerContext, rest: string[]): Promise<Response | null> {
|
||||||
|
if (rest.length > 0) return null;
|
||||||
|
|
||||||
|
if (ctx.req.method === 'GET') {
|
||||||
|
// No `fields` argument: session-get returns everything, and the settings UI reads nearly all of it.
|
||||||
|
const session = await rpc<SessionSettings>('session-get');
|
||||||
|
return Response.json({ session });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.req.method === 'POST') {
|
||||||
|
const body = await readJson(ctx.req);
|
||||||
|
if (!body) return badRequest('expected a JSON object body');
|
||||||
|
const args = pick(body, SESSION_SET_FIELDS);
|
||||||
|
if (Object.keys(args).length === 0) return badRequest('no writable session fields in body');
|
||||||
|
await rpc('session-set', args);
|
||||||
|
// Read back rather than echoing the request: Transmission clamps and normalises several of these
|
||||||
|
// (alt-speed times, queue sizes), so the request body is not what the daemon ends up holding.
|
||||||
|
const session = await rpc<SessionSettings>('session-get');
|
||||||
|
return Response.json({ session });
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodNotAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStats(ctx: OfficerContext): Promise<Response> {
|
||||||
|
if (ctx.req.method !== 'GET') return methodNotAllowed();
|
||||||
|
const stats = await rpc<SessionStats>('session-stats');
|
||||||
|
return Response.json({ stats });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFreeSpace(ctx: OfficerContext): Promise<Response> {
|
||||||
|
if (ctx.req.method !== 'GET') return methodNotAllowed();
|
||||||
|
const path = ctx.url.searchParams.get('path');
|
||||||
|
if (!path) return badRequest('path query parameter is required');
|
||||||
|
const result = await rpc<{ path: string; 'size-bytes': number }>('free-space', { path });
|
||||||
|
return Response.json({ path: result.path, bytes: result['size-bytes'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePortTest(ctx: OfficerContext): Promise<Response> {
|
||||||
|
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||||
|
const result = await rpc<{ 'port-is-open': boolean }>('port-test');
|
||||||
|
return Response.json({ open: result['port-is-open'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBlocklistUpdate(ctx: OfficerContext): Promise<Response> {
|
||||||
|
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||||
|
const result = await rpc<{ 'blocklist-size': number }>('blocklist-update');
|
||||||
|
return Response.json({ size: result['blocklist-size'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Response | null> {
|
||||||
|
const { req } = ctx;
|
||||||
|
|
||||||
|
// GET /_officer/torrents — the list poll.
|
||||||
|
if (rest.length === 0 && req.method === 'GET') {
|
||||||
|
const result = await rpc<{ torrents: Torrent[] }>('torrent-get', { fields: [...LIST_FIELDS] });
|
||||||
|
return Response.json({ torrents: result.torrents.map(decorate) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rest.length === 1) {
|
||||||
|
const [segment] = rest as [string];
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
switch (segment) {
|
||||||
|
case 'add':
|
||||||
|
return await addTorrent(req);
|
||||||
|
case 'action':
|
||||||
|
return await runAction(req);
|
||||||
|
case 'set':
|
||||||
|
return await setTorrent(req);
|
||||||
|
case 'location':
|
||||||
|
return await setLocation(req);
|
||||||
|
case 'rename':
|
||||||
|
return await renamePath(req);
|
||||||
|
case 'remove':
|
||||||
|
return await removeTorrents(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /_officer/torrents/:id — the detail poll, one torrent, with the expensive arrays.
|
||||||
|
const id = Number(segment);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return notFound();
|
||||||
|
if (req.method !== 'GET') return methodNotAllowed();
|
||||||
|
|
||||||
|
const result = await rpc<{ torrents: Torrent[] }>('torrent-get', {
|
||||||
|
ids: [id],
|
||||||
|
fields: [...LIST_FIELDS, ...DETAIL_FIELDS],
|
||||||
|
});
|
||||||
|
const torrent = result.torrents[0];
|
||||||
|
if (!torrent) return notFound('torrent not found');
|
||||||
|
return Response.json({ torrent: decorate(torrent) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTorrent(req: Request): Promise<Response> {
|
||||||
|
const body = await readJson(req);
|
||||||
|
if (!body) return badRequest('expected a JSON object body');
|
||||||
|
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
// Exactly one source. `metainfo` is a base64 .torrent; `filename` is a magnet link or an http(s) URL.
|
||||||
|
if (typeof body['metainfo'] === 'string' && body['metainfo']) args['metainfo'] = body['metainfo'];
|
||||||
|
else if (typeof body['filename'] === 'string' && body['filename']) args['filename'] = body['filename'];
|
||||||
|
else return badRequest('either metainfo or filename is required');
|
||||||
|
|
||||||
|
if (typeof body['downloadDir'] === 'string' && body['downloadDir']) args['download-dir'] = body['downloadDir'];
|
||||||
|
if (Array.isArray(body['labels'])) args['labels'] = body['labels'].filter((l) => typeof l === 'string');
|
||||||
|
if (typeof body['paused'] === 'boolean') args['paused'] = body['paused'];
|
||||||
|
if (typeof body['bandwidthPriority'] === 'number') args['bandwidthPriority'] = body['bandwidthPriority'];
|
||||||
|
if (typeof body['sequentialDownload'] === 'boolean') args['sequential_download'] = body['sequentialDownload'];
|
||||||
|
|
||||||
|
const result = await rpc<{
|
||||||
|
'torrent-added'?: { id: number; name: string; hashString: string };
|
||||||
|
'torrent-duplicate'?: { id: number; name: string; hashString: string };
|
||||||
|
}>('torrent-add', args);
|
||||||
|
|
||||||
|
const added = result['torrent-added'];
|
||||||
|
const duplicate = result['torrent-duplicate'];
|
||||||
|
// A duplicate is not an error — Transmission reports the torrent you already had, and the UI counts it
|
||||||
|
// separately so a batch add can say "3 added, 1 already present" instead of failing opaquely.
|
||||||
|
if (duplicate) return Response.json({ status: 'duplicate', torrent: duplicate });
|
||||||
|
if (added) return Response.json({ status: 'added', torrent: added });
|
||||||
|
return Response.json({ status: 'added', torrent: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(req: Request): Promise<Response> {
|
||||||
|
const body = await readJson(req);
|
||||||
|
if (!body) return badRequest('expected a JSON object body');
|
||||||
|
|
||||||
|
const action = typeof body['action'] === 'string' ? body['action'] : '';
|
||||||
|
const method = ACTIONS[action];
|
||||||
|
if (!method) return badRequest(`unknown action "${action}"`);
|
||||||
|
|
||||||
|
const ids = readIds(body);
|
||||||
|
if (!ids) return badRequest('ids must be an array of positive integers');
|
||||||
|
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
|
||||||
|
|
||||||
|
await rpc(method, { ids });
|
||||||
|
return Response.json({ ok: true, affected: ids.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setTorrent(req: Request): Promise<Response> {
|
||||||
|
const body = await readJson(req);
|
||||||
|
if (!body) return badRequest('expected a JSON object body');
|
||||||
|
|
||||||
|
const ids = readIds(body);
|
||||||
|
if (!ids) return badRequest('ids must be an array of positive integers');
|
||||||
|
|
||||||
|
const fields = pick(body, TORRENT_SET_FIELDS);
|
||||||
|
if (Object.keys(fields).length === 0) return badRequest('no writable torrent fields in body');
|
||||||
|
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
|
||||||
|
|
||||||
|
await rpc('torrent-set', { ids, ...fields });
|
||||||
|
return Response.json({ ok: true, affected: ids.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setLocation(req: Request): Promise<Response> {
|
||||||
|
const body = await readJson(req);
|
||||||
|
if (!body) return badRequest('expected a JSON object body');
|
||||||
|
|
||||||
|
const ids = readIds(body);
|
||||||
|
if (!ids) return badRequest('ids must be an array of positive integers');
|
||||||
|
const location = typeof body['location'] === 'string' ? body['location'].trim() : '';
|
||||||
|
if (!location) return badRequest('location is required');
|
||||||
|
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
|
||||||
|
|
||||||
|
// `move: false` re-points the torrent at data already sitting there; `true` physically moves it. Getting
|
||||||
|
// this backwards either loses the data or copies gigabytes unasked, so it is required, not defaulted.
|
||||||
|
const move = body['move'] === true;
|
||||||
|
await rpc('torrent-set-location', { ids, location, move });
|
||||||
|
return Response.json({ ok: true, affected: ids.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renamePath(req: Request): Promise<Response> {
|
||||||
|
const body = await readJson(req);
|
||||||
|
if (!body) return badRequest('expected a JSON object body');
|
||||||
|
|
||||||
|
const id = Number(body['id']);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return badRequest('id must be a positive integer');
|
||||||
|
const path = typeof body['path'] === 'string' ? body['path'] : '';
|
||||||
|
const name = typeof body['name'] === 'string' ? body['name'].trim() : '';
|
||||||
|
if (!path) return badRequest('path is required');
|
||||||
|
if (!name) return badRequest('name is required');
|
||||||
|
|
||||||
|
// torrent-rename-path takes ONE id — an array is accepted but the result is undefined. Enforce it.
|
||||||
|
await rpc('torrent-rename-path', { ids: [id], path, name });
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTorrents(req: Request): Promise<Response> {
|
||||||
|
const body = await readJson(req);
|
||||||
|
if (!body) return badRequest('expected a JSON object body');
|
||||||
|
|
||||||
|
const ids = readIds(body);
|
||||||
|
if (!ids) return badRequest('ids must be an array of positive integers');
|
||||||
|
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
|
||||||
|
|
||||||
|
const deleteLocalData = body['deleteLocalData'] === true;
|
||||||
|
await rpc('torrent-remove', { ids, 'delete-local-data': deleteLocalData });
|
||||||
|
return Response.json({ ok: true, affected: ids.length, deletedData: deleteLocalData });
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { Torrent, TrackerStat } from './types';
|
||||||
|
import { getAuthHeader, getRpcPath, getTransmissionBase } from './upstream';
|
||||||
|
|
||||||
|
// The Transmission RPC call layer. Every upstream request in this sidecar goes through here, so the
|
||||||
|
// wire-level quirks are handled exactly once:
|
||||||
|
//
|
||||||
|
// • THE 409 HANDSHAKE. Transmission's CSRF defence answers the first request of a session with
|
||||||
|
// `409 Conflict` and an `X-Transmission-Session-Id` header, expecting the client to repeat the request
|
||||||
|
// carrying it. The token also rotates whenever the daemon restarts, so this is not a start-up ritual you
|
||||||
|
// can do once — any call can 409 at any time. Absorbing it here is the single biggest reason this
|
||||||
|
// sidecar exists: a client that handles it naively works until the daemon is restarted and then fails
|
||||||
|
// in a way that looks like an auth bug.
|
||||||
|
// • Errors are NOT signalled by HTTP status. A perfectly successful-looking 200 carries
|
||||||
|
// `{"result": "some error string"}`; only `result === 'success'` means it worked.
|
||||||
|
// • Auth is HTTP Basic, and an EMPTY username must send no header at all — see getAuthHeader.
|
||||||
|
// • `torrent-get` with an unknown field name fails the whole call rather than ignoring the field, so
|
||||||
|
// LIST_FIELDS/DETAIL_FIELDS are curated against the running daemon's rpc-version, not guessed.
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
|
||||||
|
export class TransmissionError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'TransmissionError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CSRF token for the current daemon session. Module-level because it is a property of the connection,
|
||||||
|
// not of any one request, and every caller benefits from a refresh any one of them performs.
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
|
||||||
|
type RpcResponse<T> = { result: string; arguments?: T };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue one RPC call. Retries exactly once on 409 after adopting the new session id; a second 409 means
|
||||||
|
* something other than a stale token (a proxy stripping the header, most likely) and is surfaced.
|
||||||
|
*/
|
||||||
|
export async function rpc<T = unknown>(method: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||||
|
return call<T>(method, args, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(method: string, args: Record<string, unknown>, mayRetry: boolean): Promise<T> {
|
||||||
|
const base = getTransmissionBase();
|
||||||
|
if (!base) throw new TransmissionError(503, 'TRANSMISSION_URL is not configured');
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (sessionId) headers['X-Transmission-Session-Id'] = sessionId;
|
||||||
|
const auth = getAuthHeader();
|
||||||
|
if (auth) headers['Authorization'] = auth;
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${base}${getRpcPath()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ method, arguments: args }),
|
||||||
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'unreachable';
|
||||||
|
throw new TransmissionError(502, `transmission upstream ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 409) {
|
||||||
|
const fresh = res.headers.get('X-Transmission-Session-Id');
|
||||||
|
if (fresh && mayRetry) {
|
||||||
|
sessionId = fresh;
|
||||||
|
return call<T>(method, args, false);
|
||||||
|
}
|
||||||
|
throw new TransmissionError(502, 'transmission rejected the session id handshake');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) throw new TransmissionError(401, 'transmission rejected the credentials');
|
||||||
|
if (!res.ok) throw new TransmissionError(502, `transmission returned ${res.status}`);
|
||||||
|
|
||||||
|
const body = (await res.json().catch(() => null)) as RpcResponse<T> | null;
|
||||||
|
if (!body) throw new TransmissionError(502, 'transmission returned an unreadable body');
|
||||||
|
// A 200 with a non-'success' result is the normal way Transmission reports a failure.
|
||||||
|
if (body.result !== 'success') throw new TransmissionError(502, body.result || 'transmission reported a failure');
|
||||||
|
|
||||||
|
return (body.arguments ?? {}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reachability + credential probe, used by /_health. */
|
||||||
|
export async function probe(): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
const args = await rpc<{ version: string; 'rpc-version': number }>('session-get', {
|
||||||
|
fields: ['version', 'rpc-version'],
|
||||||
|
});
|
||||||
|
return { ok: true, version: args.version, rpcVersion: args['rpc-version'] };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived fields ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Subdomain prefixes that carry no identity — `tracker.example.org` and `t.example.org` are the same
|
||||||
|
// operator to a human grouping their torrents, and showing them as two entries makes the filter useless.
|
||||||
|
const IGNORED_TRACKER_PREFIXES = new Set(['t', 'tr', 'tk', 'tracker', 'bt', 'open', 'opentracker', 'pt']);
|
||||||
|
|
||||||
|
const ANNOUNCE_STATE: Record<number, string> = {
|
||||||
|
0: 'Inactive',
|
||||||
|
1: 'Waiting',
|
||||||
|
2: 'Queued',
|
||||||
|
3: 'Announcing',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `udp://tr.example.org:1337/announce` → `example.org`. Empty string when there is no tracker at all. */
|
||||||
|
export function trackerHost(stats: TrackerStat[]): string {
|
||||||
|
const primary = stats.find((t) => !t.isBackup) ?? stats[0];
|
||||||
|
if (!primary) return '';
|
||||||
|
|
||||||
|
const raw = primary.host || primary.announce;
|
||||||
|
let host: string;
|
||||||
|
try {
|
||||||
|
host = new URL(raw.includes('://') ? raw : `http://${raw}`).hostname;
|
||||||
|
} catch {
|
||||||
|
host = raw
|
||||||
|
.replace(/^[a-z]+:\/\//i, '')
|
||||||
|
.split('/')[0]!
|
||||||
|
.split(':')[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = host.split('.');
|
||||||
|
// Only strip a prefix when something recognisable remains — `t.co` must not become `co`.
|
||||||
|
if (parts.length > 2 && IGNORED_TRACKER_PREFIXES.has(parts[0]!.toLowerCase())) parts.shift();
|
||||||
|
return parts.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackerStatus(stats: TrackerStat[]): string {
|
||||||
|
const primary = stats.find((t) => !t.isBackup) ?? stats[0];
|
||||||
|
if (!primary) return '';
|
||||||
|
if (primary.lastAnnounceSucceeded === false && primary.lastAnnounceResult) return primary.lastAnnounceResult;
|
||||||
|
return ANNOUNCE_STATE[primary.announceState] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach the `officer*` fields the UI groups and filters by. Mutating the parsed body is fine — it's ours. */
|
||||||
|
export function decorate(torrent: Torrent): Torrent {
|
||||||
|
const stats = torrent.trackerStats ?? [];
|
||||||
|
torrent.officerTrackerHost = trackerHost(stats);
|
||||||
|
torrent.officerTrackerStatus = trackerStatus(stats);
|
||||||
|
torrent.officerTrackerErrors = stats.filter((t) => t.lastAnnounceSucceeded === false && t.hasAnnounced).length;
|
||||||
|
return torrent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// Wire types for the Transmission RPC surface this sidecar uses.
|
||||||
|
//
|
||||||
|
// Field names are Transmission's OWN, verbatim — including its inconsistent casing (`percentDone` next to
|
||||||
|
// `begin_piece`, `peer-limit`, `sequential_download`). That is deliberate: renaming them would mean every
|
||||||
|
// future field needs a translation entry, and a name that doesn't appear in Transmission's rpc-spec.md is a
|
||||||
|
// name you cannot grep for when something misbehaves. The only fields we invent are the `officer*` derived
|
||||||
|
// ones at the bottom of Torrent, which are prefixed so they can never be mistaken for upstream data.
|
||||||
|
|
||||||
|
/** Transmission's torrent status enum (rpc-spec.md §3.3). */
|
||||||
|
export const TorrentStatus = {
|
||||||
|
Stopped: 0,
|
||||||
|
CheckWait: 1,
|
||||||
|
Check: 2,
|
||||||
|
DownloadWait: 3,
|
||||||
|
Download: 4,
|
||||||
|
SeedWait: 5,
|
||||||
|
Seed: 6,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TrackerStat = {
|
||||||
|
announce: string;
|
||||||
|
announceState: number;
|
||||||
|
downloadCount: number;
|
||||||
|
hasAnnounced: boolean;
|
||||||
|
hasScraped: boolean;
|
||||||
|
host: string;
|
||||||
|
id: number;
|
||||||
|
isBackup: boolean;
|
||||||
|
lastAnnouncePeerCount: number;
|
||||||
|
lastAnnounceResult: string;
|
||||||
|
lastAnnounceSucceeded: boolean;
|
||||||
|
lastAnnounceTime: number;
|
||||||
|
lastScrapeResult: string;
|
||||||
|
lastScrapeSucceeded: boolean;
|
||||||
|
lastScrapeTime: number;
|
||||||
|
leecherCount: number;
|
||||||
|
nextAnnounceTime: number;
|
||||||
|
scrape: string;
|
||||||
|
seederCount: number;
|
||||||
|
tier: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TorrentFile = {
|
||||||
|
bytesCompleted: number;
|
||||||
|
length: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TorrentFileStat = {
|
||||||
|
bytesCompleted: number;
|
||||||
|
priority: number;
|
||||||
|
wanted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TorrentPeer = {
|
||||||
|
address: string;
|
||||||
|
clientName: string;
|
||||||
|
clientIsChoked: boolean;
|
||||||
|
clientIsInterested: boolean;
|
||||||
|
flagStr: string;
|
||||||
|
isDownloadingFrom: boolean;
|
||||||
|
isEncrypted: boolean;
|
||||||
|
isIncoming: boolean;
|
||||||
|
isUploadingTo: boolean;
|
||||||
|
isUTP: boolean;
|
||||||
|
peerIsChoked: boolean;
|
||||||
|
peerIsInterested: boolean;
|
||||||
|
port: number;
|
||||||
|
progress: number;
|
||||||
|
rateToClient: number;
|
||||||
|
rateToPeer: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeersFrom = {
|
||||||
|
fromCache: number;
|
||||||
|
fromDht: number;
|
||||||
|
fromIncoming: number;
|
||||||
|
fromLpd: number;
|
||||||
|
fromLtep: number;
|
||||||
|
fromPex: number;
|
||||||
|
fromTracker: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Torrent = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
totalSize: number;
|
||||||
|
sizeWhenDone: number;
|
||||||
|
leftUntilDone: number;
|
||||||
|
haveValid: number;
|
||||||
|
percentDone: number;
|
||||||
|
metadataPercentComplete: number;
|
||||||
|
recheckProgress?: number;
|
||||||
|
downloadedEver: number;
|
||||||
|
uploadedEver: number;
|
||||||
|
corruptEver?: number;
|
||||||
|
uploadRatio: number;
|
||||||
|
rateDownload: number;
|
||||||
|
rateUpload: number;
|
||||||
|
eta: number;
|
||||||
|
peersSendingToUs: number;
|
||||||
|
peersGettingFromUs: number;
|
||||||
|
addedDate: number;
|
||||||
|
doneDate: number;
|
||||||
|
activityDate: number;
|
||||||
|
secondsSeeding: number;
|
||||||
|
downloadDir: string;
|
||||||
|
bandwidthPriority: number;
|
||||||
|
queuePosition: number;
|
||||||
|
isPrivate: boolean;
|
||||||
|
labels: string[];
|
||||||
|
error: number;
|
||||||
|
errorString: string;
|
||||||
|
pieceCount: number;
|
||||||
|
pieceSize: number;
|
||||||
|
magnetLink: string;
|
||||||
|
group: string;
|
||||||
|
'file-count': number;
|
||||||
|
trackerStats: TrackerStat[];
|
||||||
|
trackerList?: string;
|
||||||
|
// Per-torrent limits — the fields the "Other settings" dialog reads and writes.
|
||||||
|
honorsSessionLimits: boolean;
|
||||||
|
downloadLimited: boolean;
|
||||||
|
downloadLimit: number;
|
||||||
|
uploadLimited: boolean;
|
||||||
|
uploadLimit: number;
|
||||||
|
'peer-limit': number;
|
||||||
|
seedRatioMode: number;
|
||||||
|
seedRatioLimit: number;
|
||||||
|
seedIdleMode: number;
|
||||||
|
seedIdleLimit: number;
|
||||||
|
sequential_download?: boolean;
|
||||||
|
// Detail-only fields — present when fetched through /_officer/torrents/:id.
|
||||||
|
hashString?: string;
|
||||||
|
comment?: string;
|
||||||
|
creator?: string;
|
||||||
|
dateCreated?: number;
|
||||||
|
maxConnectedPeers?: number;
|
||||||
|
files?: TorrentFile[];
|
||||||
|
fileStats?: TorrentFileStat[];
|
||||||
|
peers?: TorrentPeer[];
|
||||||
|
peersFrom?: PeersFrom;
|
||||||
|
// ── Derived by the sidecar, never sent by Transmission ──
|
||||||
|
/**
|
||||||
|
* The primary tracker's host with its port and any ignorable subdomain prefix stripped
|
||||||
|
* (`udp://tr.example.org:1337/announce` → `example.org`). The UI groups by this, so the normalisation
|
||||||
|
* has to be one implementation, not one per caller.
|
||||||
|
*/
|
||||||
|
officerTrackerHost: string;
|
||||||
|
/** Human-readable announce state of the primary tracker, for the "Tracker Status" column. */
|
||||||
|
officerTrackerStatus: string;
|
||||||
|
/** Count of trackers that have reported an error, so the UI can flag a torrent without walking the array. */
|
||||||
|
officerTrackerErrors: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionSettings = Record<string, unknown> & {
|
||||||
|
version: string;
|
||||||
|
'rpc-version': number;
|
||||||
|
'download-dir': string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionStats = {
|
||||||
|
activeTorrentCount: number;
|
||||||
|
pausedTorrentCount: number;
|
||||||
|
torrentCount: number;
|
||||||
|
downloadSpeed: number;
|
||||||
|
uploadSpeed: number;
|
||||||
|
'cumulative-stats': StatsBucket;
|
||||||
|
'current-stats': StatsBucket;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatsBucket = {
|
||||||
|
downloadedBytes: number;
|
||||||
|
uploadedBytes: number;
|
||||||
|
filesAdded: number;
|
||||||
|
sessionCount: number;
|
||||||
|
secondsActive: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The fields the torrent list needs. Mirrors what the list UI actually renders — nothing speculative. */
|
||||||
|
export const LIST_FIELDS = [
|
||||||
|
'activityDate',
|
||||||
|
'addedDate',
|
||||||
|
'bandwidthPriority',
|
||||||
|
'corruptEver',
|
||||||
|
'doneDate',
|
||||||
|
'downloadDir',
|
||||||
|
'downloadedEver',
|
||||||
|
'downloadLimit',
|
||||||
|
'downloadLimited',
|
||||||
|
'error',
|
||||||
|
'errorString',
|
||||||
|
'eta',
|
||||||
|
'file-count',
|
||||||
|
'group',
|
||||||
|
'haveValid',
|
||||||
|
'honorsSessionLimits',
|
||||||
|
'id',
|
||||||
|
'isPrivate',
|
||||||
|
'labels',
|
||||||
|
'leftUntilDone',
|
||||||
|
'magnetLink',
|
||||||
|
'metadataPercentComplete',
|
||||||
|
'name',
|
||||||
|
'peer-limit',
|
||||||
|
'peersGettingFromUs',
|
||||||
|
'peersSendingToUs',
|
||||||
|
'percentDone',
|
||||||
|
'pieceCount',
|
||||||
|
'pieceSize',
|
||||||
|
'queuePosition',
|
||||||
|
'rateDownload',
|
||||||
|
'rateUpload',
|
||||||
|
'secondsSeeding',
|
||||||
|
'seedIdleLimit',
|
||||||
|
'seedIdleMode',
|
||||||
|
'seedRatioLimit',
|
||||||
|
'seedRatioMode',
|
||||||
|
'sequential_download',
|
||||||
|
'sizeWhenDone',
|
||||||
|
'status',
|
||||||
|
'totalSize',
|
||||||
|
'trackerList',
|
||||||
|
'trackerStats',
|
||||||
|
'uploadedEver',
|
||||||
|
'uploadLimit',
|
||||||
|
'uploadLimited',
|
||||||
|
'uploadRatio',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Extra fields only the detail pane needs — big arrays we refuse to pull for every row on every poll. */
|
||||||
|
export const DETAIL_FIELDS = [
|
||||||
|
'comment',
|
||||||
|
'creator',
|
||||||
|
'dateCreated',
|
||||||
|
'fileStats',
|
||||||
|
'files',
|
||||||
|
'hashString',
|
||||||
|
'maxConnectedPeers',
|
||||||
|
'peers',
|
||||||
|
'peersFrom',
|
||||||
|
'recheckProgress',
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Transmission upstream config for the officer-transmission sidecar.
|
||||||
|
//
|
||||||
|
// All knowledge of the Transmission daemon (its URL, its RPC path and its credentials) lives here,
|
||||||
|
// mirroring the officer-slskd/officer-vault philosophy: the platform API is a thin auth+forward proxy and
|
||||||
|
// holds NO Transmission credentials.
|
||||||
|
|
||||||
|
const { TRANSMISSION_URL, TRANSMISSION_USER, TRANSMISSION_PASS, TRANSMISSION_RPC_PATH } = process.env;
|
||||||
|
|
||||||
|
let warnedUnset = false;
|
||||||
|
|
||||||
|
/** The Transmission base URL (no trailing slash), or null when unconfigured (the sidecar then 503s). */
|
||||||
|
export function getTransmissionBase(): string | null {
|
||||||
|
const raw = TRANSMISSION_URL?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
if (!warnedUnset) {
|
||||||
|
console.warn('[transmission] TRANSMISSION_URL is unset — the sidecar will respond 503 until it is set');
|
||||||
|
warnedUnset = true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return raw.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The RPC endpoint path. Transmission serves it at /transmission/rpc by default, but a reverse proxy can
|
||||||
|
* mount it anywhere, so it is configurable rather than hardcoded.
|
||||||
|
*/
|
||||||
|
export function getRpcPath(): string {
|
||||||
|
const raw = TRANSMISSION_RPC_PATH?.trim();
|
||||||
|
if (!raw) return '/transmission/rpc';
|
||||||
|
return raw.startsWith('/') ? raw : `/${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Authorization: Basic …` header value, or null when the daemon has no auth configured.
|
||||||
|
*
|
||||||
|
* Transmission treats an empty username as "no authentication required" — sending an empty Basic header in
|
||||||
|
* that case is not merely useless, it makes the daemon reject the request. So this returns null unless a
|
||||||
|
* username is actually set.
|
||||||
|
*/
|
||||||
|
export function getAuthHeader(): string | null {
|
||||||
|
const user = TRANSMISSION_USER?.trim();
|
||||||
|
if (!user) return null;
|
||||||
|
const pass = TRANSMISSION_PASS ?? '';
|
||||||
|
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
|||||||
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
||||||
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
||||||
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
|
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
|
||||||
|
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
|
||||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||||
import { useAppRegistry } from './useAppRegistry';
|
import { useAppRegistry } from './useAppRegistry';
|
||||||
import { useUserApps } from 'state/useUserApps';
|
import { useUserApps } from 'state/useUserApps';
|
||||||
@@ -33,6 +34,7 @@ const apps = [
|
|||||||
...musicMetas,
|
...musicMetas,
|
||||||
...soulseekMetas,
|
...soulseekMetas,
|
||||||
...headscaleMetas,
|
...headscaleMetas,
|
||||||
|
...transmissionMetas,
|
||||||
...monitorMetas,
|
...monitorMetas,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Loader2, Plug, ShieldCheck } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useMaintenance, useSession } from './useTransmissionData';
|
||||||
|
|
||||||
|
// The daemon's settings, as one form with a draft.
|
||||||
|
//
|
||||||
|
// Only the fields you actually changed are sent — session-set is a partial update, and posting the whole
|
||||||
|
// object back would race the 5-second poll and re-apply stale values you never touched. The sidecar reads
|
||||||
|
// the settings back after writing because Transmission clamps several of them, so the form snaps to what
|
||||||
|
// the daemon really holds rather than to what you typed.
|
||||||
|
|
||||||
|
export const SettingsView = () => {
|
||||||
|
const { session, isLoading, save } = useSession();
|
||||||
|
const { portTest, updateBlocklist } = useMaintenance();
|
||||||
|
const [draft, setDraft] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
|
if (isLoading && !session) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading settings…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Could not read the session settings.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = <T,>(key: string, fallback: T): T =>
|
||||||
|
key in draft ? (draft[key] as T) : ((session[key] as T) ?? fallback);
|
||||||
|
const set = (key: string, value: unknown) => setDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const dirty = Object.keys(draft).length > 0;
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
await save.mutateAsync(draft);
|
||||||
|
setDraft({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bool = (key: string, label: string, hint?: string) => (
|
||||||
|
<Toggle checked={get(key, false)} onChange={(v) => set(key, v)} label={label} hint={hint} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const number = (key: string, label: string, unit?: string, min = 0) => (
|
||||||
|
<Field label={label} unit={unit}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
value={String(get(key, 0))}
|
||||||
|
onChange={(ev) => set(key, Number(ev.target.value))}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = (key: string, label: string, placeholder?: string) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={`tr-${key}`}>{label}</Label>
|
||||||
|
<Input
|
||||||
|
id={`tr-${key}`}
|
||||||
|
value={String(get(key, ''))}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(ev) => set(key, ev.target.value)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<Tabs defaultValue="download" className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<TabsList className="mx-4 mt-3 w-fit shrink-0">
|
||||||
|
<TabsTrigger value="download">Downloads</TabsTrigger>
|
||||||
|
<TabsTrigger value="bandwidth">Bandwidth</TabsTrigger>
|
||||||
|
<TabsTrigger value="network">Network</TabsTrigger>
|
||||||
|
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||||
|
<TabsTrigger value="other">Other</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
|
<TabsContent value="download" className="mt-0 space-y-5">
|
||||||
|
<Section title="Location">
|
||||||
|
{text('download-dir', 'Download directory', '/downloads')}
|
||||||
|
{bool('incomplete-dir-enabled', 'Keep incomplete files somewhere else')}
|
||||||
|
{get('incomplete-dir-enabled', false) && text('incomplete-dir', 'Incomplete directory')}
|
||||||
|
{bool(
|
||||||
|
'rename-partial-files',
|
||||||
|
'Append .part to incomplete files',
|
||||||
|
'Stops media scanners picking up half-downloaded files.',
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="When adding">
|
||||||
|
{bool('start-added-torrents', 'Start torrents as soon as they are added')}
|
||||||
|
{bool('trash-original-torrent-files', 'Delete the .torrent file after adding')}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Seeding limits">
|
||||||
|
{bool('seedRatioLimited', 'Stop seeding at a ratio')}
|
||||||
|
{get('seedRatioLimited', false) && (
|
||||||
|
<Field label="Ratio">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min={0}
|
||||||
|
value={String(get('seedRatioLimit', 2))}
|
||||||
|
onChange={(ev) => set('seedRatioLimit', Number(ev.target.value))}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{bool('idle-seeding-limit-enabled', 'Stop seeding when idle')}
|
||||||
|
{get('idle-seeding-limit-enabled', false) && number('idle-seeding-limit', 'Idle for', 'minutes')}
|
||||||
|
</Section>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="bandwidth" className="mt-0 space-y-5">
|
||||||
|
<Section title="Speed limits">
|
||||||
|
{bool('speed-limit-down-enabled', 'Limit download speed')}
|
||||||
|
{get('speed-limit-down-enabled', false) && number('speed-limit-down', 'Download', 'kB/s')}
|
||||||
|
{bool('speed-limit-up-enabled', 'Limit upload speed')}
|
||||||
|
{get('speed-limit-up-enabled', false) && number('speed-limit-up', 'Upload', 'kB/s')}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Alternative speed limits"
|
||||||
|
hint="The turtle button in the toolbar switches to these without touching the limits above."
|
||||||
|
>
|
||||||
|
{bool('alt-speed-enabled', 'Use the alternative limits now')}
|
||||||
|
{number('alt-speed-down', 'Download', 'kB/s')}
|
||||||
|
{number('alt-speed-up', 'Upload', 'kB/s')}
|
||||||
|
{bool('alt-speed-time-enabled', 'Switch automatically on a schedule')}
|
||||||
|
{get('alt-speed-time-enabled', false) && (
|
||||||
|
<>
|
||||||
|
<TimeField
|
||||||
|
label="From"
|
||||||
|
minutes={get('alt-speed-time-begin', 0)}
|
||||||
|
onChange={(v) => set('alt-speed-time-begin', v)}
|
||||||
|
/>
|
||||||
|
<TimeField
|
||||||
|
label="To"
|
||||||
|
minutes={get('alt-speed-time-end', 0)}
|
||||||
|
onChange={(v) => set('alt-speed-time-end', v)}
|
||||||
|
/>
|
||||||
|
<DayPicker mask={get('alt-speed-time-day', 127)} onChange={(v) => set('alt-speed-time-day', v)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="network" className="mt-0 space-y-5">
|
||||||
|
<Section title="Listening port">
|
||||||
|
{number('peer-port', 'Peer port', undefined, 1)}
|
||||||
|
{bool('peer-port-random-on-start', 'Pick a random port on each start')}
|
||||||
|
{bool('port-forwarding-enabled', 'Ask the router to forward it (UPnP / NAT-PMP)')}
|
||||||
|
<div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => portTest.mutate()} disabled={portTest.isPending}>
|
||||||
|
{portTest.isPending ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plug className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Test port
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Peers">
|
||||||
|
{number('peer-limit-global', 'Maximum peers overall', undefined, 1)}
|
||||||
|
{number('peer-limit-per-torrent', 'Maximum peers per torrent', undefined, 1)}
|
||||||
|
<Field label="Encryption">
|
||||||
|
<Select value={String(get('encryption', 'preferred'))} onValueChange={(v) => set('encryption', v)}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tolerated">Allow unencrypted</SelectItem>
|
||||||
|
<SelectItem value="preferred">Prefer encryption</SelectItem>
|
||||||
|
<SelectItem value="required">Require encryption</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Peer discovery" hint="Private torrents ignore all of these by design.">
|
||||||
|
{bool('pex-enabled', 'Peer exchange (PEX)')}
|
||||||
|
{bool('dht-enabled', 'Distributed hash table (DHT)')}
|
||||||
|
{bool('lpd-enabled', 'Local peer discovery')}
|
||||||
|
{bool('utp-enabled', 'µTP — yields to other traffic on your connection')}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Blocklist">
|
||||||
|
{bool('blocklist-enabled', 'Use a blocklist')}
|
||||||
|
{text('blocklist-url', 'Blocklist URL')}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => updateBlocklist.mutate()}
|
||||||
|
disabled={updateBlocklist.isPending}
|
||||||
|
>
|
||||||
|
{updateBlocklist.isPending ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Update now
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{Number(session['blocklist-size'] ?? 0).toLocaleString()} rules loaded
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="queue" className="mt-0 space-y-5">
|
||||||
|
<Section title="Download queue">
|
||||||
|
{bool('download-queue-enabled', 'Limit how many torrents download at once')}
|
||||||
|
{get('download-queue-enabled', false) && number('download-queue-size', 'At most', 'torrents', 1)}
|
||||||
|
</Section>
|
||||||
|
<Section title="Seed queue">
|
||||||
|
{bool('seed-queue-enabled', 'Limit how many torrents seed at once')}
|
||||||
|
{get('seed-queue-enabled', false) && number('seed-queue-size', 'At most', 'torrents', 1)}
|
||||||
|
</Section>
|
||||||
|
<Section
|
||||||
|
title="Stalled torrents"
|
||||||
|
hint="A stalled torrent gives up its queue slot so something else can run."
|
||||||
|
>
|
||||||
|
{bool('queue-stalled-enabled', 'Treat torrents with no activity as stalled')}
|
||||||
|
{get('queue-stalled-enabled', false) && number('queue-stalled-minutes', 'After', 'minutes', 1)}
|
||||||
|
</Section>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="other" className="mt-0 space-y-5">
|
||||||
|
<Section title="Performance">{number('cache-size-mb', 'Disk cache', 'MB')}</Section>
|
||||||
|
|
||||||
|
<Section title="On completion">
|
||||||
|
{bool('script-torrent-done-enabled', 'Run a script when a torrent finishes')}
|
||||||
|
{get('script-torrent-done-enabled', false) &&
|
||||||
|
text('script-torrent-done-filename', 'Script path', '/etc/transmission/done.sh')}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Default trackers"
|
||||||
|
hint="Added to every public torrent. One URL per line, blank line between tiers."
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
rows={6}
|
||||||
|
value={String(get('default-trackers', ''))}
|
||||||
|
onChange={(ev) => set('default-trackers', ev.target.value)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="About">
|
||||||
|
<dl className="grid gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
||||||
|
<About label="Version" value={String(session.version ?? '—')} />
|
||||||
|
<About label="RPC version" value={String(session['rpc-version'] ?? '—')} />
|
||||||
|
<About label="Config directory" value={String(session['config-dir'] ?? '—')} />
|
||||||
|
<About label="Session ID" value={String(session['session-id'] ?? '—')} />
|
||||||
|
</dl>
|
||||||
|
</Section>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* The bar only exists when there is something to save — a permanently-visible disabled Save button
|
||||||
|
teaches you to ignore it. */}
|
||||||
|
{dirty && (
|
||||||
|
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-muted/40 px-4 py-2">
|
||||||
|
<span className="flex-1 text-xs text-muted-foreground">
|
||||||
|
{Object.keys(draft).length} unsaved change{Object.keys(draft).length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setDraft({})}>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={apply} disabled={save.isPending}>
|
||||||
|
{save.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Section = ({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) => (
|
||||||
|
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||||
|
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||||
|
{hint && <p className="mt-0.5 text-xs text-muted-foreground">{hint}</p>}
|
||||||
|
<div className="mt-3 space-y-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toggle = ({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
}) => (
|
||||||
|
<label className="flex items-start gap-2.5 text-sm">
|
||||||
|
<Checkbox className="mt-0.5" checked={checked} onCheckedChange={(v) => onChange(v === true)} />
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
{hint && <span className="mt-0.5 block text-xs text-muted-foreground">{hint}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Field = ({ label, unit, children }: { label: string; unit?: string; children: ReactNode }) => (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="w-56 shrink-0">{label}</span>
|
||||||
|
{children}
|
||||||
|
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Transmission stores the alt-speed schedule as minutes past midnight; the browser wants HH:MM. */
|
||||||
|
const TimeField = ({
|
||||||
|
label,
|
||||||
|
minutes,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
minutes: number;
|
||||||
|
onChange: (minutes: number) => void;
|
||||||
|
}) => {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const value = `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
|
||||||
|
return (
|
||||||
|
<Field label={label}>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={value}
|
||||||
|
onChange={(ev) => {
|
||||||
|
const [h, m] = ev.target.value.split(':').map(Number);
|
||||||
|
if (Number.isFinite(h) && Number.isFinite(m)) onChange(h! * 60 + m!);
|
||||||
|
}}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// alt-speed-time-day is a bitfield, Sunday = bit 0 (rpc-spec.md). 127 is every day.
|
||||||
|
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
const DayPicker = ({ mask, onChange }: { mask: number; onChange: (mask: number) => void }) => (
|
||||||
|
<Field label="On days">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{DAYS.map((day, i) => {
|
||||||
|
const bit = 1 << i;
|
||||||
|
const on = (mask & bit) !== 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(on ? mask & ~bit : mask | bit)}
|
||||||
|
className={`rounded px-2 py-1 text-xs transition-colors ${
|
||||||
|
on ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
|
const About = ({ label, value }: { label: string; value: string }) => (
|
||||||
|
<div className="flex min-w-0 items-baseline gap-3">
|
||||||
|
<dt className="w-32 shrink-0 text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="min-w-0 flex-1 truncate font-mono" title={value}>
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { StatsBucket } from './shared';
|
||||||
|
import { ArrowDownToLine, ArrowUpFromLine, HardDrive, Loader2 } from 'lucide-react';
|
||||||
|
import { formatDuration, formatRatio, formatSize, formatSpeed } from './format';
|
||||||
|
import { useFreeSpace, useSession, useSessionStats, useTorrents } from './useTransmissionData';
|
||||||
|
|
||||||
|
// Session statistics. Two columns because Transmission keeps two counters and conflating them is a classic
|
||||||
|
// misreading — "this session" resets when the daemon restarts, "all time" never does.
|
||||||
|
|
||||||
|
export const StatsView = () => {
|
||||||
|
const { stats, isLoading } = useSessionStats();
|
||||||
|
const { session } = useSession();
|
||||||
|
const { torrents } = useTorrents();
|
||||||
|
const downloadDir = session?.['download-dir'];
|
||||||
|
const { bytes: freeBytes } = useFreeSpace(downloadDir);
|
||||||
|
|
||||||
|
if (isLoading && !stats) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading statistics…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">No statistics.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSize = torrents.reduce((sum, t) => sum + (t.totalSize ?? 0), 0);
|
||||||
|
const onDisk = torrents.reduce((sum, t) => sum + (t.haveValid ?? 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Tile
|
||||||
|
icon={<ArrowDownToLine className="h-4 w-4 text-blue-500" />}
|
||||||
|
label="Download"
|
||||||
|
value={formatSpeed(stats.downloadSpeed) || '0 B/s'}
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
icon={<ArrowUpFromLine className="h-4 w-4 text-emerald-500" />}
|
||||||
|
label="Upload"
|
||||||
|
value={formatSpeed(stats.uploadSpeed) || '0 B/s'}
|
||||||
|
/>
|
||||||
|
<Tile label="Torrents" value={String(stats.torrentCount)} hint={`${stats.activeTorrentCount} active`} />
|
||||||
|
<Tile
|
||||||
|
icon={<HardDrive className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
label="Free space"
|
||||||
|
value={freeBytes == null ? '—' : formatSize(freeBytes)}
|
||||||
|
hint={downloadDir}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
|
<Bucket title="This session" bucket={stats['current-stats']} />
|
||||||
|
<Bucket title="All time" bucket={stats['cumulative-stats']} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-4 rounded-xl border border-border p-4">
|
||||||
|
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Local library</h3>
|
||||||
|
<dl className="grid gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
||||||
|
<Row label="Torrents" value={String(torrents.length)} />
|
||||||
|
<Row label="Paused" value={String(stats.pausedTorrentCount)} />
|
||||||
|
<Row label="Total size" value={formatSize(totalSize)} />
|
||||||
|
{/* haveValid, not totalSize: an unfinished or partially-wanted torrent occupies less than it
|
||||||
|
claims, and the difference is the whole reason to look. */}
|
||||||
|
<Row label="Verified on disk" value={formatSize(onDisk)} />
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Bucket = ({ title, bucket }: { title: string; bucket: StatsBucket }) => (
|
||||||
|
<section className="rounded-xl border border-border p-4">
|
||||||
|
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||||
|
<dl className="grid gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
||||||
|
<Row label="Downloaded" value={formatSize(bucket.downloadedBytes)} />
|
||||||
|
<Row label="Uploaded" value={formatSize(bucket.uploadedBytes)} />
|
||||||
|
<Row
|
||||||
|
label="Ratio"
|
||||||
|
value={formatRatio(bucket.downloadedBytes > 0 ? bucket.uploadedBytes / bucket.downloadedBytes : -1)}
|
||||||
|
/>
|
||||||
|
<Row label="Torrents added" value={bucket.filesAdded.toLocaleString()} />
|
||||||
|
<Row label="Running time" value={formatDuration(bucket.secondsActive)} />
|
||||||
|
<Row label="Sessions" value={bucket.sessionCount.toLocaleString()} />
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
type TileProps = { label: string; value: string; hint?: string; icon?: ReactNode };
|
||||||
|
|
||||||
|
const Tile = ({ label, value, hint, icon }: TileProps) => (
|
||||||
|
<div className="rounded-xl border border-border p-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold tabular-nums">{value}</div>
|
||||||
|
{hint && (
|
||||||
|
<div className="mt-0.5 truncate text-xs text-muted-foreground" title={hint}>
|
||||||
|
{hint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Row = ({ label, value }: { label: string; value: string }) => (
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<dt className="w-32 shrink-0 text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="tabular-nums">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Loader2, X } from 'lucide-react';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { statusLabel, statusTone, TONE_CLASSES } from './format';
|
||||||
|
import { useTorrentDetail } from './useTransmissionData';
|
||||||
|
import { GeneralTab } from './detail/GeneralTab';
|
||||||
|
import { FilesTab } from './detail/FilesTab';
|
||||||
|
import { PeersTab } from './detail/PeersTab';
|
||||||
|
import { TrackersTab } from './detail/TrackersTab';
|
||||||
|
import { TrackersDialog } from './dialogs/TrackersDialog';
|
||||||
|
|
||||||
|
// The bottom pane of the torrents section, driven by ?selected= in the URL.
|
||||||
|
//
|
||||||
|
// It fetches its own torrent rather than being handed the row from the list: the list poll deliberately
|
||||||
|
// omits files, peers and peersFrom, and threading two shapes of the same object through the tree is how
|
||||||
|
// you end up rendering a stale peer list.
|
||||||
|
|
||||||
|
type TorrentDetailProps = { id: number; onClose: () => void };
|
||||||
|
|
||||||
|
export const TorrentDetail = ({ id, onClose }: TorrentDetailProps) => {
|
||||||
|
const { torrent, isLoading } = useTorrentDetail(id);
|
||||||
|
const [editingTrackers, setEditingTrackers] = useState(false);
|
||||||
|
|
||||||
|
if (isLoading && !torrent) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!torrent) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">That torrent is gone.</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tone = TONE_CLASSES[statusTone(torrent)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col border-t border-border">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
|
<span className={`h-2 w-2 shrink-0 rounded-full ${tone.dot}`} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={torrent.name}>
|
||||||
|
{torrent.name}
|
||||||
|
</span>
|
||||||
|
<span className={`shrink-0 text-xs ${tone.text}`}>{statusLabel(torrent)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Close details"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="general" className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<TabsList className="mx-3 mt-2 w-fit shrink-0">
|
||||||
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
|
<TabsTrigger value="files">Files{torrent.files ? ` (${torrent.files.length})` : ''}</TabsTrigger>
|
||||||
|
<TabsTrigger value="peers">Peers{torrent.peers ? ` (${torrent.peers.length})` : ''}</TabsTrigger>
|
||||||
|
<TabsTrigger value="trackers">
|
||||||
|
Trackers{torrent.trackerStats ? ` (${torrent.trackerStats.length})` : ''}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* mt-0 undoes the Tabs default spacing; these panes own their own scrolling. */}
|
||||||
|
<TabsContent value="general" className="mt-0 min-h-0 flex-1 overflow-auto">
|
||||||
|
<GeneralTab torrent={torrent} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="files" className="mt-0 min-h-0 flex-1">
|
||||||
|
<FilesTab torrent={torrent} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="peers" className="mt-0 min-h-0 flex-1">
|
||||||
|
<PeersTab torrent={torrent} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="trackers" className="mt-0 min-h-0 flex-1">
|
||||||
|
<TrackersTab torrent={torrent} onEdit={() => setEditingTrackers(true)} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<TrackersDialog open={editingTrackers} onOpenChange={setEditingTrackers} torrent={torrent} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import type { MouseEvent } from 'react';
|
||||||
|
import type { Torrent } from './shared';
|
||||||
|
import type { ColumnDef, ColumnKey } from './columns';
|
||||||
|
import type { SortKey } from './useTorrentFilters';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { ArrowDown, ArrowUp, Check } from 'lucide-react';
|
||||||
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||||
|
import { ALL_COLUMNS, PINNED_COLUMN } from './columns';
|
||||||
|
|
||||||
|
// The torrent list. Virtualised because the row count is unbounded and the whole thing re-renders on a
|
||||||
|
// 5-second poll — 43 rows is fine, 4000 is not, and a torrent client accumulates.
|
||||||
|
//
|
||||||
|
// Layout is a fixed-width grid inside a single horizontal scroller shared by the header and the body, so
|
||||||
|
// the two cannot drift apart the way two independently-scrolled elements do.
|
||||||
|
|
||||||
|
const ROW_HEIGHT = 30;
|
||||||
|
|
||||||
|
type TorrentTableProps = {
|
||||||
|
torrents: Torrent[];
|
||||||
|
columns: ColumnDef[];
|
||||||
|
/** Multi-selection, for the bulk actions in the toolbar and the row context menu. */
|
||||||
|
selection: Set<number>;
|
||||||
|
/** The one torrent whose detail pane is open (?selected=) — a different thing from being in `selection`. */
|
||||||
|
focused: number | null;
|
||||||
|
onRowClick: (torrent: Torrent, ev: MouseEvent) => void;
|
||||||
|
sort: SortKey;
|
||||||
|
desc: boolean;
|
||||||
|
onSort: (key: SortKey) => void;
|
||||||
|
visibleKeys: ColumnKey[];
|
||||||
|
onToggleColumn: (key: ColumnKey) => void;
|
||||||
|
onResetColumns: () => void;
|
||||||
|
/** Right-clicking a row selects it if it isn't already, then opens the actions menu. */
|
||||||
|
onRowContextMenu: (torrent: Torrent) => void;
|
||||||
|
rowMenu: (torrent: Torrent) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TorrentTable = ({
|
||||||
|
torrents,
|
||||||
|
columns,
|
||||||
|
selection,
|
||||||
|
focused,
|
||||||
|
onRowClick,
|
||||||
|
sort,
|
||||||
|
desc,
|
||||||
|
onSort,
|
||||||
|
visibleKeys,
|
||||||
|
onToggleColumn,
|
||||||
|
onResetColumns,
|
||||||
|
onRowContextMenu,
|
||||||
|
rowMenu,
|
||||||
|
}: TorrentTableProps) => {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: torrents.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => ROW_HEIGHT,
|
||||||
|
overscan: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridTemplate = columns.map((c) => `${c.width}px`).join(' ');
|
||||||
|
const totalWidth = columns.reduce((sum, c) => sum + c.width, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={scrollRef} className="h-full overflow-auto">
|
||||||
|
{/* min-w on an inline-block wrapper: the header must be as wide as the widest row so it keeps
|
||||||
|
scrolling with the body rather than clipping at the viewport edge. */}
|
||||||
|
<div style={{ minWidth: totalWidth }}>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
className="sticky top-0 z-10 grid border-b border-border bg-background/95 backdrop-blur"
|
||||||
|
style={{ gridTemplateColumns: gridTemplate }}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<button
|
||||||
|
key={col.key}
|
||||||
|
type="button"
|
||||||
|
disabled={!col.sortKey}
|
||||||
|
onClick={() => col.sortKey && onSort(col.sortKey)}
|
||||||
|
className={`flex items-center gap-1 overflow-hidden px-2 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground ${
|
||||||
|
col.sortKey ? 'hover:text-foreground' : 'cursor-default'
|
||||||
|
} ${col.align === 'right' ? 'justify-end' : col.align === 'center' ? 'justify-center' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{col.label}</span>
|
||||||
|
{col.sortKey === sort &&
|
||||||
|
(desc ? <ArrowDown className="h-3 w-3 shrink-0" /> : <ArrowUp className="h-3 w-3 shrink-0" />)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="max-h-[70vh] w-56 overflow-y-auto">
|
||||||
|
{ALL_COLUMNS.map((col) => {
|
||||||
|
const shown = visibleKeys.includes(col.key);
|
||||||
|
const pinned = col.key === PINNED_COLUMN;
|
||||||
|
return (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={col.key}
|
||||||
|
disabled={pinned}
|
||||||
|
onSelect={(ev) => {
|
||||||
|
// Keep the menu open — picking columns is a several-clicks job and reopening it each
|
||||||
|
// time, at the same mouse position, is a small torture.
|
||||||
|
ev.preventDefault();
|
||||||
|
onToggleColumn(col.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={`mr-2 h-3.5 w-3.5 ${shown ? '' : 'opacity-0'}`} />
|
||||||
|
{col.label}
|
||||||
|
</ContextMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ContextMenuItem onSelect={onResetColumns}>Reset to defaults</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
|
<div className="relative" style={{ height: virtualizer.getTotalSize() }}>
|
||||||
|
{virtualizer.getVirtualItems().map((row) => {
|
||||||
|
const torrent = torrents[row.index];
|
||||||
|
if (!torrent) return null;
|
||||||
|
const isSelected = selection.has(torrent.id);
|
||||||
|
const isFocused = focused === torrent.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu key={torrent.id}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
onClick={(ev) => onRowClick(torrent, ev)}
|
||||||
|
onContextMenu={() => onRowContextMenu(torrent)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: row.size,
|
||||||
|
transform: `translateY(${row.start}px)`,
|
||||||
|
gridTemplateColumns: gridTemplate,
|
||||||
|
}}
|
||||||
|
className={`grid cursor-default items-center border-b border-border/40 text-xs ${
|
||||||
|
isSelected ? 'bg-primary/10' : 'hover:bg-muted/50'
|
||||||
|
} ${isFocused ? 'ring-1 ring-inset ring-primary/40' : ''}`}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className={`min-w-0 overflow-hidden whitespace-nowrap px-2 ${
|
||||||
|
col.align === 'right'
|
||||||
|
? 'text-right tabular-nums'
|
||||||
|
: col.align === 'center'
|
||||||
|
? 'text-center'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{col.render(torrent)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-56">{rowMenu(torrent)}</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import type { Torrent, TorrentAction } from './shared';
|
||||||
|
import {
|
||||||
|
ChevronsDown,
|
||||||
|
ChevronsUp,
|
||||||
|
CircleCheckBig,
|
||||||
|
FolderInput,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Settings2,
|
||||||
|
Rabbit,
|
||||||
|
Tag,
|
||||||
|
Trash2,
|
||||||
|
Turtle,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
// Actions above the list. Everything acts on the current selection except Add and the turtle toggle; with
|
||||||
|
// nothing selected the selection-scoped buttons are disabled rather than silently acting on all torrents,
|
||||||
|
// which is the single most destructive mistake a torrent client UI can make.
|
||||||
|
|
||||||
|
type TorrentToolbarProps = {
|
||||||
|
selected: Torrent[];
|
||||||
|
search: string;
|
||||||
|
onSearch: (value: string) => void;
|
||||||
|
onAct: (action: TorrentAction) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onMove: () => void;
|
||||||
|
onLabels: () => void;
|
||||||
|
onOptions: () => void;
|
||||||
|
/** Session alt-speed ("turtle mode"), which is global rather than per-torrent. */
|
||||||
|
altSpeed: boolean;
|
||||||
|
onToggleAltSpeed: () => void;
|
||||||
|
total: number;
|
||||||
|
shown: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TorrentToolbar = ({
|
||||||
|
selected,
|
||||||
|
search,
|
||||||
|
onSearch,
|
||||||
|
onAct,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
onMove,
|
||||||
|
onLabels,
|
||||||
|
onOptions,
|
||||||
|
altSpeed,
|
||||||
|
onToggleAltSpeed,
|
||||||
|
total,
|
||||||
|
shown,
|
||||||
|
}: TorrentToolbarProps) => {
|
||||||
|
const none = selected.length === 0;
|
||||||
|
// "Start" and "Pause" are offered by what the selection can actually do, so a paused selection doesn't
|
||||||
|
// show a live Pause button that would be a no-op.
|
||||||
|
const anyStopped = selected.some((t) => t.status === 0);
|
||||||
|
const anyRunning = selected.some((t) => t.status !== 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-1 border-b border-border px-3 py-2">
|
||||||
|
<Button size="sm" onClick={onAdd}>
|
||||||
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<IconButton label="Start" icon={Play} disabled={none || !anyStopped} onClick={() => onAct('start')} />
|
||||||
|
<IconButton label="Pause" icon={Pause} disabled={none || !anyRunning} onClick={() => onAct('stop')} />
|
||||||
|
<IconButton label="Verify local data" icon={CircleCheckBig} disabled={none} onClick={() => onAct('verify')} />
|
||||||
|
<IconButton
|
||||||
|
label="Ask tracker for more peers"
|
||||||
|
icon={RefreshCw}
|
||||||
|
disabled={none}
|
||||||
|
onClick={() => onAct('reannounce')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<IconButton label="Move up the queue" icon={ChevronsUp} disabled={none} onClick={() => onAct('queue-up')} />
|
||||||
|
<IconButton label="Move down the queue" icon={ChevronsDown} disabled={none} onClick={() => onAct('queue-down')} />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<IconButton label="Set location" icon={FolderInput} disabled={none} onClick={onMove} />
|
||||||
|
<IconButton label="Labels" icon={Tag} disabled={none} onClick={onLabels} />
|
||||||
|
<IconButton label="Torrent options" icon={Settings2} disabled={none} onClick={onOptions} />
|
||||||
|
<IconButton label="Remove" icon={Trash2} disabled={none} onClick={onRemove} destructive />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="sm" variant="ghost" className="text-xs">
|
||||||
|
More
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem disabled={none} onSelect={() => onAct('start-now')}>
|
||||||
|
Start now (skip the queue)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem disabled={none} onSelect={() => onAct('queue-top')}>
|
||||||
|
Move to the top of the queue
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled={none} onSelect={() => onAct('queue-bottom')}>
|
||||||
|
Move to the bottom of the queue
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
label={altSpeed ? 'Alternative speed limits are on' : 'Turn on alternative speed limits'}
|
||||||
|
icon={altSpeed ? Turtle : Rabbit}
|
||||||
|
onClick={onToggleAltSpeed}
|
||||||
|
active={altSpeed}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">{selected.length} selected</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{shown === total ? `${total}` : `${shown} / ${total}`}
|
||||||
|
</span>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(ev) => onSearch(ev.target.value)}
|
||||||
|
placeholder="Filter by name or label"
|
||||||
|
className="h-8 w-56 pl-7 pr-7 text-xs"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSearch('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Clear filter"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Separator = () => <span className="mx-1 h-5 w-px bg-border" />;
|
||||||
|
|
||||||
|
type IconButtonProps = {
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
destructive?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IconButton = ({ label, icon: Icon, onClick, disabled, destructive, active }: IconButtonProps) => (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={label}
|
||||||
|
className={`h-8 w-8 p-0 ${destructive ? 'hover:text-destructive' : ''} ${active ? 'bg-muted text-primary' : ''}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type { MouseEvent } from 'react';
|
||||||
|
import type { Torrent, TorrentAction } from './shared';
|
||||||
|
import type { ColumnKey } from './columns';
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Loader2, PackageOpen } from 'lucide-react';
|
||||||
|
import { useLocalStorageState } from 'hooks';
|
||||||
|
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
||||||
|
import { COLUMN_BY_KEY, DEFAULT_VISIBLE_COLUMNS, PINNED_COLUMN } from './columns';
|
||||||
|
import { buildFacets, selectTorrents, useTorrentFilters } from './useTorrentFilters';
|
||||||
|
import { useSession, useTorrentMutations, useTorrents } from './useTransmissionData';
|
||||||
|
import { TorrentToolbar } from './TorrentToolbar';
|
||||||
|
import { TorrentTable } from './TorrentTable';
|
||||||
|
import { TorrentDetail } from './TorrentDetail';
|
||||||
|
import { AddTorrentDialog } from './dialogs/AddTorrentDialog';
|
||||||
|
import { RemoveTorrentDialog } from './dialogs/RemoveTorrentDialog';
|
||||||
|
import { MoveTorrentDialog } from './dialogs/MoveTorrentDialog';
|
||||||
|
import { LabelsDialog } from './dialogs/LabelsDialog';
|
||||||
|
import { TorrentOptionsDialog } from './dialogs/TorrentOptionsDialog';
|
||||||
|
|
||||||
|
// The torrents section: toolbar, list, and the detail pane for ?selected=.
|
||||||
|
//
|
||||||
|
// Two kinds of selection live here and they are deliberately different things:
|
||||||
|
// • the multi-selection (local state) is what the toolbar and the context menu act on. It is ephemeral —
|
||||||
|
// a checkbox state, not an address, and putting forty ids in the query string would be absurd.
|
||||||
|
// • ?selected= is the one torrent whose detail pane is open. That IS an address: it survives a reload
|
||||||
|
// and can be sent to yourself.
|
||||||
|
|
||||||
|
const COLUMNS_STORAGE_KEY = 'transmission:columns';
|
||||||
|
/** How much of the pane the detail takes when open. */
|
||||||
|
const DETAIL_HEIGHT = '45%';
|
||||||
|
|
||||||
|
export const TorrentsView = () => {
|
||||||
|
const { torrents, isLoading, error } = useTorrents();
|
||||||
|
const { session, save } = useSession();
|
||||||
|
const { act } = useTorrentMutations();
|
||||||
|
const { filters, setSearch, setSort, selected, setSelected } = useTorrentFilters();
|
||||||
|
|
||||||
|
const [visibleKeys, setVisibleKeys] = useLocalStorageState<ColumnKey[]>(COLUMNS_STORAGE_KEY, DEFAULT_VISIBLE_COLUMNS);
|
||||||
|
const [selection, setSelection] = useState<Set<number>>(() => new Set());
|
||||||
|
const anchorRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [removing, setRemoving] = useState(false);
|
||||||
|
const [moving, setMoving] = useState(false);
|
||||||
|
const [labelling, setLabelling] = useState(false);
|
||||||
|
const [optioning, setOptioning] = useState(false);
|
||||||
|
|
||||||
|
const rows = useMemo(() => selectTorrents(torrents, filters), [torrents, filters]);
|
||||||
|
const facets = useMemo(() => buildFacets(torrents), [torrents]);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() =>
|
||||||
|
// Ordered by the catalogue, not by the stored array — a saved order from an older column set would
|
||||||
|
// otherwise silently drop the columns added since.
|
||||||
|
[...visibleKeys].map((key) => COLUMN_BY_KEY.get(key)).filter((c): c is NonNullable<typeof c> => !!c),
|
||||||
|
[visibleKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
const byId = useMemo(() => new Map(torrents.map((t) => [t.id, t])), [torrents]);
|
||||||
|
const selectedTorrents = useMemo(
|
||||||
|
() => [...selection].map((id) => byId.get(id)).filter((t): t is Torrent => !!t),
|
||||||
|
[selection, byId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRowClick = useCallback(
|
||||||
|
(torrent: Torrent, ev: MouseEvent) => {
|
||||||
|
const index = rows.findIndex((r) => r.id === torrent.id);
|
||||||
|
|
||||||
|
if (ev.shiftKey && anchorRef.current != null) {
|
||||||
|
const anchorIndex = rows.findIndex((r) => r.id === anchorRef.current);
|
||||||
|
if (anchorIndex >= 0) {
|
||||||
|
const [from, to] = anchorIndex < index ? [anchorIndex, index] : [index, anchorIndex];
|
||||||
|
setSelection(new Set(rows.slice(from, to + 1).map((r) => r.id)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.ctrlKey || ev.metaKey) {
|
||||||
|
setSelection((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(torrent.id)) next.delete(torrent.id);
|
||||||
|
else next.add(torrent.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
anchorRef.current = torrent.id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelection(new Set([torrent.id]));
|
||||||
|
anchorRef.current = torrent.id;
|
||||||
|
// A plain click both selects and opens — the detail pane is the point of clicking a row.
|
||||||
|
setSelected(torrent.id);
|
||||||
|
},
|
||||||
|
[rows, setSelected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRowContextMenu = useCallback((torrent: Torrent) => {
|
||||||
|
// Right-clicking outside the selection replaces it. Right-clicking inside keeps it, so a menu action on
|
||||||
|
// a 20-row selection doesn't silently collapse to the one row under the cursor.
|
||||||
|
setSelection((prev) => (prev.has(torrent.id) ? prev : new Set([torrent.id])));
|
||||||
|
anchorRef.current = torrent.id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runAction = useCallback(
|
||||||
|
(action: TorrentAction) => {
|
||||||
|
if (selection.size === 0) return;
|
||||||
|
act.mutate({ ids: [...selection], action });
|
||||||
|
},
|
||||||
|
[act, selection],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleColumn = useCallback(
|
||||||
|
(key: ColumnKey) => {
|
||||||
|
if (key === PINNED_COLUMN) return;
|
||||||
|
setVisibleKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||||||
|
},
|
||||||
|
[setVisibleKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
const altSpeed = session?.['alt-speed-enabled'] === true;
|
||||||
|
|
||||||
|
if (isLoading && torrents.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading torrents…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
|
||||||
|
<div className="text-sm font-medium">Cannot reach Transmission</div>
|
||||||
|
<p className="max-w-md text-xs text-muted-foreground">
|
||||||
|
The officer-transmission sidecar answered with an error. Check that the daemon is running and that
|
||||||
|
TRANSMISSION_URL points at it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<TorrentToolbar
|
||||||
|
selected={selectedTorrents}
|
||||||
|
search={filters.q}
|
||||||
|
onSearch={setSearch}
|
||||||
|
onAct={runAction}
|
||||||
|
onAdd={() => setAdding(true)}
|
||||||
|
onRemove={() => selection.size > 0 && setRemoving(true)}
|
||||||
|
onMove={() => selection.size > 0 && setMoving(true)}
|
||||||
|
onLabels={() => selection.size > 0 && setLabelling(true)}
|
||||||
|
onOptions={() => selection.size > 0 && setOptioning(true)}
|
||||||
|
altSpeed={altSpeed}
|
||||||
|
onToggleAltSpeed={() => save.mutate({ 'alt-speed-enabled': !altSpeed })}
|
||||||
|
total={torrents.length}
|
||||||
|
shown={rows.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||||
|
<PackageOpen className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-semibold">{filters.isFiltered ? 'Nothing matches' : 'No torrents'}</div>
|
||||||
|
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||||
|
{filters.isFiltered
|
||||||
|
? 'Clear the filters in the sidebar to see everything again.'
|
||||||
|
: 'Add a magnet link or a .torrent file to get started.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TorrentTable
|
||||||
|
torrents={rows}
|
||||||
|
columns={columns}
|
||||||
|
selection={selection}
|
||||||
|
focused={selected}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
sort={filters.sort}
|
||||||
|
desc={filters.desc}
|
||||||
|
onSort={(key) => setSort(key, filters.sort, filters.desc)}
|
||||||
|
visibleKeys={visibleKeys}
|
||||||
|
onToggleColumn={toggleColumn}
|
||||||
|
onResetColumns={() => setVisibleKeys(DEFAULT_VISIBLE_COLUMNS)}
|
||||||
|
onRowContextMenu={onRowContextMenu}
|
||||||
|
rowMenu={() => (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem onSelect={() => runAction('start')}>Start</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => runAction('start-now')}>Start now</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => runAction('stop')}>Pause</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onSelect={() => runAction('verify')}>Verify local data</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => runAction('reannounce')}>Ask for more peers</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onSelect={() => runAction('queue-top')}>Move to top of queue</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => runAction('queue-up')}>Move up</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => runAction('queue-down')}>Move down</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => runAction('queue-bottom')}>Move to bottom</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onSelect={() => setMoving(true)}>Set location…</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => setLabelling(true)}>Labels…</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => setOptioning(true)}>Torrent options…</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem className="text-destructive" onSelect={() => setRemoving(true)}>
|
||||||
|
Remove…
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected != null && (
|
||||||
|
<div style={{ height: DETAIL_HEIGHT }} className="min-h-0 shrink-0">
|
||||||
|
<TorrentDetail id={selected} onClose={() => setSelected(null)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddTorrentDialog open={adding} onOpenChange={setAdding} />
|
||||||
|
<RemoveTorrentDialog
|
||||||
|
open={removing}
|
||||||
|
onOpenChange={setRemoving}
|
||||||
|
targets={selectedTorrents}
|
||||||
|
onRemoved={() => {
|
||||||
|
setSelection(new Set());
|
||||||
|
// The detail pane would keep polling a torrent that no longer exists and settle on "that torrent
|
||||||
|
// is gone" — closing it is both correct and less alarming.
|
||||||
|
if (selected != null && selection.has(selected)) setSelected(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MoveTorrentDialog
|
||||||
|
open={moving}
|
||||||
|
onOpenChange={setMoving}
|
||||||
|
targets={selectedTorrents}
|
||||||
|
knownDirs={facets.dirs.map((d) => d.value)}
|
||||||
|
/>
|
||||||
|
<LabelsDialog
|
||||||
|
open={labelling}
|
||||||
|
onOpenChange={setLabelling}
|
||||||
|
targets={selectedTorrents}
|
||||||
|
known={facets.labels.map((l) => l.value)}
|
||||||
|
/>
|
||||||
|
<TorrentOptionsDialog open={optioning} onOpenChange={setOptioning} targets={selectedTorrents} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import type { FacetEntry } from './useTorrentFilters';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { NavLink } from 'react-router';
|
||||||
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowUpFromLine,
|
||||||
|
ChevronRight,
|
||||||
|
CircleAlert,
|
||||||
|
Folder,
|
||||||
|
Gauge,
|
||||||
|
ListChecks,
|
||||||
|
Radio,
|
||||||
|
Settings,
|
||||||
|
Tag,
|
||||||
|
Waves,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { TRANSMISSION_SECTIONS, transmissionSectionPath, type TransmissionSectionId } from './shared';
|
||||||
|
import { formatSpeed } from './format';
|
||||||
|
import { useSessionStats, useTorrents } from './useTransmissionData';
|
||||||
|
import { buildFacets, countByStatus, STATUS_FILTERS, useTorrentFilters } from './useTorrentFilters';
|
||||||
|
|
||||||
|
// Left panel of /transmission: live throughput on top, the three sections, then the facet filters.
|
||||||
|
//
|
||||||
|
// Sections are real links (cmd-click, back button, reload) with active state from react-router's NavLink.
|
||||||
|
// Filters are buttons because they write to the query string of the page you are already on — they are
|
||||||
|
// still addressable, just not a different route.
|
||||||
|
//
|
||||||
|
// The facet lists are derived from the torrents already in the cache, not fetched separately: Transmission
|
||||||
|
// has no "list my labels" RPC, and asking for one would mean a second poll returning the same data.
|
||||||
|
|
||||||
|
const ICONS: Record<TransmissionSectionId, LucideIcon> = {
|
||||||
|
torrents: ListChecks,
|
||||||
|
stats: Gauge,
|
||||||
|
settings: Settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||||
|
|
||||||
|
export const TransmissionNav = () => {
|
||||||
|
const { torrents } = useTorrents();
|
||||||
|
const { stats } = useSessionStats();
|
||||||
|
const { filters, setStatus, toggle, clearFilters } = useTorrentFilters();
|
||||||
|
|
||||||
|
const facets = buildFacets(torrents);
|
||||||
|
const counts = countByStatus(torrents);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-4">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-amber-500/15 text-amber-500 ring-1 ring-black/5">
|
||||||
|
<Waves className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold leading-tight">Transmission</div>
|
||||||
|
<div className="flex items-center gap-2.5 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1 tabular-nums">
|
||||||
|
<ArrowDownToLine className="h-3 w-3 text-blue-500" />
|
||||||
|
{formatSpeed(stats?.downloadSpeed) || '0'}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 tabular-nums">
|
||||||
|
<ArrowUpFromLine className="h-3 w-3 text-emerald-500" />
|
||||||
|
{formatSpeed(stats?.uploadSpeed) || '0'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||||
|
{TRANSMISSION_SECTIONS.map(({ id, label }) => {
|
||||||
|
const Icon = ICONS[id];
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={id}
|
||||||
|
to={transmissionSectionPath(id)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${ROW} ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary/10 font-medium text-primary'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||||
|
)}
|
||||||
|
<Icon
|
||||||
|
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{label}</span>
|
||||||
|
{id === 'torrents' && torrents.length > 0 && (
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">{torrents.length}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-5 pb-1 pt-1">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Filter</span>
|
||||||
|
{filters.isFiltered && (
|
||||||
|
<button type="button" onClick={clearFilters} className="text-[10px] font-medium text-primary hover:underline">
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5 px-2 pb-2">
|
||||||
|
{STATUS_FILTERS.map(({ id, label }) => (
|
||||||
|
<FilterRow
|
||||||
|
key={id}
|
||||||
|
label={label}
|
||||||
|
count={counts[id]}
|
||||||
|
active={filters.status === id}
|
||||||
|
onClick={() => setStatus(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{facets.errors > 0 && (
|
||||||
|
<FilterRow
|
||||||
|
icon={CircleAlert}
|
||||||
|
label="Tracker errors"
|
||||||
|
count={facets.errors}
|
||||||
|
active={filters.errorOnly}
|
||||||
|
tone="text-amber-500"
|
||||||
|
onClick={() => toggle('error', '1', filters.errorOnly ? '1' : null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FacetGroup
|
||||||
|
title="Labels"
|
||||||
|
icon={Tag}
|
||||||
|
entries={facets.labels}
|
||||||
|
selected={filters.label}
|
||||||
|
onPick={(v) => toggle('label', v, filters.label)}
|
||||||
|
/>
|
||||||
|
<FacetGroup
|
||||||
|
title="Trackers"
|
||||||
|
icon={Radio}
|
||||||
|
entries={facets.trackers}
|
||||||
|
selected={filters.tracker}
|
||||||
|
onPick={(v) => toggle('tracker', v, filters.tracker)}
|
||||||
|
/>
|
||||||
|
<FacetGroup
|
||||||
|
title="Locations"
|
||||||
|
icon={Folder}
|
||||||
|
entries={facets.dirs}
|
||||||
|
selected={filters.downloadDir}
|
||||||
|
onPick={(v) => toggle('dir', v, filters.downloadDir)}
|
||||||
|
// A path is only distinguishable by its tail, and the panel is 22% of the window wide.
|
||||||
|
renderLabel={(v) => v.split('/').filter(Boolean).slice(-1)[0] || v}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-auto px-4 py-3 text-[10px] text-muted-foreground">
|
||||||
|
{stats ? `${stats.activeTorrentCount} active · ${stats.pausedTorrentCount} paused` : 'connecting…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FilterRowProps = {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
tone?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FilterRow = ({ label, count, active, onClick, icon: Icon, tone, title }: FilterRowProps) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={title ?? label}
|
||||||
|
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
|
||||||
|
active ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className={`h-3.5 w-3.5 shrink-0 ${tone ?? ''}`} />}
|
||||||
|
<span className={`min-w-0 flex-1 truncate ${tone ?? ''}`}>{label}</span>
|
||||||
|
<span className="shrink-0 tabular-nums opacity-60">{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
type FacetGroupProps = {
|
||||||
|
title: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
entries: FacetEntry[];
|
||||||
|
selected: string | null;
|
||||||
|
onPick: (value: string) => void;
|
||||||
|
renderLabel?: (value: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible because these lists are open-ended — one tracker per site, one label per whim. Collapsed by
|
||||||
|
* default unless something in the group is selected, so arriving on a shared filtered URL shows you why.
|
||||||
|
*/
|
||||||
|
const FacetGroup = ({ title, icon: Icon, entries, selected, onPick, renderLabel }: FacetGroupProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const expanded = open || selected != null;
|
||||||
|
|
||||||
|
if (!entries.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-2 pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||||
|
<Icon className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="flex-1">{title}</span>
|
||||||
|
<span className="tabular-nums opacity-60">{entries.length}</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-0.5 flex flex-col gap-0.5">
|
||||||
|
{entries.map(({ value, count }) => (
|
||||||
|
<FilterRow
|
||||||
|
key={value}
|
||||||
|
label={renderLabel ? renderLabel(value) : value}
|
||||||
|
title={value}
|
||||||
|
count={count}
|
||||||
|
active={selected === value}
|
||||||
|
onClick={() => onPick(value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useTransmissionSection } from './useTransmissionSection';
|
||||||
|
import { TorrentsView } from './TorrentsView';
|
||||||
|
import { StatsView } from './StatsView';
|
||||||
|
import { SettingsView } from './SettingsView';
|
||||||
|
|
||||||
|
// Right panel of the /transmission workspace — renders the section named by the URL.
|
||||||
|
|
||||||
|
export const TransmissionView = () => {
|
||||||
|
const section = useTransmissionSection();
|
||||||
|
|
||||||
|
switch (section) {
|
||||||
|
case 'stats':
|
||||||
|
return <StatsView />;
|
||||||
|
case 'settings':
|
||||||
|
return <SettingsView />;
|
||||||
|
default:
|
||||||
|
return <TorrentsView />;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ArrowDownToLine, ArrowUpFromLine, Waves } from 'lucide-react';
|
||||||
|
import { TRANSMISSION_SECTIONS } from './shared';
|
||||||
|
import { formatSpeed } from './format';
|
||||||
|
import { useTransmissionSection } from './useTransmissionSection';
|
||||||
|
import { useSession, useSessionStats } from './useTransmissionData';
|
||||||
|
|
||||||
|
// Panel header for the right (transmission-view) panel: which section, which daemon version, and the live
|
||||||
|
// totals — the one number you want visible no matter which section you are looking at.
|
||||||
|
|
||||||
|
export const TransmissionViewHeader = () => {
|
||||||
|
const section = useTransmissionSection();
|
||||||
|
const { stats } = useSessionStats();
|
||||||
|
const { session } = useSession();
|
||||||
|
const label = TRANSMISSION_SECTIONS.find((s) => s.id === section)?.label ?? 'Transmission';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Waves className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="flex-1 truncate text-xs font-medium">
|
||||||
|
{label}
|
||||||
|
{session?.version && <span className="ml-1.5 font-normal text-black/50">· {session.version}</span>}
|
||||||
|
</span>
|
||||||
|
{stats && (
|
||||||
|
<span className="flex shrink-0 items-center gap-2 text-[10px] tabular-nums text-black/60">
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<ArrowDownToLine className="h-3 w-3" />
|
||||||
|
{formatSpeed(stats.downloadSpeed) || '0'}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<ArrowUpFromLine className="h-3 w-3" />
|
||||||
|
{formatSpeed(stats.uploadSpeed) || '0'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { Torrent } from './shared';
|
||||||
|
import type { SortKey } from './useTorrentFilters';
|
||||||
|
import { Lock } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
formatDuration,
|
||||||
|
formatEta,
|
||||||
|
formatPercent,
|
||||||
|
formatRatio,
|
||||||
|
formatSize,
|
||||||
|
formatSpeed,
|
||||||
|
PRIORITY_LABELS,
|
||||||
|
statusLabel,
|
||||||
|
statusTone,
|
||||||
|
TONE_CLASSES,
|
||||||
|
} from './format';
|
||||||
|
|
||||||
|
// Column catalogue for the torrent table. Mirrors the reference client's set, minus the columns that only
|
||||||
|
// exist there because its canvas renderer needed them split (uploadedDownloaded, the separate seeds cell).
|
||||||
|
//
|
||||||
|
// Widths are fixed px rather than fractions: the table scrolls horizontally and is virtualised vertically,
|
||||||
|
// so a row has to be laid out without measuring, and a percentage-width grid inside a horizontal scroller
|
||||||
|
// collapses to the viewport instead of the content.
|
||||||
|
|
||||||
|
export type ColumnKey =
|
||||||
|
| 'name'
|
||||||
|
| 'status'
|
||||||
|
| 'percentDone'
|
||||||
|
| 'totalSize'
|
||||||
|
| 'sizeWhenDone'
|
||||||
|
| 'leftUntilDone'
|
||||||
|
| 'haveValid'
|
||||||
|
| 'downloadedEver'
|
||||||
|
| 'uploadedEver'
|
||||||
|
| 'rateDownload'
|
||||||
|
| 'rateUpload'
|
||||||
|
| 'eta'
|
||||||
|
| 'uploadRatio'
|
||||||
|
| 'peersSendingToUs'
|
||||||
|
| 'peersGettingFromUs'
|
||||||
|
| 'addedDate'
|
||||||
|
| 'doneDate'
|
||||||
|
| 'activityDate'
|
||||||
|
| 'secondsSeeding'
|
||||||
|
| 'downloadDir'
|
||||||
|
| 'officerTrackerHost'
|
||||||
|
| 'officerTrackerStatus'
|
||||||
|
| 'bandwidthPriority'
|
||||||
|
| 'queuePosition'
|
||||||
|
| 'labels'
|
||||||
|
| 'isPrivate'
|
||||||
|
| 'group'
|
||||||
|
| 'file-count'
|
||||||
|
| 'pieceCount'
|
||||||
|
| 'id';
|
||||||
|
|
||||||
|
export type ColumnDef = {
|
||||||
|
key: ColumnKey;
|
||||||
|
label: string;
|
||||||
|
width: number;
|
||||||
|
align?: 'right' | 'center';
|
||||||
|
/** Absent when the column has no meaningful order (nothing here, currently — kept for honesty). */
|
||||||
|
sortKey?: SortKey;
|
||||||
|
render: (t: Torrent) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const num = (v: number | undefined) => (v == null ? '—' : v.toLocaleString());
|
||||||
|
/** A count of zero is noise in a dense table — the absence of peers is already visible as a blank. */
|
||||||
|
const positive = (v: number | undefined) => (v ? v.toLocaleString() : '');
|
||||||
|
|
||||||
|
const ProgressCell = ({ torrent }: { torrent: Torrent }) => {
|
||||||
|
const tone = TONE_CLASSES[statusTone(torrent)];
|
||||||
|
// Verifying reports its own progress in recheckProgress; percentDone stays where it was, so a recheck of
|
||||||
|
// a complete torrent would otherwise render a full bar while the daemon is 3% into the hash check.
|
||||||
|
const isChecking = torrent.recheckProgress != null && torrent.recheckProgress > 0 && torrent.percentDone < 1;
|
||||||
|
const fraction = isChecking ? torrent.recheckProgress! : torrent.percentDone;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className={`h-full rounded-full ${tone.dot}`} style={{ width: `${Math.min(100, fraction * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-11 shrink-0 text-right tabular-nums text-muted-foreground">{formatPercent(fraction)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_COLUMNS: ColumnDef[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
width: 340,
|
||||||
|
sortKey: 'name',
|
||||||
|
render: (t) => (
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
{t.isPrivate && <Lock className="h-3 w-3 shrink-0 text-amber-500" aria-label="Private torrent" />}
|
||||||
|
<span className="truncate" title={t.name}>
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
width: 130,
|
||||||
|
sortKey: 'status',
|
||||||
|
render: (t) => {
|
||||||
|
const tone = TONE_CLASSES[statusTone(t)];
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 ${tone.text}`} title={t.errorString || undefined}>
|
||||||
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${tone.dot}`} />
|
||||||
|
<span className="truncate">{statusLabel(t)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'percentDone',
|
||||||
|
label: 'Progress',
|
||||||
|
width: 150,
|
||||||
|
sortKey: 'percentDone',
|
||||||
|
render: (t) => <ProgressCell torrent={t} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalSize',
|
||||||
|
label: 'Size',
|
||||||
|
width: 90,
|
||||||
|
align: 'right',
|
||||||
|
sortKey: 'totalSize',
|
||||||
|
render: (t) => formatSize(t.totalSize),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sizeWhenDone',
|
||||||
|
label: 'Size when done',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
render: (t) => formatSize(t.sizeWhenDone),
|
||||||
|
},
|
||||||
|
{ key: 'leftUntilDone', label: 'Remaining', width: 100, align: 'right', render: (t) => formatSize(t.leftUntilDone) },
|
||||||
|
{ key: 'haveValid', label: 'Have', width: 90, align: 'right', render: (t) => formatSize(t.haveValid) },
|
||||||
|
{
|
||||||
|
key: 'downloadedEver',
|
||||||
|
label: 'Downloaded',
|
||||||
|
width: 105,
|
||||||
|
align: 'right',
|
||||||
|
sortKey: 'downloadedEver',
|
||||||
|
render: (t) => formatSize(t.downloadedEver),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'uploadedEver',
|
||||||
|
label: 'Uploaded',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
sortKey: 'uploadedEver',
|
||||||
|
render: (t) => formatSize(t.uploadedEver),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rateDownload',
|
||||||
|
label: 'Down',
|
||||||
|
width: 95,
|
||||||
|
align: 'right',
|
||||||
|
sortKey: 'rateDownload',
|
||||||
|
render: (t) => <span className="text-blue-500">{formatSpeed(t.rateDownload)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rateUpload',
|
||||||
|
label: 'Up',
|
||||||
|
width: 95,
|
||||||
|
align: 'right',
|
||||||
|
sortKey: 'rateUpload',
|
||||||
|
render: (t) => <span className="text-emerald-500">{formatSpeed(t.rateUpload)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'eta',
|
||||||
|
label: 'ETA',
|
||||||
|
width: 85,
|
||||||
|
align: 'right',
|
||||||
|
sortKey: 'eta',
|
||||||
|
// ETA is meaningless once a torrent is done — Transmission keeps returning the sentinel and the column
|
||||||
|
// would read "∞" down the whole seeding half of the list.
|
||||||
|
render: (t) => (t.percentDone >= 1 ? '' : formatEta(t.eta)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'uploadRatio',
|
||||||
|
label: 'Ratio',
|
||||||
|
width: 75,
|
||||||
|
align: 'right',
|
||||||
|
sortKey: 'uploadRatio',
|
||||||
|
render: (t) => formatRatio(t.uploadRatio),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'peersSendingToUs',
|
||||||
|
label: 'Seeds',
|
||||||
|
width: 70,
|
||||||
|
align: 'right',
|
||||||
|
sortKey: 'peersSendingToUs',
|
||||||
|
render: (t) => positive(t.peersSendingToUs),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'peersGettingFromUs',
|
||||||
|
label: 'Peers',
|
||||||
|
width: 70,
|
||||||
|
align: 'right',
|
||||||
|
sortKey: 'peersGettingFromUs',
|
||||||
|
render: (t) => positive(t.peersGettingFromUs),
|
||||||
|
},
|
||||||
|
{ key: 'addedDate', label: 'Added', width: 145, sortKey: 'addedDate', render: (t) => formatDate(t.addedDate) },
|
||||||
|
{ key: 'doneDate', label: 'Completed', width: 145, sortKey: 'doneDate', render: (t) => formatDate(t.doneDate) },
|
||||||
|
{
|
||||||
|
key: 'activityDate',
|
||||||
|
label: 'Last active',
|
||||||
|
width: 145,
|
||||||
|
sortKey: 'activityDate',
|
||||||
|
render: (t) => formatDate(t.activityDate),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'secondsSeeding',
|
||||||
|
label: 'Seeding time',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
sortKey: 'secondsSeeding',
|
||||||
|
render: (t) => formatDuration(t.secondsSeeding),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'downloadDir',
|
||||||
|
label: 'Location',
|
||||||
|
width: 220,
|
||||||
|
sortKey: 'downloadDir',
|
||||||
|
render: (t) => (
|
||||||
|
<span className="truncate" title={t.downloadDir}>
|
||||||
|
{t.downloadDir}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'officerTrackerHost',
|
||||||
|
label: 'Tracker',
|
||||||
|
width: 150,
|
||||||
|
sortKey: 'officerTrackerHost',
|
||||||
|
render: (t) => t.officerTrackerHost || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'officerTrackerStatus',
|
||||||
|
label: 'Tracker status',
|
||||||
|
width: 190,
|
||||||
|
render: (t) => (
|
||||||
|
<span className={t.officerTrackerErrors > 0 ? 'text-amber-500' : undefined} title={t.officerTrackerStatus}>
|
||||||
|
<span className="truncate">{t.officerTrackerStatus || '—'}</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bandwidthPriority',
|
||||||
|
label: 'Priority',
|
||||||
|
width: 85,
|
||||||
|
render: (t) => PRIORITY_LABELS[t.bandwidthPriority] ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'queuePosition',
|
||||||
|
label: 'Queue',
|
||||||
|
width: 70,
|
||||||
|
align: 'right',
|
||||||
|
sortKey: 'queuePosition',
|
||||||
|
render: (t) => num(t.queuePosition),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'labels',
|
||||||
|
label: 'Labels',
|
||||||
|
width: 160,
|
||||||
|
render: (t) =>
|
||||||
|
t.labels.length ? (
|
||||||
|
<span className="flex gap-1 overflow-hidden">
|
||||||
|
{t.labels.map((l) => (
|
||||||
|
<span key={l} className="shrink-0 rounded bg-muted px-1.5 py-px text-[10px] leading-4">
|
||||||
|
{l}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'isPrivate', label: 'Private', width: 70, align: 'center', render: (t) => (t.isPrivate ? 'Yes' : 'No') },
|
||||||
|
{ key: 'group', label: 'Group', width: 100, render: (t) => t.group || '' },
|
||||||
|
{ key: 'file-count', label: 'Files', width: 70, align: 'right', render: (t) => num(t['file-count']) },
|
||||||
|
{ key: 'pieceCount', label: 'Pieces', width: 80, align: 'right', render: (t) => num(t.pieceCount) },
|
||||||
|
{ key: 'id', label: 'ID', width: 60, align: 'right', sortKey: 'id', render: (t) => String(t.id) },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COLUMN_BY_KEY = new Map(ALL_COLUMNS.map((c) => [c.key, c]));
|
||||||
|
|
||||||
|
/** What a fresh install shows. Everything else is one right-click on the header away. */
|
||||||
|
export const DEFAULT_VISIBLE_COLUMNS: ColumnKey[] = [
|
||||||
|
'name',
|
||||||
|
'status',
|
||||||
|
'percentDone',
|
||||||
|
'totalSize',
|
||||||
|
'rateDownload',
|
||||||
|
'rateUpload',
|
||||||
|
'eta',
|
||||||
|
'uploadRatio',
|
||||||
|
'peersSendingToUs',
|
||||||
|
'peersGettingFromUs',
|
||||||
|
'addedDate',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The name column is the only thing identifying a row, so it is not hideable. */
|
||||||
|
export const PINNED_COLUMN: ColumnKey = 'name';
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import type { Torrent } from '../shared';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ChevronRight, File, Folder, Pencil } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { PRIORITY } from '../shared';
|
||||||
|
import { formatPercent, formatSize, PRIORITY_LABELS } from '../format';
|
||||||
|
import { useTorrentMutations } from '../useTransmissionData';
|
||||||
|
import { RenameDialog } from '../dialogs/RenameDialog';
|
||||||
|
|
||||||
|
// Files, as a tree, because a torrent of a TV season is thirty files in three folders and a flat list of
|
||||||
|
// thirty identical-looking paths is unusable.
|
||||||
|
//
|
||||||
|
// Transmission addresses files by their INDEX in the `files` array, not by path — so every node carries the
|
||||||
|
// indices beneath it, and a folder operation is just the same RPC with more indices.
|
||||||
|
|
||||||
|
type FileNode = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
/** Leaf files only; a directory's own index list is the union of its descendants'. */
|
||||||
|
index?: number;
|
||||||
|
children: FileNode[];
|
||||||
|
indices: number[];
|
||||||
|
size: number;
|
||||||
|
completed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildTree(torrent: Torrent): FileNode {
|
||||||
|
const root: FileNode = { key: '', name: '', children: [], indices: [], size: 0, completed: 0 };
|
||||||
|
const files = torrent.files ?? [];
|
||||||
|
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
const segments = file.name.split('/').filter(Boolean);
|
||||||
|
let node = root;
|
||||||
|
segments.forEach((segment, depth) => {
|
||||||
|
const isLeaf = depth === segments.length - 1;
|
||||||
|
const key = segments.slice(0, depth + 1).join('/');
|
||||||
|
let child = node.children.find((c) => c.key === key);
|
||||||
|
if (!child) {
|
||||||
|
child = { key, name: segment, children: [], indices: [], size: 0, completed: 0 };
|
||||||
|
node.children.push(child);
|
||||||
|
}
|
||||||
|
if (isLeaf) child.index = index;
|
||||||
|
node = child;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Roll sizes and indices up in one post-order pass, so a folder row can show its own progress bar.
|
||||||
|
const roll = (node: FileNode): FileNode => {
|
||||||
|
if (node.index != null) {
|
||||||
|
const file = files[node.index]!;
|
||||||
|
node.size = file.length;
|
||||||
|
node.completed = file.bytesCompleted;
|
||||||
|
node.indices = [node.index];
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
node.children.forEach(roll);
|
||||||
|
node.size = node.children.reduce((s, c) => s + c.size, 0);
|
||||||
|
node.completed = node.children.reduce((s, c) => s + c.completed, 0);
|
||||||
|
node.indices = node.children.flatMap((c) => c.indices);
|
||||||
|
// Folders before files, then alphabetical — the ordering every file manager uses.
|
||||||
|
node.children.sort((a, b) => Number(!!a.index) - Number(!!b.index) || a.name.localeCompare(b.name));
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
return roll(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilesTabProps = { torrent: Torrent };
|
||||||
|
|
||||||
|
export const FilesTab = ({ torrent }: FilesTabProps) => {
|
||||||
|
const { set } = useTorrentMutations();
|
||||||
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||||
|
const [renaming, setRenaming] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const tree = useMemo(() => buildTree(torrent), [torrent.files, torrent.id]);
|
||||||
|
const stats = torrent.fileStats ?? [];
|
||||||
|
|
||||||
|
if (!torrent.files?.length) {
|
||||||
|
return (
|
||||||
|
<p className="p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{torrent.metadataPercentComplete < 1
|
||||||
|
? 'Still fetching metadata — the file list appears once the magnet resolves.'
|
||||||
|
: 'This torrent has no files.'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantedCount = stats.filter((s) => s.wanted).length;
|
||||||
|
const wantedBytes = torrent.files.reduce((sum, f, i) => sum + (stats[i]?.wanted === false ? 0 : f.length), 0);
|
||||||
|
|
||||||
|
const setWanted = (indices: number[], wanted: boolean) =>
|
||||||
|
set.mutate({ ids: [torrent.id], [wanted ? 'files-wanted' : 'files-unwanted']: indices });
|
||||||
|
|
||||||
|
const setPriority = (indices: number[], priority: number) => {
|
||||||
|
const field =
|
||||||
|
priority === PRIORITY.High ? 'priority-high' : priority === PRIORITY.Low ? 'priority-low' : 'priority-normal';
|
||||||
|
set.mutate({ ids: [torrent.id], [field]: indices });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCollapsed = (key: string) =>
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderNode = (node: FileNode, depth: number): React.ReactNode[] => {
|
||||||
|
const isDir = node.index == null;
|
||||||
|
const isCollapsed = collapsed.has(node.key);
|
||||||
|
const nodeStats = node.indices.map((i) => stats[i]).filter(Boolean);
|
||||||
|
const allWanted = nodeStats.length > 0 && nodeStats.every((s) => s!.wanted);
|
||||||
|
const someWanted = nodeStats.some((s) => s!.wanted);
|
||||||
|
const priorities = new Set(nodeStats.map((s) => s!.priority));
|
||||||
|
const priority = priorities.size === 1 ? [...priorities][0]! : null;
|
||||||
|
const fraction = node.size > 0 ? node.completed / node.size : 0;
|
||||||
|
|
||||||
|
const row = (
|
||||||
|
<div
|
||||||
|
key={node.key}
|
||||||
|
className="grid grid-cols-[minmax(0,1fr)_90px_120px_90px_28px] items-center gap-2 border-b border-border/40 px-3 py-1.5 text-xs hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5" style={{ paddingLeft: depth * 14 }}>
|
||||||
|
{isDir ? (
|
||||||
|
<button type="button" onClick={() => toggleCollapsed(node.key)} className="shrink-0">
|
||||||
|
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isCollapsed ? '' : 'rotate-90'}`} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<Checkbox
|
||||||
|
checked={allWanted ? true : someWanted ? 'indeterminate' : false}
|
||||||
|
onCheckedChange={() => setWanted(node.indices, !allWanted)}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
{isDir ? (
|
||||||
|
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<File className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className={`truncate ${someWanted ? '' : 'text-muted-foreground line-through'}`} title={node.key}>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-right tabular-nums text-muted-foreground">{formatSize(node.size)}</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="h-full rounded-full bg-blue-500" style={{ width: `${fraction * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-10 shrink-0 text-right tabular-nums text-muted-foreground">{formatPercent(fraction)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button type="button" className="text-left text-muted-foreground hover:text-foreground">
|
||||||
|
{priority == null ? 'mixed' : PRIORITY_LABELS[priority]}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.High)}>High</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.Normal)}>Normal</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.Low)}>Low</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRenaming(node.key)}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={`Rename ${node.name}`}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDir && !isCollapsed) {
|
||||||
|
return [row, ...node.children.flatMap((child) => renderNode(child, depth + 1))];
|
||||||
|
}
|
||||||
|
return [row];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center gap-3 border-b border-border px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{wantedCount} of {torrent.files.length} files · {formatSize(wantedBytes)} wanted
|
||||||
|
</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
setWanted(
|
||||||
|
torrent.files!.map((_, i) => i),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Select all
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="sm" variant="ghost">
|
||||||
|
Set all priorities
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{[PRIORITY.High, PRIORITY.Normal, PRIORITY.Low].map((p) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={p}
|
||||||
|
onSelect={() =>
|
||||||
|
setPriority(
|
||||||
|
torrent.files!.map((_, i) => i),
|
||||||
|
p,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{PRIORITY_LABELS[p]}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">{tree.children.flatMap((child) => renderNode(child, 0))}</div>
|
||||||
|
|
||||||
|
<RenameDialog
|
||||||
|
open={renaming != null}
|
||||||
|
onOpenChange={(open) => !open && setRenaming(null)}
|
||||||
|
torrent={torrent}
|
||||||
|
path={renaming ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { Torrent } from '../shared';
|
||||||
|
import { Check, Copy } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
formatDuration,
|
||||||
|
formatEta,
|
||||||
|
formatPercent,
|
||||||
|
formatRatio,
|
||||||
|
formatSize,
|
||||||
|
formatSpeed,
|
||||||
|
PRIORITY_LABELS,
|
||||||
|
statusLabel,
|
||||||
|
} from '../format';
|
||||||
|
|
||||||
|
// The "what is this torrent" tab: transfer numbers, then the immutable facts from the metainfo.
|
||||||
|
|
||||||
|
type GeneralTabProps = { torrent: Torrent };
|
||||||
|
|
||||||
|
export const GeneralTab = ({ torrent: t }: GeneralTabProps) => (
|
||||||
|
<div className="space-y-5 p-4">
|
||||||
|
{t.error !== 0 && t.errorString && (
|
||||||
|
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">{t.errorString}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Transfer">
|
||||||
|
<Field label="Status" value={statusLabel(t)} />
|
||||||
|
<Field label="Progress" value={formatPercent(t.percentDone)} />
|
||||||
|
<Field label="Downloaded" value={formatSize(t.downloadedEver)} />
|
||||||
|
<Field label="Uploaded" value={formatSize(t.uploadedEver)} />
|
||||||
|
<Field label="Ratio" value={formatRatio(t.uploadRatio)} />
|
||||||
|
<Field label="Remaining" value={formatSize(t.leftUntilDone)} />
|
||||||
|
<Field label="Down speed" value={formatSpeed(t.rateDownload) || '—'} />
|
||||||
|
<Field label="Up speed" value={formatSpeed(t.rateUpload) || '—'} />
|
||||||
|
<Field label="ETA" value={t.percentDone >= 1 ? '—' : formatEta(t.eta)} />
|
||||||
|
<Field label="Seeds / peers" value={`${t.peersSendingToUs} / ${t.peersGettingFromUs}`} />
|
||||||
|
<Field label="Seeding time" value={formatDuration(t.secondsSeeding)} />
|
||||||
|
{/* Corrupt bytes are re-downloaded silently, so this is the only place a failing disk or a bad peer
|
||||||
|
shows up before the ratio quietly goes wrong. */}
|
||||||
|
<Field label="Corrupt" value={t.corruptEver ? formatSize(t.corruptEver) : '—'} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Dates">
|
||||||
|
<Field label="Added" value={formatDate(t.addedDate)} />
|
||||||
|
<Field label="Completed" value={formatDate(t.doneDate)} />
|
||||||
|
<Field label="Last active" value={formatDate(t.activityDate)} />
|
||||||
|
<Field label="Created" value={formatDate(t.dateCreated)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Torrent">
|
||||||
|
<Field label="Total size" value={formatSize(t.totalSize)} />
|
||||||
|
<Field label="Files" value={String(t['file-count'] ?? '—')} />
|
||||||
|
<Field label="Pieces" value={`${t.pieceCount} × ${formatSize(t.pieceSize)}`} />
|
||||||
|
<Field label="Privacy" value={t.isPrivate ? 'Private torrent' : 'Public torrent'} />
|
||||||
|
<Field label="Priority" value={PRIORITY_LABELS[t.bandwidthPriority] ?? '—'} />
|
||||||
|
<Field label="Queue position" value={String(t.queuePosition)} />
|
||||||
|
<Field label="Creator" value={t.creator || '—'} />
|
||||||
|
<Field label="Peer limit" value={String(t['peer-limit'] ?? '—')} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Location" columns={1}>
|
||||||
|
<Field label="Download directory" value={t.downloadDir} mono />
|
||||||
|
<Field label="Hash" value={t.hashString ?? '—'} mono copy={t.hashString} />
|
||||||
|
<Field label="Magnet" value={t.magnetLink || '—'} mono truncate copy={t.magnetLink} />
|
||||||
|
{t.labels.length > 0 && <Field label="Labels" value={t.labels.join(', ')} />}
|
||||||
|
{t.comment && <Field label="Comment" value={t.comment} />}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{t.peersFrom && (
|
||||||
|
<Section title="Peer sources">
|
||||||
|
<Field label="Tracker" value={String(t.peersFrom.fromTracker)} />
|
||||||
|
<Field label="DHT" value={String(t.peersFrom.fromDht)} />
|
||||||
|
<Field label="PEX" value={String(t.peersFrom.fromPex)} />
|
||||||
|
<Field label="Incoming" value={String(t.peersFrom.fromIncoming)} />
|
||||||
|
<Field label="Local discovery" value={String(t.peersFrom.fromLpd)} />
|
||||||
|
<Field label="Cache" value={String(t.peersFrom.fromCache)} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Section = ({ title, children, columns = 2 }: { title: string; children: ReactNode; columns?: number }) => (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||||
|
<dl className={`grid gap-x-6 gap-y-1.5 ${columns === 1 ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}`}>
|
||||||
|
{children}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
type FieldProps = { label: string; value: string; mono?: boolean; truncate?: boolean; copy?: string };
|
||||||
|
|
||||||
|
const Field = ({ label, value, mono, truncate, copy }: FieldProps) => (
|
||||||
|
<div className="flex min-w-0 items-baseline gap-3 text-xs">
|
||||||
|
<dt className="w-32 shrink-0 text-muted-foreground">{label}</dt>
|
||||||
|
<dd className={`min-w-0 flex-1 ${mono ? 'font-mono' : ''} ${truncate ? 'truncate' : 'break-words'}`} title={value}>
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
{copy && <CopyButton value={copy} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CopyButton = ({ value }: { value: string }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
// navigator.clipboard needs a secure context; Officer is always behind HTTPS, but a failure here
|
||||||
|
// should be silent rather than an unhandled rejection in the console.
|
||||||
|
void navigator.clipboard?.writeText(value).then(
|
||||||
|
() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1200);
|
||||||
|
},
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
aria-label="Copy"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Torrent } from '../shared';
|
||||||
|
import { Lock } from 'lucide-react';
|
||||||
|
import { describePeerFlags, formatPercent, formatSpeed } from '../format';
|
||||||
|
|
||||||
|
// Connected peers. Not sortable and not virtualised on purpose: the list is capped by the torrent's peer
|
||||||
|
// limit (50 by default), it churns every poll, and a sort you have to re-establish every five seconds is
|
||||||
|
// worse than no sort.
|
||||||
|
|
||||||
|
type PeersTabProps = { torrent: Torrent };
|
||||||
|
|
||||||
|
const HEAD = 'px-3 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||||
|
|
||||||
|
export const PeersTab = ({ torrent }: PeersTabProps) => {
|
||||||
|
const peers = torrent.peers ?? [];
|
||||||
|
|
||||||
|
if (!peers.length) {
|
||||||
|
return <p className="p-6 text-center text-sm text-muted-foreground">No peers connected.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<table className="w-full border-collapse text-xs">
|
||||||
|
<thead className="sticky top-0 bg-background/95 backdrop-blur">
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className={HEAD}>Address</th>
|
||||||
|
<th className={HEAD}>Client</th>
|
||||||
|
<th className={`${HEAD} text-right`}>Progress</th>
|
||||||
|
<th className={`${HEAD} text-right`}>Down</th>
|
||||||
|
<th className={`${HEAD} text-right`}>Up</th>
|
||||||
|
<th className={HEAD}>Flags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{peers.map((peer) => (
|
||||||
|
<tr key={`${peer.address}:${peer.port}`} className="border-b border-border/40 hover:bg-muted/40">
|
||||||
|
<td className="px-3 py-1.5 font-mono">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{peer.isEncrypted && <Lock className="h-3 w-3 shrink-0 text-emerald-500" aria-label="Encrypted" />}
|
||||||
|
{peer.address}:{peer.port}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[200px] truncate px-3 py-1.5" title={peer.clientName}>
|
||||||
|
{peer.clientName || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">{formatPercent(peer.progress)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums text-blue-500">{formatSpeed(peer.rateToClient)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums text-emerald-500">{formatSpeed(peer.rateToPeer)}</td>
|
||||||
|
<td className="px-3 py-1.5 font-mono text-muted-foreground" title={describePeerFlags(peer.flagStr)}>
|
||||||
|
{peer.flagStr}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Torrent } from '../shared';
|
||||||
|
import { CircleAlert, CircleCheck, Pencil, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { formatDate } from '../format';
|
||||||
|
import { useTorrentMutations } from '../useTransmissionData';
|
||||||
|
|
||||||
|
// One card per tracker, grouped by tier. The point of this tab is diagnosing a torrent that isn't finding
|
||||||
|
// peers, so the failure text gets as much room as the success numbers.
|
||||||
|
|
||||||
|
type TrackersTabProps = { torrent: Torrent; onEdit: () => void };
|
||||||
|
|
||||||
|
export const TrackersTab = ({ torrent, onEdit }: TrackersTabProps) => {
|
||||||
|
const { act } = useTorrentMutations();
|
||||||
|
const trackers = torrent.trackerStats ?? [];
|
||||||
|
|
||||||
|
const tiers = new Map<number, typeof trackers>();
|
||||||
|
for (const tracker of trackers) {
|
||||||
|
tiers.set(tracker.tier, [...(tiers.get(tracker.tier) ?? []), tracker]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
|
<span className="flex-1 text-xs text-muted-foreground">
|
||||||
|
{trackers.length} tracker{trackers.length === 1 ? '' : 's'} in {tiers.size} tier
|
||||||
|
{tiers.size === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => act.mutate({ ids: [torrent.id], action: 'reannounce' })}>
|
||||||
|
<RefreshCw className="mr-1.5 h-3 w-3" />
|
||||||
|
Reannounce
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onEdit}>
|
||||||
|
<Pencil className="mr-1.5 h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trackers.length === 0 && (
|
||||||
|
<p className="p-6 text-center text-sm text-muted-foreground">
|
||||||
|
No trackers. This torrent relies on DHT and peer exchange.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4 p-3">
|
||||||
|
{[...tiers.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([tier, list]) => (
|
||||||
|
<section key={tier}>
|
||||||
|
<h3 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Tier {tier + 1}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{list.map((tracker) => (
|
||||||
|
<div key={tracker.id} className="rounded-lg border border-border p-3 text-xs">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{tracker.lastAnnounceSucceeded ? (
|
||||||
|
<CircleCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="break-all font-mono">{tracker.announce}</div>
|
||||||
|
{tracker.isBackup && (
|
||||||
|
<div className="mt-0.5 text-muted-foreground">
|
||||||
|
Backup — only tried if the tier above fails entirely.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right tabular-nums text-muted-foreground">
|
||||||
|
{tracker.seederCount >= 0 ? `${tracker.seederCount} seeds` : 'seeds unknown'}
|
||||||
|
<br />
|
||||||
|
{tracker.leecherCount >= 0 ? `${tracker.leecherCount} peers` : 'peers unknown'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-2 grid grid-cols-1 gap-x-6 gap-y-1 sm:grid-cols-2">
|
||||||
|
<Row label="Last announce" value={formatDate(tracker.lastAnnounceTime)} />
|
||||||
|
<Row
|
||||||
|
label="Result"
|
||||||
|
value={tracker.lastAnnounceResult || '—'}
|
||||||
|
tone={tracker.lastAnnounceSucceeded ? undefined : 'text-amber-500'}
|
||||||
|
/>
|
||||||
|
<Row label="Next announce" value={formatDate(tracker.nextAnnounceTime)} />
|
||||||
|
<Row label="Peers returned" value={String(tracker.lastAnnouncePeerCount)} />
|
||||||
|
<Row label="Last scrape" value={formatDate(tracker.lastScrapeTime)} />
|
||||||
|
<Row
|
||||||
|
label="Scrape result"
|
||||||
|
value={tracker.lastScrapeResult || '—'}
|
||||||
|
tone={tracker.lastScrapeSucceeded ? undefined : 'text-amber-500'}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Row = ({ label, value, tone }: { label: string; value: string; tone?: string }) => (
|
||||||
|
<div className="flex min-w-0 items-baseline gap-2">
|
||||||
|
<dt className="w-28 shrink-0 text-muted-foreground">{label}</dt>
|
||||||
|
<dd className={`min-w-0 flex-1 break-words ${tone ?? ''}`}>{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import type { ChangeEvent, DragEvent } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { FileUp, Loader2, X } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { PRIORITY } from '../shared';
|
||||||
|
import { useSession, useTorrentMutations } from '../useTransmissionData';
|
||||||
|
|
||||||
|
// Add by magnet/URL or by .torrent file, one dialog.
|
||||||
|
//
|
||||||
|
// Transmission's torrent-add takes either `filename` (a magnet or an http URL it fetches itself) or
|
||||||
|
// `metainfo` (base64 of the .torrent bytes). It cannot take both, so the two inputs are exclusive here:
|
||||||
|
// picking a file clears the text box and vice versa, rather than letting you fill in both and guess.
|
||||||
|
|
||||||
|
type AddTorrentDialogProps = { open: boolean; onOpenChange: (open: boolean) => void };
|
||||||
|
|
||||||
|
type PickedFile = { name: string; metainfo: string };
|
||||||
|
|
||||||
|
/** FileReader gives back a `data:...;base64,XXX` URL; torrent-add wants only the payload. */
|
||||||
|
async function readAsBase64(file: File): Promise<string> {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
let binary = '';
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
// Chunked because String.fromCharCode(...bytes) blows the argument limit on a torrent with many pieces.
|
||||||
|
for (let i = 0; i < bytes.length; i += 8192) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddTorrentDialog = ({ open, onOpenChange }: AddTorrentDialogProps) => {
|
||||||
|
const { session } = useSession();
|
||||||
|
const { add } = useTorrentMutations();
|
||||||
|
|
||||||
|
const [urls, setUrls] = useState('');
|
||||||
|
const [files, setFiles] = useState<PickedFile[]>([]);
|
||||||
|
const [downloadDir, setDownloadDir] = useState('');
|
||||||
|
const [labels, setLabels] = useState('');
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [priority, setPriority] = useState('0');
|
||||||
|
const [sequential, setSequential] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const defaultDir = (session?.['download-dir'] as string) ?? '';
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setUrls('');
|
||||||
|
setFiles([]);
|
||||||
|
setDownloadDir('');
|
||||||
|
setLabels('');
|
||||||
|
setPaused(false);
|
||||||
|
setPriority('0');
|
||||||
|
setSequential(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
reset();
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const takeFiles = async (list: FileList | null) => {
|
||||||
|
if (!list?.length) return;
|
||||||
|
const picked = await Promise.all([...list].map(async (f) => ({ name: f.name, metainfo: await readAsBase64(f) })));
|
||||||
|
setFiles((prev) => [...prev, ...picked]);
|
||||||
|
setUrls('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (ev: DragEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
void takeFiles(ev.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPick = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
void takeFiles(ev.target.files);
|
||||||
|
// Clear the input so picking the same file twice still fires a change event.
|
||||||
|
ev.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlList = urls
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const canSubmit = (files.length > 0 || urlList.length > 0) && !busy;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const common = {
|
||||||
|
downloadDir: downloadDir.trim() || undefined,
|
||||||
|
labels: labels
|
||||||
|
.split(',')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
paused,
|
||||||
|
bandwidthPriority: Number(priority),
|
||||||
|
sequentialDownload: sequential,
|
||||||
|
};
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
let added = 0;
|
||||||
|
let duplicate = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// Sequential rather than Promise.all: torrent-add is not idempotent and the daemon serialises it
|
||||||
|
// anyway, and a partial failure should report which of a paste of twenty magnets got in.
|
||||||
|
for (const file of files) {
|
||||||
|
const result = await add.mutateAsync({ ...common, metainfo: file.metainfo }).catch(() => null);
|
||||||
|
if (!result) failed += 1;
|
||||||
|
else if (result.status === 'duplicate') duplicate += 1;
|
||||||
|
else added += 1;
|
||||||
|
}
|
||||||
|
for (const url of urlList) {
|
||||||
|
const result = await add.mutateAsync({ ...common, filename: url }).catch(() => null);
|
||||||
|
if (!result) failed += 1;
|
||||||
|
else if (result.status === 'duplicate') duplicate += 1;
|
||||||
|
else added += 1;
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
|
||||||
|
const parts = [added && `${added} added`, duplicate && `${duplicate} already present`, failed && `${failed} failed`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
if (added || duplicate) toast.success(parts);
|
||||||
|
else if (failed) toast.error(parts);
|
||||||
|
|
||||||
|
if (!failed) close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add torrents</DialogTitle>
|
||||||
|
<DialogDescription>Paste magnet links or drop .torrent files.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tr-add-urls">Magnet links or URLs</Label>
|
||||||
|
<Textarea
|
||||||
|
id="tr-add-urls"
|
||||||
|
rows={3}
|
||||||
|
value={urls}
|
||||||
|
disabled={files.length > 0}
|
||||||
|
placeholder={'magnet:?xt=urn:btih:…\nhttps://example.org/file.torrent'}
|
||||||
|
onChange={(ev) => setUrls(ev.target.value)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">One per line.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDragOver={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={onDrop}
|
||||||
|
className={`rounded-lg border border-dashed p-4 text-center transition-colors ${
|
||||||
|
dragging ? 'border-primary bg-primary/5' : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileUp className="mx-auto h-5 w-5 text-muted-foreground" />
|
||||||
|
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||||
|
Drop .torrent files here, or{' '}
|
||||||
|
<label className="cursor-pointer text-primary underline">
|
||||||
|
browse
|
||||||
|
<input type="file" accept=".torrent" multiple className="hidden" onChange={onPick} />
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul className="mt-2 space-y-1 text-left">
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<li key={`${f.name}-${i}`} className="flex items-center gap-2 rounded bg-muted px-2 py-1 text-xs">
|
||||||
|
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={`Remove ${f.name}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="tr-add-dir">Download to</Label>
|
||||||
|
<Input
|
||||||
|
id="tr-add-dir"
|
||||||
|
value={downloadDir}
|
||||||
|
placeholder={defaultDir || 'server default'}
|
||||||
|
onChange={(ev) => setDownloadDir(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tr-add-labels">Labels</Label>
|
||||||
|
<Input
|
||||||
|
id="tr-add-labels"
|
||||||
|
value={labels}
|
||||||
|
placeholder="comma, separated"
|
||||||
|
onChange={(ev) => setLabels(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tr-add-priority">Priority</Label>
|
||||||
|
<Select value={priority} onValueChange={setPriority}>
|
||||||
|
<SelectTrigger id="tr-add-priority">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={String(PRIORITY.High)}>High</SelectItem>
|
||||||
|
<SelectItem value={String(PRIORITY.Normal)}>Normal</SelectItem>
|
||||||
|
<SelectItem value={String(PRIORITY.Low)}>Low</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={paused} onCheckedChange={(v) => setPaused(v === true)} />
|
||||||
|
Start paused
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={sequential} onCheckedChange={(v) => setSequential(v === true)} />
|
||||||
|
Sequential download
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={close} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={!canSubmit}>
|
||||||
|
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Add {files.length + urlList.length || ''}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
Binary file not shown.
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Torrent } from '../shared';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useTorrentMutations } from '../useTransmissionData';
|
||||||
|
|
||||||
|
// torrent-set-location does two unrelated things depending on one boolean, so the dialog asks in words
|
||||||
|
// rather than showing a checkbox called "move".
|
||||||
|
//
|
||||||
|
// move: true — physically relocate the data, then point the torrent at the new place
|
||||||
|
// move: false — the data is ALREADY there; just re-point the torrent (this is how you recover after
|
||||||
|
// moving files by hand, and it is also how you lose a torrent if you pick it by mistake)
|
||||||
|
|
||||||
|
type MoveTorrentDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
targets: Torrent[];
|
||||||
|
/** Every distinct download dir currently in use, offered as one-click destinations. */
|
||||||
|
knownDirs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MoveTorrentDialog = ({ open, onOpenChange, targets, knownDirs }: MoveTorrentDialogProps) => {
|
||||||
|
const { setLocation } = useTorrentMutations();
|
||||||
|
const [location, setLocationValue] = useState('');
|
||||||
|
const [move, setMove] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
// Seed with the current location when they all share one — the common case is a small edit to it.
|
||||||
|
const dirs = new Set(targets.map((t) => t.downloadDir));
|
||||||
|
setLocationValue(dirs.size === 1 ? [...dirs][0]! : '');
|
||||||
|
setMove(true);
|
||||||
|
}, [open, targets]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
await setLocation.mutateAsync({ ids: targets.map((t) => t.id), location: location.trim(), move });
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Set location</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{targets.length === 1 ? targets[0]!.name : `${targets.length} torrents`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tr-move-path">Destination</Label>
|
||||||
|
<Input
|
||||||
|
id="tr-move-path"
|
||||||
|
value={location}
|
||||||
|
onChange={(ev) => setLocationValue(ev.target.value)}
|
||||||
|
placeholder="/downloads/complete"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{knownDirs.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{knownDirs.map((dir) => (
|
||||||
|
<button
|
||||||
|
key={dir}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLocationValue(dir)}
|
||||||
|
title={dir}
|
||||||
|
className="max-w-full truncate rounded bg-muted px-2 py-1 text-xs hover:bg-muted/70"
|
||||||
|
>
|
||||||
|
{dir}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||||
|
<input type="radio" name="tr-move-mode" className="mt-1" checked={move} onChange={() => setMove(true)} />
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Move the data there</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||||
|
Transmission copies the files to the new location and removes the originals.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="tr-move-mode"
|
||||||
|
className="mt-1"
|
||||||
|
checked={!move}
|
||||||
|
onChange={() => setMove(false)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">The data is already there</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||||
|
Nothing is copied. Use this after moving files yourself — if the files are not there, the torrent will
|
||||||
|
re-download.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={!location.trim() || setLocation.isPending}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { Torrent } from '../shared';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { TriangleAlert } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { formatSize } from '../format';
|
||||||
|
import { useTorrentMutations } from '../useTransmissionData';
|
||||||
|
|
||||||
|
// Removing a torrent and deleting its data are one RPC apart and a very long way apart in consequence, so
|
||||||
|
// they are one dialog with an explicit opt-in rather than two menu items you can mis-click between.
|
||||||
|
|
||||||
|
type RemoveTorrentDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
targets: Torrent[];
|
||||||
|
onRemoved?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RemoveTorrentDialog = ({ open, onOpenChange, targets, onRemoved }: RemoveTorrentDialogProps) => {
|
||||||
|
const { remove } = useTorrentMutations();
|
||||||
|
const [deleteData, setDeleteData] = useState(false);
|
||||||
|
|
||||||
|
// Re-arm on every open: "also delete the files" must never be inherited from the last time.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setDeleteData(false);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const bytes = targets.reduce((sum, t) => sum + (t.totalSize ?? 0), 0);
|
||||||
|
const one = targets.length === 1 ? targets[0] : null;
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
await remove.mutateAsync({ ids: targets.map((t) => t.id), deleteLocalData: deleteData });
|
||||||
|
onOpenChange(false);
|
||||||
|
onRemoved?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove {one ? 'torrent' : `${targets.length} torrents`}?</DialogTitle>
|
||||||
|
<DialogDescription className="break-words">
|
||||||
|
{one ? one.name : `${targets.length} torrents totalling ${formatSize(bytes)}.`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||||
|
<Checkbox className="mt-0.5" checked={deleteData} onCheckedChange={(v) => setDeleteData(v === true)} />
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Also delete the downloaded files</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||||
|
{deleteData
|
||||||
|
? `${formatSize(bytes)} will be deleted from disk. This cannot be undone.`
|
||||||
|
: 'The files stay on disk; only the torrent is removed.'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{deleteData && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
<TriangleAlert className="h-4 w-4 shrink-0" />
|
||||||
|
Transmission deletes the data immediately — there is no trash.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={confirm} disabled={remove.isPending}>
|
||||||
|
{deleteData ? 'Remove and delete' : 'Remove'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Torrent } from '../shared';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useTorrentMutations } from '../useTransmissionData';
|
||||||
|
|
||||||
|
// torrent-rename-path renames a path *inside* the torrent, addressed by its full path relative to the
|
||||||
|
// torrent root. Renaming the root itself means passing the torrent's own name as the path — which is why
|
||||||
|
// this dialog takes a `path` as well as the new name, and why it is single-target only.
|
||||||
|
|
||||||
|
type RenameDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
torrent: Torrent | null;
|
||||||
|
/** The path being renamed; the torrent's own name when renaming the root. */
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RenameDialog = ({ open, onOpenChange, torrent, path }: RenameDialogProps) => {
|
||||||
|
const { rename } = useTorrentMutations();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
// Only the last segment is editable — the rest of the path is where the file lives, not what it is called.
|
||||||
|
const segments = path.split('/');
|
||||||
|
const parent = segments.slice(0, -1).join('/');
|
||||||
|
const current = segments[segments.length - 1] ?? '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setName(current);
|
||||||
|
}, [open, current]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!torrent) return;
|
||||||
|
await rename.mutateAsync({ id: torrent.id, path, name: name.trim() });
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rename</DialogTitle>
|
||||||
|
<DialogDescription className="break-all">{parent ? `${parent}/` : path}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tr-rename">New name</Label>
|
||||||
|
<Input
|
||||||
|
id="tr-rename"
|
||||||
|
value={name}
|
||||||
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
|
onKeyDown={(ev) => ev.key === 'Enter' && name.trim() && void submit()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This renames the file or folder on disk, not just the display name.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={!name.trim() || name.trim() === current || rename.isPending}>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type { Torrent } from '../shared';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { LIMIT_MODE, PRIORITY } from '../shared';
|
||||||
|
import { useTorrentMutations } from '../useTransmissionData';
|
||||||
|
|
||||||
|
// Per-torrent bandwidth, seeding and peer options — the reference client's "other settings" dialog.
|
||||||
|
//
|
||||||
|
// Every limit here is a pair (a boolean and a number) because Transmission stores them that way: turning a
|
||||||
|
// limit off keeps the number, so unchecking and re-checking gets you back the value you had.
|
||||||
|
|
||||||
|
type TorrentOptionsDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; targets: Torrent[] };
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
bandwidthPriority: string;
|
||||||
|
honorsSessionLimits: boolean;
|
||||||
|
downloadLimited: boolean;
|
||||||
|
downloadLimit: string;
|
||||||
|
uploadLimited: boolean;
|
||||||
|
uploadLimit: string;
|
||||||
|
seedRatioMode: string;
|
||||||
|
seedRatioLimit: string;
|
||||||
|
seedIdleMode: string;
|
||||||
|
seedIdleLimit: string;
|
||||||
|
peerLimit: string;
|
||||||
|
sequential: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromTorrent = (t: Torrent): FormState => ({
|
||||||
|
bandwidthPriority: String(t.bandwidthPriority ?? 0),
|
||||||
|
honorsSessionLimits: t.honorsSessionLimits ?? true,
|
||||||
|
downloadLimited: t.downloadLimited ?? false,
|
||||||
|
downloadLimit: String(t.downloadLimit ?? 0),
|
||||||
|
uploadLimited: t.uploadLimited ?? false,
|
||||||
|
uploadLimit: String(t.uploadLimit ?? 0),
|
||||||
|
seedRatioMode: String(t.seedRatioMode ?? 0),
|
||||||
|
seedRatioLimit: String(t.seedRatioLimit ?? 2),
|
||||||
|
seedIdleMode: String(t.seedIdleMode ?? 0),
|
||||||
|
seedIdleLimit: String(t.seedIdleLimit ?? 30),
|
||||||
|
peerLimit: String(t['peer-limit'] ?? 50),
|
||||||
|
sequential: t.sequential_download ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TorrentOptionsDialog = ({ open, onOpenChange, targets }: TorrentOptionsDialogProps) => {
|
||||||
|
const { set } = useTorrentMutations();
|
||||||
|
const [form, setForm] = useState<FormState | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Seeded from the first target. With a mixed selection the others' values are simply overwritten —
|
||||||
|
// which is what "apply these settings to all of them" means, and the header says so.
|
||||||
|
if (open && targets[0]) setForm(fromTorrent(targets[0]));
|
||||||
|
}, [open, targets]);
|
||||||
|
|
||||||
|
if (!form) return null;
|
||||||
|
|
||||||
|
const patch = (next: Partial<FormState>) => setForm((prev) => (prev ? { ...prev, ...next } : prev));
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
await set.mutateAsync({
|
||||||
|
ids: targets.map((t) => t.id),
|
||||||
|
bandwidthPriority: Number(form.bandwidthPriority),
|
||||||
|
honorsSessionLimits: form.honorsSessionLimits,
|
||||||
|
downloadLimited: form.downloadLimited,
|
||||||
|
downloadLimit: Math.max(0, Number(form.downloadLimit) || 0),
|
||||||
|
uploadLimited: form.uploadLimited,
|
||||||
|
uploadLimit: Math.max(0, Number(form.uploadLimit) || 0),
|
||||||
|
seedRatioMode: Number(form.seedRatioMode),
|
||||||
|
seedRatioLimit: Math.max(0, Number(form.seedRatioLimit) || 0),
|
||||||
|
seedIdleMode: Number(form.seedIdleMode),
|
||||||
|
seedIdleLimit: Math.max(0, Number(form.seedIdleLimit) || 0),
|
||||||
|
'peer-limit': Math.max(1, Number(form.peerLimit) || 1),
|
||||||
|
sequential_download: form.sequential,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Torrent options</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{targets.length === 1
|
||||||
|
? targets[0]!.name
|
||||||
|
: `Applying to ${targets.length} torrents — all of them will take these values.`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bandwidth</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tr-opt-priority">Priority</Label>
|
||||||
|
<Select value={form.bandwidthPriority} onValueChange={(v) => patch({ bandwidthPriority: v })}>
|
||||||
|
<SelectTrigger id="tr-opt-priority">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={String(PRIORITY.High)}>High</SelectItem>
|
||||||
|
<SelectItem value={String(PRIORITY.Normal)}>Normal</SelectItem>
|
||||||
|
<SelectItem value={String(PRIORITY.Low)}>Low</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tr-opt-peers">Maximum peers</Label>
|
||||||
|
<Input
|
||||||
|
id="tr-opt-peers"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.peerLimit}
|
||||||
|
onChange={(ev) => patch({ peerLimit: ev.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LimitRow
|
||||||
|
id="tr-opt-down"
|
||||||
|
label="Limit download speed"
|
||||||
|
enabled={form.downloadLimited}
|
||||||
|
value={form.downloadLimit}
|
||||||
|
onToggle={(v) => patch({ downloadLimited: v })}
|
||||||
|
onChange={(v) => patch({ downloadLimit: v })}
|
||||||
|
/>
|
||||||
|
<LimitRow
|
||||||
|
id="tr-opt-up"
|
||||||
|
label="Limit upload speed"
|
||||||
|
enabled={form.uploadLimited}
|
||||||
|
value={form.uploadLimit}
|
||||||
|
onToggle={(v) => patch({ uploadLimited: v })}
|
||||||
|
onChange={(v) => patch({ uploadLimit: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.honorsSessionLimits}
|
||||||
|
onCheckedChange={(v) => patch({ honorsSessionLimits: v === true })}
|
||||||
|
/>
|
||||||
|
Honour the global speed limits
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={form.sequential} onCheckedChange={(v) => patch({ sequential: v === true })} />
|
||||||
|
Download pieces in order
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Stop seeding when</h3>
|
||||||
|
<div className="grid grid-cols-[1fr_120px] items-end gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tr-opt-ratio-mode">Ratio reaches</Label>
|
||||||
|
<Select value={form.seedRatioMode} onValueChange={(v) => patch({ seedRatioMode: v })}>
|
||||||
|
<SelectTrigger id="tr-opt-ratio-mode">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={String(LIMIT_MODE.Global)}>Use the global setting</SelectItem>
|
||||||
|
<SelectItem value={String(LIMIT_MODE.Single)}>Use this value</SelectItem>
|
||||||
|
<SelectItem value={String(LIMIT_MODE.Unlimited)}>Seed forever</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min={0}
|
||||||
|
disabled={form.seedRatioMode !== String(LIMIT_MODE.Single)}
|
||||||
|
value={form.seedRatioLimit}
|
||||||
|
onChange={(ev) => patch({ seedRatioLimit: ev.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[1fr_120px] items-end gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tr-opt-idle-mode">Idle for (minutes)</Label>
|
||||||
|
<Select value={form.seedIdleMode} onValueChange={(v) => patch({ seedIdleMode: v })}>
|
||||||
|
<SelectTrigger id="tr-opt-idle-mode">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={String(LIMIT_MODE.Global)}>Use the global setting</SelectItem>
|
||||||
|
<SelectItem value={String(LIMIT_MODE.Single)}>Use this value</SelectItem>
|
||||||
|
<SelectItem value={String(LIMIT_MODE.Unlimited)}>Never stop</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
disabled={form.seedIdleMode !== String(LIMIT_MODE.Single)}
|
||||||
|
value={form.seedIdleLimit}
|
||||||
|
onChange={(ev) => patch({ seedIdleLimit: ev.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={set.isPending}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type LimitRowProps = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
enabled: boolean;
|
||||||
|
value: string;
|
||||||
|
onToggle: (enabled: boolean) => void;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LimitRow = ({ id, label, enabled, value, onToggle, onChange }: LimitRowProps) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex flex-1 items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={enabled} onCheckedChange={(v) => onToggle(v === true)} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
disabled={!enabled}
|
||||||
|
value={value}
|
||||||
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
|
className="w-28"
|
||||||
|
/>
|
||||||
|
<span className="w-10 text-xs text-muted-foreground">kB/s</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Torrent } from '../shared';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useTorrentMutations } from '../useTransmissionData';
|
||||||
|
|
||||||
|
// Transmission 4 replaced the old trackerAdd/trackerRemove/trackerReplace triple with `trackerList`: the
|
||||||
|
// whole announce list as text, one URL per line, a blank line between tiers. Editing text is also the only
|
||||||
|
// honest way to express tiers, which a list of rows with buttons cannot.
|
||||||
|
|
||||||
|
type TrackersDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; torrent: Torrent | null };
|
||||||
|
|
||||||
|
/** Rebuild the tier-separated text from trackerStats when the daemon didn't send trackerList. */
|
||||||
|
function fromStats(torrent: Torrent): string {
|
||||||
|
const tiers = new Map<number, string[]>();
|
||||||
|
for (const t of torrent.trackerStats ?? []) {
|
||||||
|
const list = tiers.get(t.tier) ?? [];
|
||||||
|
list.push(t.announce);
|
||||||
|
tiers.set(t.tier, list);
|
||||||
|
}
|
||||||
|
return [...tiers.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([, urls]) => urls.join('\n'))
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrackersDialog = ({ open, onOpenChange, torrent }: TrackersDialogProps) => {
|
||||||
|
const { set } = useTorrentMutations();
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !torrent) return;
|
||||||
|
setText(torrent.trackerList ?? fromStats(torrent));
|
||||||
|
}, [open, torrent]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!torrent) return;
|
||||||
|
// Trailing whitespace on a line makes Transmission reject the whole list, and pasting from a website
|
||||||
|
// reliably brings some along.
|
||||||
|
const normalised = text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.join('\n');
|
||||||
|
await set.mutateAsync({ ids: [torrent.id], trackerList: normalised });
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Trackers</DialogTitle>
|
||||||
|
<DialogDescription className="break-words">{torrent?.name}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tr-trackers">Announce URLs</Label>
|
||||||
|
<Textarea
|
||||||
|
id="tr-trackers"
|
||||||
|
rows={10}
|
||||||
|
value={text}
|
||||||
|
onChange={(ev) => setText(ev.target.value)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
One URL per line. Leave a blank line between tiers — Transmission only falls through to the next tier when
|
||||||
|
every tracker in the one above it fails.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{torrent?.isPrivate && (
|
||||||
|
<p className="rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-500">
|
||||||
|
This is a private torrent. Adding trackers it wasn't issued with will not help and may get you banned from
|
||||||
|
the tracker it came from.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={set.isPending}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { Torrent } from './shared';
|
||||||
|
import { STATUS } from './shared';
|
||||||
|
|
||||||
|
// Display formatting for the Transmission panels. One implementation each, because a torrent list shows the
|
||||||
|
// same quantity in a column, a tooltip and a detail row, and three near-identical formatters drift.
|
||||||
|
|
||||||
|
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
|
||||||
|
/** Binary (1024-based) sizes, matching what Transmission's own clients show. */
|
||||||
|
export function formatSize(bytes: number | undefined | null): string {
|
||||||
|
if (bytes == null || !Number.isFinite(bytes)) return '—';
|
||||||
|
if (bytes <= 0) return '0 B';
|
||||||
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), SIZE_UNITS.length - 1);
|
||||||
|
const value = bytes / 1024 ** i;
|
||||||
|
return `${value.toFixed(i === 0 ? 0 : value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${SIZE_UNITS[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zero renders blank, not "0 B/s" — a list where every idle row shouts zero is unreadable. */
|
||||||
|
export function formatSpeed(bytesPerSecond: number | undefined | null): string {
|
||||||
|
if (!bytesPerSecond || bytesPerSecond <= 0) return '';
|
||||||
|
return `${formatSize(bytesPerSecond)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transmission's ETA sentinels: -1 means "not available", -2 means "unknown". Both are ordinary states for a
|
||||||
|
* stopped or seeding torrent, so neither is an error worth surfacing.
|
||||||
|
*/
|
||||||
|
export function formatEta(seconds: number | undefined | null): string {
|
||||||
|
if (seconds == null || seconds < 0) return '∞';
|
||||||
|
return formatDuration(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(seconds: number | undefined | null): string {
|
||||||
|
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '—';
|
||||||
|
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||||
|
|
||||||
|
const d = Math.floor(seconds / 86400);
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;
|
||||||
|
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ratio, with the two conventions every torrent client shares: nothing uploaded yet reads as a dash, and
|
||||||
|
* anything uploaded against a zero download is infinite rather than a division blow-up.
|
||||||
|
*/
|
||||||
|
export function formatRatio(ratio: number | undefined | null): string {
|
||||||
|
if (ratio == null) return '—';
|
||||||
|
if (ratio < 0) return '∞';
|
||||||
|
return ratio.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transmission timestamps are unix seconds, and 0 means "never" rather than 1970. */
|
||||||
|
export function formatDate(unixSeconds: number | undefined | null): string {
|
||||||
|
if (!unixSeconds || unixSeconds <= 0) return '—';
|
||||||
|
const d = new Date(unixSeconds * 1000);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(fraction: number | undefined | null): string {
|
||||||
|
if (fraction == null) return '—';
|
||||||
|
return `${(fraction * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type StatusTone = 'stopped' | 'downloading' | 'seeding' | 'checking' | 'queued' | 'error';
|
||||||
|
|
||||||
|
export function statusTone(torrent: Torrent): StatusTone {
|
||||||
|
if (torrent.error !== 0) return 'error';
|
||||||
|
switch (torrent.status) {
|
||||||
|
case STATUS.Download:
|
||||||
|
return 'downloading';
|
||||||
|
case STATUS.Seed:
|
||||||
|
return 'seeding';
|
||||||
|
case STATUS.Check:
|
||||||
|
case STATUS.CheckWait:
|
||||||
|
return 'checking';
|
||||||
|
case STATUS.DownloadWait:
|
||||||
|
case STATUS.SeedWait:
|
||||||
|
return 'queued';
|
||||||
|
default:
|
||||||
|
return 'stopped';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusLabel(torrent: Torrent): string {
|
||||||
|
if (torrent.error !== 0) return 'Error';
|
||||||
|
switch (torrent.status) {
|
||||||
|
case STATUS.Stopped:
|
||||||
|
// A stopped torrent that never finished is paused; one that did is simply done and not seeding.
|
||||||
|
return torrent.percentDone >= 1 ? 'Finished' : 'Paused';
|
||||||
|
case STATUS.CheckWait:
|
||||||
|
return 'Queued to verify';
|
||||||
|
case STATUS.Check:
|
||||||
|
return 'Verifying';
|
||||||
|
case STATUS.DownloadWait:
|
||||||
|
return 'Queued to download';
|
||||||
|
case STATUS.Download:
|
||||||
|
// Transmission reports "downloading" while it is still fetching the metadata of a magnet link, which
|
||||||
|
// is a materially different state — there is nothing to download yet.
|
||||||
|
return torrent.metadataPercentComplete < 1 ? 'Fetching metadata' : 'Downloading';
|
||||||
|
case STATUS.SeedWait:
|
||||||
|
return 'Queued to seed';
|
||||||
|
case STATUS.Seed:
|
||||||
|
return 'Seeding';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tailwind classes per tone, kept together so the list, the pill and the detail header cannot disagree. */
|
||||||
|
export const TONE_CLASSES: Record<StatusTone, { text: string; bg: string; dot: string }> = {
|
||||||
|
stopped: { text: 'text-muted-foreground', bg: 'bg-muted', dot: 'bg-muted-foreground' },
|
||||||
|
downloading: { text: 'text-blue-500', bg: 'bg-blue-500/10', dot: 'bg-blue-500' },
|
||||||
|
seeding: { text: 'text-emerald-500', bg: 'bg-emerald-500/10', dot: 'bg-emerald-500' },
|
||||||
|
checking: { text: 'text-cyan-500', bg: 'bg-cyan-500/10', dot: 'bg-cyan-500' },
|
||||||
|
queued: { text: 'text-amber-500', bg: 'bg-amber-500/10', dot: 'bg-amber-500' },
|
||||||
|
error: { text: 'text-destructive', bg: 'bg-destructive/10', dot: 'bg-destructive' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRIORITY_LABELS: Record<number, string> = { [-1]: 'Low', 0: 'Normal', 1: 'High' };
|
||||||
|
|
||||||
|
/** Peer flag characters, spelled out for the peers table tooltip (rpc-spec.md §3.2). */
|
||||||
|
export const PEER_FLAGS: Record<string, string> = {
|
||||||
|
O: 'Optimistic unchoke',
|
||||||
|
D: 'Downloading from peer',
|
||||||
|
d: 'We would download but peer is choking us',
|
||||||
|
U: 'Uploading to peer',
|
||||||
|
u: 'Peer would download but we are choking',
|
||||||
|
K: 'Peer unchoked us but we are not interested',
|
||||||
|
'?': 'We unchoked peer but it is not interested',
|
||||||
|
E: 'Encrypted connection',
|
||||||
|
H: 'Peer discovered through DHT',
|
||||||
|
X: 'Peer discovered through PEX',
|
||||||
|
I: 'Peer is incoming',
|
||||||
|
T: 'Peer is over uTP',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function describePeerFlags(flagStr: string): string {
|
||||||
|
return (
|
||||||
|
flagStr
|
||||||
|
.split('')
|
||||||
|
.map((c) => PEER_FLAGS[c])
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n') || flagStr
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
|
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
||||||
|
import { TransmissionNav } from './TransmissionNav';
|
||||||
|
import { TransmissionView } from './TransmissionView';
|
||||||
|
import { TransmissionViewHeader } from './TransmissionViewHeader';
|
||||||
|
|
||||||
|
export { TransmissionNav, TransmissionView };
|
||||||
|
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{
|
||||||
|
key: 'transmission-nav',
|
||||||
|
name: 'Transmission',
|
||||||
|
icon: PanelLeft,
|
||||||
|
component: TransmissionNav,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'transmission-view',
|
||||||
|
name: 'Transmission',
|
||||||
|
icon: LayoutGrid,
|
||||||
|
component: TransmissionView,
|
||||||
|
header: TransmissionViewHeader,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// Shared types/constants for the /transmission workspace panels. The wire shapes mirror what the
|
||||||
|
// officer-transmission sidecar returns under /api/transmission/_officer/* — which in turn keeps
|
||||||
|
// Transmission's own field names, casing warts and all (`percentDone` beside `peer-limit` and
|
||||||
|
// `sequential_download`). Renaming them here would mean this file and Transmission's rpc-spec.md disagree
|
||||||
|
// on what a field is called, which is exactly the confusion you don't want at 2am.
|
||||||
|
//
|
||||||
|
// The `officer*` fields are the sidecar's own additions — see src/servers/sidecar/transmission/rpc.ts.
|
||||||
|
|
||||||
|
export const TRANSMISSION_SECTIONS = [
|
||||||
|
{ id: 'torrents', label: 'Torrents' },
|
||||||
|
{ id: 'stats', label: 'Statistics' },
|
||||||
|
{ id: 'settings', label: 'Settings' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TransmissionSectionId = (typeof TRANSMISSION_SECTIONS)[number]['id'];
|
||||||
|
|
||||||
|
/** Where /transmission lands, and where an unrecognised section redirects to. */
|
||||||
|
export const DEFAULT_TRANSMISSION_SECTION: TransmissionSectionId = 'torrents';
|
||||||
|
|
||||||
|
export const isTransmissionSection = (value: string | undefined): value is TransmissionSectionId =>
|
||||||
|
TRANSMISSION_SECTIONS.some((s) => s.id === value);
|
||||||
|
|
||||||
|
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
|
||||||
|
export const transmissionSectionPath = (id: TransmissionSectionId) => `/transmission/${id}`;
|
||||||
|
|
||||||
|
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Transmission's torrent status enum (rpc-spec.md §3.3). */
|
||||||
|
export const STATUS = {
|
||||||
|
Stopped: 0,
|
||||||
|
CheckWait: 1,
|
||||||
|
Check: 2,
|
||||||
|
DownloadWait: 3,
|
||||||
|
Download: 4,
|
||||||
|
SeedWait: 5,
|
||||||
|
Seed: 6,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TrackerStat = {
|
||||||
|
announce: string;
|
||||||
|
announceState: number;
|
||||||
|
downloadCount: number;
|
||||||
|
hasAnnounced: boolean;
|
||||||
|
hasScraped: boolean;
|
||||||
|
host: string;
|
||||||
|
id: number;
|
||||||
|
isBackup: boolean;
|
||||||
|
lastAnnouncePeerCount: number;
|
||||||
|
lastAnnounceResult: string;
|
||||||
|
lastAnnounceSucceeded: boolean;
|
||||||
|
lastAnnounceTime: number;
|
||||||
|
lastScrapeResult: string;
|
||||||
|
lastScrapeSucceeded: boolean;
|
||||||
|
lastScrapeTime: number;
|
||||||
|
leecherCount: number;
|
||||||
|
nextAnnounceTime: number;
|
||||||
|
scrape: string;
|
||||||
|
seederCount: number;
|
||||||
|
tier: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TorrentFile = { bytesCompleted: number; length: number; name: string };
|
||||||
|
export type TorrentFileStat = { bytesCompleted: number; priority: number; wanted: boolean };
|
||||||
|
|
||||||
|
export type TorrentPeer = {
|
||||||
|
address: string;
|
||||||
|
clientName: string;
|
||||||
|
flagStr: string;
|
||||||
|
isEncrypted: boolean;
|
||||||
|
isIncoming: boolean;
|
||||||
|
isUTP: boolean;
|
||||||
|
port: number;
|
||||||
|
progress: number;
|
||||||
|
rateToClient: number;
|
||||||
|
rateToPeer: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeersFrom = {
|
||||||
|
fromCache: number;
|
||||||
|
fromDht: number;
|
||||||
|
fromIncoming: number;
|
||||||
|
fromLpd: number;
|
||||||
|
fromLtep: number;
|
||||||
|
fromPex: number;
|
||||||
|
fromTracker: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Torrent = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
totalSize: number;
|
||||||
|
sizeWhenDone: number;
|
||||||
|
leftUntilDone: number;
|
||||||
|
haveValid: number;
|
||||||
|
percentDone: number;
|
||||||
|
metadataPercentComplete: number;
|
||||||
|
recheckProgress?: number;
|
||||||
|
downloadedEver: number;
|
||||||
|
uploadedEver: number;
|
||||||
|
corruptEver?: number;
|
||||||
|
uploadRatio: number;
|
||||||
|
rateDownload: number;
|
||||||
|
rateUpload: number;
|
||||||
|
eta: number;
|
||||||
|
peersSendingToUs: number;
|
||||||
|
peersGettingFromUs: number;
|
||||||
|
addedDate: number;
|
||||||
|
doneDate: number;
|
||||||
|
activityDate: number;
|
||||||
|
secondsSeeding: number;
|
||||||
|
downloadDir: string;
|
||||||
|
bandwidthPriority: number;
|
||||||
|
queuePosition: number;
|
||||||
|
isPrivate: boolean;
|
||||||
|
labels: string[];
|
||||||
|
error: number;
|
||||||
|
errorString: string;
|
||||||
|
pieceCount: number;
|
||||||
|
pieceSize: number;
|
||||||
|
magnetLink: string;
|
||||||
|
group: string;
|
||||||
|
'file-count': number;
|
||||||
|
trackerStats: TrackerStat[];
|
||||||
|
trackerList?: string;
|
||||||
|
honorsSessionLimits: boolean;
|
||||||
|
downloadLimited: boolean;
|
||||||
|
downloadLimit: number;
|
||||||
|
uploadLimited: boolean;
|
||||||
|
uploadLimit: number;
|
||||||
|
'peer-limit': number;
|
||||||
|
seedRatioMode: number;
|
||||||
|
seedRatioLimit: number;
|
||||||
|
seedIdleMode: number;
|
||||||
|
seedIdleLimit: number;
|
||||||
|
sequential_download?: boolean;
|
||||||
|
// Detail-only — present when fetched through useTorrentDetail.
|
||||||
|
hashString?: string;
|
||||||
|
comment?: string;
|
||||||
|
creator?: string;
|
||||||
|
dateCreated?: number;
|
||||||
|
maxConnectedPeers?: number;
|
||||||
|
files?: TorrentFile[];
|
||||||
|
fileStats?: TorrentFileStat[];
|
||||||
|
peers?: TorrentPeer[];
|
||||||
|
peersFrom?: PeersFrom;
|
||||||
|
// Derived by the sidecar.
|
||||||
|
officerTrackerHost: string;
|
||||||
|
officerTrackerStatus: string;
|
||||||
|
officerTrackerErrors: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionSettings = Record<string, unknown> & {
|
||||||
|
version: string;
|
||||||
|
'rpc-version': number;
|
||||||
|
'download-dir': string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatsBucket = {
|
||||||
|
downloadedBytes: number;
|
||||||
|
uploadedBytes: number;
|
||||||
|
filesAdded: number;
|
||||||
|
sessionCount: number;
|
||||||
|
secondsActive: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionStats = {
|
||||||
|
activeTorrentCount: number;
|
||||||
|
pausedTorrentCount: number;
|
||||||
|
torrentCount: number;
|
||||||
|
downloadSpeed: number;
|
||||||
|
uploadSpeed: number;
|
||||||
|
'cumulative-stats': StatsBucket;
|
||||||
|
'current-stats': StatsBucket;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The ids the sidecar accepts at POST /_officer/torrents/action. */
|
||||||
|
export type TorrentAction =
|
||||||
|
| 'start'
|
||||||
|
| 'start-now'
|
||||||
|
| 'stop'
|
||||||
|
| 'verify'
|
||||||
|
| 'reannounce'
|
||||||
|
| 'queue-top'
|
||||||
|
| 'queue-up'
|
||||||
|
| 'queue-down'
|
||||||
|
| 'queue-bottom';
|
||||||
|
|
||||||
|
export const PRIORITY = { Low: -1, Normal: 0, High: 1 } as const;
|
||||||
|
|
||||||
|
/** Transmission's seedRatioMode / seedIdleMode share this vocabulary. */
|
||||||
|
export const LIMIT_MODE = { Global: 0, Single: 1, Unlimited: 2 } as const;
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import type { Torrent } from './shared';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { STATUS } from './shared';
|
||||||
|
|
||||||
|
// Filtering and sorting for the torrent list, with every knob in the query string.
|
||||||
|
//
|
||||||
|
// The nav panel writes these params and the table panel reads them; they never talk to each other. That is
|
||||||
|
// the whole reason the state lives in the URL rather than in a channel — two sibling panels, one address,
|
||||||
|
// and a filtered view you can bookmark or send to yourself.
|
||||||
|
|
||||||
|
export const STATUS_FILTERS = [
|
||||||
|
{ id: 'all', label: 'All' },
|
||||||
|
{ id: 'active', label: 'Active' },
|
||||||
|
{ id: 'downloading', label: 'Downloading' },
|
||||||
|
{ id: 'seeding', label: 'Seeding' },
|
||||||
|
{ id: 'paused', label: 'Paused' },
|
||||||
|
{ id: 'finished', label: 'Finished' },
|
||||||
|
{ id: 'checking', label: 'Verifying' },
|
||||||
|
{ id: 'error', label: 'Error' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type StatusFilterId = (typeof STATUS_FILTERS)[number]['id'];
|
||||||
|
|
||||||
|
const isStatusFilter = (v: string | null): v is StatusFilterId => !!v && STATUS_FILTERS.some((f) => f.id === v);
|
||||||
|
|
||||||
|
export type SortKey =
|
||||||
|
| 'name'
|
||||||
|
| 'queuePosition'
|
||||||
|
| 'addedDate'
|
||||||
|
| 'doneDate'
|
||||||
|
| 'activityDate'
|
||||||
|
| 'percentDone'
|
||||||
|
| 'totalSize'
|
||||||
|
| 'rateDownload'
|
||||||
|
| 'rateUpload'
|
||||||
|
| 'uploadRatio'
|
||||||
|
| 'status'
|
||||||
|
| 'eta'
|
||||||
|
| 'downloadedEver'
|
||||||
|
| 'uploadedEver'
|
||||||
|
| 'peersSendingToUs'
|
||||||
|
| 'peersGettingFromUs'
|
||||||
|
| 'secondsSeeding'
|
||||||
|
| 'downloadDir'
|
||||||
|
| 'officerTrackerHost'
|
||||||
|
| 'id';
|
||||||
|
|
||||||
|
const DEFAULT_SORT: SortKey = 'queuePosition';
|
||||||
|
|
||||||
|
/** Param names, in one place so the nav, the table header and the screen guard cannot drift. */
|
||||||
|
const P = {
|
||||||
|
q: 'q',
|
||||||
|
status: 'status',
|
||||||
|
label: 'label',
|
||||||
|
tracker: 'tracker',
|
||||||
|
dir: 'dir',
|
||||||
|
error: 'err',
|
||||||
|
sort: 'sort',
|
||||||
|
desc: 'desc',
|
||||||
|
selected: 'selected',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TorrentFilters = {
|
||||||
|
q: string;
|
||||||
|
status: StatusFilterId;
|
||||||
|
label: string | null;
|
||||||
|
tracker: string | null;
|
||||||
|
downloadDir: string | null;
|
||||||
|
errorOnly: boolean;
|
||||||
|
sort: SortKey;
|
||||||
|
desc: boolean;
|
||||||
|
/** Any of the narrowing filters (not sort, not selection) is engaged. */
|
||||||
|
isFiltered: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTorrentFilters() {
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
|
||||||
|
const rawStatus = params.get(P.status);
|
||||||
|
const filters = useMemo<TorrentFilters>(() => {
|
||||||
|
const q = params.get(P.q) ?? '';
|
||||||
|
const status = isStatusFilter(rawStatus) ? rawStatus : 'all';
|
||||||
|
const label = params.get(P.label);
|
||||||
|
const tracker = params.get(P.tracker);
|
||||||
|
const downloadDir = params.get(P.dir);
|
||||||
|
const errorOnly = params.get(P.error) === '1';
|
||||||
|
return {
|
||||||
|
q,
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
tracker,
|
||||||
|
downloadDir,
|
||||||
|
errorOnly,
|
||||||
|
sort: (params.get(P.sort) as SortKey) || DEFAULT_SORT,
|
||||||
|
desc: params.get(P.desc) === '1',
|
||||||
|
isFiltered: !!q || status !== 'all' || !!label || !!tracker || !!downloadDir || errorOnly,
|
||||||
|
};
|
||||||
|
// params is a fresh URLSearchParams object on every render, so depend on the values instead of the
|
||||||
|
// object — otherwise every render produces a new filters object and re-sorts the whole list.
|
||||||
|
}, [
|
||||||
|
params.get(P.q),
|
||||||
|
rawStatus,
|
||||||
|
params.get(P.label),
|
||||||
|
params.get(P.tracker),
|
||||||
|
params.get(P.dir),
|
||||||
|
params.get(P.error),
|
||||||
|
params.get(P.sort),
|
||||||
|
params.get(P.desc),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes go through the functional form of setSearchParams so concurrent updates (typing in the search
|
||||||
|
* box while a filter menu closes) compose instead of clobbering each other. `replace` keeps the back
|
||||||
|
* button useful: forty keystrokes should not be forty history entries.
|
||||||
|
*/
|
||||||
|
const patch = useCallback(
|
||||||
|
(next: Partial<Record<keyof typeof P, string | null>>, replace = true) => {
|
||||||
|
setParams(
|
||||||
|
(prev) => {
|
||||||
|
const out = new URLSearchParams(prev);
|
||||||
|
for (const [key, value] of Object.entries(next)) {
|
||||||
|
const param = P[key as keyof typeof P];
|
||||||
|
if (value === null || value === '') out.delete(param);
|
||||||
|
else out.set(param, value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
{ replace },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setSearch = useCallback((q: string) => patch({ q: q || null }), [patch]);
|
||||||
|
const setStatus = useCallback((s: StatusFilterId) => patch({ status: s === 'all' ? null : s }), [patch]);
|
||||||
|
|
||||||
|
/** Menu entries toggle: clicking the one already applied clears it. Saves a separate "clear" affordance. */
|
||||||
|
const toggle = useCallback(
|
||||||
|
(key: 'label' | 'tracker' | 'dir' | 'error', value: string, current: string | null) =>
|
||||||
|
patch({ [key]: current === value ? null : value }),
|
||||||
|
[patch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(
|
||||||
|
() => patch({ q: null, status: null, label: null, tracker: null, dir: null, error: null }, false),
|
||||||
|
[patch],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorting toggles direction when you pick the column that is already sorted. New columns start ascending,
|
||||||
|
* except the ones where "most" is the interesting end — nobody opens a torrent list to see the slowest.
|
||||||
|
*/
|
||||||
|
const setSort = useCallback(
|
||||||
|
(key: SortKey, currentSort: SortKey, currentDesc: boolean) => {
|
||||||
|
if (key === currentSort) return patch({ sort: key, desc: currentDesc ? null : '1' });
|
||||||
|
return patch({ sort: key, desc: DESC_BY_DEFAULT.has(key) ? '1' : null });
|
||||||
|
},
|
||||||
|
[patch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedId = Number(params.get(P.selected));
|
||||||
|
const selected = Number.isFinite(selectedId) && selectedId > 0 ? selectedId : null;
|
||||||
|
const setSelected = useCallback((id: number | null) => patch({ selected: id ? String(id) : null }), [patch]);
|
||||||
|
|
||||||
|
return { filters, setSearch, setStatus, toggle, clearFilters, setSort, selected, setSelected };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DESC_BY_DEFAULT = new Set<SortKey>([
|
||||||
|
'addedDate',
|
||||||
|
'doneDate',
|
||||||
|
'activityDate',
|
||||||
|
'rateDownload',
|
||||||
|
'rateUpload',
|
||||||
|
'uploadRatio',
|
||||||
|
'totalSize',
|
||||||
|
'downloadedEver',
|
||||||
|
'uploadedEver',
|
||||||
|
'peersSendingToUs',
|
||||||
|
'peersGettingFromUs',
|
||||||
|
'secondsSeeding',
|
||||||
|
'percentDone',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Predicates ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function matchesStatus(t: Torrent, filter: StatusFilterId): boolean {
|
||||||
|
switch (filter) {
|
||||||
|
case 'all':
|
||||||
|
return true;
|
||||||
|
// "Active" means moving bytes right now, which is not the same as "not stopped" — a seeding torrent
|
||||||
|
// with no peers is running but idle, and lumping it in here makes the filter useless.
|
||||||
|
case 'active':
|
||||||
|
return t.rateDownload > 0 || t.rateUpload > 0;
|
||||||
|
case 'downloading':
|
||||||
|
return t.status === STATUS.Download || t.status === STATUS.DownloadWait;
|
||||||
|
case 'seeding':
|
||||||
|
return t.status === STATUS.Seed || t.status === STATUS.SeedWait;
|
||||||
|
case 'paused':
|
||||||
|
return t.status === STATUS.Stopped;
|
||||||
|
case 'finished':
|
||||||
|
return t.percentDone >= 1;
|
||||||
|
case 'checking':
|
||||||
|
return t.status === STATUS.Check || t.status === STATUS.CheckWait;
|
||||||
|
case 'error':
|
||||||
|
return t.error !== 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compare = (a: Torrent, b: Torrent, key: SortKey): number => {
|
||||||
|
const av = a[key as keyof Torrent];
|
||||||
|
const bv = b[key as keyof Torrent];
|
||||||
|
if (typeof av === 'string' || typeof bv === 'string') {
|
||||||
|
return String(av ?? '').localeCompare(String(bv ?? ''), undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
return Number(av ?? 0) - Number(bv ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the filters and the sort. Pure, and memoised by the caller — 43 torrents is nothing but the list
|
||||||
|
* re-renders every 5 seconds forever, and this also runs on every keystroke in the search box.
|
||||||
|
*/
|
||||||
|
export function selectTorrents(torrents: Torrent[], filters: TorrentFilters): Torrent[] {
|
||||||
|
const needle = filters.q.trim().toLowerCase();
|
||||||
|
|
||||||
|
const out = torrents.filter((t) => {
|
||||||
|
if (!matchesStatus(t, filters.status)) return false;
|
||||||
|
if (filters.errorOnly && t.error === 0) return false;
|
||||||
|
if (filters.label && !t.labels.includes(filters.label)) return false;
|
||||||
|
if (filters.tracker && t.officerTrackerHost !== filters.tracker) return false;
|
||||||
|
if (filters.downloadDir && t.downloadDir !== filters.downloadDir) return false;
|
||||||
|
if (needle && !t.name.toLowerCase().includes(needle) && !t.labels.some((l) => l.toLowerCase().includes(needle)))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
out.sort((a, b) => {
|
||||||
|
const primary = compare(a, b, filters.sort);
|
||||||
|
// Ties on a coarse key (status, priority, a rate of zero) would otherwise reshuffle on every poll,
|
||||||
|
// because Array.sort is only stable with respect to the input order and the input is a fresh fetch.
|
||||||
|
return (filters.desc ? -primary : primary) || a.id - b.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The distinct values the nav offers as filters, each with how many torrents carry it. */
|
||||||
|
export type FacetEntry = { value: string; count: number };
|
||||||
|
|
||||||
|
export function buildFacets(torrents: Torrent[]) {
|
||||||
|
const labels = new Map<string, number>();
|
||||||
|
const trackers = new Map<string, number>();
|
||||||
|
const dirs = new Map<string, number>();
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const t of torrents) {
|
||||||
|
for (const label of t.labels) labels.set(label, (labels.get(label) ?? 0) + 1);
|
||||||
|
if (t.officerTrackerHost) trackers.set(t.officerTrackerHost, (trackers.get(t.officerTrackerHost) ?? 0) + 1);
|
||||||
|
if (t.downloadDir) dirs.set(t.downloadDir, (dirs.get(t.downloadDir) ?? 0) + 1);
|
||||||
|
if (t.error !== 0) errors += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSorted = (m: Map<string, number>): FacetEntry[] =>
|
||||||
|
[...m.entries()]
|
||||||
|
.map(([value, count]) => ({ value, count }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
||||||
|
|
||||||
|
return { labels: toSorted(labels), trackers: toSorted(trackers), dirs: toSorted(dirs), errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-status counts for the nav badges, computed in one pass rather than eight filters. */
|
||||||
|
export function countByStatus(torrents: Torrent[]): Record<StatusFilterId, number> {
|
||||||
|
const counts = Object.fromEntries(STATUS_FILTERS.map((f) => [f.id, 0])) as Record<StatusFilterId, number>;
|
||||||
|
for (const t of torrents) {
|
||||||
|
for (const f of STATUS_FILTERS) if (matchesStatus(t, f.id)) counts[f.id] += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import type { SessionSettings, SessionStats, Torrent, TorrentAction } from './shared';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
// Data layer for the /transmission panels. Everything talks to the officer-transmission sidecar through the
|
||||||
|
// /api/transmission auth proxy.
|
||||||
|
//
|
||||||
|
// Transmission has no push channel — it is poll-only RPC — so the list refetches on an interval rather than
|
||||||
|
// subscribing. Mutations invalidate the whole ['transmission'] prefix instead of patching caches: starting
|
||||||
|
// one torrent changes the session speed totals and the queue positions of every torrent below it, so a
|
||||||
|
// targeted patch would be wrong more often than it was cheap.
|
||||||
|
|
||||||
|
const TORRENTS_KEY = ['transmission', 'torrents'] as const;
|
||||||
|
const SESSION_KEY = ['transmission', 'session'] as const;
|
||||||
|
const STATS_KEY = ['transmission', 'stats'] as const;
|
||||||
|
|
||||||
|
const EMPTY_TORRENTS: Torrent[] = [];
|
||||||
|
|
||||||
|
/** How often the list re-reads. Matches the reference client's default and is cheap against loopback. */
|
||||||
|
const LIST_POLL_MS = 5_000;
|
||||||
|
/** Session settings change only when someone edits them, so this is a safety net, not a live feed. */
|
||||||
|
const SESSION_POLL_MS = 60_000;
|
||||||
|
|
||||||
|
export function useTorrents() {
|
||||||
|
const { get } = useClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: TORRENTS_KEY,
|
||||||
|
queryFn: () => get<{ torrents: Torrent[] }>('/transmission/_officer/torrents'),
|
||||||
|
refetchInterval: LIST_POLL_MS,
|
||||||
|
// Anything shorter than the poll would make an unrelated remount trigger a second fetch immediately.
|
||||||
|
staleTime: LIST_POLL_MS - 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
torrents: query.data?.torrents ?? EMPTY_TORRENTS,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
error: query.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One torrent with the expensive arrays (files, peers, trackers). Kept separate from the list because those
|
||||||
|
* arrays are per-file and per-peer — pulling them for every row on every poll is what makes naive
|
||||||
|
* Transmission clients hammer the daemon.
|
||||||
|
*/
|
||||||
|
export function useTorrentDetail(id: number | null) {
|
||||||
|
const { get } = useClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['transmission', 'torrent', id] as const,
|
||||||
|
queryFn: () => get<{ torrent: Torrent }>(`/transmission/_officer/torrents/${id}`),
|
||||||
|
enabled: id != null,
|
||||||
|
refetchInterval: LIST_POLL_MS,
|
||||||
|
staleTime: LIST_POLL_MS - 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { torrent: query.data?.torrent ?? null, isLoading: query.isLoading, error: query.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionStats() {
|
||||||
|
const { get } = useClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: STATS_KEY,
|
||||||
|
queryFn: () => get<{ stats: SessionStats }>('/transmission/_officer/stats'),
|
||||||
|
refetchInterval: LIST_POLL_MS,
|
||||||
|
staleTime: LIST_POLL_MS - 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { stats: query.data?.stats ?? null, isLoading: query.isLoading, error: query.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
const { get, post } = useClient();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: SESSION_KEY,
|
||||||
|
queryFn: () => get<{ session: SessionSettings }>('/transmission/_officer/session'),
|
||||||
|
refetchInterval: SESSION_POLL_MS,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (patch: Record<string, unknown>) =>
|
||||||
|
post<{ session: SessionSettings }>('/transmission/_officer/session', patch),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
// The sidecar reads the settings back after writing, because Transmission clamps several of them.
|
||||||
|
// Seeding the cache with that response means the form shows what the daemon actually holds.
|
||||||
|
qc.setQueryData(SESSION_KEY, data);
|
||||||
|
toast.success('Settings saved');
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(errorMessage(err, 'Could not save settings')),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: query.data?.session ?? null,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
error: query.error,
|
||||||
|
save,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free space at a path, as the daemon sees it. Worth stating plainly: this is the *daemon's* filesystem,
|
||||||
|
* which for a containerised Transmission is not the same namespace as the platform's — the number is
|
||||||
|
* meaningful, the path may not resolve to anything on this host.
|
||||||
|
*/
|
||||||
|
export function useFreeSpace(path: string | undefined) {
|
||||||
|
const { get } = useClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['transmission', 'free-space', path] as const,
|
||||||
|
queryFn: () =>
|
||||||
|
get<{ path: string; bytes: number }>(`/transmission/_officer/free-space?path=${encodeURIComponent(path!)}`),
|
||||||
|
enabled: !!path,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { bytes: query.data?.bytes ?? null, isLoading: query.isLoading, error: query.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddTorrentInput = {
|
||||||
|
metainfo?: string;
|
||||||
|
filename?: string;
|
||||||
|
downloadDir?: string;
|
||||||
|
labels?: string[];
|
||||||
|
paused?: boolean;
|
||||||
|
bandwidthPriority?: number;
|
||||||
|
sequentialDownload?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddTorrentResult = { status: 'added' | 'duplicate'; torrent: { id: number; name: string } | null };
|
||||||
|
|
||||||
|
export function useTorrentMutations() {
|
||||||
|
const { post } = useClient();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['transmission'] });
|
||||||
|
|
||||||
|
const act = useMutation({
|
||||||
|
mutationFn: ({ ids, action }: { ids: number[]; action: TorrentAction }) =>
|
||||||
|
post<{ ok: true; affected: number }>('/transmission/_officer/torrents/action', { ids, action }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: (err) => toast.error(errorMessage(err, 'Action failed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const set = useMutation({
|
||||||
|
mutationFn: ({ ids, ...fields }: { ids: number[] } & Record<string, unknown>) =>
|
||||||
|
post<{ ok: true }>('/transmission/_officer/torrents/set', { ids, ...fields }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: (err) => toast.error(errorMessage(err, 'Could not apply the change')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: ({ ids, deleteLocalData }: { ids: number[]; deleteLocalData: boolean }) =>
|
||||||
|
post<{ ok: true; affected: number }>('/transmission/_officer/torrents/remove', { ids, deleteLocalData }),
|
||||||
|
onSuccess: (data, vars) => {
|
||||||
|
invalidate();
|
||||||
|
toast.success(
|
||||||
|
`Removed ${vars.ids.length} torrent${vars.ids.length === 1 ? '' : 's'}${
|
||||||
|
vars.deleteLocalData ? ' and their data' : ''
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(errorMessage(err, 'Could not remove')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setLocation = useMutation({
|
||||||
|
mutationFn: ({ ids, location, move }: { ids: number[]; location: string; move: boolean }) =>
|
||||||
|
post<{ ok: true }>('/transmission/_officer/torrents/location', { ids, location, move }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast.success('Location updated');
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(errorMessage(err, 'Could not change location')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rename = useMutation({
|
||||||
|
mutationFn: ({ id, path, name }: { id: number; path: string; name: string }) =>
|
||||||
|
post<{ ok: true }>('/transmission/_officer/torrents/rename', { id, path, name }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast.success('Renamed');
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(errorMessage(err, 'Could not rename')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: (input: AddTorrentInput) => post<AddTorrentResult>('/transmission/_officer/torrents/add', input),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: (err) => toast.error(errorMessage(err, 'Could not add torrent')),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { act, set, remove, setLocation, rename, add };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMaintenance() {
|
||||||
|
const { post } = useClient();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const portTest = useMutation({
|
||||||
|
mutationFn: () => post<{ open: boolean }>('/transmission/_officer/port-test'),
|
||||||
|
onSuccess: (data) => (data.open ? toast.success('Peer port is open') : toast.error('Peer port is closed')),
|
||||||
|
onError: (err) => toast.error(errorMessage(err, 'Port test failed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBlocklist = useMutation({
|
||||||
|
mutationFn: () => post<{ size: number }>('/transmission/_officer/blocklist-update'),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
qc.invalidateQueries({ queryKey: SESSION_KEY });
|
||||||
|
toast.success(`Blocklist updated — ${data.size.toLocaleString()} rules`);
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(errorMessage(err, 'Blocklist update failed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { portTest, updateBlocklist };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useClient rejects with `{status, message}` rather than an Error, and the sidecar's message is a JSON body.
|
||||||
|
* Unwrap both so the toast says "transmission upstream unreachable" instead of "[object Object]".
|
||||||
|
*/
|
||||||
|
function errorMessage(err: unknown, fallback: string): string {
|
||||||
|
const raw = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : '';
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { error?: string };
|
||||||
|
if (parsed.error) return parsed.error;
|
||||||
|
} catch {
|
||||||
|
/* not JSON — use it as-is */
|
||||||
|
}
|
||||||
|
return raw || fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { DEFAULT_TRANSMISSION_SECTION, isTransmissionSection, type TransmissionSectionId } from './shared';
|
||||||
|
|
||||||
|
// The URL names the open section — not a panel channel. See docs/navigation-audit.md. TransmissionScreen
|
||||||
|
// redirects anything unrecognised, so the fallback here only covers the instant before that lands.
|
||||||
|
|
||||||
|
export function useTransmissionSection(): TransmissionSectionId {
|
||||||
|
const { section } = useParams();
|
||||||
|
return isTransmissionSection(section) ? section : DEFAULT_TRANSMISSION_SECTION;
|
||||||
|
}
|
||||||
@@ -30,6 +30,14 @@ export { CodeEditorView } from './apps/CodeEditor';
|
|||||||
// The route helpers, so the /headscale screen and the nav agree on one spelling of the section URL.
|
// The route helpers, so the /headscale screen and the nav agree on one spelling of the section URL.
|
||||||
export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from './apps/Headscale/shared';
|
export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from './apps/Headscale/shared';
|
||||||
export type { HeadscaleSectionId } from './apps/Headscale/shared';
|
export type { HeadscaleSectionId } from './apps/Headscale/shared';
|
||||||
|
|
||||||
|
// Same for /transmission.
|
||||||
|
export {
|
||||||
|
DEFAULT_TRANSMISSION_SECTION,
|
||||||
|
transmissionSectionPath,
|
||||||
|
isTransmissionSection,
|
||||||
|
} from './apps/Transmission/shared';
|
||||||
|
export type { TransmissionSectionId } from './apps/Transmission/shared';
|
||||||
export {
|
export {
|
||||||
useFilesAPI,
|
useFilesAPI,
|
||||||
useTasks,
|
useTasks,
|
||||||
|
|||||||
Reference in New Issue
Block a user