531 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|