Discord: log node-reported stream state; remove a radio channel or a server

- handle_actual logs every node-reported state TRANSITION (not heartbeats)
  with stream/node/guild/channel/token ids and the node's error text: info
  for connected/connecting/stopped, warning for error, and a dedicated
  "radio bot X failed to log in on node N: <reason>" line for login_failed.
  The node's reason code is now stored in actual.code and shown next to the
  error on Settings > Discord and the node page.
- Remove a radio channel permanently: streams.delete_stream stops it through
  desired state first (node republished without it, token -> releasing per
  #216, freed on the node's ack), then deletes the doc in a transaction that
  refuses if the channel was restarted in between. DELETE
  /discord/streams/{id} (platform admin) and DELETE
  /discord/nodes/{node}/streams/{id} (admin or org owner, same org + node
  only; operators can still stop, not remove). Audited.
- Remove a server: DELETE /discord/guilds/{id} (admin) deletes every radio
  channel in it the same way, then tombstones discord_guilds (org_id None +
  unlinked_at, #218 semantics, plus removed_at) so it never falls back to
  DISCORD_UNLINKED_GUILD_ORG_ID; the admin page hides it, and /drb link or the
  admin link brings it back. Optional leave_radio_bots makes pool bots and the
  server's org's own BYO bots leave via Discord REST; the DRB server bot stays
  until kicked, and the UI says so. Audited.
- UI: Remove (with confirm) on Radio channels, Servers and the node page list.

Refs #213, #221

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 22:40:43 -04:00
co-authored by Claude Opus 5.5
parent fabdf5839e
commit c92bdfe841
13 changed files with 836 additions and 25 deletions
@@ -10,6 +10,8 @@ import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
// 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.
// Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then
// deleted, and the server is unlinked with no fallback org.
function LinkForm({
overview,
@@ -83,6 +85,63 @@ function LinkForm({
);
}
type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] };
function RemoveForm({
guild,
onDone,
onCancel,
}: {
guild: DiscordGuild;
onDone: (r: RemoveResult) => void;
onCancel: () => void;
}) {
const [leaveBots, setLeaveBots] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const n = guild.live_streams;
async function submit() {
setSaving(true);
setError(null);
try {
const r = await c2api.removeDiscordGuild(guild.guild_id, leaveBots);
onDone({ guild: guildLabel(guild), streams: r.streams_removed.length, bots: r.radio_bots });
} catch (err) {
setError(parseC2Error(err).message);
} finally {
setSaving(false);
}
}
return (
<div className="px-4 py-4 border-b border-line bg-raised text-xs text-ink-2 space-y-3">
<p>
<b className="text-ink">Remove {guildLabel(guild)} from DRB?</b>{" "}
{n > 0
? `Its ${n} live radio channel${n === 1 ? " is" : "s are"} stopped first, then every radio channel in it is deleted.`
: "Every radio channel in it is deleted."}{" "}
It is unlinked and will not fall back to the default organization; linking it again brings it back.
</p>
<label className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={leaveBots} onChange={(e) => setLeaveBots(e.target.checked)} />
<span>
Also make the radio bots leave this Discord server (the platform pool and this server&apos;s organization&apos;s
own bots). They need a new invite to come back.
</span>
</label>
<p className="text-ink-muted">
The DRB bot itself stays in the Discord server until someone kicks it there.
</p>
{error && <p className="text-sev-major">{error}</p>}
<div className="flex gap-2">
<Button size="sm" variant="danger" disabled={saving} onClick={submit}>{saving ? "Removing…" : "Remove server"}</Button>
<Button size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div>
</div>
);
}
export function ServersPanel({
overview,
onChanged,
@@ -92,6 +151,8 @@ export function ServersPanel({
}) {
// "new" = blank link form; a guild id = editing that row's link.
const [editing, setEditing] = useState<string | null>(null);
const [removing, setRemoving] = useState<string | null>(null);
const [removed, setRemoved] = useState<RemoveResult | 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;
@@ -119,6 +180,19 @@ export function ServersPanel({
}
>
{editing === "new" && <LinkForm overview={overview} onDone={done} onCancel={() => setEditing(null)} />}
{removed && (
<div className="px-4 py-3 border-b border-line text-xs text-ink-2">
Removed {removed.guild}: {removed.streams} radio channel{removed.streams === 1 ? "" : "s"} deleted.
{removed.bots.length > 0 && (
<>
{" "}Radio bots:{" "}
{removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}.
</>
)}{" "}
The DRB bot is still in that Discord server until someone kicks it.
<button className="ml-3 text-accent hover:underline" onClick={() => setRemoved(null)}>Dismiss</button>
</div>
)}
{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>
) : (
@@ -147,13 +221,50 @@ export function ServersPanel({
<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)}>
<td className={`${TD} text-right whitespace-nowrap`}>
<Button
size="sm"
variant="secondary"
onClick={() => {
setRemoving(null);
setEditing(editing === g.guild_id ? null : g.guild_id);
}}
>
{g.linked ? "Change org" : "Link"}
</Button>
<Button
size="sm"
variant="danger"
className="ml-1"
onClick={() => {
setEditing(null);
setRemoved(null);
setRemoving(removing === g.guild_id ? null : g.guild_id);
}}
>
Remove
</Button>
</td>
</tr>
);
if (removing === g.guild_id) {
return [
row,
<tr key={`${g.guild_id}-remove`}>
<td colSpan={6} className="p-0 border-b border-line">
<RemoveForm
guild={g}
onCancel={() => setRemoving(null)}
onDone={async (r) => {
setRemoving(null);
setRemoved(r);
await onChanged();
}}
/>
</td>
</tr>,
];
}
if (editing !== g.guild_id) return [row];
return [
row,
@@ -18,6 +18,8 @@ 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.
type Invite = { name: string; invite_url: string | null }[];
@@ -214,7 +216,12 @@ export function StreamsPanel({
<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.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]">
@@ -224,7 +231,7 @@ export function StreamsPanel({
)}
</td>
<td className={TD}>{s.token_name ?? <span className={DIM}>—</span>}</td>
<td className={`${TD} text-right`}>
<td className={`${TD} text-right whitespace-nowrap`}>
{isOn ? (
<Button
size="sm"
@@ -256,6 +263,22 @@ export function StreamsPanel({
{busy === s.stream_id ? "Starting…" : "Start"}
</Button>
)}
<Button
size="sm"
variant="danger"
className="ml-1"
disabled={busy !== null}
onClick={() => {
const name = s.channel_name || "this channel";
const msg = isOn
? `Remove radio from ${name} permanently? It is stopped first (the node logs its radio bot out), then deleted from DRB.`
: `Remove ${name} from DRB permanently?`;
if (!confirm(msg)) return;
act(s, () => c2api.deleteDiscordStream(s.stream_id));
}}
>
Remove
</Button>
</td>
</tr>
);