mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
- Added functionality to pin/unpin table headers, maintaining visibility while scrolling. This feature is accessible via the channel table menu and UI Settings page, with persistence across sessions. - Refactored table preferences management by migrating `table-size` preference to a centralized `useTablePreferences` hook, enhancing maintainability and consistency across table components.
977 lines
29 KiB
JavaScript
977 lines
29 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);
|
|
const [restoring, setRestoring] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
// Read user's preferences from settings
|
|
const [timeFormat] = useLocalStorage('time-format', '12h');
|
|
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
|
|
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: 100,
|
|
},
|
|
],
|
|
[]
|
|
);
|
|
|
|
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 () => {
|
|
setDeleting(true);
|
|
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 {
|
|
setDeleting(false);
|
|
setDeleteConfirmOpen(false);
|
|
setSelectedBackup(null);
|
|
}
|
|
};
|
|
|
|
const handleRestoreClick = (backup) => {
|
|
setSelectedBackup(backup);
|
|
setRestoreConfirmOpen(true);
|
|
};
|
|
|
|
const handleRestoreConfirm = async () => {
|
|
setRestoring(true);
|
|
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 {
|
|
setRestoring(false);
|
|
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}
|
|
loading={restoring}
|
|
/>
|
|
|
|
<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}
|
|
loading={deleting}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|