Discord admin page + hide admin-only bot pool from operators (#213 items 2, 4) #219
@@ -107,6 +107,9 @@ class StreamStart(BaseModel):
|
||||
|
||||
@router.post("/streams")
|
||||
async def start_stream(body: StreamStart, _: dict = Depends(require_service_key_or_admin)):
|
||||
# The bot always sends snowflakes; the web admin page types them in.
|
||||
if not (str(body.guild_id).isdigit() and str(body.channel_id).isdigit()):
|
||||
raise HTTPException(400, "guild_id and channel_id must be Discord ids (digits only).")
|
||||
org_id = await _org_or_403(body.guild_id)
|
||||
system = await fstore.doc_get_cached("systems", body.system_id)
|
||||
if not system or system.get("org_id") not in (None, org_id):
|
||||
@@ -144,18 +147,99 @@ async def stop_guild(guild_id: str, body: GuildStop, _: dict = Depends(require_s
|
||||
|
||||
class GuildLink(BaseModel):
|
||||
org_id: str
|
||||
# Display label only (the admin page); Discord never reads it.
|
||||
guild_name: Optional[str] = None
|
||||
|
||||
|
||||
@router.put("/guilds/{guild_id}/link")
|
||||
async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(require_admin_token)):
|
||||
if not guild_id.isdigit():
|
||||
raise HTTPException(400, "guild_id must be a Discord server id (digits only).")
|
||||
org = await fstore.doc_get("organizations", body.org_id)
|
||||
if not org:
|
||||
raise HTTPException(404, "Organization not found.")
|
||||
await fstore.doc_set(streams.GUILDS, guild_id, {
|
||||
previous_org = await streams.resolve_guild_org(guild_id)
|
||||
doc = {
|
||||
"guild_id": guild_id, "org_id": body.org_id,
|
||||
"linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id}
|
||||
}
|
||||
if body.guild_name and body.guild_name.strip():
|
||||
doc["guild_name"] = body.guild_name.strip()[:100]
|
||||
await fstore.doc_set(streams.GUILDS, guild_id, doc)
|
||||
# Live channels in this server were placed on the previous org's nodes;
|
||||
# left running, the reconciler would keep re-placing them there, so one
|
||||
# org's equipment would keep serving a server that now belongs to another.
|
||||
stopped: list[str] = []
|
||||
if previous_org and previous_org != body.org_id:
|
||||
stopped = await streams.stop_guild(guild_id, "server linked to another organization")
|
||||
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": stopped}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin view — the web Discord page (server-26#213 item 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/admin/overview")
|
||||
async def admin_overview(_: dict = Depends(require_admin_token)):
|
||||
"""Every Discord server DRB knows about, every radio channel (desired vs
|
||||
actual) and what the page needs to act on them. Platform admin only.
|
||||
|
||||
A server is "known" when it is linked (``discord_guilds``) or when it has
|
||||
ever had a radio channel — an unlinked server that ran ``/join`` acts for
|
||||
``DISCORD_UNLINKED_GUILD_ORG_ID`` and should be visible so it can be linked.
|
||||
Radio bots come from ``GET /tokens``; no token string is returned here.
|
||||
Discord user ids (``created_by_discord_id``) and the linking admin's uid
|
||||
are deliberately left out.
|
||||
"""
|
||||
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
|
||||
if g.get("guild_id")}
|
||||
all_streams = await fstore.collection_list(streams.STREAMS)
|
||||
orgs = {o.get("org_id"): o for o in await fstore.collection_list("organizations") if o.get("org_id")}
|
||||
nodes = {n.get("node_id"): n for n in await fstore.collection_list("nodes") if n.get("node_id")}
|
||||
tokens = {t.get("token_id"): t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
||||
fallback = settings.discord_unlinked_guild_org_id or None
|
||||
|
||||
guilds: dict[str, dict] = {}
|
||||
for gid, link in links.items():
|
||||
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
|
||||
"org_id": link.get("org_id"), "linked_at": link.get("linked_at")}
|
||||
rows = []
|
||||
for s in all_streams:
|
||||
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
|
||||
g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False,
|
||||
"org_id": None, "linked_at": None})
|
||||
if s.get("guild_name") and not g.get("guild_name"):
|
||||
g["guild_name"] = s["guild_name"]
|
||||
node = nodes.get(s.get("node_id")) or {}
|
||||
tok = tokens.get(s.get("token_id")) or {}
|
||||
rows.append({**_public_stream(s), "org_id": s.get("org_id"), "guild_name": s.get("guild_name"),
|
||||
"created_at": s.get("created_at"), "node_name": node.get("name"),
|
||||
"node_status": node.get("status"), "token_name": tok.get("name")})
|
||||
for g in guilds.values():
|
||||
acting = g["org_id"] if g["linked"] else fallback
|
||||
g["acting_org_id"] = acting
|
||||
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
|
||||
g["live_streams"] = sum(1 for r in rows if r["guild_id"] == g["guild_id"] and r.get("desired") == "on")
|
||||
|
||||
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
|
||||
r.get("channel_name") or r.get("channel_id") or ""))
|
||||
systems = await fstore.collection_list("systems")
|
||||
# Names for every node a stream or a radio-bot lease points at.
|
||||
referenced = {r.get("node_id") for r in rows} | {(t.get("lease") or {}).get("node_id") for t in tokens.values()}
|
||||
node_names = {nid: nodes[nid].get("name") or nid for nid in referenced if nid in nodes}
|
||||
return {
|
||||
"guilds": sorted(guilds.values(), key=lambda g: (not g["linked"], g.get("guild_name") or g["guild_id"])),
|
||||
"streams": rows,
|
||||
"orgs": sorted(({"org_id": oid, "name": o.get("name") or oid} for oid, o in orgs.items()),
|
||||
key=lambda o: o["name"].lower()),
|
||||
"node_names": node_names,
|
||||
"systems": [{"system_id": s.get("system_id"), "name": s.get("name"), "org_id": s.get("org_id")}
|
||||
for s in systems if s.get("system_id")],
|
||||
"limits": {"guild_max_streams": settings.discord_guild_max_streams,
|
||||
"node_max_streams": settings.discord_node_max_streams,
|
||||
"unlinked_guild_org_id": fallback,
|
||||
"unlinked_guild_org_name": (orgs.get(fallback) or {}).get("name") if fallback else None},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Web Discord admin page backend (server-26#213 item 2): GET /discord/admin/overview,
|
||||
plus the id validation the page relies on for PUT /discord/guilds/{id}/link and
|
||||
POST /discord/streams.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import settings
|
||||
from app.internal.auth import require_admin_token, require_service_key_or_admin
|
||||
from app.main import app
|
||||
from tests.fake_firestore import FakeFirestore
|
||||
|
||||
client = TestClient(app)
|
||||
ADMIN = {"uid": "admin-1", "role": "admin"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fs(monkeypatch):
|
||||
fake = FakeFirestore.install(monkeypatch)
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
|
||||
yield fake
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _seed(fake):
|
||||
fake.put("organizations", "founding", {"org_id": "founding", "name": "Founding"})
|
||||
fake.put("organizations", "org-b", {"org_id": "org-b", "name": "Bravo Fire"})
|
||||
fake.put("discord_guilds", "111", {"guild_id": "111", "org_id": "org-b", "linked_by": "uid-x",
|
||||
"linked_at": "2026-10-01T00:00:00+00:00", "guild_name": "Bravo Server"})
|
||||
fake.put("nodes", "n1", {"node_id": "n1", "name": "Node One", "status": "online", "org_id": "org-b"})
|
||||
fake.put("bot_tokens", "t1", {"token_id": "t1", "name": "Radio 1", "token": "secret-t1"})
|
||||
fake.put("systems", "s1", {"system_id": "s1", "name": "County P25", "org_id": "org-b"})
|
||||
fake.put("discord_streams", "111:900", {
|
||||
"stream_id": "111:900", "guild_id": "111", "channel_id": "900", "channel_name": "Dispatch",
|
||||
"org_id": "org-b", "system_id": "s1", "mode": "persistent", "desired": "on", "node_id": "n1",
|
||||
"token_id": "t1", "actual": {"state": "connected"}, "created_by_discord_id": "4242",
|
||||
})
|
||||
# A server that ran /join before anyone linked it — acts for the fallback org.
|
||||
fake.put("discord_streams", "222:901", {
|
||||
"stream_id": "222:901", "guild_id": "222", "channel_id": "901", "guild_name": "Unlinked Server",
|
||||
"org_id": "founding", "system_id": "s1", "mode": "adhoc", "desired": "off",
|
||||
"actual": {"state": "stopping"},
|
||||
})
|
||||
|
||||
|
||||
def test_overview_requires_platform_admin(fs):
|
||||
def deny():
|
||||
raise HTTPException(403, "Admin access required")
|
||||
app.dependency_overrides[require_admin_token] = deny
|
||||
assert client.get("/discord/admin/overview").status_code == 403
|
||||
|
||||
|
||||
def test_overview_lists_linked_and_seen_guilds_streams_and_limits(fs):
|
||||
_seed(fs)
|
||||
app.dependency_overrides[require_admin_token] = lambda: ADMIN
|
||||
body = client.get("/discord/admin/overview").json()
|
||||
|
||||
guilds = {g["guild_id"]: g for g in body["guilds"]}
|
||||
assert guilds["111"]["linked"] is True and guilds["111"]["acting_org_name"] == "Bravo Fire"
|
||||
assert guilds["111"]["live_streams"] == 1 and guilds["111"]["guild_name"] == "Bravo Server"
|
||||
# unlinked but seen: shown, acting for the fallback org, name taken from its stream
|
||||
assert guilds["222"]["linked"] is False and guilds["222"]["acting_org_id"] == "founding"
|
||||
assert guilds["222"]["guild_name"] == "Unlinked Server" and guilds["222"]["live_streams"] == 0
|
||||
assert [g["guild_id"] for g in body["guilds"]] == ["111", "222"] # linked first
|
||||
|
||||
s = body["streams"][0]
|
||||
assert s["stream_id"] == "111:900" and s["node_name"] == "Node One" and s["token_name"] == "Radio 1"
|
||||
assert s["actual"]["state"] == "connected"
|
||||
assert body["streams"][1]["desired"] == "off" # live channels first
|
||||
|
||||
text = repr(body)
|
||||
assert "secret-t1" not in text # no token strings
|
||||
assert "4242" not in text # no Discord user ids
|
||||
assert "uid-x" not in text # no linking-admin uid
|
||||
assert body["limits"]["unlinked_guild_org_name"] == "Founding"
|
||||
assert {o["org_id"] for o in body["orgs"]} == {"founding", "org-b"}
|
||||
assert body["node_names"] == {"n1": "Node One"}
|
||||
assert body["systems"] == [{"system_id": "s1", "name": "County P25", "org_id": "org-b"}]
|
||||
|
||||
|
||||
def test_link_rejects_non_snowflake_and_stores_label(fs):
|
||||
_seed(fs)
|
||||
app.dependency_overrides[require_admin_token] = lambda: ADMIN
|
||||
assert client.put("/discord/guilds/abc/link", json={"org_id": "org-b"}).status_code == 400
|
||||
r = client.put("/discord/guilds/333/link", json={"org_id": "org-b", "guild_name": " Charlie "})
|
||||
assert r.status_code == 200
|
||||
assert fs.get("discord_guilds", "333")["guild_name"] == "Charlie"
|
||||
assert client.put("/discord/guilds/333/link", json={"org_id": "nope"}).status_code == 404
|
||||
|
||||
|
||||
def test_start_stream_rejects_non_snowflake_ids(fs):
|
||||
app.dependency_overrides[require_service_key_or_admin] = lambda: ADMIN
|
||||
r = client.post("/discord/streams", json={"guild_id": "111", "channel_id": "general", "system_id": "s1"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_relinking_a_server_to_another_org_stops_its_live_channels(fs, monkeypatch):
|
||||
from app.internal import discord_streams as streams
|
||||
calls = []
|
||||
|
||||
async def _stop_guild(guild_id, reason, channel_id=None):
|
||||
calls.append((guild_id, reason))
|
||||
return [f"{guild_id}:900"]
|
||||
monkeypatch.setattr(streams, "stop_guild", _stop_guild)
|
||||
_seed(fs)
|
||||
app.dependency_overrides[require_admin_token] = lambda: ADMIN
|
||||
|
||||
# same org again: nothing stopped
|
||||
assert client.put("/discord/guilds/111/link", json={"org_id": "org-b"}).json()["stopped"] == []
|
||||
# unlinked server (acting for the fallback org) linked to that same org: nothing stopped
|
||||
assert client.put("/discord/guilds/222/link", json={"org_id": "founding"}).json()["stopped"] == []
|
||||
assert calls == []
|
||||
# moved to a different org: its live channels are stopped
|
||||
r = client.put("/discord/guilds/111/link", json={"org_id": "founding"}).json()
|
||||
assert r["stopped"] == ["111:900"] and calls == [("111", "server linked to another organization")]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. Needed = most simultaneous channels in any one server."
|
||||
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's gateway session, then acks, then the
|
||||
token returns to the pool — a token is never live on two nodes. Invite links ask for Connect + 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { auth } from "@/lib/firebase";
|
||||
import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types";
|
||||
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||
|
||||
@@ -55,12 +56,39 @@ export const c2api = {
|
||||
deleteSystem: (id: string) =>
|
||||
request(`/systems/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Tokens
|
||||
// Tokens (radio bots) — every route is platform-admin only (server-26#204 item 6)
|
||||
getTokens: () => request<unknown[]>("/tokens"),
|
||||
addToken: (body: { name: string; token: string }) =>
|
||||
request("/tokens", { method: "POST", body: JSON.stringify(body) }),
|
||||
addToken: (body: { name: string; token: string; org_id?: string | null }) =>
|
||||
request<RadioBotAddResult>("/tokens", { method: "POST", body: JSON.stringify(body) }),
|
||||
deleteToken: (id: string) =>
|
||||
request(`/tokens/${id}`, { method: "DELETE" }),
|
||||
/** Re-check one radio bot with Discord: token valid? which servers is it in? */
|
||||
refreshToken: (id: string) =>
|
||||
request<RadioBot>(`/tokens/${id}/refresh`, { method: "POST" }),
|
||||
/** Admin last resort: force-release every radio-bot lease. */
|
||||
flushTokens: () =>
|
||||
request<{ released: number }>("/tokens/flush", { method: "POST" }),
|
||||
|
||||
// Discord radio channels — web admin page (server-26#213 item 2)
|
||||
getDiscordAdmin: () => request<DiscordAdminOverview>("/discord/admin/overview"),
|
||||
linkDiscordGuild: (guildId: string, body: { org_id: string; guild_name?: string }) =>
|
||||
request<{ ok: boolean; stopped: string[] }>(`/discord/guilds/${encodeURIComponent(guildId)}/link`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
startDiscordStream: (body: {
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
system_id: string;
|
||||
mode: "persistent" | "adhoc";
|
||||
channel_name?: string;
|
||||
preferred_token_id?: string;
|
||||
}) => request<DiscordStream>("/discord/streams", { method: "POST", body: JSON.stringify(body) }),
|
||||
stopDiscordStream: (guildId: string, channelId: string) =>
|
||||
request<{ stopped: string[] }>(`/discord/guilds/${encodeURIComponent(guildId)}/stop`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ channel_id: channelId, reason: "stopped from web admin" }),
|
||||
}),
|
||||
|
||||
// Node approval
|
||||
approveNode: (id: string) =>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Discord radio channels + radio-bot pool — shapes returned by c2-core
|
||||
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
|
||||
// small helpers shared by /settings/discord and /tokens (server-26#213).
|
||||
|
||||
export interface RadioBotLease {
|
||||
node_id: string;
|
||||
lease_id?: string;
|
||||
since?: string;
|
||||
streams?: string[];
|
||||
releasing?: boolean;
|
||||
}
|
||||
|
||||
/** One bot_tokens doc as GET /tokens returns it — token string masked server-side. */
|
||||
export interface RadioBot {
|
||||
token_id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
org_id?: string | null; // null = platform pool, else that org's BYO bot
|
||||
valid?: boolean | null; // null/undefined = never checked (Discord unreachable)
|
||||
guild_ids?: string[] | null; // null = membership unknown
|
||||
check_error?: string | null;
|
||||
validated_at?: string | null;
|
||||
application_id?: string | null;
|
||||
bot_name?: string | null;
|
||||
invite_url?: string | null;
|
||||
lease?: RadioBotLease | null;
|
||||
in_use?: boolean;
|
||||
assigned_node_id?: string | null;
|
||||
preferred_for_system_id?: string | null;
|
||||
}
|
||||
|
||||
export interface RadioBotAddResult {
|
||||
token_id: string;
|
||||
name: string;
|
||||
valid: boolean | null;
|
||||
guild_ids: string[] | null;
|
||||
check_error: string | null;
|
||||
invite_url: string | null;
|
||||
}
|
||||
|
||||
export interface DiscordStreamActual {
|
||||
state?: string;
|
||||
error?: string | null;
|
||||
since?: string | null;
|
||||
at?: string | null;
|
||||
}
|
||||
|
||||
export interface DiscordStream {
|
||||
stream_id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
channel_name?: string | null;
|
||||
guild_name?: string | null;
|
||||
org_id?: string | null;
|
||||
system_id?: string;
|
||||
system_name?: string | null;
|
||||
mode?: "persistent" | "adhoc";
|
||||
desired?: "on" | "off";
|
||||
node_id?: string | null;
|
||||
node_name?: string | null;
|
||||
node_status?: string | null;
|
||||
token_id?: string | null;
|
||||
token_name?: string | null;
|
||||
actual?: DiscordStreamActual | null;
|
||||
stop_reason?: string | null;
|
||||
updated_at?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DiscordGuild {
|
||||
guild_id: string;
|
||||
guild_name: string | null;
|
||||
linked: boolean;
|
||||
org_id: string | null;
|
||||
linked_at: string | null;
|
||||
acting_org_id: string | null;
|
||||
acting_org_name: string | null;
|
||||
live_streams: number;
|
||||
}
|
||||
|
||||
export interface DiscordAdminOverview {
|
||||
guilds: DiscordGuild[];
|
||||
streams: DiscordStream[];
|
||||
orgs: { org_id: string; name: string }[];
|
||||
node_names: Record<string, string>;
|
||||
systems: { system_id: string; name: string | null; org_id: string | null }[];
|
||||
limits: {
|
||||
guild_max_streams: number;
|
||||
node_max_streams: number;
|
||||
unlinked_guild_org_id: string | null;
|
||||
unlinked_guild_org_name: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** A desired-on channel that has not reached "connected" for this long turns red. */
|
||||
export const STUCK_AFTER_MS = 2 * 60 * 1000;
|
||||
|
||||
export type Health = "ok" | "warn" | "bad" | "off";
|
||||
|
||||
export function streamHealth(s: DiscordStream, now = Date.now()): { health: Health; label: string } {
|
||||
const state = s.actual?.state ?? "pending";
|
||||
if (s.desired !== "on") {
|
||||
return { health: "off", label: state === "stopping" ? "off → stopping" : "off" };
|
||||
}
|
||||
if (state === "connected") return { health: "ok", label: "on → connected" };
|
||||
if (state === "error" || state === "node_offline") return { health: "bad", label: `on → ${state.replace("_", " ")}` };
|
||||
const since = s.actual?.since ? Date.parse(s.actual.since) : NaN;
|
||||
if (!Number.isNaN(since) && now - since > STUCK_AFTER_MS) {
|
||||
const mins = Math.round((now - since) / 60000);
|
||||
return { health: "bad", label: `on → ${state} for ${mins} min` };
|
||||
}
|
||||
return { health: "warn", label: `on → ${state.replace("_", " ")}` };
|
||||
}
|
||||
|
||||
/** The pool bot's invite link pinned to one server (same params c2-core's invite_url(app, guild) adds). */
|
||||
export function inviteForGuild(inviteUrl: string | null | undefined, guildId: string): string | null {
|
||||
if (!inviteUrl) return null;
|
||||
return `${inviteUrl}&guild_id=${encodeURIComponent(guildId)}&disable_guild_select=true`;
|
||||
}
|
||||
|
||||
export function guildLabel(g: { guild_id: string; guild_name?: string | null }): string {
|
||||
return g.guild_name || `server ${shortId(g.guild_id)}`;
|
||||
}
|
||||
|
||||
/** 1152…4402 — enough to tell snowflakes apart without a 19-digit column. */
|
||||
export function shortId(id: string | null | undefined): string {
|
||||
if (!id) return "—";
|
||||
return id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id;
|
||||
}
|
||||
|
||||
export const SNOWFLAKE = /^\d{15,21}$/;
|
||||
|
||||
/**
|
||||
* c2api's request() throws `C2 API error <status>: <body>`. Discord routes
|
||||
* answer with `{detail, code, invite: [{name, invite_url}]}` — pull those out
|
||||
* so "no bot in this server" can render its invite links.
|
||||
*/
|
||||
export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
|
||||
if (m) {
|
||||
try {
|
||||
const body = JSON.parse(m[2]);
|
||||
const detail = typeof body?.detail === "string" ? body.detail : Array.isArray(body?.detail) ? body.detail.map((d: { msg?: string }) => d.msg).join("; ") : null;
|
||||
return { message: detail || raw, invite: Array.isArray(body?.invite) ? body.invite : [] };
|
||||
} catch {
|
||||
return { message: m[2] || raw, invite: [] };
|
||||
}
|
||||
}
|
||||
return { message: raw, invite: [] };
|
||||
}
|
||||
|
||||
export function fmtWhen(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const sameDay = d.toDateString() === new Date().toDateString();
|
||||
return sameDay
|
||||
? d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
|
||||
: d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
Reference in New Issue
Block a user