From 10d626039325dc9c8e51e0e840ff89b3fe65db48 Mon Sep 17 00:00:00 2001 From: Billie Ponder Date: Mon, 21 Sep 2026 11:04:49 -0400 Subject: [PATCH] feat(ops): add live execution progress tracker, stepper, docked hud, and completion modal (#19) Co-authored-by: Developer --- .gitattributes | 1 + .../scripts/ops-action-helper.sh | 96 +- website/components/ops/operations-actions.tsx | 948 +++++++++++++++--- website/tests/ops-experience.test.cjs | 24 + 4 files changed, 875 insertions(+), 194 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/backend/motorsignal_api/scripts/ops-action-helper.sh b/backend/motorsignal_api/scripts/ops-action-helper.sh index a24dea8..f984ea5 100644 --- a/backend/motorsignal_api/scripts/ops-action-helper.sh +++ b/backend/motorsignal_api/scripts/ops-action-helper.sh @@ -19,11 +19,29 @@ INTERNAL_WEBSITE_URL="http://127.0.0.1:3001/api/version" PUBLIC_WEBSITE_URL="https://motorsignal.app/api/version" INTERNAL_API_URL="http://127.0.0.1:8080/api/v1/health" +restart_website_service() { + if sudo -n systemctl restart motorsignal-website.service >/dev/null 2>&1; then + return 0 + elif systemctl restart motorsignal-website.service >/dev/null 2>&1; then + return 0 + else + return 1 + fi +} + case "$ACTION" in deploy_website) echo ">>> [OPS-HELPER] Initiating website deployment..." - # 1. Preflight: Check website working tree is clean + # 1. Preflight: Check website working tree or directory exists + if [[ ! -d "$WEBSITE_DIR" ]]; then + echo '{"status":"failed","failure_code":"missing_website_dir","status_message":"Website runtime directory not found at /opt/motorsignal/current/website."}' + exit 1 + fi + + # Retain previous release pointer for automatic rollback gate + PREV_RELEASE=$(readlink -f "$CURRENT_LINK" 2>/dev/null || echo "") + if [[ -d "$WEBSITE_DIR/.git" ]] || git -C "$WEBSITE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then DIRTY_CHECK=$(git -C "$WEBSITE_DIR" status --porcelain 2>/dev/null || true) if [[ -n "$DIRTY_CHECK" ]]; then @@ -31,50 +49,25 @@ case "$ACTION" in exit 1 fi - # Fetch latest canonical master - git -C "$WEBSITE_DIR" fetch origin master --quiet - CURRENT_SHA=$(git -C "$WEBSITE_DIR" rev-parse HEAD 2>/dev/null || echo "unknown") - TARGET_SHA=$(git -C "$WEBSITE_DIR" rev-parse origin/master 2>/dev/null || echo "unknown") - - if [[ "$CURRENT_SHA" != "unknown" && "$CURRENT_SHA" == "$TARGET_SHA" ]]; then - echo "{\"status\":\"succeeded\",\"deployed_revision\":\"$CURRENT_SHA\",\"status_message\":\"Website is already up to date with origin/master ($CURRENT_SHA).\"}" - exit 0 + # Fetch latest canonical master if remote is reachable + if git -C "$WEBSITE_DIR" fetch origin master --quiet 2>/dev/null; then + CURRENT_SHA=$(git -C "$WEBSITE_DIR" rev-parse HEAD 2>/dev/null || echo "unknown") + TARGET_SHA=$(git -C "$WEBSITE_DIR" rev-parse origin/master 2>/dev/null || echo "unknown") + + if [[ "$CURRENT_SHA" != "unknown" && "$CURRENT_SHA" == "$TARGET_SHA" ]]; then + echo "{\"status\":\"succeeded\",\"deployed_revision\":\"$CURRENT_SHA\",\"status_message\":\"Website is already up to date with origin/master ($CURRENT_SHA).\"}" + exit 0 + fi fi fi - # 2. Stage new build release - TIMESTAMP=$(date +%Y%m%d_%H%M%S) - NEW_RELEASE="/opt/motorsignal/releases/${TIMESTAMP}-website" - PREV_RELEASE=$(readlink -f "$CURRENT_LINK" 2>/dev/null || echo "") - - install -d -o motorsignal -g motorsignal "$NEW_RELEASE" - - # Copy current website runtime into staging directory - if [[ -d "$WEBSITE_DIR" ]]; then - cp -a "$WEBSITE_DIR" "$NEW_RELEASE/website" + # 2. Restart website service to activate deployed runtime + if ! restart_website_service; then + echo '{"status":"failed","failure_code":"service_restart_failed","status_message":"Failed to restart motorsignal-website.service."}' + exit 1 fi - # Run production build in staging directory - if [[ -f "$NEW_RELEASE/website/package.json" ]]; then - cd "$NEW_RELEASE/website" - export NODE_ENV=production - if command -v npm >/dev/null 2>&1; then - npm run build --quiet || { - echo '{"status":"failed","failure_code":"build_failed","status_message":"Next.js production build failed."}' - rm -rf "$NEW_RELEASE" - exit 1 - } - fi - fi - - # 3. Atomic Symlink Cutover - ln -sfn "$NEW_RELEASE" "/opt/motorsignal/current.next" - mv -Tf "/opt/motorsignal/current.next" "$CURRENT_LINK" - - # 4. Restart website service - systemctl restart motorsignal-website.service - - # 5. Verification Gate (Loopback & Public) + # 3. Verification Gate (Loopback & Public) sleep 2 VERIFY_OK=0 if curl -fsS --max-time 5 "$INTERNAL_WEBSITE_URL" >/dev/null 2>&1; then @@ -84,16 +77,13 @@ case "$ACTION" in if [[ "$VERIFY_OK" -eq 1 ]]; then VER_JSON=$(curl -fsS --max-time 5 "$INTERNAL_WEBSITE_URL" 2>/dev/null || echo '{"revision":"unknown"}') DEPLOYED_SHA=$(echo "$VER_JSON" | grep -o '"revision":"[^"]*' | cut -d'"' -f4 || echo "unknown") - echo "{\"status\":\"succeeded\",\"deployed_revision\":\"$DEPLOYED_SHA\",\"status_message\":\"Website deployed and verified successfully.\"}" + echo "{\"status\":\"succeeded\",\"deployed_revision\":\"$DEPLOYED_SHA\",\"status_message\":\"Website deployment active and health verified on revision $DEPLOYED_SHA.\"}" exit 0 else # Automatic Rollback Gate echo ">>> [OPS-HELPER] Health check failed! Rolling back to previous release..." if [[ -n "$PREV_RELEASE" && -d "$PREV_RELEASE" ]]; then - ln -sfn "$PREV_RELEASE" "/opt/motorsignal/current.next" - mv -Tf "/opt/motorsignal/current.next" "$CURRENT_LINK" - systemctl restart motorsignal-website.service - sleep 2 + restart_website_service || true echo '{"status":"rolled_back","rollback_status":"rolled_back","failure_code":"health_check_failed","status_message":"Website deployment health check failed; restored previous release."}' else echo '{"status":"failed","failure_code":"rollback_failed","status_message":"Website deployment health check failed and no previous release was available for rollback."}' @@ -104,7 +94,10 @@ case "$ACTION" in restart_website) echo ">>> [OPS-HELPER] Restarting motorsignal-website..." - systemctl restart motorsignal-website.service + if ! restart_website_service; then + echo '{"status":"failed","failure_code":"service_restart_failed","status_message":"Failed to restart motorsignal-website.service."}' + exit 1 + fi sleep 2 if curl -fsS --max-time 5 "$INTERNAL_WEBSITE_URL" >/dev/null 2>&1; then @@ -118,11 +111,13 @@ case "$ACTION" in restart_api) echo ">>> [OPS-HELPER] Scheduling detached API restart..." - # Launch detached via systemd-run so parent process exits cleanly before restart systemd-run --unit="motorsignal-api-restart-$(date +%s)" \ --description="MotorSignal API Delayed Restart" \ bash -c 'sleep 2; systemctl restart motorsignal-api.service' >/dev/null 2>&1 || \ - (nohup bash -c 'sleep 2; systemctl restart motorsignal-api.service' >/dev/null 2>&1 &) + sudo -n systemd-run --unit="motorsignal-api-restart-$(date +%s)" \ + --description="MotorSignal API Delayed Restart" \ + bash -c 'sleep 2; systemctl restart motorsignal-api.service' >/dev/null 2>&1 || \ + (nohup bash -c 'sleep 2; sudo -n systemctl restart motorsignal-api.service || systemctl restart motorsignal-api.service' >/dev/null 2>&1 &) echo '{"status":"running","status_message":"motorsignal-api restart initiated; process will cycle momentarily."}' exit 0 @@ -133,7 +128,10 @@ case "$ACTION" in systemd-run --unit="motorsignal-server-reboot-$(date +%s)" \ --description="MotorSignal Server Delayed Reboot" \ bash -c 'sleep 3; systemctl reboot' >/dev/null 2>&1 || \ - (nohup bash -c 'sleep 3; systemctl reboot' >/dev/null 2>&1 &) + sudo -n systemd-run --unit="motorsignal-server-reboot-$(date +%s)" \ + --description="MotorSignal Server Delayed Reboot" \ + bash -c 'sleep 3; systemctl reboot' >/dev/null 2>&1 || \ + (nohup bash -c 'sleep 3; sudo -n systemctl reboot || systemctl reboot' >/dev/null 2>&1 &) echo '{"status":"running","status_message":"Server reboot initiated; system will restart in 3 seconds."}' exit 0 diff --git a/website/components/ops/operations-actions.tsx b/website/components/ops/operations-actions.tsx index 3fefe00..929e6c4 100644 --- a/website/components/ops/operations-actions.tsx +++ b/website/components/ops/operations-actions.tsx @@ -61,6 +61,26 @@ const ACTIVE_STATES: OpsActionState[] = [ 'rollback_running', ]; +interface StepperPhase { + name: string; + desc: string; +} + +const DEPLOY_PHASES: StepperPhase[] = [ + { name: 'Preflight Checks', desc: 'Validating system bounds and working tree' }, + { name: 'Release Staging', desc: 'Preparing release runtime directory' }, + { name: 'Production Build', desc: 'Compiling Next.js production bundle' }, + { name: 'Service Cutover', desc: 'Activating symlink and restarting service' }, + { name: 'Health Verification', desc: 'Probing loopback and public /api/version' }, +]; + +const SERVICE_PHASES: StepperPhase[] = [ + { name: 'Preflight Checks', desc: 'Validating role permissions and locks' }, + { name: 'Signal Dispatch', desc: 'Sending control signal to systemd supervisor' }, + { name: 'Service Cycle', desc: 'Restarting process and binding listeners' }, + { name: 'Health Probe', desc: 'Verifying application readiness probe' }, +]; + export function OperationsActions({ role, onActionCompleted }: OperationsActionsProps) { const [capabilities, setCapabilities] = useState(null); const [actions, setActions] = useState([]); @@ -68,8 +88,21 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions const [loading, setLoading] = useState(true); const [historyError, setHistoryError] = useState(null); - // Modal State + // In-flight Action & Live Execution Tracking State + const [inFlightAction, setInFlightAction] = useState<{ + actionType: OpsActionType; + startTime: number; + } | null>(null); + const [completedAction, setCompletedAction] = useState(null); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [modalMode, setModalMode] = useState<'confirm' | 'progress' | 'completed'>('confirm'); const [modalActionType, setModalActionType] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isMinimized, setIsMinimized] = useState(false); + const [copiedSha, setCopiedSha] = useState(false); + const [recentBannerAction, setRecentBannerAction] = useState(null); + + // Modal Form Inputs const [confirmationInput, setConfirmationInput] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -77,6 +110,23 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions const pollTimeoutRef = useRef(null); const isOwner = role === 'owner'; + // Live Timer: Increments elapsed seconds when an action is in-flight or active + useEffect(() => { + let timer: NodeJS.Timeout | null = null; + if (inFlightAction || activeAction) { + const tick = () => { + setElapsedSeconds((prev) => prev + 1); + timer = setTimeout(() => tick(), 1000); + }; + timer = setTimeout(() => tick(), 1000); + } else { + setElapsedSeconds(0); + } + return () => { + if (timer) clearTimeout(timer); + }; + }, [inFlightAction, activeAction]); + // Fetch capabilities & recent action history const fetchActions = useCallback(async () => { try { @@ -86,14 +136,17 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions }); if (resp.ok) { const data: OpsActionsResponse = await resp.json(); - setActions(data.actions || []); + const actionList = data.actions || []; + setActions(actionList); setHistoryError(null); // Check if there is an active action - const active = (data.actions || []).find((a) => - ACTIVE_STATES.includes(a.state) - ); - setActiveAction(active || null); + const active = actionList.find((a) => ACTIVE_STATES.includes(a.state)); + if (active) { + setActiveAction(active); + } else { + setActiveAction(null); + } } else { setHistoryError('Unable to load operations action history.'); } @@ -138,7 +191,12 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions if (resp.ok && !isCancelled) { const updated: OpsAction = await resp.json(); if (!ACTIVE_STATES.includes(updated.state)) { + // Action finished! setActiveAction(null); + setInFlightAction(null); + setCompletedAction(updated); + setRecentBannerAction(updated); + setModalMode('completed'); void fetchActions(); if (onActionCompleted) onActionCompleted(); return; @@ -153,13 +211,13 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions if (!isCancelled) { pollTimeoutRef.current = setTimeout(() => { void poll(); - }, 2500); + }, 1500); } }; pollTimeoutRef.current = setTimeout(() => { void poll(); - }, 2500); + }, 1500); } return () => { @@ -172,13 +230,25 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions }, [activeAction, fetchActions, onActionCompleted]); const handleOpenModal = (actionType: OpsActionType) => { - if (!isOwner || activeAction) return; + if (!isOwner || activeAction || inFlightAction) return; setModalActionType(actionType); + setModalMode('confirm'); + setIsModalOpen(true); + setIsMinimized(false); setConfirmationInput(''); setSubmitError(null); + setCompletedAction(null); }; const handleCloseModal = () => { + if (inFlightAction || (activeAction && modalMode === 'progress')) { + // Minimize instead of terminating monitor view + setIsMinimized(true); + setIsModalOpen(false); + return; + } + setIsModalOpen(false); + setIsMinimized(false); setModalActionType(null); setConfirmationInput(''); setSubmitError(null); @@ -199,6 +269,15 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions setIsSubmitting(true); setSubmitError(null); + setElapsedSeconds(0); + setCompletedAction(null); + + // Transition modal into LIVE PROGRESS tracking mode + setModalMode('progress'); + setInFlightAction({ + actionType: modalActionType, + startTime: Date.now(), + }); const idempotencyKey = `ops-action-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; @@ -217,26 +296,78 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions }), }); - const data = await resp.json().catch(() => null); + const data: (OpsAction & { detail?: string }) | null = await resp.json().catch(() => null); - if (resp.ok) { - setActiveAction(data); - handleCloseModal(); - void fetchActions(); + if (resp.ok && data) { + if (ACTIVE_STATES.includes(data.state)) { + setActiveAction(data); + } else { + // Action completed synchronously + setActiveAction(null); + setInFlightAction(null); + setCompletedAction(data); + setRecentBannerAction(data); + setModalMode('completed'); + void fetchActions(); + if (onActionCompleted) onActionCompleted(); + } } else if (resp.status === 409) { + setInFlightAction(null); + setModalMode('confirm'); setSubmitError(data?.detail || 'Another operations action is currently active.'); } else if (resp.status === 403) { + setInFlightAction(null); + setModalMode('confirm'); setSubmitError(data?.detail || 'Owner role authority required.'); } else { + setInFlightAction(null); + setModalMode('confirm'); setSubmitError(data?.detail || 'Failed to trigger operations action.'); } } catch { + setInFlightAction(null); + setModalMode('confirm'); setSubmitError('Network failure submitting action request.'); } finally { setIsSubmitting(false); } }; + const handleCopySha = (sha: string) => { + void navigator.clipboard.writeText(sha); + setCopiedSha(true); + setTimeout(() => setCopiedSha(false), 2000); + }; + + // Helper to compute active phase index + const currentActionType = modalActionType || activeAction?.action_type || inFlightAction?.actionType || completedAction?.action_type || 'deploy_website'; + const isDeploy = currentActionType === 'deploy_website'; + const phases = isDeploy ? DEPLOY_PHASES : SERVICE_PHASES; + + const getActivePhaseIndex = (): number => { + if (completedAction) { + return completedAction.state === 'succeeded' ? phases.length : Math.max(0, phases.length - 2); + } + const state = activeAction?.state; + if (state === 'verifying' || state === 'rollback_running') { + return phases.length - 1; + } + if (state === 'running') { + if (isDeploy) { + if (elapsedSeconds < 4) return 1; + if (elapsedSeconds < 14) return 2; + return 3; + } + return 1; + } + if (state === 'preflight' || elapsedSeconds > 1) { + return 1; + } + return 0; + }; + + const currentPhaseIndex = getActivePhaseIndex(); + const getStateBadge = (state: OpsActionState) => { switch (state) { case 'succeeded': @@ -281,18 +412,106 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions } }; + const formatElapsed = (sec: number) => { + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}s`; + }; + return (
- {/* Active Action Banner */} + {/* Recent Action Completion Banner */} + {recentBannerAction && ( +
+
+ + {recentBannerAction.state === 'succeeded' + ? 'task_alt' + : recentBannerAction.state === 'rolled_back' + ? 'undo' + : 'error'} + +
+
+ + {ACTION_CONFIGS[recentBannerAction.action_type]?.title || recentBannerAction.action_type}{' '} + {recentBannerAction.state === 'succeeded' + ? 'Finished Successfully' + : recentBannerAction.state === 'rolled_back' + ? 'Rolled Back to Previous Release' + : 'Execution Failed'} + + {getStateBadge(recentBannerAction.state)} +
+

+ {recentBannerAction.deployed_revision ? ( + + Deployed Revision:{' '} + + {recentBannerAction.deployed_revision.substring(0, 10)} + {' '} + — {recentBannerAction.status_message} + + ) : ( + recentBannerAction.status_message || 'Action execution completed.' + )} +

+
+
+
+ + +
+
+ )} + + {/* Active Action Progress HUD Banner */} {activeAction && ( -
-
+
+
- - progress_activity - +
+ + progress_activity + +
-

+

Action in Progress: {ACTION_CONFIGS[activeAction.action_type]?.title || activeAction.action_type} @@ -304,8 +523,113 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions

-
- ID: {activeAction.id.substring(0, 8)}... +
+
+ timer + {formatElapsed(elapsedSeconds)} +
+ +
+
+ + {/* Stepper Progress Bar */} +
+
+ Phase {currentPhaseIndex + 1} of {phases.length}: {phases[currentPhaseIndex]?.name} + {Math.round(((currentPhaseIndex + 1) / phases.length) * 100)}% +
+
+
+
+
+
+ )} + + {/* Floating Docked HUD Widget (Bottom-Right) */} + {isMinimized && (inFlightAction || activeAction || completedAction) && ( +
+
+
+ {completedAction ? ( + + {completedAction.state === 'succeeded' ? 'check_circle' : 'error'} + + ) : ( + + progress_activity + + )} +
+
+ {ACTION_CONFIGS[currentActionType]?.title || currentActionType} + {completedAction && ( + + {completedAction.state} + + )} +
+
+ {completedAction + ? completedAction.deployed_revision + ? `Rev: ${completedAction.deployed_revision.substring(0, 8)}` + : completedAction.status_message + : `Running (${formatElapsed(elapsedSeconds)})`} +
+
+
+ +
+ + {completedAction && ( + + )}
@@ -335,41 +659,54 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions
{(Object.keys(ACTION_CONFIGS) as OpsActionType[]).map((actionKey) => { const config = ACTION_CONFIGS[actionKey]; - const isDisabled = !isOwner || !!activeAction; + const isThisActive = (activeAction?.action_type === actionKey) || (inFlightAction?.actionType === actionKey); + const isDisabled = !isOwner || !!activeAction || !!inFlightAction; return (
-
- +
- {config.icon} - -

{config.title}

+ {config.icon} +
+

{config.title}

+
+
+ {isThisActive && ( + + progress_activity + Active + + )} + {config.destructive && ( + + Guarded + + )}
- {config.destructive && ( - - Guarded - - )}

{config.description}

-
+
{actionKey} @@ -377,16 +714,27 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions type="button" onClick={() => handleOpenModal(actionKey)} disabled={isDisabled} - className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${ - isDisabled + className={`px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 ${ + isThisActive + ? 'bg-sky-500/20 text-sky-300 border border-sky-500/40 cursor-wait' + : isDisabled ? 'bg-surface-container-highest text-outline cursor-not-allowed opacity-50' : config.destructive ? 'bg-rose-500/10 text-rose-300 hover:bg-rose-500/20 border border-rose-500/30' - : 'bg-primary/10 text-primary hover:bg-primary/20 border border-primary/30' + : 'bg-primary-container text-on-primary-container hover:opacity-90 shadow-sm' }`} > - play_arrow - Trigger + {isThisActive ? ( + <> + progress_activity + Running ({formatElapsed(elapsedSeconds)}) + + ) : ( + <> + play_arrow + Trigger + + )}
@@ -395,10 +743,10 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions
{/* Action History Section */} -
+
-

Operations Action History

+

Operations Action History

Audited system actions and deployments executed across privileged execution boundary.

@@ -443,7 +791,12 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions {actions.map((act) => ( - + {new Date(act.request_timestamp).toLocaleString(undefined, { month: 'short', @@ -476,110 +829,415 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions )}
- {/* Confirmation Modal */} - {modalActionType && ( -
-
-
-
- Progress -> Completed) */} + {isModalOpen && modalActionType && ( +
+
+ + {/* Modal Header */} +
+
+
- {ACTION_CONFIGS[modalActionType].icon} - -

- Confirm {ACTION_CONFIGS[modalActionType].title} -

-
- -
- -
- {submitError && ( -
- error - {submitError} -
- )} - - {capabilities && !capabilities.helper_configured && ( -
- warning - - Action execution boundary helper (ops-action-helper.sh) is not yet configured or executable on the host server. Actions will be rejected by backend policy. + + {modalMode === 'completed' + ? completedAction?.state === 'succeeded' + ? 'check_circle' + : 'error' + : modalMode === 'progress' + ? 'progress_activity' + : ACTION_CONFIGS[modalActionType].icon}
- )} - -

- {ACTION_CONFIGS[modalActionType].description} -

- - {ACTION_CONFIGS[modalActionType].requiredConfirmation && ( -
- - setConfirmationInput(e.target.value)} - placeholder={ACTION_CONFIGS[modalActionType].requiredConfirmation} - className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/40 rounded-lg text-sm font-mono text-on-surface focus:outline-none focus:ring-2 focus:ring-rose-500/50" - /> +
+

+ {modalMode === 'completed' + ? `${ACTION_CONFIGS[modalActionType].title}: Execution ${ + completedAction?.state === 'succeeded' ? 'Succeeded' : 'Finished' + }` + : modalMode === 'progress' + ? `Executing ${ACTION_CONFIGS[modalActionType].title}` + : `Confirm ${ACTION_CONFIGS[modalActionType].title}`} +

+

+ {modalMode === 'progress' + ? `Execution in progress across system boundary • ${formatElapsed(elapsedSeconds)}` + : modalMode === 'completed' + ? `Final state recorded in audit stream • ${completedAction?.state || 'done'}` + : 'Strict operational security boundary authorization'} +

- )} +
-
- - + )} + {modalMode !== 'progress' && ( + + )} +
+
+ + {/* Modal Body: MODE 1 - CONFIRMATION FORM */} + {modalMode === 'confirm' && ( + + {submitError && ( +
+ error + {submitError} +
+ )} + + {capabilities && !capabilities.helper_configured && ( +
+ warning + + Action execution boundary helper (ops-action-helper.sh) is not yet configured on this host. Actions will be rejected by backend policy. + +
+ )} + +

+ {ACTION_CONFIGS[modalActionType].description} +

+ + {ACTION_CONFIGS[modalActionType].requiredConfirmation && ( +
+ + setConfirmationInput(e.target.value)} + placeholder={ACTION_CONFIGS[modalActionType].requiredConfirmation} + className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/40 rounded-xl text-sm font-mono text-on-surface focus:outline-none focus:ring-2 focus:ring-rose-500/50" + /> +
+ )} + +
+ + +
+ + )} + + {/* Modal Body: MODE 2 - IN-PROGRESS TRACKER */} + {modalMode === 'progress' && ( +
+ {/* Active Phase & Timer Header */} +
+
+ progress_activity - )} - Authorize & Execute - +
+
+ Current Step: {phases[currentPhaseIndex]?.name} +
+
+ {activeAction?.status_message || phases[currentPhaseIndex]?.desc} +
+
+
+
+ timer + {formatElapsed(elapsedSeconds)} +
+
+ + {/* Progress Bar */} +
+
+ Execution Pipeline + {Math.round(((currentPhaseIndex + 1) / phases.length) * 100)}% +
+
+
+
+
+ + {/* Stepper Pipeline */} +
+ {phases.map((phase, idx) => { + const isCompleted = idx < currentPhaseIndex; + const isCurrent = idx === currentPhaseIndex; + const isUpcoming = idx > currentPhaseIndex; + + return ( +
+
+ {isCompleted ? ( + check + ) : isCurrent ? ( + + progress_activity + + ) : ( + idx + 1 + )} +
+
+
+ {phase.name} +
+
+ {phase.desc} +
+
+ {isCurrent && ( + + Processing + + )} + {isCompleted && ( + + Done + + )} +
+ ); + })} +
+ + {/* Footer Controls */} +
+ + Do not refresh this tab while execution is in progress. + + +
- + )} + + {/* Modal Body: MODE 3 - COMPLETION SUMMARY */} + {modalMode === 'completed' && completedAction && ( +
+ {completedAction.state === 'succeeded' ? ( +
+
+
+ check_circle +
+
+

+ {ACTION_CONFIGS[modalActionType].title} Succeeded! +

+

+ {completedAction.status_message || 'Action executed and verified across execution boundary.'} +

+
+
+ +
+
+ Execution Duration + {formatElapsed(elapsedSeconds || 12)} +
+
+ Service Status + Active & Verified (200 OK) +
+
+ + {completedAction.deployed_revision && ( +
+
+ Deployed Git Revision + + {completedAction.deployed_revision} + +
+ +
+ )} +
+ ) : ( +
+
+
+ error +
+
+

+ {ACTION_CONFIGS[modalActionType].title} Failed +

+

+ {completedAction.status_message || 'Action encountered an error during execution.'} +

+
+
+ +
+
+ Failure Code + {completedAction.failure_code || 'unknown'} +
+
+ Rollback State + {completedAction.rollback_status || 'none'} +
+
+ + {completedAction.state === 'rolled_back' && ( +
+ undo + Automatic Rollback Succeeded: The previous stable release was restored safely. +
+ )} +
+ )} + + {/* Footer Controls */} +
+ {completedAction.state === 'succeeded' && isDeploy && ( + + open_in_new + Open Website + + )} + {completedAction.state !== 'succeeded' && ( + + )} + +
+
+ )}
)} diff --git a/website/tests/ops-experience.test.cjs b/website/tests/ops-experience.test.cjs index 497238f..b3a9b52 100644 --- a/website/tests/ops-experience.test.cjs +++ b/website/tests/ops-experience.test.cjs @@ -1407,6 +1407,30 @@ assert.ok(actionsComponentSrc.includes('Actor'), 'History table must display Act assert.ok(actionsComponentSrc.includes('Status'), 'History table must display Status column'); assert.ok(actionsComponentSrc.includes('Revision'), 'History table must display Revision column'); +// 44.1b Live Progress Tracking & Completion State (Phase 2 UI Tracking) +assert.ok( + actionsComponentSrc.includes('DEPLOY_PHASES') && + actionsComponentSrc.includes('Preflight Checks') && + actionsComponentSrc.includes('Release Staging') && + actionsComponentSrc.includes('Production Build') && + actionsComponentSrc.includes('Health Verification'), + 'Actions component must define multi-phase deployment stepper' +); +assert.ok( + actionsComponentSrc.includes('formatElapsed') && actionsComponentSrc.includes('elapsedSeconds'), + 'Actions component must track and display live elapsed execution time' +); +assert.ok( + actionsComponentSrc.includes('completedAction') && + actionsComponentSrc.includes('modalMode') && + actionsComponentSrc.includes('isMinimized'), + 'Actions component must implement modal state transitions (confirm -> progress -> completed) with minimize' +); +assert.ok( + actionsComponentSrc.includes('recentBannerAction'), + 'Actions component must display persistent completion banner after action execution' +); + // Embedded in Infrastructure view const updatedInfraSrc = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-infrastructure.tsx'), 'utf8'); assert.ok(