Files
server-26/drb-frontend/components/discord/StreamsPanel.tsx
T
Logan CusanoandClaude Opus 5.5 5c53850cbf Discord admin Add form: say so when the system already plays in that channel (#213)
Re-adding the same system to a voice channel returns the running stream
untouched (already_on). The form closed as if a stream had been added and
the new label/bot was silently dropped; it now stays open and explains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:38:58 -04:00

345 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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.
// Remove is DELETE /discord/streams/{id} (admin): c2-core stops the channel
// through desired state first, then deletes it.
//
// One row per STREAM, not per voice channel: a channel can carry several,
// each with its own radio bot and system (server-26#213 follow-up). Stop,
// Start and Remove all act on the row's stream_id, so the other streams in
// the same channel keep playing. Add never replaces an existing stream.
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));
// A bot can be in one voice channel per server: name the stream holding it.
const busyHere = new Map<string, DiscordStream>();
for (const s of overview.streams) {
if (s.desired === "on" && s.guild_id === guildId && s.token_id) busyHere.set(s.token_id, s);
}
const inChannel = overview.streams.filter(
(s) => s.desired === "on" && s.guild_id === guildId && s.channel_id === channelId.trim(),
);
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 {
const res = await c2api.startDiscordStream({
guild_id: guildId,
channel_id: channelId.trim(),
system_id: systemId,
mode,
channel_name: channelName.trim() || undefined,
preferred_token_id: botId || undefined,
});
if (res?.already_on) {
// Same system re-added to a channel that already plays it: c2-core
// returns that stream untouched (label, mode and bot kept). Say so
// instead of closing as if a stream had been added.
setError({
message: `${res.system_name || "That system"} is already playing in this voice channel, so nothing was added or changed. Pick another system to add a second stream, or Remove the existing row to change its label or bot.`,
invite: [],
});
return;
}
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) => {
const busy = busyHere.get(b.token_id);
return (
<option key={b.token_id} value={b.token_id} disabled={!!busy}>
{busy
? `${b.name} — in use here (${busy.system_name || busy.system_id || "radio"}${busy.channel_name ? ` in ${busy.channel_name}` : ""})`
: `Use ${b.name}`}
</option>
);
})}
</select>
</Field>
{inChannel.length > 0 && (
<div className="sm:col-span-3 text-xs text-ink-muted">
This voice channel already plays{" "}
{inChannel.map((s) => `${s.system_name || s.system_id}${s.token_name ? ` (${s.token_name})` : ""}`).join(", ")}.
A new system is added beside it with another bot; nothing that is playing is replaced.
</div>
)}
{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]);
// Streams sharing a voice channel (c2-core sorts them next to each other).
const perChannel = useMemo(() => {
const m = new Map<string, number>();
for (const s of overview.streams) {
const k = `${s.guild_id}:${s.channel_id}`;
m.set(k, (m.get(k) ?? 0) + 1);
}
return m;
}, [overview.streams]);
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 radio stream — a voice channel can carry several, each with its own bot and system. Persistent streams 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";
const shared = (perChannel.get(`${s.guild_id}:${s.channel_id}`) ?? 1) - 1;
const name = s.channel_name || "this channel";
const what = `${s.system_name || s.system_id || "radio"} in ${name}`;
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>
{shared > 0 && (
<div className={DIM}>
+ {shared} other stream{shared === 1 ? "" : "s"} in this voice channel
</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}
{s.actual.code && <span className="font-mono"> [{s.actual.code}]</span>}
</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 whitespace-nowrap`}>
{isOn ? (
<Button
size="sm"
variant="secondary"
disabled={busy !== null}
onClick={() => {
if (!confirm(`Stop ${what}?`)) return;
act(s, () => c2api.stopDiscordStream(s.guild_id, s.stream_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",
stream_id: s.stream_id,
}),
)
}
>
{busy === s.stream_id ? "Starting…" : "Start"}
</Button>
)}
<Button
size="sm"
variant="danger"
className="ml-1"
disabled={busy !== null}
onClick={() => {
const msg = isOn
? `Remove ${what} permanently? It is stopped first (the node logs its radio bot out), then deleted from DRB.${shared > 0 ? " The other streams in this voice channel keep playing." : ""}`
: `Remove ${what} from DRB permanently?`;
if (!confirm(msg)) return;
act(s, () => c2api.deleteDiscordStream(s.stream_id));
}}
>
Remove
</Button>
</td>
</tr>
);
})}
</Table>
)}
</Panel>
);
}