Compare commits

...

4 Commits

2 changed files with 353 additions and 102 deletions

View File

@@ -1,6 +1,7 @@
import { SignOutButton } from "@/components/auth/sign-out-button";
import { DashboardControls } from "@/components/dashboard/dashboard-controls";
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
import { formatDateTime } from "@/lib/dates";
import type { Database } from "@/lib/types";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import Link from "next/link";
@@ -17,6 +18,10 @@ type ActionLogSummary = Pick<
Database["public"]["Tables"]["actions_log"]["Row"],
"target_id" | "status" | "action" | "created_at" | "error_message"
>;
type AutosyncLatestLogSummary = Pick<
Database["public"]["Tables"]["actions_log"]["Row"],
"created_at"
>;
type HealthLogSummary = Pick<
Database["public"]["Tables"]["actions_log"]["Row"],
| "id"
@@ -28,6 +33,64 @@ type HealthLogSummary = Pick<
| "metadata"
>;
const RELATIVE_TIME_FORMATTER = new Intl.RelativeTimeFormat("en", {
numeric: "auto",
});
function getLatestTimestamp(values: Array<string | null | undefined>) {
let latestIso: string | null = null;
let latestMs = Number.NEGATIVE_INFINITY;
for (const value of values) {
if (!value) continue;
const parsed = new Date(value).getTime();
if (Number.isNaN(parsed)) continue;
if (parsed > latestMs) {
latestMs = parsed;
latestIso = value;
}
}
return latestIso;
}
function formatRelativeTime(value?: string | null) {
if (!value) return null;
const targetMs = new Date(value).getTime();
if (Number.isNaN(targetMs)) return null;
const deltaSeconds = Math.round((targetMs - Date.now()) / 1000);
const absSeconds = Math.abs(deltaSeconds);
if (absSeconds < 60) {
return RELATIVE_TIME_FORMATTER.format(deltaSeconds, "second");
}
const deltaMinutes = Math.round(deltaSeconds / 60);
if (Math.abs(deltaMinutes) < 60) {
return RELATIVE_TIME_FORMATTER.format(deltaMinutes, "minute");
}
const deltaHours = Math.round(deltaMinutes / 60);
if (Math.abs(deltaHours) < 24) {
return RELATIVE_TIME_FORMATTER.format(deltaHours, "hour");
}
const deltaDays = Math.round(deltaHours / 24);
if (Math.abs(deltaDays) < 30) {
return RELATIVE_TIME_FORMATTER.format(deltaDays, "day");
}
const deltaMonths = Math.round(deltaDays / 30);
if (Math.abs(deltaMonths) < 12) {
return RELATIVE_TIME_FORMATTER.format(deltaMonths, "month");
}
const deltaYears = Math.round(deltaMonths / 12);
return RELATIVE_TIME_FORMATTER.format(deltaYears, "year");
}
function normalizeDomainLike(value: string) {
return value.trim().toLowerCase().replace(/\.$/, "");
}
@@ -59,6 +122,7 @@ export default async function DashboardPage() {
{ data: domains },
{ data: billing },
{ data: autoSyncLogs },
{ data: latestAutoSyncLog },
{ data: healthLogs },
] = (await Promise.all([
supabase
@@ -100,6 +164,16 @@ export default async function DashboardPage() {
.eq("action", "plesk_sync_auto")
.order("created_at", { ascending: false })
.limit(200),
supabase
.from("actions_log")
.select("created_at")
.eq("agency_id", context.agencyId)
.eq("target_type", "plesk_instance")
.eq("action", "autosync_completed")
.eq("status", "success")
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from("actions_log")
.select(
@@ -173,6 +247,7 @@ export default async function DashboardPage() {
},
{ data: BillingSummary | null },
{ data: ActionLogSummary[] | null },
{ data: AutosyncLatestLogSummary | null },
{ data: HealthLogSummary[] | null },
];
@@ -293,6 +368,40 @@ export default async function DashboardPage() {
healthEventsByInstance.set(log.target_id, existing);
}
const healthCounts = { ok: 0, degraded: 0, down: 0, unknown: 0 };
for (const instance of instances ?? []) {
const status = (instance.health_status ?? "unknown") as
| "ok"
| "degraded"
| "down"
| "unknown";
if (status === "ok") healthCounts.ok += 1;
else if (status === "degraded") healthCounts.degraded += 1;
else if (status === "down") healthCounts.down += 1;
else healthCounts.unknown += 1;
}
const latestHealthCheckAt = getLatestTimestamp(
(instances ?? []).map((instance) => instance.health_last_checked_at),
);
const autoSyncEnabledCount = (instances ?? []).filter(
(instance) => instance.auto_sync_enabled,
).length;
const latestAutoSyncFromInstances = getLatestTimestamp(
(instances ?? []).map((instance) => instance.last_auto_sync_at),
);
const latestAutoSyncAt =
latestAutoSyncFromInstances ?? latestAutoSyncLog?.created_at ?? null;
const downInstanceNames = (instances ?? [])
.filter((instance) => instance.health_status === "down")
.slice(0, 3)
.map((instance) => instance.name || instance.base_url);
return (
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-8">
<header className="flex items-center justify-between">
@@ -313,6 +422,89 @@ export default async function DashboardPage() {
</div>
</header>
<section className="rounded-xl border border-slate-200 bg-white p-4">
<div className="space-y-3 text-sm text-slate-700">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Instance Health
</p>
<div className="mt-1 flex flex-wrap gap-2">
<span className="rounded bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
Ok: {healthCounts.ok}
</span>
<span className="rounded bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
Degraded: {healthCounts.degraded}
</span>
<span className="rounded bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700">
Down: {healthCounts.down}
</span>
<span className="rounded bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">
Unknown: {healthCounts.unknown}
</span>
</div>
</div>
{healthCounts.down > 0 ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
{healthCounts.down} instance{healthCounts.down === 1 ? "" : "s"}{" "}
down
{downInstanceNames.length > 0
? `: ${downInstanceNames.join(", ")}${healthCounts.down > downInstanceNames.length ? ", …" : ""}`
: ""}
</div>
) : null}
<div className="flex flex-wrap gap-3">
<p>
<span className="font-medium text-slate-900">
Last Health Check:
</span>{" "}
<span
title={
latestHealthCheckAt
? formatDateTime(latestHealthCheckAt)
: undefined
}
>
{formatRelativeTime(latestHealthCheckAt) ?? "Never"}
</span>
</p>
<p>
<span className="font-medium text-slate-900">Auto Sync:</span>{" "}
Enabled on {autoSyncEnabledCount} instance
{autoSyncEnabledCount === 1 ? "" : "s"}
</p>
<p>
<span className="font-medium text-slate-900">Last run:</span>{" "}
<span
title={
latestAutoSyncAt
? formatDateTime(latestAutoSyncAt)
: undefined
}
>
{formatRelativeTime(latestAutoSyncAt) ?? "Not yet run"}
</span>
</p>
</div>
<div className="flex flex-wrap gap-2 pt-1">
<Link
href="/dashboard#instances-panel"
className="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
>
Instances
</Link>
<Link
href="/dashboard/activity"
className="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
>
Activity
</Link>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-500">Plesk Instances</p>
@@ -338,6 +530,7 @@ export default async function DashboardPage() {
Instances query failed: {instancesError.message}
</div>
) : null}
<section id="instances-panel">
<DashboardControls
instances={instances ?? []}
subscriptions={subscriptions ?? []}
@@ -379,6 +572,7 @@ export default async function DashboardPage() {
healthEventsByInstance.entries(),
)}
/>
</section>
</main>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { ChangeEvent } from "react";
import type { FormEvent } from "react";
import { formatDateTime } from "@/lib/dates";
@@ -167,6 +167,7 @@ export function DashboardControls({
autoSyncByInstance,
healthEventsByInstance,
}: Props) {
const hasInstances = instances.length > 0;
const autoSyncFrequencyOptions = [15, 30, 60, 180, 360, 1440];
const [instanceName, setInstanceName] = useState("");
const [baseUrl, setBaseUrl] = useState("");
@@ -182,6 +183,39 @@ export function DashboardControls({
const [domainStatus, setDomainStatus] = useState("all");
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [isConnectPanelOpen, setIsConnectPanelOpen] = useState(!hasInstances);
useEffect(() => {
if (!hasInstances) {
setIsConnectPanelOpen(true);
return;
}
const savedState = window.localStorage.getItem(
"dashboard_connect_panel_open",
);
if (savedState === "true") {
setIsConnectPanelOpen(true);
return;
}
if (savedState === "false") {
setIsConnectPanelOpen(false);
return;
}
setIsConnectPanelOpen(false);
}, [hasInstances]);
useEffect(() => {
if (!hasInstances) return;
window.localStorage.setItem(
"dashboard_connect_panel_open",
isConnectPanelOpen ? "true" : "false",
);
}, [hasInstances, isConnectPanelOpen]);
const healthCounts = instances.reduce(
(acc, instance) => {
@@ -416,11 +450,32 @@ export function DashboardControls({
</div>
<div className="grid gap-4 lg:grid-cols-2">
<form
onSubmit={onCreateInstance}
className="space-y-3 rounded-xl border border-slate-200 bg-white p-4"
<div className="rounded-xl border border-slate-200 bg-white p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">
{hasInstances
? "Connect another Plesk instance"
: "Connect Plesk Instance"}
</h2>
{hasInstances ? (
<button
type="button"
onClick={() => setIsConnectPanelOpen((open) => !open)}
className="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
>
<h2 className="text-lg font-semibold">Connect Plesk Instance</h2>
{isConnectPanelOpen ? "Hide" : "Add instance"}
</button>
) : null}
</div>
{!isConnectPanelOpen && hasInstances ? (
<p className="mt-2 text-sm text-slate-500">
Add another server when needed.
</p>
) : null}
{isConnectPanelOpen ? (
<form onSubmit={onCreateInstance} className="mt-3 space-y-3">
<input
required
value={instanceName}
@@ -489,6 +544,8 @@ export function DashboardControls({
Save & Validate
</button>
</form>
) : null}
</div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
<h2 className="text-lg font-semibold">Plesk Instances</h2>