Files
server-26/drb-frontend/app/settings/discord/page.tsx
T
Logan CusanoandClaude Opus 5.5 d3277c13e1 Discord admin page: servers, radio channels, radio bots (server-26#213 item 2)
Frontend, built from design-canvas/discord-system-mockup.html ("DRB admin"):
- Settings > Discord (/settings/discord, platform admin only; the tab is
  hidden from org owners because every route behind it is admin-only).
  KPIs + tabs:
  * Radio channels: every discord_streams doc, desired -> actual, the node's
    real error, red after 2 min desired-on without connecting. Stop
    (POST /discord/guilds/{id}/stop), Start for a stopped channel and
    "+ Add radio channel" (POST /discord/streams), with the invite links a
    "no bot in this server" refusal carries.
  * Servers: linked servers plus servers seen running radio while unlinked
    (acting for DISCORD_UNLINKED_GUILD_ORG_ID); Link / Change org
    (PUT /discord/guilds/{id}/link).
  * Radio bots: valid / check_error, guild membership with a per-server
    invite link for each server the bot is missing from, lease (node,
    since, releasing), fan-out, Refresh (POST /tokens/{id}/refresh),
    Remove, Release all leases (POST /tokens/flush), add with optional BYO org.
  * How it works.
- /tokens now renders the same radio-bot table (new token fields + refresh).
- Left out, no backend yet: alert routes, incident forum/roles/plan columns,
  per-channel talkgroup chips, "Generate link code" (item 1).

Backend (routers/discord.py):
- GET /discord/admin/overview (platform admin): guilds, streams with node
  and bot names, orgs, systems, limits. No token strings, no Discord user
  ids, no linking-admin uid.
- PUT /discord/guilds/{id}/link: digits-only id, optional guild_name label,
  and re-linking a server to a different org stops its live channels
  (the reconciler would otherwise keep re-placing them on the old org's
  nodes, using its stored org_id).
- POST /discord/streams: guild_id/channel_id must be digits (the web form
  types them in; the bot always sent snowflakes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:05:35 -04:00

170 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// Settings › Discord — the admin page from design-canvas/discord-system-mockup.html
// ("DRB admin" view), server-26#213 item 2. Desired state lives here; nodes
// reconcile and report back. Platform admin only for now: every route behind
// it (GET /discord/admin/overview, /tokens, PUT /discord/guilds/{id}/link) is
// require_admin_token. The mockup's "Alert routes" tab, incident-forum /
// roles / plan columns and per-channel talkgroup chips have no backend yet and
// are left out rather than shown as dead controls.
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { type DiscordAdminOverview, type RadioBot, parseC2Error, streamHealth } from "@/lib/discord";
import { ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { StreamsPanel } from "@/components/discord/StreamsPanel";
import { ServersPanel } from "@/components/discord/ServersPanel";
import { RadioBotsPanel } from "@/components/discord/RadioBotsPanel";
import { Panel } from "@/components/discord/ui";
type Tab = "streams" | "guilds" | "bots" | "how";
const TABS: { key: Tab; label: string }[] = [
{ key: "streams", label: "Radio channels" },
{ key: "guilds", label: "Servers" },
{ key: "bots", label: "Radio bots" },
{ key: "how", label: "How it works" },
];
const POLL_MS = 10_000;
function Kpi({ k, v, s }: { k: string; v: string; s: string }) {
return (
<div className="bg-surface border border-line rounded-xl p-3">
<div className="text-xs text-ink-muted">{k}</div>
<div className="text-xl font-semibold text-ink mt-0.5">{v}</div>
<div className="text-xs text-ink-muted">{s}</div>
</div>
);
}
function HowItWorks() {
const steps = [
["1 · Desired", "/join in Discord or this page writes a discord_streams doc."],
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server, in one transaction."],
["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."],
["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."],
["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."],
];
return (
<Panel
title="How a radio channel stays up"
note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab."
>
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
{steps.map(([t, d]) => (
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
<b className="block text-ink text-sm mb-1">{t}</b>
{d}
</div>
))}
</div>
</Panel>
);
}
export default function DiscordSettingsPage() {
const { isAdmin, loading: authLoading } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<Tab>("streams");
const [overview, setOverview] = useState<DiscordAdminOverview | null>(null);
const [bots, setBots] = useState<RadioBot[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/settings/organization");
}, [authLoading, isAdmin, router]);
useEffect(() => {
const h = window.location.hash.slice(1);
if (TABS.some((t) => t.key === h)) setTab(h as Tab);
}, []);
const load = useCallback(async () => {
try {
const [o, t] = await Promise.all([c2api.getDiscordAdmin(), c2api.getTokens()]);
setOverview(o);
setBots(t as RadioBot[]);
setError(null);
} catch (err) {
setError(parseC2Error(err).message);
}
}, []);
useEffect(() => {
if (!isAdmin) return;
load();
// Actual state is reported by nodes; poll while the tab is visible.
const id = setInterval(() => {
if (document.visibilityState === "visible") load();
}, POLL_MS);
return () => clearInterval(id);
}, [isAdmin, load]);
// Every hook runs before this guard (React error #310 — see app/nodes/page.tsx).
if (authLoading || !isAdmin) return null;
const linked = overview?.guilds.filter((g) => g.linked).length ?? 0;
const unlinked = (overview?.guilds.length ?? 0) - linked;
const live = overview?.streams.filter((s) => s.desired === "on") ?? [];
const connected = live.filter((s) => streamHealth(s).health === "ok").length;
const stuck = live.filter((s) => streamHealth(s).health === "bad").length;
const leased = bots.filter((b) => b.lease).length;
const byo = bots.filter((b) => b.org_id).length;
const attention = bots.filter((b) => b.valid === false || b.guild_ids == null).length;
return (
<div className="space-y-4">
<div>
<h2 className="text-ink font-semibold text-lg">Discord</h2>
<p className="text-ink-muted text-sm mt-0.5">
Desired state lives here; nodes reconcile and report back. Voice audio comes from each node&apos;s PulseAudio
monitor, never Icecast.
</p>
</div>
{error && <ErrorBanner message={error} />}
{!overview && !error ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
) : overview ? (
<>
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
<Kpi k="Linked servers" v={String(linked)} s={unlinked ? `${unlinked} seen but unlinked` : "all linked"} />
<Kpi k="Radio channels" v={`${connected} / ${live.length}`} s={stuck ? `connected / desired · ${stuck} failing` : "connected / desired"} />
<Kpi k="Radio bots leased" v={`${leased} / ${bots.length}`} s={byo ? `pool + ${byo} BYO` : "platform pool"} />
<Kpi k="Bots needing a check" v={String(attention)} s="rejected or membership unknown" />
</div>
<div className="flex gap-1 border-b border-line overflow-x-auto">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => {
setTab(t.key);
history.replaceState(null, "", `#${t.key}`);
}}
className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${
tab === t.key ? "border-accent text-ink" : "border-transparent text-ink-muted hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "streams" && <StreamsPanel overview={overview} bots={bots} onChanged={load} />}
{tab === "guilds" && <ServersPanel overview={overview} onChanged={load} />}
{tab === "bots" && <RadioBotsPanel bots={bots} overview={overview} onChanged={load} />}
{tab === "how" && <HowItWorks />}
</>
) : null}
</div>
);
}