216 lines
5.9 KiB
TypeScript
216 lines
5.9 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import {
|
|
createCorrelationId,
|
|
fail,
|
|
type HousekeepingResult,
|
|
} from "../../../foundation/contracts";
|
|
import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell";
|
|
import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state";
|
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
|
import {
|
|
type SystemObservabilityQueryData,
|
|
type SystemObservabilityQueryInput,
|
|
systemObservabilityQuery,
|
|
} from "../queries/observability";
|
|
|
|
interface SystemObservabilityPageProps {
|
|
readonly result?: HousekeepingResult<SystemObservabilityQueryData>;
|
|
}
|
|
|
|
function pageState(
|
|
kind: "loading" | "empty" | "error",
|
|
title: string,
|
|
description: string,
|
|
) {
|
|
return (
|
|
<div data-housekeeping-state={kind}>
|
|
<HousekeepingPageState
|
|
state={kind}
|
|
title={title}
|
|
description={description}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function forbiddenState() {
|
|
return (
|
|
<section
|
|
role="alert"
|
|
data-housekeeping-state="forbidden"
|
|
className="rounded-lg border border-[var(--admin-error)] bg-[var(--admin-surface)] p-4"
|
|
>
|
|
<h2 className="font-medium text-[var(--admin-text)]">Access denied</h2>
|
|
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
|
Your account cannot view this observability source.
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function observabilityContent(data: SystemObservabilityQueryData) {
|
|
if (data.kind === "devops") {
|
|
return (
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
|
<h2 className="font-medium text-[var(--admin-text)]">
|
|
Service health
|
|
</h2>
|
|
{data.health ? (
|
|
<dl className="mt-3 grid grid-cols-2 gap-2 text-sm text-[var(--admin-text-muted)]">
|
|
<dt>Database</dt>
|
|
<dd>{data.health.dbOk ? "Available" : "Unavailable"}</dd>
|
|
<dt>Redis</dt>
|
|
<dd>
|
|
{data.health.redisOk === null
|
|
? "Not configured"
|
|
: data.health.redisOk
|
|
? "Available"
|
|
: "Unavailable"}
|
|
</dd>
|
|
<dt>Emulator</dt>
|
|
<dd>{data.health.emulatorOk ? "Available" : "Unavailable"}</dd>
|
|
<dt>Online users</dt>
|
|
<dd>{data.health.onlineUsers}</dd>
|
|
</dl>
|
|
) : (
|
|
<p className="mt-3 text-sm text-[var(--admin-text-muted)]">
|
|
Health data is unavailable.
|
|
</p>
|
|
)}
|
|
</section>
|
|
{observationRows("Recent emulator errors", data.errors)}
|
|
</div>
|
|
);
|
|
}
|
|
if (data.kind === "devops-errors") {
|
|
return observationRows("Emulator errors", data.errors);
|
|
}
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
{data.metrics.map((metric) => (
|
|
<section
|
|
key={metric.label}
|
|
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
|
>
|
|
<p className="text-xs text-[var(--admin-text-muted)]">
|
|
{metric.label}
|
|
</p>
|
|
<p className="mt-1 text-xl font-semibold text-[var(--admin-text)]">
|
|
{metric.value}
|
|
</p>
|
|
</section>
|
|
))}
|
|
</div>
|
|
{observationRows(
|
|
data.kind === "logs" ? "Recent entries" : "Details",
|
|
data.rows,
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function observationRows(
|
|
title: string,
|
|
rows: readonly { id: string; primary: string; secondary?: string }[],
|
|
) {
|
|
return (
|
|
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
|
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
|
|
<ul className="mt-3 divide-y divide-[var(--admin-border)]">
|
|
{rows.map((row) => (
|
|
<li key={row.id} className="py-2 text-sm">
|
|
<p className="font-medium text-[var(--admin-text)]">
|
|
{row.primary}
|
|
</p>
|
|
{row.secondary ? (
|
|
<p className="break-words text-[var(--admin-text-muted)]">
|
|
{row.secondary}
|
|
</p>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function isEmpty(data: SystemObservabilityQueryData): boolean {
|
|
if (data.kind === "devops")
|
|
return data.health === null && data.errors.length === 0;
|
|
if (data.kind === "devops-errors") return data.errors.length === 0;
|
|
return data.metrics.length === 0 && data.rows.length === 0;
|
|
}
|
|
|
|
export function SystemObservabilityPage({
|
|
result,
|
|
}: SystemObservabilityPageProps) {
|
|
let body: ReactNode;
|
|
if (!result) {
|
|
body = pageState(
|
|
"loading",
|
|
"Loading observability data",
|
|
"Reading metrics and operational logs.",
|
|
);
|
|
} else if (!result.ok) {
|
|
body =
|
|
result.error.code === "FORBIDDEN"
|
|
? forbiddenState()
|
|
: pageState(
|
|
"error",
|
|
"Observability data unavailable",
|
|
`Request ${result.correlationId} could not be completed.`,
|
|
);
|
|
} else if (
|
|
isEmpty(result.data) &&
|
|
result.data.partialDependencies.length === 0
|
|
) {
|
|
body = pageState(
|
|
"empty",
|
|
"No observations",
|
|
"The selected source returned no records.",
|
|
);
|
|
} else {
|
|
body = (
|
|
<div
|
|
data-housekeeping-state={
|
|
result.data.partialDependencies.length > 0 ? "partial" : "ready"
|
|
}
|
|
className="space-y-4"
|
|
>
|
|
{result.data.partialDependencies.length > 0 ? (
|
|
<HousekeepingPageState
|
|
state="partial"
|
|
partialLabel="Partial data"
|
|
title="Some observability data is unavailable"
|
|
description={`Unavailable: ${result.data.partialDependencies.join(", ")}`}
|
|
/>
|
|
) : null}
|
|
{observabilityContent(result.data)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<HousekeepingPageShell
|
|
title="System observability"
|
|
description="Review metrics, service health, and operational records."
|
|
>
|
|
{body}
|
|
</HousekeepingPageShell>
|
|
);
|
|
}
|
|
|
|
export async function renderSystemObservabilityPage(
|
|
input: HousekeepingPageInput,
|
|
) {
|
|
const routeId = input.match
|
|
.routeId as SystemObservabilityQueryInput["routeId"];
|
|
const supported = routeId.startsWith("system.observability.");
|
|
const result = supported
|
|
? await systemObservabilityQuery.run(input.context, { routeId })
|
|
: fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId());
|
|
return <SystemObservabilityPage result={result} />;
|
|
}
|