fix(backup): orthogonal code review fixes + UI/UX overhaul

## Critical fixes
- H1: Fix env_id/env/environment_id triple inconsistency in API route
  (_action_routes.py now passes 'environment_id', matches scheduler)
- H2: Decompose BackupPlugin.execute() — CC 13 → 5 methods, CC ≤ 5 each
- H3: Fix unhandled int() ValueError on non-numeric dashboard_ids
- H4: Add concurrent guard test with API-style params (env key fallback)
- H5: Make RetentionPolicy configurable via StorageConfig
  (retention_daily/weekly/monthly in config model + backend plugin)
- Fix: storage DELETE route missing 'await' on async delete_file (bug)

## UI/UX overhaul
- NEW: centralized cron utility (frontend/src/lib/utils/cron.ts)
  - validateCron(), calcNextCronRun(), formatNextRun()
  - Used by BackupManager, BackupDashboardModal
- BackupManager: replace custom BackupList with shared FileList
  - Adds: download, delete, bulk actions, search, sort, pagination
  - Adds: cron next-run preview below schedule input
  - Adds: auto-open TaskDrawer on backup task creation
- BackupDashboardModal: dead 'Cron Help' button removed
  - Adds: live cron validation + next-run preview
- StorageSettings: adds retention daily/weekly/monthly fields
- BackupCreateRequest type fixed to match actual API contract
- i18n: 7 new keys for en/ru (next_run, retention_*)
This commit is contained in:
2026-07-13 20:58:02 +03:00
parent 2819ca3a15
commit d630573402
20 changed files with 717 additions and 359 deletions

View File

