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.
167 lines
7.4 KiB
TypeScript
167 lines
7.4 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import { Link } from 'react-router';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { ArrowDownToLine, ArrowUpFromLine, Search, RefreshCw, Radio } from 'lucide-react';
|
|
import {
|
|
SEARCH_PARAM,
|
|
formatWhen,
|
|
soulseekSectionPath,
|
|
transferPhase,
|
|
toneSolid,
|
|
PHASE_TONE,
|
|
type SlskdApplication,
|
|
type SlskdSearchSummary,
|
|
type SlskdTransferUser,
|
|
type TransferPhase,
|
|
} from './shared';
|
|
|
|
// Dashboard — the at-a-glance overview for the /soulseek workspace: connection health, live
|
|
// download/upload tallies, and the most recent searches. Read-only aggregation over the same slskd
|
|
// endpoints the other panels use; every tile and row is a real link into the section it summarises, and
|
|
// a recent search links to that search rather than to the search screen's front page.
|
|
|
|
const POLL_MS = 2000;
|
|
|
|
type Tally = Record<TransferPhase, number>;
|
|
const emptyTally = (): Tally => ({ downloading: 0, queued: 0, done: 0, failed: 0 });
|
|
const tally = (users: SlskdTransferUser[]): Tally => {
|
|
const t = emptyTally();
|
|
for (const u of users) for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++;
|
|
return t;
|
|
};
|
|
|
|
export const SoulseekDashboard = () => {
|
|
const client = useClient();
|
|
const [app, setApp] = useState<SlskdApplication | null>(null);
|
|
const [downloads, setDownloads] = useState<Tally>(emptyTally);
|
|
const [uploads, setUploads] = useState<Tally>(emptyTally);
|
|
const [searches, setSearches] = useState<SlskdSearchSummary[]>([]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const tick = async () => {
|
|
const [a, d, u, s] = await Promise.allSettled([
|
|
client.get<SlskdApplication>('/soulseek/api/v0/application'),
|
|
client.get<SlskdTransferUser[]>('/soulseek/api/v0/transfers/downloads'),
|
|
client.get<SlskdTransferUser[]>('/soulseek/api/v0/transfers/uploads'),
|
|
client.get<SlskdSearchSummary[]>('/soulseek/api/v0/searches'),
|
|
]);
|
|
if (cancelled) return;
|
|
if (a.status === 'fulfilled') setApp(a.value);
|
|
if (d.status === 'fulfilled') setDownloads(tally(d.value));
|
|
if (u.status === 'fulfilled') setUploads(tally(u.value));
|
|
if (s.status === 'fulfilled')
|
|
setSearches([...s.value].sort((x, y) => new Date(y.startedAt).getTime() - new Date(x.startedAt).getTime()));
|
|
};
|
|
tick();
|
|
const timer = setInterval(tick, POLL_MS);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(timer);
|
|
};
|
|
// useClient() is a fresh object each render — poll on a stable interval only.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const server = app?.server;
|
|
const connected = server?.isConnected ?? false;
|
|
const recent = useMemo(() => searches.slice(0, 6), [searches]);
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto p-4">
|
|
<div className="mx-auto flex max-w-3xl flex-col gap-4">
|
|
{/* Connection */}
|
|
<div className="flex items-center gap-3 rounded-xl border border-border bg-card 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>
|
|
{app?.version?.current && (
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
v{app.version.current}
|
|
{app.version.isUpdateAvailable ? ' · update available' : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Downloads */}
|
|
<Section title="Downloads" icon={<ArrowDownToLine className="h-4 w-4" />} to={soulseekSectionPath('downloads')}>
|
|
<Tile label="Downloading" value={downloads.downloading} dot={toneSolid[PHASE_TONE.downloading]} />
|
|
<Tile label="Queued" value={downloads.queued} dot={toneSolid[PHASE_TONE.queued]} />
|
|
<Tile label="Completed" value={downloads.done} dot={toneSolid[PHASE_TONE.done]} />
|
|
<Tile label="Failed" value={downloads.failed} dot={toneSolid[PHASE_TONE.failed]} />
|
|
</Section>
|
|
|
|
{/* Uploads */}
|
|
<Section title="Uploads" icon={<ArrowUpFromLine className="h-4 w-4" />} to={soulseekSectionPath('uploads')}>
|
|
<Tile label="Uploading" value={uploads.downloading} dot={toneSolid[PHASE_TONE.downloading]} />
|
|
<Tile label="Queued" value={uploads.queued} dot={toneSolid[PHASE_TONE.queued]} />
|
|
<Tile label="Completed" value={uploads.done} dot={toneSolid[PHASE_TONE.done]} />
|
|
<Tile label="Failed" value={uploads.failed} dot={toneSolid[PHASE_TONE.failed]} />
|
|
</Section>
|
|
|
|
{/* Recent searches */}
|
|
<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">
|
|
<Search className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium text-foreground">Recent searches</span>
|
|
<Link
|
|
to={soulseekSectionPath('search')}
|
|
className="ml-auto text-xs text-muted-foreground transition hover:text-foreground"
|
|
>
|
|
View all
|
|
</Link>
|
|
</div>
|
|
{recent.length === 0 ? (
|
|
<p className="px-4 py-3 text-sm text-muted-foreground">No searches yet.</p>
|
|
) : (
|
|
<div className="divide-y divide-border/50">
|
|
{recent.map((s) => (
|
|
<Link
|
|
key={s.id}
|
|
to={`${soulseekSectionPath('search')}?${SEARCH_PARAM}=${s.id}`}
|
|
className="flex w-full items-center gap-3 px-4 py-2 text-left transition hover:bg-muted"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{s.searchText}</span>
|
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{s.responseCount} responses</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">{formatWhen(s.startedAt)}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type SectionProps = { title: string; icon: React.ReactNode; to: string; children: React.ReactNode };
|
|
|
|
const Section = ({ title, icon, to, children }: SectionProps) => (
|
|
<div className="rounded-xl border border-border bg-card">
|
|
<Link
|
|
to={to}
|
|
className="flex w-full items-center gap-2 border-b border-border px-4 py-2.5 text-left transition hover:bg-muted"
|
|
>
|
|
<span className="text-muted-foreground">{icon}</span>
|
|
<span className="text-sm font-medium text-foreground">{title}</span>
|
|
<RefreshCw className="ml-auto h-3 w-3 text-muted-foreground" />
|
|
</Link>
|
|
<div className="grid grid-cols-2 gap-2 p-3 sm:grid-cols-4">{children}</div>
|
|
</div>
|
|
);
|
|
|
|
type TileProps = { label: string; value: number; dot: string };
|
|
|
|
const Tile = ({ label, value, dot }: TileProps) => (
|
|
<div className="flex flex-col gap-1 rounded-lg border border-border/50 bg-muted/20 px-3 py-2.5">
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<span className={`h-2 w-2 rounded-full ${dot}`} />
|
|
{label}
|
|
</div>
|
|
<div className="text-xl font-semibold tabular-nums text-foreground">{value}</div>
|
|
</div>
|
|
);
|