diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 7e18348..55e6a86 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -9,6 +9,7 @@ import { Sidebar } from './Sidebar'; // import { UserAvatar } from './UserAvatar'; import { CreateSnapshotDialog } from './CreateSnapshotDialog'; import { LiveDataWarningBanner } from './LiveDataWarningBanner'; +import { RestoreStatusPanel } from './RestoreStatusPanel'; import { useAdminMode } from '../contexts/AdminModeContext'; import { ApiKeyErrorBanner } from './ApiKeyErrorBanner'; @@ -116,6 +117,7 @@ export function Layout({ children }: LayoutProps) { }} > {children} + diff --git a/src/components/RestoreStatusPanel.tsx b/src/components/RestoreStatusPanel.tsx new file mode 100644 index 0000000..ef88a68 --- /dev/null +++ b/src/components/RestoreStatusPanel.tsx @@ -0,0 +1,426 @@ +import { useState } from 'react'; +import { + Box, + Button, + Collapse, + IconButton, + LinearProgress, + Paper, + Stack, + Typography, +} from '@mui/material'; +import { + CheckCircleOutline, + ErrorOutline, + ExpandMore, + ExpandLess, + Close, + Restore as RestoreIcon, +} from '@mui/icons-material'; +import { useNavigate } from '@tanstack/react-router'; +import { useRestore } from '../contexts/RestoreContext'; + +export function RestoreStatusPanel() { + const [showFailures, setShowFailures] = useState(false); + const { restoreProgress, setPanelState, clearRestore } = useRestore(); + const navigate = useNavigate(); + + const { + status, + snapshotId, + snapshotTitle, + requestedPvCount, + progress, + message, + error, + panelState, + } = restoreProgress; + const failures = restoreProgress.failures ?? []; + + if (status === 'idle' || panelState === 'closed') { + return null; + } + + const isRunning = status === 'running'; + const isExpanded = panelState === 'expanded'; + const isWarning = status === 'warning'; + const isError = status === 'error'; + const isSuccess = status === 'success'; + + let headerTitle: string; + if (isRunning) { + headerTitle = 'Restoring Snapshot'; + } else if (isError) { + headerTitle = 'Restore Failed'; + } else { + headerTitle = 'Restore Complete'; + } + + const progressValue = typeof progress === 'number' ? Math.max(0, Math.min(100, progress)) : null; + + let detailText: string; + if (isRunning) { + detailText = progressValue !== null ? `${progressValue}% complete` : 'In progress'; + } else if (isError) { + detailText = 'Unable to complete restore'; + } else { + detailText = '100% complete'; + } + + let detailButtonLabel: string; + if (failures.length > 0) { + detailButtonLabel = showFailures ? 'Hide Failures' : `View ${failures.length} Failed PVs`; + } else { + detailButtonLabel = 'View Details'; + } + + const handleToggleCollapse = () => { + setPanelState(isExpanded ? 'collapsed' : 'expanded'); + }; + + const renderHeaderIcon = () => { + if (isRunning) { + return ; + } + if (isError || isWarning) { + return ; + } + return ; + }; + + const renderProgressSection = () => { + let completedBarColor: string; + if (isError) { + completedBarColor = '#c62828'; + } else if (isWarning) { + completedBarColor = '#b26a00'; + } else { + completedBarColor = '#2c3e50'; + } + + if (isRunning) { + return ( + <> + + {progressValue !== null ? ( + + ) : ( + + )} + + + {detailText} + + + ); + } + + return ( + <> + + + + + {detailText} + + + ); + }; + + const renderStatusCard = () => { + if (isError) { + return ( + + + + + + Restore failed + + + {error || 'An error occurred while restoring the snapshot.'} + + + + + ); + } + + if (isWarning) { + return ( + + + + + + Restore completed with issues + + + {message || 'Some PVs may have failed during restore.'} + + + + + ); + } + + if (isSuccess) { + return ( + + + + + + Restore successful + + + {message || 'All requested PVs were restored successfully.'} + + + + + ); + } + + return null; + }; + + return ( + theme.zIndex.drawer + 2, + borderRadius: 2, + overflow: 'hidden', + boxShadow: '0 10px 30px rgba(0, 0, 0, 0.18)', + backgroundColor: 'white', + }} + > + + + {renderHeaderIcon()} + + {headerTitle} + + + + + + {isExpanded ? : } + + + + + + + + + + + {snapshotTitle || 'Snapshot'} + + + + {isRunning && message ? message : `${requestedPvCount ?? 0} PVs`} + + + + {renderProgressSection()} + + + + {isRunning && ( + + + + You can navigate away + + { + ' - The restore will continue in the background. Progress is shown here as it updates.' + } + + + )} + + {!isRunning && renderStatusCard()} + + {!isRunning && ( + + + + + + )} + + {/* Failure details list */} + + + {failures.map((f, i) => ( + + + {f.pvName} + + + {f.error} + + + ))} + {failures.length >= 50 && ( + + Showing first 50 failures only. + + )} + + + + + + + ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index aed001b..bfb14c3 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -26,6 +26,7 @@ import { import { Link, useLocation, useNavigate } from '@tanstack/react-router'; import { useSnapshot } from '../contexts'; import { useAdminMode } from '../contexts/AdminModeContext'; +import { useRestore } from '../contexts/RestoreContext'; const SIDEBAR_WIDTH_EXPANDED = 240; const SIDEBAR_WIDTH_COLLAPSED = 60; @@ -67,6 +68,7 @@ export function Sidebar({ open, onToggle, onSaveSnapshot }: SidebarProps) { const location = useLocation(); const navigate = useNavigate(); const { snapshotProgress, clearSnapshot } = useSnapshot(); + const { restoreProgress, reopenPanel } = useRestore(); const { isAdminMode } = useAdminMode(); const menuItems = [ @@ -95,6 +97,54 @@ export function Sidebar({ open, onToggle, onSaveSnapshot }: SidebarProps) { const showStatus = snapshotProgress.isCreating || snapshotProgress.snapshotId || snapshotProgress.error; + const showRestoreStatus = restoreProgress.status !== 'idle'; + + const handleRestoreClick = () => { + // Always reopen panel when clicked + reopenPanel(); + }; + + const renderRestoreIcon = (): ReactElement => { + if (restoreProgress.status === 'running') { + if (restoreProgress.progress !== null) { + return ( + + + + ); + } + return ; + } + + if (restoreProgress.status === 'error') { + return ; + } + + if (restoreProgress.status === 'warning') { + return ; + } + + return ; + }; + + const getRestoreMessage = () => { + if (restoreProgress.status === 'running') { + return restoreProgress.message || 'Restoring snapshot...'; + } + if (restoreProgress.status === 'error') { + return 'Restore failed - Click to view'; + } + if (restoreProgress.status === 'warning') { + return 'Restore complete with issues - Click to view'; + } + return 'Restore complete'; + }; + const renderStatusIcon = (): ReactElement => { if (snapshotProgress.isCreating) { if (snapshotProgress.progress !== null) { @@ -302,6 +352,74 @@ export function Sidebar({ open, onToggle, onSaveSnapshot }: SidebarProps) { )} + {/* Restore Snapshot Status Indicator */} + {showRestoreStatus && ( + + + {renderRestoreIcon()} + {open && ( + + + {getRestoreMessage()} + + + {/* Snapshot title */} + {restoreProgress.snapshotTitle && ( + + {restoreProgress.snapshotTitle} + + )} + + {restoreProgress.status === 'running' && restoreProgress.progress !== null && ( + + {restoreProgress.progress}% complete + + )} + + )} + + + )} + {/* Save Snapshot Button */} ; + panelState: RestorePanelState; +} + +interface StartRestoreParams { + snapshotId: string; + snapshotTitle: string; + requestedPvCount: number; + pvIds?: string[]; +} + +interface RestoreContextType { + restoreProgress: RestoreProgress; + startRestore: (params: StartRestoreParams) => void; + clearRestore: () => void; + setPanelState: (state: RestorePanelState) => void; + reopenPanel: () => void; +} + +const initialRestoreProgress: RestoreProgress = { + status: 'idle', + isRestoring: false, + jobId: null, + snapshotId: null, + snapshotTitle: null, + requestedPvCount: null, + progress: null, + message: null, + error: null, + failures: [], + panelState: 'expanded', +}; + +const POLL_INTERVAL_MS = 1000; + +const RestoreContext = createContext(undefined); + +export function RestoreProvider({ children }: { children: ReactNode }) { + const [restoreProgress, setRestoreProgress] = useState(initialRestoreProgress); + + const pollIntervalRef = useRef(null); + + const stopPolling = useCallback(() => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + }, []); + + const pollJobStatus = useCallback( + (jobId: string, snapshotId: string, snapshotTitle: string, requestedPvCount: number) => { + const poll = async () => { + try { + const job = await jobService.getJobStatus(jobId); + + if (job.status === 'completed') { + stopPolling(); + + const failureMatch = job.message?.match(/\((\d+)\s+failed\)/i); + const failureCount = failureMatch ? Number(failureMatch[1]) : 0; + const resultData = job.jobData?.result; + const failures = resultData?.failures ?? []; + + setRestoreProgress((prev) => ({ + ...prev, + status: failureCount > 0 ? 'warning' : 'success', + isRestoring: false, + jobId, + snapshotId, + snapshotTitle, + requestedPvCount, + progress: 100, + error: null, + failures, + message: job.message || 'Restore completed', + })); + } else if (job.status === 'failed') { + stopPolling(); + + setRestoreProgress((prev) => ({ + ...prev, + status: 'error', + isRestoring: false, + jobId, + snapshotId, + snapshotTitle, + requestedPvCount, + progress: null, + error: job.error || 'Restore failed', + message: null, + })); + } else { + setRestoreProgress((prev) => ({ + ...prev, + status: 'running', + isRestoring: true, + jobId, + snapshotId, + snapshotTitle, + requestedPvCount, + progress: job.progress ?? null, + error: null, + message: job.message || 'Restoring snapshot...', + })); + } + } catch (err) { + stopPolling(); + + setRestoreProgress((prev) => ({ + ...prev, + status: 'error', + isRestoring: false, + jobId, + snapshotId, + snapshotTitle, + requestedPvCount, + progress: null, + error: err instanceof Error ? err.message : 'Failed to check restore job status', + message: null, + })); + } + }; + + poll(); + pollIntervalRef.current = window.setInterval(poll, POLL_INTERVAL_MS); + }, + [stopPolling] + ); + + const startRestore = useCallback( + (params: StartRestoreParams) => { + stopPolling(); + + setRestoreProgress({ + status: 'running', + isRestoring: true, + jobId: null, + snapshotId: params.snapshotId, + snapshotTitle: params.snapshotTitle, + requestedPvCount: params.requestedPvCount, + progress: 0, + error: null, + failures: [], + message: 'Starting restore...', + panelState: 'expanded', + }); + + const request = params.pvIds && params.pvIds.length > 0 ? { pvIds: params.pvIds } : undefined; + + snapshotService + .restoreSnapshotAsync(params.snapshotId, request) + .then((result) => { + setRestoreProgress((prev) => ({ + ...prev, + jobId: result.jobId, + message: result.message || 'Restore queued', + })); + + pollJobStatus( + result.jobId, + params.snapshotId, + params.snapshotTitle, + params.requestedPvCount + ); + }) + .catch((err) => { + setRestoreProgress((prev) => ({ + ...prev, + status: 'error', + isRestoring: false, + progress: null, + error: err instanceof Error ? err.message : 'Failed to start restore', + message: null, + })); + }); + }, + [pollJobStatus, stopPolling] + ); + + const clearRestore = useCallback(() => { + stopPolling(); + setRestoreProgress(initialRestoreProgress); + }, [stopPolling]); + + const setPanelState = useCallback((state: RestorePanelState) => { + setRestoreProgress((prev) => ({ + ...prev, + panelState: state, + })); + }, []); + + const reopenPanel = useCallback(() => { + setRestoreProgress((prev) => ({ + ...prev, + panelState: 'expanded', + })); + }, []); + + const value = useMemo( + () => ({ + restoreProgress, + startRestore, + clearRestore, + setPanelState, + reopenPanel, + }), + [restoreProgress, startRestore, clearRestore, setPanelState, reopenPanel] + ); + + return {children}; +} + +export function useRestore(): RestoreContextType { + const context = useContext(RestoreContext); + if (!context) { + throw new Error('useRestore must be used within a RestoreProvider'); + } + return context; +} diff --git a/src/main.tsx b/src/main.tsx index f379195..2f3c648 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@ta import { ApiKeyError } from './config/api'; import { HeartbeatProvider } from './contexts/HeartbeatContext'; import { LivePVProvider } from './contexts/LivePVContext'; +import { RestoreProvider } from './contexts/RestoreContext'; import { SnapshotProvider } from './contexts/SnapshotContext'; import { AdminModeProvider } from './contexts/AdminModeContext'; import { ApiKeyErrorProvider, useApiKeyError } from './contexts/ApiKeyErrorContext'; @@ -55,7 +56,9 @@ function App() { - + + + diff --git a/src/pages/SnapshotDetailsPage.tsx b/src/pages/SnapshotDetailsPage.tsx index b05d088..a13f401 100644 --- a/src/pages/SnapshotDetailsPage.tsx +++ b/src/pages/SnapshotDetailsPage.tsx @@ -25,7 +25,7 @@ import { SnapshotSummaryDTO } from '../types/api'; interface SnapshotDetailsPageProps { snapshot: Snapshot | null; onBack: () => void; - onRestore?: (pvs: PV[]) => void; + onRestore?: (pvs: PV[]) => Promise; onCompare?: (comparisonSnapshotId: string) => void; } @@ -127,10 +127,9 @@ export function SnapshotDetailsPage({ setShowRestoreDialog(true); }; - const confirmRestore = () => { - if (onRestore) { - onRestore(pvsToRestore); - } + const confirmRestore = async () => { + if (!onRestore) return; + await onRestore(pvsToRestore); setShowRestoreDialog(false); }; diff --git a/src/routes/snapshot-details.tsx b/src/routes/snapshot-details.tsx index 51543c0..8707407 100644 --- a/src/routes/snapshot-details.tsx +++ b/src/routes/snapshot-details.tsx @@ -6,6 +6,7 @@ import { SnapshotDetailsPage } from '../pages'; import { Snapshot, PV, Severity, Status } from '../types'; import { useSnapshot, snapshotKeys } from '../hooks'; import { SnapshotDTO, PVValueDTO } from '../types/api'; +import { useRestore } from '../contexts/RestoreContext'; // Map severity number from backend to Severity enum const mapSeverity = (severity?: number): Severity => { @@ -96,6 +97,7 @@ interface SearchParams { function SnapshotDetails() { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { startRestore } = useRestore(); // eslint-disable-next-line @typescript-eslint/no-use-before-define const { id } = Route.useSearch(); @@ -108,11 +110,6 @@ function SnapshotDetails() { navigate({ to: '/snapshots' }); }, [navigate, queryClient]); - const handleRestore = useCallback((pvs: PV[]) => { - // eslint-disable-next-line no-alert - alert(`Restoring ${pvs.length} PV(s) - This feature is not yet implemented`); - }, []); - const handleCompare = useCallback( (comparisonSnapshotId: string) => { navigate({ @@ -129,6 +126,23 @@ function SnapshotDetails() { return mapSnapshotDTOtoSnapshot(snapshotDTO); }, [snapshotDTO]); + const handleRestore = useCallback( + async (pvs: PV[]): Promise => { + if (!id || !snapshot) return; + + const pvIds = pvs.length > 0 ? pvs.map((pv) => pv.uuid) : undefined; + const requestedPvCount = pvIds?.length ?? snapshot.pvs.length; + + startRestore({ + snapshotId: snapshot.uuid, + snapshotTitle: snapshot.title, + requestedPvCount, + pvIds, + }); + }, + [id, snapshot, startRestore] + ); + if (!id) { return ( { + return apiClient.post( + `${API_CONFIG.endpoints.snapshots}/${snapshotId}/restore?async=true`, + request + ); + }, + /** * Delete a snapshot by ID */ diff --git a/src/types/api.ts b/src/types/api.ts index a748d64..7df6224 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -61,6 +61,16 @@ export interface SnapshotDTO extends SnapshotSummaryDTO { pvValues: PVValueDTO[]; } +export interface RestoreRequestDTO { + pvIds?: string[]; +} + +export interface RestoreFailureDTO { + pvId: string; + pvName: string; + error: string; +} + export interface EpicsValueDTO { // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any; @@ -177,6 +187,19 @@ export interface ApiKeyCreateResultDTO extends ApiKeyDTO { token: string; } +export interface RestoreFailureDetail { + pvId: string; + pvName: string; + error: string; +} + +export interface RestoreResultData { + total_pvs: number; + success_count: number; + failure_count: number; + failures: RestoreFailureDetail[]; +} + /** * Job DTOs for async task tracking */ @@ -188,6 +211,10 @@ export interface JobDTO { message?: string; resultId?: string; error?: string; + jobData?: { + result?: RestoreResultData; + [key: string]: unknown; + }; createdAt: string; startedAt?: string; completedAt?: string;