diff --git a/CHANGELOG.md b/CHANGELOG.md
index 60eb57cb..9d9102b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Backup restore loading feedback: Added visual loading indicator to the backup restore confirmation dialog. When clicking "Restore", the button now displays an animated dots loader and becomes disabled, providing clear feedback that the restore operation is in progress.
- Channel profile edit and duplicate functionality: Users can now rename existing channel profiles and create duplicates with automatic channel membership cloning. Each profile action (edit, duplicate, delete) in the profile dropdown for quick access.
- ProfileModal component extracted for improved code organization and maintainability of channel profile management operations.
- Frontend unit tests for pages and utilities: Added comprehensive unit test coverage for frontend components within pages/ and JS files within utils/, along with a GitHub Actions workflow (`frontend-tests.yml`) to automatically run tests on commits and pull requests - Thanks [@nick4810](https://github.com/nick4810)
diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx
index 73805513..94fb169c 100644
--- a/frontend/src/components/ConfirmationDialog.jsx
+++ b/frontend/src/components/ConfirmationDialog.jsx
@@ -16,6 +16,7 @@ import useWarningsStore from '../store/warnings';
* @param {string} props.actionKey - Unique key for this type of action (used for suppression)
* @param {Function} props.onSuppressChange - Called when "don't show again" option changes
* @param {string} [props.size='md'] - Size of the modal
+ * @param {boolean} [props.loading=false] - Whether the confirm button should show loading state
*/
const ConfirmationDialog = ({
opened,
@@ -31,6 +32,7 @@ const ConfirmationDialog = ({
zIndex = 1000,
showDeleteFileOption = false,
deleteFileLabel = 'Also delete files from disk',
+ loading = false,
}) => {
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
@@ -93,10 +95,16 @@ const ConfirmationDialog = ({
)}
-
diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx
index fed0dcfa..102c7254 100644
--- a/frontend/src/components/backups/BackupManager.jsx
+++ b/frontend/src/components/backups/BackupManager.jsx
@@ -34,7 +34,13 @@ import useLocalStorage from '../../hooks/useLocalStorage';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from '../tables/CustomTable';
-const RowActions = ({ row, handleDownload, handleRestoreClick, handleDeleteClick, downloading }) => {
+const RowActions = ({
+ row,
+ handleDownload,
+ handleRestoreClick,
+ handleDeleteClick,
+ downloading,
+}) => {
return (
@@ -98,7 +104,6 @@ function to24Hour(time12, period) {
return `${String(hours24).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
-
// Get default timezone (same as Settings page)
function getDefaultTimeZone() {
try {
@@ -116,35 +121,60 @@ function validateCronExpression(expression) {
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' };
+ 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+)?)*)$/;
+ 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)' };
+ 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)' };
+ 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)' };
+ 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)' };
+ 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)' };
+ 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(',')) {
+ if (
+ value === '*' ||
+ value.includes('/') ||
+ value.includes('-') ||
+ value.includes(',')
+ ) {
return null;
}
const num = parseInt(value, 10);
@@ -200,6 +230,7 @@ export default function BackupManager() {
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [selectedBackup, setSelectedBackup] = useState(null);
+ const [restoring, setRestoring] = useState(false);
// Read user's preferences from settings
const [timeFormat] = useLocalStorage('time-format', '12h');
@@ -508,11 +539,13 @@ export default function BackupManager() {
};
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.',
+ message:
+ 'Backup restored successfully. You may need to refresh the page.',
color: 'green',
});
setTimeout(() => window.location.reload(), 2000);
@@ -523,6 +556,7 @@ export default function BackupManager() {
color: 'red',
});
} finally {
+ setRestoring(false);
setRestoreConfirmOpen(false);
setSelectedBackup(null);
}
@@ -555,16 +589,22 @@ export default function BackupManager() {
{/* Schedule Settings */}
- Scheduled Backups
+
+ Scheduled Backups
+
handleScheduleChange('enabled', e.currentTarget.checked)}
+ onChange={(e) =>
+ handleScheduleChange('enabled', e.currentTarget.checked)
+ }
label={schedule.enabled ? 'Enabled' : 'Disabled'}
/>
- Advanced (Cron Expression)
+
+ Advanced (Cron Expression)
+
setAdvancedMode(e.currentTarget.checked)}
@@ -584,18 +624,24 @@ export default function BackupManager() {
handleScheduleChange('cron_expression', e.currentTarget.value)}
+ 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}
/>
- Examples:
- • 0 3 * * * - Every day at 3:00 AM
- • 0 2 * * 0 - Every Sunday at 2:00 AM
- • 0 */6 * * * - Every 6 hours
- • 30 14 1 * * - 1st of every month at 2:30 PM
+ Examples:
• 0 3 * * * - Every day at 3:00
+ AM
+
• 0 2 * * 0 - Every Sunday at 2:00 AM
+
• 0 */6 * * * - Every 6 hours
+
• 30 14 1 * * - 1st of every month at
+ 2:30 PM
@@ -603,7 +649,9 @@ export default function BackupManager() {
label="Retention"
description="0 = keep all"
value={schedule.retention_count}
- onChange={(value) => handleScheduleChange('retention_count', value || 0)}
+ onChange={(value) =>
+ handleScheduleChange('retention_count', value || 0)
+ }
min={0}
disabled={!schedule.enabled}
/>
@@ -623,7 +671,9 @@ export default function BackupManager() {
@@ -881,6 +952,7 @@ export default function BackupManager() {
cancelLabel="Cancel"
actionKey="restore-backup"
onSuppressChange={suppressWarning}
+ loading={restoring}
/>