Files
server-26/drb-frontend/components/discord/StreamsPanel.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

268 lines
10 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";
import { useMemo, useState } from "react";
import { c2api } from "@/lib/c2api";
import {
type DiscordAdminOverview,
type DiscordStream,
type RadioBot,
SNOWFLAKE,
guildLabel,
parseC2Error,
shortId,
streamHealth,
} from "@/lib/discord";
import { Button } from "@/components/ui/Button";
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
// Radio channels = discord_streams docs, desired vs actual. Start/Stop/Add go
// through the same routes the Discord bot uses (POST /discord/streams,
// POST /discord/guilds/{id}/stop), which accept a platform-admin token.
type Invite = { name: string; invite_url: string | null }[];
function AddChannelForm({
overview,
bots,
onDone,
onCancel,
}: {
overview: DiscordAdminOverview;
bots: RadioBot[];
onDone: () => void;
onCancel: () => void;
}) {
// Only servers that act for an org can carry radio (start_stream refuses the rest).
const guilds = overview.guilds.filter((g) => g.acting_org_id);
const [guildId, setGuildId] = useState(guilds[0]?.guild_id ?? "");
const [channelId, setChannelId] = useState("");
const [channelName, setChannelName] = useState("");
const [mode, setMode] = useState<"persistent" | "adhoc">("persistent");
const [systemId, setSystemId] = useState("");
const [botId, setBotId] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<{ message: string; invite: Invite } | null>(null);
const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null;
const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
const usableBots = bots.filter((b) => b.valid !== false && (b.org_id == null || b.org_id === org));
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!SNOWFLAKE.test(channelId.trim())) {
setError({ message: "Voice channel ID is the 17–20 digit number from Discord (right-click the channel → Copy Channel ID).", invite: [] });
return;
}
setSaving(true);
setError(null);
try {
await c2api.startDiscordStream({
guild_id: guildId,
channel_id: channelId.trim(),
system_id: systemId,
mode,
channel_name: channelName.trim() || undefined,
preferred_token_id: botId || undefined,
});
onDone();
} catch (err) {
setError(parseC2Error(err));
} finally {
setSaving(false);
}
}
if (!guilds.length) {
return (
<div className="px-4 py-4 border-b border-line bg-raised text-xs text-ink-muted">
No Discord server acts for an organization yet. Link one under <b className="text-ink">Servers</b> first.
<button className="ml-3 text-accent hover:underline" onClick={onCancel}>Close</button>
</div>
);
}
return (
<form onSubmit={submit} className="px-4 py-4 border-b border-line grid gap-3 sm:grid-cols-3 bg-raised">
<Field label="Server">
<select value={guildId} onChange={(e) => { setGuildId(e.target.value); setSystemId(""); setBotId(""); }} className={INPUT}>
{guilds.map((g) => (
<option key={g.guild_id} value={g.guild_id}>
{guildLabel(g)}{g.linked ? "" : " (unlinked)"}
</option>
))}
</select>
</Field>
<Field label="Voice channel ID" hint="Discord: right-click the voice channel → Copy Channel ID.">
<input value={channelId} onChange={(e) => setChannelId(e.target.value)} required inputMode="numeric" className={`${INPUT} font-mono`} />
</Field>
<Field label="Label (optional)">
<input value={channelName} onChange={(e) => setChannelName(e.target.value)} className={INPUT} placeholder="County Dispatch" />
</Field>
<Field label="Radio system">
<select value={systemId} onChange={(e) => setSystemId(e.target.value)} required className={INPUT}>
<option value="" disabled>Choose a system…</option>
{systems.map((s) => (
<option key={s.system_id} value={s.system_id}>{s.name || s.system_id}</option>
))}
</select>
</Field>
<Field label="Mode" hint={mode === "persistent" ? "Comes back by itself after a node reboot or loss." : "Stopped, not moved, when its node goes away."}>
<select value={mode} onChange={(e) => setMode(e.target.value as "persistent" | "adhoc")} className={INPUT}>
<option value="persistent">Persistent</option>
<option value="adhoc">Ad-hoc</option>
</select>
</Field>
<Field label="Radio bot">
<select value={botId} onChange={(e) => setBotId(e.target.value)} className={INPUT}>
<option value="">Any free bot in this server</option>
{usableBots.map((b) => (
<option key={b.token_id} value={b.token_id}>Prefer {b.name}</option>
))}
</select>
</Field>
{error && (
<div className="sm:col-span-3 text-xs text-sev-major">
{error.message}
<InviteLinks invite={error.invite} />
</div>
)}
<div className="sm:col-span-3 flex gap-2">
<Button type="submit" size="sm" disabled={saving || !systemId}>{saving ? "Starting…" : "Start radio channel"}</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
function source(s: DiscordStream) {
const sys = s.system_name || s.system_id || "—";
return s.node_name ? `${sys} · ${s.node_name}` : sys;
}
export function StreamsPanel({
overview,
bots,
onChanged,
}: {
overview: DiscordAdminOverview;
bots: RadioBot[];
onChanged: () => Promise<void> | void;
}) {
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [rowError, setRowError] = useState<{ id: string; message: string; invite: Invite } | null>(null);
const guildsById = useMemo(() => new Map(overview.guilds.map((g) => [g.guild_id, g])), [overview.guilds]);
async function act(s: DiscordStream, fn: () => Promise<unknown>) {
setBusy(s.stream_id);
setRowError(null);
try {
await fn();
await onChanged();
} catch (err) {
setRowError({ id: s.stream_id, ...parseC2Error(err) });
} finally {
setBusy(null);
}
}
return (
<Panel
title="Radio channels"
description="One row per Discord voice channel. Persistent channels come back by themselves."
action={
<Button size="sm" onClick={() => setAdding((v) => !v)}>+ Add radio channel</Button>
}
note={
<>
<b className="text-ink">Every talkgroup the node follows is played.</b> A per-channel talkgroup filter is a
later phase (design-canvas/discord-system-notes.md, phase 2). A channel that stays desired-on without
connecting for 2 minutes turns red here.
</>
}
>
{adding && (
<AddChannelForm
overview={overview}
bots={bots}
onCancel={() => setAdding(false)}
onDone={async () => {
setAdding(false);
await onChanged();
}}
/>
)}
{overview.streams.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-muted">
No radio channels yet. Start one here, or run <span className="font-mono">/join</span> in a linked Discord server.
</p>
) : (
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>
{overview.streams.map((s) => {
const { health, label } = streamHealth(s);
const g = guildsById.get(s.guild_id);
const err = rowError?.id === s.stream_id ? rowError : null;
const isOn = s.desired === "on";
return (
<tr key={s.stream_id}>
<td className={TD}>
<b>{s.channel_name ? `🔊 ${s.channel_name}` : `channel ${shortId(s.channel_id)}`}</b>
<div className={DIM}>{g ? guildLabel(g) : guildLabel({ guild_id: s.guild_id, guild_name: s.guild_name })}</div>
</td>
<td className={TD}>{source(s)}</td>
<td className={TD}><span className={DIM}>All talkgroups</span></td>
<td className={TD}>{s.mode === "adhoc" ? "ad-hoc" : s.mode ?? "—"}</td>
<td className={TD}>
<Status health={health}>{label}</Status>
{isOn && s.actual?.error && <div className={`${DIM} mt-1 max-w-[16rem]`}>{s.actual.error}</div>}
{!isOn && s.stop_reason && <div className={`${DIM} mt-1`}>{s.stop_reason}</div>}
{err && (
<div className="text-xs text-sev-major mt-1 max-w-[16rem]">
{err.message}
<InviteLinks invite={err.invite} />
</div>
)}
</td>
<td className={TD}>{s.token_name ?? <span className={DIM}>—</span>}</td>
<td className={`${TD} text-right`}>
{isOn ? (
<Button
size="sm"
variant="secondary"
disabled={busy !== null}
onClick={() => {
if (!confirm(`Stop radio in ${s.channel_name || "this channel"}?`)) return;
act(s, () => c2api.stopDiscordStream(s.guild_id, s.channel_id));
}}
>
{busy === s.stream_id ? "Stopping…" : "Stop"}
</Button>
) : (
<Button
size="sm"
variant="secondary"
disabled={busy !== null || !s.system_id}
onClick={() =>
act(s, () =>
c2api.startDiscordStream({
guild_id: s.guild_id,
channel_id: s.channel_id,
system_id: s.system_id as string,
mode: s.mode ?? "persistent",
}),
)
}
>
{busy === s.stream_id ? "Starting…" : "Start"}
</Button>
)}
</td>
</tr>
);
})}
</Table>
)}
</Panel>
);
}