@@ -734,6 +734,20 @@ export const api = {
getStorageFileBlob: (path: string) => fetchApiBlob(`/storage/file?path=${encodeURIComponent(path)}`),
// #endregion getStorageFileBlob
// #region getStorageFiles [C:2] [TYPE Function] [SEMANTICS storage,api,files,list]
// @BRIEF List files in a storage category, with optional subpath.
// @LAYER API
// @RELATION DEPENDS_ON -> [fetchApi]
// @DATA_CONTRACT params -> { category, path? }
// @DATA_CONTRACT response -> StoredFile[]
getStorageFiles: <T = unknown>(category: string, subpath?: string, options?: FetchOptions) => {
const query = subpath
? `/storage/files?category=${encodeURIComponent(category)}&path=${encodeURIComponent(subpath)}`
: `/storage/files?category=${encodeURIComponent(category)}`;
return fetchApi<T>(query, options);
},
// #endregion getStorageFiles
// ═══ Dashboards ═══════════════════════════════════════════════
// #region getDashboards [C:2] [TYPE Function] [SEMANTICS dashboards,api,list,pagination]

View File

@@ -1,225 +1,186 @@
<!-- #region BackupManager [C:3] [TYPE Component] [SEMANTICS backup, orchestrator, schedule, crud, manager] -->
<!-- @ingroup Components -->
<!-- @BRIEF Main container for backup management, handling creation and listing. -->
<!-- @BRIEF Main container for backup management: trigger, schedule, and file browser via shared FileList. -->
<!-- @LAYER UI -->
<!--
@SEMANTICS: backup, manager, orchestrator
@PURPOSE: Main container for backup management, handling creation and listing.
@LAYER Feature
@RELATION USES -> [BackupList]
@RELATION USES -> [EXT:frontend:api]
@RELATION USES -> [FileList]
@RELATION USES -> [storageService]
@RELATION CALLS -> [api]
@INVARIANT: Only one backup task can be triggered at a time from the UI.
@UX_FEEDBACK Error toast on backup creation failure (was missing — caught state hung forever).
@UX_FEEDBACK Error toast on data load failure (was missing — loading spinner stayed forever).
@UX_FEEDBACK Error toast on schedule update failure (was missing).
@UX_RECOVERY Request timeout (30s) via AbortSignal.timeout — prevents infinite spinner when backend is unreachable.
@UX_RECOVERY In-flight requests cancelled via AbortController onDestroy — prevents stale state after navigation.
@RATIONALE Fetch API has no default timeout. Without timeout, loadData() and handleCreateBackup() hang
indefinitely when backend is unreachable, leaving loading/creating=true forever. Added 30s
timeout via AbortSignal.timeout() + onDestroy cleanup + user-facing error toasts.
@REJECTED AbortController without timeout rejected — timeout is essential; abort-on-destroy alone
doesn't protect against backend that never responds.
@UX_FEEDBACK Error toasts on failure (creation, schedule update, data load).
@UX_RECOVERY Request timeout (30s) via AbortSignal; schedule optimistic update rollback.
@UX_RECOVERY Auto-opens TaskDrawer on backup task creation.
@RATIONALE FileList replaced custom BackupList — shared component provides download, delete,
bulk actions, search, sort, and pagination out of the box.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n/index.svelte.js';
import { log } from "$lib/cot-logger";
import { api, requestApi, API_REQUEST_TIMEOUT } from '$lib/api';
import { api, API_REQUEST_TIMEOUT } from '$lib/api';
import { addToast } from '$lib/toasts.svelte.js';
import { Button, Card, Select, Input } from '$lib/ui';
import BackupList from './BackupList.svelte';
import type { Backup } from '../../../types/backup';
// [/SECTION]
import { openDrawerForTaskIfPreferred } from '$lib/stores/taskDrawer.svelte.js';
import { Button, Card, Select, Input, ConfirmDialog } from '$lib/ui';
import { listFiles, deleteFile } from '../../../services/storageService';
import FileList from '$lib/components/storage/FileList.svelte';
import { appTimezone } from '$lib/stores/timezone.svelte.js';
// [SECTION: STATE]
let backups: Backup[] = $state([]);
// ── State ───────────────────────────────────────────────────────
let files: any[] = $state([]);
let environments: any[] = $state([]);
let selectedEnvId = $state('');
let loading = $state(true);
let creating = $state(false);
let savingSchedule = $state(false);
let currentPath = $state('backups');
let cronNextRun = $state<string | null>(null);
let cronError = $state('');
// Schedule state for selected environment
// Schedule state
let scheduleEnabled = $state(false);
let cronExpression = $state('0 0 * * *');
let selectedEnv = $derived(
environments.find((environment) => environment.id === selectedEnvId),
environments.find((e) => e.id === selectedEnvId),
);
$effect(() => {
if (!selectedEnv) return;
scheduleEnabled = selectedEnv.backup_schedule?.enabled ?? false;
cronExpression =
selectedEnv.backup_schedule?.cron_expression ?? '0 0 * * *';
cronExpression = selectedEnv.backup_schedule?.cron_expression ?? '0 0 * * *';
updateCronPreview();
});
// ── Lifecycle & request cancellation ───────────────────────────
// Delete confirmation state
let showDeleteConfirm = $state(false);
let deletePayload = $state<{ category: string; path: string; name: string } | null>(null);
// ── Lifecycle ───────────────────────────────────────────────────
let abortController = new AbortController();
onDestroy(() => { abortController.abort(); abortController = new AbortController(); });
onDestroy(() => {
abortController.abort();
abortController = new AbortController(); // allow re-creation if needed
});
// [/SECTION]
// #region loadData:Function [TYPE Function]
// @ingroup Components
/**
* @purpose Loads backups and environments from the backend.
*
* @pre API must be reachable.
* @post environments and backups stores are populated.
*
* @returns {Promise<void>}
* @side_effect Updates local state variables.
*/
// @RELATION CALLS -> api.getEnvironmentsList
// @RELATION CALLS -> api.requestApi
// ── Helpers ─────────────────────────────────────────────────────
function getSubpath(path: string): string {
if (!path || path === 'backups') return '';
return path.replace(/^backups\/?/, '');
}
function normalizeBackupsPath(path: string): string {
function normalizePath(path: string): string {
const trimmed = (path || '').trim().replace(/^\/+|\/+$/g, '');
if (!trimmed) return 'backups';
return trimmed.startsWith('backups') ? trimmed : `backups/${trimmed}`;
}
// ── Cron next-run preview ────────────────────────────────────
// Uses centralized cron utilities from $lib/utils/cron.ts
import { validateCron, formatNextRun } from '$lib/utils/cron';
$effect(() => {
if (scheduleEnabled && cronExpression) updateCronPreview();
});
function updateCronPreview() {
cronNextRun = null;
cronError = '';
if (!cronExpression) return;
const err = validateCron(cronExpression);
if (err) { cronError = err; return; }
const formatted = formatNextRun(cronExpression, appTimezone.current);
if (formatted) cronNextRun = formatted;
}
// ── Data loading ────────────────────────────────────────────────
async function loadData() {
log("BackupManager", "REASON", "Loading data");
loading = true;
try {
const subpath = getSubpath(currentPath);
const filesUrl = subpath
? `/storage/files?category=backups&path=${encodeURIComponent(subpath)}`
: '/storage/files?category=backups';
const signal = AbortSignal.timeout(API_REQUEST_TIMEOUT);
const [envsData, storageData] = await Promise.all([
api.getEnvironmentsList({ signal }),
requestApi(filesUrl, 'GET', null, { signal })
listFiles('backups', subpath || undefined),
]);
environments = envsData;
// Pre-fill with active environment from global context
if (!selectedEnvId) {
const { get } = await import('svelte/store');
const envCtx = await import('$lib/stores/environmentContext.svelte.js');
const state = get(envCtx.environmentContextStore);
if (state.selectedEnvId && environments.some((env) => env.id === state.selectedEnvId)) {
selectedEnvId = state.selectedEnvId;
}
try {
const { get } = await import('svelte/store');
const envCtx = await import('$lib/stores/environmentContext.svelte.js');
const state = get(envCtx.environmentContextStore);
if (state.selectedEnvId && environments.some((env) => env.id === state.selectedEnvId)) {
selectedEnvId = state.selectedEnvId;
}
} catch { /* env context not available */ }
}
backups = (storageData || []).map((file: any) => ({
id: file.path,
name: file.name,
path: file.path,
environment: file.path.split('/')[1] || $t.common?.unknown,
created_at: file.created_at,
size_bytes: file.size,
is_directory: file.mime_type === 'directory',
status: 'success'
}));
log("BackupManager", "REFLECT", "Data loaded successfully");
} catch (error) {
log("BackupManager", "EXPLORE", "Load failed", {}, error instanceof Error ? error.message : "Unknown");
files = storageData || [];
log("BackupManager", "REFLECT", "Data loaded", { count: files.length });
} catch (error: any) {
log("BackupManager", "EXPLORE", "Load failed", {}, error?.message || 'Unknown');
if (error instanceof DOMException && error.name === 'AbortError') {
addToast('Request timed out', 'error');
} else {
const errMsg = error instanceof Error ? error.message : $t.common.error;
addToast(errMsg, 'error');
addToast(error?.message || $t.common.error, 'error');
}
} finally {
loading = false;
}
}
// #endregion loadData:Function
// #region handleCreateBackup:Function [TYPE Function]
// @ingroup Components
/**
* @purpose Triggers a new backup task for the selected environment.
*
* @pre selectedEnvId must be a valid environment ID.
* @post A new task is created on the backend.
*
* @returns {Promise<void>}
* @side_effect Dispatches a toast notification.
*/
// @RELATION CALLS -> api.createTask
// #region handleUpdateSchedule:Function [TYPE Function]
// @ingroup Components
/**
* @purpose Updates the backup schedule for the selected environment.
* @pre selectedEnvId must be set.
* @post Environment config is updated on the backend.
*/
async function handleUpdateSchedule() {
if (!selectedEnvId) return;
log("BackupManager", "REASON", "Updating schedule for env", { selectedEnvId });
savingSchedule = true;
try {
await api.updateEnvironmentSchedule(selectedEnvId, {
enabled: scheduleEnabled,
cron_expression: cronExpression
});
addToast($t.common.success, 'success');
// Update local state
environments = environments.map(e =>
e.id === selectedEnvId
? { ...e, backup_schedule: { enabled: scheduleEnabled, cron_expression: cronExpression } }
: e
);
} catch (error) {
log("BackupManager", "EXPLORE", "Schedule update failed", {}, error instanceof Error ? error.message : "Unknown");
addToast($t.common.error, 'error');
} finally {
savingSchedule = false;
}
}
// #endregion handleUpdateSchedule:Function
// ── Backup trigger ──────────────────────────────────────────────
async function handleCreateBackup() {
if (!selectedEnvId) {
addToast($t.tasks.select_env, 'error');
return;
}
log("BackupManager", "REASON", "Triggering backup for env", { selectedEnvId });
if (!selectedEnvId) { addToast($t.tasks.select_env, 'error'); return; }
log("BackupManager", "REASON", "Triggering backup", { selectedEnvId });
creating = true;
try {
await api.createTask('superset-backup', { environment_id: selectedEnvId },
const response = await api.createTask('superset-backup', { environment_id: selectedEnvId },
{ signal: AbortSignal.timeout(API_REQUEST_TIMEOUT) }
);
addToast($t.common.success, 'success');
log("BackupManager", "REFLECT", "Backup task triggered");
} catch (error) {
log("BackupManager", "EXPLORE", "Create backup failed", {}, error instanceof Error ? error.message : "Unknown");
// Auto-open task drawer
const taskId = typeof response === 'object' && response?.id
? String(response.id) : String(response);
if (taskId && taskId !== 'undefined') openDrawerForTaskIfPreferred(taskId);
} catch (error: any) {
log("BackupManager", "EXPLORE", "Create backup failed", {}, error?.message || 'Unknown');
if (error instanceof DOMException && error.name === 'AbortError') {
addToast('Backup request timed out', 'error');
} else {
const errMsg = error instanceof Error ? error.message : $t.common.error;
addToast(errMsg, 'error');
addToast(error?.message || $t.common.error, 'error');
}
} finally {
creating = false;
}
} finally { creating = false; }
}
// #endregion handleCreateBackup:Function
// ── Schedule update ─────────────────────────────────────────────
async function handleUpdateSchedule() {
if (!selectedEnvId) return;
log("BackupManager", "REASON", "Updating schedule", { selectedEnvId });
const prevEnv = environments.find(e => e.id === selectedEnvId);
const prevEnabled = prevEnv?.backup_schedule?.enabled ?? false;
const prevCron = prevEnv?.backup_schedule?.cron_expression ?? '0 0 * * *';
savingSchedule = true;
try {
environments = environments.map(e => e.id === selectedEnvId
? { ...e, backup_schedule: { enabled: scheduleEnabled, cron_expression: cronExpression } }
: e);
await api.updateEnvironmentSchedule(selectedEnvId, { enabled: scheduleEnabled, cron_expression: cronExpression });
addToast($t.common.success, 'success');
} catch (error: any) {
log("BackupManager", "EXPLORE", "Schedule update failed", {}, error?.message || 'Unknown');
environments = environments.map(e => e.id === selectedEnvId
? { ...e, backup_schedule: { enabled: prevEnabled, cron_expression: prevCron } }
: e);
scheduleEnabled = prevEnabled;
cronExpression = prevCron;
addToast($t.common.error, 'error');
} finally { savingSchedule = false; }
}
// ── Navigation ──────────────────────────────────────────────────
function handleNavigate(path: string) {
currentPath = normalizeBackupsPath(path);
currentPath = normalizePath(path);
loadData();
}
function handleNavigateUp() {
if (currentPath === 'backups') return;
const parts = currentPath.split('/');
@@ -228,10 +189,37 @@
loadData();
}
// ── Delete flow ─────────────────────────────────────────────────
function handleDelete(payload: { category: string; path: string; name: string }) {
deletePayload = payload;
showDeleteConfirm = true;
}
function handleBulkDelete(files: Array<{ category: string; path: string; name: string }>) {
if (files.length === 0) return;
deletePayload = files[0];
showDeleteConfirm = true;
(window as any).__bulkDeletePayloads = files;
}
async function onConfirmDelete() {
const payloads: Array<{ category: string; path: string; name: string }> =
(window as any).__bulkDeletePayloads?.length > 0
? (window as any).__bulkDeletePayloads
: (deletePayload ? [deletePayload] : []);
(window as any).__bulkDeletePayloads = [];
deletePayload = null;
let errors = 0;
for (const p of payloads) {
try { await deleteFile(p.category, p.path); } catch { errors++; }
}
if (errors === 0) addToast($t.common.success, 'success');
else addToast($t.common.error, 'error');
await loadData();
}
onMount(loadData);
</script>
<!-- [SECTION: TEMPLATE] -->
<!-- ── TEMPLATE ──────────────────────────────────────────────────── -->
<div class="space-y-6">
<Card title={$t.tasks.manual_backup}>
<div class="space-y-4">
@@ -242,15 +230,11 @@
bind:value={selectedEnvId}
options={[
{ value: '', label: $t.tasks.select_env },
...environments.map(e => ({ value: e.id, label: e.name }))
...environments.map(e => ({ value: e.id, label: e.name })),
]}
/>
</div>
<Button
variant="primary"
onclick={handleCreateBackup}
disabled={creating || !selectedEnvId}
>
<Button variant="primary" onclick={handleCreateBackup} disabled={creating || !selectedEnvId}>
{creating ? $t.common.loading : $t.tasks.start_backup}
</Button>
</div>
@@ -263,7 +247,6 @@
</svg>
{$t.tasks.backup_schedule}
</h3>
<div class="bg-surface-muted rounded-lg p-4 border border-border">
<div class="flex flex-col md:flex-row md:items-start gap-6">
<div class="pt-8">
@@ -283,16 +266,17 @@
bind:value={cronExpression}
disabled={!scheduleEnabled}
/>
<p class="text-xs text-text-muted italic">{$t.tasks.cron_hint}</p>
{#if scheduleEnabled && cronNextRun}
<p class="text-xs text-success">{$t.tasks.next_run || 'Next run'}: {cronNextRun}</p>
{:else if scheduleEnabled && cronError}
<p class="text-xs text-destructive">{cronError}</p>
{:else}
<p class="text-xs text-text-muted italic">{$t.tasks.cron_hint}</p>
{/if}
</div>
<div class="pt-8">
<Button
variant="secondary"
onclick={handleUpdateSchedule}
disabled={savingSchedule}
class="min-w-[100px]"
>
<Button variant="secondary" onclick={handleUpdateSchedule} disabled={savingSchedule} class="min-w-[100px]">
{#if savingSchedule}
<span class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
@@ -315,18 +299,26 @@
<div class="space-y-3">
<h2 class="text-lg font-semibold text-text">{$t.storage.backups}</h2>
{#if loading}
<div class="py-10 text-center text-text-muted">{$t.common.loading}</div>
{:else}
<BackupList
{backups}
{currentPath}
onNavigate={handleNavigate}
onNavigateUp={handleNavigateUp}
/>
{/if}
<FileList
{files}
{currentPath}
{loading}
ondelete={handleDelete}
onnavigate={handleNavigate}
onnavigateup={handleNavigateUp}
onbulkdelete={handleBulkDelete}
/>
</div>
</div>
<!-- [/SECTION] -->
<ConfirmDialog
bind:show={showDeleteConfirm}
title={$t.storage.messages?.delete_title || 'Delete file?'}
message={deletePayload ? ($t.storage.messages?.delete_confirm || 'Delete {name}?').replace('{name}', deletePayload.name) : ''}
variant="destructive"
confirmLabel={$t.storage.table?.delete || 'Delete'}
cancelLabel={$t.common?.cancel || 'Cancel'}
onConfirm={onConfirmDelete}
onCancel={() => { deletePayload = null; (window as any).__bulkDeletePayloads = []; }}
/>
<!-- #endregion BackupManager -->

View File

@@ -76,6 +76,7 @@
"cron_expression": "Cron expression",
"cron_help": "Help with cron syntax",
"cron_placeholder": "0 2 * * * (daily at 2 AM)",
"next_run": "Next: {time}",
"load_failed": "Failed to load dashboards",
"validation_start_failed": "Failed to start validation",
"unknown_error": "Unknown error",

View File

@@ -43,6 +43,11 @@
"logging": "Logging Configuration",
"logging_description": "Configure logging and task log levels.",
"storage_description": "Configure file storage paths and patterns.",
"retention_title": "Backup Retention",
"retention_description": "Number of daily, weekly, and monthly backup archives to keep per dashboard.",
"retention_daily": "Daily",
"retention_weekly": "Weekly",
"retention_monthly": "Monthly",
"synchronized_resources": "Synchronized Resources",
"save_storage_config": "Save Storage Config",
"save_success": "Settings saved",

View File

@@ -18,6 +18,7 @@
"schedule_enabled": "Enabled",
"cron_label": "Cron Expression",
"cron_hint": "e.g., 0 0 * * * for daily at midnight",
"next_run": "Next run",
"footer_text": "Task continues running in background",
"drawer": "Task drawer",
"close_drawer": "Close drawer",

View File

@@ -75,6 +75,7 @@
"cron_expression": "Cron-выражение",
"cron_help": "Помощь по синтаксису cron",
"cron_placeholder": "0 2 * * * (ежедневно в 02:00)",
"next_run": "След.: {time}",
"load_failed": "Не удалось загрузить дашборды",
"validation_start_failed": "Не удалось запустить проверку",
"unknown_error": "Неизвестная ошибка",

View File

@@ -42,6 +42,11 @@
"logging": "Настройка логирования",
"logging_description": "Настройка уровней логирования задач.",
"storage_description": "Настройка путей и шаблонов файлового хранилища.",
"retention_title": "Хранение бекапов",
"retention_description": "Количество ежедневных, еженедельных и ежемесячных архивов для каждого дашборда.",
"retention_daily": "Дневных",
"retention_weekly": "Недельных",
"retention_monthly": "Месячных",
"storage": "Хранилище",
"synchronized_resources": "Синхронизированные ресурсы",
"save_storage_config": "Сохранить настройки хранилища",

View File

@@ -18,6 +18,7 @@
"schedule_enabled": "Включено",
"cron_label": "Cron-выражение",
"cron_hint": "например, 0 0 * * * для ежедневного запуска в полночь",
"next_run": "След. запуск",
"footer_text": "Задача продолжает работать в фоновом режиме",
"drawer": "Панель задач",
"close_drawer": "Закрыть панель задач",

View File

@@ -0,0 +1,139 @@
// #region CronUtils [C:2] [TYPE Module] [SEMANTICS cron,schedule,parsing,validation]
// @BRIEF Centralized cron expression utilities — parse, validate, and preview next run.
// @RELATION USED_BY -> [BackupManager]
// @RELATION USED_BY -> [BackupDashboardModal]
// @RELATION USED_BY -> [ScheduleConfig]
// @RELATION USED_BY -> [EnvironmentsTab]
// @RELATION USED_BY -> [MigrationSettings]
// @DATA_CONTRACT Input: "5-field cron string" → Output: { valid: bool, nextRun: Date|null, error: string|null }
/**
* Parse a single cron field (star, star-slash-N, N, N-M, comma-sep) into allowed values.
* @throws Error on invalid syntax
*/
function parseCronField(field: string, min: number, max: number): number[] {
const results: number[] = [];
const parts = field.split(',');
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed) continue;
if (trimmed === '*') {
for (let i = min; i <= max; i++) results.push(i);
} else if (trimmed.startsWith('*/')) {
const step = parseInt(trimmed.slice(2), 10);
if (isNaN(step) || step <= 0) throw new Error(`Invalid step in "${trimmed}"`);
for (let i = min; i <= max; i += step) results.push(i);
} else if (trimmed.includes('-')) {
const [lo, hi] = trimmed.split('-').map(Number);
if (isNaN(lo) || isNaN(hi)) throw new Error(`Invalid range in "${trimmed}"`);
if (lo < min || hi > max) throw new Error(`Range ${trimmed} out of bounds (${min}-${max})`);
for (let i = lo; i <= hi; i++) results.push(i);
} else {
const n = parseInt(trimmed, 10);
if (isNaN(n)) throw new Error(`Invalid value "${trimmed}"`);
if (n < min || n > max) throw new Error(`Value ${n} out of bounds (${min}-${max})`);
results.push(n);
}
}
return [...new Set(results)].sort((a, b) => a - b);
}
/**
* Validate a 5-field cron expression.
* @param expr - A 5-field cron string (e.g. "0 0 * * *")
* @returns null if valid, or an error message string
*/
export function validateCron(expr: string): string | null {
const trimmed = (expr || '').trim();
if (!trimmed) return 'Cron expression is empty';
const fields = trimmed.split(/\s+/);
if (fields.length !== 5) return 'Cron must have exactly 5 fields: minute hour day month weekday';
const labels = ['minute (0-59)', 'hour (0-23)', 'day of month (1-31)', 'month (1-12)', 'day of week (0-7, 0/7=Sun)'];
const bounds: [number, number][] = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
for (let i = 0; i < 5; i++) {
try {
const parsed = parseCronField(fields[i], bounds[i][0], bounds[i][1]);
if (parsed.length === 0) return `Field ${i + 1} (${labels[i]}) has no valid values`;
} catch (e: any) {
return `Field ${i + 1} (${labels[i]}): ${e.message}`;
}
}
return null;
}
/**
* Calculate the next datetime a cron expression will fire, starting from now.
* Searches up to 2 years ahead. Uses standard cron semantics:
* - If both day-of-month and day-of-week are specified (non-*), a match on EITHER triggers.
* - Otherwise, the non-* field controls the day match.
* @param expr - 5-field cron expression
* @returns Date of next match, or null if none found within 2 years
*/
export function calcNextCronRun(expr: string): Date | null {
const trimmed = (expr || '').trim();
if (!trimmed) return null;
const fields = trimmed.split(/\s+/);
if (fields.length !== 5) return null;
let minutes: number[], hours: number[], doms: number[], months: number[], dows: number[];
try {
minutes = parseCronField(fields[0], 0, 59);
hours = parseCronField(fields[1], 0, 23);
doms = parseCronField(fields[2], 1, 31);
months = parseCronField(fields[3], 1, 12);
dows = parseCronField(fields[4], 0, 7);
} catch { return null; }
const useDom = fields[2] !== '*';
const useDow = fields[4] !== '*';
const now = new Date();
let cursor = new Date(now);
cursor.setSeconds(0, 0);
cursor.setMinutes(cursor.getMinutes() + 1);
// Search limit: 2 years ahead (catch cron expressions that fire rarely)
const limit = new Date(now);
limit.setFullYear(limit.getFullYear() + 2);
while (cursor <= limit) {
const month = cursor.getMonth() + 1;
const dom = cursor.getDate();
const dow = cursor.getDay();
const hour = cursor.getHours();
const min = cursor.getMinutes();
if (months.includes(month) && hours.includes(hour) && minutes.includes(min)) {
const domMatch = doms.includes(dom);
const dowMatch = dows.includes(dow);
const matched = (useDom && useDow) ? (domMatch || dowMatch)
: useDom ? domMatch
: useDow ? dowMatch
: true;
if (matched) return new Date(cursor);
}
cursor.setMinutes(cursor.getMinutes() + 1);
}
return null;
}
/**
* Format the next cron run as a locale-friendly string.
* Returns empty string if expression is invalid or no future match.
* @param expr - 5-field cron expression
* @param timezoneIANA - IANA timezone name (e.g. "Europe/Moscow"), defaults to system timezone
*/
export function formatNextRun(expr: string, timezoneIANA?: string): string {
const next = calcNextCronRun(expr);
if (!next) return '';
try {
return next.toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: timezoneIANA || undefined,
});
} catch {
// If timezoneIANA is invalid, fall back to locale
return next.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
}
// #endregion CronUtils

View File

@@ -1,17 +1,38 @@
<!-- #region BackupDashboardModal [C:3] [TYPE Component] [SEMANTICS dashboard,backup,modal] -->
<!-- @ingroup Routes -->
<!-- @BRIEF Modal for scheduling backup of selected dashboards. -->
<!-- @BRIEF Modal for scheduling backup of selected dashboards. Cron help replaced with live validation + next-run preview. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [DashboardHubModel] -->
<!-- @RELATION DEPENDS_ON -> [CronUtils] -->
<!-- @UX_FEEDBACK Cron validation error shown inline below input. -->
<!-- @UX_FEEDBACK Next-run preview shown when cron is valid. -->
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.js';
import { Button } from '$lib/ui';
import type { DashboardHubModel } from '$lib/models/DashboardHubModel.svelte';
import { validateCron, formatNextRun } from '$lib/utils/cron';
let { model, environments = [] }: {
model: DashboardHubModel;
environments: Array<{ id: string; name: string }>;
} = $props();
let cronValidation = $state('');
let cronNext = $state('');
$effect(() => {
if (model.backupSchedule && model.backupSchedule !== '') {
const err = validateCron(model.backupSchedule);
if (err) { cronValidation = err; cronNext = ''; }
else {
cronValidation = '';
const next = formatNextRun(model.backupSchedule);
cronNext = next ? $t.dashboard?.next_run?.replace('{time}', next) || `Next: ${next}` : '';
}
} else {
cronValidation = '';
cronNext = '';
}
});
</script>
{#if model.showBackupModal}
@@ -82,7 +103,13 @@
<div class="ml-6">
<label for="cron-expression" class="block text-xs text-text-muted mb-1">{$t.dashboard?.cron_expression}</label>
<input id="cron-expression" type="text" class="search-input w-full text-sm" placeholder={$t.dashboard?.cron_placeholder || '0 2 * * * (daily at 2 AM)'} bind:value={model.backupSchedule} />
<Button variant="ghost" size="sm" class="text-xs text-primary hover:underline mt-1">{$t.dashboard?.cron_help}</Button>
{#if cronValidation}
<p class="text-xs text-destructive mt-1">{cronValidation}</p>
{:else if cronNext}
<p class="text-xs text-success mt-1">{cronNext}</p>
{:else}
<p class="text-xs text-text-muted mt-1">{$t.tasks?.cron_hint || 'Standard cron format'}</p>
{/if}
</div>
{/if}
</div>

View File

@@ -61,6 +61,32 @@
</div>
</div>
<!-- Retention policy -->
<div class="border-t border-border pt-4 mt-4">
<h3 class="text-base font-semibold text-text mb-3">{$t.settings?.retention_title || 'Backup Retention'}</h3>
<p class="text-sm text-text-muted mb-4">{$t.settings?.retention_description || 'Number of daily, weekly, and monthly backup archives to keep per dashboard.'}</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="retention_daily" class="block text-sm font-medium text-text">{$t.settings?.retention_daily || 'Daily'}</label>
<input type="number" id="retention_daily" min="0" max="365"
bind:value={settings.storage.retention_daily}
class="mt-1 block w-full border border-border-strong rounded-md shadow-sm p-2" />
</div>
<div>
<label for="retention_weekly" class="block text-sm font-medium text-text">{$t.settings?.retention_weekly || 'Weekly'}</label>
<input type="number" id="retention_weekly" min="0" max="52"
bind:value={settings.storage.retention_weekly}
class="mt-1 block w-full border border-border-strong rounded-md shadow-sm p-2" />
</div>
<div>
<label for="retention_monthly" class="block text-sm font-medium text-text">{$t.settings?.retention_monthly || 'Monthly'}</label>
<input type="number" id="retention_monthly" min="0" max="120"
bind:value={settings.storage.retention_monthly}
class="mt-1 block w-full border border-border-strong rounded-md shadow-sm p-2" />
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
onclick={() => onSave()}

View File

@@ -1,11 +1,11 @@
// #region BackupTypes [C:1] [TYPE Module] [SEMANTICS backup]
// @BRIEF TypeScript interfaces for Backup Management UI.
// @DATA_CONTRACT [BackupPlugin.execute] Input -> BackupCreateRequest
// @DATA_CONTRACT [BackupPlugin.execute] Output -> BackupTaskResult
/**
* #region BackupTypes:Module [TYPE Function]
* @SEMANTICS: types, backup, interface
* @PURPOSE: Defines types and interfaces for the Backup Management UI.
* Represents a single backup file or directory as returned by the Storage API.
*/
export interface Backup {
id: string;
name: string;
@@ -17,11 +17,35 @@ export interface Backup {
status: 'success' | 'failed' | 'in_progress';
}
/**
* Payload for POST /api/dashboards/backup — matches BackupRequest Pydantic schema.
*/
export interface BackupCreateRequest {
environment_id: string;
env_id: string;
dashboard_ids: number[];
schedule?: string;
}
/**
* #endregion BackupTypes:Module
* Payload for creating a task via POST /api/tasks with plugin_id="superset-backup".
*/
export interface BackupTaskParams {
environment_id: string;
dashboard_ids?: number[];
schedule?: string;
}
/**
* Result shape returned by BackupPlugin.execute() — matches backup task result.
*/
export interface BackupTaskResult {
status: 'SUCCESS' | 'PARTIAL_SUCCESS' | 'NO_DASHBOARDS';
environment: string;
backup_root: string;
total_dashboards: number;
backed_up_dashboards: number;
failed_dashboards: number;
dashboards: Array<{ id: number; title: string; path: string }>;
failures: Array<{ id: number; title: string; error: string }>;
}
// #endregion BackupTypes