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>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { c2api } from "@/lib/c2api";
|
|
import {
|
|
type DiscordAdminOverview,
|
|
type RadioBot,
|
|
type RadioBotAddResult,
|
|
fmtWhen,
|
|
guildLabel,
|
|
inviteForGuild,
|
|
parseC2Error,
|
|
shortId,
|
|
} from "@/lib/discord";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
|
|
|
|
// Radio bots = the bot_tokens pool (routers/tokens.py). Used by the Discord
|
|
// admin page's "Radio bots" tab and by /tokens. Every action here is a real
|
|
// platform-admin route: POST /tokens, POST /tokens/{id}/refresh,
|
|
// DELETE /tokens/{id}, POST /tokens/flush.
|
|
|
|
function Checks({ t }: { t: RadioBot }) {
|
|
if (t.valid === false) {
|
|
return (
|
|
<div>
|
|
<Status health="bad">Token rejected by Discord</Status>
|
|
{t.check_error && <div className={`${DIM} mt-1 max-w-[16rem]`}>{t.check_error}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
if (t.valid) {
|
|
return (
|
|
<div>
|
|
<Status health="ok">Token valid</Status>
|
|
<div className={`${DIM} mt-1`}>checked {fmtWhen(t.validated_at)}</div>
|
|
{t.check_error && <div className={`${DIM} mt-1 max-w-[16rem]`}>last check failed: {t.check_error}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div>
|
|
<Status health="warn">Not checked yet</Status>
|
|
{t.check_error && <div className={`${DIM} mt-1 max-w-[16rem]`}>{t.check_error}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOverview | null }) {
|
|
if (t.guild_ids == null) return <span className={DIM}>unknown — refresh</span>;
|
|
const known = new Map((overview?.guilds ?? []).map((g) => [g.guild_id, g]));
|
|
const members = t.guild_ids;
|
|
// Servers this bot could serve (its own org's for a BYO bot, every one for the pool) but is not in.
|
|
const missing = (overview?.guilds ?? []).filter(
|
|
(g) => !members.includes(g.guild_id) && (t.org_id == null || g.acting_org_id === t.org_id),
|
|
);
|
|
if (!members.length && !missing.length) return <span className={DIM}>no servers</span>;
|
|
return (
|
|
<div className="flex flex-wrap gap-1 max-w-[22rem]">
|
|
{members.map((gid) => (
|
|
<span key={gid} className="text-[11px] rounded px-1.5 py-px bg-raised border border-line text-ink-2">
|
|
{known.has(gid) ? guildLabel(known.get(gid)!) : `server ${shortId(gid)}`}
|
|
</span>
|
|
))}
|
|
{missing.map((g) => {
|
|
const href = inviteForGuild(t.invite_url, g.guild_id);
|
|
const label = `${guildLabel(g)} ✕`;
|
|
return href ? (
|
|
<a
|
|
key={g.guild_id}
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title={`Not in ${guildLabel(g)} — invite it`}
|
|
className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate hover:underline"
|
|
>
|
|
{label}
|
|
</a>
|
|
) : (
|
|
<span key={g.guild_id} className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate">
|
|
{label}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AddBotForm({
|
|
overview,
|
|
onDone,
|
|
onCancel,
|
|
}: {
|
|
overview: DiscordAdminOverview | null;
|
|
onDone: (r: RadioBotAddResult) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const [name, setName] = useState("");
|
|
const [token, setToken] = useState("");
|
|
const [owner, setOwner] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const r = await c2api.addToken({ name: name.trim(), token: token.trim(), org_id: owner || null });
|
|
onDone(r);
|
|
} 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="Label">
|
|
<input value={name} onChange={(e) => setName(e.target.value)} required className={INPUT} placeholder="DRB Radio 2" />
|
|
</Field>
|
|
<Field label="Discord bot token" hint="Checked with Discord before it is saved.">
|
|
<input
|
|
value={token}
|
|
onChange={(e) => setToken(e.target.value)}
|
|
required
|
|
type="password"
|
|
autoComplete="off"
|
|
className={`${INPUT} font-mono`}
|
|
/>
|
|
</Field>
|
|
<Field label="Who can use it" hint="A BYO bot is only ever leased to its own org.">
|
|
<select value={owner} onChange={(e) => setOwner(e.target.value)} className={INPUT}>
|
|
<option value="">Platform pool</option>
|
|
{(overview?.orgs ?? []).map((o) => (
|
|
<option key={o.org_id} value={o.org_id}>BYO · {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}>{saving ? "Checking with Discord…" : "Add bot"}</Button>
|
|
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function RadioBotsPanel({
|
|
bots,
|
|
overview,
|
|
onChanged,
|
|
}: {
|
|
bots: RadioBot[];
|
|
overview: DiscordAdminOverview | null;
|
|
onChanged: () => Promise<void> | void;
|
|
}) {
|
|
const [adding, setAdding] = useState(false);
|
|
const [added, setAdded] = useState<RadioBotAddResult | null>(null);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const orgName = (id: string | null | undefined) =>
|
|
(overview?.orgs ?? []).find((o) => o.org_id === id)?.name ?? id ?? "";
|
|
const nodeName = (id: string) => overview?.node_names?.[id] ?? id;
|
|
const systemName = (id: string) => (overview?.systems ?? []).find((s) => s.system_id === id)?.name ?? id;
|
|
const fanOut = overview?.limits.node_max_streams ?? 4;
|
|
|
|
async function act(key: string, fn: () => Promise<unknown>) {
|
|
setBusy(key);
|
|
setError(null);
|
|
try {
|
|
await fn();
|
|
await onChanged();
|
|
} catch (err) {
|
|
setError(parseC2Error(err).message);
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
const leased = bots.filter((b) => b.lease).length;
|
|
|
|
return (
|
|
<Panel
|
|
title="Radio bots"
|
|
description="Each bot can be in one voice channel per server, in many servers at once. Needed = most simultaneous channels in any one server."
|
|
action={
|
|
<>
|
|
{leased > 0 && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={busy !== null}
|
|
onClick={() => {
|
|
if (!confirm(`Force-release all ${leased} leased radio bot(s)? Their nodes log the bots out; persistent channels are re-placed.`)) return;
|
|
act("flush", () => c2api.flushTokens());
|
|
}}
|
|
>
|
|
Release all leases
|
|
</Button>
|
|
)}
|
|
<Button size="sm" variant="secondary" onClick={() => { setAdding((v) => !v); setAdded(null); }}>
|
|
+ Add bot token
|
|
</Button>
|
|
</>
|
|
}
|
|
note={
|
|
<>
|
|
<b className="text-ink">Release is ordered:</b> the node stops the bot's gateway session, then acks, then the
|
|
token returns to the pool — a token is never live on two nodes. Invite links ask for Connect + Speak only.
|
|
</>
|
|
}
|
|
>
|
|
{adding && (
|
|
<AddBotForm
|
|
overview={overview}
|
|
onCancel={() => setAdding(false)}
|
|
onDone={async (r) => {
|
|
setAdding(false);
|
|
setAdded(r);
|
|
await onChanged();
|
|
}}
|
|
/>
|
|
)}
|
|
{added && (
|
|
<div className="px-4 py-3 border-b border-line text-xs text-ink-2 flex flex-wrap items-center gap-2">
|
|
<span>
|
|
Added <b className="text-ink">{added.name}</b>
|
|
{added.valid ? ` · token valid · in ${added.guild_ids?.length ?? 0} server(s)` : " · Discord could not be reached, membership unknown"}
|
|
{added.check_error ? ` (${added.check_error})` : ""}.
|
|
</span>
|
|
{added.invite_url && (
|
|
<a href={added.invite_url} target="_blank" rel="noopener noreferrer" className="text-accent underline">
|
|
Invite it to a server
|
|
</a>
|
|
)}
|
|
<button className="ml-auto text-ink-muted hover:text-ink" onClick={() => setAdded(null)} aria-label="Dismiss">✕</button>
|
|
</div>
|
|
)}
|
|
{error && <p className="px-4 py-2 text-sev-major text-xs border-b border-line">{error}</p>}
|
|
{bots.length === 0 ? (
|
|
<p className="px-4 py-6 text-sm text-ink-muted">No radio bots yet. Add one to let nodes carry radio into Discord voice channels.</p>
|
|
) : (
|
|
<Table head={["Bot", "Lease", "Voice now", "Member of", "Checks", ""]}>
|
|
{bots.map((t) => {
|
|
const streamsHere = t.lease?.streams?.length ?? 0;
|
|
return (
|
|
<tr key={t.token_id}>
|
|
<td className={TD}>
|
|
<b>{t.name}</b>
|
|
<div className={DIM}>{t.org_id ? `BYO · ${orgName(t.org_id)} only` : "platform pool"}</div>
|
|
<div className={`${DIM} font-mono`}>{t.token}</div>
|
|
{t.preferred_for_system_id && (
|
|
<div className={DIM}>preferred for {systemName(t.preferred_for_system_id)}</div>
|
|
)}
|
|
</td>
|
|
<td className={TD}>
|
|
{t.lease ? (
|
|
<>
|
|
{nodeName(t.lease.node_id)}
|
|
<div className={DIM}>
|
|
{t.lease.releasing ? "releasing — waiting for the node to log out" : `since ${fmtWhen(t.lease.since)}`}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<span className={DIM}>free</span>
|
|
)}
|
|
</td>
|
|
<td className={TD}>
|
|
{t.lease ? (
|
|
<>
|
|
{streamsHere} server{streamsHere === 1 ? "" : "s"}
|
|
<div className="h-1.5 w-28 bg-raised rounded mt-1 overflow-hidden">
|
|
<i className="block h-full bg-accent" style={{ width: `${Math.min(100, (streamsHere / fanOut) * 100)}%` }} />
|
|
</div>
|
|
<div className={DIM}>{streamsHere} / {fanOut} per node</div>
|
|
</>
|
|
) : (
|
|
<span className={DIM}>—</span>
|
|
)}
|
|
</td>
|
|
<td className={TD}>
|
|
<Membership t={t} overview={overview} />
|
|
</td>
|
|
<td className={TD}>
|
|
<Checks t={t} />
|
|
</td>
|
|
<td className={`${TD} text-right whitespace-nowrap`}>
|
|
<div className="flex flex-col items-end gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
disabled={busy !== null}
|
|
onClick={() => act(`r:${t.token_id}`, () => c2api.refreshToken(t.token_id))}
|
|
>
|
|
{busy === `r:${t.token_id}` ? "Checking…" : "Refresh"}
|
|
</Button>
|
|
{t.invite_url && (
|
|
<a href={t.invite_url} target="_blank" rel="noopener noreferrer" className="text-xs text-accent hover:underline">
|
|
Invite link
|
|
</a>
|
|
)}
|
|
<button
|
|
disabled={busy !== null || !!t.lease}
|
|
title={t.lease ? "Leased to a node — stop its radio channels first" : undefined}
|
|
onClick={() => {
|
|
if (!confirm(`Remove ${t.name} from the pool?`)) return;
|
|
act(`d:${t.token_id}`, () => c2api.deleteToken(t.token_id));
|
|
}}
|
|
className="text-xs text-sev-major hover:underline disabled:opacity-30 disabled:no-underline disabled:cursor-not-allowed"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</Table>
|
|
)}
|
|
</Panel>
|
|
);
|
|
}
|