"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 (
{k}
{v}
{s}
); } function HowItWorks() { const steps = [ ["1 · Desired", "A node page's picker, this page, or /join (DRB server bot) writes a discord_streams doc."], ["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server that Discord lets View, Connect and Speak in the channel — the system's preferred bot first — 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 (

The DRB server bot is optional. One radio bot in a Discord server is enough: adding it through Connect a Discord server (Settings → Organization) links the server, and C2 reads its voice channels and permissions with that bot. The server bot adds the slash commands (/join, /leave, /status, /drb link) and pushes its own channel list; both ways keep working side by side.

{steps.map(([t, d]) => (
{t} {d}
))}
); } export default function DiscordSettingsPage() { const { isAdmin, loading: authLoading } = useAuth(); const router = useRouter(); const [tab, setTab] = useState("streams"); const [overview, setOverview] = useState(null); const [bots, setBots] = useState([]); const [error, setError] = useState(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 (

Discord

Desired state lives here; nodes reconcile and report back. Voice audio comes from each node's PulseAudio monitor, never Icecast.

{error && } {!overview && !error ? (
{Array.from({ length: 4 }).map((_, i) => )}
) : overview ? ( <>
{TABS.map((t) => ( ))}
{tab === "streams" && } {tab === "guilds" && } {tab === "bots" && } {tab === "how" && } ) : null}
); }