Seventh plugin out, and the first that was already dead. Soulseek had been switched off since 2026-08-13: hono.ts had the router import and its mount commented out, so createSidecarProxy never ran, registerProxiedPrefix was never called, the slskd:server announcement fired into a void, and officer-slskd was not in the ecosystem at all. The permission entry had been reduced to api: [], which is why assertPermissionTotality was satisfied and the server booted clean. The UI rendered perfectly and every request 404'd. So this is a resurrection as well as a move. The mount is now /api/soulseek, derived via mountPrefix from the appName — all 47 frontend call sites repointed from /slskd/. The credential key in service_connections stays 'slskd': it names the daemon, not the route. Those two strings differ on purpose and conflating them reads the wrong row. The connection stays in core service_connections, as transmission does, so 'slskd' remains in a platform union this plugin cannot extend. Recorded in PLUGIN.md. Unlike transmission this plugin does own a schema: favourites and the two halves of the cached browse tree, which slskd has no concept of. Those three tables had been dropped from the drizzle barrel, so bun db:push would have deleted them on any server holding data. 313 palette classes converted to the semantic tone set, including a shared PHASE_TONE the three transfer views previously each copied.
146 lines
5.8 KiB
TypeScript
146 lines
5.8 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { toast } from 'sonner';
|
|
import { Server, Radio, Plug, PlugZap, RefreshCw, Package, Loader2 } from 'lucide-react';
|
|
import type { SlskdApplication, SlskdServerState } from './shared';
|
|
|
|
// System panel — connection health and controls for the slskd daemon. GET /application carries the
|
|
// server block (state/address/username) and version info; PUT /server connects to Soulseek, DELETE
|
|
// /server disconnects. Basic version: a connection card with connect/disconnect, and a version card.
|
|
|
|
const POLL_MS = 3000;
|
|
|
|
export const SoulseekSystem = () => {
|
|
const client = useClient();
|
|
const [app, setApp] = useState<SlskdApplication | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
setApp(await client.get<SlskdApplication>('/soulseek/api/v0/application'));
|
|
} catch {
|
|
/* connection card will show the stale/empty state */
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
const timer = setInterval(refresh, POLL_MS);
|
|
return () => clearInterval(timer);
|
|
}, [refresh]);
|
|
|
|
const server: SlskdServerState | undefined = app?.server;
|
|
const connected = server?.isConnected ?? false;
|
|
|
|
const connect = async () => {
|
|
setBusy(true);
|
|
try {
|
|
await client.put('/soulseek/api/v0/server');
|
|
toast.success('Connecting to Soulseek…');
|
|
await refresh();
|
|
} catch (err) {
|
|
toast.error(`Connect failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const disconnect = async () => {
|
|
setBusy(true);
|
|
try {
|
|
await client.delete('/soulseek/api/v0/server');
|
|
toast.success('Disconnected from Soulseek.');
|
|
await refresh();
|
|
} catch (err) {
|
|
toast.error(`Disconnect failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const version = app?.version;
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto p-4">
|
|
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<Server className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium text-foreground">System</span>
|
|
<button
|
|
type="button"
|
|
onClick={refresh}
|
|
title="Refresh"
|
|
className="ml-auto rounded p-1 text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Connection */}
|
|
<div className="rounded-xl border border-border bg-card">
|
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
|
<Radio className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${connected ? 'bg-success' : 'bg-destructive'}`} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium text-foreground">{server?.state ?? 'Unknown'}</div>
|
|
{server?.address && <div className="truncate text-xs text-muted-foreground">{server.address}</div>}
|
|
</div>
|
|
{server?.username && <span className="shrink-0 text-xs text-muted-foreground">{server.username}</span>}
|
|
</div>
|
|
<div className="flex items-center gap-2 p-3">
|
|
<button
|
|
type="button"
|
|
onClick={connect}
|
|
disabled={busy || connected}
|
|
className="flex items-center gap-1.5 rounded-lg border border-border bg-muted/40 px-3 py-1.5 text-sm text-foreground transition hover:bg-muted disabled:opacity-40"
|
|
>
|
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <PlugZap className="h-3.5 w-3.5" />}
|
|
Connect
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={disconnect}
|
|
disabled={busy || !connected}
|
|
className="flex items-center gap-1.5 rounded-lg border border-border bg-muted/40 px-3 py-1.5 text-sm text-foreground transition hover:bg-muted disabled:opacity-40"
|
|
>
|
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plug className="h-3.5 w-3.5" />}
|
|
Disconnect
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Version */}
|
|
<div className="rounded-xl border border-border bg-card">
|
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
|
<Package className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium text-foreground">Version</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 p-3">
|
|
<Field label="Current" value={version?.current ?? '—'} />
|
|
<Field label="Latest" value={version?.latest ?? '—'} />
|
|
<div className="col-span-2">
|
|
{version?.isUpdateAvailable ? (
|
|
<span className="inline-flex items-center gap-1.5 rounded-md bg-warning/15 px-2 py-1 text-xs text-warning">
|
|
<Package className="h-3.5 w-3.5" /> Update available
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">Up to date</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type FieldProps = { label: string; value: string };
|
|
|
|
const Field = ({ label, value }: FieldProps) => (
|
|
<div className="flex flex-col gap-1 rounded-lg border border-border/50 bg-muted/20 px-3 py-2">
|
|
<span className="text-xs text-muted-foreground">{label}</span>
|
|
<span className="truncate text-sm font-medium tabular-nums text-foreground">{value}</span>
|
|
</div>
|
|
);
|