move transmission and slskd credentials into the database
both sidecars read their upstream from a new service_connections table instead of process.env: one row per (user, service), the secret encrypted at rest, upserted through a /_config route the app drives. transmission gains a Connection section, soulseek gains one too, and both take over the whole app while nothing is stored. TRANSMISSION_URL/USER/PASS/RPC_PATH and SLSKD_URL/API_KEY can come out of .env. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import type { ServiceConnection } from '../../hooks/useServiceConnection';
|
||||
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 '../../hooks/useServiceConnection';
|
||||
|
||||
// Connecting a slskd daemon to Officer, from the app.
|
||||
//
|
||||
// Both the setup wizard and the permanent settings page: SoulseekView renders it in place of whatever
|
||||
// section the nav asked for while nothing is connected, and the Connection section 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.
|
||||
//
|
||||
// The API key is write-only — the GET it reads has no field that could carry one back, so the input is
|
||||
// always blank and an empty input means "keep the stored key".
|
||||
|
||||
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
||||
|
||||
/** slskd's own default HTTP port. Officer dials it from the server, not from this browser. */
|
||||
const DEFAULT_URL = 'http://localhost:5030';
|
||||
|
||||
const URL_HINT =
|
||||
"The daemon's base URL. Officer reaches it from the server, not from this browser — so localhost here " +
|
||||
'means the machine Officer runs on.';
|
||||
|
||||
const KEY_HINT =
|
||||
'An API key from slskd.yml (web.authentication.api_keys). Officer sends it as X-API-Key on every call.';
|
||||
|
||||
type SaveInput = Record<string, unknown> & { url: string };
|
||||
|
||||
export const SoulseekConnection = () => {
|
||||
const { data, isLoading } = useServiceConnection('slskd');
|
||||
const { data: health } = useServiceHealth('slskd');
|
||||
const { save, forget } = useServiceConnectionActions<SaveInput>('slskd');
|
||||
|
||||
const connection = data?.connection ?? null;
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Seed from the stored row once it arrives. Keyed on its id so a save (same id) doesn't stomp what the
|
||||
// owner is still typing, while forgetting and re-adding does reset the form.
|
||||
useEffect(() => {
|
||||
setUrl(connection?.url ?? '');
|
||||
setApiKey('');
|
||||
}, [connection?.id]);
|
||||
|
||||
const submit = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await save.mutateAsync({ url: url.trim(), apiKey: apiKey.trim() });
|
||||
setApiKey('');
|
||||
} 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-sky-500/15 text-sky-500">
|
||||
<Plug className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">slskd daemon</h2>
|
||||
<p className={HINT}>
|
||||
{connection
|
||||
? 'Where Officer talks to slskd. Saving re-checks the daemon before storing anything.'
|
||||
: 'Point Officer at your slskd daemon. It needs the URL and one API key.'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{connection && <StatusRow connection={connection} health={health} />}
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium">Server URL</span>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(ev) => setUrl(ev.target.value)}
|
||||
placeholder={DEFAULT_URL}
|
||||
autoFocus={!connection}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className={HINT}>{URL_HINT}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium">API key</span>
|
||||
<Input
|
||||
value={apiKey}
|
||||
onChange={(ev) => setApiKey(ev.target.value)}
|
||||
placeholder={connection?.hasSecret ? '•••••••• (unchanged)' : 'slskd API key'}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className={HINT}>{KEY_HINT}</span>
|
||||
</label>
|
||||
|
||||
{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() || (!apiKey.trim() && !connection?.hasSecret) || 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-emerald-500" />
|
||||
) : (
|
||||
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
|
||||
)}
|
||||
<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 ? ` · slskd ${connection.version}` : ''}
|
||||
</div>
|
||||
{!health?.ok && health?.error && <div className="text-destructive">{health.error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { LayoutGrid, Search, ArrowDownToLine, ArrowUpFromLine, Hash, MessageCircle, Users, Server } from 'lucide-react';
|
||||
import {
|
||||
LayoutGrid,
|
||||
Search,
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Hash,
|
||||
MessageCircle,
|
||||
Users,
|
||||
Server,
|
||||
Plug,
|
||||
} from 'lucide-react';
|
||||
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared';
|
||||
|
||||
// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Publishes
|
||||
@@ -15,6 +25,7 @@ const ICONS: Record<SoulseekSectionId, LucideIcon> = {
|
||||
chat: MessageCircle,
|
||||
users: Users,
|
||||
system: Server,
|
||||
connection: Plug,
|
||||
};
|
||||
|
||||
export const SoulseekNav = () => {
|
||||
@@ -50,7 +61,9 @@ export const SoulseekNav = () => {
|
||||
{active && (
|
||||
<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 ${active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`} />
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ import { SoulseekDashboard } from './SoulseekDashboard';
|
||||
import { SoulseekChat } from './SoulseekChat';
|
||||
import { SoulseekUsers } from './SoulseekUsers';
|
||||
import { SoulseekSystem } from './SoulseekSystem';
|
||||
import { SoulseekConnection } from './SoulseekConnection';
|
||||
import { useServiceConnection } from '../../hooks/useServiceConnection';
|
||||
|
||||
// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. The panel
|
||||
// header's +/- controls set a per-panel zoom factor, persisted via useDashboardState (same store as the
|
||||
@@ -49,6 +51,8 @@ const sectionView = (section: SoulseekSectionId) => {
|
||||
return <SoulseekUsers />;
|
||||
case 'system':
|
||||
return <SoulseekSystem />;
|
||||
case 'connection':
|
||||
return <SoulseekConnection />;
|
||||
default:
|
||||
return <Placeholder id={section} />;
|
||||
}
|
||||
@@ -59,8 +63,13 @@ type SoulseekViewProps = { panelId: string };
|
||||
export const SoulseekView = ({ panelId }: SoulseekViewProps) => {
|
||||
const [section] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
|
||||
const { value: zoom } = useDashboardState<number>(soulseekZoomKey(panelId), 1);
|
||||
const { data: connection, isLoading } = useServiceConnection('slskd');
|
||||
const z = zoom ?? 1;
|
||||
|
||||
// Nothing connected yet: the setup form takes over every section, because none of them can do anything
|
||||
// without a daemon. Zoom is skipped for it too — it is a form, not a dense slskd panel.
|
||||
if (!isLoading && !connection?.configured) return <SoulseekConnection />;
|
||||
|
||||
if (z === 1) return <div className="h-full w-full">{sectionView(section)}</div>;
|
||||
|
||||
// transform: scale doesn't reflow, so size the box to 1/z and let the scale bring it back to 100%.
|
||||
|
||||
@@ -255,7 +255,8 @@ export type SoulseekSectionId =
|
||||
| 'rooms'
|
||||
| 'chat'
|
||||
| 'users'
|
||||
| 'system';
|
||||
| 'system'
|
||||
| 'connection';
|
||||
export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'search', label: 'Search' },
|
||||
@@ -265,6 +266,7 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'system', label: 'System' },
|
||||
{ id: 'connection', label: 'Connection' },
|
||||
];
|
||||
|
||||
// Published by the username dropdown (search results / downloads) to jump straight to a peer in the Users
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { ServiceConnection } from '../../hooks/useServiceConnection';
|
||||
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 '../../hooks/useServiceConnection';
|
||||
|
||||
// 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-amber-500/15 text-amber-500">
|
||||
<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-emerald-500" />
|
||||
) : (
|
||||
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
@@ -133,8 +133,8 @@ export const TorrentsView = () => {
|
||||
<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.
|
||||
The officer-transmission sidecar answered with an error. Check that the daemon is running, and that the URL
|
||||
under Connection still points at it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Folder,
|
||||
Gauge,
|
||||
ListChecks,
|
||||
Plug,
|
||||
Radio,
|
||||
Settings,
|
||||
Tag,
|
||||
@@ -33,6 +34,7 @@ const ICONS: Record<TransmissionSectionId, LucideIcon> = {
|
||||
torrents: ListChecks,
|
||||
stats: Gauge,
|
||||
settings: Settings,
|
||||
connection: Plug,
|
||||
};
|
||||
|
||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useServiceConnection } from '../../hooks/useServiceConnection';
|
||||
import { useTransmissionSection } from './useTransmissionSection';
|
||||
import { TorrentsView } from './TorrentsView';
|
||||
import { StatsView } from './StatsView';
|
||||
import { SettingsView } from './SettingsView';
|
||||
import { ConnectionView } from './ConnectionView';
|
||||
|
||||
// Right panel of the /transmission workspace — renders the section named by the URL.
|
||||
//
|
||||
// With no daemon configured every other section can only render an error, so the connection form takes over
|
||||
// until there is one. The URL is left alone: once connected, the section already in it is what appears.
|
||||
|
||||
export const TransmissionView = () => {
|
||||
const section = useTransmissionSection();
|
||||
const { data, isLoading } = useServiceConnection('transmission');
|
||||
|
||||
if (!isLoading && !data?.configured) return <ConnectionView />;
|
||||
|
||||
switch (section) {
|
||||
case 'connection':
|
||||
return <ConnectionView />;
|
||||
case 'stats':
|
||||
return <StatsView />;
|
||||
case 'settings':
|
||||
|
||||
@@ -10,6 +10,7 @@ export const TRANSMISSION_SECTIONS = [
|
||||
{ id: 'torrents', label: 'Torrents' },
|
||||
{ id: 'stats', label: 'Statistics' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
{ id: 'connection', label: 'Connection' },
|
||||
] as const;
|
||||
|
||||
export type TransmissionSectionId = (typeof TRANSMISSION_SECTIONS)[number]['id'];
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './useFilesAPI';
|
||||
export * from './useFileViewerPanels';
|
||||
export * from './useChat';
|
||||
export * from './useDock';
|
||||
export * from './useServiceConnection';
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
// The data layer for a SINGLE-connection sidecar — one where the owner has exactly one of the thing, so the
|
||||
// whole configuration is one row and one form. Transmission and slskd both work this way; the registries
|
||||
// (photos, invoiceshelf) do not, and deliberately have their own hooks.
|
||||
//
|
||||
// Every such sidecar serves the same three-verb contract at `/<service>/_config`, so the hook is written
|
||||
// once here rather than copied per app:
|
||||
//
|
||||
// GET → { configured, connection } never any secret, only whether one is stored
|
||||
// PUT → { connection } upsert, validated against the live service first
|
||||
// DELETE → { configured: false } forget it
|
||||
//
|
||||
// plus `/<service>/_health`, whose failure BODIES are the useful part — see useServiceHealth.
|
||||
|
||||
export type ServiceConnection = {
|
||||
id: number;
|
||||
service: string;
|
||||
url: string;
|
||||
username: string | null;
|
||||
path: string | null;
|
||||
hasSecret: boolean;
|
||||
version: string | null;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ServiceConnectionState = { configured: boolean; connection: ServiceConnection | null };
|
||||
|
||||
export type ServiceHealth = { ok: boolean; configured: boolean; version?: string | null; error?: string; ms?: number };
|
||||
|
||||
/** Unwrap the `{ status, message }` useClient throws, where `message` is the sidecar's JSON body. */
|
||||
export function serviceErrorMessage(err: unknown): string {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
|
||||
} catch {
|
||||
/* plain text */
|
||||
}
|
||||
return raw.slice(0, 300);
|
||||
}
|
||||
|
||||
const configKey = (service: string) => [service, 'connection'] as const;
|
||||
const healthKey = (service: string) => [service, 'health'] as const;
|
||||
|
||||
export function useServiceConnection(service: string) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: configKey(service),
|
||||
queryFn: () => get<ServiceConnectionState>(`/${service}/_config`),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Health, including its failure bodies.
|
||||
*
|
||||
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
|
||||
* the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a JSON body,
|
||||
* so the throw is turned back into the answer rather than an error state.
|
||||
*/
|
||||
export function useServiceHealth(service: string) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: healthKey(service),
|
||||
queryFn: async (): Promise<ServiceHealth> => {
|
||||
try {
|
||||
return await get<ServiceHealth>(`/${service}/_health`);
|
||||
} catch (err) {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const body = JSON.parse(raw) as ServiceHealth;
|
||||
if (body && body.ok === false) return body;
|
||||
} catch {
|
||||
/* not the sidecar's body */
|
||||
}
|
||||
}
|
||||
// Anything else — the platform proxy, auth, the sidecar being down — is a configured service that is
|
||||
// failing, not an unconfigured one. Never offer the setup form on a guess.
|
||||
return { ok: false, configured: true, error: serviceErrorMessage(err) };
|
||||
}
|
||||
},
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and forget. Both invalidate the WHOLE service prefix, not just the connection: re-pointing at a
|
||||
* different daemon invalidates every list, stat and setting already in the cache.
|
||||
*/
|
||||
export function useServiceConnectionActions<TInput extends Record<string, unknown>>(service: string) {
|
||||
const { put, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: [service] });
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (input: TInput) => put<{ connection: ServiceConnection }>(`/${service}/_config`, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const forget = useMutation({
|
||||
mutationFn: () => del<ServiceConnectionState>(`/${service}/_config`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { save, forget };
|
||||
}
|
||||
Reference in New Issue
Block a user