Files
Logan CusanoandClaude Opus 5.5 d3277c13e1 Discord admin page: servers, radio channels, radio bots (server-26#213 item 2)
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>
2026-10-04 20:05:35 -04:00

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>
);
}