Files
Magent/frontend/app/ui/AdminDiagnosticsPanel.tsx
T

531 lines
18 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type DiagnosticCatalogItem = {
key: string;
label: string;
category: string;
description: string;
live_safe: boolean;
target: string | null;
configured: boolean;
config_status: string;
config_detail: string;
};
type DiagnosticResult = {
key: string;
label: string;
category: string;
description: string;
target: string | null;
live_safe: boolean;
configured: boolean;
status: string;
message: string;
detail?: unknown;
checked_at?: string;
duration_ms?: number;
};
type DiagnosticsResponse = {
checks: DiagnosticCatalogItem[];
categories: string[];
generated_at: string;
};
type RunDiagnosticsResponse = {
results: DiagnosticResult[];
summary: {
total: number;
up: number;
down: number;
degraded: number;
not_configured: number;
disabled: number;
};
checked_at: string;
};
type RunMode = "safe" | "all" | "single";
type AdminDiagnosticsPanelProps = {
embedded?: boolean;
};
type DatabaseDiagnosticDetail = {
integrity_check?: string;
database_path?: string;
database_size_bytes?: number;
wal_size_bytes?: number;
shm_size_bytes?: number;
page_size_bytes?: number;
page_count?: number;
freelist_pages?: number;
allocated_bytes?: number;
free_bytes?: number;
row_counts?: Record<string, number>;
timings_ms?: Record<string, number>;
};
const REFRESH_INTERVAL_MS = 30000;
const STATUS_LABELS: Record<string, string> = {
idle: "Ready",
up: "Up",
down: "Down",
degraded: "Degraded",
disabled: "Disabled",
not_configured: "Not configured",
};
function formatCheckedAt(value?: string) {
if (!value) return "Not yet run";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
}
function formatDuration(value?: number) {
if (typeof value !== "number" || Number.isNaN(value) || value <= 0) {
return "Pending";
}
return `${value.toFixed(1)} ms`;
}
function statusLabel(status: string) {
return STATUS_LABELS[status] ?? status;
}
function formatBytes(value?: number) {
if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
return "0 B";
}
if (value >= 1024 * 1024 * 1024) {
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KB`;
}
return `${value} B`;
}
function formatDetailLabel(value: string) {
return value.replace(/_/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
}
function asDatabaseDiagnosticDetail(detail: unknown): DatabaseDiagnosticDetail | null {
if (!detail || typeof detail !== "object" || Array.isArray(detail)) {
return null;
}
return detail as DatabaseDiagnosticDetail;
}
function renderDatabaseMetricGroup(title: string, values: Array<[string, string]>) {
if (values.length === 0) {
return null;
}
return (
<div className="diagnostic-detail-group">
<h4>{title}</h4>
<div className="diagnostic-detail-grid">
{values.map(([label, value]) => (
<div key={`${title}-${label}`} className="diagnostic-detail-item">
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</div>
);
}
export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false);
const [checks, setChecks] = useState<DiagnosticCatalogItem[]>([]);
const [resultsByKey, setResultsByKey] = useState<Record<string, DiagnosticResult>>({});
const [runningKeys, setRunningKeys] = useState<string[]>([]);
const [autoRefresh, setAutoRefresh] = useState(true);
const [pageError, setPageError] = useState("");
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
const [lastRunMode, setLastRunMode] = useState<RunMode | null>(null);
const [emailRecipient, setEmailRecipient] = useState("");
const liveSafeKeys = useMemo(() => checks.filter((check) => check.live_safe).map((check) => check.key), [checks]);
const runDiagnostics = useCallback(
async (keys?: string[], mode: RunMode = "single") => {
const baseUrl = getApiBase();
const effectiveKeys = keys && keys.length > 0 ? keys : checks.map((check) => check.key);
if (effectiveKeys.length === 0) {
return;
}
setRunningKeys((current) => Array.from(new Set([...current, ...effectiveKeys])));
setPageError("");
try {
const response = await authFetch(`${baseUrl}/admin/diagnostics/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
keys: effectiveKeys,
...(emailRecipient.trim() ? { recipient_email: emailRecipient.trim() } : {}),
}),
});
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Diagnostics run failed: ${response.status}`);
}
const data = (await response.json()) as { status: string } & RunDiagnosticsResponse;
const nextResults: Record<string, DiagnosticResult> = {};
for (const result of data.results ?? []) {
nextResults[result.key] = result;
}
setResultsByKey((current) => ({ ...current, ...nextResults }));
setLastRunAt(data.checked_at ?? new Date().toISOString());
setLastRunMode(mode);
} catch (error) {
console.error(error);
setPageError(error instanceof Error ? error.message : "Diagnostics run failed.");
} finally {
setRunningKeys((current) => current.filter((key) => !effectiveKeys.includes(key)));
}
},
[checks, emailRecipient, router],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: Authorization bootstrap runs once for each router instance.
useEffect(() => {
let active = true;
const loadPage = async () => {
if (!getToken()) {
router.push("/login");
return;
}
try {
const baseUrl = getApiBase();
const authResponse = await authFetch(`${baseUrl}/auth/me`);
if (!authResponse.ok) {
if (authResponse.status === 401) {
clearToken();
router.push("/login");
return;
}
router.push("/");
return;
}
const me = await authResponse.json();
if (!active) return;
if (me?.role !== "admin") {
router.push("/");
return;
}
const diagnosticsResponse = await authFetch(`${baseUrl}/admin/diagnostics`);
if (!diagnosticsResponse.ok) {
const text = await diagnosticsResponse.text();
throw new Error(text || `Diagnostics load failed: ${diagnosticsResponse.status}`);
}
const data = (await diagnosticsResponse.json()) as { status: string } & DiagnosticsResponse;
if (!active) return;
setChecks(data.checks ?? []);
setAuthorized(true);
setLoading(false);
const safeKeys = (data.checks ?? []).filter((check) => check.live_safe).map((check) => check.key);
if (safeKeys.length > 0) {
void runDiagnostics(safeKeys, "safe");
}
} catch (error) {
console.error(error);
if (!active) return;
setPageError(error instanceof Error ? error.message : "Unable to load diagnostics.");
setLoading(false);
}
};
void loadPage();
return () => {
active = false;
};
}, [router]);
useEffect(() => {
if (!authorized || !autoRefresh || liveSafeKeys.length === 0) {
return;
}
const interval = window.setInterval(() => {
void runDiagnostics(liveSafeKeys, "safe");
}, REFRESH_INTERVAL_MS);
return () => {
window.clearInterval(interval);
};
}, [authorized, autoRefresh, liveSafeKeys, runDiagnostics]);
if (loading) {
return <div className="admin-panel">Loading diagnostics...</div>;
}
if (!authorized) {
return null;
}
const orderedCategories: string[] = [];
for (const check of checks) {
if (!orderedCategories.includes(check.category)) {
orderedCategories.push(check.category);
}
}
const mergedResults = checks.map((check) => {
const result = resultsByKey[check.key];
if (result) {
return result;
}
return {
key: check.key,
label: check.label,
category: check.category,
description: check.description,
target: check.target,
live_safe: check.live_safe,
configured: check.configured,
status: check.configured ? "idle" : check.config_status,
message: check.configured ? "Ready to test." : check.config_detail,
checked_at: undefined,
duration_ms: undefined,
} satisfies DiagnosticResult;
});
const summary = {
total: mergedResults.length,
up: 0,
down: 0,
degraded: 0,
disabled: 0,
not_configured: 0,
idle: 0,
};
for (const result of mergedResults) {
const key = result.status as keyof typeof summary;
if (key in summary) {
summary[key] += 1;
}
}
return (
<div className={`diagnostics-page${embedded ? " diagnostics-page-embedded" : ""}`}>
<div className="admin-panel diagnostics-control-panel">
<div className="diagnostics-control-copy">
<h2>{embedded ? "Connectivity diagnostics" : "Control center"}</h2>
<p className="lede">
Check Magent and your connected services. Automatic refresh runs health checks only. Test messages are
managed in Notifications below.
</p>
</div>
<div className="diagnostics-control-actions">
<button
type="button"
className={autoRefresh ? "is-active" : ""}
onClick={() => setAutoRefresh((current) => !current)}
>
{autoRefresh ? "Disable auto refresh" : "Enable auto refresh"}
</button>
<button
type="button"
onClick={() => {
void runDiagnostics(liveSafeKeys, "safe");
}}
disabled={runningKeys.length > 0 || liveSafeKeys.length === 0}
>
Run live checks
</button>
<span className={`small-pill ${autoRefresh ? "is-positive" : ""}`}>
{autoRefresh ? "Auto refresh on" : "Auto refresh off"}
</span>
<span className="small-pill">{lastRunMode ? `Last run: ${lastRunMode}` : "No run yet"}</span>
</div>
</div>
<div className="admin-panel diagnostics-inline-summary">
<div className="diagnostics-inline-metric">
<span>Total</span>
<strong>{summary.total}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Up</span>
<strong>{summary.up}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Degraded</span>
<strong>{summary.degraded}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Down</span>
<strong>{summary.down}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Disabled</span>
<strong>{summary.disabled}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Not configured</span>
<strong>{summary.not_configured}</strong>
</div>
<div className="diagnostics-inline-last-run">Last completed run: {formatCheckedAt(lastRunAt ?? undefined)}</div>
</div>
{pageError ? <div className="admin-panel diagnostics-error">{pageError}</div> : null}
{orderedCategories.map((category) => {
const categoryChecks = mergedResults.filter((check) => check.category === category);
return (
<div key={category} className="admin-panel diagnostics-category-panel">
<div className="diagnostics-category-header">
<div>
<h2>{category}</h2>
<p>
{category === "Notifications" ? "These tests can emit real messages." : "Safe live health checks."}
</p>
</div>
<span className="small-pill">{categoryChecks.length} checks</span>
</div>
{category === "Notifications" && (
<div className="diagnostics-notification-controls">
<label className="diagnostics-email-recipient">
<span>Test email recipient</span>
<input
type="email"
placeholder="Leave blank to use configured sender"
value={emailRecipient}
onChange={(event) => setEmailRecipient(event.target.value)}
/>
</label>
<p>Choose where the test email goes. Other channels use their configured destinations.</p>
<button
type="button"
disabled={runningKeys.length > 0 || categoryChecks.length === 0}
onClick={() =>
void runDiagnostics(
categoryChecks.map((check) => check.key),
"all",
)
}
>
Test all notification channels
</button>
</div>
)}
<div className="diagnostics-grid">
{categoryChecks.map((check) => {
const isRunning = runningKeys.includes(check.key);
return (
<article key={check.key} className={`diagnostic-card diagnostic-card-${check.status}`}>
<div className="diagnostic-card-top">
<div className="diagnostic-card-copy">
<div className="diagnostic-card-title-row">
<h3>{check.label}</h3>
<span className={`system-pill system-pill-${check.status}`}>{statusLabel(check.status)}</span>
</div>
<p>{check.description}</p>
</div>
<button
type="button"
className="system-test"
onClick={() => {
void runDiagnostics([check.key], "single");
}}
disabled={isRunning}
>
{check.live_safe ? "Ping" : "Send test"}
</button>
</div>
<div className="diagnostic-meta-grid">
<div className="diagnostic-meta-item">
<span>Target</span>
<strong>{check.target || "Not set"}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Latency</span>
<strong>{formatDuration(check.duration_ms)}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Mode</span>
<strong>{check.live_safe ? "Live safe" : "Manual only"}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Last checked</span>
<strong>{formatCheckedAt(check.checked_at)}</strong>
</div>
</div>
<div className={`diagnostic-message diagnostic-message-${check.status}`}>
<span className="system-dot" />
<span>{isRunning ? "Running diagnostic..." : check.message}</span>
</div>
{check.key === "database"
? (() => {
const detail = asDatabaseDiagnosticDetail(check.detail);
if (!detail) {
return null;
}
return (
<details className="diagnostic-detail-panel">
<summary>Database storage, tables and timings</summary>
{renderDatabaseMetricGroup("Storage", [
["Database file", formatBytes(detail.database_size_bytes)],
["WAL file", formatBytes(detail.wal_size_bytes)],
["Shared memory", formatBytes(detail.shm_size_bytes)],
["Allocated bytes", formatBytes(detail.allocated_bytes)],
["Free bytes", formatBytes(detail.free_bytes)],
["Page size", formatBytes(detail.page_size_bytes)],
["Page count", `${detail.page_count?.toLocaleString() ?? 0}`],
["Freelist pages", `${detail.freelist_pages?.toLocaleString() ?? 0}`],
])}
{renderDatabaseMetricGroup(
"Tables",
Object.entries(detail.row_counts ?? {}).map(([key, value]) => [
formatDetailLabel(key),
value.toLocaleString(),
]),
)}
{renderDatabaseMetricGroup(
"Timings",
Object.entries(detail.timings_ms ?? {}).map(([key, value]) => [
formatDetailLabel(key),
`${value.toFixed(1)} ms`,
]),
)}
</details>
);
})()
: null}
</article>
);
})}
</div>
</div>
);
})}
</div>
);
}