Fill the remaining gaps: badge upload, radio tools, VPN, writeable boxes

Built the admin tools previously listed as missing:
- Badge upload (/admin/badges): uploads a <code>.gif into the emulator's
  badge dir via BADGE_UPLOAD_DIR (node:fs); validated code/type/size,
  logged. Made configurable rather than skipped.
- Radio tools: /admin/radio/api-keys (CRUD, server-generated keys),
  /admin/radio/autodj (Auto-DJ playlist CRUD), /admin/radio/embed (embed
  snippet generator), /admin/radio/points (points settings),
  /admin/radio/monitoring (live stream/now-playing/listeners status).
  radio_api_keys + radio_auto_dj_playlist already had real columns.
- /admin/vpn: VPN/proxy detection config (block toggle + provider + key),
  complementing /admin/ip's raw blacklist.
- Writeable boxes: new website_writeable_boxes table (model + migration
  0006) + /admin/writeable-boxes CRUD; active boxes render on the public
  home page. env: BADGE_UPLOAD_DIR.

Verified live (prod, amx_test): all 8 pages render with real data; a test
writeable box appeared on the public home and was reverted. tsc 0,
vitest 49/49, next build 0 (7 new admin routes).
This commit is contained in:
Simo committed 2026-06-28 21:04:34 +02:00
1 parent e004dfedaf
commit 0cd4d06ff6
21 files changed
+2443 -61

No files matched your search

+305
View File
@@ -0,0 +1,305 @@
import Link from 'next/link';
import { requireStaff } from '@/lib/admin/guard';
import { siteSettings } from '@/lib/services/site-settings';
import { StatusCard, DiagnosticRow } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Radio Monitoring' };
// READ-ONLY live probe of the configured radio endpoints. We never write to the
// DB here; we only read the relevant radio_* keys via siteSettings.get and do a
// best-effort, fail-soft server-side fetch of each URL with a short timeout so a
// slow/down stream provider can never hang the admin page.
const FETCH_TIMEOUT_MS = 4000;
type ProbeResult = {
/** true when the endpoint responded with a 2xx/3xx status within the timeout. */
reachable: boolean;
status: number | null;
/** Parsed JSON when the body was JSON, else null. */
json: unknown;
/** Raw text body (trimmed) when not JSON, else null. */
text: string | null;
error: string | null;
};
/**
* Fetch a URL server-side with a hard AbortController timeout. Always resolves
* (never throws) so the page renders even when the provider is unreachable.
*/
async function probe(url: string): Promise<ProbeResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: controller.signal,
cache: 'no-store',
headers: { accept: 'application/json, text/plain, */*' },
});
const raw = await res.text();
let json: unknown = null;
let text: string | null = null;
const trimmed = raw.trim();
if (trimmed !== '') {
try {
json = JSON.parse(trimmed);
} catch {
text = trimmed.slice(0, 2000);
}
}
return {
reachable: res.ok,
status: res.status,
json,
text,
error: res.ok ? null : `HTTP ${res.status}`,
};
} catch (e) {
const aborted = e instanceof Error && e.name === 'AbortError';
return {
reachable: false,
status: null,
json: null,
text: null,
error: aborted ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s` : 'Fetch failed',
};
} finally {
clearTimeout(timer);
}
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function pickString(obj: Record<string, unknown>, key: string): string | null {
const v = obj[key];
return typeof v === 'string' && v.trim() !== '' ? v.trim() : null;
}
/**
* Best-effort now-playing extraction. Supports the common AzureCast shape
* (now_playing.song.title / .artist), a flat { title, artist }, a bare string,
* or { now_playing: "..." }. Returns a single display string or null.
*/
function parseNowPlaying(result: ProbeResult): string | null {
if (result.json == null) {
return result.text && result.text.length <= 200 ? result.text : null;
}
const data = result.json;
if (typeof data === 'string') return data.trim() || null;
if (!isRecord(data)) return null;
// AzureCast: { now_playing: { song: { title, artist, text } } }
const np = data.now_playing;
if (typeof np === 'string' && np.trim() !== '') return np.trim();
if (isRecord(np)) {
const song = np.song;
if (isRecord(song)) {
const title = pickString(song, 'title');
const artist = pickString(song, 'artist');
if (title || artist) {
return [artist, title].filter(Boolean).join(' - ');
}
const txt = pickString(song, 'text');
if (txt) return txt;
}
}
// Flat shapes: { song: { title, artist } } or { title, artist } or { text }.
const song = data.song;
if (isRecord(song)) {
const title = pickString(song, 'title');
const artist = pickString(song, 'artist');
if (title || artist) return [artist, title].filter(Boolean).join(' - ');
}
const title = pickString(data, 'title');
const artist = pickString(data, 'artist');
if (title || artist) return [artist, title].filter(Boolean).join(' - ');
return pickString(data, 'text') ?? pickString(data, 'song') ?? null;
}
function findNumberDeep(value: unknown, keys: string[], depth = 0): number | null {
if (depth > 4) return null;
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (!isRecord(value)) return null;
for (const key of keys) {
const v = value[key];
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) {
return Number(v);
}
}
for (const v of Object.values(value)) {
if (isRecord(v)) {
const found = findNumberDeep(v, keys, depth + 1);
if (found !== null) return found;
}
}
return null;
}
/**
* Best-effort listener count. Looks for listeners(.current/.total), num_listeners,
* unique_listeners, or a bare numeric body. Returns null when nothing parses.
*/
function parseListeners(result: ProbeResult): number | null {
if (result.json == null) {
if (result.text && Number.isFinite(Number(result.text.trim()))) {
return Number(result.text.trim());
}
return null;
}
const data = result.json;
if (typeof data === 'number' && Number.isFinite(data)) return data;
return findNumberDeep(data, [
'current',
'total',
'num_listeners',
'listeners',
'unique_listeners',
'count',
]);
}
function shortUrl(url: string): string {
try {
const u = new URL(url);
return u.host + (u.pathname === '/' ? '' : u.pathname);
} catch {
return url.length > 60 ? `${url.slice(0, 57)}…` : url;
}
}
export default async function AdminRadioMonitoringPage() {
await requireStaff();
let nowPlayingUrl: string | null = null;
let listenersUrl: string | null = null;
let streamUrl: string | null = null;
try {
nowPlayingUrl = (await siteSettings.get('radio_now_playing_api_url', '')) || null;
listenersUrl = (await siteSettings.get('radio_listeners_api_url', '')) || null;
streamUrl = (await siteSettings.get('radio_stream_url', '')) || null;
} catch {
nowPlayingUrl = null;
listenersUrl = null;
streamUrl = null;
}
// Probe each configured endpoint (fail-soft). The now-playing endpoint doubles
// as our "stream reachable?" signal since it reflects the live provider.
const nowPlayingResult = nowPlayingUrl ? await probe(nowPlayingUrl) : null;
const listenersResult = listenersUrl ? await probe(listenersUrl) : null;
const nowPlaying = nowPlayingResult ? parseNowPlaying(nowPlayingResult) : null;
const listeners = listenersResult ? parseListeners(listenersResult) : null;
// Stream is "online" when its primary status source (now-playing, else
// listeners) responded successfully.
const streamProbe = nowPlayingResult ?? listenersResult;
const streamOnline = streamProbe?.reachable ?? false;
const streamConfigured = Boolean(nowPlayingUrl || listenersUrl);
const endpoints: {
label: string;
url: string | null;
result: ProbeResult | null;
}[] = [
{ label: 'Now-playing API', url: nowPlayingUrl, result: nowPlayingResult },
{ label: 'Listeners API', url: listenersUrl, result: listenersResult },
];
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/monitoring">Monitoring</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<section className="admin-section">
<h2>Radio Monitoring</h2>
<p className="muted" style={{ marginTop: '-0.4rem' }}>
Read-only live probe of the configured radio endpoints
(<code>radio_now_playing_api_url</code>, <code>radio_listeners_api_url</code>).
Each request fails soft with a {FETCH_TIMEOUT_MS / 1000}s timeout — no data is
written. Configure URLs under{' '}
<Link href="/admin/radio/settings">Settings</Link>.
</p>
<div className="admin-stats">
<StatusCard
label="Stream"
value={
!streamConfigured ? 'Not configured' : streamOnline ? 'Online' : 'Offline'
}
state={!streamConfigured ? 'neutral' : streamOnline ? 'ok' : 'danger'}
hint={streamUrl ? shortUrl(streamUrl) : undefined}
icon="📡"
/>
<StatusCard
label="Now playing"
value={nowPlaying ?? '—'}
state={nowPlaying ? 'ok' : 'neutral'}
hint={!nowPlayingUrl ? 'no now-playing API set' : undefined}
icon="🎵"
/>
<StatusCard
label="Listeners"
value={listeners ?? '—'}
state={listeners != null ? 'ok' : 'neutral'}
hint={!listenersUrl ? 'no listeners API set' : undefined}
icon="🎧"
/>
</div>
</section>
<section className="admin-section">
<h2>Endpoint diagnostics</h2>
<p className="muted" style={{ marginTop: '-0.4rem' }}>
<span className="admin-badge ok">OK</span> reachable ·{' '}
<span className="admin-badge">WARN</span> not configured ·{' '}
<span className="admin-badge danger">ERROR</span> fetch failed / timed out.
</p>
<div className="admin-diag">
{endpoints.map((ep) => {
let state: 'ok' | 'warn' | 'danger';
let detail: string;
if (!ep.url) {
state = 'warn';
detail = 'Not configured';
} else if (ep.result?.reachable) {
state = 'ok';
detail = `${shortUrl(ep.url)}${
ep.result.status != null ? ` · HTTP ${ep.result.status}` : ''
}`;
} else {
state = 'danger';
detail = `${shortUrl(ep.url)} · ${ep.result?.error ?? 'unreachable'}`;
}
return (
<DiagnosticRow
key={ep.label}
label={ep.label}
detail={detail}
state={state}
/>
);
})}
<DiagnosticRow
label="Stream URL"
detail={streamUrl ? shortUrl(streamUrl) : 'Not configured'}
state={streamUrl ? 'ok' : 'warn'}
/>
</div>
</section>
</main>
);
}