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
@@ -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';