MotorSignal-App/website/components/ops/operations-actions.tsx

576 lines
22 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',
];
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);
// Modal State
const [modalActionType, setModalActionType] = useState<OpsActionType | null>(null);
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';
// 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();
setActions(data.actions || []);
setHistoryError(null);
// Check if there is an active action
const active = (data.actions || []).find((a) =>
ACTIVE_STATES.includes(a.state)
);
setActiveAction(active || 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)) {
setActiveAction(null);
void fetchActions();
if (onActionCompleted) onActionCompleted();
return;
} else {
setActiveAction(updated);
}
}
} catch {
// Non-blocking error
}
if (!isCancelled) {
pollTimeoutRef.current = setTimeout(() => {
void poll();
}, 2500);
}
};
pollTimeoutRef.current = setTimeout(() => {
void poll();
}, 2500);
}
return () => {
isCancelled = true;
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
}
};
}, [activeAction, fetchActions, onActionCompleted]);
const handleOpenModal = (actionType: OpsActionType) => {
if (!isOwner || activeAction) return;
setModalActionType(actionType);
setConfirmationInput('');
setSubmitError(null);
};
const handleCloseModal = () => {
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);
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,
idempotency_key: idempotencyKey,
}),
});
const data = await resp.json().catch(() => null);
if (resp.ok) {
setActiveAction(data);
handleCloseModal();
void fetchActions();
} else if (resp.status === 409) {
setSubmitError(data?.detail || 'Another operations action is currently active.');
} else if (resp.status === 403) {
setSubmitError(data?.detail || 'Owner role authority required.');
} else {
setSubmitError(data?.detail || 'Failed to trigger operations action.');
}
} catch {
setSubmitError('Network failure submitting action request.');
} finally {
setIsSubmitting(false);
}
};
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>
);
}
};
return (
<div className="space-y-6">
{/* Active Action Banner */}
{activeAction && (
<div className="p-4 rounded-xl border border-sky-500/30 bg-sky-950/20 text-sky-200">
<div className="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>
<h4 className="font-medium text-sm text-sky-100 flex items-center gap-2">
<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="text-right text-[11px] font-mono text-sky-300/70">
ID: {activeAction.id.substring(0, 8)}...
</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 isDisabled = !isOwner || !!activeAction;
return (
<div
key={actionKey}
className={`p-4 rounded-xl border transition-all ${
config.destructive
? 'border-rose-500/20 bg-surface-container/40'
: 'border-outline-variant/20 bg-surface-container/40'
} flex flex-col justify-between`}
>
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-lg ${
config.destructive ? 'text-rose-400' : 'text-primary'
}`}
>
{config.icon}
</span>
<h4 className="font-medium text-sm text-on-surface">{config.title}</h4>
</div>
{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>
<p className="text-xs text-on-surface-variant leading-relaxed mb-4">
{config.description}
</p>
</div>
<div className="pt-2 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 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
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'
}`}
>
<span className="material-symbols-outlined text-sm">play_arrow</span>
<span>Trigger</span>
</button>
</div>
</div>
);
})}
</div>
{/* Action History Section */}
<div className="p-4 rounded-xl border border-outline-variant/20 bg-surface-container/30">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-on-surface">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">
<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>
{/* Confirmation Modal */}
{modalActionType && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-150">
<div className="w-full max-w-lg bg-surface-container border border-outline-variant/30 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-outline-variant/15">
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-xl ${
ACTION_CONFIGS[modalActionType].destructive ? 'text-rose-400' : 'text-primary'
}`}
>
{ACTION_CONFIGS[modalActionType].icon}
</span>
<h3 className="font-semibold text-base text-on-surface">
Confirm {ACTION_CONFIGS[modalActionType].title}
</h3>
</div>
<button
type="button"
onClick={handleCloseModal}
disabled={isSubmitting}
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>
<form onSubmit={handleSubmitAction} className="p-5 space-y-4">
{submitError && (
<div className="p-3 text-xs bg-error/10 text-error border border-error/20 rounded-lg">
{submitError}
</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-lg 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-lg text-xs font-medium text-outline hover:text-on-surface transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={
isSubmitting ||
(Boolean(ACTION_CONFIGS[modalActionType].requiredConfirmation) &&
confirmationInput.trim() !==
ACTION_CONFIGS[modalActionType].requiredConfirmation)
}
className={`px-4 py-2 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
isSubmitting ||
(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-md shadow-rose-900/30'
: 'bg-primary text-on-primary hover:bg-primary/90 shadow-md shadow-primary/20'
}`}
>
{isSubmitting && (
<span className="material-symbols-outlined text-sm animate-spin">
progress_activity
</span>
)}
<span>Authorize & Execute</span>
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}