mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Update backup feature based on PR feedback
- Simplify to database-only backups (remove data directory backup) - Update UI to match app styling patterns: - Use ActionIcon with transparent variant for table actions - Match icon/color conventions (SquareMinus/red.9, RotateCcw/yellow.5, Download/blue.5) - Use standard button bar layout with Paper/Box/Flex - Green "Create Backup" button matching "Add" pattern - Remove Card wrapper, Alert, and Divider for cleaner layout - Update to Mantine v8 Table syntax - Use standard ConfirmationDialog (remove unused color prop) - Update tests to remove get_data_dirs references
This commit is contained in:
parent
f1320c9a5d
commit
3f9fd424e2
3 changed files with 171 additions and 209 deletions
|
|
@ -20,12 +20,6 @@ def get_backup_dir() -> Path:
|
|||
return backup_dir
|
||||
|
||||
|
||||
def get_data_dirs() -> list[Path]:
|
||||
"""Get list of data directories to include in backups."""
|
||||
dirs = getattr(settings, "BACKUP_DATA_DIRS", [])
|
||||
return [Path(d) for d in dirs if d and Path(d).exists()]
|
||||
|
||||
|
||||
def _is_postgresql() -> bool:
|
||||
"""Check if we're using PostgreSQL."""
|
||||
return settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql"
|
||||
|
|
@ -223,14 +217,6 @@ def create_backup() -> Path:
|
|||
}
|
||||
zip_file.writestr("metadata.json", json.dumps(metadata, indent=2))
|
||||
|
||||
# Add data directories
|
||||
for data_dir in get_data_dirs():
|
||||
logger.debug(f"Adding directory: {data_dir}")
|
||||
for file_path in data_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
arcname = f"data/{data_dir.name}/{file_path.relative_to(data_dir)}"
|
||||
zip_file.write(file_path, arcname)
|
||||
|
||||
logger.info(f"Backup created successfully: {backup_file}")
|
||||
return backup_file
|
||||
|
||||
|
|
@ -264,33 +250,6 @@ def restore_backup(backup_file: Path) -> None:
|
|||
# Restore database
|
||||
_restore_database(temp_path, metadata)
|
||||
|
||||
# Restore data directories
|
||||
data_root = temp_path / "data"
|
||||
if data_root.exists():
|
||||
logger.info("Restoring data directories...")
|
||||
for extracted_dir in data_root.iterdir():
|
||||
if not extracted_dir.is_dir():
|
||||
continue
|
||||
|
||||
target_name = extracted_dir.name
|
||||
data_dirs = get_data_dirs()
|
||||
matching = [d for d in data_dirs if d.name == target_name]
|
||||
|
||||
if not matching:
|
||||
logger.warning(f"No configured directory for {target_name}, skipping")
|
||||
continue
|
||||
|
||||
target = matching[0]
|
||||
logger.debug(f"Restoring {target_name} to {target}")
|
||||
|
||||
# Create parent directory if needed
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Remove existing and copy from backup
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
shutil.copytree(extracted_dir, target)
|
||||
|
||||
logger.info("Restore completed successfully")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -20,14 +20,11 @@ class BackupServicesTestCase(TestCase):
|
|||
|
||||
def setUp(self):
|
||||
self.temp_backup_dir = tempfile.mkdtemp()
|
||||
self.temp_data_dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
if Path(self.temp_backup_dir).exists():
|
||||
shutil.rmtree(self.temp_backup_dir)
|
||||
if Path(self.temp_data_dir).exists():
|
||||
shutil.rmtree(self.temp_data_dir)
|
||||
|
||||
@patch('apps.backups.services.settings')
|
||||
def test_get_backup_dir_creates_directory(self, mock_settings):
|
||||
|
|
@ -42,31 +39,12 @@ class BackupServicesTestCase(TestCase):
|
|||
services.get_backup_dir()
|
||||
mock_path_instance.mkdir.assert_called_once_with(parents=True, exist_ok=True)
|
||||
|
||||
@patch('apps.backups.services.settings')
|
||||
def test_get_data_dirs_with_empty_config(self, mock_settings):
|
||||
"""Test that get_data_dirs returns empty list when no dirs configured"""
|
||||
mock_settings.BACKUP_DATA_DIRS = []
|
||||
result = services.get_data_dirs()
|
||||
self.assertEqual(result, [])
|
||||
|
||||
@patch('apps.backups.services.settings')
|
||||
def test_get_data_dirs_filters_nonexistent(self, mock_settings):
|
||||
"""Test that get_data_dirs filters out non-existent directories"""
|
||||
nonexistent_dir = '/tmp/does-not-exist-12345'
|
||||
mock_settings.BACKUP_DATA_DIRS = [self.temp_data_dir, nonexistent_dir]
|
||||
|
||||
result = services.get_data_dirs()
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(str(result[0]), self.temp_data_dir)
|
||||
|
||||
@patch('apps.backups.services.get_backup_dir')
|
||||
@patch('apps.backups.services.get_data_dirs')
|
||||
@patch('apps.backups.services._is_postgresql')
|
||||
@patch('apps.backups.services._dump_sqlite')
|
||||
def test_create_backup_success_sqlite(self, mock_dump_sqlite, mock_is_pg, mock_get_data_dirs, mock_get_backup_dir):
|
||||
def test_create_backup_success_sqlite(self, mock_dump_sqlite, mock_is_pg, mock_get_backup_dir):
|
||||
"""Test successful backup creation with SQLite"""
|
||||
mock_get_backup_dir.return_value = Path(self.temp_backup_dir)
|
||||
mock_get_data_dirs.return_value = []
|
||||
mock_is_pg.return_value = False
|
||||
|
||||
# Mock SQLite dump to create a temp file
|
||||
|
|
@ -94,13 +72,11 @@ class BackupServicesTestCase(TestCase):
|
|||
self.assertEqual(metadata['database_type'], 'sqlite')
|
||||
|
||||
@patch('apps.backups.services.get_backup_dir')
|
||||
@patch('apps.backups.services.get_data_dirs')
|
||||
@patch('apps.backups.services._is_postgresql')
|
||||
@patch('apps.backups.services._dump_postgresql')
|
||||
def test_create_backup_success_postgresql(self, mock_dump_pg, mock_is_pg, mock_get_data_dirs, mock_get_backup_dir):
|
||||
def test_create_backup_success_postgresql(self, mock_dump_pg, mock_is_pg, mock_get_backup_dir):
|
||||
"""Test successful backup creation with PostgreSQL"""
|
||||
mock_get_backup_dir.return_value = Path(self.temp_backup_dir)
|
||||
mock_get_data_dirs.return_value = []
|
||||
mock_is_pg.return_value = True
|
||||
|
||||
# Mock PostgreSQL dump to create a temp file
|
||||
|
|
@ -176,14 +152,12 @@ class BackupServicesTestCase(TestCase):
|
|||
services.delete_backup("nonexistent-backup.zip")
|
||||
|
||||
@patch('apps.backups.services.get_backup_dir')
|
||||
@patch('apps.backups.services.get_data_dirs')
|
||||
@patch('apps.backups.services._is_postgresql')
|
||||
@patch('apps.backups.services._restore_postgresql')
|
||||
def test_restore_backup_postgresql(self, mock_restore_pg, mock_is_pg, mock_get_data_dirs, mock_get_backup_dir):
|
||||
def test_restore_backup_postgresql(self, mock_restore_pg, mock_is_pg, mock_get_backup_dir):
|
||||
"""Test successful restoration of PostgreSQL backup"""
|
||||
backup_dir = Path(self.temp_backup_dir)
|
||||
mock_get_backup_dir.return_value = backup_dir
|
||||
mock_get_data_dirs.return_value = []
|
||||
mock_is_pg.return_value = True
|
||||
|
||||
# Create PostgreSQL backup file
|
||||
|
|
@ -201,14 +175,12 @@ class BackupServicesTestCase(TestCase):
|
|||
mock_restore_pg.assert_called_once()
|
||||
|
||||
@patch('apps.backups.services.get_backup_dir')
|
||||
@patch('apps.backups.services.get_data_dirs')
|
||||
@patch('apps.backups.services._is_postgresql')
|
||||
@patch('apps.backups.services._restore_sqlite')
|
||||
def test_restore_backup_sqlite(self, mock_restore_sqlite, mock_is_pg, mock_get_data_dirs, mock_get_backup_dir):
|
||||
def test_restore_backup_sqlite(self, mock_restore_sqlite, mock_is_pg, mock_get_backup_dir):
|
||||
"""Test successful restoration of SQLite backup"""
|
||||
backup_dir = Path(self.temp_backup_dir)
|
||||
mock_get_backup_dir.return_value = backup_dir
|
||||
mock_get_data_dirs.return_value = []
|
||||
mock_is_pg.return_value = False
|
||||
|
||||
# Create SQLite backup file
|
||||
|
|
@ -226,13 +198,11 @@ class BackupServicesTestCase(TestCase):
|
|||
mock_restore_sqlite.assert_called_once()
|
||||
|
||||
@patch('apps.backups.services.get_backup_dir')
|
||||
@patch('apps.backups.services.get_data_dirs')
|
||||
@patch('apps.backups.services._is_postgresql')
|
||||
def test_restore_backup_database_type_mismatch(self, mock_is_pg, mock_get_data_dirs, mock_get_backup_dir):
|
||||
def test_restore_backup_database_type_mismatch(self, mock_is_pg, mock_get_backup_dir):
|
||||
"""Test restore fails when database type doesn't match"""
|
||||
backup_dir = Path(self.temp_backup_dir)
|
||||
mock_get_backup_dir.return_value = backup_dir
|
||||
mock_get_data_dirs.return_value = []
|
||||
mock_is_pg.return_value = True # Current system is PostgreSQL
|
||||
|
||||
# Create SQLite backup file
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileInput,
|
||||
Flex,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
|
|
@ -19,12 +20,11 @@ import {
|
|||
} from '@mantine/core';
|
||||
import {
|
||||
Download,
|
||||
PlayCircle,
|
||||
RefreshCcw,
|
||||
RotateCcw,
|
||||
SquareMinus,
|
||||
SquarePlus,
|
||||
UploadCloud,
|
||||
Trash2,
|
||||
Clock,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
|
|
@ -312,20 +312,11 @@ export default function BackupManager() {
|
|||
};
|
||||
|
||||
return (
|
||||
<Stack spacing="md">
|
||||
<Alert color="blue" title="Backup Information">
|
||||
Backups include your database and configured data directories. Use the
|
||||
create button to generate a new backup, or upload an existing backup to
|
||||
restore.
|
||||
</Alert>
|
||||
|
||||
<Stack gap="md">
|
||||
{/* Schedule Settings */}
|
||||
<Card withBorder>
|
||||
<Group position="apart" mb="md">
|
||||
<Group>
|
||||
<Clock size={20} />
|
||||
<Text weight={600}>Scheduled Backups</Text>
|
||||
</Group>
|
||||
<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)}
|
||||
|
|
@ -337,7 +328,7 @@ export default function BackupManager() {
|
|||
<Loader size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<Group grow mb="md" align="flex-start">
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Frequency"
|
||||
value={schedule.frequency}
|
||||
|
|
@ -388,130 +379,174 @@ export default function BackupManager() {
|
|||
/>
|
||||
)}
|
||||
<NumberInput
|
||||
label="Keep Last N Backups"
|
||||
description="0 = keep all"
|
||||
inputWrapperOrder={['label', 'input', 'description', 'error']}
|
||||
label="Retention (0 = all)"
|
||||
value={schedule.retention_count}
|
||||
onChange={(value) => handleScheduleChange('retention_count', value || 0)}
|
||||
min={0}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
</Group>
|
||||
<Group position="right">
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
leftIcon={<Save size={16} />}
|
||||
onClick={handleSaveSchedule}
|
||||
loading={scheduleSaving}
|
||||
disabled={!scheduleChanged}
|
||||
variant="default"
|
||||
>
|
||||
Save Schedule
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
{/* 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>
|
||||
|
||||
<Group position="apart">
|
||||
<Text size="xl" weight={700}>
|
||||
Backups
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
leftIcon={<UploadCloud size={16} />}
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
<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)',
|
||||
}}
|
||||
>
|
||||
Upload Backup
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<RefreshCcw size={16} />}
|
||||
onClick={loadBackups}
|
||||
loading={loading}
|
||||
variant="light"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<PlayCircle size={16} />}
|
||||
onClick={handleCreateBackup}
|
||||
loading={creating}
|
||||
>
|
||||
Create Backup
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{loading ? (
|
||||
<Group position="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : backups.length === 0 ? (
|
||||
<Alert color="gray">No backups found. Create one to get started!</Alert>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backups.map((backup) => (
|
||||
<tr key={backup.name}>
|
||||
<td>
|
||||
<Text size="sm" weight={500}>
|
||||
{backup.name}
|
||||
</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="sm">{formatBytes(backup.size)}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="sm">{formatDate(backup.created)}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Group spacing="xs">
|
||||
<Tooltip label="Download">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => handleDownload(backup.name)}
|
||||
loading={downloading === backup.name}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Restore">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
onClick={() => handleRestoreClick(backup)}
|
||||
>
|
||||
<PlayCircle size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => handleDeleteClick(backup)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
)}
|
||||
{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>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Filename</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{backups.map((backup) => (
|
||||
<Table.Tr key={backup.name}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{backup.name}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{formatBytes(backup.size)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{formatDate(backup.created)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Tooltip label="Download">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="blue.5"
|
||||
onClick={() => handleDownload(backup.name)}
|
||||
loading={downloading === backup.name}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
<Download size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Restore">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="yellow.5"
|
||||
onClick={() => handleRestoreClick(backup)}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="red.9"
|
||||
onClick={() => handleDeleteClick(backup)}
|
||||
>
|
||||
<SquareMinus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={uploadModalOpen}
|
||||
|
|
@ -529,9 +564,9 @@ export default function BackupManager() {
|
|||
value={uploadFile}
|
||||
onChange={setUploadFile}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setUploadModalOpen(false);
|
||||
setUploadFile(null);
|
||||
|
|
@ -539,7 +574,7 @@ export default function BackupManager() {
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUploadSubmit} disabled={!uploadFile}>
|
||||
<Button onClick={handleUploadSubmit} disabled={!uploadFile} variant="default">
|
||||
Upload
|
||||
</Button>
|
||||
</Group>
|
||||
|
|
@ -557,7 +592,6 @@ export default function BackupManager() {
|
|||
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"
|
||||
color="orange"
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
|
|
@ -571,7 +605,6 @@ export default function BackupManager() {
|
|||
message={`Are you sure you want to delete "${selectedBackup?.name}"? This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
color="red"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue