- Copy: picker, admin Servers/Streams/How-it-works, org Discord card, bot /help and the server-limit refusal no longer imply the DRB server bot is required; a radio bot alone is enough. - Admin overview: per-server main_bot_present, radio_bots, linked_via, directory_source; Servers tab gets a Bots column. - discord_web docstring: states the reversed "never calls Discord" rule (plan finding 5) and the optional server bot. - Server bot directory cog: re-push (which drops C2's cached permission answers) on overwrite changes, role permission changes and role deletes. - tests/test_discord_owner_flows.py: one-bot org (BYO bot, owner-set preferred, OAuth link, picker, start, reconciler) and full setup (server bot + 4 radio bots: placement, shared channel, every refusal explained), against a modelled Discord over httpx.MockTransport. - storage.download_audio removed: no caller on main since #233. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
7.7 KiB
TypeScript
176 lines
7.7 KiB
TypeScript
"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", "A node page's picker, this page, or /join (DRB server bot) writes a discord_streams doc."],
|
||
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server that Discord lets View, Connect and Speak in the channel — the system's preferred bot first — 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."
|
||
>
|
||
<p className="px-4 pt-4 text-xs text-ink-2">
|
||
<b className="text-ink">The DRB server bot is optional.</b> One radio bot in a Discord server is enough: adding it
|
||
through <i>Connect a Discord server</i> (Settings → Organization) links the server, and C2 reads its voice
|
||
channels and permissions with that bot. The server bot adds the slash commands (/join, /leave, /status,
|
||
/drb link) and pushes its own channel list; both ways keep working side by side.
|
||
</p>
|
||
<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>
|
||
);
|
||
}
|