From 2b3264372bc04c99c0d6fbbe2bb31e23b0ad0fe7 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 15:18:32 +0200 Subject: [PATCH] Migrate Ops interface patterns --- docs/INTERFACE_PATTERN_MIGRATION.md | 21 ++++ src/govoplan_ops/backend/manifest.py | 115 +++++++++++++++++- .../test_interface_documentation_contract.py | 76 ++++++++++++ webui/src/features/ops/OpsHealthWidget.tsx | 5 + webui/src/features/ops/OpsPage.tsx | 84 +++++++++---- webui/src/features/ops/interfacePatterns.ts | 20 +++ webui/src/i18n/generatedTranslations.ts | 56 +++++++++ webui/src/module.ts | 15 ++- 8 files changed, 367 insertions(+), 25 deletions(-) create mode 100644 docs/INTERFACE_PATTERN_MIGRATION.md create mode 100644 tests/test_interface_documentation_contract.py create mode 100644 webui/src/features/ops/interfacePatterns.ts diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..30e9e04 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,21 @@ +# Ops interface pattern migration + +Ops uses the platform monitoring and governed-operation patterns. It projects sanitized state owned by Core and modules; it does not become the repair authority for domain effects. + +## Surfaces + +- `ops.page` is the route-level operations workspace and `ops.navigation` is its navigation entry. +- `ops.page.summary` presents readiness, capacity, queue, storage, backup, recovery, provider, and governance metrics. +- `ops.page.health` owns bounded module probes; `ops.action.run-probes` is available only to an operations runner. +- `ops.page.runtime` owns runtime heartbeats and the confirmed `ops.action.drain-node` lifecycle action. +- `ops.page.recovery` presents sanitized durable recovery evidence without replay controls. +- `ops.page.governance`, `ops.page.deployment`, and `ops.page.sizing` expose declared architecture, provider, topology, and capacity assumptions. +- `ops.widget.health` is a read-only Dashboard projection of the same status endpoint. + +Backend and WebUI manifests publish the same identifiers and parent hierarchy for Views and configured-system Docs. + +## Consequences + +Running probes performs only bounded, declared operational checks and refreshes the status projection. Draining a node stops new work from being routed to the selected runtime incarnation while in-flight work completes; cancellation is possible while the node remains draining. The shared confirmation dialog names the target and consequence. Stale, stopped, unauthorized, loading, and already-running states remain visible with an explicit reason. + +Ops does not reconcile outcome-unknown work, restore backups, expose secrets, or invoke private sibling-module code. Operators follow the owning module's recovery contract and use Ops only for bounded observation and runtime coordination. diff --git a/src/govoplan_ops/backend/manifest.py b/src/govoplan_ops/backend/manifest.py index 07ebcb4..721b3a5 100644 --- a/src/govoplan_ops/backend/manifest.py +++ b/src/govoplan_ops/backend/manifest.py @@ -133,7 +133,22 @@ manifest = ModuleManifest( documentation_types=("admin", "user"), audience=("operator", "system_admin"), related_modules=("audit", "docs", "notifications"), - metadata={"kind": "reference"}, + metadata={ + "kind": "reference", + "help_contexts": [ + "ops.page", + "ops.page.summary", + "ops.page.health", + "ops.page.governance", + "ops.page.deployment", + "ops.page.sizing", + "ops.widget.health", + "ops.state.read-only", + ], + "consequence_classes": { + "run_probes": "run bounded module-owned health probes and refresh the sanitized operational projection", + }, + }, ), DocumentationTopic( id="ops.runtime-coordination-and-recovery", @@ -165,6 +180,18 @@ manifest = ModuleManifest( "Ops does not create or restore backups and never receives private artifact or key-custody references.", "A verified receipt proves the recorded drill; it does not make an unsafe post-migration code rollback reversible.", ], + "help_contexts": [ + "ops.page.runtime", + "ops.page.recovery", + "ops.action.drain-node", + "ops.state.readiness-blocked", + "ops.state.stale-node", + ], + "consequence_classes": { + "drain_node": "stop routing new work to the selected runtime incarnation while in-flight work completes", + "cancel_node_drain": "return a still-draining runtime node to active scheduling", + "inspect_recovery": "read sanitized durable recovery state without replaying or repairing the owning effect", + }, }, ), ), @@ -199,6 +226,92 @@ manifest = ModuleManifest( ), ), view_surfaces=( + ViewSurface( + id="ops.navigation", + module_id="ops", + kind="navigation", + label="Operations navigation", + order=10, + ), + ViewSurface( + id="ops.page", + module_id="ops", + kind="route", + label="Operations workspace", + order=20, + ), + ViewSurface( + id="ops.page.summary", + module_id="ops", + kind="section", + label="Operations summary", + parent_id="ops.page", + order=10, + ), + ViewSurface( + id="ops.page.health", + module_id="ops", + kind="section", + label="Health checks", + parent_id="ops.page", + order=20, + ), + ViewSurface( + id="ops.page.runtime", + module_id="ops", + kind="section", + label="Runtime cluster", + parent_id="ops.page", + order=30, + ), + ViewSurface( + id="ops.page.recovery", + module_id="ops", + kind="section", + label="Recovery evidence", + parent_id="ops.page", + order=40, + ), + ViewSurface( + id="ops.page.governance", + module_id="ops", + kind="section", + label="Governance inventory", + parent_id="ops.page", + order=50, + ), + ViewSurface( + id="ops.page.deployment", + module_id="ops", + kind="section", + label="Deployment profiles", + parent_id="ops.page", + order=60, + ), + ViewSurface( + id="ops.page.sizing", + module_id="ops", + kind="section", + label="Sizing assumptions", + parent_id="ops.page", + order=70, + ), + ViewSurface( + id="ops.action.run-probes", + module_id="ops", + kind="action", + label="Run operational probes", + parent_id="ops.page.health", + order=80, + ), + ViewSurface( + id="ops.action.drain-node", + module_id="ops", + kind="action", + label="Drain runtime node", + parent_id="ops.page.runtime", + order=90, + ), ViewSurface( id="ops.widget.health", module_id="ops", diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..bc633ee --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + +from govoplan_ops.backend.manifest import get_manifest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class OpsInterfaceDocumentationContractTests(unittest.TestCase): + def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None: + frontend = get_manifest().frontend + self.assertIsNotNone(frontend) + surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr] + expected = { + "ops.navigation", + "ops.page", + "ops.page.summary", + "ops.page.health", + "ops.page.runtime", + "ops.page.recovery", + "ops.page.governance", + "ops.page.deployment", + "ops.page.sizing", + "ops.action.run-probes", + "ops.action.drain-node", + "ops.widget.health", + } + self.assertEqual(expected, set(surfaces)) + for surface_id in ( + "ops.page.summary", + "ops.page.health", + "ops.page.runtime", + "ops.page.recovery", + "ops.page.governance", + "ops.page.deployment", + "ops.page.sizing", + ): + self.assertEqual("ops.page", surfaces[surface_id].parent_id) + self.assertEqual("ops.page.health", surfaces["ops.action.run-probes"].parent_id) + self.assertEqual("ops.page.runtime", surfaces["ops.action.drain-node"].parent_id) + + def test_help_and_consequence_metadata_remain_published(self) -> None: + topics = {topic.id: topic for topic in get_manifest().documentation} + status = topics["ops.health-governance-and-sizing"] + recovery = topics["ops.runtime-coordination-and-recovery"] + + self.assertIn("ops.page.health", status.metadata["help_contexts"]) + self.assertIn("run_probes", status.metadata["consequence_classes"]) + self.assertIn("ops.action.drain-node", recovery.metadata["help_contexts"]) + self.assertIn("drain_node", recovery.metadata["consequence_classes"]) + self.assertIn("cancel_node_drain", recovery.metadata["consequence_classes"]) + self.assertIn("inspect_recovery", recovery.metadata["consequence_classes"]) + + def test_webui_uses_shared_operational_patterns(self) -> None: + page = (REPO_ROOT / "webui/src/features/ops/OpsPage.tsx").read_text(encoding="utf-8") + widget = (REPO_ROOT / "webui/src/features/ops/OpsHealthWidget.tsx").read_text(encoding="utf-8") + + for component in ( + "ActionBlockerHint", + "ConfirmDialog", + "DataGrid", + "DocumentationHelpLink", + "LoadingFrame", + "MetricCard", + "TableActionGroup", + ): + self.assertIn(component, page) + self.assertIn("DocumentationHelpLink", widget) + self.assertIn("LoadingFrame", widget) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/ops/OpsHealthWidget.tsx b/webui/src/features/ops/OpsHealthWidget.tsx index bfae614..29f03b1 100644 --- a/webui/src/features/ops/OpsHealthWidget.tsx +++ b/webui/src/features/ops/OpsHealthWidget.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { DismissibleAlert, + DocumentationHelpLink, LoadingFrame, MetricCard, StatusBadge, @@ -8,6 +9,7 @@ import { type ApiSettings } from "@govoplan/core-webui"; import { fetchOpsStatus, type OpsStatus } from "../../api/ops"; +import { OPS_DOCUMENTATION } from "./interfacePatterns"; export default function OpsHealthWidget({ settings, refreshKey }: { settings: ApiSettings; refreshKey: number }) { const [status, setStatus] = useState(null); @@ -38,6 +40,9 @@ export default function OpsHealthWidget({ settings, refreshKey }: { settings: Ap return ( {error && {error}} +
+ +
diff --git a/webui/src/features/ops/OpsPage.tsx b/webui/src/features/ops/OpsPage.tsx index 7cb0b46..d38ad55 100644 --- a/webui/src/features/ops/OpsPage.tsx +++ b/webui/src/features/ops/OpsPage.tsx @@ -1,18 +1,22 @@ import { useEffect, useState } from "react"; import { PauseCircle, PlayCircle, RefreshCw } from "lucide-react"; import { + ActionBlockerHint, Button, Card, ConfirmDialog, DataGrid, DismissibleAlert, + DocumentationHelpLink, LoadingFrame, MetricCard, PageScrollViewport, PageTitle, StatusBadge, + TableActionGroup, adminErrorMessage, hasAnyScope, + i18nMessage, type ApiSettings, type AuthInfo, type DataGridColumn } from @@ -30,10 +34,16 @@ import { type OpsSizingAssumption, type OpsStatus } from "../../api/ops"; +import { + OPS_DOCUMENTATION, + OPS_I18N, + OPS_RECOVERY_DOCUMENTATION, +} from "./interfacePatterns"; export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); + const [runningProbes, setRunningProbes] = useState(false); const [error, setError] = useState(""); const [drainTarget, setDrainTarget] = useState(null); const [nodeActionId, setNodeActionId] = useState(""); @@ -51,6 +61,7 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth: } async function runChecks() { + setRunningProbes(true); setLoading(true); setError(""); try { @@ -58,6 +69,7 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth: } catch (err) { setError(adminErrorMessage(err)); } finally { + setRunningProbes(false); setLoading(false); } } @@ -97,6 +109,13 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth: const errorCount = checks.filter((item) => item.state === "error").length; const ready = status?.readiness.ready ?? false; const canRunChecks = hasAnyScope(auth, ["ops:operations:run", "system:settings:write"]); + const runProbesDisabledReason = runningProbes + ? OPS_I18N.runningProbes + : loading + ? OPS_I18N.loading + : !canRunChecks + ? OPS_I18N.runPermissionRequired + : undefined; const queueDepths = status?.summary.worker_metrics.queue_depths ?? {}; const queuedTasks = Object.values(queueDepths).reduce((total, value) => total + value, 0); const storageUsage = status?.summary.storage_metrics?.capacity_used_percent; @@ -110,12 +129,25 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:

