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;