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:
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user