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:
1 parent
e004dfedaf
commit
0cd4d06ff6
21 files changed
+2443
-61
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user