This commit is contained in:
jarek committed 2026-07-25 08:41:01 +02:00
1 parent 9f09d60a83
commit 64b6dcd744
177 files changed
+21182 -1042

No files matched your search

+14 -1
View File
@@ -16,7 +16,8 @@
Users,
Info,
GitBranch,
Tags
Tags,
Archive
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -28,6 +29,7 @@
import GitTab from './git/GitTab.svelte';
import ConfigSetsTab from './config-sets/ConfigSetsTab.svelte';
import NotificationsTab from './notifications/NotificationsTab.svelte';
import BackupsTab from './backups/BackupsTab.svelte';
import AuthTab from './auth/AuthTab.svelte';
import LicenseTab from './license/LicenseTab.svelte';
import AboutTab from './about/AboutTab.svelte';
@@ -77,6 +79,13 @@
<Bell class="w-4 h-4" />
Notifications
</Tabs.Trigger>
<!-- BETA GATE: Backups tab hidden unless FEAT_BACKUPS_ENABLED (see features.ts) -->
{#if $page.data.backupsEnabled}
<Tabs.Trigger value="backups" class="flex-1 flex items-center justify-center gap-1.5">
<Archive class="w-4 h-4" />
Backups
</Tabs.Trigger>
{/if}
<Tabs.Trigger value="auth" class="flex-1 flex items-center justify-center gap-1.5">
<Users class="w-4 h-4" />
Authentication
@@ -119,6 +128,10 @@
{#if activeTab === 'notifications'}<NotificationsTab />{/if}
</Tabs.Content>
<Tabs.Content value="backups" class="flex-1 min-h-0 overflow-y-auto">
{#if activeTab === 'backups'}<BackupsTab />{/if}
</Tabs.Content>
<Tabs.Content value="auth" class="flex-1 min-h-0 flex flex-col">
{#if activeTab === 'auth'}<AuthTab onTabChange={handleTabChange} />{/if}
</Tabs.Content>
@@ -1,11 +1,12 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Shield, Pencil, Plus, Check, RefreshCw, Box, Image, HardDrive, Cable, Layers, Globe, Download, Bell, Sliders, Settings, Users, Eye, SquarePlus, Play, Square, RotateCcw, Trash2, Terminal, ScrollText, Search, Upload, Plug, Unplug, Copy, GitBranch, KeyRound, Building2, Container, TriangleAlert, ClipboardList, Activity, Timer } from 'lucide-svelte';
import { Shield, Pencil, Plus, Check, RefreshCw, Box, Image, HardDrive, Cable, Layers, Globe, Download, Bell, Sliders, Settings, Users, Eye, SquarePlus, Play, Square, RotateCcw, Trash2, Terminal, ScrollText, Search, Upload, Plug, Unplug, Copy, GitBranch, KeyRound, Building2, Container, TriangleAlert, ClipboardList, Activity, Timer, Archive } from 'lucide-svelte';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import * as Alert from '$lib/components/ui/alert';
import { focusFirstInput } from '$lib/utils';
@@ -65,6 +66,7 @@
audit_logs: string[];
activity: string[];
schedules: string[];
backups: string[];
}>({
containers: [],
images: [],
@@ -81,7 +83,8 @@
license: [],
audit_logs: [],
activity: [],
schedules: []
schedules: [],
backups: []
});
@@ -138,6 +141,12 @@
{ key: 'view', label: 'View schedules' },
{ key: 'edit', label: 'Edit schedules' },
{ key: 'run', label: 'Run schedules' }
],
backups: [
{ key: 'view', label: 'View backups' },
{ key: 'run', label: 'Run backups' },
{ key: 'restore', label: 'Restore from backups' },
{ key: 'manage', label: 'Manage destinations' }
]
};
@@ -204,7 +213,8 @@
license: KeyRound,
audit_logs: ClipboardList,
activity: Activity,
schedules: Timer
schedules: Timer,
backups: Archive
};
const categoryColorsSolid: Record<string, string> = {
@@ -223,7 +233,8 @@
license: 'bg-yellow-100 dark:bg-yellow-950 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-800',
audit_logs: 'bg-stone-100 dark:bg-stone-950 text-stone-700 dark:text-stone-400 border-stone-300 dark:border-stone-800',
activity: 'bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-400 border-emerald-300 dark:border-emerald-800',
schedules: 'bg-sky-100 dark:bg-sky-950 text-sky-700 dark:text-sky-400 border-sky-300 dark:border-sky-800'
schedules: 'bg-sky-100 dark:bg-sky-950 text-sky-700 dark:text-sky-400 border-sky-300 dark:border-sky-800',
backups: 'bg-lime-100 dark:bg-lime-950 text-lime-700 dark:text-lime-400 border-lime-300 dark:border-lime-800'
};
const permissionIcons: Record<string, typeof Eye> = {
@@ -272,7 +283,8 @@
license: [],
audit_logs: [],
activity: [],
schedules: []
schedules: [],
backups: []
};
}
@@ -302,7 +314,8 @@
license: [...(role.permissions.license || [])],
audit_logs: [...(role.permissions.audit_logs || [])],
activity: [...(role.permissions.activity || [])],
schedules: [...(role.permissions.schedules || [])]
schedules: [...(role.permissions.schedules || [])],
backups: [...(role.permissions.backups || [])]
};
formError = '';
formErrors = {};
@@ -330,7 +343,8 @@
license: [...(copyFrom.permissions.license || [])],
audit_logs: [...(copyFrom.permissions.audit_logs || [])],
activity: [...(copyFrom.permissions.activity || [])],
schedules: [...(copyFrom.permissions.schedules || [])]
schedules: [...(copyFrom.permissions.schedules || [])],
backups: [...(copyFrom.permissions.backups || [])]
};
formError = '';
formErrors = {};
@@ -483,7 +497,8 @@
</div>
</div>
<div class="p-3 grid grid-cols-2 lg:grid-cols-4 gap-3">
{#each Object.entries(systemPermissions) as [category, permissions]}
<!-- BETA GATE: drop the backups permission group unless FEAT_BACKUPS_ENABLED (see features.ts) -->
{#each Object.entries(systemPermissions).filter(([c]) => c !== 'backups' || $page.data.backupsEnabled) as [category, permissions]}
{@const IconComponent = categoryIcons[category]}
<div class="relative border rounded-md pt-5 pb-3 px-3">
<!-- Category pill on border -->
@@ -0,0 +1,693 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import { DataGrid } from '$lib/components/data-grid';
import { Plus, Trash2, Pencil, HardDrive, Server, CheckCircle, XCircle, AlertCircle, Wifi, Database, RefreshCw, Search, FolderSync, Archive, Loader2, Save, CircleHelp, Unlock, PackageCheck, Eraser, BarChart3, Wrench, FolderCheck, KeyRound } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Label } from '$lib/components/ui/label';
import { appSettings } from '$lib/stores/settings';
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
import { formatDateTime } from '$lib/stores/settings';
import { FolderOpen, Box, Layers, FileStack, Camera, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-svelte';
import { formatBytes } from '$lib/utils/format';
import { getRepoTypeIcon, getRepoTypeLabel } from '$lib/utils/backup';
import { shouldSaveBackupImage } from '$lib/utils/backup-image';
import SnapshotBrowser from '../../containers/SnapshotBrowser.svelte';
import type { Component } from 'svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { canAccess } from '$lib/stores/auth';
import DestinationModal from './DestinationModal.svelte';
import VerifyModal from './VerifyModal.svelte';
import RotatePasswordModal from './RotatePasswordModal.svelte';
import { EmptyState } from '$lib/components/ui/empty-state';
interface Destination {
id: number;
name: string;
repository: string;
envVars?: Record<string, string>;
flags?: string;
lastTestStatus?: string | null;
lastTestError?: string | null;
lastTestAt?: string | null;
createdAt: string;
updatedAt: string;
}
interface BackupConfig {
id: number;
destinationId: number;
targetName: string;
type: string;
}
let destinations = $state<Destination[]>([]);
let configs = $state<BackupConfig[]>([]);
let loading = $state(true);
let showModal = $state(false);
let editingDest = $state<Destination | null>(null);
let confirmDeleteId = $state<number | null>(null);
let confirmAction = $state<{ destId: number; task: string } | null>(null);
let searchQuery = $state('');
let testingId = $state<number | null>(null);
let testingAll = $state(false);
let verifyModalOpen = $state(false);
let verifyDestId = $state(0);
let verifyDestName = $state('');
let rotateModalOpen = $state(false);
let rotateDestId = $state(0);
let rotateDestName = $state('');
let repoStats = $state<Map<number, { totalSize: number; totalFiles: number; snapshots: number }>>(new Map());
let loadingStats = $state<Set<number>>(new Set());
let fetchingAllStats = $state(false);
async function fetchRepoStats(destId: number) {
const s = new Set(loadingStats); s.add(destId); loadingStats = s;
try {
const res = await fetch(`/api/backup/destinations/${destId}/task`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task: 'stats' })
});
const data = await res.json();
if (data.success && data.stats) {
const m = new Map(repoStats);
m.set(destId, data.stats);
repoStats = m;
}
} catch {} finally {
const s2 = new Set(loadingStats); s2.delete(destId); loadingStats = s2;
}
}
async function fetchAllStats() {
fetchingAllStats = true;
// Skip destinations that aren't initialized (failed, needs_init, or never tested)
const healthy = destinations.filter(d => d.lastTestStatus === 'success');
await Promise.all(healthy.map(d => fetchRepoStats(d.id)));
fetchingAllStats = false;
}
async function testAllDestinations() {
testingAll = true;
let passed = 0;
let failed = 0;
// Clear statuses so UI shows spinners
for (const dest of destinations) {
dest.lastTestStatus = null;
}
destinations = [...destinations];
repoStats = new Map();
// Run all tests + stats in parallel, update UI incrementally
await Promise.allSettled(destinations.map(async (dest) => {
try {
const res = await fetch(`/api/backup/destinations/${dest.id}/test`, { method: 'POST' });
const data = await res.json();
if (data.success) {
passed++;
dest.lastTestStatus = 'success';
destinations = [...destinations];
await fetchRepoStats(dest.id);
} else {
failed++;
dest.lastTestStatus = data.status === 'needs_init' ? 'needs_init' : 'failed';
dest.lastTestError = data.error || null;
destinations = [...destinations];
}
} catch {
failed++;
dest.lastTestStatus = 'failed';
destinations = [...destinations];
}
}));
testingAll = false;
if (failed === 0) toast.success(`All ${passed} destinations tested & stats collected`);
else toast.error(`${failed} failed, ${passed} passed`);
}
// Backup helper image setting
// Pre-filled from the store, which carries the API's real version-pinned default
// (fnsys/dockhand-backup:<version>). No local `:latest` guess — that value is
// wrong (the engine uses the versioned image) and misleading to persist.
let backupImage = $state($appSettings.defaultBackupImage);
// The value we loaded the field with. We persist ONLY when the user changed it,
// so leaving the pre-filled versioned default untouched keeps the DB row empty and
// the engine keeps tracking the app version across upgrades (see backup-image.ts).
let backupImageInitial = $state($appSettings.defaultBackupImage);
let savingImage = $state(false);
let imageSavedOk = $state(false);
async function saveBackupImage() {
if (!shouldSaveBackupImage(backupImage, backupImageInitial)) {
// Unchanged — nothing to persist. Give the same confirmation feedback.
imageSavedOk = true;
setTimeout(() => { imageSavedOk = false; }, 2000);
return;
}
savingImage = true;
try {
const res = await fetch('/api/settings/general', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ defaultBackupImage: backupImage })
});
if (res.ok) {
backupImageInitial = backupImage;
imageSavedOk = true;
setTimeout(() => { imageSavedOk = false; }, 2000);
}
else toast.error('Failed to save');
} catch { toast.error('Failed to save'); }
finally { savingImage = false; }
}
let initializingId = $state<number | null>(null);
let runningTask = $state<{ destId: number; task: string } | null>(null);
async function runRepoTask(destId: number, task: string) {
runningTask = { destId, task };
try {
const res = await fetch(`/api/backup/destinations/${destId}/task`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task })
});
const data = await res.json();
if (data.success) {
if (task === 'stats' && data.stats) {
const m = new Map(repoStats);
m.set(destId, data.stats);
repoStats = m;
}
toast.success(data.output || `${task} completed`);
} else {
toast.error(data.error || `${task} failed`);
}
} catch (err: any) {
toast.error(err.message || `${task} failed`);
} finally {
runningTask = null;
}
}
// Browse repository snapshots
let browseOpen = $state(false);
let browseDestId = $state(0);
let browseDestName = $state('');
let browseDestRepo = $state('');
let browseSnapshots = $state<any[]>([]);
let browseLoading = $state(false);
// Snapshot list sorting — default newest first (by snapshot timestamp).
type BrowseSortField = 'shortId' | 'time' | 'type' | 'name';
let browseSort = $state<{ field: BrowseSortField; dir: 'asc' | 'desc' }>({ field: 'time', dir: 'desc' });
function snapName(snap: any): string {
return (snap.tags || []).find((t: string) => t.startsWith('dockhand:name='))?.replace('dockhand:name=', '') || '';
}
function snapType(snap: any): string {
return (snap.tags || []).find((t: string) => t.startsWith('dockhand:type='))?.replace('dockhand:type=', '') || '';
}
function toggleBrowseSort(field: BrowseSortField) {
if (browseSort.field === field) browseSort = { field, dir: browseSort.dir === 'asc' ? 'desc' : 'asc' };
else browseSort = { field, dir: field === 'time' ? 'desc' : 'asc' };
}
const sortedBrowseSnapshots = $derived.by(() => {
const { field, dir } = browseSort;
const key = (s: any) =>
field === 'shortId' ? (s.shortId ?? '') :
field === 'time' ? (s.time ?? '') :
field === 'type' ? snapType(s) :
snapName(s);
return [...browseSnapshots].sort((a, b) => {
const ka = key(a), kb = key(b);
const cmp = ka < kb ? -1 : ka > kb ? 1 : 0;
return dir === 'asc' ? cmp : -cmp;
});
});
// Snapshot file browser
let snapshotBrowseOpen = $state(false);
let snapshotBrowseId = $state('');
let snapshotBrowseDestId = $state(0);
let snapshotBrowseName = $state('');
async function browseDestination(dest: Destination) {
browseDestId = dest.id;
browseDestName = dest.name;
browseDestRepo = dest.repository;
browseSnapshots = [];
browseOpen = true;
browseLoading = true;
try {
// List all snapshots in this destination (no configId filter)
const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`);
if (res.ok) { const d = await res.json(); browseSnapshots = d.snapshots ?? d; }
else {
const data = await res.json();
const errMsg = data.error || 'Failed to list snapshots';
try { const p = JSON.parse(errMsg); toast.error(p.message || errMsg); } catch { toast.error(errMsg); }
}
} catch { toast.error('Failed to list snapshots'); }
finally { browseLoading = false; }
}
const getTypeIcon = getRepoTypeIcon;
const getTypeLabel = getRepoTypeLabel;
function getUsageCount(destId: number): { containers: number; stacks: number } {
const related = configs.filter(c => c.destinationId === destId);
return {
containers: related.filter(c => c.type === 'container').length,
stacks: related.filter(c => c.type === 'stack').length
};
}
const filteredDestinations = $derived(
searchQuery.trim()
? destinations.filter(d =>
d.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.repository.toLowerCase().includes(searchQuery.toLowerCase()) ||
getTypeLabel(d.repository).toLowerCase().includes(searchQuery.toLowerCase())
)
: destinations
);
async function fetchData() {
loading = true;
repoStats = new Map();
try {
const [destRes, configRes] = await Promise.all([
fetch('/api/backup/destinations'),
fetch('/api/backup/configs')
]);
destinations = await destRes.json();
const configData = await configRes.json();
configs = Array.isArray(configData) ? configData : [];
} catch (error) {
console.error('Failed to fetch backup data:', error);
toast.error('Failed to fetch backup destinations');
} finally {
loading = false;
}
}
async function openModal(dest?: Destination) {
if (dest) {
// The LIST endpoint omits envVars (cloud creds) for security.
// Re-fetch the single destination so the modal can pre-fill the
// credential fields.
try {
const res = await fetch(`/api/backup/destinations/${dest.id}`);
if (res.ok) {
editingDest = await res.json();
} else {
editingDest = dest; // fall back to what we have
}
} catch {
editingDest = dest;
}
} else {
editingDest = null;
}
showModal = true;
}
async function testDestination(id: number) {
testingId = id;
try {
const res = await fetch(`/api/backup/destinations/${id}/test`, { method: 'POST' });
const data = await res.json();
if (data.success) {
toast.success('Connection test successful');
} else if (data.status === 'needs_init') {
toast.warning(data.error || 'Repository needs initialization');
} else {
toast.error(data.error || 'Connection test failed');
}
} catch { toast.error('Connection test failed'); }
finally { testingId = null; await fetchData(); }
}
async function initDestination(id: number) {
initializingId = id;
try {
const res = await fetch(`/api/backup/destinations/${id}/init`, { method: 'POST' });
const data = await res.json();
toast[data.success ? 'success' : 'error'](data.message || data.error || 'Init failed');
} catch { toast.error('Init failed'); }
finally { initializingId = null; await fetchData(); }
}
async function deleteDestination(id: number) {
try {
const response = await fetch(`/api/backup/destinations/${id}`, { method: 'DELETE' });
if (response.ok) {
await fetchData();
toast.success('Destination deleted');
} else {
const data = await response.json();
toast.error(data.error || 'Failed to delete destination');
}
} catch { toast.error('Failed to delete destination'); }
}
onMount(() => { fetchData(); });
</script>
<div class="space-y-4">
<!-- Backup helper image -->
<div class="flex items-center gap-3 p-3 border rounded-md bg-muted/20">
<Label class="text-xs shrink-0 flex items-center gap-1.5">
Backup helper image
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger>
<CircleHelp class="w-3 h-3 text-muted-foreground/70 cursor-help" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom" sideOffset={4} class="!w-80 text-xs">
Docker image with restic for backup/restore. Auto-pulled on first run. Change for private registries.
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</Label>
<Input bind:value={backupImage} class="w-80" />
<Button variant="outline" size="sm" class="h-8" onclick={saveBackupImage} disabled={savingImage}>
{#if savingImage}<Loader2 class="w-3.5 h-3.5 animate-spin" />{:else if imageSavedOk}<CheckCircle class="w-3.5 h-3.5 text-green-500" />{:else}<Save class="w-3.5 h-3.5" />{/if}
</Button>
</div>
<div class="flex justify-between items-center">
<div class="flex items-center gap-3">
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
bind:value={searchQuery}
placeholder="Filter destinations..."
class="pl-9 h-8 w-64 text-sm"
/>
</div>
<Badge variant="secondary" class="text-xs">{destinations.length} destination{destinations.length !== 1 ? 's' : ''}</Badge>
</div>
<div class="flex gap-2">
{#if $canAccess('backups', 'manage')}
<Button size="sm" onclick={() => openModal()}>
<Plus class="w-4 h-4 mr-1" />
Add destination
</Button>
{/if}
<Button size="sm" variant="outline" onclick={testAllDestinations} disabled={testingAll}>
{#if testingAll}<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />{:else}<Wifi class="w-3.5 h-3.5 mr-1" />{/if}
Test all
</Button>
<Button size="sm" variant="outline" onclick={fetchData}>
<RefreshCw class="w-3.5 h-3.5" />
</Button>
</div>
</div>
{#if loading && destinations.length === 0}
<p class="text-muted-foreground text-sm">Loading backup destinations...</p>
{:else if destinations.length === 0}
<EmptyState
icon={Archive as unknown as Component}
title="No backup destinations"
description="Add a backup destination to start protecting your container data"
/>
{:else}
<DataGrid
data={filteredDestinations}
keyField="id"
gridId="backupDestinations"
loading={loading}
onRowClick={(dest) => openModal(dest)}
class="border-none"
wrapperClass="border rounded-lg"
>
{#snippet cell(column, dest)}
{#if column.id === 'type'}
{@const TypeIcon = getTypeIcon(dest.repository)}
<div class="flex items-center gap-1.5" title={getTypeLabel(dest.repository)}>
<TypeIcon class="w-4 h-4 text-muted-foreground" />
<span class="text-xs text-muted-foreground">{getTypeLabel(dest.repository)}</span>
</div>
{:else if column.id === 'name'}
<span class="font-medium text-sm">{dest.name}</span>
{:else if column.id === 'repository'}
<span class="text-xs text-muted-foreground truncate block" title={dest.repository}>
{dest.repository}
</span>
{:else if column.id === 'usage'}
{@const usage = getUsageCount(dest.id)}
{#if usage.containers > 0 || usage.stacks > 0}
<div class="flex items-center gap-1.5">
{#if usage.containers > 0}
<span class="flex items-center gap-0.5 text-xs text-muted-foreground" title="{usage.containers} container{usage.containers !== 1 ? 's' : ''} using this repository"><Box class="w-3 h-3" />{usage.containers}</span>
{/if}
{#if usage.stacks > 0}
<span class="flex items-center gap-0.5 text-xs text-muted-foreground" title="{usage.stacks} stack{usage.stacks !== 1 ? 's' : ''} using this repository"><Layers class="w-3 h-3" />{usage.stacks}</span>
{/if}
</div>
{:else}
<span class="text-xs text-muted-foreground/50">—</span>
{/if}
{:else if column.id === 'stats'}
{@const stat = repoStats.get(dest.id)}
{#if stat}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="flex items-center gap-0.5" title="Total size"><HardDrive class="w-3 h-3" />{formatBytes(stat.totalSize)}</span>
<span class="flex items-center gap-0.5" title="Files"><FileStack class="w-3 h-3" />{stat.totalFiles}</span>
<span class="flex items-center gap-0.5" title="Snapshots"><Camera class="w-3 h-3" />{stat.snapshots}</span>
</div>
{:else if loadingStats.has(dest.id)}
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
{:else}
<span class="text-xs text-muted-foreground/50">—</span>
{/if}
{:else if column.id === 'status'}
{#if dest.lastTestStatus === 'success'}
<div class="flex items-center gap-1.5">
<CheckCircle class="w-3.5 h-3.5 text-green-500" />
<span class="text-xs text-green-600 dark:text-green-400">Initialized</span>
</div>
{:else if dest.lastTestStatus === 'needs_init'}
<div class="flex items-center gap-1.5">
<AlertCircle class="w-3.5 h-3.5 text-amber-500" />
<span class="text-xs text-amber-600 dark:text-amber-400">Needs init</span>
</div>
{:else if dest.lastTestStatus === 'failed'}
<Tooltip.Root>
<Tooltip.Trigger>
<div class="flex items-center gap-1.5">
<XCircle class="w-3.5 h-3.5 text-destructive" />
<span class="text-xs text-destructive">Failed</span>
</div>
</Tooltip.Trigger>
{#if dest.lastTestError}
<Tooltip.Content class="max-w-sm whitespace-normal text-xs">{dest.lastTestError}</Tooltip.Content>
{/if}
</Tooltip.Root>
{:else if testingAll}
<div class="flex items-center gap-1.5">
<Loader2 class="w-3.5 h-3.5 text-muted-foreground animate-spin" />
<span class="text-xs text-muted-foreground">Testing...</span>
</div>
{:else}
<div class="flex items-center gap-1.5">
<AlertCircle class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs text-muted-foreground">Not tested</span>
</div>
{/if}
{:else if column.id === 'actions'}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex items-center justify-end gap-0.5" onclick={(e) => e.stopPropagation()}>
{#if dest.lastTestStatus === 'success'}
<!-- Repo actions (only for initialized repos) -->
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => browseDestination(dest)} title="Browse snapshots">
<FolderOpen class="grid-action-icon grid-action-info text-muted-foreground" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => runRepoTask(dest.id, 'stats')} disabled={runningTask?.destId === dest.id} title="Repository stats">
{#if runningTask?.destId === dest.id && runningTask.task === 'stats'}<Loader2 class="grid-action-icon text-muted-foreground animate-spin" />{:else}<BarChart3 class="grid-action-icon grid-action-info text-muted-foreground" />{/if}
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => runRepoTask(dest.id, 'check')} disabled={runningTask?.destId === dest.id} title="Check integrity">
{#if runningTask?.destId === dest.id && runningTask.task === 'check'}<Loader2 class="grid-action-icon text-muted-foreground animate-spin" />{:else}<PackageCheck class="grid-action-icon grid-action-info text-muted-foreground" />{/if}
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => { verifyDestId = dest.id; verifyDestName = dest.name; verifyModalOpen = true; }} title="Verify data integrity">
<FolderCheck class="grid-action-icon grid-action-info text-muted-foreground" />
</button>
<ConfirmPopover
open={confirmAction?.destId === dest.id && confirmAction.task === 'unlock'}
action="Unlock" itemType="repository" itemName={dest.name} confirmText="Unlock" position="left"
onConfirm={() => { confirmAction = null; runRepoTask(dest.id, 'unlock'); }}
onOpenChange={(o) => confirmAction = o ? { destId: dest.id, task: 'unlock' } : null}
>
{#snippet children({ open })}
{#if runningTask?.destId === dest.id && runningTask.task === 'unlock'}<Loader2 class="grid-action-icon text-muted-foreground animate-spin" />{:else}<Unlock class="grid-action-icon grid-action-edit text-muted-foreground" />{/if}
{/snippet}
</ConfirmPopover>
<ConfirmPopover
open={confirmAction?.destId === dest.id && confirmAction.task === 'prune'}
action="Prune" itemType="unused data from" itemName={dest.name} confirmText="Prune" variant="destructive" position="left"
onConfirm={() => { confirmAction = null; runRepoTask(dest.id, 'prune'); }}
onOpenChange={(o) => confirmAction = o ? { destId: dest.id, task: 'prune' } : null}
>
{#snippet children({ open })}
{#if runningTask?.destId === dest.id && runningTask.task === 'prune'}<Loader2 class="grid-action-icon text-muted-foreground animate-spin" />{:else}<Eraser class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground'}" />{/if}
{/snippet}
</ConfirmPopover>
<ConfirmPopover
open={confirmAction?.destId === dest.id && confirmAction.task === 'repair'}
action="Repair" itemType="index for" itemName={dest.name} confirmText="Repair" variant="destructive" position="left"
onConfirm={() => { confirmAction = null; runRepoTask(dest.id, 'repair-index'); }}
onOpenChange={(o) => confirmAction = o ? { destId: dest.id, task: 'repair' } : null}
>
{#snippet children({ open })}
{#if runningTask?.destId === dest.id && runningTask.task === 'repair-index'}<Loader2 class="grid-action-icon text-muted-foreground animate-spin" />{:else}<Wrench class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground'}" />{/if}
{/snippet}
</ConfirmPopover>
{/if}
<!-- Init (only when needs_init) -->
{#if dest.lastTestStatus === 'needs_init'}
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors text-amber-500 hover:text-amber-600" onclick={() => initDestination(dest.id)} disabled={initializingId === dest.id} title="Initialize repository">
{#if initializingId === dest.id}<Loader2 class="w-3 h-3 animate-spin" />{:else}<Database class="w-3 h-3" />{/if}
</button>
{/if}
<!-- Always visible: test, edit, delete -->
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => testDestination(dest.id)} disabled={testingId === dest.id} title="Test connection">
{#if testingId === dest.id}<RefreshCw class="grid-action-icon text-muted-foreground animate-spin" />{:else}<Wifi class="grid-action-icon grid-action-restart text-muted-foreground" />{/if}
</button>
{#if $canAccess('backups', 'manage')}
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => { rotateDestId = dest.id; rotateDestName = dest.name; rotateModalOpen = true; }} title="Rotate repository password">
<KeyRound class="grid-action-icon grid-action-edit text-muted-foreground" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => openModal(dest)} title="Edit">
<Pencil class="grid-action-icon grid-action-edit text-muted-foreground" />
</button>
<ConfirmPopover
open={confirmDeleteId === dest.id}
action="Delete"
itemType="destination"
itemName={dest.name}
title="Remove"
position="left"
onConfirm={() => deleteDestination(dest.id)}
onOpenChange={(open) => confirmDeleteId = open ? dest.id : null}
>
{#snippet children({ open })}
<Trash2 class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
</div>
{/if}
{/snippet}
</DataGrid>
{/if}
</div>
<DestinationModal
bind:open={showModal}
destination={editingDest}
existingDestinations={destinations}
onClose={() => { showModal = false; editingDest = null; }}
onSaved={fetchData}
/>
<Dialog.Root bind:open={browseOpen}>
<Dialog.Content class="max-w-2xl h-[70vh] flex flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<FolderOpen class="w-5 h-5" />
Repository snapshots on
<svelte:component this={getRepoTypeIcon(browseDestRepo)} class="w-4 h-4" />
<span class="text-amber-600 dark:text-amber-400">{browseDestName}</span>
</Dialog.Title>
</Dialog.Header>
<div class="flex-1 overflow-y-auto">
{#if browseLoading}
<div class="flex items-center justify-center py-12"><Loader2 class="w-6 h-6 animate-spin text-muted-foreground" /></div>
{:else if browseSnapshots.length === 0}
<p class="text-sm text-muted-foreground p-4">No snapshots in this repository.</p>
{:else}
{#snippet sortHead(field: BrowseSortField, label: string, extra = '')}
<Table.Head class={extra}>
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground transition-colors" onclick={() => toggleBrowseSort(field)}>
{label}
{#if browseSort.field === field}
{#if browseSort.dir === 'asc'}<ArrowUp class="w-3 h-3" />{:else}<ArrowDown class="w-3 h-3" />{/if}
{:else}
<ArrowUpDown class="w-3 h-3 opacity-40" />
{/if}
</button>
</Table.Head>
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
{@render sortHead('shortId', 'ID', 'w-24')}
{@render sortHead('time', 'Created')}
{@render sortHead('type', 'Type', 'w-16')}
{@render sortHead('name', 'Name')}
<Table.Head class="w-12 text-right">Browse</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each sortedBrowseSnapshots as snap}
{@const name = snapName(snap)}
{@const type = snapType(snap)}
<Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => { snapshotBrowseDestId = browseDestId; snapshotBrowseId = snap.id; snapshotBrowseName = name; snapshotBrowseOpen = true; }}>
<Table.Cell class="font-mono text-xs text-muted-foreground">{snap.shortId}</Table.Cell>
<Table.Cell class="text-xs">{formatDateTime(snap.time)}</Table.Cell>
<Table.Cell>
{#if type === 'container'}
<Box class="w-3.5 h-3.5 text-blue-500" />
{:else if type === 'stack'}
<Layers class="w-3.5 h-3.5 text-purple-500" />
{:else}
<Box class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</Table.Cell>
<Table.Cell class="text-xs">{name}</Table.Cell>
<Table.Cell class="text-right">
<span class="inline-flex p-1 rounded hover:bg-muted transition-colors text-muted-foreground" title="Browse snapshot content">
<FolderOpen class="w-3.5 h-3.5" />
</span>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</div>
<Dialog.Footer class="pt-4">
<Button variant="outline" onclick={() => browseOpen = false}>Close</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<SnapshotBrowser
bind:open={snapshotBrowseOpen}
destinationId={snapshotBrowseDestId}
snapshotId={snapshotBrowseId}
targetName={snapshotBrowseName}
/>
<VerifyModal
bind:open={verifyModalOpen}
destinationId={verifyDestId}
destinationName={verifyDestName}
/>
<RotatePasswordModal
bind:open={rotateModalOpen}
destinationId={rotateDestId}
destinationName={rotateDestName}
/>
@@ -0,0 +1,679 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Input } from '$lib/components/ui/input';
import { Plus, Check, RefreshCw, Wifi, Database, HardDrive, Dices, Copy, ChevronDown, BarChart3, Loader2, Clock, PackageCheck, FolderCheck, Unlock, CircleHelp, AlertTriangle } from 'lucide-svelte';
import cronstrue from 'cronstrue';
import { formatBytes } from '$lib/utils/format';
import CronEditor from '$lib/components/cron-editor.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Popover from '$lib/components/ui/popover';
import { AmazonS3Icon, BackblazeIcon, AzureBlobIcon, GoogleCloudIcon, RestServerIcon } from '$lib/components/cloud-icons';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
interface Destination {
id: number;
name: string;
repository: string;
envVars?: Record<string, string>;
flags?: string;
lastTestStatus?: string | null;
createdAt: string;
updatedAt: string;
}
interface Props {
open: boolean;
destination?: Destination | null;
existingDestinations?: Destination[];
onClose: () => void;
onSaved: () => void;
}
let { open = $bindable(), destination = null, existingDestinations = [], onClose, onSaved }: Props = $props();
const isEditing = $derived(destination !== null);
// Stats for existing destinations
let repoStats = $state<{ totalSize: number; totalFiles: number; snapshots: number } | null>(null);
let loadingStats = $state(false);
async function fetchStats() {
if (!destination?.id) return;
loadingStats = true;
try {
const res = await fetch(`/api/backup/destinations/${destination.id}/task`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task: 'stats' })
});
const data = await res.json();
if (data.success && data.stats) repoStats = data.stats;
} catch {} finally { loadingStats = false; }
}
$effect(() => {
if (open && destination?.id) {
repoStats = null;
fetchStats();
}
});
interface FormField {
key: string;
label: string;
placeholder: string;
secret?: boolean;
envKey?: string; // if set, stored as env var instead of part of repo URL
}
interface BackendType {
value: string;
label: string;
icon: any;
fields: FormField[];
buildRepo: (fields: Record<string, string>) => string;
parseRepo: (repo: string) => Record<string, string>;
}
const backendTypes: BackendType[] = [
{
value: 'local', label: 'Local path', icon: HardDrive,
fields: [
{ key: 'path', label: 'Path', placeholder: '/mnt/backups/dockhand' }
],
buildRepo: (f) => f.path || '',
parseRepo: (repo) => ({ path: repo })
},
{
value: 's3', label: 'Amazon S3', icon: AmazonS3Icon,
fields: [
{ key: 'endpoint', label: 'Endpoint', placeholder: 's3.amazonaws.com (or http://minio:9000)' },
{ key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' },
{ key: 'path', label: 'Path (optional)', placeholder: 'dockhand' },
{ key: 'accessKey', label: 'Access key ID', placeholder: '', envKey: 'AWS_ACCESS_KEY_ID' },
{ key: 'secretKey', label: 'Secret access key', placeholder: '', secret: true, envKey: 'AWS_SECRET_ACCESS_KEY' }
],
buildRepo: (f) => {
const endpoint = f.endpoint || 's3.amazonaws.com';
const path = f.path ? `/${f.path}` : '';
return `s3:${endpoint.includes('://') ? '' : 'https://'}${endpoint}/${f.bucket}${path}`;
},
parseRepo: (repo) => {
// s3:https://s3.amazonaws.com/bucket/path or s3:http://minio:9000/bucket/path
const after = repo.replace(/^s3:/, '');
try {
const url = new URL(after.startsWith('http') ? after : `https://${after}`);
const parts = url.pathname.replace(/^\//, '').split('/');
const bucket = parts[0] || '';
const path = parts.slice(1).join('/');
const endpoint = after.startsWith('http') ? `${url.protocol}//${url.host}` : url.host;
return { endpoint, bucket, path };
} catch {
return { endpoint: '', bucket: '', path: '' };
}
}
},
{
value: 'b2', label: 'Backblaze B2', icon: BackblazeIcon,
fields: [
{ key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' },
{ key: 'path', label: 'Path (optional)', placeholder: 'dockhand' },
{ key: 'accountId', label: 'Account ID', placeholder: '', envKey: 'B2_ACCOUNT_ID' },
{ key: 'accountKey', label: 'Application key', placeholder: '', secret: true, envKey: 'B2_ACCOUNT_KEY' }
],
buildRepo: (f) => `b2:${f.bucket}:${f.path || ''}`.replace(/:$/, ''),
parseRepo: (repo) => {
const after = repo.replace(/^b2:/, '');
const [bucket, ...rest] = after.split(':');
return { bucket: bucket || '', path: rest.join(':') };
}
},
{
value: 'azure', label: 'Azure Blob', icon: AzureBlobIcon,
fields: [
{ key: 'container', label: 'Container', placeholder: 'my-backup-container' },
{ key: 'path', label: 'Path (optional)', placeholder: 'dockhand' },
{ key: 'accountName', label: 'Account name', placeholder: '', envKey: 'AZURE_ACCOUNT_NAME' },
{ key: 'accountKey', label: 'Account key', placeholder: '', secret: true, envKey: 'AZURE_ACCOUNT_KEY' }
],
buildRepo: (f) => `azure:${f.container}:${f.path || ''}`.replace(/:$/, ''),
parseRepo: (repo) => {
const after = repo.replace(/^azure:/, '');
const [container, ...rest] = after.split(':');
return { container: container || '', path: rest.join(':') };
}
},
{
value: 'gs', label: 'Google Cloud', icon: GoogleCloudIcon,
fields: [
{ key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' },
{ key: 'path', label: 'Path (optional)', placeholder: '/dockhand' },
{ key: 'accessToken', label: 'Access token', placeholder: '', secret: true, envKey: 'GOOGLE_ACCESS_TOKEN' }
],
buildRepo: (f) => `gs:${f.bucket}:${f.path || '/'}`,
parseRepo: (repo) => {
const after = repo.replace(/^gs:/, '');
const [bucket, ...rest] = after.split(':');
return { bucket: bucket || '', path: rest.join(':') };
}
},
{
value: 'rest', label: 'REST server', icon: RestServerIcon,
fields: [
{ key: 'url', label: 'Server URL', placeholder: 'https://backup-server:8000/repo-name' }
],
buildRepo: (f) => `rest:${f.url || ''}`,
parseRepo: (repo) => ({ url: repo.replace(/^rest:/, '') })
}
];
let formName = $state('');
let formBackendType = $state('local');
let formFields = $state<Record<string, string>>({});
let formPassword = $state('');
let formFlags = $state('');
let formError = $state('');
let formSaving = $state(false);
// Policies
let policyPruneEnabled = $state(true);
let policyPruneSchedule = $state('0 0 1 * *');
let policyPruneMaxUnused = $state('10');
let policyCheckEnabled = $state(true);
let policyCheckSchedule = $state('0 0 1 * *');
let policyVerifyEnabled = $state(false);
let policyVerifySchedule = $state('0 0 1 * *');
let policyVerifyDataSubset = $state('5%');
let policyAutoUnlock = $state(true);
let testing = $state(false);
let initializing = $state(false);
const selectedBackend = $derived(backendTypes.find(b => b.value === formBackendType) ?? backendTypes[0]);
const repoFields = $derived(selectedBackend.fields.filter(f => !f.envKey));
const credentialFields = $derived(selectedBackend.fields.filter(f => f.envKey));
function detectBackendType(repo: string): string {
for (const prefix of ['s3:', 'b2:', 'azure:', 'gs:', 'rest:']) {
if (repo.startsWith(prefix)) return prefix.slice(0, -1);
}
return 'local';
}
function resetForm() {
formName = ''; formBackendType = 'local'; formFields = {}; formPassword = '';
formFlags = ''; formError = '';
formSaving = false;
policyPruneEnabled = true; policyPruneSchedule = '0 0 1 * *'; policyPruneMaxUnused = '10';
policyCheckEnabled = true; policyCheckSchedule = '0 0 1 * *';
policyVerifyEnabled = false; policyVerifySchedule = '0 0 1 * *'; policyVerifyDataSubset = '5%';
policyAutoUnlock = true;
}
$effect(() => {
if (open) {
if (destination) {
formName = destination.name;
const type = detectBackendType(destination.repository);
formBackendType = type;
const backend = backendTypes.find(b => b.value === type);
// Parse repo URL into fields, then merge credential env vars
const fields = backend ? backend.parseRepo(destination.repository) : {};
if (destination.envVars) {
for (const [key, value] of Object.entries(destination.envVars)) {
const field = backend?.fields.find(f => f.envKey === key);
if (field) fields[field.key] = value as string;
}
}
formFields = fields;
formPassword = '';
formFlags = destination.flags || '';
formError = '';
// Load policies
const pol = destination.policies ? (() => { try { return JSON.parse(destination.policies); } catch { return {}; } })() : {};
policyPruneEnabled = pol.pruneEnabled ?? true;
policyPruneSchedule = pol.pruneSchedule || '0 0 1 * *';
policyPruneMaxUnused = pol.pruneMaxUnused || '10';
policyCheckEnabled = pol.checkEnabled ?? true;
policyCheckSchedule = pol.checkSchedule || '0 0 1 * *';
policyVerifyEnabled = pol.verifyEnabled ?? false;
policyVerifySchedule = pol.verifySchedule || '0 0 1 * *';
policyVerifyDataSubset = pol.verifyDataSubset || '5%';
policyAutoUnlock = pol.autoUnlock ?? true;
} else { resetForm(); }
}
});
let generatedOk = $state(false);
let copiedOk = $state(false);
function generatePassword() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&*-_=+';
// Rejection sampling to avoid modulo bias: 256 % 72 = 40, so bytes >= 216
// are discarded and re-drawn, keeping every character uniformly likely.
const max = 256 - (256 % chars.length); // 216
const result: string[] = [];
while (result.length < 32) {
const batch = new Uint8Array(32);
crypto.getRandomValues(batch);
for (const b of batch) {
if (b >= max) continue;
result.push(chars[b % chars.length]);
if (result.length === 32) break;
}
}
formPassword = result.join('');
generatedOk = true;
setTimeout(() => { generatedOk = false; }, 1500);
}
function copyPassword() {
copyToClipboard(formPassword);
copiedOk = true;
setTimeout(() => { copiedOk = false; }, 1500);
}
function handleBackendChange(value: string) {
formBackendType = value;
formFields = {};
}
async function testConnection() {
testing = true;
try {
let payload: Record<string, unknown>;
if (isEditing) {
// Use saved credentials from DB
payload = { destinationId: destination!.id };
} else {
const repository = selectedBackend.buildRepo(formFields);
if (!repository.trim()) { toast.error('Fill in repository fields before testing'); testing = false; return; }
if (!formPassword.trim()) { toast.error('Enter encryption password before testing'); testing = false; return; }
const envVars: Record<string, string> = {};
for (const field of selectedBackend.fields) {
if (field.envKey && formFields[field.key]?.trim()) envVars[field.envKey] = formFields[field.key];
}
payload = { repository, password: formPassword, envVars: Object.keys(envVars).length > 0 ? envVars : undefined };
}
const res = await fetch('/api/backup/destinations/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success && data.needsInit) {
toast.info(data.message || 'Connection works, but repository needs initialization');
} else {
toast[res.ok && data.success ? 'success' : 'error'](data.success ? 'Connection test successful' : (data.error || 'Connection test failed'));
}
} catch { toast.error('Connection test failed'); } finally { testing = false; }
}
async function initRepo() {
if (!destination) return;
initializing = true;
try {
const res = await fetch(`/api/backup/destinations/${destination.id}/init`, { method: 'POST' });
const data = await res.json();
toast[res.ok && data.success ? 'success' : 'error'](data.success ? 'Repository initialized' : (data.error || 'Init failed'));
} catch { toast.error('Init failed'); } finally { initializing = false; }
}
// Normalize a restic repository string for loose equality: lowercase, drop
// trailing slashes, collapse internal whitespace. Good enough to catch the
// common "same repo, two rows" mistake (s3:https://host/bucket vs the same
// with a trailing slash). This only gates a non-blocking WARNING, so a missed
// match just means no warning — never a false block.
function normalizeRepo(repo: string): string {
return repo.trim().toLowerCase().replace(/\/+$/, '').replace(/\s+/g, ' ');
}
// Set to the colliding destination's name when the entered repo matches an
// existing destination's repo. Shows a warning + flips Save to "Save anyway".
let repoConflictName = $state<string | null>(null);
// Editing the repo after the warning appeared clears it, so the user must
// re-confirm against whatever repo they end up with (not a stale acknowledgement).
const currentRepo = $derived(selectedBackend.buildRepo(formFields));
$effect(() => {
currentRepo; // track
repoConflictName = null;
});
async function save() {
if (!formName.trim()) { formError = 'Name is required'; return; }
const repository = selectedBackend.buildRepo(formFields);
if (!repository.trim()) { formError = 'Repository fields are incomplete'; return; }
// Warn (once) if this repo is already used by another destination. Backups
// to a shared repo are serialized (restic locks the repo), not parallel —
// legitimate for rotation/migration, but usually a mistake. Non-blocking:
// a second click on "Save anyway" proceeds.
if (!repoConflictName) {
const norm = normalizeRepo(repository);
const clash = existingDestinations.find(
(d) => d.id !== destination?.id && normalizeRepo(d.repository) === norm
);
if (clash) { repoConflictName = clash.name; return; }
}
formSaving = true; formError = '';
try {
const envVars: Record<string, string> = {};
for (const field of selectedBackend.fields) {
if (field.envKey && formFields[field.key]?.trim()) {
envVars[field.envKey] = formFields[field.key];
}
}
const policies = {
pruneEnabled: policyPruneEnabled,
pruneSchedule: policyPruneSchedule,
pruneMaxUnused: policyPruneMaxUnused,
checkEnabled: policyCheckEnabled,
checkSchedule: policyCheckSchedule,
verifyEnabled: policyVerifyEnabled,
verifySchedule: policyVerifySchedule,
verifyDataSubset: policyVerifyDataSubset,
autoUnlock: policyAutoUnlock
};
const body: Record<string, unknown> = {
name: formName.trim(), repository,
envVars: Object.keys(envVars).length > 0 ? envVars : undefined,
flags: formFlags.trim() || undefined,
policies: JSON.stringify(policies)
};
if (formPassword || !isEditing) body.password = formPassword || undefined;
const res = await fetch(isEditing ? `/api/backup/destinations/${destination!.id}` : '/api/backup/destinations', {
method: isEditing ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
if (res.ok) { open = false; onSaved(); toast.success(isEditing ? 'Destination updated' : 'Destination created'); }
else { const data = await res.json(); formError = data.error || 'Failed'; }
} catch { formError = 'Failed'; } finally { formSaving = false; }
}
</script>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { formError = ''; repoConflictName = null; focusFirstInput(); } }}>
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title>{isEditing ? 'Edit backup destination' : 'Add backup destination'}</Dialog.Title>
<Dialog.Description>{isEditing ? 'Update backup destination settings' : 'Configure a restic backup repository'}</Dialog.Description>
</Dialog.Header>
{#if formError}<p class="text-sm text-destructive">{formError}</p>{/if}
<div class="flex-1 overflow-y-auto pr-3">
{#if selectedBackend.value === 'local'}
<div class="flex items-start gap-2 p-2.5 mt-4 rounded-md bg-amber-500/10 border border-amber-500/20 text-xs text-amber-600 dark:text-amber-400">
<HardDrive class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<span>Local path repositories can only be used to back up and restore containers running on the same Docker host as Dockhand. For remote environments or cross-env restore, use S3, REST, or another remote backend.</span>
</div>
{/if}
<div class="grid grid-cols-2 gap-6 py-4">
<!-- Left column: connection -->
<div class="space-y-4">
<div class="space-y-2">
<Label for="dest-name">Name</Label>
<Input id="dest-name" bind:value={formName} />
</div>
<div class="space-y-2">
<Label>Backend type</Label>
<Select.Root type="single" value={formBackendType} onValueChange={handleBackendChange}>
<Select.Trigger class="w-full">
<span class="flex items-center gap-2">
<selectedBackend.icon class="w-4 h-4 text-muted-foreground" />
{selectedBackend.label}
</span>
</Select.Trigger>
<Select.Content>
{#each backendTypes as backend}
<Select.Item value={backend.value}>
<span class="flex items-center gap-2">
<backend.icon class="w-4 h-4 text-muted-foreground" />
{backend.label}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#each repoFields as field}
<div class="space-y-1">
<Label for="field-{field.key}">{field.label}</Label>
<Input
id="field-{field.key}"
value={formFields[field.key] ?? ''}
oninput={(e: Event) => { formFields[field.key] = (e.target as HTMLInputElement).value; }}
placeholder={field.placeholder}
/>
</div>
{/each}
</div>
<!-- Right column: credentials & security -->
<div class="space-y-4">
{#each credentialFields as field}
{#if field.key === 'skipHostKey'}
<div class="flex items-center justify-between">
<Label>{field.label}</Label>
<TogglePill checked={formFields[field.key] === 'true'} onchange={() => { formFields[field.key] = formFields[field.key] === 'true' ? '' : 'true'; }} />
</div>
{:else}
<div class="space-y-1">
<Label for="field-{field.key}">{field.label}</Label>
<Input
id="field-{field.key}"
type={field.secret ? 'password' : 'text'}
value={formFields[field.key] ?? ''}
oninput={(e: Event) => { formFields[field.key] = (e.target as HTMLInputElement).value; }}
placeholder={isEditing && field.secret ? '(leave blank to keep current)' : field.placeholder}
/>
</div>
{/if}
{/each}
<div class="space-y-2">
<Label for="dest-password">Encryption password</Label>
<div class="flex gap-1.5">
<Input id="dest-password" type="password" bind:value={formPassword} placeholder={isEditing ? '(leave blank to keep current)' : ''} class="flex-1" />
<Button variant="outline" size="sm" class="h-9 px-2 shrink-0" onclick={generatePassword} title="Generate strong password">
{#if generatedOk}<Check class="w-3.5 h-3.5 text-green-500" />{:else}<Dices class="w-3.5 h-3.5" />{/if}
</Button>
{#if formPassword}
<Button variant="outline" size="sm" class="h-9 px-2 shrink-0" onclick={copyPassword} title="Copy password">
{#if copiedOk}<Check class="w-3.5 h-3.5 text-green-500" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</Button>
{/if}
</div>
<p class="text-xs text-muted-foreground">Restic encrypts all data with this password. You will need it to restore.</p>
</div>
<div class="space-y-2">
<Label for="dest-flags">Extra restic flags</Label>
<Input id="dest-flags" bind:value={formFlags} />
<div class="text-[10px] text-muted-foreground space-y-0.5">
<p>Common flags:</p>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 font-mono">
<span class="cursor-pointer hover:text-foreground" onclick={() => { formFlags = (formFlags + ' --limit-upload 5120').trim(); }} title="Limit upload speed to 5 MB/s">--limit-upload</span>
<span class="cursor-pointer hover:text-foreground" onclick={() => { formFlags = (formFlags + ' --limit-download 10240').trim(); }} title="Limit download speed to 10 MB/s">--limit-download</span>
<span class="cursor-pointer hover:text-foreground" onclick={() => { formFlags = (formFlags + ' --verbose').trim(); }} title="Verbose output">--verbose</span>
<span class="cursor-pointer hover:text-foreground" onclick={() => { formFlags = (formFlags + ' --compression max').trim(); }} title="Maximum compression">--compression max</span>
</div>
</div>
</div>
</div>
</div>
<!-- Policies section -->
<div class="border-t pt-3 mt-2 space-y-3">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Repository policies</span>
<!-- Prune policy -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<Clock class="w-3.5 h-3.5 text-muted-foreground" />
<Label class="text-sm">Scheduled prune</Label>
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root><Tooltip.Trigger><CircleHelp class="w-3 h-3 text-muted-foreground/50 cursor-help" /></Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content side="right" class="!w-64 text-xs">Removes unreferenced data from the repository. Frees disk space after snapshots are forgotten. Run monthly for most repos.</Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<TogglePill bind:checked={policyPruneEnabled} />
</div>
{#if policyPruneEnabled}
<div class="pl-5 space-y-2">
<CronEditor value={policyPruneSchedule} onchange={(v) => policyPruneSchedule = v} />
<div class="flex items-center gap-2">
<label class="text-[10px] text-muted-foreground">Max unused %</label>
<Input bind:value={policyPruneMaxUnused} type="number" min="0" max="100" class="h-8 text-xs w-20" />
</div>
</div>
{/if}
</div>
<!-- Check policy -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<PackageCheck class="w-3.5 h-3.5 text-muted-foreground" />
<Label class="text-sm">Scheduled integrity check</Label>
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root><Tooltip.Trigger><CircleHelp class="w-3 h-3 text-muted-foreground/50 cursor-help" /></Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content side="right" class="!w-64 text-xs">Verifies the structure and integrity of the repository. Detects corruption or missing data. Run monthly or after storage issues.</Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<TogglePill bind:checked={policyCheckEnabled} />
</div>
{#if policyCheckEnabled}
<div class="pl-5">
<CronEditor value={policyCheckSchedule} onchange={(v) => policyCheckSchedule = v} />
</div>
{/if}
</div>
<!-- Data verification policy -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<FolderCheck class="w-3.5 h-3.5 text-muted-foreground" />
<Label class="text-sm">Scheduled data verification</Label>
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root><Tooltip.Trigger><CircleHelp class="w-3 h-3 text-muted-foreground/50 cursor-help" /></Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content side="right" class="!w-64 text-xs">Reads a random subset of data packs to verify they're readable and intact. Catches bit rot and storage corruption. Slower and uses bandwidth on cloud repos. Off by default.</Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<TogglePill bind:checked={policyVerifyEnabled} />
</div>
{#if policyVerifyEnabled}
<div class="pl-5 space-y-2">
<CronEditor value={policyVerifySchedule} onchange={(v) => policyVerifySchedule = v} />
<div class="flex items-center gap-2">
<label class="text-[10px] text-muted-foreground">Data %</label>
<Select.Root type="single" value={policyVerifyDataSubset} onValueChange={(v) => policyVerifyDataSubset = v}>
<Select.Trigger class="h-8 w-20 text-xs">{policyVerifyDataSubset}</Select.Trigger>
<Select.Content>
<Select.Item value="5%">5%</Select.Item>
<Select.Item value="10%">10%</Select.Item>
<Select.Item value="25%">25%</Select.Item>
<Select.Item value="50%">50%</Select.Item>
<Select.Item value="100%">100%</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
{/if}
</div>
<!-- Auto-unlock -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<Unlock class="w-3.5 h-3.5 text-muted-foreground" />
<Label class="text-sm">Auto-unlock stale locks</Label>
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root><Tooltip.Trigger><CircleHelp class="w-3 h-3 text-muted-foreground/50 cursor-help" /></Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content side="right" class="!w-64 text-xs">Automatically removes stale repository locks before prune and check operations. Locks can be left behind by interrupted or crashed backups.</Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<TogglePill bind:checked={policyAutoUnlock} />
</div>
</div>
<!-- Stats section (only for existing destinations) -->
{#if isEditing}
<div class="border-t pt-3 mt-2">
<div class="flex items-center gap-2 mb-2">
<BarChart3 class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Repository usage</span>
{#if loadingStats}
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
{/if}
</div>
{#if repoStats}
<div class="grid grid-cols-3 gap-2">
<div class="bg-muted/30 rounded px-2 py-1.5 text-center border border-border/30">
<div class="text-sm font-semibold">{formatBytes(repoStats.totalSize)}</div>
<div class="text-[9px] text-muted-foreground">Size</div>
</div>
<div class="bg-muted/30 rounded px-2 py-1.5 text-center border border-border/30">
<div class="text-sm font-semibold">{repoStats.totalFiles.toLocaleString()}</div>
<div class="text-[9px] text-muted-foreground">Files</div>
</div>
<div class="bg-muted/30 rounded px-2 py-1.5 text-center border border-border/30">
<div class="text-sm font-semibold">{repoStats.snapshots}</div>
<div class="text-[9px] text-muted-foreground">Snapshots</div>
</div>
</div>
<!-- Usage bar -->
{#if repoStats.totalSize > 0}
<div class="h-2 bg-muted rounded-full overflow-hidden">
<div class="h-full bg-primary/60 rounded-full transition-all" style="width: {Math.min(100, (repoStats.totalSize / (1024 * 1024 * 1024)) * 10)}%"></div>
</div>
<p class="text-[10px] text-muted-foreground mt-1">{repoStats.snapshots} snapshots across {repoStats.totalFiles.toLocaleString()} files</p>
{/if}
{:else if !loadingStats}
<p class="text-xs text-muted-foreground">Loading repository stats...</p>
{/if}
</div>
{/if}
</div>
{#if repoConflictName}
<div class="flex-shrink-0 flex gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle class="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>
This repository is already used by <span class="font-medium">"{repoConflictName}"</span>.
Backups to a shared repository run one at a time (restic locks the repo), not in parallel.
Click <span class="font-medium">Save anyway</span> to continue.
</span>
</div>
{/if}
<Dialog.Footer class="flex-shrink-0 border-t mt-auto pt-4">
<div class="flex gap-2 mr-auto">
<Button variant="outline" size="sm" onclick={testConnection} disabled={testing}>
{#if testing}<RefreshCw class="w-4 h-4 mr-1 animate-spin" />{:else}<Wifi class="w-4 h-4 mr-1" />{/if}
Test
</Button>
{#if isEditing}
<Button variant="outline" size="sm" class="{destination?.lastTestStatus === 'success' ? 'opacity-30' : ''}" onclick={initRepo} disabled={initializing} title={destination?.lastTestStatus === 'success' ? 'Already initialized' : 'Initialize repository'}>
{#if initializing}<RefreshCw class="w-4 h-4 mr-1 animate-spin" />{:else}<Database class="w-4 h-4 mr-1" />{/if}
Init repo
</Button>
{/if}
</div>
<Button variant="outline" onclick={() => { open = false; onClose(); }}>Cancel</Button>
<Button onclick={save} disabled={formSaving} variant={repoConflictName ? 'destructive' : 'default'}>
{#if formSaving}<RefreshCw class="w-4 h-4 mr-1 animate-spin" />{:else if repoConflictName}<AlertTriangle class="w-4 h-4 mr-1" />{:else if isEditing}<Check class="w-4 h-4" />{:else}<Plus class="w-4 h-4" />{/if}
{repoConflictName ? 'Save anyway' : isEditing ? 'Save' : 'Create'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,144 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { KeyRound, Loader2, AlertTriangle } from 'lucide-svelte';
interface Props {
open: boolean;
destinationId: number;
destinationName: string;
}
let { open = $bindable(), destinationId, destinationName }: Props = $props();
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let submitting = $state(false);
let dbOutOfSync = $state(false);
let errorMsg = $state<string | null>(null);
// Reset when the dialog opens for a new destination
$effect(() => {
if (open) {
currentPassword = '';
newPassword = '';
confirmPassword = '';
dbOutOfSync = false;
errorMsg = null;
}
});
const passwordsMatch = $derived(newPassword.length > 0 && newPassword === confirmPassword);
const canSubmit = $derived(
!submitting &&
currentPassword.length > 0 &&
newPassword.length > 0 &&
passwordsMatch &&
newPassword !== currentPassword
);
async function submit() {
errorMsg = null;
dbOutOfSync = false;
submitting = true;
try {
const res = await fetch(`/api/backup/destinations/${destinationId}/rotate-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
toast.success(`Password rotated for "${destinationName}"`);
open = false;
return;
}
if (data.dbOutOfSync) {
dbOutOfSync = true;
errorMsg = data.error || 'Repository was rotated but Dockhand could not save the new password.';
return;
}
errorMsg = data.error || `Rotation failed (HTTP ${res.status})`;
} catch (err) {
errorMsg = err instanceof Error ? err.message : String(err);
} finally {
submitting = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<KeyRound class="w-4 h-4" />
Rotate repository password
</Dialog.Title>
<Dialog.Description>
Change the restic encryption password for <strong>{destinationName}</strong>. The current
password is required for verification.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-3 py-2">
<div class="space-y-1">
<Label for="rotate-current">Current password</Label>
<Input id="rotate-current" type="password" bind:value={currentPassword} autocomplete="current-password" />
</div>
<div class="space-y-1">
<Label for="rotate-new">New password</Label>
<Input id="rotate-new" type="password" bind:value={newPassword} autocomplete="new-password" />
</div>
<div class="space-y-1">
<Label for="rotate-confirm">Confirm new password</Label>
<Input id="rotate-confirm" type="password" bind:value={confirmPassword} autocomplete="new-password" />
{#if confirmPassword.length > 0 && !passwordsMatch}
<p class="text-xs text-destructive">Passwords do not match</p>
{/if}
{#if newPassword.length > 0 && newPassword === currentPassword}
<p class="text-xs text-destructive">New password must differ from current</p>
{/if}
</div>
{#if errorMsg}
<div class="rounded border border-destructive/40 bg-destructive/10 p-3 text-sm">
<div class="flex items-start gap-2">
<AlertTriangle class="w-4 h-4 mt-0.5 text-destructive shrink-0" />
<div>
<p class="font-medium text-destructive">
{#if dbOutOfSync}
Repository rotated, database update failed
{:else}
Rotation failed
{/if}
</p>
<p class="text-muted-foreground mt-1 break-words">{errorMsg}</p>
{#if dbOutOfSync}
<p class="text-muted-foreground mt-2">
Open <strong>Edit destination</strong> and manually set the password to the new
value so Dockhand can talk to the repository again.
</p>
{/if}
</div>
</div>
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)} disabled={submitting}>Cancel</Button>
<Button onclick={submit} disabled={!canSubmit}>
{#if submitting}
<Loader2 class="w-3 h-3 mr-2 animate-spin" />
Rotating…
{:else}
Rotate password
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,130 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { ShieldCheck, Loader2, CheckCircle, XCircle } from 'lucide-svelte';
import { watchJob } from '$lib/utils/sse-fetch';
interface Props {
open: boolean;
destinationId: number;
destinationName: string;
}
let { open = $bindable(), destinationId, destinationName }: Props = $props();
let dataSubset = $state('5%');
let running = $state(false);
let status = $state<'idle' | 'running' | 'success' | 'error'>('idle');
let logs = $state<string[]>([]);
let error = $state('');
$effect(() => {
if (open) {
status = 'idle';
running = false;
logs = [];
error = '';
dataSubset = '5%';
}
});
async function runVerify() {
running = true;
status = 'running';
logs = [`Starting verification (reading ${dataSubset} of data)...`];
error = '';
try {
const res = await fetch(`/api/backup/destinations/${destinationId}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dataSubset })
});
const data = await res.json();
if (data.jobId) {
await watchJob(data.jobId, (line) => {
if (line.event === 'progress' && line.data?.message) {
logs = [...logs, line.data.message];
}
});
status = 'success';
logs = [...logs, 'Verification passed'];
} else if (data.error) {
status = 'error';
error = data.error;
} else {
status = data.success ? 'success' : 'error';
if (!data.success) error = data.error || 'Unknown error';
}
} catch (err: any) {
status = 'error';
error = err.message || 'Verification failed';
} finally {
running = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-lg max-h-[70vh] flex flex-col">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<ShieldCheck class="w-4 h-4" />Verify backup integrity
</Dialog.Title>
<Dialog.Description>{destinationName}</Dialog.Description>
</Dialog.Header>
{#if status === 'idle'}
<div class="space-y-3 py-2">
<p class="text-xs text-muted-foreground">
Reads a random subset of data packs from the repository and verifies they are intact and readable. Larger subsets take longer but provide more thorough verification.
</p>
<div class="flex items-center gap-3">
<span class="text-xs text-muted-foreground shrink-0">Data to verify:</span>
<Select.Root type="single" value={dataSubset} onValueChange={(v) => dataSubset = v}>
<Select.Trigger class="h-8 w-28 text-xs">{dataSubset}</Select.Trigger>
<Select.Content>
<Select.Item value="5%">5% (quick)</Select.Item>
<Select.Item value="10%">10%</Select.Item>
<Select.Item value="25%">25%</Select.Item>
<Select.Item value="50%">50%</Select.Item>
<Select.Item value="100%">100% (full)</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onclick={() => open = false}>Cancel</Button>
<Button size="sm" onclick={runVerify}>
<ShieldCheck class="w-3.5 h-3.5 mr-1.5" />Verify
</Button>
</div>
{:else}
<div class="flex-1 overflow-y-auto border rounded bg-muted/30 p-2 font-mono text-[11px] space-y-0.5 min-h-[200px]">
{#each logs as line}
<div class="text-muted-foreground">{line}</div>
{/each}
{#if status === 'running'}
<div class="flex items-center gap-1.5 text-primary">
<Loader2 class="w-3 h-3 animate-spin" />Running...
</div>
{/if}
{#if status === 'success'}
<div class="flex items-center gap-1.5 text-green-500 font-medium mt-2">
<CheckCircle class="w-3.5 h-3.5" />Verification passed
</div>
{/if}
{#if status === 'error'}
<div class="flex items-center gap-1.5 text-destructive font-medium mt-2">
<XCircle class="w-3.5 h-3.5" />{error}
</div>
{/if}
</div>
<div class="flex justify-end pt-2">
<Button variant="outline" size="sm" onclick={() => open = false}>Close</Button>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,275 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import CronEditor from '$lib/components/cron-editor.svelte';
import { Box, Layers, Loader2, Plus, ChevronDown, ChevronRight, ArrowRight, Zap, GitBranch } from 'lucide-svelte';
import { formatCron, getRepoTypeIcon, isLocalRepo, isRemoteEnvironment } from '$lib/utils/backup';
import { standaloneContainers, volumesForStack, type BackupItem } from '$lib/utils/mounts';
import BackupPanel from '../../containers/BackupPanel.svelte';
interface Props {
environmentId: number;
environmentName: string;
connectionType?: string;
host?: string | null;
}
let { environmentId, environmentName, connectionType, host }: Props = $props();
interface BackupConfig { id: number; targetName: string; type: string; destinationId: number; schedule: string | null; destinationName?: string; }
// `external` = an untracked stack (unknown compose path). Not backup-able — shown
// disabled, excluded from configure / batch, per assertStackBackupable.
// `git` = a git-deployed stack (marked with a small "git" pill).
type BackupListItem = BackupItem & { external?: boolean; git?: boolean };
let items = $state<BackupListItem[]>([]);
let configs = $state<BackupConfig[]>([]);
let destinations = $state<Map<number, { name: string; repository: string }>>(new Map());
let destList = $state<{ id: number; name: string; repository: string }[]>([]);
let loading = $state(true);
let expandedItem = $state<string | null>(null);
// Batch setup
let showBatch = $state(false);
let batchDestId = $state<number>(0);
let batchSchedule = $state('0 2 * * *');
let batchSaving = $state(false);
const isRemote = $derived(isRemoteEnvironment({ connectionType, host }));
const usableDestinations = $derived(isRemote ? destList.filter(d => !isLocalRepo(d.repository)) : destList);
// External stacks can't be backed up — exclude them from the unconfigured count
// and from "Schedule all" so the number reflects only backup-able targets. The
// list mixes stacks and standalone containers, and "Schedule all" configs BOTH,
// so split the count by type to make clear what the batch will create.
const unconfiguredItems = $derived(items.filter(i => !i.external && !configs.some(c => c.targetName === i.name)));
const unconfiguredStacks = $derived(unconfiguredItems.filter(i => i.type === 'stack').length);
const unconfiguredContainers = $derived(unconfiguredItems.filter(i => i.type === 'container').length);
const unconfiguredCount = $derived(unconfiguredItems.length);
/** "3 stacks + 5 containers" / "3 stacks" / "5 containers" — only the non-zero parts. */
const unconfiguredLabel = $derived([
unconfiguredStacks > 0 ? `${unconfiguredStacks} ${unconfiguredStacks === 1 ? 'stack' : 'stacks'}` : '',
unconfiguredContainers > 0 ? `${unconfiguredContainers} ${unconfiguredContainers === 1 ? 'container' : 'containers'}` : '',
].filter(Boolean).join(' + '));
async function fetchAll() {
loading = true;
try {
const [containerRes, stackRes, configRes, destRes] = await Promise.all([
fetch(`/api/containers?env=${environmentId}`),
fetch(`/api/stacks?env=${environmentId}`),
fetch(`/api/backup/configs?env=${environmentId}`),
fetch('/api/backup/destinations')
]);
// Stacks come from /api/stacks (knows sourceType, includes stopped stacks);
// standalone containers from /api/containers. Reconstructing stacks from
// container labels (the old path) couldn't see sourceType and dropped any
// stopped stack. An external/untracked stack (unknown compose path) is
// listed but flagged not backup-able.
const containers = containerRes.ok ? await containerRes.json() : [];
const stacks = stackRes.ok ? await stackRes.json() : [];
const stackItems: BackupListItem[] = (Array.isArray(stacks) ? stacks : []).map((s: any): BackupListItem => ({
name: s.name,
type: 'stack',
volumes: volumesForStack(containers, s.name),
external: s.sourceType !== 'internal' && s.sourceType !== 'git',
git: s.sourceType === 'git'
}));
items = [
...stackItems.sort((a, b) => a.name.localeCompare(b.name)),
...standaloneContainers(containers)
];
if (destRes.ok) {
const dests = await destRes.json();
destList = dests;
destinations = new Map(dests.map((d: any) => [d.id, { name: d.name, repository: d.repository }]));
}
if (configRes.ok) {
const raw = await configRes.json();
configs = Array.isArray(raw) ? raw : [];
}
} catch {}
loading = false;
}
function getItemConfigs(name: string): BackupConfig[] {
return configs.filter(c => c.targetName === name);
}
async function refreshConfigs() {
try {
const res = await fetch(`/api/backup/configs?env=${environmentId}`);
if (res.ok) {
const raw = await res.json();
configs = Array.isArray(raw) ? raw : [];
}
} catch {}
}
function toggleExpand(name: string) {
expandedItem = expandedItem === name ? null : name;
}
async function batchApply() {
if (!batchDestId) { toast.error('Select a repository'); return; }
batchSaving = true;
let created = 0;
for (const item of items) {
if (item.external) continue; // external stacks aren't backup-able
if (configs.some(c => c.targetName === item.name)) continue; // skip already configured
try {
const res = await fetch('/api/backup/configs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetName: item.name,
type: item.type,
destinationId: batchDestId,
environmentId,
schedule: batchSchedule || null,
retention: { keepLast: 7, keepDaily: 7 }
})
});
if (res.ok) created++;
} catch {}
}
toast.success(`Created ${created} backup schedule${created > 1 ? 's' : ''}`);
showBatch = false;
batchSaving = false;
await fetchAll();
}
onMount(fetchAll);
</script>
{#if loading}
<div class="flex items-center justify-center py-12">
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
{:else if items.length === 0}
<p class="text-sm text-muted-foreground py-4 text-center">No containers or stacks found on this environment.</p>
{:else}
<!-- Batch setup -->
{#if unconfiguredCount > 0}
{#if !showBatch}
<div class="mb-2">
<Button variant="outline" size="sm" class="w-full" onclick={() => { showBatch = true; if (!batchDestId && usableDestinations.length > 0) batchDestId = usableDestinations[0].id; }}>
<Zap class="w-3.5 h-3.5 mr-1.5" />Schedule all ({unconfiguredLabel})
</Button>
</div>
{:else}
<div class="mb-2 border rounded-md p-2.5 bg-muted/20 space-y-2">
<div class="flex items-center gap-2">
<Select.Root type="single" value={batchDestId ? String(batchDestId) : ''} onValueChange={(v) => batchDestId = v ? parseInt(v) : 0}>
<Select.Trigger class="h-9 text-xs w-[200px] flex-shrink-0">
{#if batchDestId}
{@const dest = destList.find(d => d.id === batchDestId)}
{#if dest}
{@const DIcon = getRepoTypeIcon(dest.repository)}
<span class="flex items-center gap-1.5 truncate"><DIcon class="w-3.5 h-3.5 text-primary/60 flex-shrink-0" />{dest.name}</span>
{/if}
{:else}Select repository{/if}
</Select.Trigger>
<Select.Content>
{#each usableDestinations as dest}
{@const DIcon = getRepoTypeIcon(dest.repository)}
<Select.Item value={String(dest.id)}>
<span class="flex items-center gap-1.5"><DIcon class="w-3.5 h-3.5 text-muted-foreground" />{dest.name}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<CronEditor value={batchSchedule} onchange={(v) => batchSchedule = v} />
</div>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" class="text-xs" onclick={() => showBatch = false}>Cancel</Button>
<Button size="sm" disabled={!batchDestId || batchSaving} onclick={batchApply}>
{#if batchSaving}<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />{/if}
Schedule {unconfiguredLabel}
</Button>
</div>
</div>
{/if}
{/if}
<div class="space-y-0 max-h-[440px] overflow-y-auto">
{#each items as item}
{@const itemConfigs = getItemConfigs(item.name)}
{@const hasBackup = itemConfigs.length > 0}
{@const isExpanded = expandedItem === item.name}
<div class="border-b last:border-0">
<!-- Row. External (untracked) stacks aren't backup-able: shown disabled,
no expand, a hint to adopt instead of a "configure" action. -->
<svelte:element
this={item.external ? 'div' : 'button'}
role={item.external ? undefined : 'button'}
class="flex items-center gap-2 w-full py-2 px-1 text-left rounded-sm {item.external ? 'opacity-60' : 'hover:bg-muted/30'}"
onclick={item.external ? undefined : () => toggleExpand(item.name)}
>
{#if item.external}
<span class="w-3.5 flex-shrink-0"></span>
{:else if isExpanded}
<ChevronDown class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
{:else}
<ChevronRight class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
{/if}
{#if item.type === 'stack'}
<Layers class="w-3.5 h-3.5 text-purple-500 flex-shrink-0" />
{:else}
<Box class="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />
{/if}
<span class="flex min-w-0 flex-1 items-center gap-1.5">
<span class="truncate text-sm">{item.name}</span>
{#if item.git}
<span class="inline-flex flex-shrink-0 items-center gap-1 rounded-full border border-purple-500/30 bg-purple-500/10 px-1.5 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400" title="Git-deployed stack"><GitBranch class="h-2.5 w-2.5" />git</span>
{/if}
{#if item.external}
<span class="inline-flex flex-shrink-0 items-center rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground" title="Untracked stack — can't be backed up until adopted">external</span>
{/if}
</span>
{#if item.external}
<span class="text-xs text-muted-foreground/60 flex-shrink-0" title="This stack is untracked — Dockhand doesn't know its compose file location, so it can't be backed up. Adopt it (Stacks → Adopt) first.">adopt first</span>
{:else if hasBackup}
<ArrowRight class="w-3 h-3 text-muted-foreground/40 flex-shrink-0" />
{#if itemConfigs.length === 1}
{@const cfg = itemConfigs[0]}
{@const dest = destinations.get(cfg.destinationId)}
<span class="flex items-center gap-1 text-xs text-muted-foreground flex-shrink-0">
{#if dest}
{@const DestIcon = getRepoTypeIcon(dest.repository)}
<DestIcon class="w-3 h-3 text-primary/60" />
{dest.name}
{/if}
{cfg.schedule ? formatCron(cfg.schedule) : 'manual'}
</span>
{:else}
<span class="text-xs text-muted-foreground flex-shrink-0">{itemConfigs.length} schedules</span>
{/if}
{:else}
<span class="text-xs text-muted-foreground/50 flex items-center gap-1 flex-shrink-0">
<Plus class="w-3 h-3" />configure
</span>
{/if}
</svelte:element>
<!-- Expanded: BackupPanel (never for external stacks — see the row above) -->
{#if isExpanded && !item.external}
<div class="ml-6 mr-1 mb-3 mt-1 p-3 border rounded-md bg-muted/20">
<BackupPanel
containerName={item.name}
volumes={item.volumes}
type={item.type}
environmentId={environmentId}
onConfigSaved={refreshConfigs}
/>
</div>
{/if}
</div>
{/each}
</div>
{/if}
@@ -62,6 +62,7 @@
ChevronRight,
XCircle,
ImageUp,
Archive,
Upload,
ArrowRight
} from 'lucide-svelte';
@@ -76,6 +77,8 @@
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { whale } from '@lucide/lab';
import ImagePullProgressPopover from '../../images/ImagePullProgressPopover.svelte';
import EnvironmentBackupsTab from './EnvironmentBackupsTab.svelte';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill';
import { ShieldOff } from 'lucide-svelte';
import { focusFirstInput } from '$lib/utils';
@@ -214,6 +217,16 @@
{ id: 'vulnerability_any', label: 'Any vulns found', description: 'Any vulnerabilities found (medium/low)' }
]
},
{
id: 'backup',
label: 'Backup events',
events: [
{ id: 'backup_success', label: 'Backup succeeded', description: 'Backup completed successfully' },
{ id: 'backup_failed', label: 'Backup failed', description: 'Backup failed' },
{ id: 'restore_success', label: 'Restore succeeded', description: 'Restore completed successfully' },
{ id: 'restore_failed', label: 'Restore failed', description: 'Restore failed' }
]
},
{
id: 'system',
label: 'System events',
@@ -1545,7 +1558,7 @@
</script>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) focusFirstInput(); else onClose(); }}>
<Dialog.Content class="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Header class="flex-shrink-0 border-b pb-4">
<Dialog.Title class="flex items-center gap-2">
{#if !isEditing}
@@ -1564,7 +1577,7 @@
{/if}
<Tabs.Root bind:value={modalTab} class="flex-1 flex flex-col overflow-hidden mt-4">
<Tabs.List class="flex-shrink-0 mb-0 w-full grid grid-cols-5">
<Tabs.List class="flex-shrink-0 mb-0 w-full grid grid-cols-6">
<Tabs.Trigger value="general" class="flex items-center justify-center gap-1.5">
<Globe class="w-3.5 h-3.5" />
General
@@ -1581,6 +1594,13 @@
<ShieldCheck class="w-3.5 h-3.5" />
Security
</Tabs.Trigger>
<!-- BETA GATE: Backups tab hidden unless FEAT_BACKUPS_ENABLED (see features.ts) -->
{#if $page.data.backupsEnabled}
<Tabs.Trigger value="backup" class="flex items-center justify-center gap-1.5">
<Archive class="w-3.5 h-3.5" />
Backups
</Tabs.Trigger>
{/if}
<Tabs.Trigger value="notifications" class="flex items-center justify-center gap-1.5">
<Bell class="w-3.5 h-3.5" />
Notifications
@@ -2705,6 +2725,20 @@
</Tabs.Content>
<!-- Notifications Tab -->
<!-- Backup Tab -->
<Tabs.Content value="backup" class="mt-0 h-full">
{#if environment?.id}
<EnvironmentBackupsTab
environmentId={environment.id}
environmentName={environment.name}
connectionType={environment.connectionType}
host={environment.host}
/>
{:else}
<p class="text-sm text-muted-foreground py-4">Save the environment first to configure backups.</p>
{/if}
</Tabs.Content>
<Tabs.Content value="notifications" class="mt-0 h-full flex flex-col">
<div class="flex items-center gap-2 text-sm font-medium flex-shrink-0">
<Bell class="w-4 h-4" />
@@ -7,9 +7,11 @@
Layers,
Shield,
HardDrive,
Archive,
ChevronDown,
ChevronRight
} from 'lucide-svelte';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
interface EventType {
id: string;
@@ -103,6 +105,17 @@
{ id: 'vulnerability_any', label: 'Any vulns found', description: 'Any vulnerabilities found (medium/low)' }
]
},
{
id: 'backup',
label: 'Backup events',
icon: Archive,
events: [
{ id: 'backup_success', label: 'Backup succeeded', description: 'Backup completed successfully' },
{ id: 'backup_failed', label: 'Backup failed', description: 'Backup failed' },
{ id: 'restore_success', label: 'Restore succeeded', description: 'Restore completed successfully' },
{ id: 'restore_failed', label: 'Restore failed', description: 'Restore failed' }
]
},
{
id: 'system',
label: 'System events',
@@ -151,7 +164,8 @@
</script>
<div class="space-y-2 max-h-[300px] overflow-y-auto pr-1">
{#each NOTIFICATION_EVENT_GROUPS as group (group.id)}
<!-- BETA GATE: drop the Backup events group unless FEAT_BACKUPS_ENABLED (see features.ts) -->
{#each NOTIFICATION_EVENT_GROUPS.filter((g) => g.id !== 'backup' || $page.data.backupsEnabled) as group (group.id)}
{@const isCollapsed = collapsedGroups.has(group.id)}
{@const selectedCount = getGroupSelectedCount(group)}
{@const allSelected = selectedCount === group.events.length}
+16 -5
View File
@@ -8,7 +8,7 @@
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, ChevronRight, ChevronDown } from 'lucide-svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, Archive, ChevronRight, ChevronDown } from 'lucide-svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode, type LabelFilterMode } from '$lib/stores/settings';
import { canAccess, authStore } from '$lib/stores/auth';
@@ -91,6 +91,7 @@ services:
let eventCollectionMode = $derived($appSettings.eventCollectionMode);
let eventPollInterval = $derived($appSettings.eventPollInterval);
let metricsCollectionInterval = $derived($appSettings.metricsCollectionInterval);
let defaultBackupImage = $derived($appSettings.defaultBackupImage);
let clearingCache = $state(false);
@@ -269,6 +270,14 @@ services:
toast.success(`Metrics interval: ${selected.value / 1000}s`);
}
}
function handleBackupImageBlur(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (value && value !== defaultBackupImage) {
appSettings.setDefaultBackupImage(value);
toast.success('Backup image updated');
}
}
</script>
<div class="flex-1 min-h-0 overflow-y-auto">
@@ -970,15 +979,17 @@ services:
onchange={handleScannerCleanupEnabledChange}
disabled={!$canAccess('settings', 'edit')}
/>
<div class="ml-auto">
</div>
<p class="text-xs text-muted-foreground">Remove cached vulnerability databases to reclaim disk space</p>
{#if scannerCleanupEnabled}
<div class="mt-2">
<CronEditor
value={scannerCleanupCron}
onchange={handleScannerCleanupCronChange}
disabled={!$canAccess('settings', 'edit') || !scannerCleanupEnabled}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
</div>
<p class="text-xs text-muted-foreground">Remove cached vulnerability databases to reclaim disk space</p>
{/if}
</div>
<div class="space-y-1 pt-2 border-t">
<div class="flex items-center gap-3">
@@ -6,15 +6,21 @@
import { Input } from '$lib/components/ui/input';
import { Badge } from '$lib/components/ui/badge';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Plus, Check, RefreshCw, Mail, Zap, Info, Send, CheckCircle2, XCircle, Key, ChevronDown, HelpCircle } from 'lucide-svelte';
import { Plus, Check, RefreshCw, Mail, Zap, Send, CheckCircle2, XCircle, Bell, HelpCircle, Settings } from 'lucide-svelte';
import * as Tabs from '$lib/components/ui/tabs';
import * as Tooltip from '$lib/components/ui/tooltip';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
// System-only events (configured at channel level, not per-environment)
const SYSTEM_EVENTS = [
{ id: 'license_expiring', label: 'License expiring', description: 'Enterprise license expiring soon' }
{ id: 'license_expiring', label: 'License expiring', description: 'Enterprise license expiring soon' },
{ id: 'repo_prune_success', label: 'Backup repository prune success', description: 'Scheduled repository prune completed successfully' },
{ id: 'repo_prune_failed', label: 'Backup repository prune failed', description: 'Scheduled repository prune failed' },
{ id: 'repo_check_success', label: 'Backup repository check success', description: 'Scheduled integrity check completed successfully' },
{ id: 'repo_check_failed', label: 'Backup repository check failed', description: 'Scheduled integrity check found errors or failed' },
{ id: 'repo_verify_success', label: 'Backup repository data verification success', description: 'Scheduled data verification completed successfully' },
{ id: 'repo_verify_failed', label: 'Backup repository data verification failed', description: 'Scheduled data verification found corruption or failed' }
] as const;
export interface NotificationSetting {
@@ -56,7 +62,7 @@
let formAppriseUrls = $state('');
// System events
let formSystemEvents = $state<string[]>([]);
let showSystemEvents = $state(false);
let activeTab = $state<'channel' | 'events'>('channel');
let formError = $state('');
let formSaving = $state(false);
let formTesting = $state(false);
@@ -78,7 +84,7 @@
formSmtpToEmails = '';
formAppriseUrls = '';
formSystemEvents = [];
showSystemEvents = false;
activeTab = 'channel';
formError = '';
formSaving = false;
formTesting = false;
@@ -114,7 +120,7 @@
// Load system events (filter to only system-scoped events)
const systemEventIds = SYSTEM_EVENTS.map(e => e.id);
formSystemEvents = (notification.eventTypes || []).filter(e => systemEventIds.includes(e as typeof SYSTEM_EVENTS[number]['id']));
showSystemEvents = formSystemEvents.length > 0;
activeTab = 'channel';
formError = '';
formSaving = false;
@@ -293,15 +299,32 @@
</script>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { formError = ''; focusFirstInput(); } }}>
<Dialog.Content class="max-w-3xl max-h-[90vh] overflow-y-auto">
<Dialog.Content class="max-w-3xl max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title>{isEditing ? 'Edit' : 'Add'} notification channel</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
{#if formError}
<div class="text-sm text-red-600 dark:text-red-400">{formError}</div>
{/if}
{#if formError}
<div class="text-sm text-red-600 dark:text-red-400">{formError}</div>
{/if}
<Tabs.Root bind:value={activeTab} class="flex-1 flex flex-col overflow-hidden mt-2">
<Tabs.List class="flex-shrink-0 mb-0 w-full grid grid-cols-2">
<Tabs.Trigger value="channel" class="flex items-center justify-center gap-1.5">
<Settings class="w-3.5 h-3.5" />
Channel
</Tabs.Trigger>
<Tabs.Trigger value="events" class="flex items-center justify-center gap-1.5">
<Bell class="w-3.5 h-3.5" />
System events
{#if formSystemEvents.length > 0}
<Badge variant="secondary" class="ml-1 h-4 px-1.5 text-[10px]">{formSystemEvents.length}</Badge>
{/if}
</Tabs.Trigger>
</Tabs.List>
<div class="overflow-y-auto pb-4 h-[520px]">
<Tabs.Content value="channel" class="space-y-4 mt-0">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="notif-name">Name *</Label>
@@ -442,48 +465,31 @@ jsons://hostname/webhook/path"
</div>
{/if}
<!-- System events configuration -->
<div class="border-t pt-4">
<button
type="button"
class="w-full flex items-center justify-between text-left"
onclick={() => showSystemEvents = !showSystemEvents}
>
<div class="flex items-center gap-2">
<Key class="w-4 h-4 text-muted-foreground" />
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Global system events</span>
</div>
<ChevronDown class="w-4 h-4 text-muted-foreground transition-transform {showSystemEvents ? 'rotate-180' : ''}" />
</button>
{#if showSystemEvents}
<div class="mt-3 space-y-2">
<p class="text-xs text-muted-foreground mb-3">
These events are not tied to specific environments and are configured globally here.
</p>
</Tabs.Content>
<Tabs.Content value="events" class="space-y-3 mt-0">
<p class="text-xs text-muted-foreground">
These events are not tied to specific environments and are configured globally here.
Environment-specific events (containers, stacks, auto-updates) are configured in each environment's settings.
</p>
<div class="space-y-1">
{#each SYSTEM_EVENTS as event}
<label class="flex items-start gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
<Checkbox
<div class="flex items-center gap-3 p-2 rounded hover:bg-muted/50">
<TogglePill
checked={formSystemEvents.includes(event.id)}
onCheckedChange={(checked) => toggleSystemEvent(event.id, !!checked)}
onchange={() => toggleSystemEvent(event.id, !formSystemEvents.includes(event.id))}
/>
<div class="flex-1 min-w-0">
<span class="text-sm font-medium">{event.label}</span>
<p class="text-xs text-muted-foreground">{event.description}</p>
</div>
</label>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
</div>
</Tabs.Root>
<!-- Info about per-env config -->
<div class="border-t pt-4">
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md p-3 flex items-start gap-2">
<Info class="w-4 h-4 mt-0.5 shrink-0" />
<span>Environment-specific events (containers, stacks, auto-updates) are configured in each environment's settings.</span>
</div>
</div>
</div>
<Dialog.Footer class="flex justify-between sm:justify-between">
<Button variant="outline" onclick={testConfig} disabled={formTesting || formSaving}>
{#if formTesting}