feat(ui): add collapsible plesk instances panel with summary view #28

Merged
robbond merged 1 commits from feature/ui-collapse-instances-panel into master 2026-03-06 06:50:55 +00:00

View File

@@ -160,6 +160,23 @@ function getDomainHref(domainName: string) {
return `https://${normalized}`; return `https://${normalized}`;
} }
function getLatestTimestamp(values: Array<string | null | undefined>) {
let latest: string | null = null;
let latestMs = Number.NEGATIVE_INFINITY;
for (const value of values) {
if (!value) continue;
const ms = new Date(value).getTime();
if (Number.isNaN(ms)) continue;
if (ms > latestMs) {
latestMs = ms;
latest = value;
}
}
return latest;
}
export function DashboardControls({ export function DashboardControls({
instances, instances,
subscriptions, subscriptions,
@@ -184,6 +201,7 @@ export function DashboardControls({
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isConnectPanelOpen, setIsConnectPanelOpen] = useState(!hasInstances); const [isConnectPanelOpen, setIsConnectPanelOpen] = useState(!hasInstances);
const [isInstancesPanelOpen, setIsInstancesPanelOpen] = useState(true);
useEffect(() => { useEffect(() => {
if (!hasInstances) { if (!hasInstances) {
@@ -217,6 +235,38 @@ export function DashboardControls({
); );
}, [hasInstances, isConnectPanelOpen]); }, [hasInstances, isConnectPanelOpen]);
useEffect(() => {
if (!hasInstances) {
setIsInstancesPanelOpen(true);
return;
}
const savedState = window.localStorage.getItem(
"dashboard_instances_panel_open",
);
if (savedState === "true") {
setIsInstancesPanelOpen(true);
return;
}
if (savedState === "false") {
setIsInstancesPanelOpen(false);
return;
}
setIsInstancesPanelOpen(true);
}, [hasInstances]);
useEffect(() => {
if (!hasInstances) return;
window.localStorage.setItem(
"dashboard_instances_panel_open",
isInstancesPanelOpen ? "true" : "false",
);
}, [hasInstances, isInstancesPanelOpen]);
const healthCounts = instances.reduce( const healthCounts = instances.reduce(
(acc, instance) => { (acc, instance) => {
if (instance.status === "connected") acc.connected += 1; if (instance.status === "connected") acc.connected += 1;
@@ -245,6 +295,30 @@ export function DashboardControls({
return statusMatches && searchMatches; return statusMatches && searchMatches;
}); });
const instanceHealthSummary = instances.reduce(
(acc, instance) => {
const status = (instance.health_status ?? "unknown").toLowerCase();
if (status === "ok") acc.ok += 1;
else if (status === "degraded") acc.degraded += 1;
else if (status === "down") acc.down += 1;
else acc.unknown += 1;
return acc;
},
{ ok: 0, degraded: 0, down: 0, unknown: 0 },
);
const autoSyncEnabledCount = instances.filter(
(instance) => instance.auto_sync_enabled,
).length;
const latestInstanceSyncAt = getLatestTimestamp(
instances.map((instance) => instance.last_sync_at),
);
const latestInstanceHealthCheckAt = getLatestTimestamp(
instances.map((instance) => instance.health_last_checked_at),
);
async function runRequest(url: string, body?: unknown) { async function runRequest(url: string, body?: unknown) {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
@@ -548,380 +622,440 @@ export function DashboardControls({
</div> </div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4"> <div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
<h2 className="text-lg font-semibold">Plesk Instances</h2> <div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Plesk Instances</h2>
{hasInstances ? (
<button
type="button"
onClick={() => setIsInstancesPanelOpen((open) => !open)}
className="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
>
{isInstancesPanelOpen ? "Hide" : "Show"}
</button>
) : null}
</div>
<div className="space-y-2"> {!isInstancesPanelOpen && hasInstances ? (
{instances.length === 0 ? ( <div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-700">
<p className="text-sm text-slate-500"> <p className="font-medium text-slate-900">
No Plesk instances connected yet. {instances.length} instance{instances.length === 1 ? "" : "s"}
</p> </p>
) : ( <p className="mt-1">
instances.map((instance) => ( Health OK: {instanceHealthSummary.ok}, Degraded:{" "}
<div {instanceHealthSummary.degraded}, Down:{" "}
key={instance.id} {instanceHealthSummary.down}, Unknown:{" "}
className="rounded border border-slate-200 p-3" {instanceHealthSummary.unknown}
> </p>
{(() => { <p className="mt-1">
const health = getHealthBadge(instance.health_status); Auto-sync enabled on {autoSyncEnabledCount} instance
{autoSyncEnabledCount === 1 ? "" : "s"}
</p>
<p className="mt-1">
Last sync: {formatDateTime(latestInstanceSyncAt)}
</p>
<p className="mt-1">
Last health check: {formatDateTime(latestInstanceHealthCheckAt)}
</p>
</div>
) : null}
return ( {isInstancesPanelOpen ? (
<div className="flex items-start justify-between gap-2"> <>
<div> <div className="space-y-2">
{autoSyncByInstance[instance.id] ? ( {instances.length === 0 ? (
<span <p className="text-sm text-slate-500">
className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${ No Plesk instances connected yet.
autoSyncByInstance[instance.id].status === </p>
"success" ) : (
? "bg-emerald-100 text-emerald-700" instances.map((instance) => (
: "bg-rose-100 text-rose-700" <div
}`} key={instance.id}
> className="rounded border border-slate-200 p-3"
Auto-sync {autoSyncByInstance[instance.id].status} >
</span> {(() => {
) : null} const health = getHealthBadge(instance.health_status);
<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>
);
})()}
<div className="mt-3 rounded border border-slate-100 bg-slate-50 p-3"> return (
<p className="text-xs font-semibold uppercase tracking-wide text-slate-600"> <div className="flex items-start justify-between gap-2">
Health Panel <div>
</p> {autoSyncByInstance[instance.id] ? (
<div className="mt-2 grid gap-1 text-xs text-slate-600 sm:grid-cols-2"> <span
<p> className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${
Current Status:{" "} autoSyncByInstance[instance.id].status ===
<span className="font-medium"> "success"
{getHealthBadge(instance.health_status).label} ? "bg-emerald-100 text-emerald-700"
</span> : "bg-rose-100 text-rose-700"
</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"> Auto-sync{" "}
{formatDateTime(event.created_at)} {autoSyncByInstance[instance.id].status}
</td> </span>
<td className="px-2 py-1">{event.action}</td> ) : null}
<td className="px-2 py-1"> <p className="text-sm font-semibold text-slate-900">
{mapHealthResult(event.action)} {instance.name}
</td> </p>
<td className="px-2 py-1"> <p className="text-xs text-slate-500">
{event.error_message ?? "—"} {instance.base_url}
{event.metadata ? ( </p>
<details> <p className="mt-1 text-xs text-slate-600">
<summary className="cursor-pointer text-[11px] text-brand-700"> Status:{" "}
metadata <span className="font-medium">
</summary> {instance.status}
<pre className="mt-1 max-w-[440px] overflow-auto rounded bg-white p-2 text-[10px] text-slate-700"> </span>
{JSON.stringify( </p>
event.metadata, <p className="text-xs text-slate-500">
null, Health:{" "}
2, <span
)} title={
</pre> instance.health_last_error ?? undefined
</details> }
) : null} 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>
);
})()}
<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> </td>
</tr> </tr>
), ) : (
) (healthEventsByInstance[instance.id] ?? []).map(
)} (event) => (
</tbody> <tr
</table> 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> </div>
</div> ))
</div> )}
)) </div>
)}
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Subscription Action</label> <label className="text-sm font-medium">
<select Subscription Action
value={actionSubId} </label>
onChange={(e: ChangeEvent<HTMLSelectElement>) => <select
setActionSubId(e.target.value) value={actionSubId}
} onChange={(e: ChangeEvent<HTMLSelectElement>) =>
className="w-full rounded border border-slate-300 px-3 py-2 text-sm" setActionSubId(e.target.value)
> }
{subscriptions.map((subscription) => ( className="w-full rounded border border-slate-300 px-3 py-2 text-sm"
<option key={subscription.id} value={subscription.id}> >
{subscription.name ?? subscription.plesk_subscription_id} {subscriptions.map((subscription) => (
</option> <option key={subscription.id} value={subscription.id}>
))} {subscription.name ?? subscription.plesk_subscription_id}
</select> </option>
<select ))}
value={actionType} </select>
onChange={(e: ChangeEvent<HTMLSelectElement>) => <select
setActionType(e.target.value as "suspend" | "unsuspend") value={actionType}
} onChange={(e: ChangeEvent<HTMLSelectElement>) =>
className="w-full rounded border border-slate-300 px-3 py-2 text-sm" setActionType(e.target.value as "suspend" | "unsuspend")
> }
<option value="suspend">Suspend</option> className="w-full rounded border border-slate-300 px-3 py-2 text-sm"
<option value="unsuspend">Unsuspend</option> >
</select> <option value="suspend">Suspend</option>
<button <option value="unsuspend">Unsuspend</option>
onClick={onSubscriptionAction} </select>
disabled={loading || !actionSubId} <button
className="rounded border px-3 py-2 text-sm" onClick={onSubscriptionAction}
> disabled={loading || !actionSubId}
Execute Action className="rounded border px-3 py-2 text-sm"
</button> >
</div> Execute Action
</button>
</div>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<button <button
onClick={onCheckout} onClick={onCheckout}
disabled={loading} disabled={loading}
className="rounded bg-brand-600 px-3 py-2 text-sm text-white" className="rounded bg-brand-600 px-3 py-2 text-sm text-white"
> >
Start Checkout Start Checkout
</button> </button>
<button <button
onClick={onPortal} onClick={onPortal}
disabled={loading} disabled={loading}
className="rounded border px-3 py-2 text-sm" className="rounded border px-3 py-2 text-sm"
> >
Billing Portal Billing Portal
</button> </button>
</div> </div>
{message ? <p className="text-sm text-slate-600">{message}</p> : null} {message ? (
<p className="text-sm text-slate-600">{message}</p>
) : null}
</>
) : null}
</div> </div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4 lg:col-span-2"> <div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4 lg:col-span-2">