feat(ui): show instance health badges and health panel

This commit is contained in:
2026-03-05 18:03:38 +00:00
parent fb385b59c4
commit 8d86378341
2 changed files with 430 additions and 153 deletions

View File

@@ -23,6 +23,15 @@ type Instance = {
last_auto_sync_at: string | null;
next_auto_sync_at: string | null;
auto_sync_lock_until: string | null;
health_enabled: boolean;
health_status: "ok" | "degraded" | "down" | "unknown";
health_last_checked_at: string | null;
health_last_ok_at: string | null;
health_last_error: string | null;
health_last_latency_ms: number | null;
health_check_frequency_minutes: number;
health_next_check_at: string | null;
health_lock_until: string | null;
};
type Subscription = {
@@ -54,8 +63,44 @@ type Props = {
string,
{ status: string; created_at: string; error_message: string | null }
>;
healthEventsByInstance: Record<
string,
Array<{
id: string;
action: string;
status: string;
created_at: string;
error_message: string | null;
metadata: unknown;
}>
>;
};
function getHealthBadge(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase();
if (normalized === "ok") {
return { label: "OK", className: "bg-emerald-100 text-emerald-700" };
}
if (normalized === "degraded") {
return { label: "Degraded", className: "bg-amber-100 text-amber-700" };
}
if (normalized === "down") {
return { label: "Down", className: "bg-rose-100 text-rose-700" };
}
return { label: "Unknown", className: "bg-slate-100 text-slate-600" };
}
function mapHealthResult(action: string) {
if (action === "health_check_ok") return "ok";
if (action === "health_check_degraded") return "degraded";
if (action === "health_check_failed") return "failed";
return "started";
}
function getDomainStatusBadge(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase();
@@ -120,6 +165,7 @@ export function DashboardControls({
subscriptions,
domains,
autoSyncByInstance,
healthEventsByInstance,
}: Props) {
const autoSyncFrequencyOptions = [15, 30, 60, 180, 360, 1440];
const [instanceName, setInstanceName] = useState("");
@@ -245,6 +291,22 @@ export function DashboardControls({
}
}
async function onRunHealthCheck(instanceId: string) {
setLoading(true);
setMessage(null);
try {
await runRequest(`/api/plesk/instances/${instanceId}/health`);
setMessage("Health check completed.");
window.location.reload();
} catch (error) {
setMessage(
error instanceof Error ? error.message : "Health check failed",
);
} finally {
setLoading(false);
}
}
async function onUpdateAutoSync(
instanceId: string,
input: { auto_sync_enabled: boolean; auto_sync_frequency_minutes: number },
@@ -442,159 +504,308 @@ export function DashboardControls({
key={instance.id}
className="rounded border border-slate-200 p-3"
>
<div className="flex items-start justify-between gap-2">
<div>
{autoSyncByInstance[instance.id] ? (
<span
className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${
autoSyncByInstance[instance.id].status === "success"
? "bg-emerald-100 text-emerald-700"
: "bg-rose-100 text-rose-700"
}`}
>
Auto-sync {autoSyncByInstance[instance.id].status}
</span>
) : null}
<p className="text-sm font-semibold text-slate-900">
{instance.name}
</p>
<p className="text-xs text-slate-500">
{instance.base_url}
</p>
<p className="mt-1 text-xs text-slate-600">
Status:{" "}
<span className="font-medium">{instance.status}</span>
</p>
<p className="text-xs text-slate-500">
Subscriptions: {instance.last_sync_subscriptions ?? 0}
</p>
<p className="text-xs text-slate-500">
Domains: {instance.last_sync_domains ?? 0}
</p>
<p className="text-xs text-slate-500">
Last sync status:{" "}
<span
className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${
instance.last_sync_status === "success"
? "bg-emerald-100 text-emerald-700"
: instance.last_sync_status === "failed" ||
instance.last_sync_status === "error"
? "bg-rose-100 text-rose-700"
: "bg-slate-100 text-slate-600"
}`}
>
{instance.last_sync_status ?? "unknown"}
</span>
</p>
<p className="text-xs text-slate-500">
Last sync time: {formatDateTime(instance.last_sync_at)}
</p>
<p className="text-xs text-slate-500">
Last connected:{" "}
{instance.last_connected_at
? formatDateTime(instance.last_connected_at)
: "never"}
</p>
<p className="text-xs text-slate-500">
Auto-sync:{" "}
{instance.auto_sync_enabled ? "Enabled" : "Disabled"}
</p>
<p className="text-xs text-slate-500">
Auto-sync frequency:{" "}
{instance.auto_sync_frequency_minutes ?? 60} min
</p>
<p className="text-xs text-slate-500">
Last auto-sync:{" "}
{formatDateTime(instance.last_auto_sync_at)}
</p>
<p className="text-xs text-slate-500">
Next auto-sync:{" "}
{formatDateTime(instance.next_auto_sync_at)}
</p>
<p className="text-xs text-slate-500">
Lock status:{" "}
{instance.auto_sync_lock_until &&
new Date(instance.auto_sync_lock_until).getTime() >
Date.now()
? "Sync in progress"
: "Idle"}
</p>
{autoSyncByInstance[instance.id] ? (
<p className="text-xs text-slate-500">
Last auto-sync:{" "}
{formatDateTime(
autoSyncByInstance[instance.id].created_at,
)}
</p>
) : null}
{instance.error_message ? (
<p className="mt-1 text-xs text-rose-600">
{instance.error_message}
</p>
) : null}
{instance.last_sync_error ? (
<p className="mt-1 text-xs text-rose-600">
Last sync error: {instance.last_sync_error}
</p>
) : null}
{autoSyncByInstance[instance.id]?.error_message ? (
<p className="mt-1 text-xs text-rose-600">
Auto-sync error:{" "}
{autoSyncByInstance[instance.id].error_message}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-600">
Auto-sync
</label>
<input
type="checkbox"
checked={instance.auto_sync_enabled}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
onUpdateAutoSync(instance.id, {
auto_sync_enabled: e.target.checked,
auto_sync_frequency_minutes:
instance.auto_sync_frequency_minutes ?? 60,
})
}
disabled={loading}
/>
{(() => {
const health = getHealthBadge(instance.health_status);
return (
<div className="flex items-start justify-between gap-2">
<div>
{autoSyncByInstance[instance.id] ? (
<span
className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${
autoSyncByInstance[instance.id].status ===
"success"
? "bg-emerald-100 text-emerald-700"
: "bg-rose-100 text-rose-700"
}`}
>
Auto-sync {autoSyncByInstance[instance.id].status}
</span>
) : null}
<p className="text-sm font-semibold text-slate-900">
{instance.name}
</p>
<p className="text-xs text-slate-500">
{instance.base_url}
</p>
<p className="mt-1 text-xs text-slate-600">
Status:{" "}
<span className="font-medium">
{instance.status}
</span>
</p>
<p className="text-xs text-slate-500">
Health:{" "}
<span
title={instance.health_last_error ?? undefined}
className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${health.className}`}
>
{health.label}
</span>
</p>
<p className="text-xs text-slate-500">
Health last checked:{" "}
{formatDateTime(instance.health_last_checked_at)}
</p>
<p className="text-xs text-slate-500">
Health latency:{" "}
{instance.health_last_latency_ms != null
? `${instance.health_last_latency_ms} ms`
: "—"}
</p>
<p className="text-xs text-slate-500">
Subscriptions:{" "}
{instance.last_sync_subscriptions ?? 0}
</p>
<p className="text-xs text-slate-500">
Domains: {instance.last_sync_domains ?? 0}
</p>
<p className="text-xs text-slate-500">
Last sync status:{" "}
<span
className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${
instance.last_sync_status === "success"
? "bg-emerald-100 text-emerald-700"
: instance.last_sync_status === "failed" ||
instance.last_sync_status === "error"
? "bg-rose-100 text-rose-700"
: "bg-slate-100 text-slate-600"
}`}
>
{instance.last_sync_status ?? "unknown"}
</span>
</p>
<p className="text-xs text-slate-500">
Last sync time:{" "}
{formatDateTime(instance.last_sync_at)}
</p>
<p className="text-xs text-slate-500">
Last connected:{" "}
{instance.last_connected_at
? formatDateTime(instance.last_connected_at)
: "never"}
</p>
<p className="text-xs text-slate-500">
Auto-sync:{" "}
{instance.auto_sync_enabled
? "Enabled"
: "Disabled"}
</p>
<p className="text-xs text-slate-500">
Auto-sync frequency:{" "}
{instance.auto_sync_frequency_minutes ?? 60} min
</p>
<p className="text-xs text-slate-500">
Last auto-sync:{" "}
{formatDateTime(instance.last_auto_sync_at)}
</p>
<p className="text-xs text-slate-500">
Next auto-sync:{" "}
{formatDateTime(instance.next_auto_sync_at)}
</p>
<p className="text-xs text-slate-500">
Lock status:{" "}
{instance.auto_sync_lock_until &&
new Date(instance.auto_sync_lock_until).getTime() >
Date.now()
? "Sync in progress"
: "Idle"}
</p>
{autoSyncByInstance[instance.id] ? (
<p className="text-xs text-slate-500">
Last auto-sync:{" "}
{formatDateTime(
autoSyncByInstance[instance.id].created_at,
)}
</p>
) : null}
{instance.error_message ? (
<p className="mt-1 text-xs text-rose-600">
{instance.error_message}
</p>
) : null}
{instance.last_sync_error ? (
<p className="mt-1 text-xs text-rose-600">
Last sync error: {instance.last_sync_error}
</p>
) : null}
{autoSyncByInstance[instance.id]?.error_message ? (
<p className="mt-1 text-xs text-rose-600">
Auto-sync error:{" "}
{autoSyncByInstance[instance.id].error_message}
</p>
) : null}
{instance.health_last_error ? (
<p
className="mt-1 text-xs text-rose-600"
title={instance.health_last_error}
>
Health error: {instance.health_last_error}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-600">
Auto-sync
</label>
<input
type="checkbox"
checked={instance.auto_sync_enabled}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
onUpdateAutoSync(instance.id, {
auto_sync_enabled: e.target.checked,
auto_sync_frequency_minutes:
instance.auto_sync_frequency_minutes ?? 60,
})
}
disabled={loading}
/>
</div>
<select
value={String(
instance.auto_sync_frequency_minutes ?? 60,
)}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onUpdateAutoSync(instance.id, {
auto_sync_enabled: instance.auto_sync_enabled,
auto_sync_frequency_minutes: Number(
e.target.value,
),
})
}
disabled={loading}
className="rounded border px-2 py-1 text-xs"
>
{autoSyncFrequencyOptions.map((minutes) => (
<option key={minutes} value={minutes}>
Every {minutes} min
</option>
))}
</select>
<button
onClick={() => onTestConnection(instance.id)}
disabled={loading}
className="rounded border px-3 py-1.5 text-xs"
>
Test Connection
</button>
<button
onClick={() => onRunHealthCheck(instance.id)}
disabled={loading}
className="rounded border px-3 py-1.5 text-xs"
>
Run Health Check
</button>
<button
onClick={() => onSync(instance.id)}
disabled={loading}
className="rounded border px-3 py-1.5 text-xs"
>
Sync Now
</button>
</div>
</div>
<select
value={String(
instance.auto_sync_frequency_minutes ?? 60,
)}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onUpdateAutoSync(instance.id, {
auto_sync_enabled: instance.auto_sync_enabled,
auto_sync_frequency_minutes: Number(e.target.value),
})
}
disabled={loading}
className="rounded border px-2 py-1 text-xs"
>
{autoSyncFrequencyOptions.map((minutes) => (
<option key={minutes} value={minutes}>
Every {minutes} min
</option>
))}
</select>
<button
onClick={() => onTestConnection(instance.id)}
disabled={loading}
className="rounded border px-3 py-1.5 text-xs"
>
Test Connection
</button>
<button
onClick={() => onSync(instance.id)}
disabled={loading}
className="rounded border px-3 py-1.5 text-xs"
>
Sync Now
</button>
);
})()}
<div className="mt-3 rounded border border-slate-100 bg-slate-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-600">
Health Panel
</p>
<div className="mt-2 grid gap-1 text-xs text-slate-600 sm:grid-cols-2">
<p>
Current Status:{" "}
<span className="font-medium">
{getHealthBadge(instance.health_status).label}
</span>
</p>
<p>
Last Checked:{" "}
<span className="font-medium">
{formatDateTime(instance.health_last_checked_at)}
</span>
</p>
<p>
Last Successful Check:{" "}
<span className="font-medium">
{formatDateTime(instance.health_last_ok_at)}
</span>
</p>
<p>
Latency:{" "}
<span className="font-medium">
{instance.health_last_latency_ms != null
? `${instance.health_last_latency_ms} ms`
: "—"}
</span>
</p>
<p className="sm:col-span-2">
Last Error:{" "}
<span className="font-medium">
{instance.health_last_error ?? "—"}
</span>
</p>
</div>
<div className="mt-3 overflow-x-auto">
<table className="min-w-full text-left text-xs">
<thead>
<tr className="border-b border-slate-200 text-slate-500">
<th className="px-2 py-1">Time</th>
<th className="px-2 py-1">Event</th>
<th className="px-2 py-1">Result</th>
<th className="px-2 py-1">Message</th>
</tr>
</thead>
<tbody>
{(healthEventsByInstance[instance.id] ?? [])
.length === 0 ? (
<tr>
<td
className="px-2 py-2 text-slate-500"
colSpan={4}
>
No health events yet.
</td>
</tr>
) : (
(healthEventsByInstance[instance.id] ?? []).map(
(event) => (
<tr
key={event.id}
className="border-b border-slate-100 align-top"
>
<td className="px-2 py-1">
{formatDateTime(event.created_at)}
</td>
<td className="px-2 py-1">{event.action}</td>
<td className="px-2 py-1">
{mapHealthResult(event.action)}
</td>
<td className="px-2 py-1">
{event.error_message ?? "—"}
{event.metadata ? (
<details>
<summary className="cursor-pointer text-[11px] text-brand-700">
metadata
</summary>
<pre className="mt-1 max-w-[440px] overflow-auto rounded bg-white p-2 text-[10px] text-slate-700">
{JSON.stringify(
event.metadata,
null,
2,
)}
</pre>
</details>
) : null}
</td>
</tr>
),
)
)}
</tbody>
</table>
</div>
</div>
</div>