Merge pull request 'Discord admin page + hide admin-only bot pool from operators (#213 items 2, 4)' (#219) from feat/213-discord-admin into main
Build & Deploy / Build & push images (push) Successful in 5m1s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 5s
Build & Deploy / Deploy to VM (push) Successful in 7s
Build & Deploy / Report a failed deploy (push) Skipped

Reviewed-on: #219
This commit was merged in pull request #219.
This commit is contained in:
2026-10-04 22:05:36 -04:00
13 changed files with 1510 additions and 173 deletions
@@ -0,0 +1,325 @@
"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."
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&apos;s gateway session, then acks, then the
token returns to the pool — a token is never live on two nodes. Invite links ask for View Channel, Connect and 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>
);
}
@@ -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>
);
}
@@ -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>
);
}
+98
View File
@@ -0,0 +1,98 @@
import type { ReactNode } from "react";
import type { Health } from "@/lib/discord";
// Shared pieces for the Discord admin page (design-canvas/discord-system-mockup.html,
// "DRB admin" view): panels with a title row, dense tables, status dots.
const DOT: Record<Health, string> = {
ok: "bg-green-500",
warn: "bg-sev-moderate",
bad: "bg-sev-major",
off: "bg-ink-muted",
};
export function Status({ health, children }: { health: Health; children: ReactNode }) {
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium whitespace-nowrap text-ink">
<i className={`inline-block w-2 h-2 rounded-full ${DOT[health]}`} aria-hidden />
{children}
</span>
);
}
export function Panel({
title,
description,
action,
note,
children,
}: {
title: string;
description?: ReactNode;
action?: ReactNode;
note?: ReactNode;
children: ReactNode;
}) {
return (
<section className="bg-surface border border-line rounded-xl overflow-hidden">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3 border-b border-line">
<h2 className="text-ink font-semibold text-sm">{title}</h2>
{description && <span className="text-ink-muted text-xs">{description}</span>}
{action && <div className="ml-auto flex gap-2">{action}</div>}
</div>
{children}
{note && <div className="text-xs text-ink-muted px-4 py-2.5 bg-raised border-t border-line">{note}</div>}
</section>
);
}
export const TH = "text-left font-medium text-ink-muted text-xs px-4 py-2 border-b border-line whitespace-nowrap";
export const TD = "px-4 py-2.5 border-b border-line align-top text-sm text-ink";
export const DIM = "text-xs text-ink-muted";
export function Table({ head, children }: { head: string[]; children: ReactNode }) {
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
{head.map((h, i) => (
<th key={i} className={TH}>{h}</th>
))}
</tr>
</thead>
<tbody className="[&>tr:last-child>td]:border-b-0">{children}</tbody>
</table>
</div>
);
}
export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: ReactNode }) {
return (
<label className="block">
<span className="text-xs text-ink-muted block mb-1">{label}</span>
{children}
{hint && <span className="text-xs text-ink-muted block mt-1">{hint}</span>}
</label>
);
}
export const INPUT =
"w-full bg-page border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none focus:border-accent";
export function InviteLinks({ invite }: { invite: { name: string; invite_url: string | null }[] }) {
if (!invite.length) return null;
return (
<span className="flex flex-wrap gap-2 mt-1">
{invite.map((b) =>
b.invite_url ? (
<a key={b.name} href={b.invite_url} target="_blank" rel="noopener noreferrer" className="text-accent underline text-xs">
Invite {b.name}
</a>
) : (
<span key={b.name} className="text-xs text-ink-muted">{b.name} (no invite link — refresh it under Radio bots)</span>
),
)}
</span>
);
}