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:
2026-07-30 17:09:19 +00:00
co-authored by Claude Opus 5
parent c39460ce2d
commit 387664964c
47 changed files with 5273 additions and 0 deletions
+30
View File
@@ -72,6 +72,36 @@ Deferred work. Context: Officer is collapsing from multi-tenant / open-source-re
as one of the tabs (main view).
- 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
- [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale
+6
View File
@@ -76,5 +76,11 @@ module.exports = {
args: 'run src/servers/sidecar/headscale/index.ts',
watch: false,
},
{
name: 'officer-transmission',
script: 'bun',
args: 'run src/servers/sidecar/transmission/index.ts',
watch: false,
},
],
};
+2
View File
@@ -44,6 +44,8 @@ export function App() {
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/headscale" 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="/activity" element={<Dashboard.ActivityScreen />} />
@@ -135,6 +135,7 @@ import {
Activity,
Radio,
Network,
ArrowDownUp,
} from 'lucide-react';
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: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
{ 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: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ 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 './Soulseek';
export * from './Headscale';
export * from './Transmission';
export * from './SystemMonitor';
export * from './Activity';
export * from './CodeEditor';
@@ -18,6 +18,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
{ 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('/code-editor'), title: 'Code Editor' },
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
+50
View File
@@ -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;
}
+3
View File
@@ -23,6 +23,7 @@ import { musicRouter } from './api/music/router';
import { vaultRouter } from './api/vault/router';
import { slskdRouter } from './api/slskd/router';
import { headscaleRouter } from './api/headscale/router';
import { transmissionRouter } from './api/transmission/router';
import { vpnRouter } from './api/vpn/router';
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
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/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/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
import { dockRouter } from './api/dock/dock';
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
@@ -113,6 +115,7 @@ protectedRouter.route('/file-browser', fileBrowserRouter);
protectedRouter.route('/music', musicRouter);
protectedRouter.route('/slskd', slskdRouter);
protectedRouter.route('/headscale', headscaleRouter);
protectedRouter.route('/transmission', transmissionRouter);
protectedRouter.route('/vpn', vpnRouter);
protectedRouter.route('/system-monitor', systemMonitorRouter);
protectedRouter.route('/activity', activityRouter);
+2
View File
@@ -70,6 +70,8 @@ export type SidecarEvent =
| { type: 'slskd:server'; port: number }
// Headscale — the sidecar reports where its HTTP server is listening (random port) on connect
| { 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
| { type: 'error'; id?: string; error: string };
+127
View File
@@ -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'));
+370
View File
@@ -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 });
}
+149
View File
@@ -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;
}
+244
View File
@@ -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 soulseekMetas } from '../apps/Soulseek';
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { useAppRegistry } from './useAppRegistry';
import { useUserApps } from 'state/useUserApps';
@@ -33,6 +34,7 @@ const apps = [
...musicMetas,
...soulseekMetas,
...headscaleMetas,
...transmissionMetas,
...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>
);
};
@@ -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;
}
+8
View File
@@ -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.
export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } 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 {
useFilesAPI,
useTasks,