Guide module installer lifecycle

This commit is contained in:
2026-08-03 08:44:51 +02:00
parent fef4e10afd
commit 6dbccd5e08
7 changed files with 434 additions and 6 deletions
@@ -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("-", "_") ?? "";
}
+16
View File
@@ -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",