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:
@@ -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]
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Неизвестная ошибка",
|
||||
|
||||
@@ -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": "Сохранить настройки хранилища",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"schedule_enabled": "Включено",
|
||||
"cron_label": "Cron-выражение",
|
||||
"cron_hint": "например, 0 0 * * * для ежедневного запуска в полночь",
|
||||
"next_run": "След. запуск",
|
||||
"footer_text": "Задача продолжает работать в фоновом режиме",
|
||||
"drawer": "Панель задач",
|
||||
"close_drawer": "Закрыть панель задач",
|
||||
|
||||
139
frontend/src/lib/utils/cron.ts
Normal file
139
frontend/src/lib/utils/cron.ts
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user