feat(ops): show worker and queue readiness

This commit is contained in:
2026-08-19 20:52:44 +02:00
parent f84097224b
commit 6ecb94c99e
12 changed files with 559 additions and 161 deletions
+26 -3
View File
@@ -169,6 +169,25 @@ export type OpsRuntimeCluster = {
};
};
export type OpsRuntimeWorkStatus = {
provider_id: string;
label: string;
backend: string;
enabled: boolean | null;
configured: boolean | null;
state: "disabled" | "unconfigured" | "starting" | "healthy" | "idle" | "busy" | "degraded" | "stale" | "unreachable" | string;
detail: string;
observed_at: string;
active_workers?: number | null;
last_heartbeat_at?: string | null;
queue_depths: Record<string, number | null>;
active_work?: number | null;
reserved_work?: number | null;
failures?: number | null;
stale_after_seconds?: number | null;
guidance: string;
};
export type OpsStatus = {
summary: {
app_env: string;
@@ -195,12 +214,15 @@ export type OpsStatus = {
};
backup_state?: string;
worker_metrics: {
workers?: number;
active_tasks?: number;
state?: string;
workers?: number | null;
active_tasks?: number | null;
reserved_tasks?: number | null;
failures?: number | null;
expected_queues?: string[];
active_queues?: string[];
missing_queues?: string[];
queue_depths?: Record<string, number>;
queue_depths?: Record<string, number | null>;
};
operational_probe_count: number;
runtime_node_count: number;
@@ -244,6 +266,7 @@ export type OpsStatus = {
deployment_profiles: OpsDeploymentProfile[];
sizing: OpsSizingAssumption[];
runtime_cluster: OpsRuntimeCluster;
runtime_work: OpsRuntimeWorkStatus[];
infrastructure: {
configured: boolean;
available: boolean;
+5 -5
View File
@@ -12,6 +12,7 @@ import {
} from "@govoplan/core-webui";
import { fetchOpsStatus, type OpsStatus } from "../../api/ops";
import { OPS_DOCUMENTATION } from "./interfacePatterns";
import { knownMetric, knownQueueDepthTotal, runtimeWorkTone } from "./runtimeStatus";
export default function OpsHealthWidget({ settings, refreshKey }: { settings: ApiSettings; refreshKey: number }) {
const [status, setStatus] = useState<OpsStatus | null>(null);
@@ -35,8 +36,7 @@ export default function OpsHealthWidget({ settings, refreshKey }: { settings: Ap
const ready = status?.readiness.ready ?? false;
const workerMetrics = status?.summary.worker_metrics;
const queueDepths = workerMetrics?.queue_depths ?? {};
const queuedTasks = Object.values(queueDepths).reduce((sum, value) => sum + value, 0);
const missingQueues = workerMetrics?.missing_queues ?? [];
const queuedTasks = knownQueueDepthTotal(queueDepths);
const storageUsage = status?.summary.storage_metrics?.capacity_used_percent;
return (
@@ -47,9 +47,9 @@ export default function OpsHealthWidget({ settings, refreshKey }: { settings: Ap
</div>
<MetricGrid columns={3} spacing="none">
<MetricCard label="Readiness" value={ready ? "ready" : "blocked"} tone={ready ? "good" : "danger"} detail={status?.readiness.profile ?? "-"} />
<MetricCard label="Workers" value={status?.summary.celery_enabled ? workerMetrics?.workers ?? 0 : "off"} tone={status?.summary.celery_enabled && !missingQueues.length ? "good" : "warning"} detail={missingQueues.length ? `${missingQueues.length} queue(s) without a consumer` : status?.summary.celery_enabled ? "All configured queues covered" : "Single-process mode"} />
<MetricCard label="Active tasks" value={workerMetrics?.active_tasks ?? 0} tone="info" detail={status?.summary.celery_enabled ? "Reported by live workers" : "Workers disabled"} />
<MetricCard label="Queued tasks" value={queuedTasks} tone={queuedTasks ? "warning" : "neutral"} detail={Object.keys(queueDepths).length ? `${Object.keys(queueDepths).length} measured queue(s)` : "Queue depth unavailable"} />
<MetricCard label="Workers" value={knownMetric(workerMetrics?.workers)} tone={runtimeWorkTone(workerMetrics?.state ?? "unreachable")} detail={workerMetrics?.state ?? "unavailable"} />
<MetricCard label="Active tasks" value={knownMetric(workerMetrics?.active_tasks)} tone="info" detail={workerMetrics?.active_tasks == null ? "Metric unavailable" : "Reported by the runtime provider"} />
<MetricCard label="Queued tasks" value={queuedTasks ?? "unavailable"} tone={queuedTasks === null ? "neutral" : queuedTasks ? "warning" : "good"} detail={queuedTasks === null ? "Queue depth unavailable" : `${Object.values(queueDepths).filter((value) => typeof value === "number").length} measured queue(s)`} />
<MetricCard label="Storage" value={storageUsage === undefined ? status?.summary.file_storage_backend ?? "-" : `${storageUsage}%`} tone={storageUsage !== undefined && storageUsage >= 90 ? "danger" : storageUsage !== undefined && storageUsage >= 75 ? "warning" : "neutral"} detail="Managed Files backend capacity" />
<MetricCard label="Failed" value={status?.summary.failed_operation_count ?? 0} tone={status?.summary.failed_operation_count ? "danger" : "good"} detail="Recovery-ledger operations" />
<MetricCard label="Unknown" value={status?.summary.outcome_unknown_count ?? 0} tone={status?.summary.outcome_unknown_count ? "danger" : "good"} detail="Outcome-unknown operations" />
+147 -24
View File
@@ -1,6 +1,6 @@
import { DescriptionList } from "@govoplan/core-webui";
import { MetricGrid } from "@govoplan/core-webui";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { PauseCircle, PlayCircle, RefreshCw } from "lucide-react";
import { ContentGrid,
ActionBlockerHint,
@@ -33,6 +33,7 @@ import {
type OpsInfrastructureCapability,
type OpsRecoveryOperation,
type OpsRuntimeNode,
type OpsRuntimeWorkStatus,
type OpsSizingAssumption,
type OpsStatus
} from "../../api/ops";
@@ -41,6 +42,14 @@ import {
OPS_I18N,
OPS_RECOVERY_DOCUMENTATION,
} from "./interfacePatterns";
import {
heartbeatAgeLabel,
heartbeatAgeSeconds,
knownMetric,
knownQueueDepthTotal,
runtimeWorkTone,
shouldPollRuntimeStatus
} from "./runtimeStatus";
export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const [status, setStatus] = useState<OpsStatus | null>(null);
@@ -49,18 +58,27 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
const [error, setError] = useState("");
const [drainTarget, setDrainTarget] = useState<OpsRuntimeNode | null>(null);
const [nodeActionId, setNodeActionId] = useState("");
const loadInFlight = useRef<Promise<void> | null>(null);
async function load() {
setLoading(true);
setError("");
try {
setStatus(await fetchOpsStatus(settings));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
const load = useCallback((background = false): Promise<void> => {
if (loadInFlight.current) return loadInFlight.current;
const request = (async () => {
if (!background) setLoading(true);
setError("");
try {
setStatus(await fetchOpsStatus(settings));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
if (!background) setLoading(false);
}
})();
loadInFlight.current = request;
void request.finally(() => {
if (loadInFlight.current === request) loadInFlight.current = null;
});
return request;
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function runChecks() {
setRunningProbes(true);
@@ -104,7 +122,37 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
}
}
useEffect(() => {void load();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
let intervalId: number | null = null;
const stopPolling = () => {
if (intervalId !== null) window.clearInterval(intervalId);
intervalId = null;
};
const startPolling = () => {
stopPolling();
if (document.visibilityState === "hidden") return;
intervalId = window.setInterval(() => {
if (shouldPollRuntimeStatus(document.visibilityState === "hidden", Boolean(loadInFlight.current))) {
void load(true);
}
}, 15_000);
};
const handleVisibility = () => {
if (document.visibilityState === "hidden") {
stopPolling();
return;
}
if (shouldPollRuntimeStatus(false, Boolean(loadInFlight.current))) void load(true);
startPolling();
};
void load();
startPolling();
document.addEventListener("visibilitychange", handleVisibility);
return () => {
stopPolling();
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [load]);
const checks = status?.checks ?? [];
const warningCount = checks.filter((item) => item.state === "warning").length;
@@ -119,7 +167,7 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
? OPS_I18N.runPermissionRequired
: undefined;
const queueDepths = status?.summary.worker_metrics.queue_depths ?? {};
const queuedTasks = Object.values(queueDepths).reduce((total, value) => total + value, 0);
const queuedTasks = knownQueueDepthTotal(queueDepths);
const storageUsage = status?.summary.storage_metrics?.capacity_used_percent;
return (
@@ -156,8 +204,8 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
<MetricCard label="i18n:govoplan-ops.modules.04e9462c" value={status?.summary.module_count ?? 0} tone="neutral" detail="i18n:govoplan-ops.enabled_in_the_runtime_registry.d2c6142d" />
<MetricCard label="i18n:govoplan-ops.permissions.842c35eb" value={status?.governance.summary.permission_count ?? 0} tone="neutral" detail="i18n:govoplan-ops.declared_governance_permissions.d08d3bf1" />
<MetricCard label="i18n:govoplan-ops.policies.e7800f56" value={status?.governance.summary.policy_count ?? 0} tone="neutral" detail="i18n:govoplan-ops.registered_policy_capabilities.112a2b64" />
<MetricCard label="i18n:govoplan-ops.workers.b6ef3acd" value={status?.summary.celery_enabled ? status.summary.worker_metrics.workers ?? 0 : "off"} tone={status?.summary.celery_enabled && !(status.summary.worker_metrics.missing_queues?.length) ? "good" : "warning"} detail={workerMetricDetail(status)} />
<MetricCard label="Queued tasks" value={queuedTasks} tone={queuedTasks ? "warning" : "good"} detail={Object.keys(queueDepths).length ? `${Object.keys(queueDepths).length} measured queue(s)` : "Queue depth unavailable"} />
<MetricCard label="i18n:govoplan-ops.workers.b6ef3acd" value={knownMetric(status?.summary.worker_metrics.workers)} tone={runtimeWorkTone(status?.summary.worker_metrics.state ?? "unreachable")} detail={workerMetricDetail(status)} />
<MetricCard label="Queued tasks" value={queuedTasks ?? "unavailable"} tone={queuedTasks === null ? "neutral" : queuedTasks ? "warning" : "good"} detail={queuedTasks === null ? "Queue depth unavailable" : `${Object.values(queueDepths).filter((value) => typeof value === "number").length} measured queue(s)`} />
<MetricCard label="Storage" value={storageUsage === undefined ? status?.summary.file_storage_backend ?? "-" : `${storageUsage}%`} tone={storageUsage !== undefined && storageUsage >= 90 ? "danger" : storageUsage !== undefined && storageUsage >= 75 ? "warning" : "neutral"} detail={storageMetricDetail(status)} />
<MetricCard label="Backup evidence" value={status?.summary.backup_state ?? "unknown"} tone={status?.summary.backup_state === "ok" ? "good" : "warning"} detail="Latest coordinated backup and restore-drill evidence" />
<MetricCard label="Failed operations" value={status?.summary.failed_operation_count ?? 0} tone={status?.summary.failed_operation_count ? "danger" : "good"} detail="Terminal failures or manual intervention" />
@@ -186,6 +234,10 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
/>
</Card>
<Card title="Worker and queue readiness">
<RuntimeWorkTable items={status?.runtime_work ?? []} />
</Card>
<Card title="Recovery evidence">
<RecoveryTable operations={status?.runtime_cluster.recovery.operations ?? []} />
</Card>
@@ -226,6 +278,70 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
}
function RuntimeWorkTable({ items }: { items: OpsRuntimeWorkStatus[] }) {
const columns: DataGridColumn<OpsRuntimeWorkStatus>[] = [
{
id: "provider",
header: "Backend",
width: "minmax(200px, 1fr)",
minWidth: 180,
resizable: true,
sortable: true,
filterable: true,
value: (item) => `${item.label} ${item.backend}`,
render: (item) => <div><strong>{item.label}</strong><span className="muted block">{item.backend} · {item.provider_id}</span></div>
},
{
id: "state",
header: "State",
width: 150,
sortable: true,
filterable: true,
value: (item) => item.state,
render: (item) => <StatusBadge status={stateTone(item.state)} label={item.state} />
},
{
id: "activity",
header: "Activity",
width: "minmax(180px, .7fr)",
minWidth: 170,
value: (item) => `${item.active_workers ?? ""} ${item.active_work ?? ""} ${item.reserved_work ?? ""}`,
render: (item) => `${knownMetric(item.active_workers)} workers · ${knownMetric(item.active_work)} active · ${knownMetric(item.reserved_work)} reserved`
},
{
id: "queues",
header: "Queue depth",
width: "minmax(220px, 1fr)",
minWidth: 190,
resizable: true,
value: (item) => Object.entries(item.queue_depths).map(([queue, depth]) => `${queue}:${depth ?? "unavailable"}`).join(" "),
render: (item) => runtimeQueueSummary(item.queue_depths)
},
{
id: "heartbeat",
header: "Last heartbeat",
width: "minmax(180px, .7fr)",
minWidth: 170,
sortable: true,
value: (item) => item.last_heartbeat_at ?? "",
render: (item) => {
const age = heartbeatAgeSeconds(Date.now(), item.last_heartbeat_at);
return <div>{heartbeatAgeLabel(age)}<span className="muted block">stale after {item.stale_after_seconds ?? "unavailable"}s</span></div>;
}
},
{
id: "guidance",
header: "Guidance",
width: "minmax(260px, 1.3fr)",
minWidth: 220,
resizable: true,
value: (item) => `${item.detail} ${item.guidance}`,
render: (item) => <div>{item.detail}<span className="muted block">{item.guidance}</span></div>
}
];
return <DataGrid id="ops-runtime-work-status" rows={items} columns={columns} getRowKey={(item) => item.provider_id} emptyText="Worker and queue status unavailable." />;
}
function RuntimeNodeTable({
nodes,
canManage,
@@ -268,7 +384,7 @@ function RuntimeNodeTable({
resizable: true,
sortable: true,
value: (node) => node.last_heartbeat_at,
render: (node) => new Date(node.last_heartbeat_at).toLocaleString()
render: (node) => <div>{heartbeatAgeLabel(heartbeatAgeSeconds(Date.now(), node.last_heartbeat_at))}<span className="muted block">{new Date(node.last_heartbeat_at).toLocaleString()}</span></div>
},
{
id: "queues",
@@ -545,9 +661,10 @@ function capabilityTone(state: OpsInfrastructureCapability["state"]): string {
}
function stateTone(state: string): string {
if (state === "ok") return "success";
if (state === "warning") return "warning";
if (state === "error") return "error";
if (["ok", "healthy", "idle"].includes(state)) return "success";
if (state === "busy") return "info";
if (["warning", "starting", "degraded", "unconfigured"].includes(state)) return "warning";
if (["error", "stale", "unreachable"].includes(state)) return "error";
return "inactive";
}
@@ -586,11 +703,17 @@ function databaseCapacityTone(status: OpsStatus | null): "good" | "warning" | "d
}
function workerMetricDetail(status: OpsStatus | null): string {
if (!status?.summary.celery_enabled) return "i18n:govoplan-ops.celery_worker_setting.323d7737";
if (!status) return "Worker status unavailable";
const metrics = status.summary.worker_metrics;
if (metrics.missing_queues?.length) return `Missing queues: ${metrics.missing_queues.join(", ")}`;
const queued = Object.values(metrics.queue_depths ?? {}).reduce((total, value) => total + value, 0);
return `${metrics.active_tasks ?? 0} active · ${queued} queued`;
if (metrics.state === "disabled") return "Workers intentionally disabled";
const queued = knownQueueDepthTotal(metrics.queue_depths ?? {});
return `${knownMetric(metrics.active_tasks)} active · ${knownMetric(metrics.reserved_tasks)} reserved · ${queued ?? "unavailable"} queued`;
}
function runtimeQueueSummary(depths: Record<string, number | null>): string {
const entries = Object.entries(depths);
if (!entries.length) return "unavailable";
return entries.map(([queue, depth]) => `${queue}: ${depth ?? "unavailable"}`).join(" · ");
}
function storageMetricDetail(status: OpsStatus | null): string {
+34
View File
@@ -0,0 +1,34 @@
export function knownMetric(value: number | null | undefined): number | "unavailable" {
return typeof value === "number" && Number.isFinite(value) ? value : "unavailable";
}
export function knownQueueDepthTotal(depths: Record<string, number | null | undefined>): number | null {
const values = Object.values(depths).filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return values.length ? values.reduce((total, value) => total + value, 0) : null;
}
export function runtimeWorkTone(state: string): "good" | "info" | "warning" | "danger" | "neutral" {
if (state === "healthy" || state === "idle") return "good";
if (state === "busy") return "info";
if (state === "starting" || state === "degraded" || state === "unconfigured") return "warning";
if (state === "stale" || state === "unreachable") return "danger";
return "neutral";
}
export function heartbeatAgeSeconds(now: number, value?: string | null): number | null {
if (!value) return null;
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return null;
return Math.max(0, Math.floor((now - timestamp) / 1000));
}
export function heartbeatAgeLabel(ageSeconds: number | null): string {
if (ageSeconds === null) return "unavailable";
if (ageSeconds < 60) return `${ageSeconds}s ago`;
if (ageSeconds < 3600) return `${Math.floor(ageSeconds / 60)}m ago`;
return `${Math.floor(ageSeconds / 3600)}h ago`;
}
export function shouldPollRuntimeStatus(hidden: boolean, requestInFlight: boolean): boolean {
return !hidden && !requestInFlight;
}