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,169 @@
|
||||
"use client";
|
||||
|
||||
// Settings › Discord — the admin page from design-canvas/discord-system-mockup.html
|
||||
// ("DRB admin" view), server-26#213 item 2. Desired state lives here; nodes
|
||||
// reconcile and report back. Platform admin only for now: every route behind
|
||||
// it (GET /discord/admin/overview, /tokens, PUT /discord/guilds/{id}/link) is
|
||||
// require_admin_token. The mockup's "Alert routes" tab, incident-forum /
|
||||
// roles / plan columns and per-channel talkgroup chips have no backend yet and
|
||||
// are left out rather than shown as dead controls.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { type DiscordAdminOverview, type RadioBot, parseC2Error, streamHealth } from "@/lib/discord";
|
||||
import { ErrorBanner } from "@/components/ui/EmptyState";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { StreamsPanel } from "@/components/discord/StreamsPanel";
|
||||
import { ServersPanel } from "@/components/discord/ServersPanel";
|
||||
import { RadioBotsPanel } from "@/components/discord/RadioBotsPanel";
|
||||
import { Panel } from "@/components/discord/ui";
|
||||
|
||||
type Tab = "streams" | "guilds" | "bots" | "how";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "streams", label: "Radio channels" },
|
||||
{ key: "guilds", label: "Servers" },
|
||||
{ key: "bots", label: "Radio bots" },
|
||||
{ key: "how", label: "How it works" },
|
||||
];
|
||||
|
||||
const POLL_MS = 10_000;
|
||||
|
||||
function Kpi({ k, v, s }: { k: string; v: string; s: string }) {
|
||||
return (
|
||||
<div className="bg-surface border border-line rounded-xl p-3">
|
||||
<div className="text-xs text-ink-muted">{k}</div>
|
||||
<div className="text-xl font-semibold text-ink mt-0.5">{v}</div>
|
||||
<div className="text-xs text-ink-muted">{s}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HowItWorks() {
|
||||
const steps = [
|
||||
["1 · Desired", "/join in Discord or this page writes a discord_streams doc."],
|
||||
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server, in one transaction."],
|
||||
["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."],
|
||||
["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."],
|
||||
["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."],
|
||||
];
|
||||
return (
|
||||
<Panel
|
||||
title="How a radio channel stays up"
|
||||
note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab."
|
||||
>
|
||||
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{steps.map(([t, d]) => (
|
||||
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
|
||||
<b className="block text-ink text-sm mb-1">{t}</b>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DiscordSettingsPage() {
|
||||
const { isAdmin, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<Tab>("streams");
|
||||
const [overview, setOverview] = useState<DiscordAdminOverview | null>(null);
|
||||
const [bots, setBots] = useState<RadioBot[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAdmin) router.replace("/settings/organization");
|
||||
}, [authLoading, isAdmin, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = window.location.hash.slice(1);
|
||||
if (TABS.some((t) => t.key === h)) setTab(h as Tab);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [o, t] = await Promise.all([c2api.getDiscordAdmin(), c2api.getTokens()]);
|
||||
setOverview(o);
|
||||
setBots(t as RadioBot[]);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(parseC2Error(err).message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
load();
|
||||
// Actual state is reported by nodes; poll while the tab is visible.
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") load();
|
||||
}, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [isAdmin, load]);
|
||||
|
||||
// Every hook runs before this guard (React error #310 — see app/nodes/page.tsx).
|
||||
if (authLoading || !isAdmin) return null;
|
||||
|
||||
const linked = overview?.guilds.filter((g) => g.linked).length ?? 0;
|
||||
const unlinked = (overview?.guilds.length ?? 0) - linked;
|
||||
const live = overview?.streams.filter((s) => s.desired === "on") ?? [];
|
||||
const connected = live.filter((s) => streamHealth(s).health === "ok").length;
|
||||
const stuck = live.filter((s) => streamHealth(s).health === "bad").length;
|
||||
const leased = bots.filter((b) => b.lease).length;
|
||||
const byo = bots.filter((b) => b.org_id).length;
|
||||
const attention = bots.filter((b) => b.valid === false || b.guild_ids == null).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-ink font-semibold text-lg">Discord</h2>
|
||||
<p className="text-ink-muted text-sm mt-0.5">
|
||||
Desired state lives here; nodes reconcile and report back. Voice audio comes from each node's PulseAudio
|
||||
monitor, never Icecast.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{!overview && !error ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
) : overview ? (
|
||||
<>
|
||||
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
|
||||
<Kpi k="Linked servers" v={String(linked)} s={unlinked ? `${unlinked} seen but unlinked` : "all linked"} />
|
||||
<Kpi k="Radio channels" v={`${connected} / ${live.length}`} s={stuck ? `connected / desired · ${stuck} failing` : "connected / desired"} />
|
||||
<Kpi k="Radio bots leased" v={`${leased} / ${bots.length}`} s={byo ? `pool + ${byo} BYO` : "platform pool"} />
|
||||
<Kpi k="Bots needing a check" v={String(attention)} s="rejected or membership unknown" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b border-line overflow-x-auto">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => {
|
||||
setTab(t.key);
|
||||
history.replaceState(null, "", `#${t.key}`);
|
||||
}}
|
||||
className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${
|
||||
tab === t.key ? "border-accent text-ink" : "border-transparent text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "streams" && <StreamsPanel overview={overview} bots={bots} onChanged={load} />}
|
||||
{tab === "guilds" && <ServersPanel overview={overview} onChanged={load} />}
|
||||
{tab === "bots" && <RadioBotsPanel bots={bots} overview={overview} onChanged={load} />}
|
||||
{tab === "how" && <HowItWorks />}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,10 @@ const TABS = [
|
||||
{ href: "/settings/billing", label: "Billing" },
|
||||
];
|
||||
|
||||
// Platform-admin-only tabs: every route behind them is require_admin_token,
|
||||
// so an org owner would only see 403s (server-26#213 item 2).
|
||||
const ADMIN_TABS = [{ href: "/settings/discord", label: "Discord" }];
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
// SAAS_PLAN.md B7: this used to gate on isAdmin (platform admin) alone,
|
||||
// which meant a paying customer who is their own org's owner couldn't
|
||||
@@ -40,7 +44,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit max-w-full overflow-x-auto">
|
||||
{TABS.map((t) => (
|
||||
{[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
// The radio-bot pool (bot_tokens). Same table as Settings › Discord › Radio
|
||||
// bots: validity, server membership, invite links, lease, and the
|
||||
// POST /tokens/{id}/refresh re-check (server-26#213 item 2). Platform admin
|
||||
// only: every /tokens route is require_admin_token since server-26#204 item 6.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
|
||||
interface TokenRecord {
|
||||
token_id: string;
|
||||
name: string;
|
||||
token: string; // masked server-side
|
||||
in_use: boolean;
|
||||
assigned_node_id: string | null;
|
||||
assigned_at: string | null;
|
||||
}
|
||||
import { type DiscordAdminOverview, type RadioBot, parseC2Error } from "@/lib/discord";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
import { ErrorBanner } from "@/components/ui/EmptyState";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { RadioBotsPanel } from "@/components/discord/RadioBotsPanel";
|
||||
|
||||
export default function TokensPage() {
|
||||
// Platform admin only: every /tokens route is require_admin_token since
|
||||
// server-26#204 item 6, so an operator here only ever saw a failed fetch.
|
||||
const { isAdmin, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [tokens, setTokens] = useState<TokenRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [bots, setBots] = useState<RadioBot[] | null>(null);
|
||||
const [overview, setOverview] = useState<DiscordAdminOverview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,151 +29,34 @@ export default function TokensPage() {
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await c2api.getTokens();
|
||||
setTokens(data as TokenRecord[]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const t = await c2api.getTokens();
|
||||
setBots(t as RadioBot[]);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(parseC2Error(err).message);
|
||||
setBots((b) => b ?? []);
|
||||
}
|
||||
// Server names for the membership column; the table still works without them.
|
||||
try {
|
||||
setOverview(await c2api.getDiscordAdmin());
|
||||
} catch {
|
||||
/* membership falls back to raw server ids */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (isAdmin) refresh(); }, [isAdmin, refresh]);
|
||||
|
||||
async function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await c2api.addToken({ name, token });
|
||||
setName("");
|
||||
setToken("");
|
||||
setShowAdd(false);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add token.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm("Remove this token from the pool?")) return;
|
||||
try {
|
||||
await c2api.deleteToken(id);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Delete failed.");
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || !isAdmin) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white font-mono">Bot Token Pool</h1>
|
||||
<p className="text-xs text-gray-500 font-mono mt-0.5">
|
||||
Discord bot tokens assigned to nodes when they join a voice channel.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
||||
>
|
||||
+ Add Token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<form onSubmit={handleAdd} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
|
||||
<h3 className="text-white font-semibold text-sm">Add Token</h3>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
placeholder="DRB Bot 1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Discord Bot Token</label>
|
||||
<input
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
required
|
||||
type="password"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||
placeholder="MTxxxxxxxxxx.Gxxxxx.xxxxxxxxxx"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdd(false)}
|
||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
||||
) : tokens.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No tokens in the pool. Add one to enable Discord voice streaming.</p>
|
||||
) : (
|
||||
<div className="border border-gray-800 rounded-lg overflow-hidden font-mono">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-900 text-xs text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-4 py-2.5 text-left">Label</th>
|
||||
<th className="px-4 py-2.5 text-left">Token</th>
|
||||
<th className="px-4 py-2.5 text-left">Status</th>
|
||||
<th className="px-4 py-2.5 text-left">Node</th>
|
||||
<th className="px-4 py-2.5 w-16"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((t) => (
|
||||
<tr key={t.token_id} className="border-t border-gray-800 hover:bg-gray-900/50">
|
||||
<td className="px-4 py-2.5 text-white">{t.name}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs">{t.token}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{t.in_use ? (
|
||||
<span className="text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded">In use</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">Free</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs">
|
||||
{t.assigned_node_id ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(t.token_id)}
|
||||
disabled={t.in_use}
|
||||
className="text-xs text-red-600 hover:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<PageHeader
|
||||
title="Radio bots"
|
||||
description="Discord bot tokens that nodes log in with to carry radio into voice channels."
|
||||
action={<LinkButton href="/settings/discord" variant="secondary" size="sm">Discord settings</LinkButton>}
|
||||
/>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
{bots === null ? <SkeletonCard /> : <RadioBotsPanel bots={bots} overview={overview} onChanged={refresh} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user