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:
co-authored by
Claude Opus 5.5
parent
fabdf5839e
commit
c92bdfe841
@@ -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's organization'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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user