Guide module installer lifecycle
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ModuleCatalogResponse | null>(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={<><Button onClick={() => void load()} disabled={loading || busy}>i18n:govoplan-admin.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={saveDisabled}>{busy ? "i18n:govoplan-admin.working.049ac820" : saveLabel}</Button></>}>
|
||||
actions={<><DocumentationHelpLink reference={{ topicId: "admin.module-lifecycle-workflow", documentationType: "admin" }} /><Button onClick={() => void load()} disabled={loading || busy}>i18n:govoplan-admin.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={saveDisabled}>{busy ? "i18n:govoplan-admin.working.049ac820" : saveLabel}</Button></>}>
|
||||
|
||||
{catalog && <>
|
||||
<div className="metric-grid module-management-metrics">
|
||||
@@ -330,6 +375,36 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
||||
<MetricCard label="i18n:govoplan-admin.maintenance.94de303b" value={maintenanceEnabled ? "i18n:govoplan-admin.on.e0049a66" : "i18n:govoplan-admin.off.e3de5ab0"} detail={canAccessMaintenance ? "i18n:govoplan-admin.bypass_allowed.4e347c27" : "i18n:govoplan-admin.bypass_denied.ab987400"} tone={maintenanceEnabled ? "warning" : "info"} />
|
||||
</div>
|
||||
|
||||
<StageRail
|
||||
className="module-installer-stage-rail"
|
||||
ariaLabel="i18n:govoplan-admin.module_lifecycle_progress.f1a20201"
|
||||
items={installerStages.map((stage) => ({
|
||||
id: stage.id,
|
||||
label: moduleInstallerStageLabel(stage.id),
|
||||
icon: stage.id === "plan"
|
||||
? <Pencil size={15} aria-hidden="true" />
|
||||
: stage.id === "preflight"
|
||||
? <Check size={15} aria-hidden="true" />
|
||||
: stage.id === "queue"
|
||||
? <Send size={15} aria-hidden="true" />
|
||||
: stage.id === "execute"
|
||||
? <Clock size={15} aria-hidden="true" />
|
||||
: <FileText size={15} aria-hidden="true" />,
|
||||
tone: moduleInstallerStageTone(stage.state),
|
||||
current: stage.current,
|
||||
locked: stage.locked,
|
||||
lockedLabel: "i18n:govoplan-admin.locked.a798882f",
|
||||
statusLabel: stage.current
|
||||
? moduleInstallerStageStatus(
|
||||
stage.id,
|
||||
installerQueueBlockReason,
|
||||
installPlan?.preflight?.allowed ?? null,
|
||||
currentInstallerRequest?.status,
|
||||
currentInstallerRun?.status
|
||||
)
|
||||
: undefined
|
||||
}))} />
|
||||
|
||||
{dirty && <div className="module-management-notes">
|
||||
<p className="alert warning">i18n:govoplan-admin.changing_enabled_modules_requires_a_dry_run_chan.10b6f5ed</p>
|
||||
{moduleStateRequest && <p>i18n:govoplan-admin.change_request_ready.b17e8c57 <code>{moduleStateRequest.request.id}</code></p>}
|
||||
@@ -554,13 +629,28 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
||||
<h2>i18n:govoplan-admin.daemon_execution.cc0fad8d</h2>
|
||||
<p>i18n:govoplan-admin.queue_the_saved_plan_for_a_separate_installer_da.92d4c96e</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => void queueInstallerRequest()} disabled={!canWrite || !canAccessMaintenance || planBusy || planDirty || !maintenanceEnabled || !installPlan.preflight?.allowed}>
|
||||
<Button variant="primary" onClick={() => void queueInstallerRequest()} disabled={Boolean(installerQueueBlock) || planBusy} disabledReason={installerQueueBlockReason || undefined}>
|
||||
i18n:govoplan-admin.queue_supervised_run.a53f248c
|
||||
</Button>
|
||||
</div>
|
||||
{planDirty && <p className="alert warning">i18n:govoplan-admin.save_the_install_plan_before_queueing_a_daemon_r.3ba00691</p>}
|
||||
{!maintenanceEnabled && <p className="alert warning">i18n:govoplan-admin.maintenance_mode_must_be_enabled_before_queueing.d233a32f</p>}
|
||||
{!canAccessMaintenance && <p className="alert warning">i18n:govoplan-admin.queueing_installer_requests_requires_maintenance.ae81f9ac</p>}
|
||||
{installerQueueBlock ? (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: MODULE_INSTALLER_I18N.queueUnavailable,
|
||||
requiredAction: installerQueueBlockReason,
|
||||
actor: moduleInstallerQueueBlockActor(installerQueueBlock),
|
||||
target: MODULE_INSTALLER_I18N.operatorPlan,
|
||||
technicalDetails: installerQueueBlock === "preflight"
|
||||
? installPlan.preflight?.issues[0]?.message
|
||||
: undefined
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: MODULE_INSTALLER_I18N.requiredAction,
|
||||
actor: MODULE_INSTALLER_I18N.responsibleActor,
|
||||
target: MODULE_INSTALLER_I18N.resolutionTarget
|
||||
}}
|
||||
documentation={{ topicId: "admin.module-lifecycle-workflow", documentationType: "admin" }} />
|
||||
) : null}
|
||||
<div className="module-installer-request-grid">
|
||||
<ToggleSwitch label="i18n:govoplan-admin.run_migrations.db6e0ce2" checked={requestOptions.migrateDatabase} onChange={(checked) => setRequestOptions((current) => ({ ...current, migrateDatabase: checked }))} disabled={!canWrite || planBusy} />
|
||||
<ToggleSwitch label="i18n:govoplan-admin.build_webui.fe8ccad7" checked={requestOptions.buildWebui} onChange={(checked) => setRequestOptions((current) => ({ ...current, buildWebui: checked }))} disabled={!canWrite || planBusy} />
|
||||
@@ -654,6 +744,62 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
||||
|
||||
}
|
||||
|
||||
function moduleInstallerStageLabel(stage: ModuleInstallerWorkflowStageId): string {
|
||||
if (stage === "plan") return "i18n:govoplan-admin.plan.ae2f98a0";
|
||||
if (stage === "preflight") return "i18n:govoplan-admin.preflight.8016a487";
|
||||
if (stage === "queue") return "i18n:govoplan-admin.installer_requests.b88db439";
|
||||
if (stage === "execute") return "i18n:govoplan-admin.daemon_execution.cc0fad8d";
|
||||
return "i18n:govoplan-admin.installer_runs.e9e344e1";
|
||||
}
|
||||
|
||||
function moduleInstallerStageTone(
|
||||
state: ModuleInstallerWorkflowStageState
|
||||
): "success" | "active" | "warning" | "danger" | "neutral" {
|
||||
if (state === "complete") return "success";
|
||||
if (state === "current") return "active";
|
||||
if (state === "failed") return "danger";
|
||||
if (state === "blocked") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function moduleInstallerStageStatus(
|
||||
stage: ModuleInstallerWorkflowStageId,
|
||||
queueBlockReason: string,
|
||||
preflightAllowed: boolean | null,
|
||||
requestStatus?: string | null,
|
||||
runStatus?: string | null
|
||||
): string {
|
||||
if (stage === "plan") return queueBlockReason || "i18n:govoplan-admin.plan.ae2f98a0";
|
||||
if (stage === "preflight") {
|
||||
if (preflightAllowed === true) return "i18n:govoplan-admin.installer_preflight_passed.6d5f8060";
|
||||
if (preflightAllowed === false) return "i18n:govoplan-admin.installer_preflight_blocked.cf83bf79";
|
||||
return "i18n:govoplan-admin.pending.c515ec74";
|
||||
}
|
||||
if (stage === "queue") return queueBlockReason || requestStatus || "i18n:govoplan-admin.queue_supervised_run.a53f248c";
|
||||
if (stage === "execute") return runStatus || requestStatus || "i18n:govoplan-admin.pending.c515ec74";
|
||||
return runStatus || requestStatus || "i18n:govoplan-admin.installer_runs.e9e344e1";
|
||||
}
|
||||
|
||||
function moduleInstallerQueueBlockReason(
|
||||
block: ModuleInstallerQueueBlock,
|
||||
validationError: string,
|
||||
preflightIssue?: string | null
|
||||
): string {
|
||||
if (block === "write_access") return "i18n:govoplan-admin.module_write_access_is_required.f1a20208";
|
||||
if (block === "empty_plan") return "i18n:govoplan-admin.no_package_changes_planned.f12d8b33";
|
||||
if (block === "invalid_plan") return validationError || "i18n:govoplan-admin.installer_preflight_blocked.cf83bf79";
|
||||
if (block === "unsaved_plan") return "i18n:govoplan-admin.save_the_install_plan_before_queueing_a_daemon_r.3ba00691";
|
||||
if (block === "preflight") return preflightIssue || "i18n:govoplan-admin.installer_preflight_blocked.cf83bf79";
|
||||
if (block === "maintenance_mode") return "i18n:govoplan-admin.maintenance_mode_must_be_enabled_before_queueing.d233a32f";
|
||||
return "i18n:govoplan-admin.queueing_installer_requests_requires_maintenance.ae81f9ac";
|
||||
}
|
||||
|
||||
function moduleInstallerQueueBlockActor(block: ModuleInstallerQueueBlock): string {
|
||||
return block === "write_access" || block === "maintenance_access"
|
||||
? "i18n:govoplan-admin.system_administrator.f1a20207"
|
||||
: "i18n:govoplan-admin.current_operator.f1a20206";
|
||||
}
|
||||
|
||||
function LicenseStatus({ license }: {license: ModuleLicenseDiagnostics;}) {
|
||||
const label = license.license_id ? `${license.license_id}${license.subject ? ` · ${license.subject}` : ""}` : license.configured ? "i18n:govoplan-admin.configured_license.3e73f8f5" : "i18n:govoplan-admin.no_license_configured.693cea1a";
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
export type ModuleInstallerWorkflowStageId =
|
||||
| "plan"
|
||||
| "preflight"
|
||||
| "queue"
|
||||
| "execute"
|
||||
| "evidence";
|
||||
|
||||
export type ModuleInstallerWorkflowStageState =
|
||||
| "complete"
|
||||
| "current"
|
||||
| "locked"
|
||||
| "blocked"
|
||||
| "failed";
|
||||
|
||||
export type ModuleInstallerWorkflowStage = {
|
||||
id: ModuleInstallerWorkflowStageId;
|
||||
state: ModuleInstallerWorkflowStageState;
|
||||
current: boolean;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
export type ModuleInstallerQueueBlock =
|
||||
| "write_access"
|
||||
| "empty_plan"
|
||||
| "invalid_plan"
|
||||
| "unsaved_plan"
|
||||
| "preflight"
|
||||
| "maintenance_mode"
|
||||
| "maintenance_access";
|
||||
|
||||
export type ModuleInstallerWorkflowInput = {
|
||||
planItemCount: number;
|
||||
planDirty: boolean;
|
||||
planValid: boolean;
|
||||
preflightAllowed: boolean | null;
|
||||
maintenanceEnabled: boolean;
|
||||
canWrite: boolean;
|
||||
canAccessMaintenance: boolean;
|
||||
requestStatus?: string | null;
|
||||
runStatus?: string | null;
|
||||
};
|
||||
|
||||
const ACTIVE_STATUSES = new Set([
|
||||
"queued",
|
||||
"pending",
|
||||
"claimed",
|
||||
"starting",
|
||||
"running",
|
||||
"cancelling",
|
||||
"rolling_back"
|
||||
]);
|
||||
|
||||
const FAILED_STATUSES = new Set([
|
||||
"blocked",
|
||||
"cancelled",
|
||||
"failed",
|
||||
"rollback_failed"
|
||||
]);
|
||||
|
||||
export function moduleInstallerQueueBlock(
|
||||
input: ModuleInstallerWorkflowInput
|
||||
): ModuleInstallerQueueBlock | null {
|
||||
if (!input.canWrite) return "write_access";
|
||||
if (input.planItemCount === 0) return "empty_plan";
|
||||
if (!input.planValid) return "invalid_plan";
|
||||
if (input.planDirty) return "unsaved_plan";
|
||||
if (input.preflightAllowed !== true) return "preflight";
|
||||
if (!input.canAccessMaintenance) return "maintenance_access";
|
||||
if (!input.maintenanceEnabled) return "maintenance_mode";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function moduleInstallerWorkflowStages(
|
||||
input: ModuleInstallerWorkflowInput
|
||||
): ModuleInstallerWorkflowStage[] {
|
||||
const queueBlock = moduleInstallerQueueBlock(input);
|
||||
const planReady = input.planItemCount > 0 && input.planValid && !input.planDirty;
|
||||
const preflightReady = planReady && input.preflightAllowed === true;
|
||||
const requestStatus = normalizeStatus(input.requestStatus);
|
||||
const runStatus = normalizeStatus(input.runStatus);
|
||||
const hasRequest = Boolean(requestStatus);
|
||||
const effectiveStatus = runStatus || requestStatus;
|
||||
const active = ACTIVE_STATUSES.has(effectiveStatus);
|
||||
const terminalStatus = hasRequest && !active ? effectiveStatus : "";
|
||||
const terminal = Boolean(terminalStatus);
|
||||
const failed = FAILED_STATUSES.has(terminalStatus);
|
||||
|
||||
if (!planReady) {
|
||||
return stagesAt("plan", input.planValid || input.planItemCount === 0 ? "current" : "blocked");
|
||||
}
|
||||
if (!preflightReady) {
|
||||
return stagesAt("preflight", input.preflightAllowed === false ? "blocked" : "current", ["plan"]);
|
||||
}
|
||||
if (!hasRequest) {
|
||||
return stagesAt("queue", queueBlock ? "blocked" : "current", ["plan", "preflight"]);
|
||||
}
|
||||
if (!terminal) {
|
||||
return stagesAt("execute", "current", ["plan", "preflight", "queue"]);
|
||||
}
|
||||
return stagesAt(
|
||||
"evidence",
|
||||
failed ? "failed" : "current",
|
||||
["plan", "preflight", "queue", "execute"]
|
||||
);
|
||||
}
|
||||
|
||||
export function installerRequestMatchesPlan(
|
||||
planUpdatedAt?: string | null,
|
||||
requestCreatedAt?: string | null
|
||||
): boolean {
|
||||
if (!planUpdatedAt) return true;
|
||||
if (!requestCreatedAt) return false;
|
||||
const planTime = Date.parse(planUpdatedAt);
|
||||
const requestTime = Date.parse(requestCreatedAt);
|
||||
return Number.isFinite(planTime)
|
||||
&& Number.isFinite(requestTime)
|
||||
&& requestTime >= planTime;
|
||||
}
|
||||
|
||||
function stagesAt(
|
||||
currentId: ModuleInstallerWorkflowStageId,
|
||||
currentState: Extract<ModuleInstallerWorkflowStageState, "current" | "blocked" | "failed">,
|
||||
completed: ModuleInstallerWorkflowStageId[] = []
|
||||
): ModuleInstallerWorkflowStage[] {
|
||||
const ids: ModuleInstallerWorkflowStageId[] = [
|
||||
"plan",
|
||||
"preflight",
|
||||
"queue",
|
||||
"execute",
|
||||
"evidence"
|
||||
];
|
||||
const currentIndex = ids.indexOf(currentId);
|
||||
const completedIds = new Set(completed);
|
||||
return ids.map((id, index) => {
|
||||
const current = id === currentId;
|
||||
const state: ModuleInstallerWorkflowStageState = current
|
||||
? currentState
|
||||
: completedIds.has(id)
|
||||
? "complete"
|
||||
: "locked";
|
||||
return {
|
||||
id,
|
||||
state,
|
||||
current,
|
||||
locked: !current && index > currentIndex
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStatus(value?: string | null): string {
|
||||
return value?.trim().toLowerCase().replaceAll("-", "_") ?? "";
|
||||
}
|
||||
@@ -2,6 +2,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-admin.module_lifecycle_progress.f1a20201": "Module lifecycle progress",
|
||||
"i18n:govoplan-admin.queue_supervised_run_unavailable.f1a20202": "Queueing a supervised run is unavailable",
|
||||
"i18n:govoplan-admin.required_action.f1a20203": "Required action",
|
||||
"i18n:govoplan-admin.who_can_fix_it.f1a20204": "Who can fix it",
|
||||
"i18n:govoplan-admin.where_to_go.f1a20205": "Where to go",
|
||||
"i18n:govoplan-admin.current_operator.f1a20206": "Current operator",
|
||||
"i18n:govoplan-admin.system_administrator.f1a20207": "System administrator",
|
||||
"i18n:govoplan-admin.module_write_access_is_required.f1a20208": "Module write access is required.",
|
||||
"i18n:govoplan-admin.access_configuration_exported.098f200d": "Access configuration exported.",
|
||||
"i18n:govoplan-admin.action.97c89a4d": "Action",
|
||||
"i18n:govoplan-admin.actions.c3cd636a": "Actions",
|
||||
@@ -391,6 +399,14 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-admin.working.049ac820": "Working..."
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-admin.module_lifecycle_progress.f1a20201": "Fortschritt des Modullebenszyklus",
|
||||
"i18n:govoplan-admin.queue_supervised_run_unavailable.f1a20202": "Ein überwachter Lauf kann nicht eingereiht werden",
|
||||
"i18n:govoplan-admin.required_action.f1a20203": "Erforderliche Maßnahme",
|
||||
"i18n:govoplan-admin.who_can_fix_it.f1a20204": "Zuständig",
|
||||
"i18n:govoplan-admin.where_to_go.f1a20205": "Ziel",
|
||||
"i18n:govoplan-admin.current_operator.f1a20206": "Aktuell ausführende Person",
|
||||
"i18n:govoplan-admin.system_administrator.f1a20207": "Systemadministration",
|
||||
"i18n:govoplan-admin.module_write_access_is_required.f1a20208": "Schreibzugriff auf Module ist erforderlich.",
|
||||
"i18n:govoplan-admin.access_configuration_exported.098f200d": "Access configuration exported.",
|
||||
"i18n:govoplan-admin.action.97c89a4d": "Action",
|
||||
"i18n:govoplan-admin.actions.c3cd636a": "Aktionen",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
installerRequestMatchesPlan,
|
||||
moduleInstallerQueueBlock,
|
||||
moduleInstallerWorkflowStages,
|
||||
type ModuleInstallerWorkflowInput
|
||||
} from "../src/features/admin/moduleInstallerWorkflow.ts";
|
||||
|
||||
const ready: ModuleInstallerWorkflowInput = {
|
||||
planItemCount: 1,
|
||||
planDirty: false,
|
||||
planValid: true,
|
||||
preflightAllowed: true,
|
||||
maintenanceEnabled: true,
|
||||
canWrite: true,
|
||||
canAccessMaintenance: true
|
||||
};
|
||||
|
||||
test("keeps the operator on the earliest incomplete installer stage", () => {
|
||||
assert.equal(moduleInstallerWorkflowStages({ ...ready, planItemCount: 0 })[0].current, true);
|
||||
assert.equal(moduleInstallerWorkflowStages({ ...ready, planDirty: true })[0].current, true);
|
||||
assert.equal(moduleInstallerWorkflowStages({ ...ready, preflightAllowed: false })[1].state, "blocked");
|
||||
assert.equal(moduleInstallerWorkflowStages({ ...ready, maintenanceEnabled: false })[2].state, "blocked");
|
||||
assert.equal(moduleInstallerWorkflowStages(ready)[2].state, "current");
|
||||
});
|
||||
|
||||
test("moves queued work through execution to durable evidence", () => {
|
||||
const queued = moduleInstallerWorkflowStages({ ...ready, requestStatus: "queued" });
|
||||
assert.equal(queued[2].state, "complete");
|
||||
assert.equal(queued[3].state, "current");
|
||||
|
||||
const completed = moduleInstallerWorkflowStages({
|
||||
...ready,
|
||||
requestStatus: "completed",
|
||||
runStatus: "completed"
|
||||
});
|
||||
assert.deepEqual(completed.map((stage) => stage.state), [
|
||||
"complete",
|
||||
"complete",
|
||||
"complete",
|
||||
"complete",
|
||||
"current"
|
||||
]);
|
||||
|
||||
const failed = moduleInstallerWorkflowStages({
|
||||
...ready,
|
||||
requestStatus: "failed",
|
||||
runStatus: "rollback_failed"
|
||||
});
|
||||
assert.equal(failed[4].state, "failed");
|
||||
});
|
||||
|
||||
test("reports one actionable queue blocker in deterministic order", () => {
|
||||
assert.equal(moduleInstallerQueueBlock({ ...ready, canWrite: false }), "write_access");
|
||||
assert.equal(moduleInstallerQueueBlock({ ...ready, planItemCount: 0 }), "empty_plan");
|
||||
assert.equal(moduleInstallerQueueBlock({ ...ready, planValid: false }), "invalid_plan");
|
||||
assert.equal(moduleInstallerQueueBlock({ ...ready, planDirty: true }), "unsaved_plan");
|
||||
assert.equal(moduleInstallerQueueBlock({ ...ready, preflightAllowed: false }), "preflight");
|
||||
assert.equal(moduleInstallerQueueBlock({ ...ready, maintenanceEnabled: false }), "maintenance_mode");
|
||||
assert.equal(moduleInstallerQueueBlock({ ...ready, canAccessMaintenance: false }), "maintenance_access");
|
||||
assert.equal(
|
||||
moduleInstallerQueueBlock({ ...ready, maintenanceEnabled: false, canAccessMaintenance: false }),
|
||||
"maintenance_access"
|
||||
);
|
||||
assert.equal(moduleInstallerQueueBlock(ready), null);
|
||||
});
|
||||
|
||||
test("does not present an older installer request as evidence for a newer plan", () => {
|
||||
assert.equal(installerRequestMatchesPlan(null, "2026-08-03T10:00:00Z"), true);
|
||||
assert.equal(
|
||||
installerRequestMatchesPlan("2026-08-03T10:00:00Z", "2026-08-03T10:00:01Z"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
installerRequestMatchesPlan("2026-08-03T10:00:00Z", "2026-08-03T09:59:59Z"),
|
||||
false
|
||||
);
|
||||
assert.equal(installerRequestMatchesPlan("invalid", "2026-08-03T10:00:00Z"), false);
|
||||
});
|
||||
Reference in New Issue
Block a user