Frontend, built from design-canvas/discord-system-mockup.html ("DRB admin"):
- Settings > Discord (/settings/discord, platform admin only; the tab is
hidden from org owners because every route behind it is admin-only).
KPIs + tabs:
* Radio channels: every discord_streams doc, desired -> actual, the node's
real error, red after 2 min desired-on without connecting. Stop
(POST /discord/guilds/{id}/stop), Start for a stopped channel and
"+ Add radio channel" (POST /discord/streams), with the invite links a
"no bot in this server" refusal carries.
* Servers: linked servers plus servers seen running radio while unlinked
(acting for DISCORD_UNLINKED_GUILD_ORG_ID); Link / Change org
(PUT /discord/guilds/{id}/link).
* Radio bots: valid / check_error, guild membership with a per-server
invite link for each server the bot is missing from, lease (node,
since, releasing), fan-out, Refresh (POST /tokens/{id}/refresh),
Remove, Release all leases (POST /tokens/flush), add with optional BYO org.
* How it works.
- /tokens now renders the same radio-bot table (new token fields + refresh).
- Left out, no backend yet: alert routes, incident forum/roles/plan columns,
per-channel talkgroup chips, "Generate link code" (item 1).
Backend (routers/discord.py):
- GET /discord/admin/overview (platform admin): guilds, streams with node
and bot names, orgs, systems, limits. No token strings, no Discord user
ids, no linking-admin uid.
- PUT /discord/guilds/{id}/link: digits-only id, optional guild_name label,
and re-linking a server to a different org stops its live channels
(the reconciler would otherwise keep re-placing them on the old org's
nodes, using its stored org_id).
- POST /discord/streams: guild_id/channel_id must be digits (the web form
types them in; the bot always sent snowflakes).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
99 lines
3.2 KiB
TypeScript
99 lines
3.2 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import type { Health } from "@/lib/discord";
|
|
|
|
// Shared pieces for the Discord admin page (design-canvas/discord-system-mockup.html,
|
|
// "DRB admin" view): panels with a title row, dense tables, status dots.
|
|
|
|
const DOT: Record<Health, string> = {
|
|
ok: "bg-green-500",
|
|
warn: "bg-sev-moderate",
|
|
bad: "bg-sev-major",
|
|
off: "bg-ink-muted",
|
|
};
|
|
|
|
export function Status({ health, children }: { health: Health; children: ReactNode }) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium whitespace-nowrap text-ink">
|
|
<i className={`inline-block w-2 h-2 rounded-full ${DOT[health]}`} aria-hidden />
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function Panel({
|
|
title,
|
|
description,
|
|
action,
|
|
note,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
description?: ReactNode;
|
|
action?: ReactNode;
|
|
note?: ReactNode;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<section className="bg-surface border border-line rounded-xl overflow-hidden">
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3 border-b border-line">
|
|
<h2 className="text-ink font-semibold text-sm">{title}</h2>
|
|
{description && <span className="text-ink-muted text-xs">{description}</span>}
|
|
{action && <div className="ml-auto flex gap-2">{action}</div>}
|
|
</div>
|
|
{children}
|
|
{note && <div className="text-xs text-ink-muted px-4 py-2.5 bg-raised border-t border-line">{note}</div>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export const TH = "text-left font-medium text-ink-muted text-xs px-4 py-2 border-b border-line whitespace-nowrap";
|
|
export const TD = "px-4 py-2.5 border-b border-line align-top text-sm text-ink";
|
|
export const DIM = "text-xs text-ink-muted";
|
|
|
|
export function Table({ head, children }: { head: string[]; children: ReactNode }) {
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse">
|
|
<thead>
|
|
<tr>
|
|
{head.map((h, i) => (
|
|
<th key={i} className={TH}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="[&>tr:last-child>td]:border-b-0">{children}</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: ReactNode }) {
|
|
return (
|
|
<label className="block">
|
|
<span className="text-xs text-ink-muted block mb-1">{label}</span>
|
|
{children}
|
|
{hint && <span className="text-xs text-ink-muted block mt-1">{hint}</span>}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export const INPUT =
|
|
"w-full bg-page border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none focus:border-accent";
|
|
|
|
export function InviteLinks({ invite }: { invite: { name: string; invite_url: string | null }[] }) {
|
|
if (!invite.length) return null;
|
|
return (
|
|
<span className="flex flex-wrap gap-2 mt-1">
|
|
{invite.map((b) =>
|
|
b.invite_url ? (
|
|
<a key={b.name} href={b.invite_url} target="_blank" rel="noopener noreferrer" className="text-accent underline text-xs">
|
|
Invite {b.name}
|
|
</a>
|
|
) : (
|
|
<span key={b.name} className="text-xs text-ink-muted">{b.name} (no invite link — refresh it under Radio bots)</span>
|
|
),
|
|
)}
|
|
</span>
|
|
);
|
|
}
|