1247 lines
55 KiB
TypeScript
1247 lines
55 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import {
|
|
OperationsRole,
|
|
OpsAction,
|
|
OpsActionType,
|
|
OpsActionState,
|
|
OpsActionCapabilities,
|
|
OpsActionsResponse,
|
|
} from './types';
|
|
|
|
interface OperationsActionsProps {
|
|
role: OperationsRole;
|
|
onActionCompleted?: () => void;
|
|
}
|
|
|
|
const ACTION_CONFIGS: Record<
|
|
OpsActionType,
|
|
{
|
|
title: string;
|
|
description: string;
|
|
icon: string;
|
|
destructive: boolean;
|
|
requiredConfirmation?: string;
|
|
}
|
|
> = {
|
|
deploy_website: {
|
|
title: 'Deploy Website',
|
|
description: 'Pulls latest master commit, builds Next.js production bundle, verifies health, and updates current symlink with automatic rollback.',
|
|
icon: 'rocket_launch',
|
|
destructive: false,
|
|
},
|
|
restart_website: {
|
|
title: 'Restart Website',
|
|
description: 'Gracefully restarts the motorsignal-website systemd service and verifies /api/version ingress readiness.',
|
|
icon: 'restart_alt',
|
|
destructive: false,
|
|
},
|
|
restart_api: {
|
|
title: 'Restart API',
|
|
description: 'Initiates detached systemd restart of motorsignal-api. In-flight requests may terminate. Type confirmation below.',
|
|
icon: 'sync_problem',
|
|
destructive: true,
|
|
requiredConfirmation: 'RESTART API',
|
|
},
|
|
reboot_server: {
|
|
title: 'Reboot Host Server',
|
|
description: 'Schedules full host server reboot. All services, website, and API traffic will be temporarily unavailable. Type confirmation below.',
|
|
icon: 'power_settings_new',
|
|
destructive: true,
|
|
requiredConfirmation: 'REBOOT MOTORSIGNAL SERVER',
|
|
},
|
|
};
|
|
|
|
const ACTIVE_STATES: OpsActionState[] = [
|
|
'queued',
|
|
'preflight',
|
|
'running',
|
|
'verifying',
|
|
'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<OpsActionCapabilities | null>(null);
|
|
const [actions, setActions] = useState<OpsAction[]>([]);
|
|
const [activeAction, setActiveAction] = useState<OpsAction | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [historyError, setHistoryError] = useState<string | null>(null);
|
|
|
|
// In-flight Action & Live Execution Tracking State
|
|
const [inFlightAction, setInFlightAction] = useState<{
|
|
actionType: OpsActionType;
|
|
startTime: number;
|
|
} | null>(null);
|
|
const [completedAction, setCompletedAction] = useState<OpsAction | null>(null);
|
|
const [elapsedSeconds, setElapsedSeconds] = useState<number>(0);
|
|
const [modalMode, setModalMode] = useState<'confirm' | 'progress' | 'completed'>('confirm');
|
|
const [modalActionType, setModalActionType] = useState<OpsActionType | null>(null);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isMinimized, setIsMinimized] = useState(false);
|
|
const [copiedSha, setCopiedSha] = useState(false);
|
|
const [recentBannerAction, setRecentBannerAction] = useState<OpsAction | null>(null);
|
|
|
|
// Modal Form Inputs
|
|
const [confirmationInput, setConfirmationInput] = useState('');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
|
|
const pollTimeoutRef = useRef<NodeJS.Timeout | null>(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 {
|
|
const resp = await fetch('/api/ops/actions?limit=10', {
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
if (resp.ok) {
|
|
const data: OpsActionsResponse = await resp.json();
|
|
const actionList = data.actions || [];
|
|
setActions(actionList);
|
|
setHistoryError(null);
|
|
|
|
// Check if there is an active action
|
|
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.');
|
|
}
|
|
} catch {
|
|
setHistoryError('Network error connecting to operations actions platform.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const fetchCapabilities = useCallback(async () => {
|
|
try {
|
|
const resp = await fetch('/api/ops/actions/capabilities', {
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
if (resp.ok) {
|
|
const data: OpsActionCapabilities = await resp.json();
|
|
setCapabilities(data);
|
|
}
|
|
} catch {
|
|
// Non-blocking
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void fetchCapabilities();
|
|
void fetchActions();
|
|
}, [fetchCapabilities, fetchActions]);
|
|
|
|
// Polling when an action is active
|
|
useEffect(() => {
|
|
let isCancelled = false;
|
|
|
|
if (activeAction) {
|
|
const poll = async () => {
|
|
try {
|
|
const resp = await fetch(`/api/ops/actions/${encodeURIComponent(activeAction.id)}`, {
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
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;
|
|
} else {
|
|
setActiveAction(updated);
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-blocking error
|
|
}
|
|
|
|
if (!isCancelled) {
|
|
pollTimeoutRef.current = setTimeout(() => {
|
|
void poll();
|
|
}, 1500);
|
|
}
|
|
};
|
|
|
|
pollTimeoutRef.current = setTimeout(() => {
|
|
void poll();
|
|
}, 1500);
|
|
}
|
|
|
|
return () => {
|
|
isCancelled = true;
|
|
if (pollTimeoutRef.current) {
|
|
clearTimeout(pollTimeoutRef.current);
|
|
pollTimeoutRef.current = null;
|
|
}
|
|
};
|
|
}, [activeAction, fetchActions, onActionCompleted]);
|
|
|
|
const handleOpenModal = (actionType: OpsActionType) => {
|
|
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);
|
|
};
|
|
|
|
const handleSubmitAction = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!modalActionType || !isOwner || isSubmitting) return;
|
|
|
|
const config = ACTION_CONFIGS[modalActionType];
|
|
if (
|
|
config.requiredConfirmation &&
|
|
confirmationInput.trim() !== config.requiredConfirmation
|
|
) {
|
|
setSubmitError(`Please type exact confirmation text: "${config.requiredConfirmation}"`);
|
|
return;
|
|
}
|
|
|
|
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)}`;
|
|
|
|
try {
|
|
const resp = await fetch('/api/ops/actions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
action_type: modalActionType,
|
|
confirmation: confirmationInput.trim() || undefined,
|
|
confirmation_text: confirmationInput.trim() || undefined,
|
|
idempotency_key: idempotencyKey,
|
|
}),
|
|
});
|
|
|
|
const data: (OpsAction & { detail?: string }) | null = await resp.json().catch(() => null);
|
|
|
|
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':
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
|
<span className="material-symbols-outlined text-[13px]">check_circle</span>
|
|
Succeeded
|
|
</span>
|
|
);
|
|
case 'rolled_back':
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
|
<span className="material-symbols-outlined text-[13px]">undo</span>
|
|
Rolled Back
|
|
</span>
|
|
);
|
|
case 'failed':
|
|
case 'rollback_failed':
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
|
<span className="material-symbols-outlined text-[13px]">error</span>
|
|
{state === 'rollback_failed' ? 'Rollback Failed' : 'Failed'}
|
|
</span>
|
|
);
|
|
case 'queued':
|
|
case 'preflight':
|
|
case 'running':
|
|
case 'verifying':
|
|
case 'rollback_running':
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono bg-sky-500/10 text-sky-400 border border-sky-500/20 animate-pulse">
|
|
<span className="material-symbols-outlined text-[13px] animate-spin">progress_activity</span>
|
|
{state.replace('_', ' ').toUpperCase()}
|
|
</span>
|
|
);
|
|
default:
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono bg-surface-container-highest text-outline border border-outline-variant/20">
|
|
{state}
|
|
</span>
|
|
);
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<div className="space-y-6">
|
|
{/* Recent Action Completion Banner */}
|
|
{recentBannerAction && (
|
|
<div
|
|
className={`p-4 rounded-xl border flex items-center justify-between transition-all ${
|
|
recentBannerAction.state === 'succeeded'
|
|
? 'border-emerald-500/30 bg-emerald-950/20 text-emerald-200'
|
|
: recentBannerAction.state === 'rolled_back'
|
|
? 'border-amber-500/30 bg-amber-950/20 text-amber-200'
|
|
: 'border-rose-500/30 bg-rose-950/20 text-rose-200'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span
|
|
className={`material-symbols-outlined text-2xl ${
|
|
recentBannerAction.state === 'succeeded'
|
|
? 'text-emerald-400'
|
|
: recentBannerAction.state === 'rolled_back'
|
|
? 'text-amber-400'
|
|
: 'text-rose-400'
|
|
}`}
|
|
>
|
|
{recentBannerAction.state === 'succeeded'
|
|
? 'task_alt'
|
|
: recentBannerAction.state === 'rolled_back'
|
|
? 'undo'
|
|
: 'error'}
|
|
</span>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-sm">
|
|
{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'}
|
|
</span>
|
|
{getStateBadge(recentBannerAction.state)}
|
|
</div>
|
|
<p className="text-xs opacity-90 mt-0.5 font-mono">
|
|
{recentBannerAction.deployed_revision ? (
|
|
<span>
|
|
Deployed Revision:{' '}
|
|
<span className="font-bold underline">
|
|
{recentBannerAction.deployed_revision.substring(0, 10)}
|
|
</span>{' '}
|
|
— {recentBannerAction.status_message}
|
|
</span>
|
|
) : (
|
|
recentBannerAction.status_message || 'Action execution completed.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setCompletedAction(recentBannerAction);
|
|
setModalActionType(recentBannerAction.action_type);
|
|
setModalMode('completed');
|
|
setIsModalOpen(true);
|
|
setIsMinimized(false);
|
|
}}
|
|
className="px-3 py-1.5 rounded-lg text-xs font-semibold bg-surface-container-highest/60 hover:bg-surface-container-highest text-on-surface transition-colors border border-outline-variant/30"
|
|
>
|
|
View Details
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setRecentBannerAction(null)}
|
|
className="p-1 rounded-lg text-outline hover:text-on-surface"
|
|
title="Dismiss notification"
|
|
>
|
|
<span className="material-symbols-outlined text-base">close</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Active Action Progress HUD Banner */}
|
|
{activeAction && (
|
|
<div className="p-5 rounded-2xl border border-sky-500/40 bg-sky-950/30 text-sky-200 shadow-xl shadow-sky-950/20">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-10 w-10 rounded-xl bg-sky-500/20 border border-sky-500/40 flex items-center justify-center text-sky-400">
|
|
<span className="material-symbols-outlined text-2xl animate-spin">
|
|
progress_activity
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-semibold text-base text-sky-100 flex items-center gap-2 font-headline-sm">
|
|
<span>Action in Progress:</span>
|
|
<span className="font-mono text-sky-300">
|
|
{ACTION_CONFIGS[activeAction.action_type]?.title || activeAction.action_type}
|
|
</span>
|
|
{getStateBadge(activeAction.state)}
|
|
</h4>
|
|
<p className="text-xs text-sky-300/80 mt-0.5 font-mono">
|
|
{activeAction.status_message || 'Executing action across system boundary...'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="px-3 py-1.5 rounded-xl bg-sky-500/10 border border-sky-500/20 font-mono text-xs text-sky-300 flex items-center gap-1.5">
|
|
<span className="material-symbols-outlined text-[14px]">timer</span>
|
|
<span>{formatElapsed(elapsedSeconds)}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setModalActionType(activeAction.action_type);
|
|
setModalMode('progress');
|
|
setIsModalOpen(true);
|
|
setIsMinimized(false);
|
|
}}
|
|
className="px-3 py-1.5 rounded-xl text-xs font-semibold bg-sky-500 text-sky-950 hover:bg-sky-400 transition-all shadow-md shadow-sky-900/30 flex items-center gap-1"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">open_in_full</span>
|
|
<span>Open Tracker</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stepper Progress Bar */}
|
|
<div className="space-y-2 pt-2 border-t border-sky-500/20">
|
|
<div className="flex items-center justify-between text-xs font-mono text-sky-300/80 mb-1">
|
|
<span>Phase {currentPhaseIndex + 1} of {phases.length}: {phases[currentPhaseIndex]?.name}</span>
|
|
<span>{Math.round(((currentPhaseIndex + 1) / phases.length) * 100)}%</span>
|
|
</div>
|
|
<div className="h-2 w-full bg-sky-950/60 rounded-full overflow-hidden border border-sky-500/30">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-sky-500 to-cyan-400 transition-all duration-500 ease-out"
|
|
style={{ width: `${Math.max(10, ((currentPhaseIndex + 1) / phases.length) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Floating Docked HUD Widget (Bottom-Right) */}
|
|
{isMinimized && (inFlightAction || activeAction || completedAction) && (
|
|
<div className="fixed bottom-6 right-6 z-50 animate-in slide-in-from-bottom duration-200">
|
|
<div
|
|
className={`p-3.5 pr-4 rounded-2xl border shadow-2xl backdrop-blur-md flex items-center gap-3 ${
|
|
completedAction?.state === 'succeeded'
|
|
? 'bg-surface-container border-emerald-500/40 text-emerald-200 shadow-emerald-950/40'
|
|
: completedAction
|
|
? 'bg-surface-container border-rose-500/40 text-rose-200 shadow-rose-950/40'
|
|
: 'bg-surface-container border-sky-500/40 text-sky-200 shadow-sky-950/40'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
{completedAction ? (
|
|
<span
|
|
className={`material-symbols-outlined text-xl ${
|
|
completedAction.state === 'succeeded' ? 'text-emerald-400' : 'text-rose-400'
|
|
}`}
|
|
>
|
|
{completedAction.state === 'succeeded' ? 'check_circle' : 'error'}
|
|
</span>
|
|
) : (
|
|
<span className="material-symbols-outlined text-xl text-sky-400 animate-spin">
|
|
progress_activity
|
|
</span>
|
|
)}
|
|
<div>
|
|
<div className="text-xs font-bold text-on-surface flex items-center gap-1.5 font-mono">
|
|
<span>{ACTION_CONFIGS[currentActionType]?.title || currentActionType}</span>
|
|
{completedAction && (
|
|
<span className="text-[10px] font-mono uppercase px-1 rounded bg-surface-container-highest">
|
|
{completedAction.state}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-[11px] text-outline font-mono">
|
|
{completedAction
|
|
? completedAction.deployed_revision
|
|
? `Rev: ${completedAction.deployed_revision.substring(0, 8)}`
|
|
: completedAction.status_message
|
|
: `Running (${formatElapsed(elapsedSeconds)})`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 pl-2 border-l border-outline-variant/20">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsModalOpen(true);
|
|
setIsMinimized(false);
|
|
}}
|
|
className="p-1.5 rounded-lg text-on-surface hover:bg-surface-container-highest transition-colors"
|
|
title="Expand progress tracker"
|
|
>
|
|
<span className="material-symbols-outlined text-base">expand_content</span>
|
|
</button>
|
|
{completedAction && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsMinimized(false);
|
|
setCompletedAction(null);
|
|
}}
|
|
className="p-1.5 rounded-lg text-outline hover:text-on-surface transition-colors"
|
|
title="Dismiss"
|
|
>
|
|
<span className="material-symbols-outlined text-base">close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Role Notice for Operators */}
|
|
{!isOwner && (
|
|
<div className="p-3 rounded-lg border border-amber-500/20 bg-amber-500/5 text-amber-300 text-xs flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-sm">lock</span>
|
|
<span>
|
|
Owner role required to execute operational actions. Operator accounts have read-only visibility into action status and history.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Execution Boundary / Helper Status Notice */}
|
|
{capabilities && !capabilities.helper_configured && (
|
|
<div className="p-3 rounded-lg border border-rose-500/20 bg-rose-500/5 text-rose-300 text-xs flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-sm">warning</span>
|
|
<span>
|
|
Action execution boundary helper is unconfigured or not executable on this host. Mutating actions will be rejected by backend policy.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Trigger Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{(Object.keys(ACTION_CONFIGS) as OpsActionType[]).map((actionKey) => {
|
|
const config = ACTION_CONFIGS[actionKey];
|
|
const isThisActive = (activeAction?.action_type === actionKey) || (inFlightAction?.actionType === actionKey);
|
|
const isDisabled = !isOwner || !!activeAction || !!inFlightAction;
|
|
|
|
return (
|
|
<div
|
|
key={actionKey}
|
|
className={`p-5 rounded-2xl border transition-all flex flex-col justify-between ${
|
|
isThisActive
|
|
? 'border-sky-500/50 bg-sky-950/10 shadow-lg shadow-sky-950/20'
|
|
: config.destructive
|
|
? 'border-rose-500/20 bg-surface-container/40 hover:border-rose-500/30'
|
|
: 'border-outline-variant/20 bg-surface-container/40 hover:border-outline-variant/40'
|
|
}`}
|
|
>
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2.5">
|
|
<div
|
|
className={`h-8 w-8 rounded-lg flex items-center justify-center ${
|
|
config.destructive
|
|
? 'bg-rose-500/10 text-rose-400'
|
|
: 'bg-primary-container/20 text-primary-container'
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-lg">{config.icon}</span>
|
|
</div>
|
|
<h4 className="font-semibold text-sm text-on-surface font-headline-sm">{config.title}</h4>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{isThisActive && (
|
|
<span className="text-[10px] font-mono font-bold uppercase px-2 py-0.5 rounded-full bg-sky-500/20 text-sky-400 border border-sky-500/30 animate-pulse flex items-center gap-1">
|
|
<span className="material-symbols-outlined text-[12px] animate-spin">progress_activity</span>
|
|
<span>Active</span>
|
|
</span>
|
|
)}
|
|
{config.destructive && (
|
|
<span className="text-[10px] font-mono uppercase px-1.5 py-0.5 rounded bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
|
Guarded
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-on-surface-variant leading-relaxed mb-4">
|
|
{config.description}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="pt-3 border-t border-outline-variant/10 flex items-center justify-between">
|
|
<span className="text-[11px] font-mono text-outline">
|
|
{actionKey}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenModal(actionKey)}
|
|
disabled={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-container text-on-primary-container hover:opacity-90 shadow-sm'
|
|
}`}
|
|
>
|
|
{isThisActive ? (
|
|
<>
|
|
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
|
<span>Running ({formatElapsed(elapsedSeconds)})</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="material-symbols-outlined text-sm">play_arrow</span>
|
|
<span>Trigger</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Action History Section */}
|
|
<div className="p-5 rounded-2xl border border-outline-variant/20 bg-surface-container/30 shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-on-surface font-headline-sm">Operations Action History</h3>
|
|
<p className="text-xs text-on-surface-variant">
|
|
Audited system actions and deployments executed across privileged execution boundary.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void fetchActions()}
|
|
disabled={loading}
|
|
className="p-1.5 rounded-lg text-outline hover:text-on-surface hover:bg-surface-container-highest transition-colors"
|
|
title="Refresh action history"
|
|
>
|
|
<span className={`material-symbols-outlined text-base ${loading ? 'animate-spin' : ''}`}>
|
|
refresh
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{historyError && (
|
|
<div className="p-3 text-xs bg-error/10 text-error border border-error/20 rounded-lg mb-3">
|
|
{historyError}
|
|
</div>
|
|
)}
|
|
|
|
{actions.length === 0 && !loading && (
|
|
<div className="p-6 text-center text-xs text-outline font-mono">
|
|
No operations actions have been executed yet.
|
|
</div>
|
|
)}
|
|
|
|
{actions.length > 0 && (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-outline-variant/15 text-outline font-mono text-[11px]">
|
|
<th className="pb-2 font-normal">Timestamp</th>
|
|
<th className="pb-2 font-normal">Action</th>
|
|
<th className="pb-2 font-normal">Actor</th>
|
|
<th className="pb-2 font-normal">Status</th>
|
|
<th className="pb-2 font-normal">Details</th>
|
|
<th className="pb-2 font-normal">Revision</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-outline-variant/10 font-mono">
|
|
{actions.map((act) => (
|
|
<tr
|
|
key={act.id}
|
|
className={`hover:bg-surface-container-highest/20 transition-colors ${
|
|
act.id === recentBannerAction?.id ? 'bg-primary/5' : ''
|
|
}`}
|
|
>
|
|
<td className="py-2.5 text-on-surface-variant whitespace-nowrap pr-3">
|
|
{new Date(act.request_timestamp).toLocaleString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
})}
|
|
</td>
|
|
<td className="py-2.5 font-semibold text-on-surface pr-3">
|
|
{ACTION_CONFIGS[act.action_type]?.title || act.action_type}
|
|
</td>
|
|
<td className="py-2.5 text-on-surface-variant pr-3">
|
|
{act.actor_identity}
|
|
</td>
|
|
<td className="py-2.5 pr-3 whitespace-nowrap">
|
|
{getStateBadge(act.state)}
|
|
</td>
|
|
<td className="py-2.5 text-on-surface-variant max-w-xs truncate pr-3" title={act.status_message || ''}>
|
|
{act.status_message || (act.failure_code ? `Error: ${act.failure_code}` : '—')}
|
|
</td>
|
|
<td className="py-2.5 text-on-surface-variant font-mono whitespace-nowrap">
|
|
{act.deployed_revision ? act.deployed_revision.substring(0, 8) : '—'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Interactive Action Modal (Confirm -> Progress -> Completed) */}
|
|
{isModalOpen && modalActionType && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-md animate-in fade-in duration-150">
|
|
<div className="w-full max-w-xl bg-surface-container border border-outline-variant/30 rounded-3xl shadow-2xl overflow-hidden flex flex-col">
|
|
|
|
{/* Modal Header */}
|
|
<div className="flex items-center justify-between p-5 border-b border-outline-variant/15">
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className={`h-10 w-10 rounded-xl flex items-center justify-center ${
|
|
modalMode === 'completed'
|
|
? completedAction?.state === 'succeeded'
|
|
? 'bg-emerald-500/20 text-emerald-400'
|
|
: 'bg-rose-500/20 text-rose-400'
|
|
: modalMode === 'progress'
|
|
? 'bg-sky-500/20 text-sky-400'
|
|
: ACTION_CONFIGS[modalActionType].destructive
|
|
? 'bg-rose-500/20 text-rose-400'
|
|
: 'bg-primary-container/20 text-primary-container'
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-2xl">
|
|
{modalMode === 'completed'
|
|
? completedAction?.state === 'succeeded'
|
|
? 'check_circle'
|
|
: 'error'
|
|
: modalMode === 'progress'
|
|
? 'progress_activity'
|
|
: ACTION_CONFIGS[modalActionType].icon}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-bold text-base text-on-surface font-headline-sm">
|
|
{modalMode === 'completed'
|
|
? `${ACTION_CONFIGS[modalActionType].title}: Execution ${
|
|
completedAction?.state === 'succeeded' ? 'Succeeded' : 'Finished'
|
|
}`
|
|
: modalMode === 'progress'
|
|
? `Executing ${ACTION_CONFIGS[modalActionType].title}`
|
|
: `Confirm ${ACTION_CONFIGS[modalActionType].title}`}
|
|
</h3>
|
|
<p className="text-xs text-on-surface-variant font-mono">
|
|
{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'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
{modalMode === 'progress' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsMinimized(true);
|
|
setIsModalOpen(false);
|
|
}}
|
|
className="px-2.5 py-1 rounded-lg text-xs font-mono text-outline hover:text-on-surface hover:bg-surface-container-highest transition-colors flex items-center gap-1"
|
|
title="Minimize tracker"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">close_fullscreen</span>
|
|
<span>Minimize</span>
|
|
</button>
|
|
)}
|
|
{modalMode !== 'progress' && (
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseModal}
|
|
className="p-1 rounded-full text-outline hover:text-on-surface hover:bg-surface-container-highest transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-lg">close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal Body: MODE 1 - CONFIRMATION FORM */}
|
|
{modalMode === 'confirm' && (
|
|
<form onSubmit={handleSubmitAction} className="p-6 space-y-5">
|
|
{submitError && (
|
|
<div className="p-3 text-xs bg-error/10 text-error border border-error/20 rounded-xl flex items-start gap-2">
|
|
<span className="material-symbols-outlined text-sm shrink-0 mt-0.5">error</span>
|
|
<span>{submitError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{capabilities && !capabilities.helper_configured && (
|
|
<div className="p-3 text-xs bg-amber-500/10 text-amber-300 border border-amber-500/20 rounded-xl flex items-start gap-2">
|
|
<span className="material-symbols-outlined text-sm shrink-0 mt-0.5">warning</span>
|
|
<span>
|
|
Action execution boundary helper (<code className="font-mono">ops-action-helper.sh</code>) is not yet configured on this host. Actions will be rejected by backend policy.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-sm text-on-surface-variant leading-relaxed">
|
|
{ACTION_CONFIGS[modalActionType].description}
|
|
</p>
|
|
|
|
{ACTION_CONFIGS[modalActionType].requiredConfirmation && (
|
|
<div className="space-y-2 pt-2">
|
|
<label htmlFor="confirmation_text" className="block text-xs font-mono text-rose-300">
|
|
Type <span className="font-bold text-rose-400 select-all">"{ACTION_CONFIGS[modalActionType].requiredConfirmation}"</span> to authorize:
|
|
</label>
|
|
<input
|
|
id="confirmation_text"
|
|
type="text"
|
|
required
|
|
value={confirmationInput}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="pt-4 border-t border-outline-variant/10 flex items-center justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseModal}
|
|
disabled={isSubmitting}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-outline hover:text-on-surface transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={
|
|
isSubmitting ||
|
|
(capabilities !== null && !capabilities.helper_configured) ||
|
|
(Boolean(ACTION_CONFIGS[modalActionType].requiredConfirmation) &&
|
|
confirmationInput.trim() !==
|
|
ACTION_CONFIGS[modalActionType].requiredConfirmation)
|
|
}
|
|
className={`px-5 py-2.5 rounded-xl text-xs font-semibold transition-all flex items-center gap-2 ${
|
|
isSubmitting ||
|
|
(capabilities !== null && !capabilities.helper_configured) ||
|
|
(Boolean(ACTION_CONFIGS[modalActionType].requiredConfirmation) &&
|
|
confirmationInput.trim() !==
|
|
ACTION_CONFIGS[modalActionType].requiredConfirmation)
|
|
? 'bg-surface-container-highest text-outline cursor-not-allowed opacity-50'
|
|
: ACTION_CONFIGS[modalActionType].destructive
|
|
? 'bg-rose-600 text-white hover:bg-rose-500 shadow-lg shadow-rose-900/30'
|
|
: 'bg-primary-container text-on-primary-container hover:opacity-90 shadow-md'
|
|
}`}
|
|
>
|
|
{isSubmitting && (
|
|
<span className="material-symbols-outlined text-sm animate-spin">
|
|
progress_activity
|
|
</span>
|
|
)}
|
|
<span>Authorize & Execute</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Modal Body: MODE 2 - IN-PROGRESS TRACKER */}
|
|
{modalMode === 'progress' && (
|
|
<div className="p-6 space-y-6">
|
|
{/* Active Phase & Timer Header */}
|
|
<div className="p-4 rounded-2xl bg-sky-950/20 border border-sky-500/30 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<span className="material-symbols-outlined text-2xl text-sky-400 animate-spin">
|
|
progress_activity
|
|
</span>
|
|
<div>
|
|
<div className="text-xs font-mono font-semibold text-sky-200 uppercase tracking-wider">
|
|
Current Step: {phases[currentPhaseIndex]?.name}
|
|
</div>
|
|
<div className="text-xs text-sky-300/80 font-mono mt-0.5">
|
|
{activeAction?.status_message || phases[currentPhaseIndex]?.desc}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="px-3 py-1.5 rounded-xl bg-sky-500/10 border border-sky-500/30 font-mono text-xs text-sky-300 font-bold flex items-center gap-1.5">
|
|
<span className="material-symbols-outlined text-sm">timer</span>
|
|
<span>{formatElapsed(elapsedSeconds)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex justify-between text-xs font-mono text-outline">
|
|
<span>Execution Pipeline</span>
|
|
<span>{Math.round(((currentPhaseIndex + 1) / phases.length) * 100)}%</span>
|
|
</div>
|
|
<div className="h-2 w-full bg-surface-container-highest rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-sky-500 via-primary-container to-cyan-400 transition-all duration-700 ease-out"
|
|
style={{ width: `${Math.max(10, ((currentPhaseIndex + 1) / phases.length) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stepper Pipeline */}
|
|
<div className="space-y-3 pt-2">
|
|
{phases.map((phase, idx) => {
|
|
const isCompleted = idx < currentPhaseIndex;
|
|
const isCurrent = idx === currentPhaseIndex;
|
|
const isUpcoming = idx > currentPhaseIndex;
|
|
|
|
return (
|
|
<div
|
|
key={phase.name}
|
|
className={`p-3 rounded-xl border flex items-center gap-3 transition-all ${
|
|
isCurrent
|
|
? 'border-sky-500/40 bg-sky-950/20 shadow-sm'
|
|
: isCompleted
|
|
? 'border-emerald-500/20 bg-emerald-950/10'
|
|
: 'border-outline-variant/10 bg-surface-container-lowest/40 opacity-60'
|
|
}`}
|
|
>
|
|
<div
|
|
className={`h-7 w-7 rounded-lg flex items-center justify-center font-mono text-xs font-bold shrink-0 ${
|
|
isCompleted
|
|
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
|
|
: isCurrent
|
|
? 'bg-sky-500/20 text-sky-400 border border-sky-500/30'
|
|
: 'bg-surface-container-highest text-outline'
|
|
}`}
|
|
>
|
|
{isCompleted ? (
|
|
<span className="material-symbols-outlined text-[16px]">check</span>
|
|
) : isCurrent ? (
|
|
<span className="material-symbols-outlined text-[14px] animate-spin">
|
|
progress_activity
|
|
</span>
|
|
) : (
|
|
idx + 1
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div
|
|
className={`text-xs font-semibold font-mono ${
|
|
isCurrent
|
|
? 'text-sky-200'
|
|
: isCompleted
|
|
? 'text-emerald-300'
|
|
: 'text-outline'
|
|
}`}
|
|
>
|
|
{phase.name}
|
|
</div>
|
|
<div className="text-[11px] text-on-surface-variant truncate">
|
|
{phase.desc}
|
|
</div>
|
|
</div>
|
|
{isCurrent && (
|
|
<span className="text-[10px] font-mono uppercase px-2 py-0.5 rounded-full bg-sky-500/20 text-sky-400 animate-pulse">
|
|
Processing
|
|
</span>
|
|
)}
|
|
{isCompleted && (
|
|
<span className="text-[10px] font-mono uppercase px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400">
|
|
Done
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Footer Controls */}
|
|
<div className="pt-4 border-t border-outline-variant/10 flex items-center justify-between">
|
|
<span className="text-xs text-outline font-mono">
|
|
Do not refresh this tab while execution is in progress.
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsMinimized(true);
|
|
setIsModalOpen(false);
|
|
}}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-surface-container-highest text-on-surface hover:bg-surface-container-high transition-colors"
|
|
>
|
|
Minimize Tracker
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal Body: MODE 3 - COMPLETION SUMMARY */}
|
|
{modalMode === 'completed' && completedAction && (
|
|
<div className="p-6 space-y-6">
|
|
{completedAction.state === 'succeeded' ? (
|
|
<div className="p-5 rounded-2xl bg-emerald-950/20 border border-emerald-500/30 text-emerald-200 space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-10 w-10 rounded-xl bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
|
|
<span className="material-symbols-outlined text-2xl">check_circle</span>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-bold text-base text-emerald-100 font-headline-sm">
|
|
{ACTION_CONFIGS[modalActionType].title} Succeeded!
|
|
</h4>
|
|
<p className="text-xs text-emerald-300/80 font-mono">
|
|
{completedAction.status_message || 'Action executed and verified across execution boundary.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-emerald-500/20 text-xs font-mono">
|
|
<div className="p-2.5 rounded-lg bg-surface-container-lowest/60 border border-emerald-500/15">
|
|
<span className="text-outline block text-[10px] uppercase">Execution Duration</span>
|
|
<span className="font-bold text-emerald-300">{formatElapsed(elapsedSeconds || 12)}</span>
|
|
</div>
|
|
<div className="p-2.5 rounded-lg bg-surface-container-lowest/60 border border-emerald-500/15">
|
|
<span className="text-outline block text-[10px] uppercase">Service Status</span>
|
|
<span className="font-bold text-emerald-300">Active & Verified (200 OK)</span>
|
|
</div>
|
|
</div>
|
|
|
|
{completedAction.deployed_revision && (
|
|
<div className="p-3 rounded-xl bg-surface-container-lowest/80 border border-emerald-500/20 flex items-center justify-between">
|
|
<div>
|
|
<span className="text-[10px] font-mono text-outline block uppercase">Deployed Git Revision</span>
|
|
<span className="font-mono text-xs font-bold text-on-surface">
|
|
{completedAction.deployed_revision}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleCopySha(completedAction.deployed_revision!)}
|
|
className="px-2.5 py-1 rounded-lg text-xs font-mono bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20 transition-colors flex items-center gap-1 border border-emerald-500/20"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">
|
|
{copiedSha ? 'check' : 'content_copy'}
|
|
</span>
|
|
<span>{copiedSha ? 'Copied' : 'Copy'}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="p-5 rounded-2xl bg-rose-950/20 border border-rose-500/30 text-rose-200 space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-10 w-10 rounded-xl bg-rose-500/20 border border-rose-500/30 flex items-center justify-center text-rose-400">
|
|
<span className="material-symbols-outlined text-2xl">error</span>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-bold text-base text-rose-100 font-headline-sm">
|
|
{ACTION_CONFIGS[modalActionType].title} Failed
|
|
</h4>
|
|
<p className="text-xs text-rose-300/80 font-mono">
|
|
{completedAction.status_message || 'Action encountered an error during execution.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-rose-500/20 text-xs font-mono">
|
|
<div className="p-2.5 rounded-lg bg-surface-container-lowest/60 border border-rose-500/15">
|
|
<span className="text-outline block text-[10px] uppercase">Failure Code</span>
|
|
<span className="font-bold text-rose-300">{completedAction.failure_code || 'unknown'}</span>
|
|
</div>
|
|
<div className="p-2.5 rounded-lg bg-surface-container-lowest/60 border border-rose-500/15">
|
|
<span className="text-outline block text-[10px] uppercase">Rollback State</span>
|
|
<span className="font-bold text-rose-300">{completedAction.rollback_status || 'none'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{completedAction.state === 'rolled_back' && (
|
|
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-300 text-xs flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-sm">undo</span>
|
|
<span>Automatic Rollback Succeeded: The previous stable release was restored safely.</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer Controls */}
|
|
<div className="pt-4 border-t border-outline-variant/10 flex items-center justify-end gap-3">
|
|
{completedAction.state === 'succeeded' && isDeploy && (
|
|
<a
|
|
href="https://motorsignal.app"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-surface-container-highest text-on-surface hover:bg-surface-container-high transition-colors flex items-center gap-1.5"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">open_in_new</span>
|
|
<span>Open Website</span>
|
|
</a>
|
|
)}
|
|
{completedAction.state !== 'succeeded' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setModalMode('confirm');
|
|
setSubmitError(null);
|
|
}}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-rose-500/10 text-rose-300 hover:bg-rose-500/20 border border-rose-500/30 transition-colors"
|
|
>
|
|
Retry Action
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsModalOpen(false);
|
|
setCompletedAction(null);
|
|
}}
|
|
className="px-5 py-2.5 rounded-xl text-xs font-semibold bg-primary-container text-on-primary-container hover:opacity-90 transition-all shadow-md"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|