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>
172 lines
6.8 KiB
TypeScript
172 lines
6.8 KiB
TypeScript
"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's systems until it is linked here. Set the variable
|
||
to an empty string once every real server is linked.
|
||
</>
|
||
) : (
|
||
<>Unlinked servers can'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>
|
||
);
|
||
}
|