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,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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user