Dispatcharr/frontend/src/components/backups/BackupManager.jsx
Jim McBride 662c5ff89a Reorganize simple mode backup scheduler layout
- Row 1: Frequency, Day (if weekly), Hour, Minute, Period (if 12h)
- Row 2: Retention, Save button
- Use wrap=nowrap to keep time selectors on same row
2025-12-13 18:49:36 -06:00

902 lines
28 KiB
JavaScript

import { useEffect, useMemo, useState } from 'react';
import {
ActionIcon,
Box,
Button,
FileInput,
Flex,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Switch,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import {
Download,
RefreshCcw,
RotateCcw,
SquareMinus,
SquarePlus,
UploadCloud,
} from 'lucide-react';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import API from '../../api';
import ConfirmationDialog from '../ConfirmationDialog';
import useLocalStorage from '../../hooks/useLocalStorage';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from '../tables/CustomTable';
const RowActions = ({ row, handleDownload, handleRestoreClick, handleDeleteClick, downloading }) => {
return (
<Flex gap={4} wrap="nowrap">
<Tooltip label="Download">
<ActionIcon
variant="transparent"
size="sm"
color="blue.5"
onClick={() => handleDownload(row.original.name)}
loading={downloading === row.original.name}
disabled={downloading !== null}
>
<Download size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label="Restore">
<ActionIcon
variant="transparent"
size="sm"
color="yellow.5"
onClick={() => handleRestoreClick(row.original)}
>
<RotateCcw size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete">
<ActionIcon
variant="transparent"
size="sm"
color="red.9"
onClick={() => handleDeleteClick(row.original)}
>
<SquareMinus size={18} />
</ActionIcon>
</Tooltip>
</Flex>
);
};
// Convert 24h time string to 12h format with period
function to12Hour(time24) {
if (!time24) return { time: '12:00', period: 'AM' };
const [hours, minutes] = time24.split(':').map(Number);
const period = hours >= 12 ? 'PM' : 'AM';
const hours12 = hours % 12 || 12;
return {
time: `${hours12}:${String(minutes).padStart(2, '0')}`,
period,
};
}
// Convert 12h time + period to 24h format
function to24Hour(time12, period) {
if (!time12) return '00:00';
const [hours, minutes] = time12.split(':').map(Number);
let hours24 = hours;
if (period === 'PM' && hours !== 12) {
hours24 = hours + 12;
} else if (period === 'AM' && hours === 12) {
hours24 = 0;
}
return `${String(hours24).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
// Get default timezone (same as Settings page)
function getDefaultTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}
// Validate cron expression
function validateCronExpression(expression) {
if (!expression || expression.trim() === '') {
return { valid: false, error: 'Cron expression is required' };
}
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) {
return { valid: false, error: 'Cron expression must have exactly 5 parts: minute hour day month weekday' };
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Validate each part (allowing *, */N steps, ranges, lists, steps)
// Supports: *, */2, 5, 1-5, 1-5/2, 1,3,5, etc.
const cronPartRegex = /^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/;
if (!cronPartRegex.test(minute)) {
return { valid: false, error: 'Invalid minute field (0-59, *, or cron syntax)' };
}
if (!cronPartRegex.test(hour)) {
return { valid: false, error: 'Invalid hour field (0-23, *, or cron syntax)' };
}
if (!cronPartRegex.test(dayOfMonth)) {
return { valid: false, error: 'Invalid day field (1-31, *, or cron syntax)' };
}
if (!cronPartRegex.test(month)) {
return { valid: false, error: 'Invalid month field (1-12, *, or cron syntax)' };
}
if (!cronPartRegex.test(dayOfWeek)) {
return { valid: false, error: 'Invalid weekday field (0-6, *, or cron syntax)' };
}
// Additional range validation for numeric values
const validateRange = (value, min, max, name) => {
// Skip if it's * or contains special characters
if (value === '*' || value.includes('/') || value.includes('-') || value.includes(',')) {
return null;
}
const num = parseInt(value, 10);
if (isNaN(num) || num < min || num > max) {
return `${name} must be between ${min} and ${max}`;
}
return null;
};
const minuteError = validateRange(minute, 0, 59, 'Minute');
if (minuteError) return { valid: false, error: minuteError };
const hourError = validateRange(hour, 0, 23, 'Hour');
if (hourError) return { valid: false, error: hourError };
const dayError = validateRange(dayOfMonth, 1, 31, 'Day');
if (dayError) return { valid: false, error: dayError };
const monthError = validateRange(month, 1, 12, 'Month');
if (monthError) return { valid: false, error: monthError };
const weekdayError = validateRange(dayOfWeek, 0, 6, 'Weekday');
if (weekdayError) return { valid: false, error: weekdayError };
return { valid: true, error: null };
}
const DAYS_OF_WEEK = [
{ value: '0', label: 'Sunday' },
{ value: '1', label: 'Monday' },
{ value: '2', label: 'Tuesday' },
{ value: '3', label: 'Wednesday' },
{ value: '4', label: 'Thursday' },
{ value: '5', label: 'Friday' },
{ value: '6', label: 'Saturday' },
];
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}
export default function BackupManager() {
const [backups, setBackups] = useState([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [downloading, setDownloading] = useState(null);
const [uploadFile, setUploadFile] = useState(null);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [selectedBackup, setSelectedBackup] = useState(null);
// Read user's preferences from settings
const [timeFormat] = useLocalStorage('time-format', '12h');
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const [tableSize] = useLocalStorage('table-size', 'default');
const [userTimezone] = useLocalStorage('time-zone', getDefaultTimeZone());
const is12Hour = timeFormat === '12h';
// Format date according to user preferences
const formatDate = (dateString) => {
const date = dayjs(dateString);
const datePart = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
const timePart = is12Hour ? 'h:mm:ss A' : 'HH:mm:ss';
return date.format(`${datePart}, ${timePart}`);
};
// Warning suppression for confirmation dialogs
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
// Schedule state
const [schedule, setSchedule] = useState({
enabled: false,
frequency: 'daily',
time: '03:00',
day_of_week: 0,
retention_count: 0,
cron_expression: '',
});
const [scheduleLoading, setScheduleLoading] = useState(false);
const [scheduleSaving, setScheduleSaving] = useState(false);
const [scheduleChanged, setScheduleChanged] = useState(false);
const [advancedMode, setAdvancedMode] = useState(false);
const [cronError, setCronError] = useState(null);
// For 12-hour display mode
const [displayTime, setDisplayTime] = useState('3:00');
const [timePeriod, setTimePeriod] = useState('AM');
const columns = useMemo(
() => [
{
header: 'Filename',
accessorKey: 'name',
grow: true,
cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Size',
accessorKey: 'size',
size: 80,
cell: ({ cell }) => (
<Text size="sm">{formatBytes(cell.getValue())}</Text>
),
},
{
header: 'Created',
accessorKey: 'created',
minSize: 180,
cell: ({ cell }) => (
<Text size="sm" style={{ whiteSpace: 'nowrap' }}>
{formatDate(cell.getValue())}
</Text>
),
},
{
id: 'actions',
header: 'Actions',
size: tableSize === 'compact' ? 75 : 100,
},
],
[tableSize]
);
const renderHeaderCell = (header) => {
return (
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
);
};
const renderBodyCell = ({ cell, row }) => {
switch (cell.column.id) {
case 'actions':
return (
<RowActions
row={row}
handleDownload={handleDownload}
handleRestoreClick={handleRestoreClick}
handleDeleteClick={handleDeleteClick}
downloading={downloading}
/>
);
}
};
const table = useTable({
columns,
data: backups,
allRowIds: backups.map((b) => b.name),
bodyCellRenderFns: {
actions: renderBodyCell,
},
headerCellRenderFns: {
name: renderHeaderCell,
size: renderHeaderCell,
created: renderHeaderCell,
actions: renderHeaderCell,
},
});
const loadBackups = async () => {
setLoading(true);
try {
const backupList = await API.listBackups();
setBackups(backupList);
} catch (error) {
notifications.show({
title: 'Error',
message: error?.message || 'Failed to load backups',
color: 'red',
});
} finally {
setLoading(false);
}
};
const loadSchedule = async () => {
setScheduleLoading(true);
try {
const settings = await API.getBackupSchedule();
// Check if using cron expression (advanced mode)
if (settings.cron_expression) {
setAdvancedMode(true);
}
setSchedule(settings);
// Initialize 12-hour display values
const { time, period } = to12Hour(settings.time);
setDisplayTime(time);
setTimePeriod(period);
setScheduleChanged(false);
} catch (error) {
// Ignore errors on initial load - settings may not exist yet
} finally {
setScheduleLoading(false);
}
};
useEffect(() => {
loadBackups();
loadSchedule();
}, []);
// Validate cron expression when switching to advanced mode
useEffect(() => {
if (advancedMode && schedule.cron_expression) {
const validation = validateCronExpression(schedule.cron_expression);
setCronError(validation.valid ? null : validation.error);
} else {
setCronError(null);
}
}, [advancedMode, schedule.cron_expression]);
const handleScheduleChange = (field, value) => {
setSchedule((prev) => ({ ...prev, [field]: value }));
setScheduleChanged(true);
// Validate cron expression if in advanced mode
if (field === 'cron_expression' && advancedMode) {
const validation = validateCronExpression(value);
setCronError(validation.valid ? null : validation.error);
}
};
// Handle time changes in 12-hour mode
const handleTimeChange12h = (newTime, newPeriod) => {
const time = newTime ?? displayTime;
const period = newPeriod ?? timePeriod;
setDisplayTime(time);
setTimePeriod(period);
// Convert to 24h and update schedule
const time24 = to24Hour(time, period);
handleScheduleChange('time', time24);
};
// Handle time changes in 24-hour mode
const handleTimeChange24h = (value) => {
handleScheduleChange('time', value);
// Also update 12h display state in case user switches formats
const { time, period } = to12Hour(value);
setDisplayTime(time);
setTimePeriod(period);
};
const handleSaveSchedule = async () => {
setScheduleSaving(true);
try {
const scheduleToSave = advancedMode
? schedule
: { ...schedule, cron_expression: '' };
const updated = await API.updateBackupSchedule(scheduleToSave);
setSchedule(updated);
setScheduleChanged(false);
notifications.show({
title: 'Success',
message: 'Backup schedule saved',
color: 'green',
});
} catch (error) {
notifications.show({
title: 'Error',
message: error?.message || 'Failed to save schedule',
color: 'red',
});
} finally {
setScheduleSaving(false);
}
};
const handleCreateBackup = async () => {
setCreating(true);
try {
await API.createBackup();
notifications.show({
title: 'Success',
message: 'Backup created successfully',
color: 'green',
});
await loadBackups();
} catch (error) {
notifications.show({
title: 'Error',
message: error?.message || 'Failed to create backup',
color: 'red',
});
} finally {
setCreating(false);
}
};
const handleDownload = async (filename) => {
setDownloading(filename);
try {
await API.downloadBackup(filename);
notifications.show({
title: 'Download Started',
message: `Downloading ${filename}...`,
color: 'blue',
});
} catch (error) {
notifications.show({
title: 'Error',
message: error?.message || 'Failed to download backup',
color: 'red',
});
} finally {
setDownloading(null);
}
};
const handleDeleteClick = (backup) => {
setSelectedBackup(backup);
setDeleteConfirmOpen(true);
};
const handleDeleteConfirm = async () => {
try {
await API.deleteBackup(selectedBackup.name);
notifications.show({
title: 'Success',
message: 'Backup deleted successfully',
color: 'green',
});
await loadBackups();
} catch (error) {
notifications.show({
title: 'Error',
message: error?.message || 'Failed to delete backup',
color: 'red',
});
} finally {
setDeleteConfirmOpen(false);
setSelectedBackup(null);
}
};
const handleRestoreClick = (backup) => {
setSelectedBackup(backup);
setRestoreConfirmOpen(true);
};
const handleRestoreConfirm = async () => {
try {
await API.restoreBackup(selectedBackup.name);
notifications.show({
title: 'Success',
message: 'Backup restored successfully. You may need to refresh the page.',
color: 'green',
});
setTimeout(() => window.location.reload(), 2000);
} catch (error) {
notifications.show({
title: 'Error',
message: error?.message || 'Failed to restore backup',
color: 'red',
});
} finally {
setRestoreConfirmOpen(false);
setSelectedBackup(null);
}
};
const handleUploadSubmit = async () => {
if (!uploadFile) return;
try {
await API.uploadBackup(uploadFile);
notifications.show({
title: 'Success',
message: 'Backup uploaded successfully',
color: 'green',
});
setUploadModalOpen(false);
setUploadFile(null);
await loadBackups();
} catch (error) {
notifications.show({
title: 'Error',
message: error?.message || 'Failed to upload backup',
color: 'red',
});
}
};
return (
<Stack gap="md">
{/* Schedule Settings */}
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" fw={500}>Scheduled Backups</Text>
<Switch
checked={schedule.enabled}
onChange={(e) => handleScheduleChange('enabled', e.currentTarget.checked)}
label={schedule.enabled ? 'Enabled' : 'Disabled'}
/>
</Group>
<Group justify="space-between">
<Text size="sm" fw={500}>Advanced (Cron Expression)</Text>
<Switch
checked={advancedMode}
onChange={(e) => setAdvancedMode(e.currentTarget.checked)}
label={advancedMode ? 'Enabled' : 'Disabled'}
disabled={!schedule.enabled}
size="sm"
/>
</Group>
{scheduleLoading ? (
<Loader size="sm" />
) : (
<>
{advancedMode ? (
<>
<Stack gap="sm">
<TextInput
label="Cron Expression"
value={schedule.cron_expression}
onChange={(e) => handleScheduleChange('cron_expression', e.currentTarget.value)}
placeholder="0 3 * * *"
description="Format: minute hour day month weekday (e.g., '0 3 * * *' = 3:00 AM daily)"
disabled={!schedule.enabled}
error={cronError}
/>
<Text size="xs" c="dimmed">
Examples: <br />
<code>0 3 * * *</code> - Every day at 3:00 AM<br />
<code>0 2 * * 0</code> - Every Sunday at 2:00 AM<br />
<code>0 */6 * * *</code> - Every 6 hours<br />
<code>30 14 1 * *</code> - 1st of every month at 2:30 PM
</Text>
</Stack>
<Group grow align="flex-end">
<NumberInput
label="Retention"
description="0 = keep all"
value={schedule.retention_count}
onChange={(value) => handleScheduleChange('retention_count', value || 0)}
min={0}
disabled={!schedule.enabled}
/>
<Button
onClick={handleSaveSchedule}
loading={scheduleSaving}
disabled={!scheduleChanged || (advancedMode && cronError)}
variant="default"
>
Save
</Button>
</Group>
</>
) : (
<Stack gap="sm">
<Group align="flex-end" gap="xs" wrap="nowrap">
<Select
label="Frequency"
value={schedule.frequency}
onChange={(value) => handleScheduleChange('frequency', value)}
data={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
]}
disabled={!schedule.enabled}
/>
{schedule.frequency === 'weekly' && (
<Select
label="Day"
value={String(schedule.day_of_week)}
onChange={(value) => handleScheduleChange('day_of_week', parseInt(value, 10))}
data={DAYS_OF_WEEK}
disabled={!schedule.enabled}
/>
)}
{is12Hour ? (
<>
<Select
label="Hour"
value={displayTime ? displayTime.split(':')[0] : '12'}
onChange={(value) => {
const minute = displayTime ? displayTime.split(':')[1] : '00';
handleTimeChange12h(`${value}:${minute}`, null);
}}
data={Array.from({ length: 12 }, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Minute"
value={displayTime ? displayTime.split(':')[1] : '00'}
onChange={(value) => {
const hour = displayTime ? displayTime.split(':')[0] : '12';
handleTimeChange12h(`${hour}:${value}`, null);
}}
data={Array.from({ length: 60 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Period"
value={timePeriod}
onChange={(value) => handleTimeChange12h(null, value)}
data={[
{ value: 'AM', label: 'AM' },
{ value: 'PM', label: 'PM' },
]}
disabled={!schedule.enabled}
/>
</>
) : (
<>
<Select
label="Hour"
value={schedule.time ? schedule.time.split(':')[0] : '00'}
onChange={(value) => {
const minute = schedule.time ? schedule.time.split(':')[1] : '00';
handleTimeChange24h(`${value}:${minute}`);
}}
data={Array.from({ length: 24 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Minute"
value={schedule.time ? schedule.time.split(':')[1] : '00'}
onChange={(value) => {
const hour = schedule.time ? schedule.time.split(':')[0] : '00';
handleTimeChange24h(`${hour}:${value}`);
}}
data={Array.from({ length: 60 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
</>
)}
</Group>
<Group grow align="flex-end" gap="xs">
<NumberInput
label="Retention"
description="0 = keep all"
value={schedule.retention_count}
onChange={(value) => handleScheduleChange('retention_count', value || 0)}
min={0}
disabled={!schedule.enabled}
/>
<Button
onClick={handleSaveSchedule}
loading={scheduleSaving}
disabled={!scheduleChanged}
variant="default"
>
Save
</Button>
</Group>
</Stack>
)}
{/* Timezone info - only show in simple mode */}
{!advancedMode && schedule.enabled && schedule.time && (
<Text size="xs" c="dimmed" mt="xs">
System Timezone: {userTimezone} Backup will run at {schedule.time} {userTimezone}
</Text>
)}
</>
)}
</Stack>
{/* Backups List */}
<Stack gap={0}>
<Paper>
<Box
style={{
display: 'flex',
justifyContent: 'flex-end',
padding: 10,
}}
>
<Flex gap={6}>
<Tooltip label="Upload existing backup">
<Button
leftSection={<UploadCloud size={18} />}
variant="light"
size="xs"
onClick={() => setUploadModalOpen(true)}
p={5}
>
Upload
</Button>
</Tooltip>
<Tooltip label="Refresh list">
<Button
leftSection={<RefreshCcw size={18} />}
variant="light"
size="xs"
onClick={loadBackups}
loading={loading}
p={5}
>
Refresh
</Button>
</Tooltip>
<Tooltip label="Create new backup">
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={handleCreateBackup}
loading={creating}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Create Backup
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<Box
style={{
display: 'flex',
flexDirection: 'column',
maxHeight: 300,
width: '100%',
overflow: 'hidden',
}}
>
<Box
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'auto',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
>
{loading ? (
<Box p="xl" style={{ display: 'flex', justifyContent: 'center' }}>
<Loader />
</Box>
) : backups.length === 0 ? (
<Text size="sm" c="dimmed" p="md" ta="center">
No backups found. Create one to get started.
</Text>
) : (
<div style={{ minWidth: 500 }}>
<CustomTable table={table} />
</div>
)}
</Box>
</Box>
</Stack>
<Modal
opened={uploadModalOpen}
onClose={() => {
setUploadModalOpen(false);
setUploadFile(null);
}}
title="Upload Backup"
>
<Stack>
<FileInput
label="Select backup file"
placeholder="Choose a .zip file"
accept=".zip,application/zip,application/x-zip-compressed"
value={uploadFile}
onChange={setUploadFile}
/>
<Group justify="flex-end">
<Button
variant="outline"
onClick={() => {
setUploadModalOpen(false);
setUploadFile(null);
}}
>
Cancel
</Button>
<Button onClick={handleUploadSubmit} disabled={!uploadFile} variant="default">
Upload
</Button>
</Group>
</Stack>
</Modal>
<ConfirmationDialog
opened={restoreConfirmOpen}
onClose={() => {
setRestoreConfirmOpen(false);
setSelectedBackup(null);
}}
onConfirm={handleRestoreConfirm}
title="Restore Backup"
message={`Are you sure you want to restore from "${selectedBackup?.name}"? This will replace all current data with the backup data. This action cannot be undone.`}
confirmLabel="Restore"
cancelLabel="Cancel"
actionKey="restore-backup"
onSuppressChange={suppressWarning}
/>
<ConfirmationDialog
opened={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false);
setSelectedBackup(null);
}}
onConfirm={handleDeleteConfirm}
title="Delete Backup"
message={`Are you sure you want to delete "${selectedBackup?.name}"? This action cannot be undone.`}
confirmLabel="Delete"
cancelLabel="Cancel"
actionKey="delete-backup"
onSuppressChange={suppressWarning}
/>
</Stack>
);
}