From 6dbccd5e086820d9fd328a0140b6122f8bb3f960 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 08:44:51 +0200 Subject: [PATCH] Guide module installer lifecycle --- README.md | 9 + src/govoplan_admin/backend/manifest.py | 21 +++ webui/package.json | 3 + .../features/admin/ModuleManagementPanel.tsx | 158 +++++++++++++++++- .../features/admin/moduleInstallerWorkflow.ts | 152 +++++++++++++++++ webui/src/i18n/generatedTranslations.ts | 16 ++ webui/tests/module-installer-workflow.test.ts | 81 +++++++++ 7 files changed, 434 insertions(+), 6 deletions(-) create mode 100644 webui/src/features/admin/moduleInstallerWorkflow.ts create mode 100644 webui/tests/module-installer-workflow.test.ts diff --git a/README.md b/README.md index eceef69..c1d445d 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,15 @@ admin UI records operator intent and queues or renders commands for the trusted installer process described in `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`. +The WebUI presents the lifecycle as five derived stages: plan, preflight, +installer request, daemon execution, and run evidence. The projection resets +when the saved plan changes and associates evidence only with an installer +request created at or after the current plan revision. It therefore cannot make +an old successful run look like evidence for a new plan. The earliest queue +blocker is shown through Core's actionable blocker pattern with the required +action, responsible operator or administrator, and destination. Contextual help +uses the stable `admin.module-lifecycle-workflow` documentation topic. + ## Package Surfaces The admin UI intentionally exposes two different package concepts: diff --git a/src/govoplan_admin/backend/manifest.py b/src/govoplan_admin/backend/manifest.py index 7d3b119..83e2cc6 100644 --- a/src/govoplan_admin/backend/manifest.py +++ b/src/govoplan_admin/backend/manifest.py @@ -42,6 +42,27 @@ manifest = ModuleManifest( related_modules=("access", "audit", "ops"), metadata={"kind": "reference"}, ), + DocumentationTopic( + id="admin.module-lifecycle-workflow", + title="Plan and supervise module lifecycle changes", + summary="Move a reviewed module package plan through preflight, maintenance-gated queueing, daemon execution, and durable run evidence.", + body=( + "The Modules administration surface projects one operator workflow: save a package plan, resolve preflight findings, enter maintenance mode with the required authority, queue a supervised installer request, and inspect the matching run record. " + "The stage indicator is derived from the saved plan timestamp, the latest matching request, and its run; an older request is never presented as evidence for a newer plan. " + "Disabled queue actions name the earliest blocker, the person who can resolve it, and the plan surface where work continues. Package mutation remains outside the FastAPI process and recovery evidence remains durable in the installer ledger." + ), + layer="configured", + documentation_types=("admin",), + audience=("system_admin", "operator", "module_admin"), + related_modules=("ops", "audit"), + metadata={ + "kind": "workflow", + "context_ids": [ + "admin.module-lifecycle", + "admin.module-lifecycle.queue-blocker", + ], + }, + ), ), frontend=FrontendModule( module_id="admin", diff --git a/webui/package.json b/webui/package.json index 95896ec..77a7ab0 100644 --- a/webui/package.json +++ b/webui/package.json @@ -12,6 +12,9 @@ "import": "./src/index.ts" } }, + "scripts": { + "test:installer-workflow": "node --experimental-strip-types --test tests/module-installer-workflow.test.ts" + }, "peerDependencies": { "@govoplan/core-webui": "^0.1.9", "lucide-react": "^1.23.0", diff --git a/webui/src/features/admin/ModuleManagementPanel.tsx b/webui/src/features/admin/ModuleManagementPanel.tsx index 3a1f5f4..0d55918 100644 --- a/webui/src/features/admin/ModuleManagementPanel.tsx +++ b/webui/src/features/admin/ModuleManagementPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import type { ApiSettings } from "@govoplan/core-webui"; -import { AdminPageLayout, adminErrorMessage, Button, dispatchPlatformModulesChanged, formatDateTime, MetricCard, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type FormatDateTimeOptions } from "@govoplan/core-webui"; +import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, Button, dispatchPlatformModulesChanged, DocumentationHelpLink, formatDateTime, MetricCard, StageRail, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type FormatDateTimeOptions } from "@govoplan/core-webui"; +import { Check, Clock, FileText, Pencil, Send } from "lucide-react"; import { cancelModuleInstallerRequest, clearModuleInstallPlan, @@ -33,6 +34,22 @@ import { type ModulePackageCatalogResponse, type ModulePackageCatalogItem } from "../../api/admin"; +import { + installerRequestMatchesPlan, + moduleInstallerQueueBlock, + moduleInstallerWorkflowStages, + type ModuleInstallerQueueBlock, + type ModuleInstallerWorkflowStageId, + type ModuleInstallerWorkflowStageState +} from "./moduleInstallerWorkflow"; + +const MODULE_INSTALLER_I18N = { + queueUnavailable: "i18n:govoplan-admin.queue_supervised_run_unavailable.f1a20202", + operatorPlan: "i18n:govoplan-admin.operator_install_plan.b203aabc", + requiredAction: "i18n:govoplan-admin.required_action.f1a20203", + responsibleActor: "i18n:govoplan-admin.who_can_fix_it.f1a20204", + resolutionTarget: "i18n:govoplan-admin.where_to_go.f1a20205" +} as const; export default function ModuleManagementPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) { const [catalog, setCatalog] = useState(null); @@ -86,6 +103,34 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai const maintenanceEnabled = Boolean(catalog?.maintenance_mode.enabled || installPlan?.maintenance_mode.enabled); const planDirty = Boolean(installPlan && JSON.stringify(normalizePlanItems(draftPlanItems)) !== JSON.stringify(normalizePlanItems(installPlan.items))); const planValid = planValidationError(draftPlanItems) === ""; + const latestInstallerRequest = installerRequests?.requests[0] ?? null; + const currentInstallerRequest = latestInstallerRequest && installerRequestMatchesPlan( + installPlan?.updated_at, + latestInstallerRequest.created_at + ) ? latestInstallerRequest : null; + const currentInstallerRun = currentInstallerRequest + ? installerRuns?.runs.find((run) => run.request_id === currentInstallerRequest.request_id) ?? null + : null; + const installerWorkflowInput = { + planItemCount: draftPlanItems.length, + planDirty, + planValid, + preflightAllowed: installPlan?.preflight?.allowed ?? null, + maintenanceEnabled, + canWrite, + canAccessMaintenance, + requestStatus: currentInstallerRequest?.status, + runStatus: currentInstallerRun?.status + }; + const installerStages = moduleInstallerWorkflowStages(installerWorkflowInput); + const installerQueueBlock = moduleInstallerQueueBlock(installerWorkflowInput); + const installerQueueBlockReason = installerQueueBlock + ? moduleInstallerQueueBlockReason( + installerQueueBlock, + planValidationError(draftPlanItems), + installPlan?.preflight?.issues[0]?.message + ) + : ""; useUnsavedDraftGuard({ dirty: dirty || planDirty, onSave: saveDirtyChanges, @@ -319,7 +364,7 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai loading={loading} error={error} success={success} - actions={<>}> + actions={<>}> {catalog && <>
@@ -330,6 +375,36 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
+ ({ + id: stage.id, + label: moduleInstallerStageLabel(stage.id), + icon: stage.id === "plan" + ?