Files
transmission/web/ConnectionView.tsx
T
pastilhasandClaude Opus 5 864dd16355 transmission, extracted from the platform into its own repository
The sixth plugin out, immediately after photos. A sidecar, a proxy router and
29 web files; no db/ directory, because there are no tables.

── The one thing that is not self-contained ──

The connection lives in `service_connections`, a CORE table, reached through
getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin
declares no schema.

Deliberate: that table is the app store's shared credential store, keyed on
(user_id, service), already encrypted at rest and already read by
app-store/effects.ts. A private copy of the same three columns would be a
second place the same fact could live, and the runbook is explicit that a move
plus a data-format change gives any failure two causes.

The cost, stated rather than hidden: this plugin depends on a core facility it
does not own, and would break if that table changed shape. Photos, by contrast,
owns photos_config outright.

── 38 palette classes paid down, mapped by meaning ──

  blue     downloading / download speed  -> info
  cyan     checking (verifying)          -> busy
  emerald  seeding / complete            -> success
  amber    queued / stalled / warnings   -> warning

Collapsing blue and cyan into one tone would have lost a real distinction in
the status dot, so `checking` went to `busy` — in-progress work, which is what
that tone is for.

── A near-miss worth recording ──

The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/
— ten components. tsgo caught it as ten "cannot find module" errors. Worth
stating because it is quiet in the other direction: had those been imported
lazily, the typecheck would have passed and the panels would have broken at
runtime. Check the directory tree, not the file glob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:19:30 +00:00

220 lines
8.0 KiB
TypeScript

import type { ServiceConnection } from 'officerdev';
import { useEffect, useState } from 'react';
import { CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
serviceErrorMessage,
useServiceConnection,
useServiceConnectionActions,
useServiceHealth,
} from 'officerdev';
// Connecting a Transmission daemon to Officer, from the app.
//
// This screen is BOTH the setup wizard and the permanent settings page: TransmissionView renders it in place
// of whatever section the URL asks for while nothing is connected, and /transmission/connection renders it
// for good. One component, so re-pointing at a different daemon later goes through exactly the code path
// that stored the first one.
//
// ONE connection, not a registry — nobody runs two Transmission daemons. And usually one FIELD: most
// daemons run with no RPC auth at all, which is why username/password sit behind a disclosure rather than
// in the owner's way. An empty username means "no auth", not "empty credentials"; the sidecar is careful
// about that distinction because Transmission rejects a request carrying an empty Basic header.
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/** Transmission's own default RPC port. Officer dials it from the server, not from this browser. */
const DEFAULT_URL = 'http://localhost:9091';
const URL_HINT =
"The daemon's base URL, without the RPC path. Officer reaches it from the server, not from this browser " +
'— so localhost here means the machine Officer runs on.';
const AUTH_HINT =
'Only if the daemon has rpc-authentication-required set. Leave the username empty for the usual case: an ' +
'empty username means no authentication, and sending a blank one anyway makes Transmission refuse the call.';
const PATH_HINT = 'Only differs behind a reverse proxy that mounts the RPC endpoint somewhere else.';
type FieldProps = {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
type?: string;
autoFocus?: boolean;
};
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">{label}</span>
<Input
value={value}
onChange={(ev) => onChange(ev.target.value)}
placeholder={placeholder}
type={type}
autoFocus={autoFocus}
autoComplete="off"
spellCheck={false}
/>
{hint && <span className={HINT}>{hint}</span>}
</label>
);
type SaveInput = Record<string, unknown> & { url: string };
export const ConnectionView = () => {
const { data, isLoading } = useServiceConnection('transmission');
const { data: health } = useServiceHealth('transmission');
const { save, forget } = useServiceConnectionActions<SaveInput>('transmission');
const connection = data?.connection ?? null;
const [url, setUrl] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [rpcPath, setRpcPath] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [error, setError] = useState('');
// Seed the form from the stored row once it arrives. Keyed on the row's id so a save (which returns the
// same id) does not stomp what the owner is still typing, but forgetting and re-adding does reset it.
useEffect(() => {
setUrl(connection?.url ?? '');
setUsername(connection?.username ?? '');
setRpcPath(connection?.path ?? '');
setPassword('');
setShowAdvanced(!!connection?.username);
}, [connection?.id]);
const submit = async () => {
setError('');
try {
await save.mutateAsync({ url: url.trim(), username: username.trim(), password, rpcPath: rpcPath.trim() });
setPassword('');
} catch (err) {
setError(serviceErrorMessage(err));
}
};
const remove = async () => {
setError('');
try {
await forget.mutateAsync();
} catch (err) {
setError(serviceErrorMessage(err));
}
};
if (isLoading) {
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>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-6 p-6">
<header className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-warning/15 text-warning">
<Plug className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">Transmission daemon</h2>
<p className={HINT}>
{connection
? 'Where Officer talks to Transmission. Saving re-checks the daemon before storing anything.'
: 'Point Officer at your Transmission daemon. Usually the URL is all it needs.'}
</p>
</div>
</header>
{connection && <StatusRow connection={connection} health={health} />}
<div className="flex flex-col gap-4 rounded-xl border p-4">
<Field
label="Server URL"
hint={URL_HINT}
value={url}
onChange={setUrl}
placeholder={DEFAULT_URL}
autoFocus={!connection}
/>
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="self-start text-[11px] font-medium text-primary hover:underline"
>
{showAdvanced ? 'Hide' : 'Show'} authentication and RPC path
</button>
{showAdvanced && (
<>
<Field label="Username" hint={AUTH_HINT} value={username} onChange={setUsername} placeholder="(none)" />
<Field
label="Password"
value={password}
onChange={setPassword}
placeholder={connection?.hasSecret ? '•••••••• (unchanged)' : '(none)'}
type="password"
/>
<Field
label="RPC path"
hint={PATH_HINT}
value={rpcPath}
onChange={setRpcPath}
placeholder="/transmission/rpc"
/>
</>
)}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-2.5 text-xs text-destructive">
<TriangleAlert className="mt-px h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="flex items-center gap-2">
<Button size="sm" onClick={submit} disabled={!url.trim() || save.isPending}>
{save.isPending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{connection ? 'Save' : 'Connect'}
</Button>
{connection && (
<Button size="sm" variant="ghost" onClick={remove} disabled={forget.isPending}>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Disconnect
</Button>
)}
</div>
</div>
</div>
</div>
);
};
type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined };
const StatusRow = ({ connection, health }: StatusRowProps) => (
<div className="flex items-start gap-2 rounded-xl border p-3 text-xs">
{health?.ok ? (
<CheckCircle2 className="mt-px h-4 w-4 shrink-0 text-success" />
) : (
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-warning" />
)}
<div className="min-w-0">
<div className="font-medium">{health?.ok ? 'Connected' : 'Not responding'}</div>
<div className="truncate text-muted-foreground">
{connection.url}
{connection.version ? ` · Transmission ${connection.version}` : ''}
</div>
{!health?.ok && health?.error && <div className="text-destructive">{health.error}</div>}
</div>
</div>
);