- 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>
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
"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'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,
|
||
}: {
|
||
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'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)} />}
|
||
{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>
|
||
);
|
||
}
|