Guide module installer lifecycle
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user