Reapply "Add missing admin action files and navigation links"

This reverts commit 4d515bc400.
This commit is contained in:
Simo committed 2026-07-11 20:52:56 +02:00
1 parent 96ed768f14
commit 5b4228261a
338 files changed
+28143 -5948

No files matched your search

+67 -70
View File
@@ -1,13 +1,13 @@
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';
import { Radio } from 'lucide-react';
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";
import { Radio } from "lucide-react";
import { getTranslations } from "next-intl/server";
export const dynamic = 'force-dynamic';
export const dynamic = "force-dynamic";
export const metadata = { title: 'Radio Monitoring' };
export const metadata = { title: "Radio Monitoring" };
const FETCH_TIMEOUT_MS = 4000;
@@ -25,14 +25,14 @@ async function probe(url: string): Promise<ProbeResult> {
try {
const res = await fetch(url, {
signal: controller.signal,
cache: 'no-store',
headers: { accept: 'application/json, text/plain, */*' },
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 !== '') {
if (trimmed !== "") {
try {
json = JSON.parse(trimmed);
} catch {
@@ -47,13 +47,13 @@ async function probe(url: string): Promise<ProbeResult> {
error: res.ok ? null : `HTTP ${res.status}`,
};
} catch (e) {
const aborted = e instanceof Error && e.name === 'AbortError';
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',
error: aborted ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s` : "Fetch failed",
};
} finally {
clearTimeout(timer);
@@ -61,13 +61,13 @@ async function probe(url: string): Promise<ProbeResult> {
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function pickString(obj: Record<string, unknown>, key: string): string | null {
// eslint-disable-next-line security/detect-object-injection -- only called with hardcoded keys
const v = obj[key];
return typeof v === 'string' && v.trim() !== '' ? v.trim() : null;
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
}
function parseNowPlaying(result: ProbeResult): string | null {
@@ -75,46 +75,46 @@ function parseNowPlaying(result: ProbeResult): string | null {
return result.text && result.text.length <= 200 ? result.text : null;
}
const data = result.json;
if (typeof data === 'string') return data.trim() || null;
if (typeof data === "string") return data.trim() || null;
if (!isRecord(data)) return null;
const np = data.now_playing;
if (typeof np === 'string' && np.trim() !== '') return np.trim();
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');
const title = pickString(song, "title");
const artist = pickString(song, "artist");
if (title || artist) {
return [artist, title].filter(Boolean).join(' - ');
return [artist, title].filter(Boolean).join(" - ");
}
const txt = pickString(song, 'text');
const txt = pickString(song, "text");
if (txt) return txt;
}
}
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(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(' - ');
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;
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 (typeof value === "number" && Number.isFinite(value)) return value;
if (!isRecord(value)) return null;
for (const key of keys) {
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
const v = value[key];
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) {
return Number(v);
}
}
@@ -135,21 +135,21 @@ function parseListeners(result: ProbeResult): number | null {
return null;
}
const data = result.json;
if (typeof data === 'number' && Number.isFinite(data)) return data;
if (typeof data === "number" && Number.isFinite(data)) return data;
return findNumberDeep(data, [
'current',
'total',
'num_listeners',
'listeners',
'unique_listeners',
'count',
"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);
return u.host + (u.pathname === "/" ? "" : u.pathname);
} catch {
return url.length > 60 ? `${url.slice(0, 57)}\u2026` : url;
}
@@ -164,9 +164,9 @@ export default async function AdminRadioMonitoringPage() {
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;
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;
@@ -195,11 +195,11 @@ export default async function AdminRadioMonitoringPage() {
return (
<main>
<nav className="text-xs text-gray-500 dark:text-gray-400 mb-2">
<Link href="/admin/radio">{t("title")}</Link> ·{' '}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{' '}
<Link href="/admin/radio/monitoring">{t("monitoring.title")}</Link> ·{' '}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{' '}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{' '}
<Link href="/admin/radio">{t("title")}</Link> ·{" "}
<Link href="/admin/radio/settings">{t("settings")}</Link> ·{" "}
<Link href="/admin/radio/monitoring">{t("monitoring.title")}</Link> ·{" "}
<Link href="/admin/radio/banners">{t("banners.title")}</Link> ·{" "}
<Link href="/admin/radio/ranks">{t("ranks")}</Link> ·{" "}
<Link href="/admin/radio/history">{t("history")}</Link>
</nav>
@@ -210,7 +210,9 @@ export default async function AdminRadioMonitoringPage() {
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("monitoring.title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("monitoring.subtitle", { timeout: FETCH_TIMEOUT_MS / 1000 })}</p>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">
{t("monitoring.subtitle", { timeout: FETCH_TIMEOUT_MS / 1000 })}
</p>
</div>
</div>
@@ -218,23 +220,27 @@ export default async function AdminRadioMonitoringPage() {
<StatusCard
label={t("monitoring.stream")}
value={
!streamConfigured ? t("monitoring.notConfigured") : streamOnline ? t("monitoring.online") : t("monitoring.offline")
!streamConfigured
? t("monitoring.notConfigured")
: streamOnline
? t("monitoring.online")
: t("monitoring.offline")
}
state={!streamConfigured ? 'neutral' : streamOnline ? 'ok' : 'danger'}
state={!streamConfigured ? "neutral" : streamOnline ? "ok" : "danger"}
hint={streamUrl ? shortUrl(streamUrl) : undefined}
icon="\u{1F4E1}"
/>
<StatusCard
label={t("monitoring.nowPlaying")}
value={nowPlaying ?? '\u2014'}
state={nowPlaying ? 'ok' : 'neutral'}
value={nowPlaying ?? "\u2014"}
state={nowPlaying ? "ok" : "neutral"}
hint={!nowPlayingUrl ? t("monitoring.noNowPlayingHint") : undefined}
icon="\u{1F3B5}"
/>
<StatusCard
label={t("monitoring.listeners")}
value={listeners ?? '\u2014'}
state={listeners != null ? 'ok' : 'neutral'}
value={listeners ?? "\u2014"}
state={listeners != null ? "ok" : "neutral"}
hint={!listenersUrl ? t("monitoring.noListenersHint") : undefined}
icon="\u{1F3A7}"
/>
@@ -243,38 +249,29 @@ export default async function AdminRadioMonitoringPage() {
<section className="mt-6">
<h2 className="admin-section-title">{t("monitoring.endpointDiagnostics")}</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("monitoring.diagnosticsLegend")}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t("monitoring.diagnosticsLegend")}</p>
<div className="flex flex-col">
{endpoints.map((ep) => {
let state: 'ok' | 'warn' | 'danger';
let state: "ok" | "warn" | "danger";
let detail: string;
if (!ep.url) {
state = 'warn';
state = "warn";
detail = t("monitoring.notConfigured");
} else if (ep.result?.reachable) {
state = 'ok';
state = "ok";
detail = `${shortUrl(ep.url)}${
ep.result.status != null ? ` \u00B7 HTTP ${ep.result.status}` : ''
ep.result.status != null ? ` \u00B7 HTTP ${ep.result.status}` : ""
}`;
} else {
state = 'danger';
state = "danger";
detail = `${shortUrl(ep.url)} \u00B7 ${ep.result?.error ?? t("monitoring.unreachable")}`;
}
return (
<DiagnosticRow
key={ep.label}
label={ep.label}
detail={detail}
state={state}
/>
);
return <DiagnosticRow key={ep.label} label={ep.label} detail={detail} state={state} />;
})}
<DiagnosticRow
label={t("monitoring.streamUrl")}
detail={streamUrl ? shortUrl(streamUrl) : t("monitoring.notConfigured")}
state={streamUrl ? 'ok' : 'warn'}
state={streamUrl ? "ok" : "warn"}
/>
</div>
</section>