feat: expose institutional architecture and runtime state
This commit is contained in:
@@ -37,6 +37,91 @@ export type OpsGovernanceModule = {
|
||||
access_control_count: number;
|
||||
search_provider_count: number;
|
||||
migration_managed: boolean;
|
||||
architecture?: {
|
||||
contract_version: string;
|
||||
layer: string;
|
||||
kind: string;
|
||||
maturity: string;
|
||||
known_limits: string[];
|
||||
supported_authority_modes: string[];
|
||||
owned_concepts: string[];
|
||||
non_owned_concepts: string[];
|
||||
evidence: Array<{ kind: string; reference: string; summary: string }>;
|
||||
} | null;
|
||||
external_provider_count: number;
|
||||
external_providers: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
maturity: string;
|
||||
authority_modes: string[];
|
||||
operations: string[];
|
||||
behavior: { outage?: string | null };
|
||||
runtime_state?: {
|
||||
configured: boolean;
|
||||
active: boolean;
|
||||
authority_mode?: string | null;
|
||||
authority_modes?: string[];
|
||||
health: string;
|
||||
freshness: string;
|
||||
conflict: string;
|
||||
recovery: string;
|
||||
observed_at: string;
|
||||
last_success_at?: string | null;
|
||||
bindings?: Array<{
|
||||
binding_ref: string;
|
||||
authority_mode: string;
|
||||
active: boolean;
|
||||
health: string;
|
||||
freshness: string;
|
||||
conflict: string;
|
||||
recovery: string;
|
||||
}>;
|
||||
} | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type OpsRuntimeNode = {
|
||||
node_id: string;
|
||||
incarnation: string;
|
||||
role: string;
|
||||
software_version: string;
|
||||
composition_hash: string;
|
||||
queues: string[];
|
||||
state: "active" | "draining" | "stopped" | string;
|
||||
started_at: string;
|
||||
last_heartbeat_at: string;
|
||||
drain_requested_at?: string | null;
|
||||
drain_reason?: string | null;
|
||||
stopped_at?: string | null;
|
||||
stale: boolean;
|
||||
};
|
||||
|
||||
export type OpsRecoveryOperation = {
|
||||
id: string;
|
||||
module_id: string;
|
||||
operation_type: string;
|
||||
resource_type?: string | null;
|
||||
resource_id?: string | null;
|
||||
mode: string;
|
||||
status: string;
|
||||
checkpoint_count: number;
|
||||
evidence_head_sha256?: string | null;
|
||||
failure_summary?: string | null;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type OpsRuntimeCluster = {
|
||||
available: boolean;
|
||||
detail: string;
|
||||
installation_id?: string;
|
||||
state_profile: string;
|
||||
expected?: { api: number; worker: number };
|
||||
active?: { api: number; worker: number };
|
||||
nodes: OpsRuntimeNode[];
|
||||
recovery: {
|
||||
operations: OpsRecoveryOperation[];
|
||||
requires_attention: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type OpsStatus = {
|
||||
@@ -62,6 +147,8 @@ export type OpsStatus = {
|
||||
queue_depths?: Record<string, number>;
|
||||
};
|
||||
operational_probe_count: number;
|
||||
runtime_node_count: number;
|
||||
recovery_required_count: number;
|
||||
};
|
||||
readiness: {
|
||||
ready: boolean;
|
||||
@@ -85,11 +172,17 @@ export type OpsStatus = {
|
||||
access_control_count: number;
|
||||
search_provider_count: number;
|
||||
migration_module_count: number;
|
||||
architecture_declared_module_count: number;
|
||||
external_provider_count: number;
|
||||
configured_external_provider_count: number;
|
||||
provider_attention_count: number;
|
||||
supported_module_count: number;
|
||||
};
|
||||
modules: OpsGovernanceModule[];
|
||||
};
|
||||
deployment_profiles: OpsDeploymentProfile[];
|
||||
sizing: OpsSizingAssumption[];
|
||||
runtime_cluster: OpsRuntimeCluster;
|
||||
};
|
||||
|
||||
export function fetchOpsStatus(settings: ApiSettings): Promise<OpsStatus> {
|
||||
@@ -99,3 +192,30 @@ export function fetchOpsStatus(settings: ApiSettings): Promise<OpsStatus> {
|
||||
export function runOpsChecks(settings: ApiSettings): Promise<OpsStatus> {
|
||||
return apiFetch(settings, "/api/v1/ops/checks/run", { method: "POST" });
|
||||
}
|
||||
|
||||
export function drainRuntimeNode(
|
||||
settings: ApiSettings,
|
||||
nodeId: string,
|
||||
reason = "operator request"
|
||||
): Promise<{ node_id: string; state: string; drain_reason: string }> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/ops/runtime/nodes/${encodeURIComponent(nodeId)}/drain`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function cancelRuntimeNodeDrain(
|
||||
settings: ApiSettings,
|
||||
nodeId: string
|
||||
): Promise<{ node_id: string; state: string }> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/ops/runtime/nodes/${encodeURIComponent(nodeId)}/drain`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { PauseCircle, PlayCircle, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
@@ -18,10 +19,14 @@ import {
|
||||
"@govoplan/core-webui";
|
||||
import {
|
||||
fetchOpsStatus,
|
||||
cancelRuntimeNodeDrain,
|
||||
drainRuntimeNode,
|
||||
runOpsChecks,
|
||||
type OpsCheck,
|
||||
type OpsDeploymentProfile,
|
||||
type OpsGovernanceModule,
|
||||
type OpsRecoveryOperation,
|
||||
type OpsRuntimeNode,
|
||||
type OpsSizingAssumption,
|
||||
type OpsStatus
|
||||
} from "../../api/ops";
|
||||
@@ -30,6 +35,8 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
|
||||
const [status, setStatus] = useState<OpsStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [drainTarget, setDrainTarget] = useState<OpsRuntimeNode | null>(null);
|
||||
const [nodeActionId, setNodeActionId] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@@ -55,6 +62,34 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDrain() {
|
||||
if (!drainTarget) return;
|
||||
setNodeActionId(drainTarget.node_id);
|
||||
setError("");
|
||||
try {
|
||||
await drainRuntimeNode(settings, drainTarget.node_id);
|
||||
setDrainTarget(null);
|
||||
setStatus(await fetchOpsStatus(settings));
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setNodeActionId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelDrain(node: OpsRuntimeNode) {
|
||||
setNodeActionId(node.node_id);
|
||||
setError("");
|
||||
try {
|
||||
await cancelRuntimeNodeDrain(settings, node.node_id);
|
||||
setStatus(await fetchOpsStatus(settings));
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setNodeActionId("");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {void load();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
const checks = status?.checks ?? [];
|
||||
@@ -89,6 +124,9 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
|
||||
<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="i18n:govoplan-ops.redis.5eaa1f2f" value={status?.summary.redis_url ? "configured" : "-"} tone={status?.summary.celery_enabled ? "info" : "neutral"} detail={status?.summary.redis_url ?? "-"} />
|
||||
<MetricCard label="i18n:govoplan-ops.warnings.1430f976" value={warningCount + errorCount} tone={errorCount ? "danger" : warningCount ? "warning" : "good"} detail="i18n:govoplan-ops.current_health_checks.7830bccf" />
|
||||
<MetricCard label="Runtime nodes" value={status?.summary.runtime_node_count ?? 0} tone={status?.runtime_cluster.available ? "good" : "danger"} detail={runtimeNodeMetricDetail(status)} />
|
||||
<MetricCard label="Recovery" value={status?.summary.recovery_required_count ?? 0} tone={status?.summary.recovery_required_count ? "danger" : "good"} detail="Operations requiring recovery attention" />
|
||||
<MetricCard label="Provider bindings" value={status?.governance.summary.configured_external_provider_count ?? 0} tone={status?.governance.summary.provider_attention_count ? "warning" : "good"} detail={`${status?.governance.summary.provider_attention_count ?? 0} requiring attention`} />
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
@@ -96,6 +134,20 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
|
||||
<CheckList checks={checks} />
|
||||
</Card>
|
||||
|
||||
<Card title="Runtime cluster">
|
||||
<RuntimeNodeTable
|
||||
nodes={status?.runtime_cluster.nodes ?? []}
|
||||
canManage={canRunChecks}
|
||||
busyNodeId={nodeActionId}
|
||||
onDrain={setDrainTarget}
|
||||
onCancelDrain={(node) => void cancelDrain(node)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Recovery evidence">
|
||||
<RecoveryTable operations={status?.runtime_cluster.recovery.operations ?? []} />
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-ops.governance_inventory.835d8e57">
|
||||
<GovernanceTable modules={status?.governance.modules ?? []} />
|
||||
</Card>
|
||||
@@ -109,11 +161,159 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
|
||||
</Card>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
<ConfirmDialog
|
||||
open={Boolean(drainTarget)}
|
||||
title="Drain runtime node"
|
||||
message={`Stop routing new work to ${drainTarget?.node_id ?? "this node"}. In-flight work is allowed to finish.`}
|
||||
confirmLabel="Drain node"
|
||||
busy={Boolean(nodeActionId)}
|
||||
onCancel={() => setDrainTarget(null)}
|
||||
onConfirm={() => void confirmDrain()}
|
||||
/>
|
||||
</div>
|
||||
</PageScrollViewport>);
|
||||
|
||||
}
|
||||
|
||||
function RuntimeNodeTable({
|
||||
nodes,
|
||||
canManage,
|
||||
busyNodeId,
|
||||
onDrain,
|
||||
onCancelDrain
|
||||
}: {
|
||||
nodes: OpsRuntimeNode[];
|
||||
canManage: boolean;
|
||||
busyNodeId: string;
|
||||
onDrain: (node: OpsRuntimeNode) => void;
|
||||
onCancelDrain: (node: OpsRuntimeNode) => void;
|
||||
}) {
|
||||
const columns: DataGridColumn<OpsRuntimeNode>[] = [
|
||||
{
|
||||
id: "node",
|
||||
header: "Node",
|
||||
width: "minmax(220px, 1fr)",
|
||||
minWidth: 200,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (node) => `${node.node_id} ${node.role}`,
|
||||
render: (node) => <div><strong>{node.node_id}</strong><span className="muted block">{node.role} · {node.software_version}</span></div>
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
header: "State",
|
||||
width: 150,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (node) => node.stale ? "stale" : node.state,
|
||||
render: (node) => <StatusBadge status={node.stale ? "error" : stateTone(node.state)} label={node.stale ? "stale" : node.state} />
|
||||
},
|
||||
{
|
||||
id: "heartbeat",
|
||||
header: "Last heartbeat",
|
||||
width: "minmax(210px, .8fr)",
|
||||
minWidth: 190,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
value: (node) => node.last_heartbeat_at,
|
||||
render: (node) => new Date(node.last_heartbeat_at).toLocaleString()
|
||||
},
|
||||
{
|
||||
id: "queues",
|
||||
header: "Queues",
|
||||
width: "minmax(180px, .7fr)",
|
||||
minWidth: 160,
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
value: (node) => node.queues.join(" "),
|
||||
render: (node) => node.queues.join(", ") || "-"
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
width: 56,
|
||||
value: (node) => node.state,
|
||||
render: (node) => {
|
||||
if (!canManage || node.state === "stopped") return null;
|
||||
const busy = busyNodeId === node.node_id;
|
||||
return node.state === "draining" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Cancel drain"
|
||||
aria-label={`Cancel drain for ${node.node_id}`}
|
||||
disabled={busy}
|
||||
onClick={() => onCancelDrain(node)}
|
||||
><PlayCircle size={16} /></Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Drain node"
|
||||
aria-label={`Drain ${node.node_id}`}
|
||||
disabled={busy || node.stale}
|
||||
onClick={() => onDrain(node)}
|
||||
><PauseCircle size={16} /></Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
return <DataGrid id="ops-runtime-nodes" rows={nodes} columns={columns} getRowKey={(node) => `${node.node_id}:${node.incarnation}`} emptyText="No runtime nodes reported." />;
|
||||
}
|
||||
|
||||
function RecoveryTable({ operations }: { operations: OpsRecoveryOperation[] }) {
|
||||
const columns: DataGridColumn<OpsRecoveryOperation>[] = [
|
||||
{
|
||||
id: "operation",
|
||||
header: "Operation",
|
||||
width: "minmax(240px, 1fr)",
|
||||
minWidth: 220,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (operation) => `${operation.module_id} ${operation.operation_type}`,
|
||||
render: (operation) => <div><strong>{operation.operation_type}</strong><span className="muted block">{operation.module_id} · {operation.mode}</span></div>
|
||||
},
|
||||
{
|
||||
id: "resource",
|
||||
header: "Resource",
|
||||
width: "minmax(180px, .8fr)",
|
||||
minWidth: 160,
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
value: (operation) => `${operation.resource_type ?? ""} ${operation.resource_id ?? ""}`,
|
||||
render: (operation) => operation.resource_type ? `${operation.resource_type}: ${operation.resource_id ?? "-"}` : "-"
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: 170,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (operation) => operation.status,
|
||||
render: (operation) => <StatusBadge status={recoveryTone(operation.status)} label={operation.status.replaceAll("_", " ")} />
|
||||
},
|
||||
{
|
||||
id: "evidence",
|
||||
header: "Evidence",
|
||||
width: 150,
|
||||
sortable: true,
|
||||
value: (operation) => operation.checkpoint_count,
|
||||
render: (operation) => `${operation.checkpoint_count} checkpoint(s)`
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
header: "Updated",
|
||||
width: "minmax(210px, .8fr)",
|
||||
minWidth: 190,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
value: (operation) => operation.updated_at,
|
||||
render: (operation) => new Date(operation.updated_at).toLocaleString()
|
||||
}
|
||||
];
|
||||
return <DataGrid id="ops-recovery-operations" rows={operations} columns={columns} getRowKey={(operation) => operation.id} emptyText="No recovery operations recorded." />;
|
||||
}
|
||||
|
||||
function GovernanceTable({ modules }: { modules: OpsGovernanceModule[] }) {
|
||||
const columns: DataGridColumn<OpsGovernanceModule>[] = [
|
||||
{
|
||||
@@ -145,6 +345,44 @@ function GovernanceTable({ modules }: { modules: OpsGovernanceModule[] }) {
|
||||
value: (module) => `${module.capability_count} ${module.policy_count}`,
|
||||
render: (module) => `${module.capability_count} capabilities · ${module.policy_count} policies`
|
||||
},
|
||||
{
|
||||
id: "architecture",
|
||||
header: "Architecture",
|
||||
width: "minmax(210px, .9fr)",
|
||||
minWidth: 190,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (module) => module.architecture
|
||||
? `${module.architecture.layer} ${module.architecture.kind} ${module.architecture.maturity}`
|
||||
: "undeclared",
|
||||
render: (module) => module.architecture ? (
|
||||
<div>
|
||||
<strong>{module.architecture.maturity}</strong>
|
||||
<span className="muted block">{module.architecture.layer} · {module.architecture.kind}</span>
|
||||
{module.architecture.known_limits.length ? <span className="muted block">{module.architecture.known_limits.length} known limit(s)</span> : null}
|
||||
</div>
|
||||
) : <span className="muted">staged declaration pending</span>
|
||||
},
|
||||
{
|
||||
id: "providers",
|
||||
header: "External providers",
|
||||
width: "minmax(230px, 1fr)",
|
||||
minWidth: 210,
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
value: (module) => `${module.external_provider_count} ${module.external_providers.map((provider) => `${provider.id} ${provider.maturity} ${provider.authority_modes.join(" ")} ${provider.runtime_state?.health ?? "unobserved"} ${provider.runtime_state?.freshness ?? ""}`).join(" ")}`,
|
||||
render: (module) => module.external_provider_count ? (
|
||||
<div>
|
||||
<strong>{module.external_provider_count} declared</strong>
|
||||
{module.external_providers.map((provider) => (
|
||||
<span className="muted block" key={provider.id}>
|
||||
{provider.label}: {provider.maturity} · {provider.runtime_state ? `${provider.runtime_state.health}/${provider.runtime_state.freshness} · ${provider.runtime_state.bindings?.length ?? 0} binding(s)` : "unobserved"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : <span className="muted">none declared</span>
|
||||
},
|
||||
{
|
||||
id: "controls",
|
||||
header: "i18n:govoplan-ops.controls.0cdb80fb",
|
||||
@@ -227,6 +465,21 @@ function stateTone(state: string): string {
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
function recoveryTone(state: string): string {
|
||||
if (["succeeded", "recovered"].includes(state)) return "success";
|
||||
if (["failed", "recovery_required"].includes(state)) return "error";
|
||||
if (["running", "recovering", "prepared"].includes(state)) return "warning";
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
function runtimeNodeMetricDetail(status: OpsStatus | null): string {
|
||||
const cluster = status?.runtime_cluster;
|
||||
if (!cluster?.available) return cluster?.detail ?? "Runtime directory unavailable";
|
||||
const active = cluster.active ?? { api: 0, worker: 0 };
|
||||
const expected = cluster.expected ?? { api: 0, worker: 0 };
|
||||
return `${active.api}/${expected.api} API · ${active.worker}/${expected.worker} workers`;
|
||||
}
|
||||
|
||||
function workerMetricDetail(status: OpsStatus | null): string {
|
||||
if (!status?.summary.celery_enabled) return "i18n:govoplan-ops.celery_worker_setting.323d7737";
|
||||
const metrics = status.summary.worker_metrics;
|
||||
|
||||
Reference in New Issue
Block a user