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:
co-authored by
Claude Opus 5.5
parent
e574bd924a
commit
d3277c13e1
@@ -0,0 +1,267 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user