Merge pull request 'Discord admin page + hide admin-only bot pool from operators (#213 items 2, 4)' (#219) from feat/213-discord-admin into main
Reviewed-on: #219
This commit was merged in pull request #219.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { auth } from "@/lib/firebase";
|
||||
import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types";
|
||||
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||
|
||||
@@ -55,12 +56,39 @@ export const c2api = {
|
||||
deleteSystem: (id: string) =>
|
||||
request(`/systems/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Tokens
|
||||
// Tokens (radio bots) — every route is platform-admin only (server-26#204 item 6)
|
||||
getTokens: () => request<unknown[]>("/tokens"),
|
||||
addToken: (body: { name: string; token: string }) =>
|
||||
request("/tokens", { method: "POST", body: JSON.stringify(body) }),
|
||||
addToken: (body: { name: string; token: string; org_id?: string | null }) =>
|
||||
request<RadioBotAddResult>("/tokens", { method: "POST", body: JSON.stringify(body) }),
|
||||
deleteToken: (id: string) =>
|
||||
request(`/tokens/${id}`, { method: "DELETE" }),
|
||||
/** Re-check one radio bot with Discord: token valid? which servers is it in? */
|
||||
refreshToken: (id: string) =>
|
||||
request<RadioBot>(`/tokens/${id}/refresh`, { method: "POST" }),
|
||||
/** Admin last resort: force-release every radio-bot lease. */
|
||||
flushTokens: () =>
|
||||
request<{ released: number }>("/tokens/flush", { method: "POST" }),
|
||||
|
||||
// Discord radio channels — web admin page (server-26#213 item 2)
|
||||
getDiscordAdmin: () => request<DiscordAdminOverview>("/discord/admin/overview"),
|
||||
linkDiscordGuild: (guildId: string, body: { org_id: string; guild_name?: string }) =>
|
||||
request<{ ok: boolean; stopped: string[] }>(`/discord/guilds/${encodeURIComponent(guildId)}/link`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
startDiscordStream: (body: {
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
system_id: string;
|
||||
mode: "persistent" | "adhoc";
|
||||
channel_name?: string;
|
||||
preferred_token_id?: string;
|
||||
}) => request<DiscordStream>("/discord/streams", { method: "POST", body: JSON.stringify(body) }),
|
||||
stopDiscordStream: (guildId: string, channelId: string) =>
|
||||
request<{ stopped: string[] }>(`/discord/guilds/${encodeURIComponent(guildId)}/stop`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ channel_id: channelId, reason: "stopped from web admin" }),
|
||||
}),
|
||||
|
||||
// Node approval
|
||||
approveNode: (id: string) =>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Discord radio channels + radio-bot pool — shapes returned by c2-core
|
||||
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
|
||||
// small helpers shared by /settings/discord and /tokens (server-26#213).
|
||||
|
||||
export interface RadioBotLease {
|
||||
node_id: string;
|
||||
lease_id?: string;
|
||||
since?: string;
|
||||
streams?: string[];
|
||||
releasing?: boolean;
|
||||
}
|
||||
|
||||
/** One bot_tokens doc as GET /tokens returns it — token string masked server-side. */
|
||||
export interface RadioBot {
|
||||
token_id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
org_id?: string | null; // null = platform pool, else that org's BYO bot
|
||||
valid?: boolean | null; // null/undefined = never checked (Discord unreachable)
|
||||
guild_ids?: string[] | null; // null = membership unknown
|
||||
check_error?: string | null;
|
||||
validated_at?: string | null;
|
||||
application_id?: string | null;
|
||||
bot_name?: string | null;
|
||||
invite_url?: string | null;
|
||||
lease?: RadioBotLease | null;
|
||||
in_use?: boolean;
|
||||
assigned_node_id?: string | null;
|
||||
preferred_for_system_id?: string | null;
|
||||
}
|
||||
|
||||
export interface RadioBotAddResult {
|
||||
token_id: string;
|
||||
name: string;
|
||||
valid: boolean | null;
|
||||
guild_ids: string[] | null;
|
||||
check_error: string | null;
|
||||
invite_url: string | null;
|
||||
}
|
||||
|
||||
export interface DiscordStreamActual {
|
||||
state?: string;
|
||||
error?: string | null;
|
||||
since?: string | null;
|
||||
at?: string | null;
|
||||
}
|
||||
|
||||
export interface DiscordStream {
|
||||
stream_id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
channel_name?: string | null;
|
||||
guild_name?: string | null;
|
||||
org_id?: string | null;
|
||||
system_id?: string;
|
||||
system_name?: string | null;
|
||||
mode?: "persistent" | "adhoc";
|
||||
desired?: "on" | "off";
|
||||
node_id?: string | null;
|
||||
node_name?: string | null;
|
||||
node_status?: string | null;
|
||||
token_id?: string | null;
|
||||
token_name?: string | null;
|
||||
actual?: DiscordStreamActual | null;
|
||||
stop_reason?: string | null;
|
||||
updated_at?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DiscordGuild {
|
||||
guild_id: string;
|
||||
guild_name: string | null;
|
||||
linked: boolean;
|
||||
org_id: string | null;
|
||||
linked_at: string | null;
|
||||
acting_org_id: string | null;
|
||||
acting_org_name: string | null;
|
||||
live_streams: number;
|
||||
}
|
||||
|
||||
export interface DiscordAdminOverview {
|
||||
guilds: DiscordGuild[];
|
||||
streams: DiscordStream[];
|
||||
orgs: { org_id: string; name: string }[];
|
||||
node_names: Record<string, string>;
|
||||
systems: { system_id: string; name: string | null; org_id: string | null }[];
|
||||
limits: {
|
||||
guild_max_streams: number;
|
||||
node_max_streams: number;
|
||||
unlinked_guild_org_id: string | null;
|
||||
unlinked_guild_org_name: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** A desired-on channel that has not reached "connected" for this long turns red. */
|
||||
export const STUCK_AFTER_MS = 2 * 60 * 1000;
|
||||
|
||||
export type Health = "ok" | "warn" | "bad" | "off";
|
||||
|
||||
export function streamHealth(s: DiscordStream, now = Date.now()): { health: Health; label: string } {
|
||||
const state = s.actual?.state ?? "pending";
|
||||
if (s.desired !== "on") {
|
||||
return { health: "off", label: state === "stopping" ? "off → stopping" : "off" };
|
||||
}
|
||||
if (state === "connected") return { health: "ok", label: "on → connected" };
|
||||
if (state === "error" || state === "node_offline") return { health: "bad", label: `on → ${state.replace("_", " ")}` };
|
||||
const since = s.actual?.since ? Date.parse(s.actual.since) : NaN;
|
||||
if (!Number.isNaN(since) && now - since > STUCK_AFTER_MS) {
|
||||
const mins = Math.round((now - since) / 60000);
|
||||
return { health: "bad", label: `on → ${state} for ${mins} min` };
|
||||
}
|
||||
return { health: "warn", label: `on → ${state.replace("_", " ")}` };
|
||||
}
|
||||
|
||||
/** The pool bot's invite link pinned to one server (same params c2-core's invite_url(app, guild) adds). */
|
||||
export function inviteForGuild(inviteUrl: string | null | undefined, guildId: string): string | null {
|
||||
if (!inviteUrl) return null;
|
||||
return `${inviteUrl}&guild_id=${encodeURIComponent(guildId)}&disable_guild_select=true`;
|
||||
}
|
||||
|
||||
export function guildLabel(g: { guild_id: string; guild_name?: string | null }): string {
|
||||
return g.guild_name || `server ${shortId(g.guild_id)}`;
|
||||
}
|
||||
|
||||
/** 1152…4402 — enough to tell snowflakes apart without a 19-digit column. */
|
||||
export function shortId(id: string | null | undefined): string {
|
||||
if (!id) return "—";
|
||||
return id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id;
|
||||
}
|
||||
|
||||
export const SNOWFLAKE = /^\d{15,21}$/;
|
||||
|
||||
/**
|
||||
* c2api's request() throws `C2 API error <status>: <body>`. Discord routes
|
||||
* answer with `{detail, code, invite: [{name, invite_url}]}` — pull those out
|
||||
* so "no bot in this server" can render its invite links.
|
||||
*/
|
||||
export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
|
||||
if (m) {
|
||||
try {
|
||||
const body = JSON.parse(m[2]);
|
||||
const detail = typeof body?.detail === "string" ? body.detail : Array.isArray(body?.detail) ? body.detail.map((d: { msg?: string }) => d.msg).join("; ") : null;
|
||||
return { message: detail || raw, invite: Array.isArray(body?.invite) ? body.invite : [] };
|
||||
} catch {
|
||||
return { message: m[2] || raw, invite: [] };
|
||||
}
|
||||
}
|
||||
return { message: raw, invite: [] };
|
||||
}
|
||||
|
||||
export function fmtWhen(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const sameDay = d.toDateString() === new Date().toDateString();
|
||||
return sameDay
|
||||
? d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
|
||||
: d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
Reference in New Issue
Block a user