579 lines
19 KiB
TypeScript
579 lines
19 KiB
TypeScript
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";
|
|
|
|
type AgencySummary = Pick<
|
|
Database["public"]["Tables"]["agencies"]["Row"],
|
|
"id" | "name"
|
|
>;
|
|
type BillingSummary = Pick<
|
|
Database["public"]["Tables"]["billing_accounts"]["Row"],
|
|
"subscription_status" | "plan_id" | "current_period_end"
|
|
>;
|
|
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"
|
|
| "target_id"
|
|
| "status"
|
|
| "action"
|
|
| "created_at"
|
|
| "error_message"
|
|
| "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(/\.$/, "");
|
|
}
|
|
|
|
function findBestSuffixStatusMatch(
|
|
normalizedDomain: string,
|
|
candidates: Array<{ normalizedName: string; status: string | null }>,
|
|
) {
|
|
let best: { normalizedName: string; status: string | null } | null = null;
|
|
|
|
for (const candidate of candidates) {
|
|
if (!normalizedDomain.endsWith(`.${candidate.normalizedName}`)) continue;
|
|
if (!best || candidate.normalizedName.length > best.normalizedName.length) {
|
|
best = candidate;
|
|
}
|
|
}
|
|
|
|
return best?.status ?? null;
|
|
}
|
|
|
|
export default async function DashboardPage() {
|
|
const context = await getServerAgencyContextOrRedirect();
|
|
const supabase = createSupabaseServerClient() as any;
|
|
|
|
const [
|
|
{ data: agency },
|
|
{ data: instances, error: instancesError },
|
|
{ data: subscriptions },
|
|
{ data: domains },
|
|
{ data: billing },
|
|
{ data: autoSyncLogs },
|
|
{ data: latestAutoSyncLog },
|
|
{ data: healthLogs },
|
|
] = (await Promise.all([
|
|
supabase
|
|
.from("agencies")
|
|
.select("id, name")
|
|
.eq("id", context.agencyId)
|
|
.maybeSingle(),
|
|
supabase
|
|
.from("plesk_instances")
|
|
.select(
|
|
"id, name, base_url, auth_type, status, error_message, last_connected_at, last_sync_at, last_sync_status, last_sync_error, last_sync_subscriptions, last_sync_domains, auto_sync_enabled, auto_sync_frequency_minutes, last_auto_sync_at, next_auto_sync_at, auto_sync_lock_until, health_enabled, health_status, health_last_checked_at, health_last_ok_at, health_last_error, health_last_latency_ms, health_check_frequency_minutes, health_next_check_at, health_lock_until",
|
|
)
|
|
.eq("agency_id", context.agencyId)
|
|
.order("created_at", { ascending: false }),
|
|
supabase
|
|
.from("plesk_subscriptions")
|
|
.select("id, name, status, plesk_subscription_id, plesk_instance_id")
|
|
.eq("agency_id", context.agencyId)
|
|
.order("updated_at", { ascending: false })
|
|
.limit(10),
|
|
supabase
|
|
.from("plesk_domains")
|
|
.select(
|
|
"id, plesk_instance_id, subscription_id, external_subscription_id, status, domain_name, hosting_type, source_created_at, aliases_count, updated_at",
|
|
)
|
|
.eq("agency_id", context.agencyId)
|
|
.order("updated_at", { ascending: false })
|
|
.limit(500),
|
|
supabase
|
|
.from("billing_accounts")
|
|
.select("subscription_status, plan_id, current_period_end")
|
|
.eq("agency_id", context.agencyId)
|
|
.maybeSingle(),
|
|
supabase
|
|
.from("actions_log")
|
|
.select("target_id, status, action, created_at, error_message")
|
|
.eq("agency_id", context.agencyId)
|
|
.eq("target_type", "plesk_instance")
|
|
.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(
|
|
"id, target_id, status, action, created_at, error_message, metadata",
|
|
)
|
|
.eq("agency_id", context.agencyId)
|
|
.eq("target_type", "plesk_instance")
|
|
.in("action", [
|
|
"health_check_started",
|
|
"health_check_ok",
|
|
"health_check_degraded",
|
|
"health_check_failed",
|
|
])
|
|
.order("created_at", { ascending: false })
|
|
.limit(400),
|
|
])) as [
|
|
{ data: AgencySummary | null },
|
|
{
|
|
data: Array<{
|
|
id: string;
|
|
name: string;
|
|
base_url: string;
|
|
auth_type: "api_key" | "basic";
|
|
status: string;
|
|
error_message: string | null;
|
|
last_connected_at: string | null;
|
|
last_sync_at: string | null;
|
|
last_sync_status: string | null;
|
|
last_sync_error: string | null;
|
|
last_sync_subscriptions: number;
|
|
last_sync_domains: number;
|
|
auto_sync_enabled: boolean;
|
|
auto_sync_frequency_minutes: number;
|
|
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;
|
|
}> | null;
|
|
error: { message: string } | null;
|
|
},
|
|
{
|
|
data: Array<{
|
|
id: string;
|
|
name: string | null;
|
|
status: string | null;
|
|
plesk_subscription_id: string;
|
|
plesk_instance_id: string;
|
|
}> | null;
|
|
},
|
|
{
|
|
data: Array<{
|
|
id: string;
|
|
plesk_instance_id: string;
|
|
subscription_id: string | null;
|
|
external_subscription_id: string | null;
|
|
domain_name: string;
|
|
status: string | null;
|
|
hosting_type: string | null;
|
|
source_created_at: string | null;
|
|
aliases_count: number | null;
|
|
updated_at: string;
|
|
}> | null;
|
|
},
|
|
{ data: BillingSummary | null },
|
|
{ data: ActionLogSummary[] | null },
|
|
{ data: AutosyncLatestLogSummary | null },
|
|
{ data: HealthLogSummary[] | null },
|
|
];
|
|
|
|
const domainInstanceIds = Array.from(
|
|
new Set((domains ?? []).map((domain) => domain.plesk_instance_id)),
|
|
);
|
|
|
|
const { data: domainSubscriptions } =
|
|
domainInstanceIds.length > 0
|
|
? await supabase
|
|
.from("plesk_subscriptions")
|
|
.select("id, plesk_instance_id, plesk_subscription_id, name, status")
|
|
.eq("agency_id", context.agencyId)
|
|
.in("plesk_instance_id", domainInstanceIds)
|
|
: { data: [] };
|
|
|
|
const subscriptionStatusById = new Map<string, string | null>(
|
|
(domainSubscriptions ?? []).map((subscription: any) => [
|
|
String(subscription.id),
|
|
subscription?.status ? String(subscription.status) : null,
|
|
]),
|
|
);
|
|
|
|
const subscriptionDisplayNameById = new Map<string, string>(
|
|
(domainSubscriptions ?? [])
|
|
.filter((subscription: any) => subscription?.id)
|
|
.map((subscription: any) => [
|
|
String(subscription.id),
|
|
String(
|
|
subscription?.name ||
|
|
subscription?.plesk_subscription_id ||
|
|
subscription?.id,
|
|
),
|
|
]),
|
|
);
|
|
|
|
const subscriptionStatusByExternalId = new Map<string, string | null>(
|
|
(domainSubscriptions ?? [])
|
|
.filter((subscription: any) =>
|
|
Boolean(
|
|
subscription?.plesk_instance_id &&
|
|
subscription?.plesk_subscription_id,
|
|
),
|
|
)
|
|
.map((subscription: any) => [
|
|
`${String(subscription.plesk_instance_id)}:${String(subscription.plesk_subscription_id)}`,
|
|
subscription?.status ? String(subscription.status) : null,
|
|
]),
|
|
);
|
|
|
|
const subscriptionStatusByInstanceAndName = new Map<string, string | null>(
|
|
(domainSubscriptions ?? [])
|
|
.filter((subscription: any) =>
|
|
Boolean(subscription?.plesk_instance_id && subscription?.name),
|
|
)
|
|
.map((subscription: any) => [
|
|
`${String(subscription.plesk_instance_id)}:${normalizeDomainLike(String(subscription.name))}`,
|
|
subscription?.status ? String(subscription.status) : null,
|
|
]),
|
|
);
|
|
|
|
const subscriptionStatusNameCandidatesByInstance = new Map<
|
|
string,
|
|
Array<{ normalizedName: string; status: string | null }>
|
|
>();
|
|
|
|
for (const subscription of domainSubscriptions ?? []) {
|
|
if (!subscription?.plesk_instance_id || !subscription?.name) continue;
|
|
|
|
const instanceId = String(subscription.plesk_instance_id);
|
|
const existing =
|
|
subscriptionStatusNameCandidatesByInstance.get(instanceId) ?? [];
|
|
existing.push({
|
|
normalizedName: normalizeDomainLike(String(subscription.name)),
|
|
status: subscription?.status ? String(subscription.status) : null,
|
|
});
|
|
subscriptionStatusNameCandidatesByInstance.set(instanceId, existing);
|
|
}
|
|
|
|
const autoSyncByInstance = new Map<
|
|
string,
|
|
{ status: string; created_at: string; error_message: string | null }
|
|
>();
|
|
|
|
for (const log of autoSyncLogs ?? []) {
|
|
if (!log.target_id || autoSyncByInstance.has(log.target_id)) continue;
|
|
autoSyncByInstance.set(log.target_id, {
|
|
status: log.status,
|
|
created_at: log.created_at,
|
|
error_message: log.error_message,
|
|
});
|
|
}
|
|
|
|
const healthEventsByInstance = new Map<
|
|
string,
|
|
Array<{
|
|
id: string;
|
|
action: string;
|
|
status: string;
|
|
created_at: string;
|
|
error_message: string | null;
|
|
metadata: unknown;
|
|
}>
|
|
>();
|
|
|
|
for (const log of healthLogs ?? []) {
|
|
if (!log.target_id) continue;
|
|
const existing = healthEventsByInstance.get(log.target_id) ?? [];
|
|
if (existing.length >= 10) continue;
|
|
existing.push({
|
|
id: log.id,
|
|
action: log.action,
|
|
status: log.status,
|
|
created_at: log.created_at,
|
|
error_message: log.error_message,
|
|
metadata: log.metadata,
|
|
});
|
|
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">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900">
|
|
{agency?.name ?? "Agency"} Dashboard
|
|
</h1>
|
|
<p className="text-sm text-slate-600">Role: {context.role}</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Link
|
|
href="/dashboard/activity"
|
|
className="text-sm text-brand-700 underline"
|
|
>
|
|
Activity
|
|
</Link>
|
|
<SignOutButton />
|
|
</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>
|
|
<p className="text-2xl font-semibold text-slate-900">
|
|
{instances?.length ?? 0}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
|
<p className="text-sm text-slate-500">Tracked Subscriptions</p>
|
|
<p className="text-2xl font-semibold text-slate-900">
|
|
{subscriptions?.length ?? 0}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
|
<p className="text-sm text-slate-500">Billing Status</p>
|
|
<p className="text-2xl font-semibold text-slate-900">
|
|
{billing?.subscription_status ?? "none"}
|
|
</p>
|
|
</div>
|
|
</section>
|
|
{instancesError ? (
|
|
<div className="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
|
|
Instances query failed: {instancesError.message}
|
|
</div>
|
|
) : null}
|
|
<section id="instances-panel">
|
|
<DashboardControls
|
|
instances={instances ?? []}
|
|
subscriptions={subscriptions ?? []}
|
|
domains={(domains ?? []).map((domain) => {
|
|
const joinedStatusByLocalId = domain.subscription_id
|
|
? subscriptionStatusById.get(domain.subscription_id)
|
|
: null;
|
|
const joinedStatusByExternalId = domain.external_subscription_id
|
|
? subscriptionStatusByExternalId.get(
|
|
`${domain.plesk_instance_id}:${domain.external_subscription_id}`,
|
|
)
|
|
: null;
|
|
const joinedStatusByName = subscriptionStatusByInstanceAndName.get(
|
|
`${domain.plesk_instance_id}:${normalizeDomainLike(domain.domain_name)}`,
|
|
);
|
|
const joinedStatusBySuffix = findBestSuffixStatusMatch(
|
|
normalizeDomainLike(domain.domain_name),
|
|
subscriptionStatusNameCandidatesByInstance.get(
|
|
domain.plesk_instance_id,
|
|
) ?? [],
|
|
);
|
|
const joinedStatus =
|
|
joinedStatusByLocalId ??
|
|
joinedStatusByExternalId ??
|
|
joinedStatusByName ??
|
|
joinedStatusBySuffix;
|
|
|
|
return {
|
|
...domain,
|
|
status: joinedStatus ?? "unknown",
|
|
subscription_name: domain.subscription_id
|
|
? (subscriptionDisplayNameById.get(domain.subscription_id) ??
|
|
null)
|
|
: null,
|
|
};
|
|
})}
|
|
autoSyncByInstance={Object.fromEntries(autoSyncByInstance.entries())}
|
|
healthEventsByInstance={Object.fromEntries(
|
|
healthEventsByInstance.entries(),
|
|
)}
|
|
/>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|