Files
server-26/drb-frontend/components/discord/ServersPanel.tsx
T
Logan CusanoandClaude Opus 5.5 c92bdfe841 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>
2026-10-04 22:40:43 -04:00

283 lines
11 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 { 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.
// 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,
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>
);
}
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,
}: {
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 [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;
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&apos;s systems until it is linked here. Set the variable
to an empty string once every real server is linked.
</>
) : (
<>Unlinked servers can&apos;t use radio commands until they are linked here.</>
)
}
>
{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>
) : (
<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 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,
<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>
);
}