1.0.38
This commit is contained in:
177 files changed
+21182
-1042
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user