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>
63 lines
2.4 KiB
TypeScript
63 lines
2.4 KiB
TypeScript
"use client";
|
||
|
||
// 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";
|
||
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() {
|
||
const { isAdmin, loading: authLoading } = useAuth();
|
||
const router = useRouter();
|
||
const [bots, setBots] = useState<RadioBot[] | null>(null);
|
||
const [overview, setOverview] = useState<DiscordAdminOverview | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!authLoading && !isAdmin) router.replace("/network");
|
||
}, [authLoading, isAdmin, router]);
|
||
|
||
const refresh = useCallback(async () => {
|
||
try {
|
||
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]);
|
||
|
||
if (authLoading || !isAdmin) return null;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<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>
|
||
);
|
||
}
|