i18n:govoplan-ops.runtime_health_deployment_profile_worker_split_a.55340156

- {canRunChecks ? : null} - + + +
{error && {error}} + {status && !ready && ( + + )}
@@ -172,9 +204,9 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth: setDrainTarget(null)} onConfirm={() => void confirmDrain()} @@ -244,24 +276,32 @@ function RuntimeNodeTable({ 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" ? ( - - ) : ( - + const cancelling = node.state === "draining"; + const disabledReason = !canManage + ? OPS_I18N.runPermissionRequired + : busy + ? OPS_I18N.nodeActionActive + : node.state === "stopped" + ? OPS_I18N.stoppedNode + : !cancelling && node.stale + ? OPS_I18N.staleNode + : undefined; + return ( + : , + disabled: Boolean(disabledReason), + disabledReason, + onClick: () => cancelling ? onCancelDrain(node) : onDrain(node) + }]} + /> ); } } diff --git a/webui/src/features/ops/interfacePatterns.ts b/webui/src/features/ops/interfacePatterns.ts new file mode 100644 index 0000000..fcb63cd --- /dev/null +++ b/webui/src/features/ops/interfacePatterns.ts @@ -0,0 +1,20 @@ +import type { DocumentationHelpReference } from "@govoplan/core-webui"; + +export const OPS_DOCUMENTATION = { + topicId: "ops.health-governance-and-sizing", + documentationType: "admin" +} satisfies DocumentationHelpReference; + +export const OPS_RECOVERY_DOCUMENTATION = { + topicId: "ops.runtime-coordination-and-recovery", + documentationType: "admin" +} satisfies DocumentationHelpReference; + +export const OPS_I18N = { + loading: "i18n:govoplan-ops.reason.loading", + runningProbes: "i18n:govoplan-ops.reason.running_probes", + runPermissionRequired: "i18n:govoplan-ops.reason.run_permission_required", + nodeActionActive: "i18n:govoplan-ops.reason.node_action_active", + staleNode: "i18n:govoplan-ops.reason.stale_node", + stoppedNode: "i18n:govoplan-ops.reason.stopped_node" +} as const; diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 94480b8..761c391 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -2,6 +2,34 @@ import type { PlatformTranslations } from "@govoplan/core-webui"; export const generatedTranslations: PlatformTranslations = { "en": { + "i18n:govoplan-ops.surface.navigation": "Operations navigation", + "i18n:govoplan-ops.surface.page": "Operations workspace", + "i18n:govoplan-ops.surface.summary": "Operations summary", + "i18n:govoplan-ops.surface.health": "Health checks", + "i18n:govoplan-ops.surface.runtime": "Runtime cluster", + "i18n:govoplan-ops.surface.recovery": "Recovery evidence", + "i18n:govoplan-ops.surface.governance": "Governance inventory", + "i18n:govoplan-ops.surface.deployment": "Deployment profiles", + "i18n:govoplan-ops.surface.sizing": "Sizing assumptions", + "i18n:govoplan-ops.surface.run_probes": "Run operational probes", + "i18n:govoplan-ops.surface.drain_node": "Drain runtime node", + "i18n:govoplan-ops.reason.loading": "Operations status is loading.", + "i18n:govoplan-ops.reason.running_probes": "Operational probes are already running.", + "i18n:govoplan-ops.reason.run_permission_required": "Operations-run permission is required.", + "i18n:govoplan-ops.reason.node_action_active": "A lifecycle action is already running for this node.", + "i18n:govoplan-ops.reason.stale_node": "A stale node cannot be drained because its current incarnation is not reporting.", + "i18n:govoplan-ops.reason.stopped_node": "A stopped node cannot accept lifecycle actions.", + "i18n:govoplan-ops.readiness_blocked_summary": "The runtime is not ready for normal traffic.", + "i18n:govoplan-ops.readiness_blocked_details": "{value0} readiness blocker(s) are active.", + "i18n:govoplan-ops.readiness_blocked_action": "Review the health checks, runtime cluster, and owning-module recovery evidence before restoring traffic.", + "i18n:govoplan-ops.operations_operator": "Operations operator", + "i18n:govoplan-ops.readiness_blocked_target": "Health checks, Runtime cluster, and Recovery evidence below", + "i18n:govoplan-ops.lifecycle_actions_for_value": "Lifecycle actions for {value0}", + "i18n:govoplan-ops.cancel_drain_for_value": "Cancel drain for {value0}", + "i18n:govoplan-ops.drain_value": "Drain {value0}", + "i18n:govoplan-ops.drain_runtime_node_message": "Stop routing new work to {value0}. In-flight work is allowed to finish.", + "i18n:govoplan-ops.this_node": "this node", + "i18n:govoplan-ops.drain_node": "Drain node", "i18n:govoplan-ops.area.2745deba": "Area", "i18n:govoplan-ops.baseline.e6ab7982": "Baseline", "i18n:govoplan-ops.authority.8802e425": "Authority", @@ -43,6 +71,34 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-ops.workers.b6ef3acd": "Workers" }, "de": { + "i18n:govoplan-ops.surface.navigation": "Betriebsnavigation", + "i18n:govoplan-ops.surface.page": "Betriebsarbeitsbereich", + "i18n:govoplan-ops.surface.summary": "Betriebsübersicht", + "i18n:govoplan-ops.surface.health": "Systemprüfungen", + "i18n:govoplan-ops.surface.runtime": "Laufzeitcluster", + "i18n:govoplan-ops.surface.recovery": "Wiederherstellungsnachweise", + "i18n:govoplan-ops.surface.governance": "Governance-Inventar", + "i18n:govoplan-ops.surface.deployment": "Bereitstellungsprofile", + "i18n:govoplan-ops.surface.sizing": "Dimensionierungsannahmen", + "i18n:govoplan-ops.surface.run_probes": "Betriebsprüfungen ausführen", + "i18n:govoplan-ops.surface.drain_node": "Laufzeitknoten leeren", + "i18n:govoplan-ops.reason.loading": "Der Betriebsstatus wird geladen.", + "i18n:govoplan-ops.reason.running_probes": "Betriebsprüfungen werden bereits ausgeführt.", + "i18n:govoplan-ops.reason.run_permission_required": "Die Berechtigung zum Ausführen von Betriebsprüfungen ist erforderlich.", + "i18n:govoplan-ops.reason.node_action_active": "Für diesen Knoten wird bereits eine Lebenszyklusaktion ausgeführt.", + "i18n:govoplan-ops.reason.stale_node": "Ein veralteter Knoten kann nicht geleert werden, weil seine aktuelle Instanz keine Statusmeldungen sendet.", + "i18n:govoplan-ops.reason.stopped_node": "Ein gestoppter Knoten kann keine Lebenszyklusaktionen annehmen.", + "i18n:govoplan-ops.readiness_blocked_summary": "Die Laufzeitumgebung ist nicht für normalen Datenverkehr bereit.", + "i18n:govoplan-ops.readiness_blocked_details": "{value0} Bereitschaftsblocker sind aktiv.", + "i18n:govoplan-ops.readiness_blocked_action": "Prüfen Sie Systemprüfungen, Laufzeitcluster und Wiederherstellungsnachweise der zuständigen Module, bevor der Datenverkehr wieder freigegeben wird.", + "i18n:govoplan-ops.operations_operator": "Betriebsverantwortliche Person", + "i18n:govoplan-ops.readiness_blocked_target": "Systemprüfungen, Laufzeitcluster und Wiederherstellungsnachweise weiter unten", + "i18n:govoplan-ops.lifecycle_actions_for_value": "Lebenszyklusaktionen für {value0}", + "i18n:govoplan-ops.cancel_drain_for_value": "Leeren von {value0} abbrechen", + "i18n:govoplan-ops.drain_value": "{value0} leeren", + "i18n:govoplan-ops.drain_runtime_node_message": "Keine neue Arbeit mehr an {value0} weiterleiten. Laufende Arbeit darf abgeschlossen werden.", + "i18n:govoplan-ops.this_node": "diesen Knoten", + "i18n:govoplan-ops.drain_node": "Knoten leeren", "i18n:govoplan-ops.area.2745deba": "Area", "i18n:govoplan-ops.baseline.e6ab7982": "Baseline", "i18n:govoplan-ops.authority.8802e425": "Berechtigungen", diff --git a/webui/src/module.ts b/webui/src/module.ts index 645eea3..67922c0 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -36,11 +36,22 @@ export const opsModule: PlatformWebModule = { optionalDependencies: ["audit", "docs", "notifications"], translations, viewSurfaces: [ + { id: "ops.navigation", moduleId: "ops", kind: "navigation", label: "i18n:govoplan-ops.surface.navigation", order: 10 }, + { id: "ops.page", moduleId: "ops", kind: "route", label: "i18n:govoplan-ops.surface.page", order: 20 }, + { id: "ops.page.summary", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.summary", parentId: "ops.page", order: 10 }, + { id: "ops.page.health", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.health", parentId: "ops.page", order: 20 }, + { id: "ops.page.runtime", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.runtime", parentId: "ops.page", order: 30 }, + { id: "ops.page.recovery", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.recovery", parentId: "ops.page", order: 40 }, + { id: "ops.page.governance", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.governance", parentId: "ops.page", order: 50 }, + { id: "ops.page.deployment", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.deployment", parentId: "ops.page", order: 60 }, + { id: "ops.page.sizing", moduleId: "ops", kind: "section", label: "i18n:govoplan-ops.surface.sizing", parentId: "ops.page", order: 70 }, + { id: "ops.action.run-probes", moduleId: "ops", kind: "action", label: "i18n:govoplan-ops.surface.run_probes", parentId: "ops.page.health", order: 80 }, + { id: "ops.action.drain-node", moduleId: "ops", kind: "action", label: "i18n:govoplan-ops.surface.drain_node", parentId: "ops.page.runtime", order: 90 }, { id: "ops.widget.health", moduleId: "ops", kind: "section", label: "Operations health widget", order: 100 } ], - navItems: [{ to: "/ops", label: "i18n:govoplan-ops.ops.907a54c2", iconName: "activity", anyOf: opsReadScopes, order: 890 }], + navItems: [{ to: "/ops", label: "i18n:govoplan-ops.ops.907a54c2", iconName: "activity", anyOf: opsReadScopes, order: 890, surfaceId: "ops.navigation" }], routes: [ - { path: "/ops", anyOf: opsReadScopes, order: 890, render: ({ settings, auth }) => createElement(OpsPage, { settings, auth }) }], + { path: "/ops", anyOf: opsReadScopes, order: 890, surfaceId: "ops.page", render: ({ settings, auth }) => createElement(OpsPage, { settings, auth }) }], uiCapabilities: { "dashboard.widgets": dashboardWidgets }