1.0.39
This commit is contained in:
16 files changed
+427
-259
No files matched your search
@@ -19,7 +19,7 @@
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import {
|
||||
Archive, Box, Layers, ChevronDown, ChevronRight, RefreshCw, Search, Play, Pause, Trash2, FolderOpen, RotateCcw,
|
||||
CheckCircle, XCircle, AlertCircle, Loader2, Clock, X, ArrowLeftRight, Package
|
||||
CheckCircle, XCircle, AlertCircle, Loader2, Clock, X, ArrowLeftRight, Package, Pencil
|
||||
} from 'lucide-svelte';
|
||||
import RotateCwFadingClock from '$lib/components/icons/RotateCwFadingClock.svelte';
|
||||
import { formatDateTime } from '$lib/stores/settings';
|
||||
@@ -31,6 +31,7 @@
|
||||
import BackupLogModal from './BackupLogModal.svelte';
|
||||
import SnapshotDiffModal from './SnapshotDiffModal.svelte';
|
||||
import CreateBackupModal from './CreateBackupModal.svelte';
|
||||
import EditBackupConfigModal from './EditBackupConfigModal.svelte';
|
||||
|
||||
interface BackupConfig {
|
||||
key: string;
|
||||
@@ -137,6 +138,9 @@
|
||||
let confirmDeleteConfig = $state<number | null>(null);
|
||||
let deletingConfig = $state<number | null>(null);
|
||||
let togglingConfig = $state<number | null>(null);
|
||||
// Edit-config modal (reuses the container/stack Backups tab — BackupPanel).
|
||||
let editModalOpen = $state(false);
|
||||
let editConfig = $state<BackupConfig | null>(null);
|
||||
|
||||
// Snapshot browser / restore
|
||||
let showBrowser = $state(false);
|
||||
@@ -506,6 +510,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The Create-backup wizard hands off a freshly-saved config here to run through the
|
||||
// shared BackupLogModal (same as Run-now), instead of an embedded log.
|
||||
async function runBackupNowById({ configId }: { configId: number; targetName: string }) {
|
||||
await fetchData();
|
||||
const config = configs.find((c) => c.id === configId);
|
||||
if (config) runBackupNow(config);
|
||||
}
|
||||
|
||||
// Delete a backup CONFIG (its schedule too — the server unregisters it). The
|
||||
// snapshots already in the repo are NOT touched; the target then shows as an
|
||||
// Pause/resume a scheduled backup. A minimal PUT with only `enabled` is a
|
||||
@@ -843,6 +855,9 @@
|
||||
{#if togglingConfig === config.id}<RefreshCw class="w-3 h-3 text-muted-foreground animate-spin" />{:else if config.enabled}<Pause class="w-3 h-3 text-muted-foreground" />{:else}<RotateCwFadingClock class="w-3 h-3 text-muted-foreground" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => { editConfig = config; editModalOpen = true; }} title="Edit backup">
|
||||
<Pencil class="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => runBackupNow(config)} disabled={runningBackup === config.id} title="Run backup now">
|
||||
{#if runningBackup === config.id}<RefreshCw class="w-3 h-3 text-muted-foreground animate-spin" />{:else}<Play class="w-3 h-3 text-muted-foreground" />{/if}
|
||||
</button>
|
||||
@@ -1035,4 +1050,11 @@
|
||||
<CreateBackupModal
|
||||
bind:open={showCreateModal}
|
||||
onCreated={fetchData}
|
||||
onRun={runBackupNowById}
|
||||
/>
|
||||
|
||||
<EditBackupConfigModal
|
||||
bind:open={editModalOpen}
|
||||
config={editConfig}
|
||||
onSaved={() => { editModalOpen = false; fetchData(); }}
|
||||
/>
|
||||
@@ -8,28 +8,29 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import { Package, Box, Layers, Search, Loader2, CheckCircle2, XCircle, ArrowBigRight, Settings, Clock, Play } from 'lucide-svelte';
|
||||
import { Package, Box, Layers, Search, Loader2, CheckCircle2, ArrowBigRight, Settings, Clock, Play } from 'lucide-svelte';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import { getRepoTypeIcon, formatCron, runBackupAction, isRemoteEnvironment, tagLogLine, type BackupFormState } from '$lib/utils/backup';
|
||||
import { getRepoTypeIcon, formatCron, runBackupAction, isRemoteEnvironment, type BackupFormState } from '$lib/utils/backup';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import VolumePicker from '$lib/components/backup/VolumePicker.svelte';
|
||||
import DestinationPicker from '$lib/components/backup/DestinationPicker.svelte';
|
||||
import LogConsole from '$lib/components/LogConsole.svelte';
|
||||
import { normalizeMounts, volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onCreated?: () => void;
|
||||
/** Called after the config is saved with intent to RUN — the parent opens the
|
||||
* shared BackupLogModal for it (same log UI as Run-now). */
|
||||
onRun?: (r: { configId: number; targetName: string }) => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), onCreated }: Props = $props();
|
||||
let { open = $bindable(), onCreated, onRun }: Props = $props();
|
||||
|
||||
interface Environment { id: number; name: string; icon?: string; connectionType?: string; host?: string | null; }
|
||||
interface Destination { id: number; name: string; repository: string; }
|
||||
interface ContainerItem { name: string; type: 'container' | 'stack'; envId: number; envName: string; envIcon?: string; volumes: VolumeInfo[]; }
|
||||
|
||||
// Step state (4 = running/complete)
|
||||
let step = $state<1 | 2 | 3 | 4>(1);
|
||||
let step = $state<1 | 2 | 3>(1);
|
||||
|
||||
// Step 1: Select source
|
||||
let environments = $state<Environment[]>([]);
|
||||
@@ -50,12 +51,7 @@
|
||||
let saveSchedule = $state(false);
|
||||
let schedule = $state('0 2 * * *');
|
||||
let scheduleInvalid = $state(false);
|
||||
let running = $state(false);
|
||||
|
||||
// Step 4: Running log
|
||||
let logs = $state<string[]>([]);
|
||||
let backupStatus = $state<'idle' | 'running' | 'success' | 'error'>('idle');
|
||||
let backupError = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
@@ -69,10 +65,7 @@
|
||||
selectedVolumes = [];
|
||||
saveSchedule = false;
|
||||
schedule = '0 2 * * *';
|
||||
running = false;
|
||||
logs = [];
|
||||
backupStatus = 'idle';
|
||||
backupError = '';
|
||||
saving = false;
|
||||
fetchEnvironments();
|
||||
fetchDestinations();
|
||||
}
|
||||
@@ -135,16 +128,12 @@
|
||||
step = 2;
|
||||
}
|
||||
|
||||
async function runBackup() {
|
||||
// Save the config, then (when run=true) hand it off to the parent's shared
|
||||
// BackupLogModal — same log UI as Run-now everywhere else. run=false saves the
|
||||
// recurring schedule without running now (it fires on cron).
|
||||
async function saveAndMaybeRun(run: boolean) {
|
||||
if (!selectedItem || !selectedDestId) return;
|
||||
running = true;
|
||||
logs = [];
|
||||
backupStatus = 'running';
|
||||
backupError = '';
|
||||
|
||||
// Wizard exposes two intents: "run and save the schedule" or
|
||||
// "run once and discard". Both map cleanly onto the shared action
|
||||
// orchestrator — same code path as BackupPanel's Save & run / Run once.
|
||||
saving = true;
|
||||
const form: BackupFormState = {
|
||||
targetName: selectedItem.name,
|
||||
type: selectedItem.type,
|
||||
@@ -154,34 +143,28 @@
|
||||
allVolumes,
|
||||
selectedVolumes
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await runBackupAction({
|
||||
form,
|
||||
action: saveSchedule ? 'save-run' : 'run-once',
|
||||
schedule,
|
||||
enabled: true,
|
||||
onProgress: (line) => {
|
||||
if (line.event === 'progress') {
|
||||
const msg = (line.data as { message?: string } | null)?.message;
|
||||
if (msg) logs = [...logs, tagLogLine(msg)];
|
||||
}
|
||||
}
|
||||
action: 'save', // persist only; the run is delegated to the parent modal
|
||||
schedule: saveSchedule ? schedule : '',
|
||||
enabled: saveSchedule
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
backupStatus = 'error';
|
||||
backupError = result.error || 'Backup failed';
|
||||
} else {
|
||||
backupStatus = 'success';
|
||||
toast.success(saveSchedule ? 'Backup completed and schedule saved' : 'Backup completed');
|
||||
if (!result.ok || !result.configId) {
|
||||
toast.error(result.error || 'Failed to save backup');
|
||||
return;
|
||||
}
|
||||
onCreated?.();
|
||||
if (run) {
|
||||
onRun?.({ configId: result.configId, targetName: selectedItem.name });
|
||||
} else {
|
||||
toast.success('Schedule saved');
|
||||
}
|
||||
open = false;
|
||||
} catch (err: any) {
|
||||
backupStatus = 'error';
|
||||
backupError = err?.message || 'Backup failed';
|
||||
toast.error(err?.message || 'Failed to save backup');
|
||||
} finally {
|
||||
running = false;
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +183,7 @@
|
||||
const isSelectedEnvRemote = $derived(isRemoteEnvironment(selectedEnv));
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen && running) return; open = isOpen; }}>
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen && saving) return; open = isOpen; }}>
|
||||
<Dialog.Content class="max-w-3xl h-[70vh] flex flex-col">
|
||||
<Dialog.Header class="pb-0">
|
||||
<Dialog.Title class="flex items-center gap-2 text-base">
|
||||
@@ -382,52 +365,20 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if backupStatus === 'idle'}
|
||||
<!-- Not started yet -->
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
{#if backupStatus === 'idle' || (backupStatus !== 'running' && logs.length === 0)}
|
||||
<Button variant="outline" size="sm" onclick={() => step = 2} disabled={running}>Back</Button>
|
||||
{/if}
|
||||
<Button variant="outline" size="sm" onclick={() => step = 2} disabled={saving}>Back</Button>
|
||||
<div class="flex-1"></div>
|
||||
{#if backupStatus === 'success'}
|
||||
<Button size="sm" onclick={() => { open = false; }}>
|
||||
<CheckCircle2 class="w-3.5 h-3.5 mr-1.5 text-green-500" />Done
|
||||
</Button>
|
||||
{:else if backupStatus === 'error' && !running}
|
||||
<Button size="sm" variant="outline" onclick={() => { backupStatus = 'idle'; logs = []; }}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button size="sm" onclick={() => { open = false; }}>Close</Button>
|
||||
{:else}
|
||||
<Button size="sm" onclick={runBackup} disabled={running || (saveSchedule && scheduleInvalid)}>
|
||||
{#if running}<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />{:else}<Play class="w-3.5 h-3.5 mr-1.5" />{/if}
|
||||
{saveSchedule ? 'Run & save schedule' : 'Run backup now'}
|
||||
{#if saveSchedule}
|
||||
<Button variant="outline" size="sm" onclick={() => saveAndMaybeRun(false)} disabled={saving || scheduleInvalid}>
|
||||
{#if saving}<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />{:else}<CheckCircle2 class="w-3.5 h-3.5 mr-1.5" />{/if}
|
||||
Save schedule only
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" onclick={() => saveAndMaybeRun(true)} disabled={saving || (saveSchedule && scheduleInvalid)}>
|
||||
{#if saving}<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />{:else}<Play class="w-3.5 h-3.5 mr-1.5" />{/if}
|
||||
{saveSchedule ? 'Run & save schedule' : 'Run backup now'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Backup log — same shared renderer (pills, auto-scroll) as the backup
|
||||
panel and restore modal, with the wizard's own status line below. -->
|
||||
{#if logs.length > 0 || running}
|
||||
<div class="mt-3 flex min-h-0 flex-1 flex-col">
|
||||
<LogConsole lines={logs} class="flex-1 min-h-0" />
|
||||
{#if backupStatus === 'running'}
|
||||
<div class="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />Backing up…
|
||||
</div>
|
||||
{:else if backupStatus === 'success'}
|
||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-green-500">
|
||||
<CheckCircle2 class="h-3.5 w-3.5" />Backup completed
|
||||
</div>
|
||||
{:else if backupStatus === 'error'}
|
||||
<div class="mt-1.5 flex items-start gap-1.5 text-xs font-medium text-red-400">
|
||||
<XCircle class="mt-0.5 h-3.5 w-3.5 shrink-0" /><span class="break-all">{backupError || 'Failed'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Edit a backup config from the Backups page by reusing the container/stack
|
||||
* Backups tab (BackupPanel) in a modal. BackupPanel needs the target's volume
|
||||
* list, which this page fetches like CreateBackupModal does.
|
||||
*/
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Loader2, Archive } from 'lucide-svelte';
|
||||
import BackupPanel from '../containers/BackupPanel.svelte';
|
||||
import { normalizeMounts, volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
|
||||
|
||||
interface EditTarget {
|
||||
id: number;
|
||||
type: 'container' | 'stack';
|
||||
targetName: string;
|
||||
environmentId: number | null;
|
||||
}
|
||||
|
||||
let { open = $bindable(), config, onSaved }: {
|
||||
open: boolean;
|
||||
config: EditTarget | null;
|
||||
onSaved?: () => void;
|
||||
} = $props();
|
||||
|
||||
let volumes = $state<VolumeInfo[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadedFor = $state<number | null>(null); // config.id we loaded volumes for
|
||||
|
||||
async function loadVolumes(cfg: EditTarget) {
|
||||
loading = true;
|
||||
volumes = [];
|
||||
try {
|
||||
const envId = cfg.environmentId;
|
||||
const contRes = await fetch(`/api/containers?env=${envId}`);
|
||||
const contData = await contRes.json();
|
||||
if (cfg.type === 'stack') {
|
||||
volumes = Array.isArray(contData) ? volumesForStack(contData, cfg.targetName) : [];
|
||||
} else {
|
||||
const c = Array.isArray(contData)
|
||||
? contData.find((x: any) => (x.name || x.Names?.[0]?.replace(/^\//, '')) === cfg.targetName)
|
||||
: null;
|
||||
volumes = c ? normalizeMounts(c.mounts || c.Mounts) : [];
|
||||
}
|
||||
} catch {
|
||||
volumes = [];
|
||||
}
|
||||
loadedFor = cfg.id;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
// Fetch volumes once per opened config.
|
||||
$effect(() => {
|
||||
if (open && config && loadedFor !== config.id) {
|
||||
loadVolumes(config);
|
||||
}
|
||||
if (!open) loadedFor = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-3xl h-[70vh] flex flex-col">
|
||||
<Dialog.Header class="pb-0">
|
||||
<Dialog.Title class="flex items-center gap-2 text-base">
|
||||
<Archive class="w-4 h-4" />
|
||||
Edit backup — {config?.targetName ?? ''}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
{#if config}
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 p-4 text-sm text-muted-foreground">
|
||||
<Loader2 class="w-4 h-4 animate-spin" /> Loading volumes…
|
||||
</div>
|
||||
{:else}
|
||||
<BackupPanel
|
||||
containerName={config.targetName}
|
||||
type={config.type}
|
||||
environmentId={config.environmentId ?? undefined}
|
||||
{volumes}
|
||||
onConfigSaved={() => onSaved?.()}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -7,7 +7,7 @@
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import ContainerSettingsTab from './ContainerSettingsTab.svelte';
|
||||
import BackupPanel from './BackupPanel.svelte';
|
||||
import { mountTypeFromHostPath } from '$lib/utils/mounts';
|
||||
import { volumeInfoFromBind } from '$lib/utils/mounts';
|
||||
import { fetchBackupExecutions } from '$lib/utils/backup';
|
||||
import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
|
||||
import { parseHostPort, expandPortBindings, formatHostPort } from '$lib/utils/port-parse';
|
||||
@@ -1198,7 +1198,7 @@
|
||||
<BackupPanel
|
||||
bind:this={backupPanelRef}
|
||||
containerName={name}
|
||||
volumes={volumeMappings.filter(v => v.hostPath && v.containerPath).map(v => ({ name: v.containerPath, mountPoint: v.hostPath, mountType: mountTypeFromHostPath(v.hostPath) }))}
|
||||
volumes={volumeMappings.filter(v => v.hostPath && v.containerPath).map(volumeInfoFromBind)}
|
||||
type="container"
|
||||
onTally={(t) => (backupTally = t)}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user