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>
This commit is contained in:
Logan Cusano
2026-10-04 20:05:35 -04:00
co-authored by Claude Opus 5.5
parent e574bd924a
commit d3277c13e1
11 changed files with 1464 additions and 161 deletions
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { c2api } from "@/lib/c2api";
import { type DiscordAdminOverview, type DiscordGuild, SNOWFLAKE, fmtWhen, guildLabel, parseC2Error } from "@/lib/discord";
import { Button } from "@/components/ui/Button";
import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
// Linked Discord servers = discord_guilds docs, plus any server that has had a
// radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID).
// Linking is PUT /discord/guilds/{id}/link (platform admin). The owner-side
// "/drb link code:" flow is server-26#213 item 1 and is not built here.
function LinkForm({
overview,
guild,
onDone,
onCancel,
}: {
overview: DiscordAdminOverview;
guild?: DiscordGuild;
onDone: () => void;
onCancel: () => void;
}) {
const [guildId, setGuildId] = useState(guild?.guild_id ?? "");
const [label, setLabel] = useState(guild?.guild_name ?? "");
const [orgId, setOrgId] = useState(guild?.org_id ?? guild?.acting_org_id ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!SNOWFLAKE.test(guildId.trim())) {
setError("Server ID is the 17–20 digit number from Discord (right-click the server icon → Copy Server ID).");
return;
}
const actingNow = guild?.acting_org_id ?? null;
if (guild && guild.live_streams > 0 && actingNow && actingNow !== orgId) {
const n = guild.live_streams;
if (!confirm(`${guildLabel(guild)} has ${n} live radio channel${n === 1 ? "" : "s"} on the current organization's nodes. Moving it stops ${n === 1 ? "it" : "them"}. Continue?`)) return;
}
setSaving(true);
setError(null);
try {
await c2api.linkDiscordGuild(guildId.trim(), { org_id: orgId, guild_name: label.trim() || undefined });
onDone();
} catch (err) {
setError(parseC2Error(err).message);
} finally {
setSaving(false);
}
}
return (
<form onSubmit={submit} className="px-4 py-4 border-b border-line grid gap-3 sm:grid-cols-3 bg-raised">
<Field label="Discord server ID">
<input
value={guildId}
onChange={(e) => setGuildId(e.target.value)}
required
readOnly={!!guild}
inputMode="numeric"
className={`${INPUT} font-mono ${guild ? "opacity-70" : ""}`}
/>
</Field>
<Field label="Label (optional)">
<input value={label} onChange={(e) => setLabel(e.target.value)} className={INPUT} placeholder="Fire Co. 7" />
</Field>
<Field label="Acts for organization">
<select value={orgId} onChange={(e) => setOrgId(e.target.value)} required className={INPUT}>
<option value="" disabled>Choose an organization…</option>
{overview.orgs.map((o) => (
<option key={o.org_id} value={o.org_id}>{o.name}</option>
))}
</select>
</Field>
{error && <p className="sm:col-span-3 text-sev-major text-xs">{error}</p>}
<div className="sm:col-span-3 flex gap-2">
<Button type="submit" size="sm" disabled={saving || !orgId}>{saving ? "Saving…" : guild?.linked ? "Save link" : "Link server"}</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
export function ServersPanel({
overview,
onChanged,
}: {
overview: DiscordAdminOverview;
onChanged: () => Promise<void> | void;
}) {
// "new" = blank link form; a guild id = editing that row's link.
const [editing, setEditing] = useState<string | null>(null);
const fallback = overview.limits.unlinked_guild_org_id;
const fallbackName = overview.limits.unlinked_guild_org_name || fallback;
const max = overview.limits.guild_max_streams;
const done = async () => {
setEditing(null);
await onChanged();
};
return (
<Panel
title="Linked Discord servers"
description="Every bot command in a server is scoped to the organization it is linked to."
action={<Button size="sm" onClick={() => setEditing(editing === "new" ? null : "new")}>+ Link a server</Button>}
note={
fallback ? (
<>
<b className="text-ink">Unlinked servers act for {fallbackName}</b> (DISCORD_UNLINKED_GUILD_ORG_ID). Any
server the DRB bot is invited to can use that org&apos;s systems until it is linked here. Set the variable
to an empty string once every real server is linked.
</>
) : (
<>Unlinked servers can&apos;t use radio commands until they are linked here.</>
)
}
>
{editing === "new" && <LinkForm overview={overview} onDone={done} onCancel={() => setEditing(null)} />}
{overview.guilds.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-muted">No Discord servers yet. Link one, or run a radio command in a server the DRB bot is in.</p>
) : (
<Table head={["Server", "Acts for", "Radio channels", "Linked", "Status", ""]}>
{overview.guilds.flatMap((g) => {
const row = (
<tr key={g.guild_id}>
<td className={TD}>
<b>{guildLabel(g)}</b>
<div className={`${DIM} font-mono`}>{g.guild_id}</div>
</td>
<td className={TD}>
{g.acting_org_name || g.acting_org_id || <span className={DIM}>nobody</span>}
{!g.linked && g.acting_org_id && <div className={DIM}>fallback, not linked</div>}
</td>
<td className={TD}>
{g.live_streams} / {max}
</td>
<td className={TD}>{g.linked ? fmtWhen(g.linked_at) : <span className={DIM}>—</span>}</td>
<td className={TD}>
{g.linked ? (
<Status health="ok">Linked</Status>
) : g.acting_org_id ? (
<Status health="warn">Unlinked · fallback org</Status>
) : (
<Status health="bad">Unlinked</Status>
)}
</td>
<td className={`${TD} text-right`}>
<Button size="sm" variant="secondary" onClick={() => setEditing(editing === g.guild_id ? null : g.guild_id)}>
{g.linked ? "Change org" : "Link"}
</Button>
</td>
</tr>
);
if (editing !== g.guild_id) return [row];
return [
row,
<tr key={`${g.guild_id}-edit`}>
<td colSpan={6} className="p-0 border-b border-line">
<LinkForm overview={overview} guild={g} onDone={done} onCancel={() => setEditing(null)} />
</td>
</tr>,
];
})}
</Table>
)}
</Panel>
);
}