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

No files matched your search

+7 -3
View File
@@ -2,6 +2,7 @@ import type { LayoutServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { isAuthEnabled, validateSession } from '$lib/server/auth';
import { hasAdminUser } from '$lib/server/db';
import { BACKUPS_ENABLED } from '$lib/server/features';
// Routes that don't require authentication
const PUBLIC_PATHS = ['/login'];
@@ -18,7 +19,8 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
return {
authEnabled: false,
user: null,
disableWhatsNew
disableWhatsNew,
backupsEnabled: BACKUPS_ENABLED
};
}
@@ -37,7 +39,8 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
authEnabled: true,
user: null,
setupMode: true,
disableWhatsNew
disableWhatsNew,
backupsEnabled: BACKUPS_ENABLED
};
}
@@ -57,6 +60,7 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
isAdmin: user.isAdmin,
provider: user.provider
} : null,
disableWhatsNew
disableWhatsNew,
backupsEnabled: BACKUPS_ENABLED
};
};
+3 -3
View File
@@ -896,7 +896,7 @@
<!-- Detail Dialog -->
<Dialog.Root bind:open={showDetailDialog}>
<Dialog.Content class="max-w-2xl">
<Dialog.Content class="max-w-4xl">
<Dialog.Header>
<Dialog.Title>Event details</Dialog.Title>
</Dialog.Header>
@@ -949,8 +949,8 @@
<tbody>
{#each Object.entries(selectedEvent.actorAttributes) as [key, value], i}
<tr class="{i % 2 === 0 ? 'bg-muted/50' : 'bg-background'}">
<td class="px-3 py-1.5 font-mono font-medium text-muted-foreground whitespace-nowrap align-top w-1/3">{key}</td>
<td class="px-3 py-1.5 font-mono break-all">{value}</td>
<td class="px-3 py-1.5 font-mono font-medium text-muted-foreground align-top break-all w-1/3">{key}</td>
<td class="px-3 py-1.5 font-mono break-all align-top">{value}</td>
</tr>
{/each}
</tbody>
+143
View File
@@ -0,0 +1,143 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackup } from '$lib/server/audit';
import {
getBackupConfigs,
createBackupConfig,
getBackupDestination,
getEnvironment
} from '$lib/server/db';
import { registerSchedule, isValidCron } from '$lib/server/scheduler';
import { assertStackBackupable } from '$lib/server/backups';
import { validateRetention, retentionToStore } from '$lib/server/backups/helpers';
// Inlined to avoid pulling lucide-svelte / svelte runtime into the server bundle
// via $lib/utils/backup. Keep in sync with that module if the predicates change.
function isLocalRepo(repository: string): boolean {
return repository.startsWith('/') || repository.startsWith('./');
}
function isRemoteEnv(connectionType?: string | null, host?: string | null): boolean {
if (!connectionType) return false;
if (connectionType === 'hawser-standard' || connectionType === 'hawser-edge') return true;
if (connectionType === 'direct' && !!host) return true;
return false;
}
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const type = url.searchParams.get('type') ?? undefined;
const targetName = url.searchParams.get('target') ?? undefined;
const envParam = url.searchParams.get('env');
const environmentId = envParam ? parseInt(envParam) : undefined;
let configs = await getBackupConfigs({
type,
targetName,
environmentId
});
// (audit #40) Enterprise env scoping — the LIST read path never filtered by
// accessible environments, leaking configs (targetName, destination, schedule,
// status) for envs the caller can't access. getAccessibleEnvironmentIds()
// returns null when the caller can see everything.
if (auth.isEnterprise) {
const allowed = await auth.getAccessibleEnvironmentIds();
if (allowed !== null) {
const allowedSet = new Set(allowed);
configs = configs.filter((c: any) => c.environmentId == null || allowedSet.has(c.environmentId));
}
}
return json(configs);
};
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const body = await request.json();
if (!body.destinationId || !body.targetName) {
return json({ error: 'Missing required fields: destinationId, targetName' }, { status: 400 });
}
// Light allowlist on targetName — it flows into paths/restic tags. Complements
// the stack-path traversal guard (audit #43).
if (typeof body.targetName !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(body.targetName)) {
return json({ error: 'Invalid targetName' }, { status: 400 });
}
// Validate cron schedule if provided (audit #7)
if (typeof body.schedule === 'string' && body.schedule.trim() && !isValidCron(body.schedule.trim())) {
return json({ error: `Invalid cron expression: ${body.schedule}` }, { status: 400 });
}
// Validate retention keep-* values before persisting (audit medium #13) — reject
// negative/fractional/string/out-of-range so corrupt retention can't reach restic.
const retentionCheck = validateRetention(body.retention);
if (!retentionCheck.ok) {
return json({ error: `Invalid retention: ${retentionCheck.reason}` }, { status: 400 });
}
// Environment access check (enterprise RBAC)
if (body.environmentId && auth.isEnterprise && !await auth.canAccessEnvironment(body.environmentId)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
// Refuse local repo + remote env: the restic helper runs on the REMOTE
// daemon, which has no access to Dockhand's filesystem. The backup would
// silently write to a wrong path on the remote host.
if (body.environmentId) {
const [dest, env] = await Promise.all([
getBackupDestination(body.destinationId),
getEnvironment(body.environmentId)
]);
if (dest && env && isRemoteEnv(env.connectionType, env.host) && isLocalRepo(dest.repository)) {
return json({
error: `Local repository "${dest.name}" cannot back up containers on remote environment "${env.name}". Use S3, REST, or another non-local backend.`
}, { status: 400 });
}
}
// External stacks (no known compose path) can't be backed up usefully —
// see assertStackBackupable for the why.
const stackCheck = await assertStackBackupable(body.type || 'container', body.targetName, body.environmentId);
if (!stackCheck.ok) {
return json({ error: stackCheck.reason }, { status: 400 });
}
try {
const config = await createBackupConfig({
type: body.type || 'container',
targetName: body.targetName,
environmentId: body.environmentId ?? null,
destinationId: body.destinationId,
enabled: body.enabled !== false,
allVolumes: body.allVolumes ?? true,
selectedVolumes: body.selectedVolumes ? JSON.stringify(body.selectedVolumes) : null,
stopBeforeBackup: body.stopBeforeBackup ?? false,
schedule: body.schedule ?? null,
retention: retentionToStore(body.retention, body.schedule),
options: body.options ? JSON.stringify(body.options) : null,
tags: body.tags ? JSON.stringify(body.tags) : null
});
// Register schedule if enabled and has a cron expression
if (config.enabled && config.schedule) {
await registerSchedule(config.id, 'backup', config.environmentId);
}
await auditBackup(event, 'create', body.targetName, body.environmentId, { destinationId: body.destinationId, schedule: body.schedule });
return json(config, { status: 201 });
} catch (error: any) {
return json({ error: error.message }, { status: 500 });
}
};
@@ -0,0 +1,144 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackup } from '$lib/server/audit';
import {
updateBackupConfig,
deleteBackupConfig,
getBackupDestination,
getEnvironment
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule, isValidCron } from '$lib/server/scheduler';
import { isBackupRunning } from '$lib/server/backups';
import { validateRetention, retentionToStore, resolveEnabledOnScheduleChange } from '$lib/server/backups/helpers';
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
import { isLocalRepo, isRemoteEnvironment } from '$lib/shared/repo-predicates';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
const denied = await requireBackups(auth, 'view');
if (denied) return denied;
// (audit LOW #41) Env-scope the single-config read too — the LIST endpoint
// already filters, but this direct fetch leaked a config for an off-limits env.
const gated = await loadConfigGateEnv(params.id, auth);
if ('response' in gated) return gated.response;
return json(gated.config);
};
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
const denied = await requireBackups(auth, 'manage');
if (denied) return denied;
// Environment access check (enterprise RBAC). Config's env is fixed at creation.
const gated = await loadConfigGateEnv(params.id, auth);
if ('response' in gated) return gated.response;
const existing = gated.config;
const id = existing.id;
const body = await request.json();
// Validate cron schedule if provided (audit #7)
if (typeof body.schedule === 'string' && body.schedule.trim() && !isValidCron(body.schedule.trim())) {
return json({ error: `Invalid cron expression: ${body.schedule}` }, { status: 400 });
}
// Validate retention keep-* values before persisting (audit medium #13).
const retentionCheck = validateRetention(body.retention);
if (!retentionCheck.ok) {
return json({ error: `Invalid retention: ${retentionCheck.reason}` }, { status: 400 });
}
// (audit #56) Changing the destination while a backup is in flight would leave
// the running backup on the OLD destination's lock while the schedule re-arms
// under the new one — refuse the destination change until the run finishes.
if (body.destinationId !== undefined && body.destinationId !== existing.destinationId && isBackupRunning(id)) {
return json({ error: 'Cannot change the destination while a backup is running for this config' }, { status: 409 });
}
// Refuse local repo + remote env (same rule as POST). Effective destination
// is the body's value if supplied, otherwise the existing config's.
const effectiveDestId = body.destinationId ?? existing.destinationId;
const effectiveEnvId = existing.environmentId; // env is fixed at creation
if (effectiveEnvId) {
const [dest, env] = await Promise.all([
getBackupDestination(effectiveDestId),
getEnvironment(effectiveEnvId)
]);
if (dest && env && isRemoteEnvironment(env) && isLocalRepo(dest.repository)) {
return json({
error: `Local repository "${dest.name}" cannot back up containers on remote environment "${env.name}". Use S3, REST, or another non-local backend.`
}, { status: 400 });
}
}
// Auto-enable a config that transitions from manual (no schedule) to scheduled
// (a real cron) — otherwise a paused run-once config that the user edits to add a
// schedule stays paused, forcing a manual un-pause. See resolveEnabledOnScheduleChange.
const resolvedEnabled = resolveEnabledOnScheduleChange({
requestedEnabled: body.enabled,
existingSchedule: existing.schedule,
newSchedule: body.schedule ?? existing.schedule
});
try {
const updated = await updateBackupConfig(id, {
destinationId: body.destinationId,
enabled: resolvedEnabled,
allVolumes: body.allVolumes,
selectedVolumes: body.selectedVolumes ? JSON.stringify(body.selectedVolumes) : body.selectedVolumes,
stopBeforeBackup: body.stopBeforeBackup,
schedule: body.schedule,
// Apply the default scheduled retention the same way create does, so a
// config edited to add a schedule with no explicit retention doesn't end
// up with pruning disabled and an unbounded-growth repo. Uses the effective
// schedule (the incoming one, or the existing one if unchanged).
retention: retentionToStore(body.retention, body.schedule ?? existing.schedule),
options: body.options ? JSON.stringify(body.options) : body.options,
tags: body.tags ? JSON.stringify(body.tags) : body.tags
});
if (!updated) return json({ error: 'Update failed' }, { status: 500 });
// Update schedule registration
if (updated.enabled && updated.schedule) {
await registerSchedule(updated.id, 'backup', updated.environmentId);
} else {
unregisterSchedule(updated.id, 'backup');
}
await auditBackup(event, 'update', updated.targetName, updated.environmentId, { configId: id });
return json(updated);
} catch (error: any) {
return json({ error: error.message }, { status: 500 });
}
};
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
const denied = await requireBackups(auth, 'manage');
if (denied) return denied;
// Environment access check (enterprise RBAC). Config's env is fixed at creation.
const gated = await loadConfigGateEnv(params.id, auth);
if ('response' in gated) return gated.response;
const existing = gated.config;
const id = existing.id;
// (audit #55) Don't delete a config out from under an in-flight backup — the
// run's helper containers + lock reference it; deleting mid-run is messy.
if (isBackupRunning(id)) {
return json({ error: 'A backup is currently running for this config — stop it before deleting' }, { status: 409 });
}
// Unregister schedule before deleting
unregisterSchedule(id, 'backup');
await deleteBackupConfig(id);
await auditBackup(event, 'delete', existing.targetName, existing.environmentId, { configId: id });
return json({ success: true });
};
@@ -0,0 +1,34 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { runBackup } from '$lib/server/backups';
import { createJobResponse } from '$lib/server/sse';
import { auditBackup } from '$lib/server/audit';
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
const denied = await requireBackups(auth, 'manage');
if (denied) return denied;
const gated = await loadConfigGateEnv(params.id, auth);
if ('response' in gated) return gated.response;
const config = gated.config;
await auditBackup(event, 'backup', config.targetName, config.environmentId, { configId: config.id });
return createJobResponse(async (send) => {
const result = await runBackup(config.id, 'manual', (status, message) => {
send('progress', { status, message });
});
send('result', result);
// A backup succeeds as 'success' or 'warning' (partial read still committed);
// 'skipped' isn't a failure. Only a real 'error' throws.
if (result.status === 'error') {
throw new Error(result.error || 'Backup failed');
}
}, request);
};
@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackup } from '$lib/server/audit';
import { cancelBackup } from '$lib/server/backups';
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
export const POST: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
const denied = await requireBackups(auth, 'manage');
if (denied) return denied;
// (bug #12) Enforce per-env access, which this route previously OMITTED — a
// user with global backups:manage but no role on the config's env could
// cancel an in-flight backup there. loadConfigGateEnv can't be called without
// the env gate, so the omission is now unrepresentable.
const gated = await loadConfigGateEnv(params.id, auth);
if ('response' in gated) return gated.response;
const cfg = gated.config;
try {
const stopped = await cancelBackup(cfg.id);
// 'stop' is the canonical audit action for "abort running operation"
await auditBackup(event, 'stop', cfg.targetName, cfg.environmentId, { configId: cfg.id, killed: stopped });
return json({ success: true, stopped });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return json({ error: msg }, { status: 500 });
}
};
@@ -0,0 +1,132 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import {
getBackupDestinations,
createBackupDestination,
decryptBackupDestination,
updateBackupDestination,
updateBackupDestinationTestStatus
} from '$lib/server/db';
import { initRepository, testRepository } from '$lib/server/backups';
import { registerSchedule } from '$lib/server/scheduler';
import { validateRepositoryForSave, validateFlags, validatePolicySchedules } from '$lib/server/backups/helpers';
/**
* Prepare destination for API response — strip password, parse env vars.
*
* The LIST endpoint omits envVars entirely. Cloud-credential env vars
* (AWS_SECRET_ACCESS_KEY, AZURE_ACCOUNT_KEY, etc.) used to ship decrypted to
* any user with backups:view permission, even though the LIST view doesn't
* need them. The edit modal re-fetches via GET /destinations/[id] (which
* still returns envVars decrypted so the form can pre-fill credential
* fields). Single GET is what populates the modal; LIST never needs them.
*/
function prepareDestination(dest: any, opts: { includeEnvVars: boolean }): any {
const decrypted = decryptBackupDestination(dest);
const result = { ...dest };
delete result.password;
if (opts.includeEnvVars) {
result.envVars = decrypted.decryptedEnvVars;
} else {
delete result.envVars;
}
return result;
}
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const destinations = await getBackupDestinations();
// LIST: strip envVars (cloud creds). Modal re-fetches single destination to edit.
return json(destinations.map(d => prepareDestination(d, { includeEnvVars: false })));
};
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const body = await request.json();
if (!body.name || !body.repository || !body.password) {
return json({ error: 'Missing required fields: name, repository, password' }, { status: 400 });
}
// Validate repository scheme + SSRF host (audit #7/#53) and restic flags (#7)
// BEFORE persisting, so nothing is saved on bad input.
const repoError = validateRepositoryForSave(body.repository);
if (repoError) return json({ error: repoError }, { status: 400 });
const flagError = validateFlags(body.flags);
if (flagError) return json({ error: flagError }, { status: 400 });
// Validate any cron schedules in the supplied policies (audit #7)
const policyCronError = validatePolicySchedules(body.policies);
if (policyCronError) {
return json({ error: policyCronError }, { status: 400 });
}
try {
// Set default policies if not provided
const defaultPolicies = JSON.stringify({
pruneEnabled: true, pruneSchedule: '0 0 1 * *', pruneMaxUnused: '10',
checkEnabled: true, checkSchedule: '0 0 1 * *',
verifyEnabled: false, verifySchedule: '0 0 1 * *', verifyDataSubset: '5%',
autoUnlock: true
});
const destination = await createBackupDestination({
name: body.name,
repository: body.repository,
password: body.password,
envVars: body.envVars ? JSON.stringify(body.envVars) : null,
flags: body.flags ?? null,
hostPath: body.hostPath ?? null,
policies: body.policies ?? defaultPolicies
});
// Auto-initialize the repository, then TEST it before recording success.
// `restic init` succeeds on an already-existing repo without decrypting it,
// so init alone doesn't prove the stored password is correct. testRepository
// reads the repo config (which requires the password), so a wrong password
// against a pre-existing repo is recorded as failed instead of success.
try {
await initRepository(destination.id);
const test = await testRepository(destination.id);
if (test.ok) {
await updateBackupDestination(destination.id, {
lastTestStatus: 'success',
lastTestAt: new Date().toISOString()
});
} else {
await updateBackupDestinationTestStatus(destination.id, 'failed', test.error ?? 'Repository is not reachable or the password is incorrect');
}
} catch (initErr) {
// Init failed — destination saved but not initialized. Record the
// failure on the destination so the UI reflects it (audit #9).
const initMsg = initErr instanceof Error ? initErr.message : String(initErr);
await updateBackupDestinationTestStatus(destination.id, 'failed', initMsg);
}
// Register repo maintenance schedules from default policies
try {
await registerSchedule(destination.id, 'repo_prune', null);
await registerSchedule(destination.id, 'repo_check', null);
await registerSchedule(destination.id, 'repo_verify', null);
} catch {}
await auditBackupDestination(event, 'create', destination.id, destination.name, { repository: body.repository });
// POST returns envVars — user just provided them, no point re-hiding.
return json(prepareDestination(destination, { includeEnvVars: true }), { status: 201 });
} catch (error: any) {
if (error.message?.includes('UNIQUE constraint')) {
return json({ error: 'A destination with this name already exists' }, { status: 409 });
}
return json({ error: error.message }, { status: 500 });
}
};
@@ -0,0 +1,199 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import {
getBackupDestination,
updateBackupDestination,
deleteBackupDestination,
decryptBackupDestination,
getBackupConfigs,
getEnvironment
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { validatePolicySchedules, validateRepositoryForSave, validateFlags } from '$lib/server/backups/helpers';
import { destinationHasRunningBackup } from '$lib/server/backups';
// Inlined predicates (kept in sync with the config routes) — see
// configs/+server.ts. A local repo cannot back up a remote environment.
function isLocalRepo(repository: string): boolean {
return repository.startsWith('/') || repository.startsWith('./');
}
function isRemoteEnv(connectionType?: string | null, host?: string | null): boolean {
if (!connectionType) return false;
if (connectionType === 'hawser-standard' || connectionType === 'hawser-edge') return true;
if (connectionType === 'direct' && !!host) return true;
return false;
}
/**
* Single-destination response shape. envVars (decrypted cloud credentials) are
* ONLY included for callers with backups:manage — the edit form that pre-fills
* credential fields. A backups:view (Viewer) caller must never receive them
* (audit #26/#29). The password is always stripped. The LIST endpoint omits
* envVars entirely — see /api/backup/destinations/+server.ts.
*/
function prepareDestination(dest: any, includeSecrets: boolean): any {
const result = { ...dest };
delete result.password;
if (includeSecrets) {
result.envVars = decryptBackupDestination(dest).decryptedEnvVars;
} else {
delete result.envVars;
}
return result;
}
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const destination = await getBackupDestination(id);
if (!destination) return json({ error: 'Destination not found' }, { status: 404 });
// Only surface decrypted cloud credentials to users who can edit the
// destination; Viewers get the row without secrets.
const canManage = !auth.authEnabled || await auth.can('backups', 'manage');
return json(prepareDestination(destination, canManage));
};
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const existing = await getBackupDestination(id);
if (!existing) return json({ error: 'Destination not found' }, { status: 404 });
const body = await request.json();
// Changing the repo target or password while a backup is writing to this
// destination would break the in-flight run. A policy/name-only edit is safe.
if ((body.repository !== undefined || body.password !== undefined) && await destinationHasRunningBackup(id)) {
return json({ error: 'A backup using this destination is currently running. Try again once it finishes.' }, { status: 409 });
}
// Validate any cron schedules in the supplied policies (audit #7)
const policyCronError = validatePolicySchedules(body.policies);
if (policyCronError) {
return json({ error: policyCronError }, { status: 400 });
}
// (audit #7/#53) The PUT edit path previously never re-validated: validate the
// repository scheme/SSRF host and restic flags when supplied, before persisting.
if (body.repository !== undefined) {
const repoError = validateRepositoryForSave(body.repository);
if (repoError) return json({ error: repoError }, { status: 400 });
}
const flagError = validateFlags(body.flags);
if (flagError) return json({ error: flagError }, { status: 400 });
// (audit #8) Changing the repository to a LOCAL path bypasses the config-route
// guard: refuse if any config on this destination targets a remote environment
// (the restic helper runs on the remote daemon, which can't see Dockhand's fs).
const effectiveRepository = body.repository !== undefined ? body.repository : existing.repository;
if (body.repository !== undefined && isLocalRepo(effectiveRepository)) {
const configs = await getBackupConfigs();
const envIds = [...new Set(
configs.filter((c: any) => c.destinationId === id && c.environmentId).map((c: any) => c.environmentId as number)
)];
const envs = await Promise.all(envIds.map((eid) => getEnvironment(eid)));
const remote = envs.find((env) => env && isRemoteEnv(env.connectionType, env.host));
if (remote) {
return json({
error: `Local repository cannot back up containers on remote environment "${remote.name}". Use S3, REST, or another non-local backend.`
}, { status: 400 });
}
}
try {
const updated = await updateBackupDestination(id, {
name: body.name,
repository: body.repository,
password: body.password,
envVars: body.envVars !== undefined ? JSON.stringify(body.envVars) : undefined,
flags: body.flags,
hostPath: body.hostPath,
policies: body.policies
});
if (!updated) return json({ error: 'Update failed' }, { status: 500 });
// Re-register maintenance schedules only when the policies actually changed
// (audit #22) — a metadata-only edit (rename, flags, etc.) shouldn't churn
// the repo_prune/check/verify cron jobs.
if (body.policies !== undefined) {
try {
await registerSchedule(id, 'repo_prune', null);
await registerSchedule(id, 'repo_check', null);
await registerSchedule(id, 'repo_verify', null);
} catch {}
}
// Audit: don't log the new password — only the fact that it changed
await auditBackupDestination(event, 'update', id, updated.name, {
repositoryChanged: body.repository !== undefined && body.repository !== existing.repository,
passwordChanged: body.password !== undefined,
policiesChanged: body.policies !== undefined
});
// PUT caller already holds backups:manage — safe to echo secrets back.
return json(prepareDestination(updated, true));
} catch (error: any) {
if (error.message?.includes('UNIQUE constraint')) {
return json({ error: 'A destination with this name already exists' }, { status: 409 });
}
return json({ error: error.message }, { status: 500 });
}
};
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const existing = await getBackupDestination(id);
if (!existing) return json({ error: 'Destination not found' }, { status: 404 });
// Don't delete a destination out from under a backup that's writing to its repo.
if (await destinationHasRunningBackup(id)) {
return json({ error: 'A backup using this destination is currently running. Try again once it finishes.' }, { status: 409 });
}
// Unregister all maintenance schedules for this destination
unregisterSchedule(id, 'repo_prune');
unregisterSchedule(id, 'repo_check');
unregisterSchedule(id, 'repo_verify');
// Tear down in-memory croner jobs for any backup configs that reference this
// destination (audit #47). The config rows may cascade-delete, but the croner
// jobs would otherwise stay registered as orphans. Mirrors env-delete teardown.
try {
const allConfigs = await getBackupConfigs();
for (const config of allConfigs) {
if (config.destinationId === id) {
unregisterSchedule(config.id, 'backup');
}
}
} catch (err) {
console.error(`Failed to unregister backup schedules for destination "${existing.name}":`, err);
}
await deleteBackupDestination(id);
await auditBackupDestination(event, 'delete', id, existing.name, { repository: existing.repository });
return json({ success: true });
};
@@ -0,0 +1,34 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
import { initRepository } from '$lib/server/backups';
export const POST: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const dest = await getBackupDestination(id);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
try {
await initRepository(id);
await updateBackupDestinationTestStatus(id, 'success');
// Repo init is a state change on the destination, treat it as an
// 'update' so it's distinguishable from create-destination events.
await auditBackupDestination(event, 'update', id, dest.name, { action: 'init', status: 'success' });
return json({ success: true, message: 'Repository initialized' });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
await updateBackupDestinationTestStatus(id, 'failed', msg);
await auditBackupDestination(event, 'update', id, dest.name, { action: 'init', status: 'failed', error: msg });
return json({ success: false, error: msg }, { status: 500 });
}
};
@@ -0,0 +1,68 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination } from '$lib/server/db';
import { rotateDestinationPassword } from '$lib/server/backups';
/**
* Rotate the restic repository password for a destination.
*
* Body: { currentPassword: string, newPassword: string }
*
* 200 — rotated, DB updated
* 400 — input invalid or current password wrong
* 404 — destination not found
* 409 — restic rotated but DB write failed (manual recovery needed; details
* include dbOutOfSync: true)
* 500 — restic call failed for an unrelated reason
*/
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const body = await request.json().catch(() => ({}));
const currentPassword = typeof body.currentPassword === 'string' ? body.currentPassword : '';
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : '';
if (!currentPassword || !newPassword) {
return json({ error: 'currentPassword and newPassword are required' }, { status: 400 });
}
const dest = await getBackupDestination(id);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
const result = await rotateDestinationPassword(id, currentPassword, newPassword);
if (!result.ok) {
// Audit the failed attempt — useful signal for spotting wrong-password
// probes against destinations.
await auditBackupDestination(event, 'update', id, dest.name, {
action: 'rotate-key',
success: false,
error: result.error,
dbOutOfSync: result.dbOutOfSync ?? false
});
if (result.dbOutOfSync) {
return json({ error: result.error, dbOutOfSync: true }, { status: 409 });
}
// Restic reports an incorrect current password as "wrong password" or
// "no key found" — surface a clearer 400 so the UI can say the current
// password is incorrect rather than a generic failure.
if (/wrong password|no key found/i.test(result.error)) {
return json({ error: 'Current password incorrect' }, { status: 400 });
}
if (result.error.toLowerCase().includes('does not match') || result.error.toLowerCase().includes('must')) {
return json({ error: result.error }, { status: 400 });
}
return json({ error: result.error }, { status: 500 });
}
await auditBackupDestination(event, 'update', id, dest.name, { action: 'rotate-key', success: true });
return json({ success: true });
};
@@ -0,0 +1,60 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination, getBackupConfigs } from '$lib/server/db';
import { runRepoTask } from '$lib/server/backups';
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const destId = parseInt(params.id);
if (isNaN(destId)) return json({ error: 'Invalid destination ID' }, { status: 400 });
const body = await request.json();
const task = body.task as string;
if (!['unlock', 'check', 'prune', 'stats', 'repair-index', 'repair-snapshots'].includes(task)) {
return json({ error: `Invalid task: ${task}` }, { status: 400 });
}
const dest = await getBackupDestination(destId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
// Repo maintenance (prune/check/repair) is destructive and operates on the
// shared repo. Destinations aren't environment-scoped, so gate on every
// environment whose configs use this destination: an enterprise user scoped
// to one env must not run maintenance on a repo shared with another env's data.
if (auth.isEnterprise) {
const configs = await getBackupConfigs();
const envIds = [...new Set(
configs.filter((c: any) => c.destinationId === destId && c.environmentId).map((c: any) => c.environmentId as number)
)];
for (const envId of envIds) {
if (!await auth.canAccessEnvironment(envId)) {
return json({ error: 'Access denied to an environment using this destination' }, { status: 403 });
}
}
}
try {
// `task` is validated against the allowed list above.
const result = await runRepoTask(destId, task as import('$lib/server/backups').RepoTask);
// 'prune' and 'check' map to canonical audit actions; the rest land on
// 'update' so they're still recoverable from the log.
const action = task === 'prune' ? 'prune' : task === 'check' ? 'verify' : 'update';
// runRepoTask can resolve with { success: false, error } WITHOUT throwing
// (e.g. repo unreachable / needs init). Derive the audited success and
// error from the result rather than hardcoding success (audit #53).
await auditBackupDestination(event, action, destId, dest.name, { task, success: result.success, error: result.error });
return json(result, { status: result.success ? 200 : 500 });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
await auditBackupDestination(event, 'update', destId, dest.name, { task, success: false, error: msg });
return json({ error: msg }, { status: 500 });
}
};
@@ -0,0 +1,33 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
getBackupDestination,
updateBackupDestinationTestStatus
} from '$lib/server/db';
import { testRepository } from '$lib/server/backups';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
const destination = await getBackupDestination(id);
if (!destination) return json({ error: 'Destination not found' }, { status: 404 });
const result = await testRepository(id);
if (result.ok) {
await updateBackupDestinationTestStatus(id, 'success');
return json({ success: true, status: 'success' });
}
if (result.needsInit) {
await updateBackupDestinationTestStatus(id, 'needs_init', result.error);
return json({ success: false, status: 'needs_init', error: result.error });
}
await updateBackupDestinationTestStatus(id, 'failed', result.error);
return json({ success: false, status: 'failed', error: result.error });
};
@@ -0,0 +1,43 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackupDestination } from '$lib/server/audit';
import { getBackupDestination } from '$lib/server/db';
import { verifyBackup } from '$lib/server/backups';
import { createJobResponse } from '$lib/server/sse';
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const destinationId = parseInt(params.id);
if (isNaN(destinationId)) return json({ error: 'Invalid destination ID' }, { status: 400 });
const body = await request.json().catch(() => ({}));
const dataSubset = body.dataSubset || '5%';
const dest = await getBackupDestination(destinationId);
if (dest) {
await auditBackupDestination(event, 'verify', destinationId, dest.name, { dataSubset });
}
return createJobResponse(async (send) => {
send('progress', { message: `Verifying backup integrity (reading ${dataSubset} of data)...` });
const result = await verifyBackup(destinationId, {
dataSubset,
onProgress: (message) => {
send('progress', { message });
}
});
send('result', result);
if (!result.success) {
throw new Error(result.error || 'Verification failed');
}
}, request);
};
@@ -0,0 +1,96 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getBackupDestination, decryptBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
import { spawn } from 'child_process';
import { buildResticEnv, cleanErrorMsg, isRepoNotInitializedError, RESTIC_EXIT_REPO_NOT_FOUND, validateRepositoryForSave } from '$lib/server/backups/helpers';
/**
* Test a backup destination configuration.
* - With `destinationId`: uses saved credentials from DB
* - Without: uses inline credentials from request body (for testing before save)
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const body = await request.json();
let repository: string;
let password: string;
let envVars: Record<string, string> = {};
if (body.destinationId) {
// Use saved destination from DB
const dest = await getBackupDestination(body.destinationId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
const decrypted = decryptBackupDestination(dest);
repository = dest.repository;
password = decrypted.decryptedPassword;
envVars = decrypted.decryptedEnvVars;
} else {
// Inline test (before save)
if (!body.repository || !body.password) {
return json({ error: 'Repository and password are required' }, { status: 400 });
}
// Reject unsupported backends AND SSRF-dangerous hosts (loopback/metadata)
// before spawning restic — same guard the save path uses, so an inline test
// can't probe an internal host that a saved destination couldn't reach.
const repoError = validateRepositoryForSave(body.repository);
if (repoError) {
return json({ error: repoError }, { status: 400 });
}
repository = body.repository;
password = body.password;
if (body.envVars && typeof body.envVars === 'object') {
for (const [key, value] of Object.entries(body.envVars)) {
if (typeof value === 'string') envVars[key] = value;
}
}
}
const env = buildResticEnv(process.env, { repository, password, envVars });
try {
const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
const proc = spawn('restic', ['snapshots', '--json', '--no-lock', '--latest', '1'], { env, timeout: 30000 });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (d) => { stdout += d; });
proc.stderr.on('data', (d) => { stderr += d; });
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
proc.on('error', (err) => resolve({ code: 1, stdout: '', stderr: err.message }));
});
if (result.code === 0) {
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'success');
return json({ success: true });
}
// Detect the repo-not-initialized case (audit #24): restic exits 10, or
// stderr matches the "no repository" family of messages. Return a distinct,
// friendlier status so the UI can offer to initialize.
const rawStderr = result.stderr.trim();
const notInitialized =
result.code === RESTIC_EXIT_REPO_NOT_FOUND ||
isRepoNotInitializedError({ exitCode: result.code }) ||
/unable to open (config|repository)|Is there a repository/i.test(rawStderr);
if (notInitialized) {
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'needs_init', 'Repository not initialized');
return json({ success: false, needsInit: true, error: 'Repository not initialized' });
}
// Surface the real restic error (audit #8): derive detail from stderr,
// cleaned of embedded JSON/ANSI, falling back to the exit code.
const detail = rawStderr ? cleanErrorMsg(rawStderr) : `restic exited with code ${result.code}`;
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'failed', detail);
return json({ success: false, error: detail });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (body.destinationId) await updateBackupDestinationTestStatus(body.destinationId, 'failed', errorMsg);
return json({ success: false, error: errorMsg });
}
};
+88
View File
@@ -0,0 +1,88 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { runRestore } from '$lib/server/backups';
import { validateRestoreRequest, type RestoreMode } from '$lib/server/backups/validate';
import { createJobResponse } from '$lib/server/sse';
import { auditRestore } from '$lib/server/audit';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const body = await request.json();
const mode: RestoreMode = body.mode === 'in-place' ? 'in-place' : 'new-location';
// Optional post-restore action. In-place: start/recreate/redeploy/none.
// New-location clone: recreate/redeploy/none bring the target up on the target env.
const postRestore = ['start', 'recreate', 'redeploy', 'none'].includes(body.postRestore) ? body.postRestore : undefined;
// New-location clone: per-volume destinations on the target env.
const volumeDestinations = Array.isArray(body.volumeDestinations) ? body.volumeDestinations : undefined;
// Authorization gates run BEFORE request validation, so an unauthorized caller
// never learns anything about the request shape.
// Target environment access (enterprise RBAC).
if (body.environmentId && auth.isEnterprise && !await auth.canAccessEnvironment(body.environmentId)) {
return json({ error: 'Access denied to target environment' }, { status: 403 });
}
// Gate on the SNAPSHOT's owning environment too (server-resolved, fail-closed).
const denied = await guardSnapshotEnvAccess(auth, body.destinationId, body.snapshotId);
if (denied) return denied;
// Validate the request shape (all errors at once). Enforces the destructive-
// intent rule: an in-place restore requires explicit confirmation.
const check = validateRestoreRequest({
destinationId: body.destinationId,
snapshotId: body.snapshotId,
volumes: body.volumes,
mode,
confirmOverwrite: body.confirmOverwrite,
targetPath: body.targetPath,
stackName: body.targetName,
environmentId: body.environmentId,
postRestore,
targetName: body.targetName,
volumeDestinations,
});
if (!check.ok) {
return json({ error: 'Invalid restore request', issues: check.issues }, { status: 400 });
}
await auditRestore(event, body.targetName || 'unknown', body.environmentId, { snapshotId: body.snapshotId, destinationId: body.destinationId, mode });
// The service also enforces snapshot ownership; thread the caller's access
// context so the enterprise environment gate is applied there too.
const access = { isEnterprise: auth.isEnterprise, canAccessEnvironment: (id: number) => auth.canAccessEnvironment(id) };
return createJobResponse(async (send) => {
const result = await runRestore(
{
destinationId: body.destinationId,
snapshotId: body.snapshotId,
mode,
targetType: body.targetType === 'stack' ? 'stack' : 'container',
volumes: body.volumes ?? [],
environmentId: body.environmentId ?? null,
confirmOverwrite: body.confirmOverwrite === true,
targetPath: body.targetPath ?? null,
targetName: body.targetName ?? null,
postRestore,
volumeDestinations,
},
access,
// Stream progress to the client (the restore modal's log). Without this the
// modal showed "0 lines" then jumped straight to the result — restore's
// op.progress lines were only persisted to the DB, never streamed.
(status, message) => send('progress', { status, message }),
);
send('result', result);
if (result.status === 'error') {
throw new Error(result.error || 'Restore failed');
}
}, request);
};
@@ -0,0 +1,37 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { previewSnapshot } from '$lib/server/backups';
import { validateSnapshotId } from '$lib/server/docker-validation';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const body = await request.json();
if (!body.destinationId || !body.snapshotId) {
return json({ error: 'Missing required fields: destinationId, snapshotId' }, { status: 400 });
}
const invalidSnap = validateSnapshotId(body.snapshotId);
if (invalidSnap) return invalidSnap;
if (body.environmentId && auth.isEnterprise && !await auth.canAccessEnvironment(body.environmentId)) {
return json({ error: 'Access denied to target environment' }, { status: 403 });
}
// Gate on the snapshot's owning environment (server-resolved, fail-closed).
const denied = await guardSnapshotEnvAccess(auth, body.destinationId, body.snapshotId);
if (denied) return denied;
try {
const access = { isEnterprise: auth.isEnterprise, canAccessEnvironment: (id: number) => auth.canAccessEnvironment(id) };
const preview = await previewSnapshot(body.destinationId, body.snapshotId, access);
return json(preview);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
};
@@ -0,0 +1,43 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditRestore } from '$lib/server/audit';
import { cancelRestore } from '$lib/server/backups';
import { isValidSnapshotId } from '$lib/server/backups/helpers';
/**
* Cancel a running restore (audit #14). Restore was previously uncancellable —
* the helper container was never targeted by any stop path. Kills the restore
* helper for the given snapshotId (or all restore helpers if omitted) so restic
* exits and runRestore's failure/cleanup path runs.
*/
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const body = await request.json().catch(() => ({}));
const snapshotId: string | undefined = body.snapshotId;
// (audit #57) Validate snapshotId when supplied, matching the restore POST route.
if (snapshotId !== undefined && !isValidSnapshotId(snapshotId)) {
return json({ error: 'Invalid snapshotId' }, { status: 400 });
}
const environmentId: number | undefined =
typeof body.environmentId === 'number' ? body.environmentId : undefined;
// Enforce environment-scoped access when targeting a specific env.
if (environmentId && auth.isEnterprise && !await auth.canAccessEnvironment(environmentId)) {
return json({ error: 'Environment access denied' }, { status: 403 });
}
try {
const stopped = await cancelRestore(snapshotId, environmentId);
await auditRestore(event, snapshotId || 'unknown', environmentId ?? null, { snapshotId, killed: stopped });
return json({ success: true, stopped });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return json({ error: msg }, { status: 500 });
}
};
+114
View File
@@ -0,0 +1,114 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getBackupConfig, getBackupDestinations } from '$lib/server/db';
import { listSnapshots } from '$lib/server/backups';
import { filterSnapshotsByEnvAccess } from '$lib/server/backups/route-guards';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const configIdParam = url.searchParams.get('configId');
const destIdParam = url.searchParams.get('destinationId');
const allDests = url.searchParams.get('allDestinations') === 'true';
if (configIdParam) {
const configId = parseInt(configIdParam);
if (isNaN(configId)) return json({ error: 'Invalid configId' }, { status: 400 });
const config = await getBackupConfig(configId);
if (!config) return json({ error: 'Config not found' }, { status: 404 });
// (audit #41) The config is environment-scoped; enforce env access before
// listing snapshots (ids/sizes/paths/tags reveal the env-B workload).
if (config.environmentId && auth.isEnterprise && !await auth.canAccessEnvironment(config.environmentId)) {
return json({ error: 'Environment access denied' }, { status: 403 });
}
// Narrow to THIS config's identity by its stable config-id tag so a sibling
// config sharing the same destination + targetName isn't mixed in.
if (allDests) {
// Search ALL destinations for this config's snapshots
try {
const destinations = await getBackupDestinations();
// Bound each destination's listing so one slow or unreachable repo
// can't stall the whole response for minutes (restic's own timeout is
// several minutes). A timed-out destination is treated as a failed
// one — surfaced as a partial result via X-Incomplete-Destinations.
const PER_DEST_MS = Number(process.env.SNAPSHOT_LIST_TIMEOUT_MS ?? 30_000);
const withTimeout = <T>(p: Promise<T>, name: string): Promise<T> =>
Promise.race([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`timed out after ${PER_DEST_MS}ms`)), PER_DEST_MS)),
]);
const results = await Promise.allSettled(
destinations.map(dest => withTimeout(listSnapshots(dest.id, config.id), dest.name).then(snaps =>
snaps.map(s => ({ ...s, _destinationId: dest.id, _destinationName: dest.name }))
))
);
const allSnapshots: any[] = [];
const seen = new Set<string>();
// (audit #29) Don't silently drop destinations that errored. Collect
// the failures and surface them via a response header (keeping the body
// a plain array for compatibility), plus a server log — so a partial
// result doesn't look complete.
const failed: string[] = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
for (const snap of result.value) {
if (!seen.has(snap.id)) {
seen.add(snap.id);
allSnapshots.push(snap);
}
}
} else {
const dest = destinations[i];
const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
failed.push(`${dest.name} (${dest.id}): ${msg}`);
}
});
allSnapshots.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
if (failed.length > 0) {
console.warn(`[Backup] Snapshot search incomplete — ${failed.length} destination(s) failed: ${failed.join('; ')}`);
return json(allSnapshots, { headers: { 'X-Incomplete-Destinations': String(failed.length) } });
}
return json(allSnapshots);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
}
// Default: only search the config's current destination
try {
const snapshots = await listSnapshots(config.destinationId, config.id);
return json(snapshots);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
} else if (destIdParam) {
const destinationId = parseInt(destIdParam);
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
try {
// No target filter — return all snapshots in this destination.
const snapshots = await listSnapshots(destinationId);
// (HIGH #8) On enterprise, a non-admin must not see snapshots belonging to
// environments they can't access — the ids/paths alone are disclosure. Drop
// any snapshot whose owning env (from its dockhand:env tag) isn't accessible.
if (auth.isEnterprise) {
const filtered = await filterSnapshotsByEnvAccess(auth, snapshots);
return json(filtered);
}
return json(snapshots);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
} else {
return json({ error: 'configId or destinationId parameter is required' }, { status: 400 });
}
};
@@ -0,0 +1,70 @@
import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditBackup } from '$lib/server/audit';
import { getBackupDestination } from '$lib/server/db';
import { forgetSnapshot } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId);
if (invalidSnap) return invalidSnap;
const destIdParam = url.searchParams.get('destinationId');
if (!destIdParam) return json({ error: 'destinationId parameter is required' }, { status: 400 });
const destinationId = parseInt(destIdParam);
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
const envParam = url.searchParams.get('env');
const envId = envParam ? parseInt(envParam) : undefined;
// Environment access check (enterprise RBAC) — only when an env is specified.
if (envId !== undefined && Number.isInteger(envId) && envId > 0 && auth.isEnterprise && !await auth.canAccessEnvironment(envId)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
// (re-audit H2) Server-authoritative env gate, matching the read-content routes
// (HIGH #8): resolve the snapshot's OWNING env from its own dockhand:env tag and
// fail closed. The caller-supplied `env` param above is not trusted for access
// control — omitting it previously skipped the RBAC check entirely on this
// DESTRUCTIVE (forget --prune) route.
const denied = await guardSnapshotEnvAccess(auth, destinationId, snapshotId);
if (denied) return denied;
try {
const result = await forgetSnapshot(destinationId, snapshotId);
const dest = await getBackupDestination(destinationId);
if (result.ok) {
// Audit on the backup_config entity (decision: reuse existing
// entity types rather than add a 'snapshot' one). entityName is
// the destination, details carries the snapshot ID and dest ID.
await auditBackup(event, 'delete', dest?.name ?? `destination#${destinationId}`, null, {
snapshotId,
destinationId,
kind: 'snapshot'
});
return json({ success: true });
}
// (audit low #25) Relay the real reason AND audit the failed delete so it's
// traceable, instead of a generic opaque 500 with no record.
await auditBackup(event, 'delete', dest?.name ?? `destination#${destinationId}`, null, {
snapshotId, destinationId, kind: 'snapshot', failed: true, reason: result.reason
});
if (result.reason === 'not-owned') {
return json({ error: 'Snapshot not found for this installation' }, { status: 404 });
}
return json({ error: result.error ?? 'Failed to forget snapshot' }, { status: 500 });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
};
@@ -0,0 +1,48 @@
import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { browseSnapshot } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId);
if (invalidSnap) return invalidSnap;
const destIdParam = url.searchParams.get('destinationId');
if (!destIdParam) return json({ error: 'destinationId parameter is required' }, { status: 400 });
const destinationId = parseInt(destIdParam);
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
const path = url.searchParams.get('path') || '/';
const envParam = url.searchParams.get('env');
const envId = envParam ? parseInt(envParam) : undefined;
// (HIGH #8) Server-authoritative env access: resolve the snapshot's OWNING
// env from its tag and enforce access — the client-supplied `env` param is no
// longer trusted as the source of truth (omitting it previously skipped the
// check entirely). Kept below is the caller-param check as an extra early gate.
const envDenied = await guardSnapshotEnvAccess(auth, destinationId, snapshotId);
if (envDenied) return envDenied;
// Additional check on any explicitly-supplied env param (enterprise RBAC).
if (envId !== undefined && !isNaN(envId) && auth.isEnterprise && !await auth.canAccessEnvironment(envId)) {
return json({ error: 'Environment access denied' }, { status: 403 });
}
try {
const entries = await browseSnapshot(destinationId, snapshotId, path);
return json({ entries, path });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
};
@@ -0,0 +1,87 @@
import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { dumpSnapshotFile, dumpSnapshotFileBytes, dumpSnapshotArchive } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId);
if (invalidSnap) return invalidSnap;
const destIdParam = url.searchParams.get('destinationId');
if (!destIdParam) return json({ error: 'destinationId parameter is required' }, { status: 400 });
const destinationId = parseInt(destIdParam);
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
// (HIGH #8) Enforce per-environment access on the snapshot's OWNING env,
// resolved server-side from its tag — not a caller-supplied param.
const envDenied = await guardSnapshotEnvAccess(auth, destinationId, snapshotId);
if (envDenied) return envDenied;
const path = url.searchParams.get('path');
if (!path) return json({ error: 'path parameter is required' }, { status: 400 });
const download = url.searchParams.get('download') === '1';
const isDir = url.searchParams.get('type') === 'directory';
// Validate path — no traversal
if (path.includes('..')) {
return json({ error: 'Invalid path' }, { status: 400 });
}
// Restrict dumps to the known snapshot roots so arbitrary snapshot paths can't be
// read. Accept the root dir itself (`/volumes`, `/metadata`) AND anything inside
// it — the previous `/volumes/` / `/metadata/` prefix check rejected downloading a
// top-level directory (e.g. `/metadata`) with a bogus "Invalid path".
if (
path !== '/volumes' && path !== '/metadata' &&
!path.startsWith('/volumes/') && !path.startsWith('/metadata/')
) {
return json({ error: 'Invalid path' }, { status: 400 });
}
// Sanitize filename for Content-Disposition (strip quotes, backslashes, control chars)
const sanitizeFilename = (name: string) => name.replace(/["\\\x00-\x1f]/g, '_');
try {
if (download && isDir) {
// Binary tar stream — serve the raw bytes untouched (a UTF-8 round-trip
// would corrupt any non-ASCII byte in the archive).
const tarData = await dumpSnapshotArchive(destinationId, snapshotId, path);
const filename = sanitizeFilename((path.split('/').filter(Boolean).pop() || 'archive') + '.tar');
return new Response(new Uint8Array(tarData), {
headers: {
'Content-Type': 'application/x-tar',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
}
if (download) {
// A file download may be binary — serve raw bytes, not a decoded string.
const bytes = await dumpSnapshotFileBytes(destinationId, snapshotId, path);
const filename = sanitizeFilename(path.split('/').pop() || 'file');
return new Response(new Uint8Array(bytes), {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
}
// Inline preview — text only.
const content = await dumpSnapshotFile(destinationId, snapshotId, path);
return json({ content });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 });
}
};
@@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import { validateSnapshotId } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getSnapshotMetadata } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const snapshotId = params.id;
const invalidSnap = validateSnapshotId(snapshotId);
if (invalidSnap) return invalidSnap;
const destIdParam = url.searchParams.get('destinationId');
if (!destIdParam) return json({ error: 'destinationId is required' }, { status: 400 });
const destinationId = parseInt(destIdParam);
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
// (HIGH #8) Enforce per-environment access on the snapshot's owning env.
const envDenied = await guardSnapshotEnvAccess(auth, destinationId, snapshotId);
if (envDenied) return envDenied;
try {
const metadata = await getSnapshotMetadata(destinationId, snapshotId);
if (!metadata) return json({ error: 'No metadata available' }, { status: 404 });
return json(metadata);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return json({ error: msg }, { status: 500 });
}
};
@@ -0,0 +1,41 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { diffSnapshots } from '$lib/server/backups';
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
import { validateSnapshotId } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const destId = url.searchParams.get('destinationId');
const snapA = url.searchParams.get('snapshotA');
const snapB = url.searchParams.get('snapshotB');
if (!destId || !snapA || !snapB) {
return json({ error: 'Missing required params: destinationId, snapshotA, snapshotB' }, { status: 400 });
}
const invalidA = validateSnapshotId(snapA);
if (invalidA) return invalidA;
const invalidB = validateSnapshotId(snapB);
if (invalidB) return invalidB;
// (HIGH #8) Enforce per-environment access on BOTH snapshots' owning env.
const destinationId = parseInt(destId);
const deniedA = await guardSnapshotEnvAccess(auth, destinationId, snapA);
if (deniedA) return deniedA;
const deniedB = await guardSnapshotEnvAccess(auth, destinationId, snapB);
if (deniedB) return deniedB;
try {
const result = await diffSnapshots(destinationId, snapA, snapB);
return json(result);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return json({ error: msg }, { status: 500 });
}
};
@@ -9,7 +9,8 @@ import {
isDigestBasedImage,
getImageIdByTag,
removeTempImage,
tagImage
tagImage,
inspectImage
} from '$lib/server/docker';
import { auditContainer } from '$lib/server/audit';
import { getScannerSettings, scanImage } from '$lib/server/scanner';
@@ -157,6 +158,17 @@ export const POST: RequestHandler = async (event) => {
const imageName = config.Image;
const currentImageId = inspectData.Image;
// Capture the OLD image's Env/Labels BEFORE pulling — once the tag is
// repointed the old digest may be untagged/GC'd and un-inspectable.
// Used by the env/label rebase in recreateContainer (#1226, #1256).
let oldImageConfig: { Env?: string[]; Labels?: Record<string, string> } | null = null;
try {
const oldImg = await inspectImage(currentImageId, envIdNum) as any;
oldImageConfig = { Env: oldImg?.Config?.Env, Labels: oldImg?.Config?.Labels };
} catch {
// Best-effort; rebase will fall back if unavailable.
}
// Skip system containers (Dockhand, Hawser)
const systemType = isSystemContainer(imageName);
if (systemType) {
@@ -459,7 +471,11 @@ export const POST: RequestHandler = async (event) => {
message: `Recreating ${containerName}...`
});
const recreateResult = await recreateContainer(containerName, envIdNum, logProgress, imageName);
const recreateResult = await recreateContainer(containerName, envIdNum, {
log: logProgress,
imageNameOverride: imageName,
oldImageConfig
});
if (recreateResult.success) {
const updatedContainers = await listContainers(true, envIdNum);
const updatedContainer = updatedContainers.find(c => c.name === containerName);
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { listContainers, pullImage, inspectContainer } from '$lib/server/docker';
import { listContainers, pullImage, inspectContainer, inspectImage } from '$lib/server/docker';
import { auditContainer } from '$lib/server/audit';
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
@@ -63,6 +63,16 @@ export const POST: RequestHandler = async (event) => {
const imageName = config.Image;
const containerName = container.name;
// Capture the OLD image's Env/Labels BEFORE the pull for the env/label
// rebase (#1226, #1256) — the old digest may be GC'd after the pull.
let oldImageConfig: { Env?: string[]; Labels?: Record<string, string> } | null = null;
try {
const oldImg = await inspectImage(inspectData.Image, envIdNum) as any;
oldImageConfig = { Env: oldImg?.Config?.Env, Labels: oldImg?.Config?.Labels };
} catch {
// Best-effort; rebase falls back if unavailable.
}
// Skip containers with dockhand.update=false label
if (isUpdateDisabledByLabel(config.Labels)) {
results.push({
@@ -89,7 +99,7 @@ export const POST: RequestHandler = async (event) => {
let newContainerId = containerId;
const recreateResult = await recreateContainer(containerName, envIdNum);
const recreateResult = await recreateContainer(containerName, envIdNum, { oldImageConfig });
if (recreateResult.success) {
const updatedContainers = await listContainers(true, envIdNum);
const updatedContainer = updatedContainers.find(c => c.name === containerName);
+1 -1
View File
@@ -12,8 +12,8 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import v8 from 'node:v8';
import os from 'node:os';
import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker';
import { formatBytes } from '$lib/utils/format';
import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker';
// Track startup time and initial RSS for growth rate calculation
const startupTime = Date.now();
+13 -1
View File
@@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit';
import { join } from 'path';
import { existsSync, rmSync, renameSync } from 'fs';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack, getBackupConfigs } from '$lib/server/db';
import { clearDockerClientCache } from '$lib/server/docker';
import { deleteGitStackFiles, getGitReposDir } from '$lib/server/git';
import { getStacksDir } from '$lib/server/stacks';
@@ -253,6 +253,18 @@ export const DELETE: RequestHandler = async (event) => {
await deleteImagePruneSettings(id);
unregisterSchedule(id, 'image_prune');
// Unregister backup config schedules for this environment (audit #10).
// The config rows themselves cascade-delete with the environment, but the
// in-memory croner jobs would otherwise stay registered as orphans.
try {
const envBackupConfigs = await getBackupConfigs({ environmentId: id });
for (const config of envBackupConfigs) {
unregisterSchedule(config.id, 'backup');
}
} catch (err) {
console.error(`Failed to unregister backup schedules for environment "${env.name}":`, err);
}
// Clean up stack directory for this environment
// Safety: only delete subdirectory named after the env, never the parent
try {
+4 -3
View File
@@ -16,8 +16,9 @@ import { registerSchedule } from '$lib/server/scheduler';
import { auditGitStack } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
// Stack name validation: Docker Compose requires lowercase; must start with a
// letter or number, and contain only lowercase letters, numbers, hyphens, underscores
const STACK_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/;
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
@@ -58,7 +59,7 @@ export const POST: RequestHandler = async (event) => {
const trimmedStackName = data.stackName.trim();
if (!STACK_NAME_REGEX.test(trimmedStackName)) {
return json({ error: 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
return json({ error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
}
// Check for name conflicts with existing stacks (regular/external/git)
+119 -1
View File
@@ -13,6 +13,9 @@ import {
getAllAutoUpdateGitStacks,
getAllEnvUpdateCheckSettings,
getAllImagePruneSettings,
getBackupConfigs,
getBackupDestination,
getBackupDestinations,
getLastExecutionForSchedule,
getRecentExecutionsForSchedule,
getEnvironment,
@@ -26,7 +29,7 @@ import { authorize } from '$lib/server/authorize';
export interface ScheduleInfo {
id: number;
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune';
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify';
name: string;
entityName: string;
description?: string;
@@ -210,6 +213,121 @@ export const GET: RequestHandler = async ({ cookies }) => {
);
schedules.push(...imagePruneSchedules);
// Get backup schedules (audit #17 — GET must match the SSE stream listing)
const allBackupConfigs = await getBackupConfigs();
const backupSchedules = await Promise.all(
allBackupConfigs
.filter(c => c.schedule)
.map(async (config) => {
const [env, dest, lastExecution, recentExecutions] = await Promise.all([
config.environmentId ? getEnvironment(config.environmentId) : null,
getBackupDestination(config.destinationId),
getLastExecutionForSchedule('backup', config.id),
getRecentExecutionsForSchedule('backup', config.id, 5)
]);
const timezone = config.environmentId ? await getEnvironmentTimezone(config.environmentId) : 'UTC';
const isEnabled = config.enabled ?? false;
const nextRun = isEnabled && config.schedule ? getNextRun(config.schedule, timezone) : null;
return {
id: config.id,
type: 'backup' as const,
name: `Backup: ${config.targetName}`,
entityName: config.targetName,
description: `Back up ${config.type} to ${dest?.name || 'unknown destination'}`,
environmentId: config.environmentId,
environmentName: env?.name ?? null,
enabled: isEnabled,
scheduleType: 'custom',
cronExpression: config.schedule ?? null,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExecution ?? null,
recentExecutions,
isSystem: false
};
})
);
schedules.push(...backupSchedules);
// Get repo maintenance schedules (prune + check + verify from destination
// policies). Synthetic ids: dest.id + 100000/200000/300000 (matches the
// stream endpoint and the run/toggle/delete REPO_ID_OFFSET decoding).
const allDestinations = await getBackupDestinations();
for (const dest of allDestinations) {
const policies = dest.policies ? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })() : {};
if (policies.pruneEnabled && policies.pruneSchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_prune', dest.id),
getRecentExecutionsForSchedule('repo_prune', dest.id, 5)
]);
const nextRun = getNextRun(policies.pruneSchedule);
const maxUnused = policies.pruneMaxUnused ?? '10';
schedules.push({
id: dest.id + 100000,
type: 'repo_prune' as const,
name: `Prune: ${dest.name}`,
entityName: dest.name,
description: `Prune unused data from ${dest.name} (max unused ${maxUnused}%)`,
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.pruneSchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
if (policies.checkEnabled && policies.checkSchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_check', dest.id),
getRecentExecutionsForSchedule('repo_check', dest.id, 5)
]);
const nextRun = getNextRun(policies.checkSchedule);
schedules.push({
id: dest.id + 200000,
type: 'repo_check' as const,
name: `Check: ${dest.name}`,
entityName: dest.name,
description: `Check integrity of ${dest.name}`,
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.checkSchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
if (policies.verifyEnabled && policies.verifySchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_verify', dest.id),
getRecentExecutionsForSchedule('repo_verify', dest.id, 5)
]);
const nextRun = getNextRun(policies.verifySchedule);
const subset = policies.verifyDataSubset || '5%';
schedules.push({
id: dest.id + 300000,
type: 'repo_verify' as const,
name: `Verify: ${dest.name}`,
entityName: dest.name,
description: `Verify ${subset} of data in ${dest.name}`,
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.verifySchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
}
// Get system schedules
const systemSchedules = await getSystemSchedules();
const sysSchedules = await Promise.all(
@@ -11,7 +11,11 @@ import {
getGitStack,
updateGitStack,
deleteEnvUpdateCheckSettings,
deleteImagePruneSettings
deleteImagePruneSettings,
deleteBackupConfig,
getBackupConfig,
getBackupDestination,
updateBackupDestination
} from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
@@ -75,6 +79,39 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
unregisterSchedule(scheduleId, 'image_prune');
return json({ success: true });
} else if (type === 'backup') {
const cfg = await getBackupConfig(scheduleId);
if (!cfg) {
return json({ error: 'Schedule not found' }, { status: 404 });
}
const envDenied = await auth.requireEnvAccess(cfg.environmentId);
if (envDenied) return envDenied;
await deleteBackupConfig(scheduleId);
unregisterSchedule(scheduleId, 'backup');
return json({ success: true });
} else if (type === 'repo_prune' || type === 'repo_check' || type === 'repo_verify') {
// Repo maintenance schedules are policy-driven; there is no standalone
// row to delete. "Delete" here means disable the policy (audit #18).
// The schedule id is synthetic — decode to the real destination id.
const REPO_ID_OFFSET: Record<string, number> = {
repo_prune: 100000, repo_check: 200000, repo_verify: 300000
};
const destId = scheduleId - REPO_ID_OFFSET[type];
const dest = await getBackupDestination(destId);
if (!dest) {
return json({ error: 'Destination not found' }, { status: 404 });
}
const policies = dest.policies
? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })()
: {};
const enabledKey = type === 'repo_prune' ? 'pruneEnabled'
: type === 'repo_check' ? 'checkEnabled' : 'verifyEnabled';
policies[enabledKey] = false;
await updateBackupDestination(destId, { policies: JSON.stringify(policies) });
unregisterSchedule(destId, type);
return json({ success: true });
} else if (type === 'system_cleanup') {
return json({ error: 'System schedules cannot be removed' }, { status: 400 });
@@ -11,8 +11,12 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
import { getBackupConfig, getBackupDestination } from '$lib/server/db';
import { runScheduledBackup } from '$lib/server/scheduler/tasks/backup';
import { runRepoPrune, runRepoCheck, runRepoVerify } from '$lib/server/scheduler/tasks/repo-maintenance';
import { authorize } from '$lib/server/authorize';
import { getAutoUpdateSettingById, getGitStack } from '$lib/server/db';
import { BACKUPS_ENABLED } from '$lib/server/features';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -28,6 +32,20 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid schedule ID' }, { status: 400 });
}
// BETA GATE: backup-type schedules are unreachable unless FEAT_BACKUPS_ENABLED (see features.ts).
if (!BACKUPS_ENABLED && (type === 'backup' || type === 'repo_prune' || type === 'repo_check' || type === 'repo_verify')) {
return new Response('Not found', { status: 404 });
}
// The schedules stream emits synthetic IDs for the three repo-maintenance
// rows per destination (dest.id + 100000/200000/300000) to keep them unique
// in the UI list. Decode back to the real destination id here (audit #7)
// so getBackupDestination() actually matches. Backup/other types pass through.
const REPO_ID_OFFSET: Record<string, number> = {
repo_prune: 100000, repo_check: 200000, repo_verify: 300000
};
const destId = REPO_ID_OFFSET[type] ? scheduleId - REPO_ID_OFFSET[type] : scheduleId;
// Resolve schedule → environmentId so we can enforce per-env access
// before triggering. System schedules (env null) are gated only by
// the global schedules:run check above.
@@ -52,6 +70,24 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
case 'system_cleanup':
scheduleEnvId = null;
break;
case 'backup': {
// Backup schedules ARE env-scoped (audit #6: these were missing here
// and 400'd before ever reaching the dispatch switch below).
const config = await getBackupConfig(scheduleId);
if (!config) return json({ error: 'Backup config not found' }, { status: 404 });
scheduleEnvId = config.environmentId;
break;
}
case 'repo_prune':
case 'repo_check':
case 'repo_verify': {
// Repo maintenance is destination-scoped, not env-scoped. Validate the
// (decoded) destination exists; access is gated by schedules:run only.
const dest = await getBackupDestination(destId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
scheduleEnvId = null;
break;
}
default:
return json({ error: 'Invalid schedule type' }, { status: 400 });
}
@@ -77,6 +113,37 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
case 'image_prune':
result = await triggerImagePrune(scheduleId);
break;
case 'backup': {
const config = await getBackupConfig(scheduleId);
if (!config) {
return json({ error: 'Backup config not found' }, { status: 404 });
}
await runScheduledBackup(scheduleId, config.targetName, config.environmentId, 'manual');
result = { success: true };
break;
}
case 'repo_prune': {
const dest = await getBackupDestination(destId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
await runRepoPrune(destId, dest.name, 'manual');
result = { success: true };
break;
}
case 'repo_check': {
const dest = await getBackupDestination(destId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
await runRepoCheck(destId, dest.name, 'manual');
result = { success: true };
break;
}
case 'repo_verify': {
const dest = await getBackupDestination(destId);
if (!dest) return json({ error: 'Destination not found' }, { status: 404 });
const pol = dest.policies ? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })() : {};
await runRepoVerify(destId, dest.name, pol.verifyDataSubset || '5%', 'manual');
result = { success: true };
break;
}
default:
// Unreachable — validated in the resolution switch above.
return json({ error: 'Invalid schedule type' }, { status: 400 });
@@ -5,11 +5,13 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db';
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings, getBackupConfig, updateBackupConfig, getBackupDestination, updateBackupDestination } from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
import { auditBackup, auditBackupDestination } from '$lib/server/audit';
export const POST: RequestHandler = async ({ params, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
const permDenied = await auth.requirePermission('schedules', 'edit');
@@ -110,6 +112,56 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
unregisterSchedule(scheduleId, 'image_prune');
}
return json({ success: true, enabled: newEnabled });
} else if (type === 'backup') {
const config = await getBackupConfig(scheduleId);
if (!config) {
return json({ error: 'Backup config not found' }, { status: 404 });
}
const envDenied = await auth.requireEnvAccess(config.environmentId);
if (envDenied) return envDenied;
const newEnabled = !config.enabled;
await updateBackupConfig(scheduleId, { enabled: newEnabled });
if (newEnabled && config.schedule) {
await registerSchedule(scheduleId, 'backup', config.environmentId);
} else {
unregisterSchedule(scheduleId, 'backup');
}
await auditBackup(event, 'update', config.targetName, config.environmentId, { configId: scheduleId, enabled: newEnabled });
return json({ success: true, enabled: newEnabled });
} else if (type === 'repo_prune' || type === 'repo_check' || type === 'repo_verify') {
// Repo maintenance schedules are policy-driven on the destination.
// Toggling flips the corresponding *Enabled flag in the destination's
// policies JSON (audit #18). The schedule id is synthetic — decode it
// back to the real destination id (same offsets as the run endpoint).
const REPO_ID_OFFSET: Record<string, number> = {
repo_prune: 100000, repo_check: 200000, repo_verify: 300000
};
const destId = scheduleId - REPO_ID_OFFSET[type];
const dest = await getBackupDestination(destId);
if (!dest) {
return json({ error: 'Destination not found' }, { status: 404 });
}
const policies = dest.policies
? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })()
: {};
const enabledKey = type === 'repo_prune' ? 'pruneEnabled'
: type === 'repo_check' ? 'checkEnabled' : 'verifyEnabled';
const newEnabled = !policies[enabledKey];
policies[enabledKey] = newEnabled;
await updateBackupDestination(destId, { policies: JSON.stringify(policies) });
// Re-register (or unregister) the repo maintenance job (env-less).
await registerSchedule(destId, type, null);
await auditBackupDestination(event, 'update', destId, dest.name, { policy: enabledKey, enabled: newEnabled });
return json({ success: true, enabled: newEnabled });
} else if (type === 'system_cleanup') {
return json({ error: 'System schedules cannot be paused' }, { status: 400 });
@@ -7,7 +7,7 @@
* - scheduleType: 'container_update' | 'git_stack_sync'
* - scheduleId: number
* - environmentId: number
* - status: 'queued' | 'running' | 'success' | 'failed' | 'skipped'
* - status: 'queued' | 'running' | 'success' | 'warning' | 'failed' | 'skipped'
* - triggeredBy: 'cron' | 'webhook' | 'manual'
* - fromDate: ISO date string
* - toDate: ISO date string
+118
View File
@@ -10,6 +10,9 @@ import {
getAllAutoUpdateGitStacks,
getAllEnvUpdateCheckSettings,
getAllImagePruneSettings,
getBackupConfigs,
getBackupDestination,
getBackupDestinations,
getLastExecutionForSchedule,
getRecentExecutionsForSchedule,
getEnvironment,
@@ -180,6 +183,121 @@ async function getSchedulesData(): Promise<ScheduleInfo[]> {
);
schedules.push(...imagePruneSchedules);
// Get backup schedules
const allBackupConfigs = await getBackupConfigs();
const backupSchedules = await Promise.all(
allBackupConfigs
.filter(c => c.schedule)
.map(async (config) => {
const [env, dest, lastExecution, recentExecutions] = await Promise.all([
config.environmentId ? getEnvironment(config.environmentId) : null,
getBackupDestination(config.destinationId),
getLastExecutionForSchedule('backup', config.id),
getRecentExecutionsForSchedule('backup', config.id, 5)
]);
const timezone = config.environmentId ? await getEnvironmentTimezone(config.environmentId) : 'UTC';
const isEnabled = config.enabled ?? false;
const nextRun = isEnabled && config.schedule ? getNextRun(config.schedule, timezone) : null;
return {
id: config.id,
type: 'backup' as const,
name: `Backup: ${config.targetName}`,
entityName: config.targetName,
description: `Back up ${config.type} to ${dest?.name || 'unknown destination'}`,
environmentId: config.environmentId,
environmentName: env?.name ?? null,
enabled: isEnabled,
scheduleType: 'custom',
cronExpression: config.schedule ?? null,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExecution ?? null,
recentExecutions,
isSystem: false
};
})
);
schedules.push(...backupSchedules);
// Get repo maintenance schedules (prune + check from destination policies)
const allDestinations = await getBackupDestinations();
for (const dest of allDestinations) {
const policies = dest.policies ? (() => { try { return JSON.parse(dest.policies); } catch { return {}; } })() : {};
if (policies.pruneEnabled && policies.pruneSchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_prune', dest.id),
getRecentExecutionsForSchedule('repo_prune', dest.id, 5)
]);
const nextRun = getNextRun(policies.pruneSchedule, 'UTC');
const maxUnused = policies.pruneMaxUnused ?? '10';
schedules.push({
id: dest.id + 100000,
type: 'repo_prune' as any,
name: `Prune: ${dest.name}`,
entityName: dest.name,
description: `Prune unused data from ${dest.name} (max unused ${maxUnused}%)`,
maxUnused: String(maxUnused),
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.pruneSchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
if (policies.checkEnabled && policies.checkSchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_check', dest.id),
getRecentExecutionsForSchedule('repo_check', dest.id, 5)
]);
const nextRun = getNextRun(policies.checkSchedule, 'UTC');
schedules.push({
id: dest.id + 200000,
type: 'repo_check' as any,
name: `Check: ${dest.name}`,
entityName: dest.name,
description: `Check integrity of ${dest.name}`,
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.checkSchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
if (policies.verifyEnabled && policies.verifySchedule) {
const [lastExec, recentExecs] = await Promise.all([
getLastExecutionForSchedule('repo_verify', dest.id),
getRecentExecutionsForSchedule('repo_verify', dest.id, 5)
]);
const nextRun = getNextRun(policies.verifySchedule, 'UTC');
const subset = policies.verifyDataSubset || '5%';
schedules.push({
id: dest.id + 300000,
type: 'repo_verify' as any,
name: `Verify: ${dest.name}`,
entityName: dest.name,
description: `Verify ${subset} of data in ${dest.name}`,
dataSubset: subset,
environmentId: null,
environmentName: null,
enabled: true,
scheduleType: 'custom',
cronExpression: policies.verifySchedule,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExec ?? null,
recentExecutions: recentExecs,
isSystem: false
});
}
}
// Get system schedules
const systemSchedules = await getSystemSchedules();
const sysSchedules = await Promise.all(
+20 -2
View File
@@ -35,6 +35,12 @@ import { authorize } from '$lib/server/authorize';
import { refreshSystemJobs } from '$lib/server/scheduler';
import { sendToEventSubprocess, sendToMetricsSubprocess } from '$lib/server/subprocess-manager';
import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner';
import { DEFAULT_HELPER_IMAGE } from '$lib/server/backups/restic';
// The real engine default (version-pinned, `-baseline`-aware). NOT a hardcoded
// `:latest` — that would advertise a floating tag the backup engine never uses and,
// if the user saved it, pin them to a helper that never re-pulls (see restic.ts).
const DEFAULT_BACKUP_IMAGE = DEFAULT_HELPER_IMAGE;
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
@@ -95,6 +101,8 @@ export interface GeneralSettings {
defaultComposeTemplate: string;
// Label filter mode
labelFilterMode: 'any' | 'all';
// Backup image
defaultBackupImage: string;
// Whether to surface URLs inferred from reverse-proxy labels — currently
// Traefik (traefik.http.routers.*) and Pangolin
// (pangolin.{public,private}-resources.*).
@@ -170,7 +178,8 @@ services:
# networks:
# default:
# driver: bridge
`
`,
defaultBackupImage: DEFAULT_BACKUP_IMAGE
};
const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one'];
@@ -251,6 +260,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
defaultTrivyImage,
defaultComposeTemplate,
labelFilterMode,
defaultBackupImage,
honorProxyLabels,
showImageChangelogLinks,
showWhatsNew,
@@ -299,6 +309,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode'),
getSetting('default_backup_image'),
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('show_whats_new'),
@@ -351,6 +362,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
defaultBackupImage: defaultBackupImage ?? DEFAULT_BACKUP_IMAGE,
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
showWhatsNew: showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
@@ -375,7 +387,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -521,6 +533,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
await setSetting('label_filter_mode', labelFilterMode);
}
if (defaultBackupImage !== undefined && typeof defaultBackupImage === 'string') {
await setSetting('default_backup_image', defaultBackupImage);
}
if (honorProxyLabels !== undefined && typeof honorProxyLabels === 'boolean') {
await setSetting('honor_proxy_labels', honorProxyLabels);
}
@@ -593,6 +608,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
defaultTrivyImageVal,
defaultComposeTemplateVal,
labelFilterModeVal,
defaultBackupImageVal,
honorProxyLabelsVal,
showImageChangelogLinksVal,
showWhatsNewVal,
@@ -641,6 +657,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode'),
getSetting('default_backup_image'),
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('show_whats_new'),
@@ -693,6 +710,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
defaultBackupImage: defaultBackupImageVal ?? DEFAULT_BACKUP_IMAGE,
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
protectScannerImages: protectScannerImagesVal ?? DEFAULT_SETTINGS.protectScannerImages,
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
@@ -22,7 +22,7 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
const body = await request.json();
const body = await request.json().catch(() => ({}));
const { pull, build, forceRecreate } = body as {
pull?: boolean;
build?: boolean;
@@ -6,7 +6,9 @@ import { authorize } from '$lib/server/authorize';
import { createJobResponse } from '$lib/server/sse';
/**
* Scan every (tagged) image in the environment for vulnerabilities.
* Scan every image in the environment for vulnerabilities. Tagged images scan by
* tag; untagged but digest-pinned images (e.g. renovate `@sha256:` pins) scan by
* their repo digest so they aren't silently skipped (#1286).
* Reuses the per-image scan flow, sequentially, reporting N/total progress.
* A single image failing does not abort the batch.
*/
@@ -26,9 +28,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
return createJobResponse(async (send, isCancelled) => {
const images = await listImages(envId);
// One scan target per image: use the first usable tag, skip dangling/<none>.
// One scan target per image: prefer a usable tag; for untagged images
// (digest-pinned via renovate etc.) fall back to a repo digest so they
// aren't silently skipped (#1286). Truly dangling images — no tag AND no
// digest — are left out; they're build cruft, not real scan targets.
const targets = images
.map((img) => (img.tags || []).find((t) => t && !t.includes('<none>')))
.map((img) => {
const tag = (img.tags || []).find((t) => t && !t.includes('<none>'));
if (tag) return tag;
return (img.repoDigests || []).find((d) => d && !d.includes('<none>')) || null;
})
.filter((t): t is string => !!t);
// Dedupe (a repo can appear once per tag; scan each unique tag once)
const uniqueTargets = Array.from(new Set(targets));
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Loader2, CheckCircle, XCircle, Archive, Copy, Check, Square } from 'lucide-svelte';
import LogConsole from '$lib/components/LogConsole.svelte';
// Backup-only progress window: shown for the Play-on-a-config path, which has no
// form modal to host its log. Restore shows its log inline inside RestoreModal.
interface Props {
open: boolean;
title: string;
status: 'running' | 'success' | 'error';
progress?: number;
logs: string[];
error?: string;
onStop?: () => void;
}
let { open = $bindable(), title, status, progress = 0, logs, error, onStop }: Props = $props();
let copied = $state(false);
function copyLogs() {
const text = logs.join('\n') + (error ? '\n' + error : '');
navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => { copied = false; }, 2000);
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-4xl h-[60vh] flex flex-col overflow-hidden" onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Archive class="w-5 h-5" />
{title}
{#if status === 'running'}
<Badge variant="secondary" class="text-xs gap-1"><Loader2 class="w-3 h-3 animate-spin" />Running</Badge>
{:else if status === 'success'}
<Badge variant="secondary" class="text-xs gap-1 text-green-500"><CheckCircle class="w-3 h-3" />Completed</Badge>
{:else}
<Badge variant="destructive" class="text-xs gap-1"><XCircle class="w-3 h-3" />Failed</Badge>
{/if}
</Dialog.Title>
</Dialog.Header>
{#if status === 'running' && progress > 0}
<div class="h-1.5 bg-muted rounded-full overflow-hidden shrink-0">
<div class="h-full bg-primary transition-all duration-300 rounded-full" style="width: {progress}%"></div>
</div>
{/if}
<div class="relative flex min-h-0 flex-1 flex-col">
<!-- Shared console (pills, dark toggle, auto-scroll) — same as the backup
and container/stack restore logs, so all operation logs look identical. -->
<LogConsole lines={logs} class="flex-1 min-h-0" />
{#if status === 'running' && logs.length === 0}
<div class="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="h-3 w-3 animate-spin" /><span>Waiting for output…</span>
</div>
{/if}
{#if error}<div class="mt-1 text-xs text-red-400">{error}</div>{/if}
<!-- Copy button -->
{#if logs.length > 0}
<button
type="button"
class="absolute top-8 right-3 rounded border bg-background/80 p-1.5 text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
onclick={copyLogs}
title="Copy log content"
>
{#if copied}<Check class="w-3.5 h-3.5 text-green-500" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
{/if}
</div>
<Dialog.Footer class="pt-3">
{#if status === 'running' && onStop}
<Button variant="destructive" onclick={onStop}>
<Square class="w-3.5 h-3.5 mr-1" />
Stop
</Button>
{/if}
<Button variant="outline" onclick={() => (open = false)}>
{status === 'running' ? 'Run in background' : 'Close'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+435
View File
@@ -0,0 +1,435 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import { Package, Box, Layers, Search, Loader2, CheckCircle2, XCircle, ArrowBigRight, Settings, Clock, Play } from 'lucide-svelte';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { getRepoTypeIcon, formatCron, runBackupAction, isRemoteEnvironment, tagLogLine, type BackupFormState } from '$lib/utils/backup';
import { toast } from 'svelte-sonner';
import VolumePicker from '$lib/components/backup/VolumePicker.svelte';
import DestinationPicker from '$lib/components/backup/DestinationPicker.svelte';
import LogConsole from '$lib/components/LogConsole.svelte';
import { normalizeMounts, volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
interface Props {
open: boolean;
onCreated?: () => void;
}
let { open = $bindable(), onCreated }: Props = $props();
interface Environment { id: number; name: string; icon?: string; connectionType?: string; host?: string | null; }
interface Destination { id: number; name: string; repository: string; }
interface ContainerItem { name: string; type: 'container' | 'stack'; envId: number; envName: string; envIcon?: string; volumes: VolumeInfo[]; }
// Step state (4 = running/complete)
let step = $state<1 | 2 | 3 | 4>(1);
// Step 1: Select source
let environments = $state<Environment[]>([]);
let selectedEnvId = $state<number | undefined>(undefined);
let containers = $state<ContainerItem[]>([]);
let loadingContainers = $state(false);
let searchQuery = $state('');
let selectedItem = $state<ContainerItem | null>(null);
// Step 2: Configure
let destinations = $state<Destination[]>([]);
let selectedDestId = $state(0);
let stopBeforeBackup = $state(false);
let allVolumes = $state(true);
let selectedVolumes = $state<string[]>([]);
// Step 3: Schedule & Run
let saveSchedule = $state(false);
let schedule = $state('0 2 * * *');
let scheduleInvalid = $state(false);
let running = $state(false);
// Step 4: Running log
let logs = $state<string[]>([]);
let backupStatus = $state<'idle' | 'running' | 'success' | 'error'>('idle');
let backupError = $state('');
$effect(() => {
if (open) {
step = 1;
selectedItem = null;
searchQuery = '';
selectedEnvId = undefined;
selectedDestId = 0;
stopBeforeBackup = false;
allVolumes = true;
selectedVolumes = [];
saveSchedule = false;
schedule = '0 2 * * *';
running = false;
logs = [];
backupStatus = 'idle';
backupError = '';
fetchEnvironments();
fetchDestinations();
}
});
async function fetchEnvironments() {
try {
const res = await fetch('/api/environments');
environments = await res.json();
if (environments.length === 1) {
selectedEnvId = environments[0].id;
fetchContainers(environments[0].id);
}
} catch {}
}
async function fetchDestinations() {
try {
const res = await fetch('/api/backup/destinations');
destinations = await res.json();
if (destinations.length === 1) selectedDestId = destinations[0].id;
} catch {}
}
async function fetchContainers(envId: number) {
loadingContainers = true;
containers = [];
try {
const [contRes, stackRes] = await Promise.all([
fetch(`/api/containers?env=${envId}`),
fetch(`/api/stacks?env=${envId}`)
]);
const contData = await contRes.json();
const stackData = await stackRes.json();
const env = environments.find(e => e.id === envId);
const items: ContainerItem[] = [];
if (Array.isArray(contData)) {
for (const c of contData) {
items.push({ name: c.name || c.Names?.[0]?.replace(/^\//, ''), type: 'container', envId, envName: env?.name || '', envIcon: env?.icon, volumes: normalizeMounts(c.mounts || c.Mounts) });
}
}
if (Array.isArray(stackData)) {
for (const s of stackData) {
// Only internal/git stacks are backup-able — Dockhand knows their compose
// file location. External/untracked stacks (unknown compose path) would
// produce an incomplete artifact, and the backend refuses them
// (assertStackBackupable), so don't offer them as targets here.
if (s.sourceType !== 'internal' && s.sourceType !== 'git') continue;
items.push({ name: s.name, type: 'stack', envId, envName: env?.name || '', envIcon: env?.icon, volumes: volumesForStack(contData, s.name) });
}
}
containers = items.sort((a, b) => a.name.localeCompare(b.name));
} catch {}
loadingContainers = false;
}
function selectSource(item: ContainerItem) {
selectedItem = item;
step = 2;
}
async function runBackup() {
if (!selectedItem || !selectedDestId) return;
running = true;
logs = [];
backupStatus = 'running';
backupError = '';
// Wizard exposes two intents: "run and save the schedule" or
// "run once and discard". Both map cleanly onto the shared action
// orchestrator — same code path as BackupPanel's Save & run / Run once.
const form: BackupFormState = {
targetName: selectedItem.name,
type: selectedItem.type,
environmentId: selectedItem.envId,
destinationId: selectedDestId,
stopBeforeBackup,
allVolumes,
selectedVolumes
};
try {
const result = await runBackupAction({
form,
action: saveSchedule ? 'save-run' : 'run-once',
schedule,
enabled: true,
onProgress: (line) => {
if (line.event === 'progress') {
const msg = (line.data as { message?: string } | null)?.message;
if (msg) logs = [...logs, tagLogLine(msg)];
}
}
});
if (!result.ok) {
backupStatus = 'error';
backupError = result.error || 'Backup failed';
} else {
backupStatus = 'success';
toast.success(saveSchedule ? 'Backup completed and schedule saved' : 'Backup completed');
}
onCreated?.();
} catch (err: any) {
backupStatus = 'error';
backupError = err?.message || 'Backup failed';
} finally {
running = false;
}
}
const filteredContainers = $derived(
searchQuery.trim()
? containers.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
: containers
);
const getDestIcon = getRepoTypeIcon;
const selectedDest = $derived(destinations.find(d => d.id === selectedDestId));
// Used by the DestinationPicker to disable local-path destinations when
// the chosen environment is remote (helper container on the remote host
// can't read Dockhand's local filesystem).
const selectedEnv = $derived(environments.find((e) => e.id === selectedItem?.envId));
const isSelectedEnvRemote = $derived(isRemoteEnvironment(selectedEnv));
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen && running) return; open = isOpen; }}>
<Dialog.Content class="max-w-3xl h-[70vh] flex flex-col">
<Dialog.Header class="pb-0">
<Dialog.Title class="flex items-center gap-2 text-base">
<Package class="w-4 h-4" />Create backup
</Dialog.Title>
{#if selectedItem}
<Dialog.Description class="flex items-center gap-2 text-sm flex-wrap">
{#if selectedItem.type === 'container'}<Box class="w-4 h-4 text-blue-500" />{:else}<Layers class="w-4 h-4 text-purple-500" />{/if}
{selectedItem.name}
<span class="text-muted-foreground">on</span>
<EnvironmentIcon icon={selectedItem.envIcon || 'globe'} envId={selectedItem.envId} class="w-4 h-4 text-muted-foreground" />
<span class="text-muted-foreground">{selectedItem.envName}</span>
{#if selectedDest}
<span class="text-muted-foreground">→</span>
<svelte:component this={getDestIcon(selectedDest.repository)} class="w-4 h-4" />
<span class="text-muted-foreground">{selectedDest.name}</span>
{/if}
</Dialog.Description>
{/if}
</Dialog.Header>
<!-- Stepper tabs -->
<div class="flex items-center border-b shrink-0 px-1 bg-muted/10">
<button
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors cursor-pointer flex items-center gap-2 {step === 1 ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => step = 1}
>
<Box class="w-4 h-4" />
Source
{#if selectedItem}
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
{/if}
</button>
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
<button
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 {step === 2 ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground'} {selectedItem ? 'cursor-pointer hover:text-foreground' : 'cursor-not-allowed'}"
disabled={!selectedItem}
onclick={() => { if (selectedItem) step = 2; }}
>
<Settings class="w-4 h-4" />
Configure
{#if selectedDestId}
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
{/if}
</button>
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
<button
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 {step === 3 ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground'} {selectedItem && selectedDestId ? 'cursor-pointer hover:text-foreground' : 'cursor-not-allowed'}"
disabled={!selectedItem || !selectedDestId}
onclick={() => { if (selectedItem && selectedDestId) step = 3; }}
>
<Clock class="w-4 h-4" />
Schedule & run
</button>
</div>
<!-- Step content -->
{#if step === 1}
<!-- Sticky env selector + search -->
<div class="shrink-0 flex gap-2 px-3 pt-3 pb-2">
<Select.Root type="single" value={selectedEnvId ? String(selectedEnvId) : undefined} onValueChange={(v) => { selectedEnvId = Number(v); selectedItem = null; fetchContainers(Number(v)); }}>
<Select.Trigger class="h-8 w-48 text-xs">
{@const env = environments.find(e => e.id === selectedEnvId)}
{#if env}
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-3 h-3 mr-1 text-muted-foreground" />{env.name}
{:else}Select environment{/if}
</Select.Trigger>
<Select.Content>
{#each environments as env}
<Select.Item value={String(env.id)}>
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-3 h-3 mr-1.5 inline text-muted-foreground" />{env.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if selectedEnvId}
<div class="relative flex-1">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input bind:value={searchQuery} placeholder="Search containers and stacks..." class="pl-8 h-8 text-xs" />
</div>
{/if}
</div>
{/if}
<div class="flex-1 min-h-0 flex flex-col overflow-y-auto px-3 pb-3">
{#if step === 1}
<!-- Step 1: Select source -->
<div class="space-y-3">
{#if loadingContainers}
<div class="flex items-center justify-center py-8"><Loader2 class="w-5 h-5 animate-spin text-muted-foreground" /></div>
{:else if selectedEnvId && filteredContainers.length > 0}
<div class="flex-1 overflow-y-auto border rounded">
{#each filteredContainers as item}
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-xs hover:bg-muted transition-colors text-left border-b last:border-0 {selectedItem?.name === item.name && selectedItem?.envId === item.envId ? 'bg-primary/10' : ''}"
onclick={() => selectSource(item)}
>
{#if item.type === 'container'}<Box class="w-3.5 h-3.5 text-muted-foreground shrink-0" />{:else}<Layers class="w-3.5 h-3.5 text-muted-foreground shrink-0" />{/if}
<span class="font-medium truncate">{item.name}</span>
<Badge variant="outline" class="text-xs ml-auto shrink-0">{item.type}</Badge>
{#if item.volumes.length > 0}
<span class="text-xs text-muted-foreground shrink-0">{item.volumes.length} vol</span>
{/if}
</button>
{/each}
</div>
{:else if selectedEnvId}
<p class="text-xs text-muted-foreground py-4 text-center">No containers or stacks found</p>
{:else}
<p class="text-xs text-muted-foreground py-4 text-center">Select an environment to see available sources</p>
{/if}
</div>
{:else if step === 2}
<!-- Step 2: Configure -->
<div class="space-y-4">
<div class="space-y-1">
<Label class="text-xs">Backup repository</Label>
<DestinationPicker
destinations={destinations}
bind:value={selectedDestId}
disableLocalForRemoteEnv={isSelectedEnvRemote}
/>
</div>
<div class="flex items-center gap-3">
<TogglePill bind:checked={stopBeforeBackup} />
<div>
<Label class="text-xs">Stop {selectedItem?.type || 'container'} during backup</Label>
<p class="text-xs text-muted-foreground">Ensures data consistency (will restart after)</p>
</div>
</div>
{#if selectedItem}
<VolumePicker
volumes={selectedItem.volumes}
bind:allVolumes
bind:selectedVolumes
emptyLabel="No volumes detected on this {selectedItem.type}"
showBindWarning={true}
/>
{/if}
<div class="flex justify-end pt-2">
<Button size="sm" onclick={() => step = 3} disabled={!selectedDestId}>
Next <ArrowBigRight class="w-3.5 h-3.5 ml-1" />
</Button>
</div>
</div>
{:else if step === 3}
<!-- Step 3: Schedule & Run -->
<div class="flex flex-col gap-4 h-full">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground pb-2 border-b">
{#if allVolumes}
<span>All volumes</span>
{:else}
<span>{selectedVolumes.length} volume{selectedVolumes.length !== 1 ? 's' : ''}</span>
{/if}
{#if stopBeforeBackup}
<span class="text-muted-foreground">·</span>
<span>stop during backup</span>
{/if}
</div>
<div class="flex items-center gap-2">
<Checkbox checked={saveSchedule} onCheckedChange={() => { saveSchedule = !saveSchedule; }} />
<span class="text-xs">Save as recurring schedule</span>
</div>
{#if saveSchedule}
<div class="pl-6">
<CronEditor bind:value={schedule} bind:invalid={scheduleInvalid} />
</div>
{:else}
<p class="pl-6 text-[11px] text-muted-foreground">
Runs once now. The backup stays on the list so you can re-run or remove it later — it just won't run on a schedule.
</p>
{/if}
{#if backupStatus === 'idle'}
<!-- Not started yet -->
{/if}
<div class="flex items-center gap-2 pt-2">
{#if backupStatus === 'idle' || (backupStatus !== 'running' && logs.length === 0)}
<Button variant="outline" size="sm" onclick={() => step = 2} disabled={running}>Back</Button>
{/if}
<div class="flex-1"></div>
{#if backupStatus === 'success'}
<Button size="sm" onclick={() => { open = false; }}>
<CheckCircle2 class="w-3.5 h-3.5 mr-1.5 text-green-500" />Done
</Button>
{:else if backupStatus === 'error' && !running}
<Button size="sm" variant="outline" onclick={() => { backupStatus = 'idle'; logs = []; }}>
Retry
</Button>
<Button size="sm" onclick={() => { open = false; }}>Close</Button>
{:else}
<Button size="sm" onclick={runBackup} disabled={running || (saveSchedule && scheduleInvalid)}>
{#if running}<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />{:else}<Play class="w-3.5 h-3.5 mr-1.5" />{/if}
{saveSchedule ? 'Run & save schedule' : 'Run backup now'}
</Button>
{/if}
</div>
<!-- Backup log — same shared renderer (pills, auto-scroll) as the backup
panel and restore modal, with the wizard's own status line below. -->
{#if logs.length > 0 || running}
<div class="mt-3 flex min-h-0 flex-1 flex-col">
<LogConsole lines={logs} class="flex-1 min-h-0" />
{#if backupStatus === 'running'}
<div class="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="h-3 w-3 animate-spin" />Backing up…
</div>
{:else if backupStatus === 'success'}
<div class="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-green-500">
<CheckCircle2 class="h-3.5 w-3.5" />Backup completed
</div>
{:else if backupStatus === 'error'}
<div class="mt-1.5 flex items-start gap-1.5 text-xs font-medium text-red-400">
<XCircle class="mt-0.5 h-3.5 w-3.5 shrink-0" /><span class="break-all">{backupError || 'Failed'}</span>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
+220
View File
@@ -0,0 +1,220 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Plus, Minus, FileEdit, FileCheck, Loader2, ArrowRight, FolderOpen, Container, Info } from 'lucide-svelte';
import { formatDateTime } from '$lib/stores/settings';
interface Props {
open: boolean;
destinationId: number;
snapshotA: { id: string; shortId: string; time: string };
snapshotB: { id: string; shortId: string; time: string };
}
let { open = $bindable(), destinationId, snapshotA, snapshotB }: Props = $props();
interface DiffResult {
added: string[];
removed: string[];
modified: string[];
metadataChanged: string[];
}
let activeTab = $state<'files' | 'metadata'>('files');
let loading = $state(true);
let error = $state<string | null>(null);
let diff = $state<DiffResult | null>(null);
let metaA = $state<any>(null);
let metaB = $state<any>(null);
let metaLoading = $state(false);
$effect(() => {
if (open && snapshotA?.id && snapshotB?.id) {
activeTab = 'files';
fetchDiff();
}
});
async function fetchDiff() {
loading = true;
error = null;
diff = null;
metaA = null;
metaB = null;
try {
const res = await fetch(`/api/backup/snapshots/diff?destinationId=${destinationId}&snapshotA=${snapshotA.id}&snapshotB=${snapshotB.id}`);
if (!res.ok) {
const data = await res.json();
error = data.error || `HTTP ${res.status}`;
return;
}
diff = await res.json();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function loadMetadata() {
if (metaA && metaB) return;
metaLoading = true;
try {
const [resA, resB] = await Promise.all([
fetch(`/api/backup/snapshots/${snapshotA.id}/metadata?destinationId=${destinationId}`),
fetch(`/api/backup/snapshots/${snapshotB.id}/metadata?destinationId=${destinationId}`)
]);
metaA = resA.ok ? await resA.json() : null;
metaB = resB.ok ? await resB.json() : null;
} catch {}
metaLoading = false;
}
function switchToMetadata() {
activeTab = 'metadata';
loadMetadata();
}
const totalChanges = $derived(diff ? diff.added.length + diff.removed.length + diff.modified.length + diff.metadataChanged.length : 0);
// Compute metadata differences
interface MetaDiff { field: string; valueA: string; valueB: string }
const metaDiffs = $derived.by(() => {
if (!metaA?.containers?.[0] || !metaB?.containers?.[0]) return [];
const a = metaA.containers[0];
const b = metaB.containers[0];
const diffs: MetaDiff[] = [];
function compare(field: string, va: any, vb: any) {
const sa = typeof va === 'object' ? JSON.stringify(va, null, 2) : String(va ?? '—');
const sb = typeof vb === 'object' ? JSON.stringify(vb, null, 2) : String(vb ?? '—');
if (sa !== sb) diffs.push({ field, valueA: sa, valueB: sb });
}
compare('Image', a.image, b.image);
compare('Cmd', a.config?.Cmd, b.config?.Cmd);
compare('Entrypoint', a.config?.Entrypoint, b.config?.Entrypoint);
compare('Env', a.config?.Env, b.config?.Env);
compare('Labels', a.config?.Labels, b.config?.Labels);
compare('Hostname', a.config?.Hostname, b.config?.Hostname);
compare('User', a.config?.User, b.config?.User);
compare('WorkingDir', a.config?.WorkingDir, b.config?.WorkingDir);
compare('RestartPolicy', a.hostConfig?.RestartPolicy, b.hostConfig?.RestartPolicy);
compare('NetworkMode', a.hostConfig?.NetworkMode, b.hostConfig?.NetworkMode);
compare('PortBindings', a.hostConfig?.PortBindings, b.hostConfig?.PortBindings);
compare('Privileged', a.hostConfig?.Privileged, b.hostConfig?.Privileged);
compare('Memory', a.hostConfig?.Memory, b.hostConfig?.Memory);
compare('CpuShares', a.hostConfig?.CpuShares, b.hostConfig?.CpuShares);
compare('Mounts', a.mounts?.length, b.mounts?.length);
compare('Networks', Object.keys(a.networkSettings?.Networks || {}), Object.keys(b.networkSettings?.Networks || {}));
return diffs;
});
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-5xl max-h-[85vh] flex flex-col">
<Dialog.Header>
<Dialog.Title>Snapshot comparison</Dialog.Title>
<Dialog.Description>
<span class="font-mono text-xs">{snapshotA.shortId}</span>
<span class="text-muted-foreground text-xs mx-1">({formatDateTime(snapshotA.time)})</span>
<ArrowRight class="w-3 h-3 inline mx-1" />
<span class="font-mono text-xs">{snapshotB.shortId}</span>
<span class="text-muted-foreground text-xs mx-1">({formatDateTime(snapshotB.time)})</span>
</Dialog.Description>
</Dialog.Header>
<!-- Tabs -->
<div class="flex gap-1 border-b mb-3">
<button class="px-3 py-1.5 text-sm {activeTab === 'files' ? 'border-b-2 border-primary font-medium' : 'text-muted-foreground'}" onclick={() => activeTab = 'files'}>
<FolderOpen class="w-3.5 h-3.5 inline mr-1" />Files
</button>
<button class="px-3 py-1.5 text-sm {activeTab === 'metadata' ? 'border-b-2 border-primary font-medium' : 'text-muted-foreground'}" onclick={switchToMetadata}>
<Container class="w-3.5 h-3.5 inline mr-1" />Metadata
</button>
</div>
<div class="flex-1 min-h-[400px] overflow-y-auto pr-3">
{#if activeTab === 'files'}
{#if loading}
<div class="flex items-center justify-center py-12">
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Comparing snapshots...</span>
</div>
{:else if error}
<div class="text-sm text-destructive py-4">{error}</div>
{:else if diff}
<div class="flex gap-2 mb-3 flex-wrap">
{#if diff.added.length > 0}
<Badge variant="outline" class="text-green-600"><Plus class="w-3 h-3 mr-1" />{diff.added.length} added</Badge>
{/if}
{#if diff.removed.length > 0}
<Badge variant="outline" class="text-red-500"><Minus class="w-3 h-3 mr-1" />{diff.removed.length} removed</Badge>
{/if}
{#if diff.modified.length > 0}
<Badge variant="outline" class="text-amber-500"><FileEdit class="w-3 h-3 mr-1" />{diff.modified.length} modified</Badge>
{/if}
{#if diff.metadataChanged.length > 0}
<Badge variant="outline" class="text-blue-500"><FileCheck class="w-3 h-3 mr-1" />{diff.metadataChanged.length} metadata</Badge>
{/if}
{#if totalChanges === 0}
<span class="text-sm text-muted-foreground">No differences found</span>
{/if}
</div>
<div class="border rounded-md bg-muted/30 p-2 font-mono text-xs space-y-0.5">
{#each diff.added as path}
<div class="text-green-600">+ {path}</div>
{/each}
{#each diff.removed as path}
<div class="text-red-500">- {path}</div>
{/each}
{#each diff.modified as path}
<div class="text-amber-500">M {path}</div>
{/each}
{#each diff.metadataChanged as path}
<div class="text-blue-500">U {path}</div>
{/each}
</div>
{/if}
{:else if activeTab === 'metadata'}
{#if metaLoading}
<div class="flex items-center justify-center py-12">
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading metadata...</span>
</div>
{:else if !metaA || !metaB}
<p class="text-sm text-muted-foreground py-4">Metadata not available for one or both snapshots.</p>
{:else if metaDiffs.length === 0}
<p class="py-4 text-sm text-muted-foreground">The container configuration is identical (image, command, env, ports, mounts, networks, …).</p>
<p class="flex items-start gap-1.5 text-xs text-muted-foreground"><Info class="mt-0.5 h-3.5 w-3.5 flex-shrink-0" /><span>If the Files tab shows <span class="font-mono">metadata.json</span> as modified, that's the capture timestamp / recorded container state inside it changing between backups — not a config change.</span></p>
{:else}
<table class="w-full text-xs">
<thead class="sticky top-0 bg-background">
<tr class="border-b text-muted-foreground">
<th class="text-left py-1.5 px-2 w-32">Field</th>
<th class="text-left py-1.5 px-2">{snapshotA.shortId}</th>
<th class="text-left py-1.5 px-2">{snapshotB.shortId}</th>
</tr>
</thead>
<tbody>
{#each metaDiffs as d}
<tr class="border-b last:border-0 hover:bg-muted/30">
<td class="py-1.5 px-2 font-medium text-muted-foreground align-top">{d.field}</td>
<td class="py-1.5 px-2 align-top">
<pre class="whitespace-pre-wrap text-red-500/80 bg-red-500/5 rounded px-1 max-h-32 overflow-auto">{d.valueA}</pre>
</td>
<td class="py-1.5 px-2 align-top">
<pre class="whitespace-pre-wrap text-green-600/80 bg-green-500/5 rounded px-1 max-h-32 overflow-auto">{d.valueB}</pre>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
+35 -4
View File
@@ -13,7 +13,7 @@
import * as Tooltip from '$lib/components/ui/tooltip';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { formatPorts, formatExposedPorts } from '$lib/utils/port-format';
import { formatBytes } from '$lib/utils/format';
import { formatBytes, formatBytesCompact } from '$lib/utils/format';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { Badge } from '$lib/components/ui/badge';
@@ -306,6 +306,10 @@
// Set of container IDs with updates available (for O(1) lookup)
const containersWithUpdatesSet = $derived(new Set(batchUpdateContainerIds));
// Container IDs whose last update check failed (e.g. registry rate-limited) — #1255
const containersWithFailedCheckSet = $derived(new Set($containerStore.failedUpdateIds));
const failedUpdateErrors = $derived($containerStore.failedUpdateErrors);
// Filter dropdown entries: real statuses plus the synthetic
// "update-available" entry, only offered once we know about a pending
// update — picking it on an empty set would just empty the list (#1063).
@@ -460,6 +464,7 @@
function handleUpdateCheckComplete(result: {
withUpdates: Array<{ containerId: string; containerName: string }>;
failed?: Array<{ containerId: string; error: string }>;
}) {
if (result.withUpdates.length === 0) {
containerStore.setPendingUpdates([], new Map());
@@ -470,6 +475,13 @@
new Map(result.withUpdates.map((r) => [r.containerId, r.containerName]))
);
}
// Record failed checks so their containers show a "check failed" state
// instead of masquerading as up to date (#1255).
const failed = result.failed ?? [];
containerStore.setFailedUpdates(
failed.map((f) => f.containerId),
new Map(failed.map((f) => [f.containerId, f.error]))
);
}
// Load pending updates from database (persisted from check-updates or scheduled jobs)
@@ -505,6 +517,9 @@
const response = await fetch(`/api/containers/pending-updates?env=${envId}`, { method: 'DELETE' });
if (response.ok) {
containerStore.setPendingUpdates([], new Map());
// Failed-check state is session-only — clear it here too so "Clear"
// dismisses the red "check failed" icons alongside the amber ones.
containerStore.setFailedUpdates([], new Map());
}
} catch {
toast.error('Failed to clear update indicators');
@@ -1746,6 +1761,22 @@
</a>
{/if}
{/if}
{:else if containersWithFailedCheckSet.has(container.id)}
<Tooltip.Root>
<Tooltip.Trigger>
<AlertTriangle class="w-3 h-3 text-red-500 shrink-0 cursor-help" />
</Tooltip.Trigger>
<Tooltip.Content side="right" class="w-72 p-3">
<div class="space-y-1.5">
<p class="font-medium text-sm flex items-center gap-1.5 whitespace-nowrap">
<AlertTriangle class="w-4 h-4 text-red-500 shrink-0" />
Update check failed
</p>
<p class="text-muted-foreground text-xs break-words">{failedUpdateErrors.get(container.id) ?? 'Could not query registry'}</p>
<p class="text-muted-foreground text-xs">Update status unknown — often a Docker Hub rate limit. Try again later.</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<span class="text-xs text-muted-foreground truncate" title={container.image}>{container.image}</span>
</div>
@@ -1797,7 +1828,7 @@
{@const memoryTooltip = stats.memoryCache > 0
? `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)} (Total: ${formatBytes(stats.memoryRaw)} | Cache: ${formatBytes(stats.memoryCache)})`
: `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)}`}
<span class="text-xs font-mono {stats.memoryPercent > 80 ? 'text-red-500' : stats.memoryPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}" title={memoryTooltip}>{formatBytes(stats.memoryUsage)}<span class="text-muted-foreground/50">/{formatBytes(stats.memoryLimit, 0)}</span></span>
<span class="text-xs font-mono {stats.memoryPercent > 80 ? 'text-red-500' : stats.memoryPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}" title={memoryTooltip}>{formatBytesCompact(stats.memoryUsage)}<span class="text-muted-foreground/50">/{formatBytesCompact(stats.memoryLimit, 0)}</span></span>
{:else if container.state === 'running'}
<span class="text-xs text-muted-foreground/50">...</span>
{:else}
@@ -1809,7 +1840,7 @@
{#if containerStats.get(container.id)}
{@const stats = containerStats.get(container.id)}
<span class="text-xs font-mono text-muted-foreground" title="↓{formatBytes(stats.networkRx)} received / ↑{formatBytes(stats.networkTx)} sent">
<span class="text-2xs text-blue-400">↓</span>{formatBytes(stats.networkRx, 0)} <span class="text-2xs text-orange-400">↑</span>{formatBytes(stats.networkTx, 0)}
<span class="text-2xs text-blue-400">↓</span>{formatBytesCompact(stats.networkRx, 0)} <span class="text-2xs text-orange-400">↑</span>{formatBytesCompact(stats.networkTx, 0)}
</span>
{:else if container.state === 'running'}
<span class="text-xs text-muted-foreground/50">...</span>
@@ -1822,7 +1853,7 @@
{#if containerStats.get(container.id)}
{@const stats = containerStats.get(container.id)}
<span class="text-xs font-mono text-muted-foreground" title="↓{formatBytes(stats.blockRead)} read / ↑{formatBytes(stats.blockWrite)} written">
<span class="text-2xs text-green-400">r</span>{formatBytes(stats.blockRead, 0)} <span class="text-2xs text-yellow-400">w</span>{formatBytes(stats.blockWrite, 0)}
<span class="text-2xs text-green-400">r</span>{formatBytesCompact(stats.blockRead, 0)} <span class="text-2xs text-yellow-400">w</span>{formatBytesCompact(stats.blockWrite, 0)}
</span>
{:else if container.state === 'running'}
<span class="text-xs text-muted-foreground/50">...</span>
+824
View File
@@ -0,0 +1,824 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import { Save, Play, Pause, RefreshCw, CheckCircle, XCircle, ChevronDown, Loader2, Plus, Trash2, Pencil, X, Check, HardDrive, Box, Layers, FileText } from 'lucide-svelte';
import RotateCwFadingClock from '$lib/components/icons/RotateCwFadingClock.svelte';
import VolumePicker from '$lib/components/backup/VolumePicker.svelte';
import DestinationPicker from '$lib/components/backup/DestinationPicker.svelte';
import LogConsole from '$lib/components/LogConsole.svelte';
import SnapshotsPanel from './SnapshotsPanel.svelte';
import ExecutionHistoryList from '$lib/components/ExecutionHistoryList.svelte';
import ExecutionLogViewer from '$lib/components/ExecutionLogViewer.svelte';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { currentEnvironment, appendEnvParam, environments } from '$lib/stores/environment';
import { formatDateTime } from '$lib/stores/settings';
import { watchJob } from '$lib/utils/sse-fetch';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { getRepoTypeIcon, isLocalRepo, isRemoteEnvironment, parseRetention, parseOptions, retentionSummary as getRetentionSummary, formatCron, runBackupAction, classifyJobResult, tagLogLine, fetchBackupExecutions, type BackupAction, type BackupFormState } from '$lib/utils/backup';
interface Props {
containerName: string;
volumes: { name: string; mountPoint: string; mountType?: 'volume' | 'bind' }[];
type?: 'container' | 'stack';
environmentId?: number;
onConfigSaved?: () => void;
autoShowRunForm?: boolean;
/** Reports the ok/fail run tally so the parent modal can badge its Backups tab. */
onTally?: (tally: { ok: number; failed: number }) => void;
}
let { containerName, volumes, type = 'container', environmentId, onConfigSaved, autoShowRunForm = false, onTally }: Props = $props();
interface Destination { id: number; name: string; repository: string; }
interface BackupConfig {
id: number; destinationId: number; enabled: boolean; schedule: string;
retention: string | null; options?: string | null; stopBeforeBackup: boolean; selectedVolumes: string[] | null;
lastBackupStatus?: string | null; lastBackupAt?: string | null;
}
const getTypeIcon = getRepoTypeIcon;
// Sub-tabs: Schedules (this panel) and Snapshots (lazily mounted — its snapshot
// listing hits every repo over the network, so it must NOT run until the user
// actually opens the tab, to keep the container/stack modal responsive).
let subTab = $state<'schedules' | 'snapshots' | 'history'>('schedules');
// Snapshot count + loading state, reported by SnapshotsPanel (which loads in the
// background as soon as the Backups view opens). null count = not loaded yet.
let snapshotCount = $state<number | null>(null);
let snapshotsLoading = $state(false);
// Execution history + ok/fail tally, loaded from the executions API for this
// target's configs. Powers the History sub-tab and the parent modal's tab badge.
let executions = $state<import('$lib/utils/execution-tally').Execution[]>([]);
let tally = $state<{ ok: number; failed: number }>({ ok: 0, failed: 0 });
let historyLoading = $state(false);
// Backup-run log viewer (opened from the History table's log button).
let logDialogOpen = $state(false);
let logDialogContent = $state<string | null>(null);
let logDialogLoading = $state(false);
// The list query omits the (potentially large) logs column, so fetch the single
// execution's logs on demand from the detail endpoint — otherwise the dialog
// always showed "No log output".
async function viewExecutionLog(id: number) {
logDialogContent = null;
logDialogLoading = true;
logDialogOpen = true;
try {
const res = await fetch(`/api/schedules/executions/${id}`);
if (res.ok) {
const detail = await res.json();
logDialogContent = detail?.logs ?? null;
}
} catch { /* leave null → "No log output" */ }
finally { logDialogLoading = false; }
}
// Ref to the (always-mounted) snapshots panel so a manual backup can force it to
// reload — a new snapshot won't show up otherwise (the panel loads once on mount).
let snapshotsPanel = $state<{ refresh: () => Promise<void> } | null>(null);
let destinations = $state<Destination[]>([]);
let configs = $state<BackupConfig[]>([]);
let loading = $state(true);
let runningBackup = $state<number | null>(null);
let confirmDeleteId = $state<number | null>(null);
// Edit form state
let editingConfig = $state<BackupConfig | null>(null);
let isNew = $state(false);
let editDestinationId = $state(0);
let editEnabled = $state(true);
let editSchedule = $state('0 2 * * *');
let editScheduleInvalid = $state(false);
let editKeepLast = $state(7);
let editKeepDaily = $state(0);
let editKeepWeekly = $state(0);
let editKeepMonthly = $state(0);
let editKeepYearly = $state(0);
let editExclude = $state('');
let editExcludeCaches = $state(true);
let editCompression = $state('auto');
let editLimitUpload = $state('');
let editLimitDownload = $state('');
let editWebhookSuccess = $state('');
let editWebhookFailure = $state('');
let editStopBefore = $state(false);
let editAllVolumes = $state(true);
let editSelectedVolumes = $state<string[]>([]);
let saving = $state(false);
// Tracks which submit action is in-flight so we can disable the right
// button and show the right spinner. The form itself is unified — only
// the post-submit behavior differs.
let submitAction = $state<'save' | 'save-run' | 'run-once' | null>(null);
let showAdvanced = $state(false);
// Live restic progress for a manual backup run (Run once / Save & run now, and
// the per-config Run-now button). Streamed into a modal so the user sees the
// same restic output the CreateBackupModal wizard shows.
let progressOpen = $state(false);
let progressLogs = $state<string[]>([]);
let progressStatus = $state<'running' | 'success' | 'error'>('running');
let progressError = $state('');
// Which repo the in-flight run targets, for the progress-modal header.
let progressDestId = $state<number>(0);
// The config id of the running backup, so the modal's Cancel can stop it.
let progressConfigId = $state<number | null>(null);
let cancelling = $state(false);
async function cancelRunningBackup() {
if (progressConfigId == null || cancelling) return;
cancelling = true;
try {
const res = await fetch(`/api/backup/configs/${progressConfigId}/stop`, { method: 'POST' });
if (res.ok) {
progressLogs = [...progressLogs, '[dockhand] Cancelling backup…'];
} else {
const d = await res.json().catch(() => ({}));
toast.error(d.error || 'Failed to cancel backup');
}
} catch {
toast.error('Failed to cancel backup');
} finally {
cancelling = false;
}
}
// Header bits: the type icon, the target repo (with its type icon), and the env.
const TypeIcon = $derived(type === 'stack' ? Layers : Box);
const progressDest = $derived(destinations.find((d) => d.id === progressDestId));
const progressEnv = $derived($environments.find((e) => e.id === envId));
const envId = $derived(environmentId ?? $currentEnvironment?.id ?? null);
const isCurrentEnvRemote = $derived(() => isRemoteEnvironment($currentEnvironment));
async function fetchDestinations() {
try {
const res = await fetch('/api/backup/destinations');
if (res.ok) destinations = await res.json();
} catch {}
}
async function fetchConfigs() {
loading = true;
try {
const params = new URLSearchParams({ target: containerName, type });
if (envId) params.set('env', String(envId));
const res = await fetch(`/api/backup/configs?${params}`);
if (res.ok) {
const results = await res.json();
configs = Array.isArray(results) ? results : results?.id ? [results] : [];
// Auto-open the unified create form when there are no configs
// (used by the backup wizard entry point so the user doesn't
// have to click "Add schedule" first on an empty list).
if (configs.length === 0 && !isNew && !editingConfig && autoShowRunForm) startNewConfig();
void loadHistory();
}
} catch {} finally { loading = false; }
}
// Load run history + tally as soon as the configs are known — the parent modal's
// tab badge needs the ok/fail counts without the user opening the History tab.
async function loadHistory() {
const ids = configs.map((c) => c.id);
if (ids.length === 0) { executions = []; tally = { ok: 0, failed: 0 }; onTally?.(tally); return; }
historyLoading = true;
try {
// Map each config to its destination so the History table can show a Repo
// column — a target can back up to more than one repo.
const repoByConfig = new Map(configs.map((c) => {
const d = destinations.find((x) => x.id === c.destinationId);
return [c.id, { repository: d?.repository, name: d?.name }] as const;
}));
const r = await fetchBackupExecutions(ids, 50, repoByConfig);
executions = r.executions;
tally = { ok: r.ok, failed: r.failed };
onTally?.(tally);
} finally { historyLoading = false; }
}
function startNewConfig() {
editingConfig = null;
isNew = true;
editDestinationId = destinations[0]?.id || 0;
editEnabled = true;
editSchedule = '0 2 * * *';
editKeepLast = 7; editKeepDaily = 0; editKeepWeekly = 0; editKeepMonthly = 0; editKeepYearly = 0;
editExclude = ''; editExcludeCaches = true; editCompression = 'auto'; editLimitUpload = ''; editLimitDownload = '';
editWebhookSuccess = ''; editWebhookFailure = '';
editStopBefore = false;
editAllVolumes = true;
editSelectedVolumes = [];
showAdvanced = false;
}
function startEditConfig(cfg: BackupConfig) {
editingConfig = cfg;
isNew = false;
editDestinationId = cfg.destinationId;
editEnabled = cfg.enabled;
editSchedule = cfg.schedule || '0 2 * * *';
const ret = parseRetention(cfg.retention);
editKeepLast = ret.keepLast || 0;
editKeepDaily = ret.keepDaily || 0;
editKeepWeekly = ret.keepWeekly || 0;
editKeepMonthly = ret.keepMonthly || 0;
editKeepYearly = ret.keepYearly || 0;
const opts = parseOptions(cfg.options);
editExclude = opts.excludePatterns || '';
editExcludeCaches = opts.excludeCaches !== false;
editCompression = opts.compression || 'auto';
editLimitUpload = opts.limitUpload || '';
editLimitDownload = opts.limitDownload || '';
editWebhookSuccess = opts.webhookSuccess || '';
editWebhookFailure = opts.webhookFailure || '';
editStopBefore = cfg.stopBeforeBackup;
editAllVolumes = cfg.selectedVolumes === null;
editSelectedVolumes = cfg.selectedVolumes ? (typeof cfg.selectedVolumes === 'string' ? JSON.parse(cfg.selectedVolumes) : cfg.selectedVolumes) : [];
showAdvanced = false;
}
function cancelEdit() {
editingConfig = null;
isNew = false;
}
/**
* Does the inline backup edit-form hold unsaved changes? Called by the parent
* modal (EditContainerModal / StackModal) before it closes, so a half-edited
* schedule (cron / keep-last / options) doesn't vanish silently.
*
* - A NEW config form open = dirty (the user started creating one, unsaved).
* - An EDIT form = dirty only if some field differs from the pristine config.
* The comparison mirrors startEditConfig() exactly, in reverse, so it matches
* what a save would persist (via parseRetention/parseOptions on editingConfig).
* - No form open (editingConfig null, not new) = not dirty.
*/
export function isDirty(): boolean {
if (isNew) return true;
if (!editingConfig) return false;
const cfg = editingConfig;
if (editDestinationId !== cfg.destinationId) return true;
if (editEnabled !== cfg.enabled) return true;
if (editSchedule !== (cfg.schedule || '0 2 * * *')) return true;
const ret = parseRetention(cfg.retention);
if (editKeepLast !== (ret.keepLast || 0)) return true;
if (editKeepDaily !== (ret.keepDaily || 0)) return true;
if (editKeepWeekly !== (ret.keepWeekly || 0)) return true;
if (editKeepMonthly !== (ret.keepMonthly || 0)) return true;
if (editKeepYearly !== (ret.keepYearly || 0)) return true;
const opts = parseOptions(cfg.options);
if (editExclude !== (opts.excludePatterns || '')) return true;
if (editExcludeCaches !== (opts.excludeCaches !== false)) return true;
if (editCompression !== (opts.compression || 'auto')) return true;
if (editLimitUpload !== (opts.limitUpload || '')) return true;
if (editLimitDownload !== (opts.limitDownload || '')) return true;
if (editWebhookSuccess !== (opts.webhookSuccess || '')) return true;
if (editWebhookFailure !== (opts.webhookFailure || '')) return true;
if (editStopBefore !== cfg.stopBeforeBackup) return true;
if (editAllVolumes !== (cfg.selectedVolumes === null)) return true;
const origVolumes = cfg.selectedVolumes
? (typeof cfg.selectedVolumes === 'string' ? JSON.parse(cfg.selectedVolumes) : cfg.selectedVolumes)
: [];
if (JSON.stringify([...editSelectedVolumes].sort()) !== JSON.stringify([...origVolumes].sort())) return true;
return false;
}
/** Snapshot the current form fields into the shared BackupFormState shape. */
function currentForm(): BackupFormState {
return {
targetName: containerName,
type,
environmentId: envId,
destinationId: editDestinationId,
stopBeforeBackup: editStopBefore,
allVolumes: editAllVolumes,
selectedVolumes: editSelectedVolumes,
retention: {
keepLast: editKeepLast || undefined,
keepDaily: editKeepDaily || undefined,
keepWeekly: editKeepWeekly || undefined,
keepMonthly: editKeepMonthly || undefined,
keepYearly: editKeepYearly || undefined
},
options: {
excludePatterns: editExclude || undefined,
excludeCaches: editExcludeCaches,
compression: editCompression !== 'auto' ? editCompression : undefined,
limitUpload: editLimitUpload || undefined,
limitDownload: editLimitDownload || undefined,
webhookSuccess: editWebhookSuccess || undefined,
webhookFailure: editWebhookFailure || undefined
}
};
}
/**
* Pre-flight validation + delegation to runBackupAction. The shared helper
* does the heavy lifting (POST/PUT, optional /run, optional cleanup). All
* three submit buttons land here, then runBackupAction branches on action.
*
* Edit mode (editingConfig != null) only renders the 'save' button — the
* other actions don't apply to in-place edits.
*/
async function submitForm(action: BackupAction) {
if (!editDestinationId) { toast.error('Select a backup repository'); return; }
const selectedDest = destinations.find(d => d.id === editDestinationId);
if (selectedDest && isCurrentEnvRemote() && isLocalRepo(selectedDest.repository)) {
toast.error('Local repositories cannot be used with remote environments');
return;
}
// Cron only matters when the schedule will be persisted. run-once
// clears it server-side so the field value is irrelevant.
if ((action === 'save' || action === 'save-run') && editScheduleInvalid) {
toast.error('Schedule is invalid');
return;
}
submitAction = action;
saving = true;
// A run action (run-once / save-run) streams restic output into the progress
// modal. A plain save persists silently — no restic runs, so no log view.
const runsBackup = action === 'run-once' || action === 'save-run';
if (runsBackup) {
progressLogs = [];
progressError = '';
progressStatus = 'running';
progressDestId = editDestinationId;
progressConfigId = editingConfig?.id ?? null; // set precisely via onStarted
progressOpen = true;
}
try {
const result = await runBackupAction({
form: currentForm(),
action,
editingConfigId: editingConfig?.id ?? null,
schedule: editSchedule,
enabled: editEnabled,
onStarted: runsBackup ? (id) => (progressConfigId = id) : undefined,
onProgress: runsBackup ? (line) => {
const msg = (line.data as { message?: string } | null)?.message;
if (msg) progressLogs = [...progressLogs, tagLogLine(msg)];
} : undefined
});
if (!result.ok) {
if (runsBackup) { progressStatus = 'error'; progressError = result.error || 'Backup failed'; }
else toast.error(result.error || 'Backup failed');
return;
}
if (runsBackup) progressStatus = 'success';
if (action === 'save') toast.success(isNew ? 'Backup schedule added' : 'Backup schedule updated');
else toast.success(`Backup completed for ${containerName}`);
editingConfig = null; isNew = false;
fetchConfigs();
// 'save-run'/'run-once' just wrote a snapshot — reload the list so it appears
// without a manual refresh. A plain 'save' writes none, so skip it.
if (runsBackup) { void snapshotsPanel?.refresh(); void loadHistory(); }
onConfigSaved?.();
} finally {
saving = false;
submitAction = null;
}
}
async function deleteConfig(id: number) {
try {
const res = await fetch(`/api/backup/configs/${id}`, { method: 'DELETE' });
if (res.ok) {
toast.success('Backup schedule removed');
fetchConfigs();
onConfigSaved?.();
} else {
toast.error('Failed to delete');
}
} catch { toast.error('Failed to delete'); }
confirmDeleteId = null;
}
// Pause/resume a scheduled backup. A minimal PUT with only `enabled` is a
// partial update (updateBackupConfig skips undefined fields) and the endpoint
// re-registers or unregisters the croner job accordingly.
let togglingId = $state<number | null>(null);
async function togglePause(cfg: BackupConfig) {
togglingId = cfg.id;
try {
const res = await fetch(`/api/backup/configs/${cfg.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !cfg.enabled })
});
if (res.ok) {
toast.success(cfg.enabled ? 'Schedule paused' : 'Schedule resumed');
fetchConfigs();
onConfigSaved?.();
} else {
toast.error('Failed to update schedule');
}
} catch { toast.error('Failed to update schedule'); }
togglingId = null;
}
async function runBackupNow(cfg: BackupConfig) {
runningBackup = cfg.id;
progressLogs = [];
progressError = '';
progressStatus = 'running';
progressDestId = cfg.destinationId;
progressConfigId = cfg.id;
progressOpen = true;
try {
const res = await fetch(`/api/backup/configs/${cfg.id}/run`, { method: 'POST' });
const data = await res.json();
if (data.jobId) {
const result = await watchJob(data.jobId, (line) => {
const msg = (line.data as { message?: string } | null)?.message;
if (msg) progressLogs = [...progressLogs, tagLogLine(msg)];
}) as any;
const { outcome, message } = classifyJobResult(result);
// 'skipped' (a concurrent op holds the lock, or a prune guard) is NOT a
// success — surface it as an error in the modal, matching the main
// backups page. Only 'success'/'warning' get the green result.
if (outcome === 'error' || outcome === 'skipped') {
progressStatus = 'error';
progressError = message || (outcome === 'skipped' ? 'Backup skipped' : 'Backup failed');
} else {
progressStatus = 'success';
}
if (outcome === 'skipped') toast.info(message || 'Backup skipped');
else if (outcome === 'warning') toast.warning(message || 'Backup completed with warnings');
fetchConfigs();
// A new snapshot was written (success/warning) — reload the list so it
// shows up without a manual refresh. Skipped = no snapshot, nothing to do.
if (outcome !== 'skipped') void snapshotsPanel?.refresh();
void loadHistory(); // a run (even failed/skipped) is a new history entry
} else { progressStatus = 'error'; progressError = data.error || 'Failed to start backup'; }
} catch { progressStatus = 'error'; progressError = 'Failed to start backup'; } finally { runningBackup = null; }
}
function cfgRetentionSummary(cfg: BackupConfig): string {
return getRetentionSummary(cfg.retention);
}
function volumeSummary(cfg: BackupConfig): string {
if (cfg.selectedVolumes === null) return `All volumes (${volumes.length})`;
const sel = typeof cfg.selectedVolumes === 'string' ? JSON.parse(cfg.selectedVolumes) : cfg.selectedVolumes;
return `${sel.length} of ${volumes.length} volumes`;
}
onMount(() => { fetchDestinations(); fetchConfigs(); });
</script>
<!-- Sub-tabs: Schedules (cheap) vs Snapshots. SnapshotsPanel is mounted as soon as
the Backups view opens (its per-repo listing loads IN THE BACKGROUND with a
spinner on the tab) and kept mounted when switching tabs, so re-opening the
Snapshots tab is instant (cached, no re-fetch — use the tab's refresh to reload). -->
<div class="mb-3 flex items-center gap-1 border-b">
<button
type="button"
class="relative -mb-px border-b-2 px-3 py-1.5 text-sm font-medium transition-colors {subTab === 'schedules' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (subTab = 'schedules')}
>
Schedules{#if configs.length > 0}<span class="ml-1.5 rounded-full bg-primary/15 px-1.5 text-xs text-primary">{configs.length}</span>{/if}
</button>
<button
type="button"
class="relative -mb-px flex items-center border-b-2 px-3 py-1.5 text-sm font-medium transition-colors {subTab === 'snapshots' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (subTab = 'snapshots')}
>
Snapshots{#if snapshotsLoading}<Loader2 class="ml-1.5 h-3.5 w-3.5 animate-spin text-primary" />{:else if snapshotCount !== null}<span class="ml-1.5 rounded-full bg-primary/15 px-1.5 text-xs text-primary">{snapshotCount}</span>{/if}
</button>
<button
type="button"
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-1.5 text-sm font-medium transition-colors {subTab === 'history' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (subTab = 'history')}
>
History
{#if historyLoading}<Loader2 class="h-3.5 w-3.5 animate-spin text-primary" />{:else if tally.ok > 0 || tally.failed > 0}
{#if tally.ok > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-xs font-medium text-emerald-500"><Check class="h-3 w-3" />{tally.ok}</span>{/if}
{#if tally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-xs font-semibold text-red-500"><X class="h-3 w-3" />{tally.failed}</span>{/if}
{/if}
</button>
</div>
<!-- Snapshots: always mounted (loads in background on open, cached across tab
switches), shown only when its tab is active. -->
<div class:hidden={subTab !== 'snapshots'}>
<SnapshotsPanel
bind:this={snapshotsPanel}
targetName={containerName} {type} {environmentId}
hasBackups={configs.length > 0}
configIds={configs.map((c) => c.id)}
onCount={(n) => (snapshotCount = n)}
onLoadingChange={(l) => (snapshotsLoading = l)}
/>
</div>
{#if subTab === 'snapshots'}
<!-- content rendered above (kept mounted) -->
{:else if subTab === 'history'}
<ExecutionHistoryList {executions} loading={historyLoading} onViewLog={viewExecutionLog} />
{:else if loading}
<div class="flex items-center justify-center py-8">
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
{:else}
<div class="space-y-3">
<!-- Existing schedules list -->
{#each configs as cfg}
{@const dest = destinations.find(d => d.id === cfg.destinationId)}
{@const Icon = dest ? getTypeIcon(dest.repository) : HardDrive}
<div class="border rounded-md px-3 py-1.5 flex items-center gap-3 {!cfg.enabled ? 'opacity-60' : ''}">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<Icon class="w-3.5 h-3.5 text-primary/70 flex-shrink-0" />
<span class="text-sm font-medium truncate">{dest?.name || 'Unknown'}</span>
{#if !cfg.enabled}<Badge variant="secondary" class="text-xs">Paused</Badge>{/if}
</div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<span>{cfg.schedule ? formatCron(cfg.schedule) : 'Manual'} · {volumeSummary(cfg)}{cfgRetentionSummary(cfg) ? ` · ${cfgRetentionSummary(cfg)}` : ''}</span>
{#if cfg.lastBackupAt}
<span>·</span>
{#if cfg.lastBackupStatus === 'success'}<CheckCircle class="w-2.5 h-2.5 text-green-500" />{:else if cfg.lastBackupStatus === 'failed' || cfg.lastBackupStatus === 'error'}<XCircle class="w-2.5 h-2.5 text-destructive" />{/if}
<span>{formatDateTime(cfg.lastBackupAt)}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
{#if cfg.schedule}
<button type="button" class="p-1 rounded hover:bg-muted" onclick={() => togglePause(cfg)} disabled={togglingId === cfg.id} title={cfg.enabled ? 'Pause schedule' : 'Resume schedule'}>
{#if togglingId === cfg.id}<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />{:else if cfg.enabled}<Pause class="w-3 h-3 text-muted-foreground" />{:else}<RotateCwFadingClock class="w-3 h-3 text-muted-foreground" />{/if}
</button>
{/if}
<button type="button" class="p-1 rounded hover:bg-muted" onclick={() => runBackupNow(cfg)} disabled={runningBackup === cfg.id} title="Run now">
{#if runningBackup === cfg.id}<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />{:else}<Play class="w-3 h-3 text-muted-foreground" />{/if}
</button>
<button type="button" class="p-1 rounded hover:bg-muted" onclick={() => startEditConfig(cfg)} title="Edit">
<Pencil class="w-3 h-3 text-muted-foreground" />
</button>
<ConfirmPopover
open={confirmDeleteId === cfg.id}
action="Delete"
itemType="backup schedule"
itemName={dest?.name || ''}
title="Remove schedule"
position="left"
onConfirm={() => deleteConfig(cfg.id)}
onOpenChange={(open) => confirmDeleteId = open ? cfg.id : null}
>
{#snippet children({ open })}
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
</div>
</div>
{/each}
{#if configs.length === 0 && !isNew && !editingConfig}
<p class="text-xs text-muted-foreground py-4 text-center">No backup schedules configured for this {type}.</p>
{/if}
<!-- Edit/New form -->
{#if isNew || editingConfig}
<div class="border border-primary/30 rounded-md p-4 space-y-4 bg-muted/10">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{isNew ? 'New backup schedule' : 'Edit schedule'}</span>
<button type="button" class="p-1 rounded hover:bg-muted" onclick={cancelEdit}>
<X class="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
<!-- Repository + Schedule -->
<div class="flex items-center gap-2">
<DestinationPicker
destinations={destinations}
bind:value={editDestinationId}
disableLocalForRemoteEnv={isCurrentEnvRemote()}
triggerClass="h-9 w-[220px] flex-shrink-0"
/>
<CronEditor bind:value={editSchedule} bind:invalid={editScheduleInvalid} />
</div>
<!-- Toggles -->
<div class="flex items-center gap-3 max-w-md">
<TogglePill bind:checked={editEnabled} />
<Label class="text-xs">Enabled</Label>
</div>
<div class="flex items-center gap-3 max-w-md">
<TogglePill bind:checked={editStopBefore} />
<Label class="text-xs">Stop {type} during backup</Label>
</div>
<!-- Volume picker (all-or-subset toggle + per-volume checkboxes) -->
<VolumePicker
volumes={volumes}
bind:allVolumes={editAllVolumes}
bind:selectedVolumes={editSelectedVolumes}
emptyLabel="No volumes detected on this {type}"
/>
<!-- Advanced -->
<button type="button" class="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground w-full" onclick={() => showAdvanced = !showAdvanced}>
<ChevronDown class="w-3.5 h-3.5 transition-transform {showAdvanced ? 'rotate-0' : '-rotate-90'}" />
Advanced
</button>
{#if showAdvanced}
<div class="space-y-3 pl-1">
<!-- Retention policy -->
<div class="space-y-1.5">
<Label class="text-xs font-medium">Retention policy</Label>
<p class="text-xs text-muted-foreground">Older snapshots are pruned after each backup. Set 0 to disable.</p>
<div class="grid grid-cols-5 gap-2">
<div class="space-y-0.5">
<label class="text-xs text-muted-foreground">Last</label>
<Input bind:value={editKeepLast} type="number" min="0" max="999" class="h-7 text-xs" />
</div>
<div class="space-y-0.5">
<label class="text-xs text-muted-foreground">Daily</label>
<Input bind:value={editKeepDaily} type="number" min="0" max="365" class="h-7 text-xs" />
</div>
<div class="space-y-0.5">
<label class="text-xs text-muted-foreground">Weekly</label>
<Input bind:value={editKeepWeekly} type="number" min="0" max="52" class="h-7 text-xs" />
</div>
<div class="space-y-0.5">
<label class="text-xs text-muted-foreground">Monthly</label>
<Input bind:value={editKeepMonthly} type="number" min="0" max="120" class="h-7 text-xs" />
</div>
<div class="space-y-0.5">
<label class="text-xs text-muted-foreground">Yearly</label>
<Input bind:value={editKeepYearly} type="number" min="0" max="100" class="h-7 text-xs" />
</div>
</div>
</div>
<!-- Exclude patterns -->
<div class="space-y-1">
<Label class="text-xs font-medium">Exclude patterns</Label>
<Input bind:value={editExclude} class="h-7 text-xs font-mono" placeholder="*.log, *.tmp, cache/" />
<p class="text-xs text-muted-foreground">Comma-separated glob patterns to exclude from backup.</p>
</div>
<!-- Exclude cache directories -->
<div class="space-y-1">
<div class="flex items-center gap-3">
<TogglePill bind:checked={editExcludeCaches} onLabel="Yes" offLabel="No" />
<Label class="text-xs font-medium">Skip cache directories</Label>
</div>
<p class="text-xs text-muted-foreground leading-snug">
Skips folders containing a <code class="font-mono text-xs">CACHEDIR.TAG</code> marker file
— used by npm, pip, Cargo, browsers, and many other tools to tag
regenerable cache content. Saves significant backup space; the contents
can always be rebuilt from source. Turn off only if you have a specific
reason to back up cache data.
</p>
</div>
<!-- Compression & bandwidth -->
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1">
<Label class="text-xs font-medium">Compression</Label>
<Select.Root type="single" value={editCompression} onValueChange={(v) => { editCompression = v; }}>
<Select.Trigger class="h-9 w-full text-xs">{editCompression}</Select.Trigger>
<Select.Content>
<Select.Item value="auto">Auto</Select.Item>
<Select.Item value="off">Off</Select.Item>
<Select.Item value="max">Max</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-medium">Upload limit</Label>
<Input bind:value={editLimitUpload} type="number" min="0" class="h-9 text-xs font-mono" placeholder="KiB/s" />
</div>
<div class="space-y-1">
<Label class="text-xs font-medium">Download limit</Label>
<Input bind:value={editLimitDownload} type="number" min="0" class="h-9 text-xs font-mono" placeholder="KiB/s" />
</div>
</div>
<!-- Webhook hooks -->
<div class="space-y-1.5">
<Label class="text-xs font-medium">Webhook hooks</Label>
<div class="space-y-1">
<label class="text-xs text-muted-foreground">On success</label>
<Input bind:value={editWebhookSuccess} class="h-7 text-xs font-mono" placeholder="https://healthchecks.io/ping/..." />
</div>
<div class="space-y-1">
<label class="text-xs text-muted-foreground">On failure</label>
<Input bind:value={editWebhookFailure} class="h-7 text-xs font-mono" placeholder="https://hooks.slack.com/..." />
</div>
<p class="text-xs text-muted-foreground">POST with a JSON payload sent after backup completes (falls back to GET for simple receivers). Use for healthchecks, Slack, etc.</p>
</div>
</div>
{/if}
<!-- Submit actions. Editing existing config: just "Save".
Creating a new config: three actions, all use the same form
state (destination, schedule, volumes, retention, options) —
they only differ in what happens after persistence. -->
<div class="flex justify-end gap-2 pt-2 border-t">
<Button size="sm" variant="outline" onclick={cancelEdit}>Cancel</Button>
{#if editingConfig}
<Button size="sm" onclick={() => submitForm('save')} disabled={saving || editScheduleInvalid}>
{#if saving}<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />{:else}<Save class="w-3.5 h-3.5 mr-1" />{/if}
Save
</Button>
{:else}
<Button size="sm" variant="outline" onclick={() => submitForm('run-once')} disabled={saving || !editDestinationId} title="Run a backup now, don't save a schedule">
{#if saving && submitAction === 'run-once'}<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />{:else}<Play class="w-3.5 h-3.5 mr-1" />{/if}
Run once
</Button>
<Button size="sm" variant="outline" onclick={() => submitForm('save')} disabled={saving || editScheduleInvalid || !editDestinationId}>
{#if saving && submitAction === 'save'}<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />{:else}<Save class="w-3.5 h-3.5 mr-1" />{/if}
Save schedule
</Button>
<Button size="sm" onclick={() => submitForm('save-run')} disabled={saving || editScheduleInvalid || !editDestinationId} title="Save the schedule and run a backup immediately">
{#if saving && submitAction === 'save-run'}<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />{:else}<Play class="w-3.5 h-3.5 mr-1" />{/if}
Save & run now
</Button>
{/if}
</div>
</div>
{/if}
<!-- "Add schedule" trigger when there's already at least one config -->
{#if configs.length > 0 && !isNew && !editingConfig}
<Button size="sm" variant="ghost" class="w-full text-muted-foreground" onclick={startNewConfig}>
<Plus class="w-3.5 h-3.5 mr-1" />
Add another schedule
</Button>
{/if}
<!-- Empty-state trigger when there are no configs yet -->
{#if configs.length === 0 && !isNew && !editingConfig}
<Button size="sm" variant="outline" class="w-full" onclick={startNewConfig}>
<Plus class="w-3.5 h-3.5 mr-1" />
Configure a backup
</Button>
{/if}
</div>
{/if}
<!-- Manual-backup progress: restic output streamed live. Not dismissible mid-run;
the user closes it once the backup finishes (or fails). -->
<Dialog.Root bind:open={progressOpen}>
<Dialog.Content class="max-w-5xl flex flex-col overflow-hidden top-[6vh] translate-y-0 h-[85vh]" interactOutsideBehavior={progressStatus === 'running' ? 'ignore' : 'close'} escapeKeydownBehavior={progressStatus === 'running' ? 'ignore' : 'close'}>
<Dialog.Header>
<Dialog.Title class="flex flex-wrap items-center gap-x-2 gap-y-1 text-base">
<TypeIcon class="h-4 w-4 text-muted-foreground" />
<span>{containerName}</span>
{#if progressEnv}
<span class="text-sm font-normal text-muted-foreground">on</span>
<span class="flex items-center gap-1 text-sm font-medium text-amber-500"><EnvironmentIcon icon={progressEnv.icon || 'globe'} envId={progressEnv.id} class="h-3.5 w-3.5" />{progressEnv.name}</span>
{/if}
{#if progressDest}
<span class="text-sm font-normal text-muted-foreground">→</span>
{@const RepoIcon = getRepoTypeIcon(progressDest.repository)}
<span class="flex items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-0.5 text-xs font-normal"><RepoIcon class="h-3.5 w-3.5 text-primary/70" />{progressDest.name}</span>
{/if}
</Dialog.Title>
<Dialog.Description class="sr-only">Live backup progress for {containerName}.</Dialog.Description>
</Dialog.Header>
<!-- flex-1 + fixed dialog height: the log fills the space and SCROLLS internally,
so the dialog never grows/jumps as lines stream in. -->
<LogConsole lines={progressLogs} class="flex-1 min-h-0" />
<div class="flex shrink-0 items-center gap-1.5 text-sm">
{#if progressStatus === 'running'}
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" /><span class="text-muted-foreground">Backing up…</span>
{:else if progressStatus === 'success'}
<CheckCircle class="h-4 w-4 text-green-500" /><span class="text-green-500">Backup completed</span>
{:else}
<XCircle class="h-4 w-4 text-destructive" /><span class="text-destructive">{progressError || 'Backup failed'}</span>
{/if}
</div>
<Dialog.Footer>
{#if progressStatus === 'running'}
<Button size="sm" variant="destructive" disabled={cancelling || progressConfigId == null} onclick={cancelRunningBackup}>
{#if cancelling}<Loader2 class="mr-1 h-3.5 w-3.5 animate-spin" />Cancelling…{:else}<X class="mr-1 h-3.5 w-3.5" />Cancel backup{/if}
</Button>
{:else}
<Button size="sm" onclick={() => (progressOpen = false)}>OK</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Backup-run log viewer, opened from the History table's log button -->
<Dialog.Root bind:open={logDialogOpen}>
<Dialog.Content class="max-w-4xl h-[80vh] overflow-hidden flex flex-col">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-base"><FileText class="h-4 w-4" />Backup log</Dialog.Title>
</Dialog.Header>
<div class="flex-1 flex flex-col min-h-0">
{#if logDialogLoading}
<div class="flex items-center justify-center py-8 gap-2 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span class="text-sm">Loading log…</span>
</div>
{:else if logDialogContent}
<ExecutionLogViewer logs={logDialogContent} />
{:else}
<p class="py-8 text-center text-sm text-muted-foreground">No log output was recorded for this run.</p>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
@@ -5,7 +5,7 @@
import * as Tabs from '$lib/components/ui/tabs';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink, Play, Square as SquareIcon, RotateCw, Trash2 } from 'lucide-svelte';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, Folder, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink, Play, Square as SquareIcon, RotateCw, Trash2 } from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import * as Select from '$lib/components/ui/select';
import { toast } from 'svelte-sonner';
@@ -677,7 +677,7 @@
<X class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{:else}
<span class="text-muted-foreground font-normal">{displayName || containerId.slice(0, 12)}</span>
<span class="font-semibold">{displayName || containerId.slice(0, 12)}</span>
<button
type="button"
onclick={startEditing}
@@ -687,6 +687,9 @@
<Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $currentEnvironment}
<span class="font-semibold">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
{/if}
{@const composeStack = containerData?.Config?.Labels?.['com.docker.compose.project']}
{#if composeStack && !loading}
<Tooltip.Root>
@@ -1336,7 +1339,15 @@
{#each containerData.Mounts as mount}
<div class="p-3 border border-border rounded-lg space-y-2">
<div class="flex items-center justify-between">
<Badge variant="outline" class="text-xs">{mount.Type}</Badge>
<!-- Typed mount chip, consistent with the backup picker/log (amber Folder
for bind, sky HardDrive for volume). -->
{#if mount.Type === 'bind'}
<span class="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400"><Folder class="h-3 w-3" />bind</span>
{:else if mount.Type === 'volume'}
<span class="inline-flex items-center gap-1 rounded-full bg-sky-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400"><HardDrive class="h-3 w-3" />volume</span>
{:else}
<Badge variant="outline" class="text-xs">{mount.Type}</Badge>
{/if}
<Badge variant={mount.RW ? 'default' : 'secondary'} class="text-xs">
{mount.RW ? 'Read/Write' : 'Read-Only'}
</Badge>
@@ -1399,7 +1410,7 @@
<div class="min-w-0">
{containerData.divergence.env.length} env var{containerData.divergence.env.length === 1 ? '' : 's'} differ from the image:
<span class="font-mono">{containerData.divergence.env.join(', ')}</span>.
Values set by you at create time will stay. To reset to the image's current values, Remove &amp; Deploy.
On the next update, image-provided values you haven't overridden are refreshed to the new image; values you set yourself are kept. Use Remove &amp; Deploy to reset everything to the image.
</div>
</div>
{/if}
@@ -1429,7 +1440,7 @@
<div class="min-w-0">
{containerData.divergence.labels.length} label{containerData.divergence.labels.length === 1 ? '' : 's'} differ from the image:
<span class="font-mono">{containerData.divergence.labels.join(', ')}</span>.
Values set by you at create time will stay. To reset to the image's current values, Remove &amp; Deploy.
On the next update, image-provided values you haven't overridden are refreshed to the new image; values you set yourself are kept. Use Remove &amp; Deploy to reset everything to the image.
</div>
</div>
{/if}
@@ -14,37 +14,7 @@
import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
// Parse shell command respecting quotes
function parseShellCommand(cmd: string): string[] {
const args: string[] = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < cmd.length; i++) {
const char = cmd[i];
if ((char === '"' || char === "'") && !inQuotes) {
inQuotes = true;
quoteChar = char;
} else if (char === quoteChar && inQuotes) {
inQuotes = false;
quoteChar = '';
} else if (char === ' ' && !inQuotes) {
if (current) {
args.push(current);
current = '';
}
} else {
current += char;
}
}
if (current) {
args.push(current);
}
return args;
}
import { parseShellCommand } from '$lib/utils/shell';
interface ConfigSet {
id: number;
@@ -608,7 +578,7 @@
<Dialog.Title class="text-base font-semibold">
Create new container
{#if $currentEnvironment}
<span class="font-medium">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
<span class="font-semibold">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
{/if}
</Dialog.Title>
<button
+135 -5
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Pencil, Check, Loader2, X, Layers } from 'lucide-svelte';
import { Pencil, Check, Loader2, X, Layers, Settings, Archive } from 'lucide-svelte';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
import { focusFirstInput } from '$lib/utils';
import ContainerSettingsTab from './ContainerSettingsTab.svelte';
import BackupPanel from './BackupPanel.svelte';
import { mountTypeFromHostPath } from '$lib/utils/mounts';
import { fetchBackupExecutions } from '$lib/utils/backup';
import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
import { parseHostPort, expandPortBindings, formatHostPort } from '$lib/utils/port-parse';
import { formatBytes } from '$lib/utils/format';
@@ -228,7 +232,16 @@
let loading = $state(false);
let loadingData = $state(true);
let error = $state('');
let activeTab = $state<'settings' | 'backups'>('settings');
let backupCount = $state(0);
// Ok/fail run tally, reported by BackupPanel — shown on the Backups tab so a
// failed backup is visible without opening the tab.
let backupTally = $state<{ ok: number; failed: number }>({ ok: 0, failed: 0 });
let abortController: AbortController | null = null;
// Unsaved-changes guard on close (mirrors StackModal): settings changes OR a
// half-edited backup schedule in the embedded panel.
let showConfirmClose = $state(false);
let backupPanelRef = $state<BackupPanel | undefined>(undefined);
let statusMessage = $state('');
let visible = $state(false);
@@ -598,6 +611,25 @@
error = 'Failed to load container data: ' + String(err);
} finally {
loadingData = false;
// Fetch backup schedule count (BETA GATE: only when backups enabled)
if ($page.data.backupsEnabled) try {
const envId = $currentEnvironment?.id;
const bp = new URLSearchParams({ target: name, type: 'container' });
if (envId) bp.set('env', String(envId));
const bRes = await fetch(`/api/backup/configs?${bp}`);
if (bRes.ok) {
const bData = await bRes.json();
const cfgs = Array.isArray(bData) ? bData : bData?.id ? [bData] : [];
backupCount = cfgs.length;
// Compute the run tally at modal level (not from BackupPanel, which
// only mounts when the Backups tab is open) so the fail count shows
// on the tab immediately.
if (cfgs.length > 0) {
const t = await fetchBackupExecutions(cfgs.map((c: any) => c.id));
backupTally = { ok: t.ok, failed: t.failed };
}
}
} catch {}
requestAnimationFrame(() => {
visible = true;
focusFirstInput();
@@ -1018,9 +1050,34 @@
abortController = null;
}
loading = false;
showConfirmClose = false;
onClose();
}
/** True if closing now would lose unsaved work: container settings changed, OR the
* embedded backup panel has a half-edited schedule. Guarded on `originalConfig`
* (the loaded baseline) — while the container config is still loading it is null,
* and hasContainerConfigChanged() returns true for a null baseline, which would
* falsely block a close during load. No baseline yet ⇒ nothing to lose. */
function hasUnsavedChanges(): boolean {
if (!originalConfig) return false;
return hasContainerConfigChanged() || (backupPanelRef?.isDirty() ?? false);
}
/** Intercept a close request: confirm first if there are unsaved changes. */
function tryClose() {
if (hasUnsavedChanges()) {
showConfirmClose = true;
} else {
handleClose();
}
}
function discardAndClose() {
showConfirmClose = false;
handleClose();
}
$effect(() => {
if (open && containerId && !hasLoadedData) {
visible = false;
@@ -1040,8 +1097,21 @@
});
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
<Dialog.Content class="max-w-4xl w-full max-h-[90vh] p-0 flex flex-col overflow-hidden sm:max-h-[85vh]">
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (isOpen) {
focusFirstInput();
} else if (hasUnsavedChanges()) {
// Re-open and show the confirmation instead of closing on unsaved changes.
open = true;
showConfirmClose = true;
} else {
handleClose();
}
}}
>
<Dialog.Content class="max-w-4xl w-full h-[85vh] p-0 flex flex-col overflow-hidden">
<Dialog.Header class="px-5 py-4 border-b bg-muted/30 shrink-0 sticky top-0 z-10">
<Dialog.Title class="text-base font-semibold flex items-center gap-1">
Edit container
@@ -1074,7 +1144,8 @@
<X class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{:else if name}
<span class="font-normal text-muted-foreground ml-1">- {name}</span>
<span class="text-muted-foreground ml-1">-</span>
<span class="font-semibold">{name}</span>
<button
type="button"
onclick={startEditingTitle}
@@ -1083,6 +1154,9 @@
>
<Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{#if $currentEnvironment}
<span class="font-semibold ml-1">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
{/if}
{/if}
</Dialog.Title>
</Dialog.Header>
@@ -1093,7 +1167,42 @@
Loading container data...
</div>
{:else}
<div class="px-5 py-4 flex-1 overflow-y-auto">
<div class="px-5 flex gap-1 border-b shrink-0">
<button
type="button"
class="flex items-center gap-1.5 px-3 py-2 text-sm transition-colors border-b-2 -mb-px {activeTab === 'settings' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'settings'}
>
<Settings class="w-3.5 h-3.5" />
Settings
</button>
<!-- BETA GATE: Backups tab hidden unless FEAT_BACKUPS_ENABLED (see features.ts) -->
{#if $page.data.backupsEnabled}
<button
type="button"
class="flex items-center gap-1.5 px-3 py-2 text-sm transition-colors border-b-2 -mb-px {activeTab === 'backups' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'backups'}
>
<Archive class="w-3.5 h-3.5" />
Backups
{#if backupCount > 0}<span class="bg-primary/15 text-primary text-[10px] px-1.5 rounded-full font-medium">{backupCount}</span>{/if}
<!-- Run tally so a failed backup is visible before opening the tab. -->
{#if backupTally.ok > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-medium text-emerald-500"><Check class="w-2.5 h-2.5" />{backupTally.ok}</span>{/if}
{#if backupTally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="w-2.5 h-2.5" />{backupTally.failed}</span>{/if}
</button>
{/if}
</div>
<div class="px-5 py-4 flex-1 overflow-y-auto h-0">
{#if activeTab === 'backups'}
<BackupPanel
bind:this={backupPanelRef}
containerName={name}
volumes={volumeMappings.filter(v => v.hostPath && v.containerPath).map(v => ({ name: v.containerPath, mountPoint: v.hostPath, mountType: mountTypeFromHostPath(v.hostPath) }))}
type="container"
onTally={(t) => (backupTally = t)}
/>
{:else}
<!-- Compose warning banners -->
{#if showComposeRenameWarning}
<div class="mb-4 px-3 py-2 text-xs text-amber-700 dark:text-amber-300 bg-amber-100/50 dark:bg-amber-900/30 rounded-md flex items-start gap-2">
@@ -1175,6 +1284,7 @@
{error}
</div>
{/if}
{/if}
</div>
<div class="flex justify-end gap-2 px-5 py-3 border-t bg-muted/30 shrink-0">
@@ -1193,3 +1303,23 @@
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Unsaved changes confirmation dialog -->
<Dialog.Root bind:open={showConfirmClose}>
<Dialog.Content class="max-w-sm">
<Dialog.Header>
<Dialog.Title>Unsaved changes</Dialog.Title>
<Dialog.Description>
You have unsaved changes. Are you sure you want to close without saving?
</Dialog.Description>
</Dialog.Header>
<div class="flex justify-end gap-1.5 mt-4">
<Button variant="outline" size="sm" onclick={() => showConfirmClose = false}>
Continue editing
</Button>
<Button variant="destructive" size="sm" onclick={discardAndClose}>
Discard changes
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -2,6 +2,7 @@
import * as Dialog from '$lib/components/ui/dialog';
import { FolderOpen } from 'lucide-svelte';
import FileBrowserPanel from './FileBrowserPanel.svelte';
import ModalHeader from '$lib/components/ModalHeader.svelte';
import { canAccess } from '$lib/stores/auth';
interface Props {
@@ -22,12 +23,9 @@
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content class="max-w-4xl h-[90vh] sm:h-[80vh] flex flex-col">
<Dialog.Content class="max-w-4xl h-[90vh] sm:h-[80vh] flex flex-col" onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<FolderOpen class="w-5 h-5" />
<span>Browse files - {containerName}</span>
</Dialog.Title>
<ModalHeader icon={FolderOpen} title="Browse files" name={containerName} />
<Dialog.Description>
Browse, upload, and download files from the container filesystem.
</Dialog.Description>
+82 -23
View File
@@ -52,7 +52,7 @@
readonly?: boolean;
}
type BrowserMode = 'container' | 'volume';
type BrowserMode = 'container' | 'volume' | 'snapshot';
type SortField = 'name' | 'size' | 'modified' | 'type';
type SortDirection = 'asc' | 'desc';
@@ -75,6 +75,9 @@
selectFilter?: RegExp;
// Callback when a file is selected (in selectMode)
onFileSelect?: (path: string, name: string) => void;
// Snapshot browsing mode
snapshotId?: string;
destinationId?: number;
}
let {
@@ -86,7 +89,9 @@
onUsageChange,
selectMode = false,
selectFilter,
onFileSelect
onFileSelect,
snapshotId,
destinationId
}: Props = $props();
// For volume mode, track whether volume is in use (controls editing ability)
@@ -96,12 +101,13 @@
let volumeHelperId = $state<string | null>(null);
// Determine mode based on which prop is provided
const mode: BrowserMode = $derived(volumeName ? 'volume' : 'container');
const mode: BrowserMode = $derived(snapshotId ? 'snapshot' : volumeName ? 'volume' : 'container');
const isVolumeMode = $derived(mode === 'volume');
const isSnapshotMode = $derived(mode === 'snapshot');
// Effective canEdit: for containers, use the prop; for volumes, only allow if not in use
// Effective canEdit: snapshots are always read-only; volumes only if not in use
const effectiveCanEdit = $derived(
isVolumeMode ? (canEdit && !volumeIsInUse) : canEdit
isSnapshotMode ? false : isVolumeMode ? (canEdit && !volumeIsInUse) : canEdit
);
// Effective container ID for file operations (use helper container for volume mode)
@@ -145,6 +151,11 @@
// Editor/Viewer state
let editingFile = $state<{ name: string; path: string; content: string } | null>(null);
let viewingFile = $state<{ name: string; path: string; content: string } | null>(null);
// Preview-only loading flag (distinct from loadingFile, which also covers
// edit/download): drives the "Loading preview…" overlay so a slow restic dump
// from a remote repo (B2/S3) shows immediate feedback in-place.
let loadingPreview = $state(false);
let previewingName = $state('');
let editorContent = $state('');
let loadingFile = $state(false);
// True when the editor buffer differs from the loaded file (unsaved changes).
@@ -368,16 +379,22 @@
async function openFileForView(entry: FileEntry) {
const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
loadingFile = true;
loadingPreview = true;
previewingName = entry.name;
try {
const params = new URLSearchParams({ path: filePath });
if (envId) params.set('env', envId.toString());
let res: Response;
if (isVolumeMode) {
res = await fetch(`/api/volumes/${encodeURIComponent(volumeName!)}/browse/content?${params}`);
if (isSnapshotMode) {
const params = new URLSearchParams({ destinationId: String(destinationId), path: filePath });
res = await fetch(`/api/backup/snapshots/${snapshotId}/dump?${params}`);
} else {
res = await fetch(`/api/containers/${effectiveContainerId}/files/content?${params}`);
const params = new URLSearchParams({ path: filePath });
if (envId) params.set('env', envId.toString());
if (isVolumeMode) {
res = await fetch(`/api/volumes/${encodeURIComponent(volumeName!)}/browse/content?${params}`);
} else {
res = await fetch(`/api/containers/${effectiveContainerId}/files/content?${params}`);
}
}
const data = await res.json();
@@ -394,6 +411,7 @@
toast.error(err.message || 'Failed to open file');
} finally {
loadingFile = false;
loadingPreview = false;
}
}
@@ -704,7 +722,11 @@
let res: Response;
let data: any;
if (isVolumeMode) {
if (isSnapshotMode) {
const snapshotParams = new URLSearchParams({ destinationId: String(destinationId), path });
res = await fetch(`/api/backup/snapshots/${snapshotId}/browse?${snapshotParams}`);
data = await res.json();
} else if (isVolumeMode) {
res = await fetch(`/api/volumes/${encodeURIComponent(volumeName!)}/browse?${params}`);
data = await res.json();
@@ -738,7 +760,20 @@
}
currentPath = data.path || path;
entries = data.entries || [];
// Map snapshot entries to match FileEntry interface
if (isSnapshotMode) {
entries = (data.entries || []).map((e: any) => ({
name: e.name,
type: e.type === 'dir' ? 'directory' : e.type,
size: e.size || 0,
permissions: '',
owner: '',
group: '',
modified: e.mtime || ''
}));
} else {
entries = data.entries || [];
}
} catch (err: any) {
error = err.message;
entries = [];
@@ -778,17 +813,20 @@
function downloadFile(entry: FileEntry) {
const filePath =
currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
const params = new URLSearchParams({
path: filePath,
format: $appSettings.downloadFormat
});
if (envId) params.set('env', envId.toString());
let url: string;
if (isVolumeMode) {
url = `/api/volumes/${encodeURIComponent(volumeName!)}/export?${params}`;
if (isSnapshotMode) {
const params = new URLSearchParams({ destinationId: String(destinationId), path: filePath, download: '1' });
if (entry.type === 'directory') params.set('type', 'directory');
url = `/api/backup/snapshots/${snapshotId}/dump?${params}`;
} else {
url = `/api/containers/${effectiveContainerId}/files/download?${params}`;
const params = new URLSearchParams({ path: filePath, format: $appSettings.downloadFormat });
if (envId) params.set('env', envId.toString());
if (isVolumeMode) {
url = `/api/volumes/${encodeURIComponent(volumeName!)}/export?${params}`;
} else {
url = `/api/containers/${effectiveContainerId}/files/download?${params}`;
}
}
window.open(url, '_blank');
}
@@ -841,8 +879,24 @@
return currentPath.split('/').filter(Boolean);
});
// Track all identity props so the effect re-fires when they change
$effect(() => {
loadDirectory(initialPath);
// Read reactive values to establish dependencies
const _mode = mode;
const _snap = snapshotId;
const _dest = destinationId;
const _cont = containerId;
const _vol = volumeName;
const _path = initialPath;
// Guard: don't load if required props for the mode are missing
if (_mode === 'snapshot' && (!_snap || !_dest)) return;
if (_mode === 'container' && !_cont) return;
currentPath = _path;
entries = [];
error = null;
loadDirectory(_path);
});
</script>
@@ -1195,7 +1249,12 @@
</Dialog.Root>
<!-- File Viewer Overlay -->
{#if viewingFile}
{#if loadingPreview && !viewingFile}
<div class="absolute inset-0 bg-background flex flex-col items-center justify-center gap-2 z-10">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
<span class="text-sm text-muted-foreground">Loading preview{previewingName ? ` — ${previewingName}` : ''}…</span>
</div>
{:else if viewingFile}
<div class="absolute inset-0 bg-background flex flex-col z-10">
<div class="flex items-center justify-between p-2 border-b bg-muted/30">
<div class="flex items-center gap-2 text-xs">
+756
View File
@@ -0,0 +1,756 @@
<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 { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
import { RotateCcw, AlertTriangle, Loader2, HardDrive, Folder, Clock, Play, CheckCircle2, XCircle, Server, PackagePlus, Ban, Rocket, Box, Layers, HelpCircle, Info } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { toast } from 'svelte-sonner';
import { watchJob } from '$lib/utils/sse-fetch';
import { environments } from '$lib/stores/environment';
import { formatDateTime } from '$lib/stores/settings';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import LogConsole from '$lib/components/LogConsole.svelte';
import { tagLogLine, classifyJobResult } from '$lib/utils/backup';
interface Props {
open: boolean;
destinationId: number;
snapshotId: string;
containerName: string;
environmentId?: number;
/** Refresh callback fired once the restore succeeds — the caller reloads its
* snapshot list. This modal is ALWAYS self-contained (form → log → result in
* one dialog); it never delegates its progress UI to a parent. */
onDone?: () => void;
}
let { open = $bindable(), destinationId, snapshotId, containerName, environmentId, onDone }: Props = $props();
// The two restore modes, framed by DESTINATION intent (not safe-vs-destructive):
// - 'new-location' ("Restore to environment"): pick a target env and, per
// volume, a DESTINATION. A destination pointing at a named volume / host path
// the container mounts + a start action = a working CLONE; a plain extract
// path = today's safe inspect-to-a-folder. Cross-env.
// - 'in-place' ("Overwrite live"): overwrite the live volumes on the SOURCE env.
type RestoreMode = 'new-location' | 'in-place';
let loading = $state(false);
let restoring = $state(false);
let error = $state('');
let restoreStatus = $state<'idle' | 'running' | 'success' | 'warning' | 'error'>('idle');
let restoreLogs = $state<string[]>([]);
// Non-fatal caveat shown on the 'warning' result (e.g. data restored but the
// post-restore recreate/redeploy failed — the operator finishes manually).
let restoreWarning = $state('');
// After the restore, what to do with the target.
type PostRestore = 'start' | 'recreate' | 'redeploy' | 'none';
// One row per volume. `dest`/`destKind` are the editable clone destination on
// the target env (pre-filled 1:1 from the snapshot's original location). `type`
// is the ORIGINAL kind (for the badge). `conflict` is set when a named-volume
// destination already exists on the target env (fail-closed — must be resolved).
interface VolRow {
name: string;
selected: boolean;
type: 'volume' | 'bind';
dest: string;
destKind: 'volume' | 'path';
/** The snapshot's ORIGINAL destination + kind (the 1:1 pre-fill), kept so we
* can detect when the user redirects a volume to a new name/type — which the
* recreate/redeploy won't follow (it uses the stored config). */
origDest: string;
origDestKind: 'volume' | 'path';
conflict: boolean;
/** A host-path destination that isn't a valid absolute path (empty, no
* leading '/', or contains '..') — mirrors the server-side validation so the
* restore is blocked before submit with an inline hint. */
pathInvalid: boolean;
}
let mode = $state<RestoreMode>('new-location');
let volumes = $state<VolRow[]>([]);
let hasMetadata = $state(false);
let hasStackFiles = $state(false);
let backupTime = $state<string | null>(null);
let targetPath = $state('/restore');
let confirmOverwrite = $state(false);
let postRestore = $state<PostRestore>('recreate');
// Set once the user picks a post-restore action by hand, so the auto "→ none on
// remap" doesn't override a deliberate choice (they may know their config matches).
let postRestoreUserPicked = $state(false);
let targetEnvId = $state<number | undefined>(undefined);
// The snapshot's SOURCE env for in-place. Seeded from the environmentId prop, but
// the prop can be empty (a config with no env, or an orphan snapshot restored from
// the list) — in that case we recover it from the snapshot metadata on preview, so
// in-place still knows which env holds the live target (otherwise the button stays
// disabled and the header shows "on " with no name).
let sourceEnvId = $state<number | undefined>(undefined);
// Volume names that already exist on the chosen target env (for conflict marks).
let existingTargetVolumes = $state<string[]>([]);
// Target-NAME collision: a container/stack of the restore's name already exists
// on the chosen target env. Mirrors the server's fail-closed check so the user
// sees it before submitting. Only blocks when a bring-up action is selected.
let nameExistsOnTarget = $state(false);
const envList = $derived($environments ?? []);
const effectiveEnvId = $derived(mode === 'in-place' ? sourceEnvId : targetEnvId);
const targetIsStack = $derived(hasStackFiles);
// After-restore action options. For a clone, 'start' is not offered (there's no
// live container to start — the target is created); 'recreate'/'redeploy' build it.
const postRestoreOptions = $derived([
...(mode === 'in-place' ? [{ value: 'start', label: targetIsStack ? 'Start stack' : 'Start container', icon: Play }] : []),
{ value: 'recreate', label: targetIsStack ? 'Recreate if missing' : 'Recreate container', icon: PackagePlus },
...(targetIsStack ? [{ value: 'redeploy', label: 'Redeploy stack', icon: Rocket }] : []),
{ value: 'none', label: 'Do nothing', icon: Ban }
]);
const targetEnv = $derived(envList.find((e) => e.id === effectiveEnvId));
const targetEnvName = $derived(targetEnv?.name ?? '');
// The snapshot's source env (for the "from <env>" phrasing in the summary).
const sourceEnv = $derived(envList.find((e) => e.id === sourceEnvId));
const sourceEnvName = $derived(sourceEnv?.name ?? '');
const postRestoreLabel = $derived(postRestoreOptions.find((o) => o.value === postRestore)?.label ?? '');
// Step-rail styling: always neutral (primary). Only the What-will-happen box
// turns red on the destructive (in-place) path — the rail/numbers stay calm.
// z-10 + solid dialog bg so the connector line is capped AT the circle edge and
// never shows through its (previously translucent) center.
const stepRingClass = 'relative z-10 border-primary/40 bg-background text-primary';
const stepLineClass = 'bg-primary/25';
const selectedRows = $derived(volumes.filter((v) => v.selected));
// A selected volume redirected to a NEW name or type (vs the snapshot's original).
// The recreate/redeploy uses the STORED config/compose, which still references the
// original mounts — so it won't pick up the redirected data. We warn + default the
// post-restore action to 'none' so the target isn't started with the wrong data.
const remappedRows = $derived(selectedRows.filter(
(v) => v.dest.trim() !== v.origDest || v.destKind !== v.origDestKind
));
const hasRemap = $derived(mode === 'new-location' && remappedRows.length > 0);
// The clone will bring the target up (recreate a container / redeploy a stack).
const willBringUp = $derived(postRestore === 'recreate' || postRestore === 'redeploy');
// Target-name collision blocks only when we'd bring the target up — a name clash
// is harmless for a pure extract (postRestore 'none').
const nameConflict = $derived(mode === 'new-location' && willBringUp && nameExistsOnTarget);
// A blocking issue: a volume collision, an invalid host path, or a target-name clash.
const hasBlockingIssue = $derived(mode === 'new-location' && (nameConflict || selectedRows.some((v) => v.conflict || v.pathInvalid)));
const canRun = $derived(
// A target environment MUST be chosen.
effectiveEnvId != null &&
(mode === 'in-place'
? confirmOverwrite
: (
// Every selected volume needs a non-empty destination; none may have a
// blocking issue (collision / invalid host path).
selectedRows.every((v) => v.dest.trim().length > 0) &&
!hasBlockingIssue &&
// Not a no-op: either something to restore, or a start action for a
// config-only snapshot (recreate/redeploy from stored config).
(selectedRows.length > 0 || postRestore !== 'none')
))
);
$effect(() => {
if (open && snapshotId) {
mode = 'new-location';
confirmOverwrite = false;
postRestore = 'recreate';
postRestoreUserPicked = false;
// Clone: NO env preselected — the user must consciously pick where it goes
// (the header shows no "to <env>" until they do). sourceEnvId is still seeded
// for in-place (which overwrites the live target on its own source env).
targetEnvId = undefined;
sourceEnvId = environmentId;
targetPath = `/restore/${containerName}`;
existingTargetVolumes = [];
// Reset stack-ness so a stale value from the previous open can't flash the
// wrong type icon while the new snapshot is still being read.
hasStackFiles = false;
restoreStatus = 'idle';
restoreLogs = [];
restoreWarning = '';
error = '';
if (envList.length === 0) environments.refresh();
void loadPreview();
}
});
// Re-check volume-name AND target-name conflicts whenever the target env changes
// (fail-closed: a named-volume destination OR the container/stack name already on
// the target blocks the clone).
$effect(() => {
const envId = effectiveEnvId;
// Track targetIsStack so the name check re-runs once the preview resolves the type.
const isStack = targetIsStack;
if (!open || mode !== 'new-location' || envId == null) { existingTargetVolumes = []; nameExistsOnTarget = false; return; }
void refreshTargetVolumes(envId);
void refreshTargetName(envId, isStack);
});
async function refreshTargetVolumes(envId: number) {
try {
const res = await fetch(`/api/volumes?env=${envId}`);
if (!res.ok) { existingTargetVolumes = []; return; }
const list = await res.json();
existingTargetVolumes = (Array.isArray(list) ? list : []).map((v: any) => v.Name ?? v.name).filter(Boolean);
} catch {
existingTargetVolumes = [];
}
}
// Does a container/stack named `containerName` already exist on the target env?
async function refreshTargetName(envId: number, isStack: boolean) {
try {
const path = isStack ? `/api/stacks?env=${envId}` : `/api/containers?env=${envId}`;
const res = await fetch(path);
if (!res.ok) { nameExistsOnTarget = false; return; }
const list = await res.json();
const names = (Array.isArray(list) ? list : []).map((x: any) => x.name ?? (Array.isArray(x.Names) ? x.Names[0]?.replace(/^\//, '') : undefined)).filter(Boolean);
nameExistsOnTarget = names.includes(containerName);
} catch {
nameExistsOnTarget = false;
}
}
// Switching a row between "Volume" (a named-volume) and "Host path" (a bind) —
// convert `dest` so a stale value from the other kind can't be submitted. A host
// path like `/docker/data/postgres18` is NOT a valid named-volume name (the server
// rejects it: validate.ts requires `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`), and vice-versa.
// path→volume: derive a name from the path's last segment; volume→path: prefix `/`.
// Empty when we can't convert cleanly, so the placeholder guides the user.
function onDestKindChange(vol: VolRow, next: 'volume' | 'path') {
if (vol.destKind === next) return;
const cur = vol.dest.trim();
if (next === 'volume') {
// From a host path → suggest the last path segment as a volume name, sanitised.
const base = cur.replace(/\/+$/, '').split('/').pop() ?? '';
const nameLike = base.replace(/[^a-zA-Z0-9_.-]/g, '');
vol.dest = /^[a-zA-Z0-9]/.test(nameLike) ? nameLike : '';
} else {
// From a volume name → an absolute path is required; only keep an already-
// absolute value, otherwise clear so the user types one.
vol.dest = cur.startsWith('/') ? cur : '';
}
vol.destKind = next;
}
// Mark each row's conflict flag: a named-volume destination that already exists
// on the target env. Bind (path) destinations are never a "conflict" (the user
// owns the host path). Recomputed whenever destinations or target volumes change.
$effect(() => {
const existing = existingTargetVolumes;
for (const v of volumes) {
const dest = v.dest.trim();
v.conflict = v.destKind === 'volume' && dest.length > 0 && existing.includes(dest);
// Match validate.ts exactly so the UI blocks a bad target BEFORE submit
// (previously only host paths were checked, so a volume kind with a path
// value like `/docker/...` slipped through to a generic 400).
v.pathInvalid = v.destKind === 'path'
? (dest.length === 0 || !dest.startsWith('/') || dest.includes('..'))
: !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(dest); // volume: must be a valid volume name
}
});
// Keep postRestore valid for the current mode (the option set differs: 'start'
// exists only in-place). On a mode switch, snap to the first allowed option.
$effect(() => {
if (!postRestoreOptions.some((o) => o.value === postRestore)) {
postRestore = postRestoreOptions[0]?.value as PostRestore;
}
});
// When the user redirects a volume to a new name/type, default the post-restore
// action to 'none' — bringing the target up would start it on the ORIGINAL mount,
// not the restored data. The user can still override (they may have already fixed
// their config); postRestoreUserPicked guards against fighting that choice.
$effect(() => {
if (hasRemap && !postRestoreUserPicked && postRestore !== 'none') postRestore = 'none';
});
async function loadPreview() {
loading = true;
error = '';
try {
const res = await fetch('/api/backup/restore/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, snapshotId, environmentId })
});
if (!res.ok) {
error = (await res.json()).error || 'Failed to read the snapshot';
return;
}
const data = await res.json();
const types: Record<string, 'volume' | 'bind'> = data.volumeTypes || {};
const sources: Record<string, string> = data.volumeSources || {};
// Pre-fill each destination 1:1 from the snapshot's original location: a
// named volume → its name (destKind 'volume'); a bind → its absolute host
// path (destKind 'path'). The user edits these to redirect the clone.
volumes = (data.volumes || []).map((name: string) => {
const type = types[name] ?? 'volume';
// destKind is 'volume' | 'path' — a bind source defaults to a host PATH,
// a named volume to a volume. (type is 'volume' | 'bind', not a destKind.)
const destKind: 'volume' | 'path' = type === 'bind' ? 'path' : 'volume';
const dest = sources[name] ?? name;
return { name, selected: true, type, dest, destKind, origDest: dest, origDestKind: destKind, conflict: false, pathInvalid: false };
});
hasMetadata = !!data.hasMetadata;
hasStackFiles = !!data.hasStackFiles;
backupTime = data.backupTime ?? null;
postRestore = data.hasStackFiles ? 'redeploy' : 'recreate';
// Recover the source env from the snapshot when the caller didn't supply one
// (empty prop) — needed for in-place (effectiveEnvId) and to default the
// clone target env. Only if that env still exists on this instance.
if (typeof data.sourceEnvironmentId === 'number' && envList.some((e) => e.id === data.sourceEnvironmentId)) {
// Only recover the SOURCE env (for in-place). The clone target env stays
// unselected on purpose — the user picks it.
if (sourceEnvId == null) sourceEnvId = data.sourceEnvironmentId;
}
} catch {
error = 'Failed to read the snapshot';
} finally {
loading = false;
}
}
async function executeRestore() {
if (!canRun) return;
restoring = true;
error = '';
restoreStatus = 'running';
restoreLogs = [];
try {
// For a clone (new-location), every selected volume carries a destination.
const volumeDestinations = mode === 'new-location'
? selectedRows.map((v) => ({ volume: v.name, kind: v.destKind, target: v.dest.trim() }))
: undefined;
const res = await fetch('/api/backup/restore', {
method: 'POST',
// Ask for the job-polling/streaming path so the modal's log fills as the
// restore runs (Accept: application/json would take the synchronous path
// with no progress lines).
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
body: JSON.stringify({
destinationId,
snapshotId,
mode,
targetType: hasStackFiles ? 'stack' : 'container',
volumes: selectedRows.map((v) => v.name),
environmentId: effectiveEnvId,
targetName: containerName,
confirmOverwrite: mode === 'in-place' ? confirmOverwrite : undefined,
// postRestore is sent for BOTH modes now (clone brings the target up).
postRestore,
targetPath: mode === 'new-location' ? targetPath.trim() : undefined,
volumeDestinations
})
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
// Surface the specific validation reason(s), not just the generic
// "Invalid restore request" — e.g. "volume destination for X is not a
// valid volume name" tells the user exactly what to fix.
const issues = Array.isArray(body.issues)
? body.issues.map((i: { field?: string; message?: string }) => i.message).filter(Boolean)
: [];
error = issues.length
? `${body.error || 'Restore failed'}: ${issues.join('; ')}`
: (body.error || 'Restore failed');
restoreStatus = 'error';
restoring = false;
return;
}
const data = await res.json();
if (data.jobId) {
const result = (await watchJob(data.jobId, (line: any) => {
if (line.event === 'progress' && line.data?.message) {
restoreLogs = [...restoreLogs, tagLogLine(line.data.message)];
}
})) as any;
// Same discriminated-union handling as the backup path: a restore can come
// back 'skipped' (a concurrent op holds the lock) or 'warning' (data was
// restored but the post-restore recreate/redeploy failed) — neither is a
// plain success, and reporting them as one hides a half-finished restore.
const { outcome, message } = classifyJobResult(result);
if (outcome === 'error' || outcome === 'skipped') {
restoreStatus = 'error';
error = message || 'Restore failed';
restoreLogs = [...restoreLogs, tagLogLine(`[dockhand] Restore failed: ${error}`)];
} else if (outcome === 'warning') {
restoreStatus = 'warning';
restoreWarning = message || 'Restore completed with warnings';
restoreLogs = [...restoreLogs, tagLogLine(`[dockhand] Restore completed with warnings: ${restoreWarning}`)];
onDone?.(); // the data DID land — refresh the snapshot list
} else {
restoreStatus = 'success';
restoreLogs = [...restoreLogs, tagLogLine('[dockhand] Restore completed.')];
onDone?.();
}
}
} catch (e: any) {
restoreStatus = 'error';
error = e?.message || 'Restore failed';
restoreLogs = [...restoreLogs, tagLogLine(`[dockhand] Restore failed: ${error}`)];
} finally {
restoring = false;
}
}
</script>
<Dialog.Root bind:open>
<!-- Anchored near the top (not vertically centered) so toggling a volume or
switching modes grows the dialog DOWNWARD instead of jumping the whole box.
max-h + scroll keeps it usable on short screens. -->
<!-- Fixed height + internal scroll: the outer box never resizes, so switching
modes or checking/unchecking volumes changes only the scroll area, never the
dialog's top/bottom edges — no jumping. -->
<!-- Tall fixed height only for the multi-step FORM; the progress/result views
(log, success, warning, error) size to their content up to a cap so the log
dialog isn't a giant near-empty box. The tall form is top-anchored (top-[4vh])
so growing it downward doesn't jump; the short result views drop that override
and use the dialog's DEFAULT centered position — otherwise a one-line result
box sits glued to the top of the screen. -->
<Dialog.Content class="max-w-5xl flex flex-col overflow-hidden {restoreStatus === 'idle' ? 'top-[4vh] translate-y-0 h-[88vh]' : 'top-[6vh] translate-y-0 h-[85vh]'}">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex flex-wrap items-center gap-x-2 gap-y-1">
<RotateCcw class="h-4 w-4" /> Restore
{#if !loading}{#if targetIsStack}<Layers class="h-4 w-4 text-muted-foreground" />{:else}<Box class="h-4 w-4 text-muted-foreground" />{/if}{/if}
<span>{containerName}</span>
{#if targetEnv}
<span class="text-muted-foreground">{mode === 'in-place' ? 'on' : 'to'}</span>
<span class="flex items-center gap-1 font-medium text-amber-500"><EnvironmentIcon icon={targetEnv.icon || 'globe'} envId={targetEnv.id} class="h-3.5 w-3.5" />{targetEnvName}</span>
{/if}
<Badge variant="outline" class="font-mono text-[10px]">{snapshotId.slice(0, 8)}</Badge>
{#if backupTime}<span class="flex items-center gap-1 text-xs font-normal text-muted-foreground"><Clock class="h-3 w-3" />{formatDateTime(backupTime)}</span>{/if}
</Dialog.Title>
<Dialog.Description class="sr-only">Restore snapshot {snapshotId.slice(0, 8)} for {containerName}.</Dialog.Description>
</Dialog.Header>
<!-- The single scroll region. flex-1 fills the fixed-height dialog between the
pinned header and footer; content growth scrolls here, not the outer box. -->
<div class="flex flex-1 flex-col overflow-y-auto -mx-6 px-6">
{#if loading}
<div class="flex flex-1 items-center justify-center text-muted-foreground">
<Loader2 class="h-5 w-5 animate-spin" /> <span class="ml-2">Reading snapshot…</span>
</div>
{:else if restoreStatus !== 'idle'}
<!-- Running AND finished states keep the LIVE LOG visible (no separate result
screen that hides what happened) — the outcome is appended as a final log
line and summarised in the status strip below, matching the backup dialog. -->
<!-- flex-1 + fixed dialog height: the log fills the space and SCROLLS
internally, so the dialog never grows/jumps as lines stream in. -->
<div class="flex min-h-0 flex-1 flex-col py-2">
<LogConsole lines={restoreLogs} class="flex-1 min-h-0" />
<div class="mt-2 flex shrink-0 items-center gap-1.5 text-sm">
{#if restoreStatus === 'running'}
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" /><span class="text-muted-foreground">Restoring…</span>
{:else if restoreStatus === 'success'}
<CheckCircle2 class="h-4 w-4 text-green-500" />
<span class="text-green-500">Restore completed</span>
<span class="text-muted-foreground">— {mode === 'new-location'
? `restored to ${targetEnvName}${postRestore !== 'none' ? (targetIsStack ? ', stack redeployed' : ', container recreated') : ''}`
: `live volume replaced — restart ${containerName}${hasStackFiles ? ' / redeploy the stack' : ''} to use it`}.</span>
{:else if restoreStatus === 'warning'}
<AlertTriangle class="h-4 w-4 text-amber-500" /><span class="text-amber-600 dark:text-amber-400">Restore completed with warnings — {restoreWarning}</span>
{:else}
<XCircle class="h-4 w-4 text-destructive" /><span class="text-destructive">{error || 'Restore failed'}</span>
{/if}
</div>
</div>
{:else}
<div class="flex-1 py-2">
{#if error}
<p class="mb-3 rounded bg-destructive/10 p-2 text-sm text-destructive">{error}</p>
{/if}
<!-- STEP 1 — Where does it go? (mode + target env) -->
<div class="grid grid-cols-[26px_1fr] gap-x-3">
<div class="flex flex-col items-center">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold {stepRingClass}">1</span>
<span class="-mb-3 w-0.5 flex-1 rounded {stepLineClass}"></span>
</div>
<div class="space-y-2 pb-3">
<div class="text-sm font-semibold">Where does it go? <span class="font-normal text-xs text-muted-foreground">— pick where, and how</span></div>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded border p-3 text-left text-sm {mode === 'new-location' ? 'border-primary bg-primary/5' : ''}"
onclick={() => (mode = 'new-location')}
>
<div class="flex items-center gap-1.5 font-medium"><Server class="h-3.5 w-3.5" /> To an environment</div>
<div class="mt-1 text-xs text-muted-foreground">Clone it onto a chosen environment.</div>
</button>
<button
type="button"
class="rounded border p-3 text-left text-sm {mode === 'in-place' ? 'border-primary bg-primary/5' : ''}"
onclick={() => (mode = 'in-place')}
>
<div class="flex items-center gap-1.5 font-medium"><AlertTriangle class="h-3.5 w-3.5 text-destructive" /> Overwrite live</div>
<div class="mt-1 text-xs text-muted-foreground">Replace the live data in place. Destructive.</div>
</button>
</div>
{#if mode === 'new-location'}
<div class="space-y-1.5">
<Label class="flex items-center gap-1.5"><Server class="h-3.5 w-3.5" /> Target environment</Label>
<Select.Root type="single" value={effectiveEnvId != null ? String(effectiveEnvId) : ''} onValueChange={(v) => (targetEnvId = v ? parseInt(v) : undefined)}>
<Select.Trigger class="h-9 w-full">
{#if targetEnv}
<span class="flex items-center gap-2"><EnvironmentIcon icon={targetEnv.icon || 'globe'} envId={targetEnv.id} class="h-4 w-4 text-muted-foreground" />{targetEnv.name}</span>
{:else}
<span class="text-muted-foreground">Select an environment…</span>
{/if}
</Select.Trigger>
<Select.Content>
{#each envList as env}
<Select.Item value={String(env.id)}>
<span class="flex items-center gap-2"><EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="h-4 w-4 text-muted-foreground" />{env.name}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
</div>
</div>
<!-- STEP 2 — What gets restored? (volumes + destinations) -->
<div class="grid grid-cols-[26px_1fr] gap-x-3">
<div class="flex flex-col items-center">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold {stepRingClass}">2</span>
<span class="-mb-3 w-0.5 flex-1 rounded {stepLineClass}"></span>
</div>
<div class="space-y-2 pb-3">
<div class="flex items-center gap-1.5 text-sm font-semibold">
{mode === 'in-place' ? 'What gets overwritten?' : 'What gets restored?'}
<span class="font-normal text-xs text-muted-foreground">— {selectedRows.length} of {volumes.length} {volumes.length === 1 ? 'volume' : 'volumes'}</span>
{#if mode === 'new-location'}
<Tooltip.Root>
<Tooltip.Trigger class="ml-0.5"><HelpCircle class="h-3.5 w-3.5 text-muted-foreground opacity-70" /></Tooltip.Trigger>
<Tooltip.Content class="w-[22rem] max-w-[90vw]">
<p class="text-xs leading-relaxed">Each destination is resolved by <b>{targetEnvName || 'the target'}</b>'s Docker daemon: a <b>host path</b> must exist on that host (not on Dockhand's), while a <b>named volume</b> is created there. Prefer named volumes for portability. The post-restore step brings the {targetIsStack ? 'stack' : 'container'} up; if it fails, the data is still restored and you finish manually.</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{#if volumes.length === 0}
<p class="text-sm text-muted-foreground">This snapshot has no volumes — restore recreates the {targetIsStack ? 'stack' : 'container'} from its saved config.</p>
{:else if mode === 'new-location'}
<!-- Clone mode: each selected volume maps to an editable DESTINATION on
the target env. Default is the original location (1:1 clone). -->
<div class="space-y-2 rounded border p-2">
{#each volumes as vol}
<div class="space-y-1">
<label class="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox bind:checked={vol.selected} />
{#if vol.type === 'bind'}
<Folder class="h-3.5 w-3.5 shrink-0 text-amber-500" />
<span class="w-11 shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>
{:else}
<HardDrive class="h-3.5 w-3.5 shrink-0 text-sky-500" />
<span class="w-11 shrink-0 rounded-full bg-sky-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>
{/if}
<span class="truncate font-mono">{vol.name}</span>
</label>
{#if vol.selected}
<div class="flex items-center gap-1.5 pl-6">
<span class="text-xs text-muted-foreground">→</span>
<Select.Root type="single" value={vol.destKind} onValueChange={(v) => onDestKindChange(vol, v as 'volume' | 'path')}>
<Select.Trigger class="h-8 w-32 shrink-0 text-xs">
{#if vol.destKind === 'path'}
<span class="flex items-center gap-1.5"><Folder class="h-3.5 w-3.5 text-amber-500" /> Host path</span>
{:else}
<span class="flex items-center gap-1.5"><HardDrive class="h-3.5 w-3.5 text-sky-500" /> Volume</span>
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="volume"><span class="flex items-center gap-1.5"><HardDrive class="h-3.5 w-3.5 text-sky-500" /> Volume</span></Select.Item>
<Select.Item value="path"><span class="flex items-center gap-1.5"><Folder class="h-3.5 w-3.5 text-amber-500" /> Host path</span></Select.Item>
</Select.Content>
</Select.Root>
<Input bind:value={vol.dest} class="h-8 flex-1 font-mono text-xs {(vol.conflict || vol.pathInvalid) ? 'border-destructive' : ''}" placeholder={vol.destKind === 'path' ? '/absolute/path' : 'volume-name'} />
</div>
{#if vol.conflict}
<p class="pl-6 text-xs text-destructive">Volume <span class="font-mono">{vol.dest}</span> already exists on {targetEnvName}. Remove it or choose another destination.</p>
{:else if vol.pathInvalid}
<p class="pl-6 text-xs text-destructive">A host path must be absolute — start it with <span class="font-mono">/</span> (e.g. <span class="font-mono">/srv/{vol.name}</span>).</p>
{/if}
{/if}
</div>
{/each}
</div>
{:else}
<div class="space-y-1.5 rounded border p-2">
{#each volumes as vol}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox bind:checked={vol.selected} />
{#if vol.type === 'bind'}
<Folder class="h-3.5 w-3.5 shrink-0 text-amber-500" />
<span class="w-11 shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>
{:else}
<HardDrive class="h-3.5 w-3.5 shrink-0 text-sky-500" />
<span class="w-11 shrink-0 rounded-full bg-sky-500/15 px-1.5 py-0.5 text-center text-[10px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>
{/if}
<span class="truncate font-mono">{vol.name}</span>
</label>
{/each}
</div>
{/if}
</div>
</div>
<!-- STEP 3 — Then what? (after-restore action) -->
<div class="grid grid-cols-[26px_1fr] gap-x-3">
<div class="flex flex-col items-center">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold {stepRingClass}">3</span>
</div>
<div class="space-y-2 pb-1">
<div class="text-sm font-semibold">Then what? <span class="font-normal text-xs text-muted-foreground">— {mode === 'in-place' ? 'bring it back up' : 'bring it up on the target'}</span></div>
{#if mode === 'new-location'}
<!-- After restore: bring the target up on the chosen env. -->
<div class="space-y-1.5">
<Label class="sr-only">After restore</Label>
<Select.Root type="single" value={postRestore} onValueChange={(v) => { postRestore = v as PostRestore; postRestoreUserPicked = true; }}>
<Select.Trigger class="h-9">
{#each postRestoreOptions as opt}
{#if opt.value === postRestore}
{@const OptIcon = opt.icon}
<span class="flex items-center gap-2"><OptIcon class="h-3.5 w-3.5 text-muted-foreground" />{opt.label}</span>
{/if}
{/each}
</Select.Trigger>
<Select.Content>
{#each postRestoreOptions as opt}
{@const OptIcon = opt.icon}
<Select.Item value={opt.value}>
<span class="flex items-center gap-2"><OptIcon class="h-3.5 w-3.5 text-muted-foreground" />{opt.label}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if nameConflict}
<p class="text-xs text-destructive">A {targetIsStack ? 'stack' : 'container'} named <span class="font-mono">{containerName}</span> already exists on {targetEnvName}. Remove it or choose "Do nothing" — the restore won't overwrite it.</p>
{/if}
</div>
{:else}
<div class="space-y-1.5">
<Label>After restore</Label>
<Select.Root type="single" value={postRestore} onValueChange={(v) => { postRestore = v as PostRestore; postRestoreUserPicked = true; }}>
<Select.Trigger class="h-9">
{#each postRestoreOptions as opt}
{#if opt.value === postRestore}
{@const OptIcon = opt.icon}
<span class="flex items-center gap-2"><OptIcon class="h-3.5 w-3.5 text-muted-foreground" />{opt.label}</span>
{/if}
{/each}
</Select.Trigger>
<Select.Content>
{#each postRestoreOptions as opt}
{@const OptIcon = opt.icon}
<Select.Item value={opt.value}>
<span class="flex items-center gap-2"><OptIcon class="h-3.5 w-3.5 text-muted-foreground" />{opt.label}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
</div>
</div>
<!-- What will happen: a live prose recap driven by the form. Env names amber,
each prefixed by the env's icon (same as the modal header). -->
{#snippet envChip(env: { id: number; icon?: string | null } | undefined, name: string)}
<span class="inline-flex items-center gap-1 align-middle font-medium text-amber-500">{#if env}<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="h-3.5 w-3.5" />{/if}{name || '…'}</span>
{/snippet}
<div class="mt-3 rounded-md border border-l-[3px] p-3 text-sm {mode === 'in-place' ? 'border-l-destructive bg-destructive/5' : 'border-l-primary bg-primary/5'}">
<div class="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{#if mode === 'in-place'}<AlertTriangle class="h-3.5 w-3.5 text-destructive" />{:else}<Info class="h-3.5 w-3.5 text-primary" />{/if}
What will happen
</div>
{#if mode === 'in-place'}
<p class="leading-relaxed">
The snapshot{#if backupTime}&nbsp;taken <span class="font-mono">{formatDateTime(backupTime)}</span>{/if}{#if sourceEnvName}&nbsp;from {@render envChip(sourceEnv, sourceEnvName)}{/if} will <b class="text-destructive">overwrite the live data</b>{#if targetEnvName}&nbsp;on {@render envChip(targetEnv, targetEnvName)}{/if}{#if selectedRows.length > 0}:{:else}.{/if}
</p>
{#if selectedRows.length > 0}
<ul class="mt-1.5 space-y-1">
{#each selectedRows as v}
<li class="flex items-center gap-2 text-xs">
{#if v.type === 'bind'}<Folder class="h-3 w-3 shrink-0 text-amber-500" />{:else}<HardDrive class="h-3 w-3 shrink-0 text-sky-500" />{/if}
{#if v.type === 'bind'}<span class="w-9 shrink-0 rounded-full bg-amber-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>{:else}<span class="w-9 shrink-0 rounded-full bg-sky-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>{/if}
<span class="font-mono">{v.name}</span>
<span class="text-muted-foreground">wiped &amp; replaced</span>
</li>
{/each}
</ul>
{/if}
<p class="mt-1.5 leading-relaxed">The {targetIsStack ? 'stack' : 'container'} is <b>stopped before the restore</b> (expect downtime); the volumes are swapped (staged then committed), then <b>{postRestoreLabel.toLowerCase()}</b>.</p>
<label class="mt-2.5 flex cursor-pointer items-center gap-2 border-t border-destructive/20 pt-2.5 text-sm">
<Checkbox bind:checked={confirmOverwrite} />
I understand this replaces the live volume data.
</label>
{:else}
<p class="leading-relaxed">
The snapshot{#if backupTime}&nbsp;taken <span class="font-mono">{formatDateTime(backupTime)}</span>{/if}{#if sourceEnvName}&nbsp;from {@render envChip(sourceEnv, sourceEnvName)}{/if} will be restored to {@render envChip(targetEnv, targetEnvName)}{#if selectedRows.length > 0}:{:else}.{/if}
</p>
{#if selectedRows.length > 0}
<ul class="mt-1.5 space-y-1">
{#each selectedRows as v}
<li class="flex items-center gap-2 text-xs">
{#if v.type === 'bind'}<Folder class="h-3 w-3 shrink-0 text-amber-500" />{:else}<HardDrive class="h-3 w-3 shrink-0 text-sky-500" />{/if}
{#if v.type === 'bind'}<span class="w-9 shrink-0 rounded-full bg-amber-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">bind</span>{:else}<span class="w-9 shrink-0 rounded-full bg-sky-500/15 px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400">vol</span>{/if}
<span class="font-mono">{v.name}</span>
<span class="text-muted-foreground">→ {v.destKind === 'path' ? 'host path' : 'named volume'}</span>
<span class="font-mono {(v.conflict || v.pathInvalid) ? 'text-destructive' : ''}">{v.dest.trim() || '…'}</span>
</li>
{/each}
</ul>
{/if}
{#if postRestore !== 'none'}
<p class="mt-1.5 leading-relaxed">Then Dockhand will <b>{postRestoreLabel.toLowerCase()}</b> on {@render envChip(targetEnv, targetEnvName)}{#if sourceEnvName && sourceEnvName !== targetEnvName}. Nothing on {@render envChip(sourceEnv, sourceEnvName)} is touched{/if}.</p>
{:else}
<p class="mt-1.5 leading-relaxed">The {targetIsStack ? 'stack' : 'container'} is <b>not started</b> — the data lands on {@render envChip(targetEnv, targetEnvName)} and you bring it up yourself.</p>
{/if}
{#if hasRemap}
<!-- The recreate/redeploy uses the snapshot's STORED config/compose, which
still references the ORIGINAL mounts — it won't follow a redirected
name/type, so the target would come up on the wrong (empty) volume. -->
<div class="mt-2.5 rounded-md border border-l-[3px] border-amber-500/30 border-l-amber-500 bg-amber-500/10 p-2.5">
<div class="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-3.5 w-3.5" /> You have changed a volume's name or type
</div>
<p class="text-xs leading-relaxed text-amber-700 dark:text-amber-300/90">Your data goes to the new volumes. But the {targetIsStack ? 'stack redeploys from the stored compose file, which still names' : 'recreated container mounts'} the <b>original</b> ones — so it {targetIsStack ? 'starts with empty volumes' : "won't see the restored data"}.</p>
<p class="mt-1 text-xs leading-relaxed text-amber-700 dark:text-amber-300/90"><b>After restoring:</b> {targetIsStack ? 'update the compose file to the new volume names, then redeploy.' : 'edit the container and point the mount at the new volume.'}</p>
{#if postRestore === 'none'}<p class="mt-1 text-[11px] leading-relaxed text-amber-600/70 dark:text-amber-300/60">Next step set to <b>Do nothing</b> so it can't start with the wrong data.</p>{/if}
</div>
{/if}
{/if}
</div>
</div>
{/if}
</div>
<Dialog.Footer class="shrink-0">
{#if restoreStatus === 'success' || restoreStatus === 'warning' || restoreStatus === 'error'}
<Button variant="outline" onclick={() => (open = false)}>OK</Button>
{:else if restoreStatus !== 'running'}
<Button variant="outline" onclick={() => (open = false)} disabled={restoring}>Cancel</Button>
<!-- Hide the restore action until the snapshot is read — until then we
don't know its volumes/target, so there's nothing to restore yet. -->
{#if !loading}
<Button
onclick={executeRestore}
disabled={!canRun || restoring}
variant={mode === 'in-place' ? 'destructive' : 'default'}
>
{#if restoring}<Loader2 class="mr-1.5 h-4 w-4 animate-spin" />{:else}<Play class="mr-1.5 h-4 w-4" />{/if}
{mode === 'in-place' ? 'Overwrite & restore' : (postRestore !== 'none' ? 'Restore & start' : 'Restore')}
</Button>
{/if}
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,422 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Badge } from '$lib/components/ui/badge';
import {
FolderOpen, Box, Layers, Network, Loader2, HardDrive, Tag, Variable,
Plug, HeartPulse, ScrollText, ShieldAlert, Server, Globe, Cpu, FileText
} from 'lucide-svelte';
import MountTypeBadge from '$lib/components/MountTypeBadge.svelte';
import { getRepoTypeIcon } from '$lib/utils/backup';
import { formatBytes } from '$lib/utils/format';
import { formatDateTime } from '$lib/stores/settings';
import { environments } from '$lib/stores/environment';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import FileBrowserPanel from './FileBrowserPanel.svelte';
interface Props {
open: boolean;
destinationId: number;
snapshotId: string;
targetName?: string;
targetType?: 'container' | 'stack';
environmentId?: number;
}
let { open = $bindable(), destinationId, snapshotId, targetName, targetType, environmentId }: Props = $props();
let activeTab = $state<'files' | 'metadata'>('files');
let metadata = $state<any>(null);
let metadataLoading = $state(false);
let metadataError = $state('');
let snapshotTime = $state('');
let destinationName = $state('');
let destinationRepo = $state('');
let snapshotEnvName = $state('');
let snapshotEnvIcon = $state('globe');
let snapshotEnvId = $state(0);
$effect(() => {
if (open && snapshotId) {
activeTab = 'files';
metadata = null;
metadataError = '';
snapshotTime = '';
destinationName = '';
destinationRepo = '';
snapshotEnvName = '';
snapshotEnvIcon = 'globe';
snapshotEnvId = 0;
fetchMetadata();
fetchSnapshotInfo();
}
});
const displayName = $derived(targetName || metadata?.targetName || '');
// The backup stores a full docker inspect (PascalCase keys) as metadata.container
// (singular) for container targets; null for stacks.
const containerInspect = $derived<any>(metadata?.container ?? null);
async function fetchSnapshotInfo() {
try {
// Get destination name
const destRes = await fetch(`/api/backup/destinations/${destinationId}`);
if (destRes.ok) {
const dest = await destRes.json();
destinationName = dest.name || '';
destinationRepo = dest.repository || '';
}
// Get snapshot time + owning environment from the snapshot's tags.
const snapRes = await fetch(`/api/backup/snapshots?destinationId=${destinationId}`);
if (snapRes.ok) {
const d = await snapRes.json();
const snaps = d.snapshots ?? d;
const snap = (Array.isArray(snaps) ? snaps : []).find((s: any) => s.id === snapshotId || s.shortId === snapshotId?.slice(0, 8));
if (snap) {
snapshotTime = snap.time;
// dockhand:envid=<n> tag identifies the source environment ('local' = none).
const envTag = (snap.tags || []).find((t: string) => t.startsWith('dockhand:envid='))?.replace('dockhand:envid=', '');
const envId = envTag && envTag !== 'local' ? parseInt(envTag) : NaN;
if (!isNaN(envId)) {
const env = $environments.find((e) => e.id === envId);
if (env) { snapshotEnvName = env.name; snapshotEnvIcon = env.icon || 'globe'; snapshotEnvId = env.id; }
}
}
}
} catch {}
}
async function fetchMetadata() {
metadataLoading = true;
metadataError = '';
try {
const res = await fetch(`/api/backup/snapshots/${snapshotId}/metadata?destinationId=${destinationId}`);
if (res.ok) {
metadata = await res.json();
} else {
try { metadataError = (await res.json()).error || 'No metadata available'; }
catch { metadataError = 'No metadata available'; }
}
} catch { metadataError = 'Failed to load metadata'; }
finally { metadataLoading = false; }
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-4xl h-[90vh] sm:h-[80vh] flex flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 flex-wrap">
<FolderOpen class="w-5 h-5" />
Browse snapshot
{#if snapshotEnvName}
<span class="text-muted-foreground font-normal">from</span>
<EnvironmentIcon icon={snapshotEnvIcon} envId={snapshotEnvId} class="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span class="font-normal text-amber-600 dark:text-amber-400">{snapshotEnvName}</span>
{/if}
{#if displayName}
<span class="text-muted-foreground font-normal">—</span>
{#if (targetType || metadata?.type) === 'stack'}
<Layers class="w-4 h-4 text-purple-500" />
{:else}
<Box class="w-4 h-4 text-blue-500" />
{/if}
<span class="font-normal">{displayName}</span>
{/if}
{#if destinationName}
<span class="text-muted-foreground font-normal">on</span>
<svelte:component this={getRepoTypeIcon(destinationRepo)} class="w-4 h-4" />
<span class="font-normal">{destinationName}</span>
{/if}
</Dialog.Title>
<Dialog.Description class="flex items-center gap-2 text-xs text-muted-foreground">
{#if snapshotId}<Badge variant="outline" class="font-mono text-[10px]">{String(snapshotId).slice(0, 8)}</Badge>{/if}
{#if snapshotTime}{formatDateTime(snapshotTime)}{/if}
</Dialog.Description>
</Dialog.Header>
<!-- Tabs -->
<div class="flex gap-1 border-b pb-0 shrink-0">
<button
type="button"
class="px-3 py-1.5 text-sm transition-colors border-b-2 -mb-px {activeTab === 'files' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'files'}
>
Files
</button>
<button
type="button"
class="px-3 py-1.5 text-sm transition-colors border-b-2 -mb-px {activeTab === 'metadata' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'metadata'}
>
Metadata
</button>
</div>
<!-- Keep BOTH panels mounted and toggle visibility with `hidden` instead of
{#if}/{:else}. An {#if} destroys FileBrowserPanel on every switch to
Metadata, so returning to Files re-runs its load $effect and re-browses
the snapshot from the (possibly remote) restic repo each time. Keeping it
alive preserves the already-loaded directory + current path — no repeat
round-trip. -->
<div class="flex-1 overflow-hidden border rounded-lg" class:hidden={activeTab !== 'files'}>
<FileBrowserPanel
{snapshotId}
{destinationId}
canEdit={false}
/>
</div>
<!-- Metadata tab -->
{#if activeTab === 'metadata'}
<div class="flex-1 overflow-y-auto py-3">
{#if metadataLoading}
<div class="flex items-center justify-center py-8"><Loader2 class="w-5 h-5 animate-spin text-muted-foreground" /></div>
{:else if metadataError}
<p class="text-sm text-muted-foreground p-4">{metadataError}</p>
{:else if metadata}
<div class="max-w-2xl space-y-5 px-1">
<!-- Backup info -->
<section class="space-y-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Backup info</h4>
<dl class="grid grid-cols-[130px_1fr] gap-y-1.5 text-sm">
<dt class="text-muted-foreground">Type</dt>
<dd><Badge variant="outline" class="text-[10px]">{metadata.type}</Badge></dd>
<dt class="text-muted-foreground">Name</dt>
<dd class="font-medium">{metadata.targetName}</dd>
<dt class="text-muted-foreground">Backup time</dt>
<dd>{formatDateTime(metadata.backupTime)}</dd>
{#if metadata.environmentId != null}
<dt class="text-muted-foreground">Environment ID</dt>
<dd class="font-mono text-xs">{metadata.environmentId}</dd>
{/if}
<dt class="text-muted-foreground">Volumes</dt>
<dd>{metadata.volumes?.length ?? 0}</dd>
{#if metadata.type === 'stack'}
{#if metadata.composeFileName}
<dt class="text-muted-foreground">Compose file</dt>
<dd class="font-mono text-xs">{metadata.composeFileName}</dd>
{/if}
<dt class="text-muted-foreground">Stack files</dt>
<dd>{metadata.hasStackFiles ? (metadata.stackFilesTruncated ? 'captured (truncated)' : 'captured') : 'not captured'}</dd>
{/if}
</dl>
</section>
<!-- Volumes / binds -->
{#if metadata.volumes?.length}
<section class="space-y-2 border-t pt-4">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><HardDrive class="w-3.5 h-3.5" />Volumes &amp; binds ({metadata.volumes.length})</h4>
<div class="space-y-1">
{#each metadata.volumes as vol}
<!-- Docker convention: source (host path / volume name) → destination
(container path). `vol.source` is the host path/volume name,
`vol.name` is the container destination. -->
<div class="flex items-center gap-2 text-sm bg-muted/30 rounded px-2 py-1.5">
<MountTypeBadge type={vol.type} />
{#if vol.source && vol.source !== vol.name}
<span class="font-mono text-muted-foreground truncate">{vol.source}</span>
<span class="text-muted-foreground shrink-0">→</span>
<span class="font-medium truncate">{vol.name || vol.key}</span>
{:else}
<span class="font-medium truncate">{vol.name || vol.key}</span>
{/if}
</div>
{/each}
</div>
</section>
{/if}
<!-- Stack files (captured under stackfiles/). The list is recorded in
metadata.json at backup time so we can enumerate the exact files
without a browse roundtrip. -->
{#if metadata.type === 'stack' && metadata.stackFilesList?.length}
<section class="space-y-2 border-t pt-4">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"><FileText class="w-3.5 h-3.5" />Stack files ({metadata.stackFilesList.length}{metadata.stackFilesListTruncated ? '+' : ''})</h4>
<div class="space-y-1">
{#each metadata.stackFilesList as file}
<div class="flex items-center gap-2 text-sm bg-muted/30 rounded px-2 py-1.5">
<span class="font-mono truncate flex-1" class:font-medium={file.path === metadata.composeFileName}>{file.path}</span>
{#if file.path === metadata.composeFileName}
<Badge variant="outline" class="text-[10px] shrink-0">compose</Badge>
{/if}
<span class="text-muted-foreground text-xs shrink-0 tabular-nums">{formatBytes(file.bytes)}</span>
</div>
{/each}
</div>
{#if metadata.stackFilesListTruncated}
<p class="text-xs text-muted-foreground">Only the first {metadata.stackFilesList.length} files are listed. Browse the snapshot to see the full tree.</p>
{/if}
</section>
{/if}
<!-- Container config (docker inspect, PascalCase, singular). Only for
container targets — stacks have no metadata.container. -->
{#if containerInspect?.Config}
<section class="space-y-2 border-t pt-4">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
<Box class="w-3 h-3" />
{(containerInspect.Name || metadata.targetName || '').replace(/^\//, '')}
</h4>
<dl class="grid grid-cols-[130px_1fr] gap-y-1.5 text-sm">
<dt class="text-muted-foreground">Image</dt>
<dd class="font-mono text-xs break-all">{containerInspect.Config.Image || '—'}</dd>
{#if containerInspect.Id}
<dt class="text-muted-foreground">Container ID</dt>
<dd class="font-mono text-xs">{containerInspect.Id.slice(0, 12)}</dd>
{/if}
{#if containerInspect.State?.Status}
<dt class="text-muted-foreground">State at backup</dt>
<dd class="text-xs">{containerInspect.State.Status}</dd>
{/if}
{#if containerInspect.Created}
<dt class="text-muted-foreground">Created</dt>
<dd class="text-xs">{formatDateTime(containerInspect.Created)}</dd>
{/if}
{#if containerInspect.Config.Cmd?.length}
<dt class="text-muted-foreground">Command</dt>
<dd class="font-mono text-xs break-all">{containerInspect.Config.Cmd.join(' ')}</dd>
{/if}
{#if containerInspect.Config.Entrypoint?.length}
<dt class="text-muted-foreground">Entrypoint</dt>
<dd class="font-mono text-xs break-all">{containerInspect.Config.Entrypoint.join(' ')}</dd>
{/if}
{#if containerInspect.Config.WorkingDir}
<dt class="text-muted-foreground">Working dir</dt>
<dd class="font-mono text-xs break-all">{containerInspect.Config.WorkingDir}</dd>
{/if}
{#if containerInspect.Config.User}
<dt class="text-muted-foreground">User</dt>
<dd class="font-mono text-xs">{containerInspect.Config.User}</dd>
{/if}
{#if containerInspect.Config.ExposedPorts}
<dt class="text-muted-foreground">Exposed ports</dt>
<dd class="text-xs break-all">{Object.keys(containerInspect.Config.ExposedPorts).join(', ')}</dd>
{/if}
{#if containerInspect.HostConfig?.RestartPolicy?.Name}
<dt class="text-muted-foreground">Restart policy</dt>
<dd class="text-xs">{containerInspect.HostConfig.RestartPolicy.Name}{containerInspect.HostConfig.RestartPolicy.MaximumRetryCount ? ` (max ${containerInspect.HostConfig.RestartPolicy.MaximumRetryCount})` : ''}</dd>
{/if}
{#if containerInspect.HostConfig?.NetworkMode}
<dt class="text-muted-foreground">Network mode</dt>
<dd class="text-xs break-all">{containerInspect.HostConfig.NetworkMode}</dd>
{/if}
{#if containerInspect.HostConfig?.Privileged}
<dt class="text-muted-foreground">Privileged</dt>
<dd class="text-xs">yes</dd>
{/if}
</dl>
<!-- Port bindings (host:container map — more useful than the internal
ExposedPorts above; only present when ports are published). -->
{#if containerInspect.HostConfig?.PortBindings && Object.keys(containerInspect.HostConfig.PortBindings).length}
<div class="pt-1 space-y-1.5">
<span class="text-xs font-semibold text-muted-foreground flex items-center gap-1.5"><Plug class="w-3.5 h-3.5" />Port bindings</span>
<div class="flex flex-wrap gap-1">
{#each Object.entries(containerInspect.HostConfig.PortBindings) as [containerPort, hostArr]}
{@const h = (hostArr as any[])?.[0]}
<Badge variant="secondary" class="text-xs font-mono">{h?.HostIp ? `${h.HostIp}:` : ''}{h?.HostPort || '?'} → {containerPort}</Badge>
{/each}
</div>
</div>
{/if}
<!-- Health at backup -->
{#if containerInspect.State?.Health?.Status}
<div class="pt-1 space-y-1.5">
<span class="text-xs font-semibold text-muted-foreground flex items-center gap-1.5"><HeartPulse class="w-3.5 h-3.5" />Health at backup</span>
<Badge variant="outline" class="text-xs {containerInspect.State.Health.Status === 'healthy' ? 'text-green-600 border-green-600/40' : containerInspect.State.Health.Status === 'unhealthy' ? 'text-destructive border-destructive/40' : ''}">{containerInspect.State.Health.Status}</Badge>
</div>
{/if}
<!-- Security flags -->
{#if containerInspect.HostConfig?.Privileged || containerInspect.HostConfig?.ReadonlyRootfs || containerInspect.HostConfig?.CapAdd?.length || containerInspect.HostConfig?.CapDrop?.length}
<div class="pt-1 space-y-1.5">
<span class="text-xs font-semibold text-muted-foreground flex items-center gap-1.5"><ShieldAlert class="w-3.5 h-3.5" />Security</span>
<div class="flex flex-wrap gap-1">
{#if containerInspect.HostConfig.Privileged}<Badge variant="outline" class="text-xs text-amber-600 border-amber-600/40">privileged</Badge>{/if}
{#if containerInspect.HostConfig.ReadonlyRootfs}<Badge variant="outline" class="text-xs">read-only rootfs</Badge>{/if}
{#each containerInspect.HostConfig.CapAdd ?? [] as cap}<Badge variant="outline" class="text-xs text-green-600 border-green-600/40 font-mono">+{cap}</Badge>{/each}
{#each containerInspect.HostConfig.CapDrop ?? [] as cap}<Badge variant="outline" class="text-xs text-muted-foreground font-mono">-{cap}</Badge>{/each}
</div>
</div>
{/if}
<!-- Runtime details (log driver, stop signal, platform) -->
{#if containerInspect.HostConfig?.LogConfig?.Type || containerInspect.Config?.StopSignal || containerInspect.Platform}
<div class="pt-1 space-y-1.5">
<span class="text-xs font-semibold text-muted-foreground flex items-center gap-1.5"><Server class="w-3.5 h-3.5" />Runtime</span>
<dl class="grid grid-cols-[130px_1fr] gap-y-1 text-sm">
{#if containerInspect.HostConfig?.LogConfig?.Type}
<dt class="text-muted-foreground flex items-center gap-1.5"><ScrollText class="w-3.5 h-3.5" />Log driver</dt>
<dd class="font-mono text-xs">{containerInspect.HostConfig.LogConfig.Type}</dd>
{/if}
{#if containerInspect.Config?.StopSignal}
<dt class="text-muted-foreground">Stop signal</dt>
<dd class="font-mono text-xs">{containerInspect.Config.StopSignal}</dd>
{/if}
{#if containerInspect.Platform}
<dt class="text-muted-foreground flex items-center gap-1.5"><Cpu class="w-3.5 h-3.5" />Platform</dt>
<dd class="font-mono text-xs">{containerInspect.Platform}</dd>
{/if}
</dl>
</div>
{/if}
<!-- DNS / extra hosts -->
{#if containerInspect.HostConfig?.Dns?.length || containerInspect.HostConfig?.ExtraHosts?.length}
<div class="pt-1 space-y-1.5">
<span class="text-xs font-semibold text-muted-foreground flex items-center gap-1.5"><Globe class="w-3.5 h-3.5" />DNS &amp; hosts</span>
<div class="flex flex-wrap gap-1">
{#each containerInspect.HostConfig.Dns ?? [] as dns}<Badge variant="secondary" class="text-xs font-mono">DNS {dns}</Badge>{/each}
{#each containerInspect.HostConfig.ExtraHosts ?? [] as host}<Badge variant="secondary" class="text-xs font-mono">{host}</Badge>{/each}
</div>
</div>
{/if}
<!-- Networks -->
{#if containerInspect.NetworkSettings?.Networks && Object.keys(containerInspect.NetworkSettings.Networks).length}
<div class="pt-1 space-y-1.5">
<span class="text-xs font-semibold text-muted-foreground flex items-center gap-1.5"><Network class="w-3.5 h-3.5" />Networks</span>
<div class="flex flex-wrap gap-1">
{#each Object.entries(containerInspect.NetworkSettings.Networks) as [net, cfg]}
<Badge variant="secondary" class="text-xs"><Network class="w-3.5 h-3.5 mr-1" />{net}{(cfg as any)?.IPAddress ? ` (${(cfg as any).IPAddress})` : ''}</Badge>
{/each}
</div>
</div>
{/if}
<!-- Labels -->
{#if containerInspect.Config.Labels && Object.keys(containerInspect.Config.Labels).length}
<div class="pt-1 space-y-1.5">
<span class="text-xs font-semibold text-muted-foreground flex items-center gap-1.5"><Tag class="w-3.5 h-3.5" />Labels ({Object.keys(containerInspect.Config.Labels).length})</span>
<div class="max-h-40 overflow-y-auto text-sm font-mono bg-muted/20 rounded p-2 space-y-0.5">
{#each Object.entries(containerInspect.Config.Labels) as [key, value]}
<div class="truncate"><span class="text-muted-foreground">{key}</span>=<span>{value}</span></div>
{/each}
</div>
</div>
{/if}
<!-- Env vars (values masked) -->
{#if containerInspect.Config.Env?.length}
<div class="pt-1 space-y-1.5">
<span class="text-xs font-semibold text-muted-foreground flex items-center gap-1.5"><Variable class="w-3.5 h-3.5" />Environment variables ({containerInspect.Config.Env.length})</span>
<div class="max-h-40 overflow-y-auto text-sm font-mono bg-muted/20 rounded p-2 space-y-0.5">
{#each containerInspect.Config.Env as envVar}
{@const eqIdx = envVar.indexOf('=')}
<div class="truncate">
<span class="text-muted-foreground">{eqIdx > 0 ? envVar.slice(0, eqIdx) : envVar}</span>{#if eqIdx > 0}=<span>***</span>{/if}
</div>
{/each}
</div>
</div>
{/if}
</section>
{/if}
</div>
{:else}
<p class="text-sm text-muted-foreground p-4">No metadata available for this snapshot</p>
{/if}
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
+347
View File
@@ -0,0 +1,347 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { Loader2, FolderOpen, RotateCcw, Trash2, ArrowLeftRight, HardDrive, Archive, RefreshCw } from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { formatDateTime } from '$lib/stores/settings';
import { formatBytes } from '$lib/utils/format';
import { getRepoTypeIcon } from '$lib/utils/backup';
import SnapshotBrowser from './SnapshotBrowser.svelte';
import RestoreModal from './RestoreModal.svelte';
import SnapshotDiffModal from '../backups/SnapshotDiffModal.svelte';
interface Props {
/** The backup target name (container or stack) whose snapshots to list. */
targetName: string;
type?: 'container' | 'stack';
environmentId?: number;
/** Whether this target has any backup config. With none there can be no
* snapshots, so we skip the (slow) per-repo listing entirely. */
hasBackups?: boolean;
/** This target's backup config (schedule) ids — used to pull per-snapshot
* stats (files new/changed, data added) from schedule executions, which are
* already recorded at backup time (no extra restic call). */
configIds?: number[];
/** Reports the loaded snapshot count so the parent can badge the tab. */
onCount?: (n: number) => void;
/** Reports loading state so the parent can show a spinner on the tab. */
onLoadingChange?: (loading: boolean) => void;
}
let { targetName, type = 'container', environmentId, hasBackups = true, configIds = [], onCount, onLoadingChange }: Props = $props();
interface Snapshot {
id: string;
shortId: string;
time: string;
tags?: string[];
_destinationId: number;
_destinationName: string;
_destinationRepository: string;
}
let loading = $state(true);
let snapshots = $state<Snapshot[]>([]);
let deletingSnapshot = $state<string | null>(null);
let confirmDeleteSnapshot = $state<string | null>(null);
// snapshotId → { filesNew, filesChanged, dataAdded } from schedule executions.
let stats = $state<Map<string, { filesNew: number; filesChanged: number; dataAdded: number }>>(new Map());
// Browse
let showBrowser = $state(false);
let browserDestId = $state(0);
let browserSnapshotId = $state('');
// Restore
let showRestore = $state(false);
let restoreDestId = $state(0);
let restoreSnapshotId = $state('');
// Diff (click one to select, another to compare)
let showDiff = $state(false);
let diffDestId = $state(0);
let diffSnapA = $state<any>({ id: '', shortId: '', time: '' });
let diffSnapB = $state<any>({ id: '', shortId: '', time: '' });
let diffPending = $state<Snapshot | null>(null);
// Repo filter (Option C). A target can be backed up to more than one repository,
// so its snapshots interleave here. `repoFilter` = the selected destination id, or
// null for "all". The chip row only appears when there is more than one repo.
let repoFilter = $state<number | null>(null);
// Distinct repos present in the loaded snapshots, with a count each.
const repoGroups = $derived.by(() => {
const m = new Map<number, { id: number; name: string; repository: string; count: number }>();
for (const s of snapshots) {
const g = m.get(s._destinationId);
if (g) g.count++;
else m.set(s._destinationId, { id: s._destinationId, name: s._destinationName, repository: s._destinationRepository, count: 1 });
}
return [...m.values()].sort((a, b) => a.name.localeCompare(b.name));
});
const visibleSnapshots = $derived(
repoFilter == null ? snapshots : snapshots.filter((s) => s._destinationId === repoFilter)
);
// A dropped repo filter (e.g. after a delete removed the last snapshot of a repo)
// falls back to "all" so the list never shows an empty filtered view.
$effect(() => {
if (repoFilter != null && !repoGroups.some((g) => g.id === repoFilter)) repoFilter = null;
});
export async function refresh() { await loadSnapshots(); }
async function loadSnapshots() {
// No backup config → no snapshots possible → skip the slow per-repo listing.
if (!hasBackups) { snapshots = []; onCount?.(0); loading = false; onLoadingChange?.(false); return; }
loading = true;
onLoadingChange?.(true);
try {
const destRes = await fetch('/api/backup/destinations');
const destinations: { id: number; name: string; repository: string }[] = destRes.ok ? await destRes.json() : [];
const nameTag = `dockhand:name=${targetName}`;
const all: Snapshot[] = [];
// Query every destination and keep only THIS target's snapshots (by tag).
await Promise.all(destinations.map(async (dest) => {
try {
const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`);
if (!res.ok) return;
const data = await res.json();
const snaps: any[] = data.snapshots ?? data;
for (const s of snaps) {
if ((s.tags || []).some((t: string) => t === nameTag)) {
all.push({ ...s, _destinationId: dest.id, _destinationName: dest.name, _destinationRepository: dest.repository });
}
}
} catch { /* skip an unreachable destination */ }
}));
all.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
snapshots = all;
onCount?.(all.length);
// Per-snapshot stats from schedule executions (recorded at backup time).
const statMap = new Map<string, { filesNew: number; filesChanged: number; dataAdded: number }>();
await Promise.all(configIds.map(async (id) => {
try {
const r = await fetch(`/api/schedules/executions?scheduleType=backup&scheduleId=${id}&limit=100`);
if (!r.ok) return;
const d = await r.json();
for (const exec of d.executions || []) {
if (exec.details?.snapshotId) {
statMap.set(exec.details.snapshotId, {
filesNew: exec.details.filesNew ?? 0,
filesChanged: exec.details.filesChanged ?? 0,
dataAdded: exec.details.dataAdded ?? 0,
});
}
}
} catch { /* stats are best-effort */ }
}));
stats = statMap;
} catch {
snapshots = [];
toast.error('Failed to load snapshots');
} finally {
loading = false;
onLoadingChange?.(false);
}
}
function openBrowser(s: Snapshot) {
browserDestId = s._destinationId;
browserSnapshotId = s.id;
showBrowser = true;
}
function openRestore(s: Snapshot) {
restoreDestId = s._destinationId;
restoreSnapshotId = s.id;
showRestore = true;
}
function toggleDiff(s: Snapshot) {
if (diffPending?.id === s.id) { diffPending = null; return; }
if (diffPending) {
// Second selection → compare (older first). Both must share a destination.
if (diffPending._destinationId !== s._destinationId) {
toast.error('Compare needs two snapshots from the same repository');
diffPending = null;
return;
}
const pair = [diffPending, s].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
diffDestId = pair[0]._destinationId;
// SnapshotDiffModal reads `short_id`; our snapshots carry `shortId` — alias it.
diffSnapA = { ...pair[0], short_id: pair[0].shortId };
diffSnapB = { ...pair[1], short_id: pair[1].shortId };
showDiff = true;
diffPending = null;
} else {
diffPending = s;
}
}
async function deleteSnapshot(s: Snapshot) {
deletingSnapshot = s.id;
try {
const res = await fetch(`/api/backup/snapshots/${s.id}?destinationId=${s._destinationId}`, { method: 'DELETE' });
if (res.ok) {
toast.success('Snapshot deleted');
await loadSnapshots();
} else {
const data = await res.json();
toast.error(data.error || 'Failed to delete snapshot');
}
} catch {
toast.error('Failed to delete snapshot');
} finally {
deletingSnapshot = null;
confirmDeleteSnapshot = null;
}
}
// `hasBackups` arrives from the parent AFTER this panel mounts (the parent
// fetches its configs async), so it starts false and flips true once configs
// load. A plain onMount would run with hasBackups=false and early-return,
// leaving the list empty until a manual refresh. This effect loads as soon as
// hasBackups is true, exactly once — so the list fills in the background without
// a click, and switching sub-tabs stays cached. If there genuinely are no
// configs (hasBackups never true), onMount shows the empty state below.
let loadedWithBackups = $state(false);
$effect(() => {
if (hasBackups && !loadedWithBackups) {
loadedWithBackups = true;
void loadSnapshots();
}
});
// Only needed for the genuine no-configs case (hasBackups never flips true) —
// render the empty state instead of the initial loading spinner forever.
onMount(() => { if (!hasBackups) void loadSnapshots(); });
</script>
<!-- Refresh header only when there are rows to refresh. On the empty/loading state
it's hidden so this tab's empty state lines up vertically with the History tab's
(which has no header row) instead of sitting one row lower. -->
{#if snapshots.length > 0}
<div class="mb-2 flex items-center justify-end">
<button type="button" class="flex items-center gap-1.5 rounded p-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50" onclick={() => refresh()} disabled={loading} title="Refresh snapshots">
<RefreshCw class="h-3.5 w-3.5" />
</button>
</div>
{/if}
{#if loading && snapshots.length === 0}
<!-- First load only: a quiet spinner, no skeleton. On a refresh where rows already
exist we fall through and keep the real table visible (the header refresh icon
already signals progress). Matches the History tab's empty/loading treatment. -->
<div class="flex flex-col items-center justify-center py-10 text-center">
<Loader2 class="mb-3 h-8 w-8 animate-spin text-muted-foreground/40" />
<p class="text-sm text-muted-foreground">Loading snapshots…</p>
</div>
{:else if snapshots.length === 0}
<div class="flex flex-col items-center justify-center py-10 text-center">
<Archive class="mb-3 h-10 w-10 text-muted-foreground/40" />
<p class="text-sm text-muted-foreground">No snapshots yet for {targetName}.</p>
<p class="mt-1 text-xs text-muted-foreground">Run a backup from the Schedules tab to create one.</p>
</div>
{:else}
<!-- Repo filter chips (Option C) — only when the target spans more than one repo. -->
{#if repoGroups.length > 1}
<div class="mb-2 flex flex-wrap items-center gap-1.5">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors {repoFilter == null ? 'border-primary/50 bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:text-foreground'}"
onclick={() => (repoFilter = null)}
>
All repositories <span class="text-[10px] opacity-70">{snapshots.length}</span>
</button>
{#each repoGroups as g}
{@const GIcon = getRepoTypeIcon(g.repository)}
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors {repoFilter === g.id ? 'border-primary/50 bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:text-foreground'}"
onclick={() => (repoFilter = g.id)}
>
<GIcon class="h-3 w-3 {repoFilter === g.id ? 'text-primary' : 'text-primary/70'}" />{g.name} <span class="text-[10px] opacity-70">{g.count}</span>
</button>
{/each}
</div>
{/if}
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="border-b text-left text-muted-foreground">
<th class="py-1.5 pl-2 font-medium">Snapshot</th>
<th class="py-1.5 pl-2 font-medium">Taken</th>
<th class="py-1.5 pl-2 font-medium">Added</th>
<th class="py-1.5 pl-2 font-medium">Repository</th>
<th class="py-1.5 pr-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each visibleSnapshots as s}
{@const isDiffPending = diffPending?.id === s.id}
{@const RepoIcon = getRepoTypeIcon(s._destinationRepository)}
{@const st = stats.get(s.id)}
<tr class="border-b text-xs last:border-0 hover:bg-muted/30 {isDiffPending ? 'bg-primary/10' : ''}">
<td class="py-1.5 pl-2 font-mono text-muted-foreground">{s.shortId}</td>
<td class="py-1.5 pl-2">{formatDateTime(s.time)}</td>
<td class="py-1.5 pl-2 text-muted-foreground" title={st ? `${st.filesNew} new, ${st.filesChanged} changed files` : ''}>{st ? formatBytes(st.dataAdded) : '—'}</td>
<td class="py-1.5 pl-2 text-muted-foreground">
<span class="flex items-center gap-1.5"><RepoIcon class="h-3.5 w-3.5 text-primary/70" />{s._destinationName}</span>
</td>
<td class="px-3 py-1.5 text-right">
<div class="flex items-center justify-end gap-0.5">
{#if visibleSnapshots.length >= 2}
<button type="button" class="rounded p-1 transition-colors {isDiffPending ? 'bg-primary/20 text-primary' : 'hover:bg-muted'}" onclick={() => toggleDiff(s)} title={isDiffPending ? 'Cancel compare' : diffPending ? 'Compare with selected' : 'Compare'}>
<ArrowLeftRight class="h-3 w-3 {isDiffPending ? 'text-primary' : 'text-muted-foreground'}" />
</button>
{/if}
<button type="button" class="rounded p-1 transition-colors hover:bg-muted" onclick={() => openBrowser(s)} title="Browse files">
<FolderOpen class="h-3 w-3 text-muted-foreground" />
</button>
<button type="button" class="rounded p-1 transition-colors hover:bg-muted" onclick={() => openRestore(s)} title="Restore">
<RotateCcw class="h-3 w-3 text-muted-foreground" />
</button>
<ConfirmPopover
open={confirmDeleteSnapshot === s.id}
action="Delete"
itemType="snapshot"
itemName={s.shortId}
title="Delete snapshot"
position="left"
onConfirm={() => deleteSnapshot(s)}
onOpenChange={(open) => confirmDeleteSnapshot = open ? s.id : null}
>
{#snippet children({ open })}
{#if deletingSnapshot === s.id}
<Loader2 class="h-3 w-3 animate-spin" />
{:else}
<Trash2 class="h-3 w-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/if}
{/snippet}
</ConfirmPopover>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<SnapshotBrowser
bind:open={showBrowser}
destinationId={browserDestId}
snapshotId={browserSnapshotId}
{targetName}
targetType={type}
{environmentId}
/>
<RestoreModal
bind:open={showRestore}
destinationId={restoreDestId}
snapshotId={restoreSnapshotId}
containerName={targetName}
{environmentId}
onDone={loadSnapshots}
/>
<SnapshotDiffModal
bind:open={showDiff}
destinationId={diffDestId}
snapshotA={diffSnapA}
snapshotB={diffSnapB}
/>
@@ -38,17 +38,21 @@
return 'bg-emerald-500';
}
// Clamp to the chart's [0,100] yDomain so a transient >100% spike (e.g. a
// new container's first stats sample on a churny host) can't render above
// the frame and squash the real data (#1279). Mirrors the clamp on the
// displayed cpuPercent value above.
const cpuChartData = $derived(
metricsHistory?.map(m => ({
date: new Date(m.timestamp),
value: m.cpu_percent
value: Math.min(100, Math.max(0, m.cpu_percent))
})) ?? []
);
const memoryChartData = $derived(
metricsHistory?.map(m => ({
date: new Date(m.timestamp),
value: m.memory_percent
value: Math.min(100, Math.max(0, m.memory_percent))
})) ?? []
);
@@ -3,6 +3,7 @@
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Loader2, Network } from 'lucide-svelte';
import ModalHeader from '$lib/components/ModalHeader.svelte';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { formatDateTime } from '$lib/stores/settings';
import ContainerTile from '../containers/ContainerTile.svelte';
@@ -62,15 +63,12 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col">
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col" onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Network class="w-5 h-5" />
Network details: <span class="text-muted-foreground font-normal">{networkName || networkId.slice(0, 12)}</span>
</Dialog.Title>
<ModalHeader icon={Network} title="Network" name={networkName || networkId} />
</Dialog.Header>
<div class="flex-1 overflow-auto space-y-4 min-h-0">
<div class="flex-1 overflow-auto space-y-4 min-h-0 pr-2">
{#if loading}
<div class="flex items-center justify-center py-8">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
+101 -9
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
import { formatBytes } from '$lib/utils/format';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
@@ -20,6 +22,7 @@
CheckCheck,
X,
AlertCircle,
AlertTriangle,
Loader2,
Search,
Server,
@@ -35,7 +38,14 @@
PlayCircle,
Trash2,
Bug,
ShieldX
ShieldX,
Archive,
ArchiveX,
HardDrive,
PackageCheck,
FolderCheck,
Eraser,
ShieldCheck
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { DataGrid } from '$lib/components/data-grid';
@@ -51,6 +61,13 @@
import { canAccess } from '$lib/stores/auth';
const canEditSchedules = $derived($canAccess('schedules', 'edit'));
function cleanError(msg: string): string {
try { const p = JSON.parse(msg); if (p.message) return p.message; } catch {}
const m = msg.match(/\{"message":"([^"]+)"\}/);
return m ? msg.replace(m[0], m[1]) : msg;
}
const canRunSchedules = $derived($canAccess('schedules', 'run'));
import { vulnerabilityCriteriaIcons, vulnerabilityCriteriaLabels } from '$lib/utils/update-steps';
import type { VulnerabilityCriteria } from '$lib/server/db';
@@ -146,7 +163,7 @@
interface ScheduleExecution {
id: number;
scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune';
scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify';
scheduleId: number;
environmentId: number | null;
entityName: string;
@@ -155,7 +172,7 @@
startedAt: string | null;
completedAt: string | null;
duration: number | null;
status: 'queued' | 'running' | 'success' | 'failed' | 'skipped';
status: 'queued' | 'running' | 'success' | 'warning' | 'failed' | 'skipped';
errorMessage: string | null;
details: ScheduleExecutionDetails;
logs: string | null;
@@ -165,7 +182,7 @@
interface Schedule {
key: string; // Unique key: type-id
id: number;
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune';
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify';
name: string;
entityName: string;
description?: string;
@@ -183,6 +200,10 @@
vulnerabilityCriteria?: string | null;
// Env update check specific fields
autoUpdate?: boolean;
// Repo verify specific fields
dataSubset?: string;
// Repo prune specific fields
maxUnused?: string;
}
// State
@@ -786,6 +807,8 @@
switch (status) {
case 'success':
return { variant: 'default' as const, class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', icon: Check };
case 'warning':
return { variant: 'default' as const, class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', icon: AlertTriangle };
case 'failed':
return { variant: 'default' as const, class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', icon: X };
case 'running':
@@ -944,6 +967,14 @@
Env update checks
{:else if filterTypes[0] === 'image_prune'}
Image prune
{:else if filterTypes[0] === 'backup'}
Backups
{:else if filterTypes[0] === 'repo_prune'}
Repo prune
{:else if filterTypes[0] === 'repo_check'}
Repo check
{:else if filterTypes[0] === 'repo_verify'}
Data verify
{:else}
System jobs
{/if}
@@ -978,6 +1009,25 @@
<Trash2 class="w-4 h-4 mr-2 inline text-amber-500 drop-shadow-[0_0_3px_rgba(245,158,11,0.4)]" />
Image prune
</Select.Item>
<!-- BETA GATE: backup schedule filters hidden unless FEAT_BACKUPS_ENABLED (see features.ts) -->
{#if $page.data.backupsEnabled}
<Select.Item value="backup">
<Archive class="w-4 h-4 mr-2 inline text-blue-500 drop-shadow-[0_0_3px_rgba(59,130,246,0.4)]" />
Backups
</Select.Item>
<Select.Item value="repo_prune">
<ArchiveX class="w-4 h-4 mr-2 inline text-blue-500 drop-shadow-[0_0_3px_rgba(59,130,246,0.4)]" />
Repo prune
</Select.Item>
<Select.Item value="repo_check">
<PackageCheck class="w-4 h-4 mr-2 inline text-blue-500 drop-shadow-[0_0_3px_rgba(59,130,246,0.4)]" />
Repo check
</Select.Item>
<Select.Item value="repo_verify">
<FolderCheck class="w-4 h-4 mr-2 inline text-blue-500 drop-shadow-[0_0_3px_rgba(59,130,246,0.4)]" />
Data verify
</Select.Item>
{/if}
{#if !hideSystemJobs}
<Select.Item value="system_cleanup">
<Wrench class="w-4 h-4 mr-2 inline text-amber-500 drop-shadow-[0_0_3px_rgba(245,158,11,0.4)]" />
@@ -1029,6 +1079,8 @@
{:else if filterStatuses.length === 1}
{#if filterStatuses[0] === 'success'}
Success
{:else if filterStatuses[0] === 'warning'}
Warning
{:else if filterStatuses[0] === 'failed'}
Failed
{:else if filterStatuses[0] === 'skipped'}
@@ -1057,6 +1109,10 @@
<Check class="w-4 h-4 mr-2 inline text-green-500" />
Success
</Select.Item>
<Select.Item value="warning">
<AlertTriangle class="w-4 h-4 mr-2 inline text-amber-500" />
Warning
</Select.Item>
<Select.Item value="failed">
<X class="w-4 h-4 mr-2 inline text-red-500" />
Failed
@@ -1159,6 +1215,14 @@
{/if}
{:else if schedule.type === 'image_prune'}
<Trash2 class="w-4 h-4 text-amber-500 glow-amber shrink-0" />
{:else if schedule.type === 'backup'}
<Archive class="w-4 h-4 text-blue-500 glow-blue shrink-0" />
{:else if schedule.type === 'repo_prune'}
<ArchiveX class="w-4 h-4 text-blue-500 glow-blue shrink-0" />
{:else if schedule.type === 'repo_check'}
<PackageCheck class="w-4 h-4 text-blue-500 glow-blue shrink-0" />
{:else if schedule.type === 'repo_verify'}
<FolderCheck class="w-4 h-4 text-blue-500 glow-blue shrink-0" />
{:else}
<Wrench class="w-4 h-4 text-amber-500 shrink-0" />
{/if}
@@ -1196,6 +1260,30 @@
<span class="truncate">{schedule.description || 'Env update check'}</span>
{:else if schedule.type === 'image_prune'}
<span class="truncate">{schedule.description || 'Prune unused images'}</span>
{:else if schedule.type === 'backup'}
{@const parts = (schedule.description || '').split(' to ')}
{#if parts.length === 2}
<span class="truncate">{parts[0]} to</span>
<HardDrive class="w-3 h-3 shrink-0 text-muted-foreground" />
<span class="truncate">{parts[1]}</span>
{:else}
<span class="truncate">{schedule.description || 'Scheduled backup'}</span>
{/if}
{:else if schedule.type === 'repo_prune'}
<Eraser class="w-3 h-3 shrink-0 text-muted-foreground" />
<span class="truncate">Prune unused data ({schedule.maxUnused ?? '10'}%) from</span>
<HardDrive class="w-3 h-3 shrink-0 text-muted-foreground" />
<span class="truncate">{schedule.entityName}</span>
{:else if schedule.type === 'repo_check'}
<PackageCheck class="w-3 h-3 shrink-0 text-muted-foreground" />
<span class="truncate">Check integrity of</span>
<HardDrive class="w-3 h-3 shrink-0 text-muted-foreground" />
<span class="truncate">{schedule.entityName}</span>
{:else if schedule.type === 'repo_verify'}
<FolderCheck class="w-3 h-3 shrink-0 text-muted-foreground" />
<span class="truncate">Verify {schedule.dataSubset || '5%'} of data in</span>
<HardDrive class="w-3 h-3 shrink-0 text-muted-foreground" />
<span class="truncate">{schedule.entityName}</span>
{:else}
<span class="truncate">{schedule.description || 'System job'}</span>
{/if}
@@ -1424,8 +1512,12 @@
</Tooltip.Content>
</Tooltip.Root>
</td>
<td class="px-2 py-1 text-xs text-destructive">
{exec.errorMessage || ''}
<td class="px-2 py-1 text-xs max-w-[400px] truncate" title={exec.errorMessage || ''}>
{#if exec.errorMessage}
<span class="text-destructive">{cleanError(exec.errorMessage)}</span>
{:else if exec.status === 'success' && exec.details?.dataAdded !== undefined}
<span class="text-muted-foreground">{exec.details.filesNew ?? 0} new, {exec.details.filesChanged ?? 0} changed · {formatBytes(exec.details.dataAdded ?? 0)} added</span>
{/if}
</td>
<td class="px-2 py-1">
<div class="flex items-center gap-1">
@@ -1512,7 +1604,7 @@
Execution details
{#if selectedExecution}
<span class="text-muted-foreground font-normal">
({#if selectedExecution.scheduleType === 'container_update'}Container update{:else if selectedExecution.scheduleType === 'env_update_check'}Environment update{:else if selectedExecution.scheduleType === 'git_stack_sync'}Git stack sync{:else}System job{/if})
({#if selectedExecution.scheduleType === 'container_update'}Container update{:else if selectedExecution.scheduleType === 'env_update_check'}Environment update{:else if selectedExecution.scheduleType === 'git_stack_sync'}Git stack sync{:else if selectedExecution.scheduleType === 'backup'}Backup{:else if selectedExecution.scheduleType === 'repo_prune'}Repo prune{:else if selectedExecution.scheduleType === 'repo_check'}Repo check{:else if selectedExecution.scheduleType === 'repo_verify'}Data verify{:else}System job{/if})
</span>
{/if}
</Dialog.Title>
@@ -1644,8 +1736,8 @@
{#if selectedExecution.errorMessage}
<div class="shrink-0">
<div class="text-xs text-muted-foreground mb-1">Error</div>
<div class="bg-destructive/10 border border-destructive/20 rounded p-3 text-xs text-destructive">
{selectedExecution.errorMessage}
<div class="bg-destructive/10 border border-destructive/20 rounded p-3 text-xs text-destructive break-words">
{cleanError(selectedExecution.errorMessage)}
</div>
</div>
{/if}
+14 -1
View File
@@ -16,7 +16,8 @@
Users,
Info,
GitBranch,
Tags
Tags,
Archive
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -28,6 +29,7 @@
import GitTab from './git/GitTab.svelte';
import ConfigSetsTab from './config-sets/ConfigSetsTab.svelte';
import NotificationsTab from './notifications/NotificationsTab.svelte';
import BackupsTab from './backups/BackupsTab.svelte';
import AuthTab from './auth/AuthTab.svelte';
import LicenseTab from './license/LicenseTab.svelte';
import AboutTab from './about/AboutTab.svelte';
@@ -77,6 +79,13 @@
<Bell class="w-4 h-4" />
Notifications
</Tabs.Trigger>
<!-- BETA GATE: Backups tab hidden unless FEAT_BACKUPS_ENABLED (see features.ts) -->
{#if $page.data.backupsEnabled}
<Tabs.Trigger value="backups" class="flex-1 flex items-center justify-center gap-1.5">
<Archive class="w-4 h-4" />
Backups
</Tabs.Trigger>
{/if}
<Tabs.Trigger value="auth" class="flex-1 flex items-center justify-center gap-1.5">
<Users class="w-4 h-4" />
Authentication
@@ -119,6 +128,10 @@
{#if activeTab === 'notifications'}<NotificationsTab />{/if}
</Tabs.Content>
<Tabs.Content value="backups" class="flex-1 min-h-0 overflow-y-auto">
{#if activeTab === 'backups'}<BackupsTab />{/if}
</Tabs.Content>
<Tabs.Content value="auth" class="flex-1 min-h-0 flex flex-col">
{#if activeTab === 'auth'}<AuthTab onTabChange={handleTabChange} />{/if}
</Tabs.Content>
@@ -1,11 +1,12 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Shield, Pencil, Plus, Check, RefreshCw, Box, Image, HardDrive, Cable, Layers, Globe, Download, Bell, Sliders, Settings, Users, Eye, SquarePlus, Play, Square, RotateCcw, Trash2, Terminal, ScrollText, Search, Upload, Plug, Unplug, Copy, GitBranch, KeyRound, Building2, Container, TriangleAlert, ClipboardList, Activity, Timer } from 'lucide-svelte';
import { Shield, Pencil, Plus, Check, RefreshCw, Box, Image, HardDrive, Cable, Layers, Globe, Download, Bell, Sliders, Settings, Users, Eye, SquarePlus, Play, Square, RotateCcw, Trash2, Terminal, ScrollText, Search, Upload, Plug, Unplug, Copy, GitBranch, KeyRound, Building2, Container, TriangleAlert, ClipboardList, Activity, Timer, Archive } from 'lucide-svelte';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import * as Alert from '$lib/components/ui/alert';
import { focusFirstInput } from '$lib/utils';
@@ -65,6 +66,7 @@
audit_logs: string[];
activity: string[];
schedules: string[];
backups: string[];
}>({
containers: [],
images: [],
@@ -81,7 +83,8 @@
license: [],
audit_logs: [],
activity: [],
schedules: []
schedules: [],
backups: []
});
@@ -138,6 +141,12 @@
{ key: 'view', label: 'View schedules' },
{ key: 'edit', label: 'Edit schedules' },
{ key: 'run', label: 'Run schedules' }
],
backups: [
{ key: 'view', label: 'View backups' },
{ key: 'run', label: 'Run backups' },
{ key: 'restore', label: 'Restore from backups' },
{ key: 'manage', label: 'Manage destinations' }
]
};
@@ -204,7 +213,8 @@
license: KeyRound,
audit_logs: ClipboardList,
activity: Activity,
schedules: Timer
schedules: Timer,
backups: Archive
};
const categoryColorsSolid: Record<string, string> = {
@@ -223,7 +233,8 @@
license: 'bg-yellow-100 dark:bg-yellow-950 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-800',
audit_logs: 'bg-stone-100 dark:bg-stone-950 text-stone-700 dark:text-stone-400 border-stone-300 dark:border-stone-800',
activity: 'bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-400 border-emerald-300 dark:border-emerald-800',
schedules: 'bg-sky-100 dark:bg-sky-950 text-sky-700 dark:text-sky-400 border-sky-300 dark:border-sky-800'
schedules: 'bg-sky-100 dark:bg-sky-950 text-sky-700 dark:text-sky-400 border-sky-300 dark:border-sky-800',
backups: 'bg-lime-100 dark:bg-lime-950 text-lime-700 dark:text-lime-400 border-lime-300 dark:border-lime-800'
};
const permissionIcons: Record<string, typeof Eye> = {
@@ -272,7 +283,8 @@
license: [],
audit_logs: [],
activity: [],
schedules: []
schedules: [],
backups: []
};
}
@@ -302,7 +314,8 @@
license: [...(role.permissions.license || [])],
audit_logs: [...(role.permissions.audit_logs || [])],
activity: [...(role.permissions.activity || [])],
schedules: [...(role.permissions.schedules || [])]
schedules: [...(role.permissions.schedules || [])],
backups: [...(role.permissions.backups || [])]
};
formError = '';
formErrors = {};
@@ -330,7 +343,8 @@
license: [...(copyFrom.permissions.license || [])],
audit_logs: [...(copyFrom.permissions.audit_logs || [])],
activity: [...(copyFrom.permissions.activity || [])],
schedules: [...(copyFrom.permissions.schedules || [])]
schedules: [...(copyFrom.permissions.schedules || [])],
backups: [...(copyFrom.permissions.backups || [])]
};
formError = '';
formErrors = {};
@@ -483,7 +497,8 @@
</div>
</div>
<div class="p-3 grid grid-cols-2 lg:grid-cols-4 gap-3">
{#each Object.entries(systemPermissions) as [category, permissions]}
<!-- BETA GATE: drop the backups permission group unless FEAT_BACKUPS_ENABLED (see features.ts) -->
{#each Object.entries(systemPermissions).filter(([c]) => c !== 'backups' || $page.data.backupsEnabled) as [category, permissions]}
{@const IconComponent = categoryIcons[category]}
<div class="relative border rounded-md pt-5 pb-3 px-3">
<!-- Category pill on border -->
@@ -0,0 +1,693 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import { DataGrid } from '$lib/components/data-grid';
import { Plus, Trash2, Pencil, HardDrive, Server, CheckCircle, XCircle, AlertCircle, Wifi, Database, RefreshCw, Search, FolderSync, Archive, Loader2, Save, CircleHelp, Unlock, PackageCheck, Eraser, BarChart3, Wrench, FolderCheck, KeyRound } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Label } from '$lib/components/ui/label';
import { appSettings } from '$lib/stores/settings';
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
import { formatDateTime } from '$lib/stores/settings';
import { FolderOpen, Box, Layers, FileStack, Camera, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-svelte';
import { formatBytes } from '$lib/utils/format';
import { getRepoTypeIcon, getRepoTypeLabel } from '$lib/utils/backup';
import { shouldSaveBackupImage } from '$lib/utils/backup-image';
import SnapshotBrowser from '../../containers/SnapshotBrowser.svelte';
import type { Component } from 'svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { canAccess } from '$lib/stores/auth';
import DestinationModal from './DestinationModal.svelte';
import VerifyModal from './VerifyModal.svelte';
import RotatePasswordModal from './RotatePasswordModal.svelte';
import { EmptyState } from '$lib/components/ui/empty-state';
interface Destination {
id: number;
name: string;
repository: string;
envVars?: Record<string, string>;
flags?: string;
lastTestStatus?: string | null;
lastTestError?: string | null;
lastTestAt?: string | null;
createdAt: string;
updatedAt: string;
}
interface BackupConfig {
id: number;
destinationId: number;
targetName: string;
type: string;
}
let destinations = $state<Destination[]>([]);
let configs = $state<BackupConfig[]>([]);
let loading = $state(true);
let showModal = $state(false);
let editingDest = $state<Destination | null>(null);
let confirmDeleteId = $state<number | null>(null);
let confirmAction = $state<{ destId: number; task: string } | null>(null);
let searchQuery = $state('');
let testingId = $state<number | null>(null);
let testingAll = $state(false);
let verifyModalOpen = $state(false);
let verifyDestId = $state(0);
let verifyDestName = $state('');
let rotateModalOpen = $state(false);
let rotateDestId = $state(0);
let rotateDestName = $state('');
let repoStats = $state<Map<number, { totalSize: number; totalFiles: number; snapshots: number }>>(new Map());
let loadingStats = $state<Set<number>>(new Set());
let fetchingAllStats = $state(false);
async function fetchRepoStats(destId: number) {
const s = new Set(loadingStats); s.add(destId); loadingStats = s;
try {
const res = await fetch(`/api/backup/destinations/${destId}/task`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task: 'stats' })
});
const data = await res.json();
if (data.success && data.stats) {
const m = new Map(repoStats);
m.set(destId, data.stats);
repoStats = m;
}
} catch {} finally {
const s2 = new Set(loadingStats); s2.delete(destId); loadingStats = s2;
}
}
async function fetchAllStats() {
fetchingAllStats = true;
// Skip destinations that aren't initialized (failed, needs_init, or never tested)
const healthy = destinations.filter(d => d.lastTestStatus === 'success');
await Promise.all(healthy.map(d => fetchRepoStats(d.id)));
fetchingAllStats = false;
}
async function testAllDestinations() {
testingAll = true;
let passed = 0;
let failed = 0;
// Clear statuses so UI shows spinners
for (const dest of destinations) {
dest.lastTestStatus = null;
}
destinations = [...destinations];
repoStats = new Map();
// Run all tests + stats in parallel, update UI incrementally
await Promise.allSettled(destinations.map(async (dest) => {
try {
const res = await fetch(`/api/backup/destinations/${dest.id}/test`, { method: 'POST' });
const data = await res.json();
if (data.success) {
passed++;
dest.lastTestStatus = 'success';
destinations = [...destinations];
await fetchRepoStats(dest.id);
} else {
failed++;
dest.lastTestStatus = data.status === 'needs_init' ? 'needs_init' : 'failed';
dest.lastTestError = data.error || null;
destinations = [...destinations];
}
} catch {
failed++;
dest.lastTestStatus = 'failed';
destinations = [...destinations];
}
}));
testingAll = false;
if (failed === 0) toast.success(`All ${passed} destinations tested & stats collected`);
else toast.error(`${failed} failed, ${passed} passed`);
}
// Backup helper image setting
// Pre-filled from the store, which carries the API's real version-pinned default
// (fnsys/dockhand-backup:<version>). No local `:latest` guess — that value is
// wrong (the engine uses the versioned image) and misleading to persist.
let backupImage = $state($appSettings.defaultBackupImage);
// The value we loaded the field with. We persist ONLY when the user changed it,
// so leaving the pre-filled versioned default untouched keeps the DB row empty and
// the engine keeps tracking the app version across upgrades (see backup-image.ts).
let backupImageInitial = $state($appSettings.defaultBackupImage);
let savingImage = $state(false);
let imageSavedOk = $state(false);
async function saveBackupImage() {
if (!shouldSaveBackupImage(backupImage, backupImageInitial)) {
// Unchanged — nothing to persist. Give the same confirmation feedback.
imageSavedOk = true;
setTimeout(() => { imageSavedOk = false; }, 2000);
return;
}
savingImage = true;
try {
const res = await fetch('/api/settings/general', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ defaultBackupImage: backupImage })
});
if (res.ok) {
backupImageInitial = backupImage;
imageSavedOk = true;
setTimeout(() => { imageSavedOk = false; }, 2000);
}
else toast.error('Failed to save');
} catch { toast.error('Failed to save'); }
finally { savingImage = false; }
}
let initializingId = $state<number | null>(null);
let runningTask = $state<{ destId: number; task: string } | null>(null);
async function runRepoTask(destId: number, task: string) {
runningTask = { destId, task };
try {
const res = await fetch(`/api/backup/destinations/${destId}/task`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task })
});
const data = await res.json();
if (data.success) {
if (task === 'stats' && data.stats) {
const m = new Map(repoStats);
m.set(destId, data.stats);
repoStats = m;
}
toast.success(data.output || `${task} completed`);
} else {
toast.error(data.error || `${task} failed`);
}
} catch (err: any) {
toast.error(err.message || `${task} failed`);
} finally {
runningTask = null;
}
}
// Browse repository snapshots
let browseOpen = $state(false);
let browseDestId = $state(0);
let browseDestName = $state('');
let browseDestRepo = $state('');
let browseSnapshots = $state<any[]>([]);
let browseLoading = $state(false);
// Snapshot list sorting — default newest first (by snapshot timestamp).
type BrowseSortField = 'shortId' | 'time' | 'type' | 'name';
let browseSort = $state<{ field: BrowseSortField; dir: 'asc' | 'desc' }>({ field: 'time', dir: 'desc' });
function snapName(snap: any): string {
return (snap.tags || []).find((t: string) => t.startsWith('dockhand:name='))?.replace('dockhand:name=', '') || '';
}
function snapType(snap: any): string {
return (snap.tags || []).find((t: string) => t.startsWith('dockhand:type='))?.replace('dockhand:type=', '') || '';
}
function toggleBrowseSort(field: BrowseSortField) {
if (browseSort.field === field) browseSort = { field, dir: browseSort.dir === 'asc' ? 'desc' : 'asc' };
else browseSort = { field, dir: field === 'time' ? 'desc' : 'asc' };
}
const sortedBrowseSnapshots = $derived.by(() => {
const { field, dir } = browseSort;
const key = (s: any) =>
field === 'shortId' ? (s.shortId ?? '') :
field === 'time' ? (s.time ?? '') :
field === 'type' ? snapType(s) :
snapName(s);
return [...browseSnapshots].sort((a, b) => {
const ka = key(a), kb = key(b);
const cmp = ka < kb ? -1 : ka > kb ? 1 : 0;
return dir === 'asc' ? cmp : -cmp;
});
});
// Snapshot file browser
let snapshotBrowseOpen = $state(false);
let snapshotBrowseId = $state('');
let snapshotBrowseDestId = $state(0);
let snapshotBrowseName = $state('');
async function browseDestination(dest: Destination) {
browseDestId = dest.id;
browseDestName = dest.name;
browseDestRepo = dest.repository;
browseSnapshots = [];
browseOpen = true;
browseLoading = true;
try {
// List all snapshots in this destination (no configId filter)
const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`);
if (res.ok) { const d = await res.json(); browseSnapshots = d.snapshots ?? d; }
else {
const data = await res.json();
const errMsg = data.error || 'Failed to list snapshots';
try { const p = JSON.parse(errMsg); toast.error(p.message || errMsg); } catch { toast.error(errMsg); }
}
} catch { toast.error('Failed to list snapshots'); }
finally { browseLoading = false; }
}
const getTypeIcon = getRepoTypeIcon;
const getTypeLabel = getRepoTypeLabel;
function getUsageCount(destId: number): { containers: number; stacks: number } {
const related = configs.filter(c => c.destinationId === destId);
return {
containers: related.filter(c => c.type === 'container').length,
stacks: related.filter(c => c.type === 'stack').length
};
}
const filteredDestinations = $derived(
searchQuery.trim()
? destinations.filter(d =>
d.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.repository.toLowerCase().includes(searchQuery.toLowerCase()) ||
getTypeLabel(d.repository).toLowerCase().includes(searchQuery.toLowerCase())
)
: destinations
);
async function fetchData() {
loading = true;
repoStats = new Map();
try {
const [destRes, configRes] = await Promise.all([
fetch('/api/backup/destinations'),
fetch('/api/backup/configs')
]);
destinations = await destRes.json();
const configData = await configRes.json();
configs = Array.isArray(configData) ? configData : [];
} catch (error) {
console.error('Failed to fetch backup data:', error);
toast.error('Failed to fetch backup destinations');
} finally {
loading = false;
}
}
async function openModal(dest?: Destination) {
if (dest) {
// The LIST endpoint omits envVars (cloud creds) for security.
// Re-fetch the single destination so the modal can pre-fill the
// credential fields.
try {
const res = await fetch(`/api/backup/destinations/${dest.id}`);
if (res.ok) {
editingDest = await res.json();
} else {
editingDest = dest; // fall back to what we have
}
} catch {
editingDest = dest;
}
} else {
editingDest = null;
}
showModal = true;
}
async function testDestination(id: number) {
testingId = id;
try {
const res = await fetch(`/api/backup/destinations/${id}/test`, { method: 'POST' });
const data = await res.json();
if (data.success) {
toast.success('Connection test successful');
} else if (data.status === 'needs_init') {
toast.warning(data.error || 'Repository needs initialization');
} else {
toast.error(data.error || 'Connection test failed');
}
} catch { toast.error('Connection test failed'); }
finally { testingId = null; await fetchData(); }
}
async function initDestination(id: number) {
initializingId = id;
try {
const res = await fetch(`/api/backup/destinations/${id}/init`, { method: 'POST' });
const data = await res.json();
toast[data.success ? 'success' : 'error'](data.message || data.error || 'Init failed');
} catch { toast.error('Init failed'); }
finally { initializingId = null; await fetchData(); }
}
async function deleteDestination(id: number) {
try {
const response = await fetch(`/api/backup/destinations/${id}`, { method: 'DELETE' });
if (response.ok) {
await fetchData();
toast.success('Destination deleted');
} else {
const data = await response.json();
toast.error(data.error || 'Failed to delete destination');
}
} catch { toast.error('Failed to delete destination'); }
}
onMount(() => { fetchData(); });
</script>
<div class="space-y-4">
<!-- Backup helper image -->
<div class="flex items-center gap-3 p-3 border rounded-md bg-muted/20">
<Label class="text-xs shrink-0 flex items-center gap-1.5">
Backup helper image
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger>
<CircleHelp class="w-3 h-3 text-muted-foreground/70 cursor-help" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom" sideOffset={4} class="!w-80 text-xs">
Docker image with restic for backup/restore. Auto-pulled on first run. Change for private registries.
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</Label>
<Input bind:value={backupImage} class="w-80" />
<Button variant="outline" size="sm" class="h-8" onclick={saveBackupImage} disabled={savingImage}>
{#if savingImage}<Loader2 class="w-3.5 h-3.5 animate-spin" />{:else if imageSavedOk}<CheckCircle class="w-3.5 h-3.5 text-green-500" />{:else}<Save class="w-3.5 h-3.5" />{/if}
</Button>
</div>
<div class="flex justify-between items-center">
<div class="flex items-center gap-3">
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
bind:value={searchQuery}
placeholder="Filter destinations..."
class="pl-9 h-8 w-64 text-sm"
/>
</div>
<Badge variant="secondary" class="text-xs">{destinations.length} destination{destinations.length !== 1 ? 's' : ''}</Badge>
</div>
<div class="flex gap-2">
{#if $canAccess('backups', 'manage')}
<Button size="sm" onclick={() => openModal()}>
<Plus class="w-4 h-4 mr-1" />
Add destination
</Button>
{/if}
<Button size="sm" variant="outline" onclick={testAllDestinations} disabled={testingAll}>
{#if testingAll}<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />{:else}<Wifi class="w-3.5 h-3.5 mr-1" />{/if}
Test all
</Button>
<Button size="sm" variant="outline" onclick={fetchData}>
<RefreshCw class="w-3.5 h-3.5" />
</Button>
</div>
</div>
{#if loading && destinations.length === 0}
<p class="text-muted-foreground text-sm">Loading backup destinations...</p>
{:else if destinations.length === 0}
<EmptyState
icon={Archive as unknown as Component}
title="No backup destinations"
description="Add a backup destination to start protecting your container data"
/>
{:else}
<DataGrid
data={filteredDestinations}
keyField="id"
gridId="backupDestinations"
loading={loading}
onRowClick={(dest) => openModal(dest)}
class="border-none"
wrapperClass="border rounded-lg"
>
{#snippet cell(column, dest)}
{#if column.id === 'type'}
{@const TypeIcon = getTypeIcon(dest.repository)}
<div class="flex items-center gap-1.5" title={getTypeLabel(dest.repository)}>
<TypeIcon class="w-4 h-4 text-muted-foreground" />
<span class="text-xs text-muted-foreground">{getTypeLabel(dest.repository)}</span>
</div>
{:else if column.id === 'name'}
<span class="font-medium text-sm">{dest.name}</span>
{:else if column.id === 'repository'}
<span class="text-xs text-muted-foreground truncate block" title={dest.repository}>
{dest.repository}
</span>
{:else if column.id === 'usage'}
{@const usage = getUsageCount(dest.id)}
{#if usage.containers > 0 || usage.stacks > 0}
<div class="flex items-center gap-1.5">
{#if usage.containers > 0}
<span class="flex items-center gap-0.5 text-xs text-muted-foreground" title="{usage.containers} container{usage.containers !== 1 ? 's' : ''} using this repository"><Box class="w-3 h-3" />{usage.containers}</span>
{/if}
{#if usage.stacks > 0}
<span class="flex items-center gap-0.5 text-xs text-muted-foreground" title="{usage.stacks} stack{usage.stacks !== 1 ? 's' : ''} using this repository"><Layers class="w-3 h-3" />{usage.stacks}</span>
{/if}
</div>
{:else}
<span class="text-xs text-muted-foreground/50">—</span>
{/if}
{:else if column.id === 'stats'}
{@const stat = repoStats.get(dest.id)}
{#if stat}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="flex items-center gap-0.5" title="Total size"><HardDrive class="w-3 h-3" />{formatBytes(stat.totalSize)}</span>
<span class="flex items-center gap-0.5" title="Files"><FileStack class="w-3 h-3" />{stat.totalFiles}</span>
<span class="flex items-center gap-0.5" title="Snapshots"><Camera class="w-3 h-3" />{stat.snapshots}</span>
</div>
{:else if loadingStats.has(dest.id)}
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
{:else}
<span class="text-xs text-muted-foreground/50">—</span>
{/if}
{:else if column.id === 'status'}
{#if dest.lastTestStatus === 'success'}
<div class="flex items-center gap-1.5">
<CheckCircle class="w-3.5 h-3.5 text-green-500" />
<span class="text-xs text-green-600 dark:text-green-400">Initialized</span>
</div>
{:else if dest.lastTestStatus === 'needs_init'}
<div class="flex items-center gap-1.5">
<AlertCircle class="w-3.5 h-3.5 text-amber-500" />
<span class="text-xs text-amber-600 dark:text-amber-400">Needs init</span>
</div>
{:else if dest.lastTestStatus === 'failed'}
<Tooltip.Root>
<Tooltip.Trigger>
<div class="flex items-center gap-1.5">
<XCircle class="w-3.5 h-3.5 text-destructive" />
<span class="text-xs text-destructive">Failed</span>
</div>
</Tooltip.Trigger>
{#if dest.lastTestError}
<Tooltip.Content class="max-w-sm whitespace-normal text-xs">{dest.lastTestError}</Tooltip.Content>
{/if}
</Tooltip.Root>
{:else if testingAll}
<div class="flex items-center gap-1.5">
<Loader2 class="w-3.5 h-3.5 text-muted-foreground animate-spin" />
<span class="text-xs text-muted-foreground">Testing...</span>
</div>
{:else}
<div class="flex items-center gap-1.5">
<AlertCircle class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs text-muted-foreground">Not tested</span>
</div>
{/if}
{:else if column.id === 'actions'}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex items-center justify-end gap-0.5" onclick={(e) => e.stopPropagation()}>
{#if dest.lastTestStatus === 'success'}
<!-- Repo actions (only for initialized repos) -->
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => browseDestination(dest)} title="Browse snapshots">
<FolderOpen class="grid-action-icon grid-action-info text-muted-foreground" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => runRepoTask(dest.id, 'stats')} disabled={runningTask?.destId === dest.id} title="Repository stats">
{#if runningTask?.destId === dest.id && runningTask.task === 'stats'}<Loader2 class="grid-action-icon text-muted-foreground animate-spin" />{:else}<BarChart3 class="grid-action-icon grid-action-info text-muted-foreground" />{/if}
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => runRepoTask(dest.id, 'check')} disabled={runningTask?.destId === dest.id} title="Check integrity">
{#if runningTask?.destId === dest.id && runningTask.task === 'check'}<Loader2 class="grid-action-icon text-muted-foreground animate-spin" />{:else}<PackageCheck class="grid-action-icon grid-action-info text-muted-foreground" />{/if}
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => { verifyDestId = dest.id; verifyDestName = dest.name; verifyModalOpen = true; }} title="Verify data integrity">
<FolderCheck class="grid-action-icon grid-action-info text-muted-foreground" />
</button>
<ConfirmPopover
open={confirmAction?.destId === dest.id && confirmAction.task === 'unlock'}
action="Unlock" itemType="repository" itemName={dest.name} confirmText="Unlock" position="left"
onConfirm={() => { confirmAction = null; runRepoTask(dest.id, 'unlock'); }}
onOpenChange={(o) => confirmAction = o ? { destId: dest.id, task: 'unlock' } : null}
>
{#snippet children({ open })}
{#if runningTask?.destId === dest.id && runningTask.task === 'unlock'}<Loader2 class="grid-action-icon text-muted-foreground animate-spin" />{:else}<Unlock class="grid-action-icon grid-action-edit text-muted-foreground" />{/if}
{/snippet}
</ConfirmPopover>
<ConfirmPopover
open={confirmAction?.destId === dest.id && confirmAction.task === 'prune'}
action="Prune" itemType="unused data from" itemName={dest.name} confirmText="Prune" variant="destructive" position="left"
onConfirm={() => { confirmAction = null; runRepoTask(dest.id, 'prune'); }}
onOpenChange={(o) => confirmAction = o ? { destId: dest.id, task: 'prune' } : null}
>
{#snippet children({ open })}
{#if runningTask?.destId === dest.id && runningTask.task === 'prune'}<Loader2 class="grid-action-icon text-muted-foreground animate-spin" />{:else}<Eraser class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground'}" />{/if}
{/snippet}
</ConfirmPopover>
<ConfirmPopover
open={confirmAction?.destId === dest.id && confirmAction.task === 'repair'}
action="Repair" itemType="index for" itemName={dest.name} confirmText="Repair" variant="destructive" position="left"
onConfirm={() => { confirmAction = null; runRepoTask(dest.id, 'repair-index'); }}
onOpenChange={(o) => confirmAction = o ? { destId: dest.id, task: 'repair' } : null}
>
{#snippet children({ open })}
{#if runningTask?.destId === dest.id && runningTask.task === 'repair-index'}<Loader2 class="grid-action-icon text-muted-foreground animate-spin" />{:else}<Wrench class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground'}" />{/if}
{/snippet}
</ConfirmPopover>
{/if}
<!-- Init (only when needs_init) -->
{#if dest.lastTestStatus === 'needs_init'}
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors text-amber-500 hover:text-amber-600" onclick={() => initDestination(dest.id)} disabled={initializingId === dest.id} title="Initialize repository">
{#if initializingId === dest.id}<Loader2 class="w-3 h-3 animate-spin" />{:else}<Database class="w-3 h-3" />{/if}
</button>
{/if}
<!-- Always visible: test, edit, delete -->
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => testDestination(dest.id)} disabled={testingId === dest.id} title="Test connection">
{#if testingId === dest.id}<RefreshCw class="grid-action-icon text-muted-foreground animate-spin" />{:else}<Wifi class="grid-action-icon grid-action-restart text-muted-foreground" />{/if}
</button>
{#if $canAccess('backups', 'manage')}
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => { rotateDestId = dest.id; rotateDestName = dest.name; rotateModalOpen = true; }} title="Rotate repository password">
<KeyRound class="grid-action-icon grid-action-edit text-muted-foreground" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100" onclick={() => openModal(dest)} title="Edit">
<Pencil class="grid-action-icon grid-action-edit text-muted-foreground" />
</button>
<ConfirmPopover
open={confirmDeleteId === dest.id}
action="Delete"
itemType="destination"
itemName={dest.name}
title="Remove"
position="left"
onConfirm={() => deleteDestination(dest.id)}
onOpenChange={(open) => confirmDeleteId = open ? dest.id : null}
>
{#snippet children({ open })}
<Trash2 class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
</div>
{/if}
{/snippet}
</DataGrid>
{/if}
</div>
<DestinationModal
bind:open={showModal}
destination={editingDest}
existingDestinations={destinations}
onClose={() => { showModal = false; editingDest = null; }}
onSaved={fetchData}
/>
<Dialog.Root bind:open={browseOpen}>
<Dialog.Content class="max-w-2xl h-[70vh] flex flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<FolderOpen class="w-5 h-5" />
Repository snapshots on
<svelte:component this={getRepoTypeIcon(browseDestRepo)} class="w-4 h-4" />
<span class="text-amber-600 dark:text-amber-400">{browseDestName}</span>
</Dialog.Title>
</Dialog.Header>
<div class="flex-1 overflow-y-auto">
{#if browseLoading}
<div class="flex items-center justify-center py-12"><Loader2 class="w-6 h-6 animate-spin text-muted-foreground" /></div>
{:else if browseSnapshots.length === 0}
<p class="text-sm text-muted-foreground p-4">No snapshots in this repository.</p>
{:else}
{#snippet sortHead(field: BrowseSortField, label: string, extra = '')}
<Table.Head class={extra}>
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground transition-colors" onclick={() => toggleBrowseSort(field)}>
{label}
{#if browseSort.field === field}
{#if browseSort.dir === 'asc'}<ArrowUp class="w-3 h-3" />{:else}<ArrowDown class="w-3 h-3" />{/if}
{:else}
<ArrowUpDown class="w-3 h-3 opacity-40" />
{/if}
</button>
</Table.Head>
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
{@render sortHead('shortId', 'ID', 'w-24')}
{@render sortHead('time', 'Created')}
{@render sortHead('type', 'Type', 'w-16')}
{@render sortHead('name', 'Name')}
<Table.Head class="w-12 text-right">Browse</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each sortedBrowseSnapshots as snap}
{@const name = snapName(snap)}
{@const type = snapType(snap)}
<Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => { snapshotBrowseDestId = browseDestId; snapshotBrowseId = snap.id; snapshotBrowseName = name; snapshotBrowseOpen = true; }}>
<Table.Cell class="font-mono text-xs text-muted-foreground">{snap.shortId}</Table.Cell>
<Table.Cell class="text-xs">{formatDateTime(snap.time)}</Table.Cell>
<Table.Cell>
{#if type === 'container'}
<Box class="w-3.5 h-3.5 text-blue-500" />
{:else if type === 'stack'}
<Layers class="w-3.5 h-3.5 text-purple-500" />
{:else}
<Box class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</Table.Cell>
<Table.Cell class="text-xs">{name}</Table.Cell>
<Table.Cell class="text-right">
<span class="inline-flex p-1 rounded hover:bg-muted transition-colors text-muted-foreground" title="Browse snapshot content">
<FolderOpen class="w-3.5 h-3.5" />
</span>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</div>
<Dialog.Footer class="pt-4">
<Button variant="outline" onclick={() => browseOpen = false}>Close</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<SnapshotBrowser
bind:open={snapshotBrowseOpen}
destinationId={snapshotBrowseDestId}
snapshotId={snapshotBrowseId}
targetName={snapshotBrowseName}
/>
<VerifyModal
bind:open={verifyModalOpen}
destinationId={verifyDestId}
destinationName={verifyDestName}
/>
<RotatePasswordModal
bind:open={rotateModalOpen}
destinationId={rotateDestId}
destinationName={rotateDestName}
/>
@@ -0,0 +1,679 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Input } from '$lib/components/ui/input';
import { Plus, Check, RefreshCw, Wifi, Database, HardDrive, Dices, Copy, ChevronDown, BarChart3, Loader2, Clock, PackageCheck, FolderCheck, Unlock, CircleHelp, AlertTriangle } from 'lucide-svelte';
import cronstrue from 'cronstrue';
import { formatBytes } from '$lib/utils/format';
import CronEditor from '$lib/components/cron-editor.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Popover from '$lib/components/ui/popover';
import { AmazonS3Icon, BackblazeIcon, AzureBlobIcon, GoogleCloudIcon, RestServerIcon } from '$lib/components/cloud-icons';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
interface Destination {
id: number;
name: string;
repository: string;
envVars?: Record<string, string>;
flags?: string;
lastTestStatus?: string | null;
createdAt: string;
updatedAt: string;
}
interface Props {
open: boolean;
destination?: Destination | null;
existingDestinations?: Destination[];
onClose: () => void;
onSaved: () => void;
}
let { open = $bindable(), destination = null, existingDestinations = [], onClose, onSaved }: Props = $props();
const isEditing = $derived(destination !== null);
// Stats for existing destinations
let repoStats = $state<{ totalSize: number; totalFiles: number; snapshots: number } | null>(null);
let loadingStats = $state(false);
async function fetchStats() {
if (!destination?.id) return;
loadingStats = true;
try {
const res = await fetch(`/api/backup/destinations/${destination.id}/task`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task: 'stats' })
});
const data = await res.json();
if (data.success && data.stats) repoStats = data.stats;
} catch {} finally { loadingStats = false; }
}
$effect(() => {
if (open && destination?.id) {
repoStats = null;
fetchStats();
}
});
interface FormField {
key: string;
label: string;
placeholder: string;
secret?: boolean;
envKey?: string; // if set, stored as env var instead of part of repo URL
}
interface BackendType {
value: string;
label: string;
icon: any;
fields: FormField[];
buildRepo: (fields: Record<string, string>) => string;
parseRepo: (repo: string) => Record<string, string>;
}
const backendTypes: BackendType[] = [
{
value: 'local', label: 'Local path', icon: HardDrive,
fields: [
{ key: 'path', label: 'Path', placeholder: '/mnt/backups/dockhand' }
],
buildRepo: (f) => f.path || '',
parseRepo: (repo) => ({ path: repo })
},
{
value: 's3', label: 'Amazon S3', icon: AmazonS3Icon,
fields: [
{ key: 'endpoint', label: 'Endpoint', placeholder: 's3.amazonaws.com (or http://minio:9000)' },
{ key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' },
{ key: 'path', label: 'Path (optional)', placeholder: 'dockhand' },
{ key: 'accessKey', label: 'Access key ID', placeholder: '', envKey: 'AWS_ACCESS_KEY_ID' },
{ key: 'secretKey', label: 'Secret access key', placeholder: '', secret: true, envKey: 'AWS_SECRET_ACCESS_KEY' }
],
buildRepo: (f) => {
const endpoint = f.endpoint || 's3.amazonaws.com';
const path = f.path ? `/${f.path}` : '';
return `s3:${endpoint.includes('://') ? '' : 'https://'}${endpoint}/${f.bucket}${path}`;
},
parseRepo: (repo) => {
// s3:https://s3.amazonaws.com/bucket/path or s3:http://minio:9000/bucket/path
const after = repo.replace(/^s3:/, '');
try {
const url = new URL(after.startsWith('http') ? after : `https://${after}`);
const parts = url.pathname.replace(/^\//, '').split('/');
const bucket = parts[0] || '';
const path = parts.slice(1).join('/');
const endpoint = after.startsWith('http') ? `${url.protocol}//${url.host}` : url.host;
return { endpoint, bucket, path };
} catch {
return { endpoint: '', bucket: '', path: '' };
}
}
},
{
value: 'b2', label: 'Backblaze B2', icon: BackblazeIcon,
fields: [
{ key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' },
{ key: 'path', label: 'Path (optional)', placeholder: 'dockhand' },
{ key: 'accountId', label: 'Account ID', placeholder: '', envKey: 'B2_ACCOUNT_ID' },
{ key: 'accountKey', label: 'Application key', placeholder: '', secret: true, envKey: 'B2_ACCOUNT_KEY' }
],
buildRepo: (f) => `b2:${f.bucket}:${f.path || ''}`.replace(/:$/, ''),
parseRepo: (repo) => {
const after = repo.replace(/^b2:/, '');
const [bucket, ...rest] = after.split(':');
return { bucket: bucket || '', path: rest.join(':') };
}
},
{
value: 'azure', label: 'Azure Blob', icon: AzureBlobIcon,
fields: [
{ key: 'container', label: 'Container', placeholder: 'my-backup-container' },
{ key: 'path', label: 'Path (optional)', placeholder: 'dockhand' },
{ key: 'accountName', label: 'Account name', placeholder: '', envKey: 'AZURE_ACCOUNT_NAME' },
{ key: 'accountKey', label: 'Account key', placeholder: '', secret: true, envKey: 'AZURE_ACCOUNT_KEY' }
],
buildRepo: (f) => `azure:${f.container}:${f.path || ''}`.replace(/:$/, ''),
parseRepo: (repo) => {
const after = repo.replace(/^azure:/, '');
const [container, ...rest] = after.split(':');
return { container: container || '', path: rest.join(':') };
}
},
{
value: 'gs', label: 'Google Cloud', icon: GoogleCloudIcon,
fields: [
{ key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' },
{ key: 'path', label: 'Path (optional)', placeholder: '/dockhand' },
{ key: 'accessToken', label: 'Access token', placeholder: '', secret: true, envKey: 'GOOGLE_ACCESS_TOKEN' }
],
buildRepo: (f) => `gs:${f.bucket}:${f.path || '/'}`,
parseRepo: (repo) => {
const after = repo.replace(/^gs:/, '');
const [bucket, ...rest] = after.split(':');
return { bucket: bucket || '', path: rest.join(':') };
}
},
{
value: 'rest', label: 'REST server', icon: RestServerIcon,
fields: [
{ key: 'url', label: 'Server URL', placeholder: 'https://backup-server:8000/repo-name' }
],
buildRepo: (f) => `rest:${f.url || ''}`,
parseRepo: (repo) => ({ url: repo.replace(/^rest:/, '') })
}
];
let formName = $state('');
let formBackendType = $state('local');
let formFields = $state<Record<string, string>>({});
let formPassword = $state('');
let formFlags = $state('');
let formError = $state('');
let formSaving = $state(false);
// Policies
let policyPruneEnabled = $state(true);
let policyPruneSchedule = $state('0 0 1 * *');
let policyPruneMaxUnused = $state('10');
let policyCheckEnabled = $state(true);
let policyCheckSchedule = $state('0 0 1 * *');
let policyVerifyEnabled = $state(false);
let policyVerifySchedule = $state('0 0 1 * *');
let policyVerifyDataSubset = $state('5%');
let policyAutoUnlock = $state(true);
let testing = $state(false);
let initializing = $state(false);
const selectedBackend = $derived(backendTypes.find(b => b.value === formBackendType) ?? backendTypes[0]);
const repoFields = $derived(selectedBackend.fields.filter(f => !f.envKey));
const credentialFields = $derived(selectedBackend.fields.filter(f => f.envKey));
function detectBackendType(repo: string): string {
for (const prefix of ['s3:', 'b2:', 'azure:', 'gs:', 'rest:']) {
if (repo.startsWith(prefix)) return prefix.slice(0, -1);
}
return 'local';
}
function resetForm() {
formName = ''; formBackendType = 'local'; formFields = {}; formPassword = '';
formFlags = ''; formError = '';
formSaving = false;
policyPruneEnabled = true; policyPruneSchedule = '0 0 1 * *'; policyPruneMaxUnused = '10';
policyCheckEnabled = true; policyCheckSchedule = '0 0 1 * *';
policyVerifyEnabled = false; policyVerifySchedule = '0 0 1 * *'; policyVerifyDataSubset = '5%';
policyAutoUnlock = true;
}
$effect(() => {
if (open) {
if (destination) {
formName = destination.name;
const type = detectBackendType(destination.repository);
formBackendType = type;
const backend = backendTypes.find(b => b.value === type);
// Parse repo URL into fields, then merge credential env vars
const fields = backend ? backend.parseRepo(destination.repository) : {};
if (destination.envVars) {
for (const [key, value] of Object.entries(destination.envVars)) {
const field = backend?.fields.find(f => f.envKey === key);
if (field) fields[field.key] = value as string;
}
}
formFields = fields;
formPassword = '';
formFlags = destination.flags || '';
formError = '';
// Load policies
const pol = destination.policies ? (() => { try { return JSON.parse(destination.policies); } catch { return {}; } })() : {};
policyPruneEnabled = pol.pruneEnabled ?? true;
policyPruneSchedule = pol.pruneSchedule || '0 0 1 * *';
policyPruneMaxUnused = pol.pruneMaxUnused || '10';
policyCheckEnabled = pol.checkEnabled ?? true;
policyCheckSchedule = pol.checkSchedule || '0 0 1 * *';
policyVerifyEnabled = pol.verifyEnabled ?? false;
policyVerifySchedule = pol.verifySchedule || '0 0 1 * *';
policyVerifyDataSubset = pol.verifyDataSubset || '5%';
policyAutoUnlock = pol.autoUnlock ?? true;
} else { resetForm(); }
}
});
let generatedOk = $state(false);
let copiedOk = $state(false);
function generatePassword() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&*-_=+';
// Rejection sampling to avoid modulo bias: 256 % 72 = 40, so bytes >= 216
// are discarded and re-drawn, keeping every character uniformly likely.
const max = 256 - (256 % chars.length); // 216
const result: string[] = [];
while (result.length < 32) {
const batch = new Uint8Array(32);
crypto.getRandomValues(batch);
for (const b of batch) {
if (b >= max) continue;
result.push(chars[b % chars.length]);
if (result.length === 32) break;
}
}
formPassword = result.join('');
generatedOk = true;
setTimeout(() => { generatedOk = false; }, 1500);
}
function copyPassword() {
copyToClipboard(formPassword);
copiedOk = true;
setTimeout(() => { copiedOk = false; }, 1500);
}
function handleBackendChange(value: string) {
formBackendType = value;
formFields = {};
}
async function testConnection() {
testing = true;
try {
let payload: Record<string, unknown>;
if (isEditing) {
// Use saved credentials from DB
payload = { destinationId: destination!.id };
} else {
const repository = selectedBackend.buildRepo(formFields);
if (!repository.trim()) { toast.error('Fill in repository fields before testing'); testing = false; return; }
if (!formPassword.trim()) { toast.error('Enter encryption password before testing'); testing = false; return; }
const envVars: Record<string, string> = {};
for (const field of selectedBackend.fields) {
if (field.envKey && formFields[field.key]?.trim()) envVars[field.envKey] = formFields[field.key];
}
payload = { repository, password: formPassword, envVars: Object.keys(envVars).length > 0 ? envVars : undefined };
}
const res = await fetch('/api/backup/destinations/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success && data.needsInit) {
toast.info(data.message || 'Connection works, but repository needs initialization');
} else {
toast[res.ok && data.success ? 'success' : 'error'](data.success ? 'Connection test successful' : (data.error || 'Connection test failed'));
}
} catch { toast.error('Connection test failed'); } finally { testing = false; }
}
async function initRepo() {
if (!destination) return;
initializing = true;
try {
const res = await fetch(`/api/backup/destinations/${destination.id}/init`, { method: 'POST' });
const data = await res.json();
toast[res.ok && data.success ? 'success' : 'error'](data.success ? 'Repository initialized' : (data.error || 'Init failed'));
} catch { toast.error('Init failed'); } finally { initializing = false; }
}
// Normalize a restic repository string for loose equality: lowercase, drop
// trailing slashes, collapse internal whitespace. Good enough to catch the
// common "same repo, two rows" mistake (s3:https://host/bucket vs the same
// with a trailing slash). This only gates a non-blocking WARNING, so a missed
// match just means no warning — never a false block.
function normalizeRepo(repo: string): string {
return repo.trim().toLowerCase().replace(/\/+$/, '').replace(/\s+/g, ' ');
}
// Set to the colliding destination's name when the entered repo matches an
// existing destination's repo. Shows a warning + flips Save to "Save anyway".
let repoConflictName = $state<string | null>(null);
// Editing the repo after the warning appeared clears it, so the user must
// re-confirm against whatever repo they end up with (not a stale acknowledgement).
const currentRepo = $derived(selectedBackend.buildRepo(formFields));
$effect(() => {
currentRepo; // track
repoConflictName = null;
});
async function save() {
if (!formName.trim()) { formError = 'Name is required'; return; }
const repository = selectedBackend.buildRepo(formFields);
if (!repository.trim()) { formError = 'Repository fields are incomplete'; return; }
// Warn (once) if this repo is already used by another destination. Backups
// to a shared repo are serialized (restic locks the repo), not parallel —
// legitimate for rotation/migration, but usually a mistake. Non-blocking:
// a second click on "Save anyway" proceeds.
if (!repoConflictName) {
const norm = normalizeRepo(repository);
const clash = existingDestinations.find(
(d) => d.id !== destination?.id && normalizeRepo(d.repository) === norm
);
if (clash) { repoConflictName = clash.name; return; }
}
formSaving = true; formError = '';
try {
const envVars: Record<string, string> = {};
for (const field of selectedBackend.fields) {
if (field.envKey && formFields[field.key]?.trim()) {
envVars[field.envKey] = formFields[field.key];
}
}
const policies = {
pruneEnabled: policyPruneEnabled,
pruneSchedule: policyPruneSchedule,
pruneMaxUnused: policyPruneMaxUnused,
checkEnabled: policyCheckEnabled,
checkSchedule: policyCheckSchedule,
verifyEnabled: policyVerifyEnabled,
verifySchedule: policyVerifySchedule,
verifyDataSubset: policyVerifyDataSubset,
autoUnlock: policyAutoUnlock
};
const body: Record<string, unknown> = {
name: formName.trim(), repository,
envVars: Object.keys(envVars).length > 0 ? envVars : undefined,
flags: formFlags.trim() || undefined,
policies: JSON.stringify(policies)
};
if (formPassword || !isEditing) body.password = formPassword || undefined;
const res = await fetch(isEditing ? `/api/backup/destinations/${destination!.id}` : '/api/backup/destinations', {
method: isEditing ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
if (res.ok) { open = false; onSaved(); toast.success(isEditing ? 'Destination updated' : 'Destination created'); }
else { const data = await res.json(); formError = data.error || 'Failed'; }
} catch { formError = 'Failed'; } finally { formSaving = false; }
}
</script>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { formError = ''; repoConflictName = null; focusFirstInput(); } }}>
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title>{isEditing ? 'Edit backup destination' : 'Add backup destination'}</Dialog.Title>
<Dialog.Description>{isEditing ? 'Update backup destination settings' : 'Configure a restic backup repository'}</Dialog.Description>
</Dialog.Header>
{#if formError}<p class="text-sm text-destructive">{formError}</p>{/if}
<div class="flex-1 overflow-y-auto pr-3">
{#if selectedBackend.value === 'local'}
<div class="flex items-start gap-2 p-2.5 mt-4 rounded-md bg-amber-500/10 border border-amber-500/20 text-xs text-amber-600 dark:text-amber-400">
<HardDrive class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<span>Local path repositories can only be used to back up and restore containers running on the same Docker host as Dockhand. For remote environments or cross-env restore, use S3, REST, or another remote backend.</span>
</div>
{/if}
<div class="grid grid-cols-2 gap-6 py-4">
<!-- Left column: connection -->
<div class="space-y-4">
<div class="space-y-2">
<Label for="dest-name">Name</Label>
<Input id="dest-name" bind:value={formName} />
</div>
<div class="space-y-2">
<Label>Backend type</Label>
<Select.Root type="single" value={formBackendType} onValueChange={handleBackendChange}>
<Select.Trigger class="w-full">
<span class="flex items-center gap-2">
<selectedBackend.icon class="w-4 h-4 text-muted-foreground" />
{selectedBackend.label}
</span>
</Select.Trigger>
<Select.Content>
{#each backendTypes as backend}
<Select.Item value={backend.value}>
<span class="flex items-center gap-2">
<backend.icon class="w-4 h-4 text-muted-foreground" />
{backend.label}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#each repoFields as field}
<div class="space-y-1">
<Label for="field-{field.key}">{field.label}</Label>
<Input
id="field-{field.key}"
value={formFields[field.key] ?? ''}
oninput={(e: Event) => { formFields[field.key] = (e.target as HTMLInputElement).value; }}
placeholder={field.placeholder}
/>
</div>
{/each}
</div>
<!-- Right column: credentials & security -->
<div class="space-y-4">
{#each credentialFields as field}
{#if field.key === 'skipHostKey'}
<div class="flex items-center justify-between">
<Label>{field.label}</Label>
<TogglePill checked={formFields[field.key] === 'true'} onchange={() => { formFields[field.key] = formFields[field.key] === 'true' ? '' : 'true'; }} />
</div>
{:else}
<div class="space-y-1">
<Label for="field-{field.key}">{field.label}</Label>
<Input
id="field-{field.key}"
type={field.secret ? 'password' : 'text'}
value={formFields[field.key] ?? ''}
oninput={(e: Event) => { formFields[field.key] = (e.target as HTMLInputElement).value; }}
placeholder={isEditing && field.secret ? '(leave blank to keep current)' : field.placeholder}
/>
</div>
{/if}
{/each}
<div class="space-y-2">
<Label for="dest-password">Encryption password</Label>
<div class="flex gap-1.5">
<Input id="dest-password" type="password" bind:value={formPassword} placeholder={isEditing ? '(leave blank to keep current)' : ''} class="flex-1" />
<Button variant="outline" size="sm" class="h-9 px-2 shrink-0" onclick={generatePassword} title="Generate strong password">
{#if generatedOk}<Check class="w-3.5 h-3.5 text-green-500" />{:else}<Dices class="w-3.5 h-3.5" />{/if}
</Button>
{#if formPassword}
<Button variant="outline" size="sm" class="h-9 px-2 shrink-0" onclick={copyPassword} title="Copy password">
{#if copiedOk}<Check class="w-3.5 h-3.5 text-green-500" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</Button>
{/if}
</div>
<p class="text-xs text-muted-foreground">Restic encrypts all data with this password. You will need it to restore.</p>
</div>
<div class="space-y-2">
<Label for="dest-flags">Extra restic flags</Label>
<Input id="dest-flags" bind:value={formFlags} />
<div class="text-[10px] text-muted-foreground space-y-0.5">
<p>Common flags:</p>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 font-mono">
<span class="cursor-pointer hover:text-foreground" onclick={() => { formFlags = (formFlags + ' --limit-upload 5120').trim(); }} title="Limit upload speed to 5 MB/s">--limit-upload</span>
<span class="cursor-pointer hover:text-foreground" onclick={() => { formFlags = (formFlags + ' --limit-download 10240').trim(); }} title="Limit download speed to 10 MB/s">--limit-download</span>
<span class="cursor-pointer hover:text-foreground" onclick={() => { formFlags = (formFlags + ' --verbose').trim(); }} title="Verbose output">--verbose</span>
<span class="cursor-pointer hover:text-foreground" onclick={() => { formFlags = (formFlags + ' --compression max').trim(); }} title="Maximum compression">--compression max</span>
</div>
</div>
</div>
</div>
</div>
<!-- Policies section -->
<div class="border-t pt-3 mt-2 space-y-3">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Repository policies</span>
<!-- Prune policy -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<Clock class="w-3.5 h-3.5 text-muted-foreground" />
<Label class="text-sm">Scheduled prune</Label>
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root><Tooltip.Trigger><CircleHelp class="w-3 h-3 text-muted-foreground/50 cursor-help" /></Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content side="right" class="!w-64 text-xs">Removes unreferenced data from the repository. Frees disk space after snapshots are forgotten. Run monthly for most repos.</Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<TogglePill bind:checked={policyPruneEnabled} />
</div>
{#if policyPruneEnabled}
<div class="pl-5 space-y-2">
<CronEditor value={policyPruneSchedule} onchange={(v) => policyPruneSchedule = v} />
<div class="flex items-center gap-2">
<label class="text-[10px] text-muted-foreground">Max unused %</label>
<Input bind:value={policyPruneMaxUnused} type="number" min="0" max="100" class="h-8 text-xs w-20" />
</div>
</div>
{/if}
</div>
<!-- Check policy -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<PackageCheck class="w-3.5 h-3.5 text-muted-foreground" />
<Label class="text-sm">Scheduled integrity check</Label>
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root><Tooltip.Trigger><CircleHelp class="w-3 h-3 text-muted-foreground/50 cursor-help" /></Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content side="right" class="!w-64 text-xs">Verifies the structure and integrity of the repository. Detects corruption or missing data. Run monthly or after storage issues.</Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<TogglePill bind:checked={policyCheckEnabled} />
</div>
{#if policyCheckEnabled}
<div class="pl-5">
<CronEditor value={policyCheckSchedule} onchange={(v) => policyCheckSchedule = v} />
</div>
{/if}
</div>
<!-- Data verification policy -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<FolderCheck class="w-3.5 h-3.5 text-muted-foreground" />
<Label class="text-sm">Scheduled data verification</Label>
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root><Tooltip.Trigger><CircleHelp class="w-3 h-3 text-muted-foreground/50 cursor-help" /></Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content side="right" class="!w-64 text-xs">Reads a random subset of data packs to verify they're readable and intact. Catches bit rot and storage corruption. Slower and uses bandwidth on cloud repos. Off by default.</Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<TogglePill bind:checked={policyVerifyEnabled} />
</div>
{#if policyVerifyEnabled}
<div class="pl-5 space-y-2">
<CronEditor value={policyVerifySchedule} onchange={(v) => policyVerifySchedule = v} />
<div class="flex items-center gap-2">
<label class="text-[10px] text-muted-foreground">Data %</label>
<Select.Root type="single" value={policyVerifyDataSubset} onValueChange={(v) => policyVerifyDataSubset = v}>
<Select.Trigger class="h-8 w-20 text-xs">{policyVerifyDataSubset}</Select.Trigger>
<Select.Content>
<Select.Item value="5%">5%</Select.Item>
<Select.Item value="10%">10%</Select.Item>
<Select.Item value="25%">25%</Select.Item>
<Select.Item value="50%">50%</Select.Item>
<Select.Item value="100%">100%</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
{/if}
</div>
<!-- Auto-unlock -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<Unlock class="w-3.5 h-3.5 text-muted-foreground" />
<Label class="text-sm">Auto-unlock stale locks</Label>
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root><Tooltip.Trigger><CircleHelp class="w-3 h-3 text-muted-foreground/50 cursor-help" /></Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content side="right" class="!w-64 text-xs">Automatically removes stale repository locks before prune and check operations. Locks can be left behind by interrupted or crashed backups.</Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<TogglePill bind:checked={policyAutoUnlock} />
</div>
</div>
<!-- Stats section (only for existing destinations) -->
{#if isEditing}
<div class="border-t pt-3 mt-2">
<div class="flex items-center gap-2 mb-2">
<BarChart3 class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Repository usage</span>
{#if loadingStats}
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
{/if}
</div>
{#if repoStats}
<div class="grid grid-cols-3 gap-2">
<div class="bg-muted/30 rounded px-2 py-1.5 text-center border border-border/30">
<div class="text-sm font-semibold">{formatBytes(repoStats.totalSize)}</div>
<div class="text-[9px] text-muted-foreground">Size</div>
</div>
<div class="bg-muted/30 rounded px-2 py-1.5 text-center border border-border/30">
<div class="text-sm font-semibold">{repoStats.totalFiles.toLocaleString()}</div>
<div class="text-[9px] text-muted-foreground">Files</div>
</div>
<div class="bg-muted/30 rounded px-2 py-1.5 text-center border border-border/30">
<div class="text-sm font-semibold">{repoStats.snapshots}</div>
<div class="text-[9px] text-muted-foreground">Snapshots</div>
</div>
</div>
<!-- Usage bar -->
{#if repoStats.totalSize > 0}
<div class="h-2 bg-muted rounded-full overflow-hidden">
<div class="h-full bg-primary/60 rounded-full transition-all" style="width: {Math.min(100, (repoStats.totalSize / (1024 * 1024 * 1024)) * 10)}%"></div>
</div>
<p class="text-[10px] text-muted-foreground mt-1">{repoStats.snapshots} snapshots across {repoStats.totalFiles.toLocaleString()} files</p>
{/if}
{:else if !loadingStats}
<p class="text-xs text-muted-foreground">Loading repository stats...</p>
{/if}
</div>
{/if}
</div>
{#if repoConflictName}
<div class="flex-shrink-0 flex gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle class="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>
This repository is already used by <span class="font-medium">"{repoConflictName}"</span>.
Backups to a shared repository run one at a time (restic locks the repo), not in parallel.
Click <span class="font-medium">Save anyway</span> to continue.
</span>
</div>
{/if}
<Dialog.Footer class="flex-shrink-0 border-t mt-auto pt-4">
<div class="flex gap-2 mr-auto">
<Button variant="outline" size="sm" onclick={testConnection} disabled={testing}>
{#if testing}<RefreshCw class="w-4 h-4 mr-1 animate-spin" />{:else}<Wifi class="w-4 h-4 mr-1" />{/if}
Test
</Button>
{#if isEditing}
<Button variant="outline" size="sm" class="{destination?.lastTestStatus === 'success' ? 'opacity-30' : ''}" onclick={initRepo} disabled={initializing} title={destination?.lastTestStatus === 'success' ? 'Already initialized' : 'Initialize repository'}>
{#if initializing}<RefreshCw class="w-4 h-4 mr-1 animate-spin" />{:else}<Database class="w-4 h-4 mr-1" />{/if}
Init repo
</Button>
{/if}
</div>
<Button variant="outline" onclick={() => { open = false; onClose(); }}>Cancel</Button>
<Button onclick={save} disabled={formSaving} variant={repoConflictName ? 'destructive' : 'default'}>
{#if formSaving}<RefreshCw class="w-4 h-4 mr-1 animate-spin" />{:else if repoConflictName}<AlertTriangle class="w-4 h-4 mr-1" />{:else if isEditing}<Check class="w-4 h-4" />{:else}<Plus class="w-4 h-4" />{/if}
{repoConflictName ? 'Save anyway' : isEditing ? 'Save' : 'Create'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,144 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { KeyRound, Loader2, AlertTriangle } from 'lucide-svelte';
interface Props {
open: boolean;
destinationId: number;
destinationName: string;
}
let { open = $bindable(), destinationId, destinationName }: Props = $props();
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let submitting = $state(false);
let dbOutOfSync = $state(false);
let errorMsg = $state<string | null>(null);
// Reset when the dialog opens for a new destination
$effect(() => {
if (open) {
currentPassword = '';
newPassword = '';
confirmPassword = '';
dbOutOfSync = false;
errorMsg = null;
}
});
const passwordsMatch = $derived(newPassword.length > 0 && newPassword === confirmPassword);
const canSubmit = $derived(
!submitting &&
currentPassword.length > 0 &&
newPassword.length > 0 &&
passwordsMatch &&
newPassword !== currentPassword
);
async function submit() {
errorMsg = null;
dbOutOfSync = false;
submitting = true;
try {
const res = await fetch(`/api/backup/destinations/${destinationId}/rotate-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
toast.success(`Password rotated for "${destinationName}"`);
open = false;
return;
}
if (data.dbOutOfSync) {
dbOutOfSync = true;
errorMsg = data.error || 'Repository was rotated but Dockhand could not save the new password.';
return;
}
errorMsg = data.error || `Rotation failed (HTTP ${res.status})`;
} catch (err) {
errorMsg = err instanceof Error ? err.message : String(err);
} finally {
submitting = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<KeyRound class="w-4 h-4" />
Rotate repository password
</Dialog.Title>
<Dialog.Description>
Change the restic encryption password for <strong>{destinationName}</strong>. The current
password is required for verification.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-3 py-2">
<div class="space-y-1">
<Label for="rotate-current">Current password</Label>
<Input id="rotate-current" type="password" bind:value={currentPassword} autocomplete="current-password" />
</div>
<div class="space-y-1">
<Label for="rotate-new">New password</Label>
<Input id="rotate-new" type="password" bind:value={newPassword} autocomplete="new-password" />
</div>
<div class="space-y-1">
<Label for="rotate-confirm">Confirm new password</Label>
<Input id="rotate-confirm" type="password" bind:value={confirmPassword} autocomplete="new-password" />
{#if confirmPassword.length > 0 && !passwordsMatch}
<p class="text-xs text-destructive">Passwords do not match</p>
{/if}
{#if newPassword.length > 0 && newPassword === currentPassword}
<p class="text-xs text-destructive">New password must differ from current</p>
{/if}
</div>
{#if errorMsg}
<div class="rounded border border-destructive/40 bg-destructive/10 p-3 text-sm">
<div class="flex items-start gap-2">
<AlertTriangle class="w-4 h-4 mt-0.5 text-destructive shrink-0" />
<div>
<p class="font-medium text-destructive">
{#if dbOutOfSync}
Repository rotated, database update failed
{:else}
Rotation failed
{/if}
</p>
<p class="text-muted-foreground mt-1 break-words">{errorMsg}</p>
{#if dbOutOfSync}
<p class="text-muted-foreground mt-2">
Open <strong>Edit destination</strong> and manually set the password to the new
value so Dockhand can talk to the repository again.
</p>
{/if}
</div>
</div>
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)} disabled={submitting}>Cancel</Button>
<Button onclick={submit} disabled={!canSubmit}>
{#if submitting}
<Loader2 class="w-3 h-3 mr-2 animate-spin" />
Rotating…
{:else}
Rotate password
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,130 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { ShieldCheck, Loader2, CheckCircle, XCircle } from 'lucide-svelte';
import { watchJob } from '$lib/utils/sse-fetch';
interface Props {
open: boolean;
destinationId: number;
destinationName: string;
}
let { open = $bindable(), destinationId, destinationName }: Props = $props();
let dataSubset = $state('5%');
let running = $state(false);
let status = $state<'idle' | 'running' | 'success' | 'error'>('idle');
let logs = $state<string[]>([]);
let error = $state('');
$effect(() => {
if (open) {
status = 'idle';
running = false;
logs = [];
error = '';
dataSubset = '5%';
}
});
async function runVerify() {
running = true;
status = 'running';
logs = [`Starting verification (reading ${dataSubset} of data)...`];
error = '';
try {
const res = await fetch(`/api/backup/destinations/${destinationId}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dataSubset })
});
const data = await res.json();
if (data.jobId) {
await watchJob(data.jobId, (line) => {
if (line.event === 'progress' && line.data?.message) {
logs = [...logs, line.data.message];
}
});
status = 'success';
logs = [...logs, 'Verification passed'];
} else if (data.error) {
status = 'error';
error = data.error;
} else {
status = data.success ? 'success' : 'error';
if (!data.success) error = data.error || 'Unknown error';
}
} catch (err: any) {
status = 'error';
error = err.message || 'Verification failed';
} finally {
running = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-lg max-h-[70vh] flex flex-col">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<ShieldCheck class="w-4 h-4" />Verify backup integrity
</Dialog.Title>
<Dialog.Description>{destinationName}</Dialog.Description>
</Dialog.Header>
{#if status === 'idle'}
<div class="space-y-3 py-2">
<p class="text-xs text-muted-foreground">
Reads a random subset of data packs from the repository and verifies they are intact and readable. Larger subsets take longer but provide more thorough verification.
</p>
<div class="flex items-center gap-3">
<span class="text-xs text-muted-foreground shrink-0">Data to verify:</span>
<Select.Root type="single" value={dataSubset} onValueChange={(v) => dataSubset = v}>
<Select.Trigger class="h-8 w-28 text-xs">{dataSubset}</Select.Trigger>
<Select.Content>
<Select.Item value="5%">5% (quick)</Select.Item>
<Select.Item value="10%">10%</Select.Item>
<Select.Item value="25%">25%</Select.Item>
<Select.Item value="50%">50%</Select.Item>
<Select.Item value="100%">100% (full)</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onclick={() => open = false}>Cancel</Button>
<Button size="sm" onclick={runVerify}>
<ShieldCheck class="w-3.5 h-3.5 mr-1.5" />Verify
</Button>
</div>
{:else}
<div class="flex-1 overflow-y-auto border rounded bg-muted/30 p-2 font-mono text-[11px] space-y-0.5 min-h-[200px]">
{#each logs as line}
<div class="text-muted-foreground">{line}</div>
{/each}
{#if status === 'running'}
<div class="flex items-center gap-1.5 text-primary">
<Loader2 class="w-3 h-3 animate-spin" />Running...
</div>
{/if}
{#if status === 'success'}
<div class="flex items-center gap-1.5 text-green-500 font-medium mt-2">
<CheckCircle class="w-3.5 h-3.5" />Verification passed
</div>
{/if}
{#if status === 'error'}
<div class="flex items-center gap-1.5 text-destructive font-medium mt-2">
<XCircle class="w-3.5 h-3.5" />{error}
</div>
{/if}
</div>
<div class="flex justify-end pt-2">
<Button variant="outline" size="sm" onclick={() => open = false}>Close</Button>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,275 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import CronEditor from '$lib/components/cron-editor.svelte';
import { Box, Layers, Loader2, Plus, ChevronDown, ChevronRight, ArrowRight, Zap, GitBranch } from 'lucide-svelte';
import { formatCron, getRepoTypeIcon, isLocalRepo, isRemoteEnvironment } from '$lib/utils/backup';
import { standaloneContainers, volumesForStack, type BackupItem } from '$lib/utils/mounts';
import BackupPanel from '../../containers/BackupPanel.svelte';
interface Props {
environmentId: number;
environmentName: string;
connectionType?: string;
host?: string | null;
}
let { environmentId, environmentName, connectionType, host }: Props = $props();
interface BackupConfig { id: number; targetName: string; type: string; destinationId: number; schedule: string | null; destinationName?: string; }
// `external` = an untracked stack (unknown compose path). Not backup-able — shown
// disabled, excluded from configure / batch, per assertStackBackupable.
// `git` = a git-deployed stack (marked with a small "git" pill).
type BackupListItem = BackupItem & { external?: boolean; git?: boolean };
let items = $state<BackupListItem[]>([]);
let configs = $state<BackupConfig[]>([]);
let destinations = $state<Map<number, { name: string; repository: string }>>(new Map());
let destList = $state<{ id: number; name: string; repository: string }[]>([]);
let loading = $state(true);
let expandedItem = $state<string | null>(null);
// Batch setup
let showBatch = $state(false);
let batchDestId = $state<number>(0);
let batchSchedule = $state('0 2 * * *');
let batchSaving = $state(false);
const isRemote = $derived(isRemoteEnvironment({ connectionType, host }));
const usableDestinations = $derived(isRemote ? destList.filter(d => !isLocalRepo(d.repository)) : destList);
// External stacks can't be backed up — exclude them from the unconfigured count
// and from "Schedule all" so the number reflects only backup-able targets. The
// list mixes stacks and standalone containers, and "Schedule all" configs BOTH,
// so split the count by type to make clear what the batch will create.
const unconfiguredItems = $derived(items.filter(i => !i.external && !configs.some(c => c.targetName === i.name)));
const unconfiguredStacks = $derived(unconfiguredItems.filter(i => i.type === 'stack').length);
const unconfiguredContainers = $derived(unconfiguredItems.filter(i => i.type === 'container').length);
const unconfiguredCount = $derived(unconfiguredItems.length);
/** "3 stacks + 5 containers" / "3 stacks" / "5 containers" — only the non-zero parts. */
const unconfiguredLabel = $derived([
unconfiguredStacks > 0 ? `${unconfiguredStacks} ${unconfiguredStacks === 1 ? 'stack' : 'stacks'}` : '',
unconfiguredContainers > 0 ? `${unconfiguredContainers} ${unconfiguredContainers === 1 ? 'container' : 'containers'}` : '',
].filter(Boolean).join(' + '));
async function fetchAll() {
loading = true;
try {
const [containerRes, stackRes, configRes, destRes] = await Promise.all([
fetch(`/api/containers?env=${environmentId}`),
fetch(`/api/stacks?env=${environmentId}`),
fetch(`/api/backup/configs?env=${environmentId}`),
fetch('/api/backup/destinations')
]);
// Stacks come from /api/stacks (knows sourceType, includes stopped stacks);
// standalone containers from /api/containers. Reconstructing stacks from
// container labels (the old path) couldn't see sourceType and dropped any
// stopped stack. An external/untracked stack (unknown compose path) is
// listed but flagged not backup-able.
const containers = containerRes.ok ? await containerRes.json() : [];
const stacks = stackRes.ok ? await stackRes.json() : [];
const stackItems: BackupListItem[] = (Array.isArray(stacks) ? stacks : []).map((s: any): BackupListItem => ({
name: s.name,
type: 'stack',
volumes: volumesForStack(containers, s.name),
external: s.sourceType !== 'internal' && s.sourceType !== 'git',
git: s.sourceType === 'git'
}));
items = [
...stackItems.sort((a, b) => a.name.localeCompare(b.name)),
...standaloneContainers(containers)
];
if (destRes.ok) {
const dests = await destRes.json();
destList = dests;
destinations = new Map(dests.map((d: any) => [d.id, { name: d.name, repository: d.repository }]));
}
if (configRes.ok) {
const raw = await configRes.json();
configs = Array.isArray(raw) ? raw : [];
}
} catch {}
loading = false;
}
function getItemConfigs(name: string): BackupConfig[] {
return configs.filter(c => c.targetName === name);
}
async function refreshConfigs() {
try {
const res = await fetch(`/api/backup/configs?env=${environmentId}`);
if (res.ok) {
const raw = await res.json();
configs = Array.isArray(raw) ? raw : [];
}
} catch {}
}
function toggleExpand(name: string) {
expandedItem = expandedItem === name ? null : name;
}
async function batchApply() {
if (!batchDestId) { toast.error('Select a repository'); return; }
batchSaving = true;
let created = 0;
for (const item of items) {
if (item.external) continue; // external stacks aren't backup-able
if (configs.some(c => c.targetName === item.name)) continue; // skip already configured
try {
const res = await fetch('/api/backup/configs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetName: item.name,
type: item.type,
destinationId: batchDestId,
environmentId,
schedule: batchSchedule || null,
retention: { keepLast: 7, keepDaily: 7 }
})
});
if (res.ok) created++;
} catch {}
}
toast.success(`Created ${created} backup schedule${created > 1 ? 's' : ''}`);
showBatch = false;
batchSaving = false;
await fetchAll();
}
onMount(fetchAll);
</script>
{#if loading}
<div class="flex items-center justify-center py-12">
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
{:else if items.length === 0}
<p class="text-sm text-muted-foreground py-4 text-center">No containers or stacks found on this environment.</p>
{:else}
<!-- Batch setup -->
{#if unconfiguredCount > 0}
{#if !showBatch}
<div class="mb-2">
<Button variant="outline" size="sm" class="w-full" onclick={() => { showBatch = true; if (!batchDestId && usableDestinations.length > 0) batchDestId = usableDestinations[0].id; }}>
<Zap class="w-3.5 h-3.5 mr-1.5" />Schedule all ({unconfiguredLabel})
</Button>
</div>
{:else}
<div class="mb-2 border rounded-md p-2.5 bg-muted/20 space-y-2">
<div class="flex items-center gap-2">
<Select.Root type="single" value={batchDestId ? String(batchDestId) : ''} onValueChange={(v) => batchDestId = v ? parseInt(v) : 0}>
<Select.Trigger class="h-9 text-xs w-[200px] flex-shrink-0">
{#if batchDestId}
{@const dest = destList.find(d => d.id === batchDestId)}
{#if dest}
{@const DIcon = getRepoTypeIcon(dest.repository)}
<span class="flex items-center gap-1.5 truncate"><DIcon class="w-3.5 h-3.5 text-primary/60 flex-shrink-0" />{dest.name}</span>
{/if}
{:else}Select repository{/if}
</Select.Trigger>
<Select.Content>
{#each usableDestinations as dest}
{@const DIcon = getRepoTypeIcon(dest.repository)}
<Select.Item value={String(dest.id)}>
<span class="flex items-center gap-1.5"><DIcon class="w-3.5 h-3.5 text-muted-foreground" />{dest.name}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<CronEditor value={batchSchedule} onchange={(v) => batchSchedule = v} />
</div>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" class="text-xs" onclick={() => showBatch = false}>Cancel</Button>
<Button size="sm" disabled={!batchDestId || batchSaving} onclick={batchApply}>
{#if batchSaving}<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />{/if}
Schedule {unconfiguredLabel}
</Button>
</div>
</div>
{/if}
{/if}
<div class="space-y-0 max-h-[440px] overflow-y-auto">
{#each items as item}
{@const itemConfigs = getItemConfigs(item.name)}
{@const hasBackup = itemConfigs.length > 0}
{@const isExpanded = expandedItem === item.name}
<div class="border-b last:border-0">
<!-- Row. External (untracked) stacks aren't backup-able: shown disabled,
no expand, a hint to adopt instead of a "configure" action. -->
<svelte:element
this={item.external ? 'div' : 'button'}
role={item.external ? undefined : 'button'}
class="flex items-center gap-2 w-full py-2 px-1 text-left rounded-sm {item.external ? 'opacity-60' : 'hover:bg-muted/30'}"
onclick={item.external ? undefined : () => toggleExpand(item.name)}
>
{#if item.external}
<span class="w-3.5 flex-shrink-0"></span>
{:else if isExpanded}
<ChevronDown class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
{:else}
<ChevronRight class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
{/if}
{#if item.type === 'stack'}
<Layers class="w-3.5 h-3.5 text-purple-500 flex-shrink-0" />
{:else}
<Box class="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />
{/if}
<span class="flex min-w-0 flex-1 items-center gap-1.5">
<span class="truncate text-sm">{item.name}</span>
{#if item.git}
<span class="inline-flex flex-shrink-0 items-center gap-1 rounded-full border border-purple-500/30 bg-purple-500/10 px-1.5 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400" title="Git-deployed stack"><GitBranch class="h-2.5 w-2.5" />git</span>
{/if}
{#if item.external}
<span class="inline-flex flex-shrink-0 items-center rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground" title="Untracked stack — can't be backed up until adopted">external</span>
{/if}
</span>
{#if item.external}
<span class="text-xs text-muted-foreground/60 flex-shrink-0" title="This stack is untracked — Dockhand doesn't know its compose file location, so it can't be backed up. Adopt it (Stacks → Adopt) first.">adopt first</span>
{:else if hasBackup}
<ArrowRight class="w-3 h-3 text-muted-foreground/40 flex-shrink-0" />
{#if itemConfigs.length === 1}
{@const cfg = itemConfigs[0]}
{@const dest = destinations.get(cfg.destinationId)}
<span class="flex items-center gap-1 text-xs text-muted-foreground flex-shrink-0">
{#if dest}
{@const DestIcon = getRepoTypeIcon(dest.repository)}
<DestIcon class="w-3 h-3 text-primary/60" />
{dest.name}
{/if}
{cfg.schedule ? formatCron(cfg.schedule) : 'manual'}
</span>
{:else}
<span class="text-xs text-muted-foreground flex-shrink-0">{itemConfigs.length} schedules</span>
{/if}
{:else}
<span class="text-xs text-muted-foreground/50 flex items-center gap-1 flex-shrink-0">
<Plus class="w-3 h-3" />configure
</span>
{/if}
</svelte:element>
<!-- Expanded: BackupPanel (never for external stacks — see the row above) -->
{#if isExpanded && !item.external}
<div class="ml-6 mr-1 mb-3 mt-1 p-3 border rounded-md bg-muted/20">
<BackupPanel
containerName={item.name}
volumes={item.volumes}
type={item.type}
environmentId={environmentId}
onConfigSaved={refreshConfigs}
/>
</div>
{/if}
</div>
{/each}
</div>
{/if}
@@ -62,6 +62,7 @@
ChevronRight,
XCircle,
ImageUp,
Archive,
Upload,
ArrowRight
} from 'lucide-svelte';
@@ -76,6 +77,8 @@
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { whale } from '@lucide/lab';
import ImagePullProgressPopover from '../../images/ImagePullProgressPopover.svelte';
import EnvironmentBackupsTab from './EnvironmentBackupsTab.svelte';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill';
import { ShieldOff } from 'lucide-svelte';
import { focusFirstInput } from '$lib/utils';
@@ -214,6 +217,16 @@
{ id: 'vulnerability_any', label: 'Any vulns found', description: 'Any vulnerabilities found (medium/low)' }
]
},
{
id: 'backup',
label: 'Backup events',
events: [
{ id: 'backup_success', label: 'Backup succeeded', description: 'Backup completed successfully' },
{ id: 'backup_failed', label: 'Backup failed', description: 'Backup failed' },
{ id: 'restore_success', label: 'Restore succeeded', description: 'Restore completed successfully' },
{ id: 'restore_failed', label: 'Restore failed', description: 'Restore failed' }
]
},
{
id: 'system',
label: 'System events',
@@ -1545,7 +1558,7 @@
</script>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) focusFirstInput(); else onClose(); }}>
<Dialog.Content class="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Header class="flex-shrink-0 border-b pb-4">
<Dialog.Title class="flex items-center gap-2">
{#if !isEditing}
@@ -1564,7 +1577,7 @@
{/if}
<Tabs.Root bind:value={modalTab} class="flex-1 flex flex-col overflow-hidden mt-4">
<Tabs.List class="flex-shrink-0 mb-0 w-full grid grid-cols-5">
<Tabs.List class="flex-shrink-0 mb-0 w-full grid grid-cols-6">
<Tabs.Trigger value="general" class="flex items-center justify-center gap-1.5">
<Globe class="w-3.5 h-3.5" />
General
@@ -1581,6 +1594,13 @@
<ShieldCheck class="w-3.5 h-3.5" />
Security
</Tabs.Trigger>
<!-- BETA GATE: Backups tab hidden unless FEAT_BACKUPS_ENABLED (see features.ts) -->
{#if $page.data.backupsEnabled}
<Tabs.Trigger value="backup" class="flex items-center justify-center gap-1.5">
<Archive class="w-3.5 h-3.5" />
Backups
</Tabs.Trigger>
{/if}
<Tabs.Trigger value="notifications" class="flex items-center justify-center gap-1.5">
<Bell class="w-3.5 h-3.5" />
Notifications
@@ -2705,6 +2725,20 @@
</Tabs.Content>
<!-- Notifications Tab -->
<!-- Backup Tab -->
<Tabs.Content value="backup" class="mt-0 h-full">
{#if environment?.id}
<EnvironmentBackupsTab
environmentId={environment.id}
environmentName={environment.name}
connectionType={environment.connectionType}
host={environment.host}
/>
{:else}
<p class="text-sm text-muted-foreground py-4">Save the environment first to configure backups.</p>
{/if}
</Tabs.Content>
<Tabs.Content value="notifications" class="mt-0 h-full flex flex-col">
<div class="flex items-center gap-2 text-sm font-medium flex-shrink-0">
<Bell class="w-4 h-4" />
@@ -7,9 +7,11 @@
Layers,
Shield,
HardDrive,
Archive,
ChevronDown,
ChevronRight
} from 'lucide-svelte';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
interface EventType {
id: string;
@@ -103,6 +105,17 @@
{ id: 'vulnerability_any', label: 'Any vulns found', description: 'Any vulnerabilities found (medium/low)' }
]
},
{
id: 'backup',
label: 'Backup events',
icon: Archive,
events: [
{ id: 'backup_success', label: 'Backup succeeded', description: 'Backup completed successfully' },
{ id: 'backup_failed', label: 'Backup failed', description: 'Backup failed' },
{ id: 'restore_success', label: 'Restore succeeded', description: 'Restore completed successfully' },
{ id: 'restore_failed', label: 'Restore failed', description: 'Restore failed' }
]
},
{
id: 'system',
label: 'System events',
@@ -151,7 +164,8 @@
</script>
<div class="space-y-2 max-h-[300px] overflow-y-auto pr-1">
{#each NOTIFICATION_EVENT_GROUPS as group (group.id)}
<!-- BETA GATE: drop the Backup events group unless FEAT_BACKUPS_ENABLED (see features.ts) -->
{#each NOTIFICATION_EVENT_GROUPS.filter((g) => g.id !== 'backup' || $page.data.backupsEnabled) as group (group.id)}
{@const isCollapsed = collapsedGroups.has(group.id)}
{@const selectedCount = getGroupSelectedCount(group)}
{@const allSelected = selectedCount === group.events.length}
+16 -5
View File
@@ -8,7 +8,7 @@
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, ChevronRight, ChevronDown } from 'lucide-svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, Archive, ChevronRight, ChevronDown } from 'lucide-svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode, type LabelFilterMode } from '$lib/stores/settings';
import { canAccess, authStore } from '$lib/stores/auth';
@@ -91,6 +91,7 @@ services:
let eventCollectionMode = $derived($appSettings.eventCollectionMode);
let eventPollInterval = $derived($appSettings.eventPollInterval);
let metricsCollectionInterval = $derived($appSettings.metricsCollectionInterval);
let defaultBackupImage = $derived($appSettings.defaultBackupImage);
let clearingCache = $state(false);
@@ -269,6 +270,14 @@ services:
toast.success(`Metrics interval: ${selected.value / 1000}s`);
}
}
function handleBackupImageBlur(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (value && value !== defaultBackupImage) {
appSettings.setDefaultBackupImage(value);
toast.success('Backup image updated');
}
}
</script>
<div class="flex-1 min-h-0 overflow-y-auto">
@@ -970,15 +979,17 @@ services:
onchange={handleScannerCleanupEnabledChange}
disabled={!$canAccess('settings', 'edit')}
/>
<div class="ml-auto">
</div>
<p class="text-xs text-muted-foreground">Remove cached vulnerability databases to reclaim disk space</p>
{#if scannerCleanupEnabled}
<div class="mt-2">
<CronEditor
value={scannerCleanupCron}
onchange={handleScannerCleanupCronChange}
disabled={!$canAccess('settings', 'edit') || !scannerCleanupEnabled}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
</div>
<p class="text-xs text-muted-foreground">Remove cached vulnerability databases to reclaim disk space</p>
{/if}
</div>
<div class="space-y-1 pt-2 border-t">
<div class="flex items-center gap-3">
@@ -6,15 +6,21 @@
import { Input } from '$lib/components/ui/input';
import { Badge } from '$lib/components/ui/badge';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Plus, Check, RefreshCw, Mail, Zap, Info, Send, CheckCircle2, XCircle, Key, ChevronDown, HelpCircle } from 'lucide-svelte';
import { Plus, Check, RefreshCw, Mail, Zap, Send, CheckCircle2, XCircle, Bell, HelpCircle, Settings } from 'lucide-svelte';
import * as Tabs from '$lib/components/ui/tabs';
import * as Tooltip from '$lib/components/ui/tooltip';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
// System-only events (configured at channel level, not per-environment)
const SYSTEM_EVENTS = [
{ id: 'license_expiring', label: 'License expiring', description: 'Enterprise license expiring soon' }
{ id: 'license_expiring', label: 'License expiring', description: 'Enterprise license expiring soon' },
{ id: 'repo_prune_success', label: 'Backup repository prune success', description: 'Scheduled repository prune completed successfully' },
{ id: 'repo_prune_failed', label: 'Backup repository prune failed', description: 'Scheduled repository prune failed' },
{ id: 'repo_check_success', label: 'Backup repository check success', description: 'Scheduled integrity check completed successfully' },
{ id: 'repo_check_failed', label: 'Backup repository check failed', description: 'Scheduled integrity check found errors or failed' },
{ id: 'repo_verify_success', label: 'Backup repository data verification success', description: 'Scheduled data verification completed successfully' },
{ id: 'repo_verify_failed', label: 'Backup repository data verification failed', description: 'Scheduled data verification found corruption or failed' }
] as const;
export interface NotificationSetting {
@@ -56,7 +62,7 @@
let formAppriseUrls = $state('');
// System events
let formSystemEvents = $state<string[]>([]);
let showSystemEvents = $state(false);
let activeTab = $state<'channel' | 'events'>('channel');
let formError = $state('');
let formSaving = $state(false);
let formTesting = $state(false);
@@ -78,7 +84,7 @@
formSmtpToEmails = '';
formAppriseUrls = '';
formSystemEvents = [];
showSystemEvents = false;
activeTab = 'channel';
formError = '';
formSaving = false;
formTesting = false;
@@ -114,7 +120,7 @@
// Load system events (filter to only system-scoped events)
const systemEventIds = SYSTEM_EVENTS.map(e => e.id);
formSystemEvents = (notification.eventTypes || []).filter(e => systemEventIds.includes(e as typeof SYSTEM_EVENTS[number]['id']));
showSystemEvents = formSystemEvents.length > 0;
activeTab = 'channel';
formError = '';
formSaving = false;
@@ -293,15 +299,32 @@
</script>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { formError = ''; focusFirstInput(); } }}>
<Dialog.Content class="max-w-3xl max-h-[90vh] overflow-y-auto">
<Dialog.Content class="max-w-3xl max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title>{isEditing ? 'Edit' : 'Add'} notification channel</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
{#if formError}
<div class="text-sm text-red-600 dark:text-red-400">{formError}</div>
{/if}
{#if formError}
<div class="text-sm text-red-600 dark:text-red-400">{formError}</div>
{/if}
<Tabs.Root bind:value={activeTab} class="flex-1 flex flex-col overflow-hidden mt-2">
<Tabs.List class="flex-shrink-0 mb-0 w-full grid grid-cols-2">
<Tabs.Trigger value="channel" class="flex items-center justify-center gap-1.5">
<Settings class="w-3.5 h-3.5" />
Channel
</Tabs.Trigger>
<Tabs.Trigger value="events" class="flex items-center justify-center gap-1.5">
<Bell class="w-3.5 h-3.5" />
System events
{#if formSystemEvents.length > 0}
<Badge variant="secondary" class="ml-1 h-4 px-1.5 text-[10px]">{formSystemEvents.length}</Badge>
{/if}
</Tabs.Trigger>
</Tabs.List>
<div class="overflow-y-auto pb-4 h-[520px]">
<Tabs.Content value="channel" class="space-y-4 mt-0">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="notif-name">Name *</Label>
@@ -442,48 +465,31 @@ jsons://hostname/webhook/path"
</div>
{/if}
<!-- System events configuration -->
<div class="border-t pt-4">
<button
type="button"
class="w-full flex items-center justify-between text-left"
onclick={() => showSystemEvents = !showSystemEvents}
>
<div class="flex items-center gap-2">
<Key class="w-4 h-4 text-muted-foreground" />
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Global system events</span>
</div>
<ChevronDown class="w-4 h-4 text-muted-foreground transition-transform {showSystemEvents ? 'rotate-180' : ''}" />
</button>
{#if showSystemEvents}
<div class="mt-3 space-y-2">
<p class="text-xs text-muted-foreground mb-3">
These events are not tied to specific environments and are configured globally here.
</p>
</Tabs.Content>
<Tabs.Content value="events" class="space-y-3 mt-0">
<p class="text-xs text-muted-foreground">
These events are not tied to specific environments and are configured globally here.
Environment-specific events (containers, stacks, auto-updates) are configured in each environment's settings.
</p>
<div class="space-y-1">
{#each SYSTEM_EVENTS as event}
<label class="flex items-start gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
<Checkbox
<div class="flex items-center gap-3 p-2 rounded hover:bg-muted/50">
<TogglePill
checked={formSystemEvents.includes(event.id)}
onCheckedChange={(checked) => toggleSystemEvent(event.id, !!checked)}
onchange={() => toggleSystemEvent(event.id, !formSystemEvents.includes(event.id))}
/>
<div class="flex-1 min-w-0">
<span class="text-sm font-medium">{event.label}</span>
<p class="text-xs text-muted-foreground">{event.description}</p>
</div>
</label>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
</div>
</Tabs.Root>
<!-- Info about per-env config -->
<div class="border-t pt-4">
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md p-3 flex items-start gap-2">
<Info class="w-4 h-4 mt-0.5 shrink-0" />
<span>Environment-specific events (containers, stacks, auto-updates) are configured in each environment's settings.</span>
</div>
</div>
</div>
<Dialog.Footer class="flex justify-between sm:justify-between">
<Button variant="outline" onclick={testConfig} disabled={formTesting || formSaving}>
{#if formTesting}
+71 -8
View File
@@ -13,7 +13,6 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Popover from '$lib/components/ui/popover';
import { formatBytes } from '$lib/utils/format';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical, Globe, CircleArrowUp, NotepadText } from 'lucide-svelte';
import { formatPorts } from '$lib/utils/port-format';
@@ -45,11 +44,16 @@
import type { DataGridSortState } from '$lib/components/data-grid/types';
import { ErrorDialog } from '$lib/components/ui/error-dialog';
import { formatHostPortUrl } from '$lib/utils/url';
import { formatBytes, formatBytesCompact } from '$lib/utils/format';
type SortField = 'name' | 'containers' | 'status' | 'cpu' | 'memory';
type SortDirection = 'asc' | 'desc';
let stacks = $state<ComposeStackInfo[]>([]);
// Container IDs whose last update check failed this session (e.g. registry
// rate-limited), with the error text for the tooltip — session-only (#1255).
let failedUpdateCheckIds = $state<Set<string>>(new Set());
let failedUpdateCheckErrors = $state<Map<string, string>>(new Map());
let stackSources = $state<Record<string, { sourceType: string; composePath?: string | null; repository?: any; gitStack?: any }>>({});
let stackEnvVarCounts = $state<Record<string, number>>({});
let gitStacks = $state<any[]>([]);
@@ -751,6 +755,30 @@
}
});
// True when any stack container shows an update-available (amber) or
// check-failed (red) indicator — gates the "dismiss indicators" button.
const hasUpdateIndicators = $derived(
stacks.some((s) => s.updatesAvailable) || failedUpdateCheckIds.size > 0
);
// Dismiss both the amber "update available" and red "check failed" indicators.
// Update-available lives in the shared pending_container_updates table (same
// one the containers page clears); failed-check state is session-only.
async function dismissStackUpdates() {
const id = $currentEnvironment?.id;
if (!id) return;
try {
const response = await fetch(`/api/containers/pending-updates?env=${id}`, { method: 'DELETE' });
if (response.ok) {
failedUpdateCheckIds = new Set();
failedUpdateCheckErrors = new Map();
await fetchStacks();
}
} catch {
toast.error('Failed to clear update indicators');
}
}
async function fetchStacks() {
// Show loading skeleton on initial load or when environment changes, but not on refreshes
if (lastLoadedEnvId !== envId) {
@@ -1413,8 +1441,27 @@
<CheckUpdatesButton
{envId}
hasPendingUpdates={stacks.some((s) => s.updatesAvailable)}
onComplete={() => fetchStacks()}
onComplete={(result) => {
// Record failed checks so stack containers show a "check failed" state
// instead of masquerading as up to date (#1255). Session-only.
const failed = result.failed ?? [];
failedUpdateCheckIds = new Set(failed.map((f) => f.containerId));
failedUpdateCheckErrors = new Map(failed.map((f) => [f.containerId, f.error]));
fetchStacks();
}}
/>
{#if hasUpdateIndicators}
<Button
size="sm"
variant="ghost"
onclick={dismissStackUpdates}
class="h-8 gap-1 text-muted-foreground hover:text-destructive"
title="Dismiss all update indicators"
>
<X class="w-3.5 h-3.5" />
Clear
</Button>
{/if}
<Button
size="sm"
variant="outline"
@@ -1802,7 +1849,7 @@
{@const stats = getStackStats(stack)}
<div class="text-right">
{#if stats}
<span class="text-xs font-mono text-muted-foreground" title="{formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)}">{formatBytes(stats.memoryUsage)}<span class="text-muted-foreground/50">/{formatBytes(stats.memoryLimit, 0)}</span></span>
<span class="text-xs font-mono text-muted-foreground" title="{formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)}">{formatBytesCompact(stats.memoryUsage)}<span class="text-muted-foreground/50">/{formatBytesCompact(stats.memoryLimit, 0)}</span></span>
{:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'}
<span class="text-xs text-muted-foreground/50">...</span>
{:else}
@@ -1814,7 +1861,7 @@
<div class="text-right whitespace-nowrap">
{#if stats}
<span class="text-xs font-mono text-muted-foreground" title="↓{formatBytes(stats.networkRx)} received / ↑{formatBytes(stats.networkTx)} sent">
<span class="text-2xs text-blue-400">↓</span>{formatBytes(stats.networkRx, 0)} <span class="text-2xs text-orange-400">↑</span>{formatBytes(stats.networkTx, 0)}
<span class="text-2xs text-blue-400">↓</span>{formatBytesCompact(stats.networkRx, 0)} <span class="text-2xs text-orange-400">↑</span>{formatBytesCompact(stats.networkTx, 0)}
</span>
{:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'}
<span class="text-xs text-muted-foreground/50">...</span>
@@ -1827,7 +1874,7 @@
<div class="text-right whitespace-nowrap">
{#if stats}
<span class="text-xs font-mono text-muted-foreground" title="↓{formatBytes(stats.blockRead)} read / ↑{formatBytes(stats.blockWrite)} written">
<span class="text-2xs text-green-400">r</span>{formatBytes(stats.blockRead, 0)} <span class="text-2xs text-yellow-400">w</span>{formatBytes(stats.blockWrite, 0)}
<span class="text-2xs text-green-400">r</span>{formatBytesCompact(stats.blockRead, 0)} <span class="text-2xs text-yellow-400">w</span>{formatBytesCompact(stats.blockWrite, 0)}
</span>
{:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'}
<span class="text-xs text-muted-foreground/50">...</span>
@@ -2098,6 +2145,22 @@
{/if}
{/if}
</span>
{:else if failedUpdateCheckIds.has(container.id)}
<Tooltip.Root>
<Tooltip.Trigger>
<AlertTriangle class="w-3.5 h-3.5 shrink-0 text-red-500 cursor-help" />
</Tooltip.Trigger>
<Tooltip.Content side="right" class="w-72 p-3">
<div class="space-y-1.5">
<p class="font-medium text-sm flex items-center gap-1.5 whitespace-nowrap">
<AlertTriangle class="w-4 h-4 text-red-500 shrink-0" />
Update check failed
</p>
<p class="text-muted-foreground text-xs break-words">{failedUpdateCheckErrors.get(container.id) ?? 'Could not query registry'}</p>
<p class="text-muted-foreground text-xs">Update status unknown — often a Docker Hub rate limit. Try again later.</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<span class="flex-1"></span>
{#if container.health}
@@ -2153,7 +2216,7 @@
<div class="space-y-0">
<div class="flex justify-between text-2xs">
<span class="text-muted-foreground">Mem</span>
<span class="font-mono text-muted-foreground">{stats ? formatBytes(stats.memoryUsage) : '-'}</span>
<span class="font-mono text-muted-foreground">{stats ? formatBytesCompact(stats.memoryUsage) : '-'}</span>
</div>
{#if history?.mem && history.mem.length >= 2}
<svg class="w-full h-4" viewBox="0 0 60 16" preserveAspectRatio="none">
@@ -2168,7 +2231,7 @@
<div class="space-y-0">
<div class="flex justify-between text-2xs">
<span class="text-muted-foreground">Net</span>
<span class="font-mono text-muted-foreground">{stats ? formatBytes(stats.networkRx + stats.networkTx) : '-'}</span>
<span class="font-mono text-muted-foreground">{stats ? formatBytesCompact(stats.networkRx + stats.networkTx) : '-'}</span>
</div>
{#if history?.netRx && history.netRx.length >= 2}
<svg class="w-full h-4" viewBox="0 0 60 16" preserveAspectRatio="none">
@@ -2183,7 +2246,7 @@
<div class="space-y-0">
<div class="flex justify-between text-2xs">
<span class="text-muted-foreground">Disk</span>
<span class="font-mono text-muted-foreground">{stats ? formatBytes(stats.blockRead + stats.blockWrite) : '-'}</span>
<span class="font-mono text-muted-foreground">{stats ? formatBytesCompact(stats.blockRead + stats.blockWrite) : '-'}</span>
</div>
{#if history?.diskR && history.diskR.length >= 2}
<svg class="w-full h-4" viewBox="0 0 60 16" preserveAspectRatio="none">
+2 -1
View File
@@ -320,7 +320,7 @@
>
<ArrowUp class="w-4 h-4" />
</button>
<code class="text-xs bg-muted px-2 py-1 rounded truncate flex-1">{currentPath || '/'}</code>
<code class="text-xs bg-muted px-2 py-1 rounded truncate flex-1 min-w-0">{currentPath || '/'}</code>
{#if creatingFolder}
<div class="flex items-center gap-1">
<Input
@@ -367,6 +367,7 @@
size="sm"
onclick={handleScan}
disabled={scanning || !currentPath}
class="shrink-0 gap-1.5 whitespace-nowrap"
>
{#if scanning}
<Loader2 class="w-4 h-4 animate-spin" />
+126 -32
View File
@@ -7,8 +7,12 @@
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert } from 'lucide-svelte';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert, Settings2, Archive } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
import BackupPanel from '../containers/BackupPanel.svelte';
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
import { fetchBackupExecutions } from '$lib/utils/backup';
import { copyToClipboard } from '$lib/utils/clipboard';
import CronEditor from '$lib/components/cron-editor.svelte';
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
@@ -85,6 +89,49 @@
let formNewRepoBranch = $state('main');
let formNewRepoCredentialId = $state<number | null>(null);
// Tabs: Settings (the deploy form) and Backups (edit mode + feature flag only).
let activeTab = $state<'settings' | 'backups'>('settings');
// The stack's volumes, loaded lazily when the Backups tab opens (git stacks
// don't otherwise need them). Feeds the backup volume picker.
let stackVolumes = $state<VolumeInfo[]>([]);
let stackVolumesLoaded = $state(false);
// Ok/fail run tally shown on the Backups tab (edit mode only).
let backupTally = $state<{ ok: number; failed: number }>({ ok: 0, failed: 0 });
let backupTallyLoaded = $state(false);
const effectiveEnvId = $derived(gitStack?.environmentId ?? environmentId ?? null);
async function loadBackupTally() {
if (backupTallyLoaded || !gitStack) return;
backupTallyLoaded = true;
try {
const p = new URLSearchParams({ target: formStackName, type: 'stack' });
if (effectiveEnvId != null) p.set('env', String(effectiveEnvId));
const res = await fetch(`/api/backup/configs?${p}`);
if (!res.ok) return;
const data = await res.json();
const cfgs = Array.isArray(data) ? data : data?.id ? [data] : [];
if (cfgs.length > 0) {
const t = await fetchBackupExecutions(cfgs.map((c: any) => c.id));
backupTally = { ok: t.ok, failed: t.failed };
}
} catch { /* tally is best-effort */ }
}
async function loadStackVolumes() {
if (stackVolumesLoaded) return;
stackVolumesLoaded = true;
try {
const url = effectiveEnvId != null ? `/api/containers?env=${effectiveEnvId}` : '/api/containers';
const res = await fetch(url);
if (res.ok) stackVolumes = volumesForStack(await res.json(), formStackName);
} catch { /* picker just shows "no volumes" — backup still defaults to all */ }
}
// Load volumes the first time the Backups tab is opened.
$effect(() => {
if (activeTab === 'backups') void loadStackVolumes();
});
// Form state - stack deployment config
let formStackName = $state('');
let formStackNameUserModified = $state(false);
@@ -104,8 +151,9 @@
let showExistsWarning = $state(false);
let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string }>({});
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
// Stack name validation: Docker Compose requires lowercase; must start with a
// letter or number, and contain only lowercase letters, numbers, hyphens, underscores
const STACK_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/;
let copiedWebhookUrl = $state<'ok' | 'error' | null>(null);
let copiedWebhookSecret = $state<'ok' | 'error' | null>(null);
@@ -350,6 +398,11 @@
async function resetForm() {
// Clear state BEFORE async loads to avoid race conditions
activeTab = 'settings';
stackVolumes = [];
stackVolumesLoaded = false;
backupTally = { ok: 0, failed: 0 };
backupTallyLoaded = false;
formError = '';
errors = {};
copiedWebhookUrl = null;
@@ -363,6 +416,7 @@
formRepoMode = 'existing';
formRepositoryId = gitStack.repositoryId;
formStackName = gitStack.stackName;
if ($page.data.backupsEnabled) void loadBackupTally();
formComposePath = gitStack.composePath;
formEnvFilePath = gitStack.envFilePath;
formAutoUpdate = gitStack.autoUpdate;
@@ -416,7 +470,7 @@
errors.stackName = 'Stack name is required';
hasErrors = true;
} else if (!STACK_NAME_REGEX.test(trimmedStackName)) {
errors.stackName = 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores';
errors.stackName = 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores';
hasErrors = true;
}
@@ -600,6 +654,41 @@
</div>
</Dialog.Header>
<!-- Tabs: Settings (deploy form) + Backups. Backups only in edit mode AND when
the backups feature flag is on (BETA GATE) — a git stack must exist before
it can be backed up, and the feature must be enabled. -->
{#if gitStack && $page.data.backupsEnabled}
<div class="flex items-center gap-1 border-b border-zinc-200 px-5 dark:border-zinc-700 flex-shrink-0">
<button
type="button"
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'settings' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (activeTab = 'settings')}
>
<Settings2 class="h-3.5 w-3.5" /> Settings
</button>
<button
type="button"
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'backups' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (activeTab = 'backups')}
>
<Archive class="h-3.5 w-3.5" /> Backups
{#if backupTally.ok > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-medium text-emerald-500"><Check class="w-2.5 h-2.5" />{backupTally.ok}</span>{/if}
{#if backupTally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="w-2.5 h-2.5" />{backupTally.failed}</span>{/if}
</button>
</div>
{/if}
{#if activeTab === 'backups' && gitStack && $page.data.backupsEnabled}
<div class="min-h-0 flex-1 overflow-auto p-5">
<BackupPanel
containerName={formStackName}
volumes={stackVolumes}
type="stack"
environmentId={effectiveEnvId ?? undefined}
onTally={(t) => (backupTally = t)}
/>
</div>
{:else}
<div bind:this={containerRef} class="flex-1 min-h-0 flex {isDraggingSplit ? 'select-none' : ''}">
<!-- Left column: Form fields -->
<div class="flex-shrink-0 flex flex-col min-w-0 overflow-y-auto" style="width: {splitRatio}%">
@@ -1095,36 +1184,41 @@
</StackEnvVarsPanel>
</div>
</div>
{/if}
<Dialog.Footer class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<Button variant="outline" onclick={onClose}>Cancel</Button>
{#if gitStack}
<Button variant="outline" onclick={() => saveGitStack(true)} disabled={formSaving}>
{#if formSaving}
<Loader2 class="w-4 h-4 mr-1 animate-spin" />
Deploying...
{:else}
<Rocket class="w-4 h-4" />
Save and deploy
{/if}
</Button>
<Button onclick={() => saveGitStack(false)} disabled={formSaving}>
{#if formSaving}
<Loader2 class="w-4 h-4 mr-1 animate-spin" />
Saving...
{:else}
Save changes
{/if}
</Button>
{:else}
<Button onclick={() => saveGitStack(formDeployNow)} disabled={formSaving}>
{#if formSaving}
<Loader2 class="w-4 h-4 mr-1 animate-spin" />
{formDeployNow ? 'Deploying...' : 'Creating...'}
{:else}
{formDeployNow ? 'Deploy' : 'Create'}
{/if}
</Button>
<Button variant="outline" onclick={onClose}>{activeTab === 'backups' ? 'Close' : 'Cancel'}</Button>
<!-- The deploy-form save buttons belong to the Settings tab. On the Backups
tab the backup panel manages its own saving, so only Close is shown. -->
{#if activeTab !== 'backups'}
{#if gitStack}
<Button variant="outline" onclick={() => saveGitStack(true)} disabled={formSaving}>
{#if formSaving}
<Loader2 class="w-4 h-4 mr-1 animate-spin" />
Deploying...
{:else}
<Rocket class="w-4 h-4" />
Save and deploy
{/if}
</Button>
<Button onclick={() => saveGitStack(false)} disabled={formSaving}>
{#if formSaving}
<Loader2 class="w-4 h-4 mr-1 animate-spin" />
Saving...
{:else}
Save changes
{/if}
</Button>
{:else}
<Button onclick={() => saveGitStack(formDeployNow)} disabled={formSaving}>
{#if formSaving}
<Loader2 class="w-4 h-4 mr-1 animate-spin" />
{formDeployNow ? 'Deploying...' : 'Creating...'}
{:else}
{formDeployNow ? 'Deploy' : 'Create'}
{/if}
</Button>
{/if}
{/if}
</Dialog.Footer>
</Dialog.Content>
+127 -49
View File
@@ -7,7 +7,10 @@
import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte';
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync } from 'lucide-svelte';
import { Layers, Save, Play, Code, GitGraph, GitBranch, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive } from 'lucide-svelte';
import BackupPanel from '../containers/BackupPanel.svelte';
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
import { fetchBackupExecutions } from '$lib/utils/backup';
import type { Component } from 'svelte';
import FilesystemBrowser from './FilesystemBrowser.svelte';
import PathBarItem from './PathBarItem.svelte';
@@ -16,6 +19,7 @@
import { Badge } from '$lib/components/ui/badge';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { appSettings } from '$lib/stores/settings';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
import { focusFirstInput } from '$lib/utils';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Alert from '$lib/components/ui/alert';
@@ -57,9 +61,13 @@
let loadError = $state<string | null>(null);
let errors = $state<{ stackName?: string; compose?: string }>({});
let composeContent = $state('');
let activeTab = $state<'editor' | 'graph'>('editor');
let activeTab = $state<'editor' | 'graph' | 'backups'>('editor');
let backupCount = $state(0);
let backupTally = $state<{ ok: number; failed: number }>({ ok: 0, failed: 0 });
let showConfirmClose = $state(false);
let editorTheme = $state<'light' | 'dark'>('dark');
// Ref to the embedded backup panel so close can check its inline form for unsaved edits.
let backupPanelRef = $state<BackupPanel | undefined>(undefined);
// Environment variables state
let envVars = $state<EnvVar[]>([]);
@@ -106,6 +114,8 @@
// Container info for untracked stacks
let stackContainers = $state<{ name: string; state: string; image: string }[]>([]);
// Volumes/binds of this stack's containers, for the backup panel picker.
let stackVolumes = $state<VolumeInfo[]>([]);
// Derived: has user customized the compose path from auto-computed default?
const isComposePathCustom = $derived(
@@ -712,6 +722,16 @@
}
}
// Populate the backup picker's volume/bind list from this stack's containers'
// mounts. Runs for BOTH the managed (internal) load path and the
// needs-file-location path, so an internal stack's backup panel is never empty.
async function loadStackVolumes(envId: number | null) {
const contRes = await fetch(appendEnvParam('/api/containers', envId));
if (contRes.ok) {
stackVolumes = volumesForStack(await contRes.json(), stackName);
}
}
async function loadComposeFile() {
if (mode !== 'edit' || !stackName) return;
@@ -740,6 +760,22 @@
loadError = null;
loading = false; // Important: stop loading spinner
// Fetch backup schedule count (BETA GATE: only when backups enabled)
if ($page.data.backupsEnabled) try {
const bp = new URLSearchParams({ target: stackName, type: 'stack' });
if (envId) bp.set('env', String(envId));
const bRes = await fetch(`/api/backup/configs?${bp}`);
if (bRes.ok) {
const bData = await bRes.json();
const cfgs = Array.isArray(bData) ? bData : bData?.id ? [bData] : [];
backupCount = cfgs.length;
if (cfgs.length > 0) {
const t = await fetchBackupExecutions(cfgs.map((c: any) => c.id));
backupTally = { ok: t.ok, failed: t.failed };
}
}
} catch {}
// Fetch containers for this stack to show what's running
try {
const stacksRes = await fetch(appendEnvParam('/api/stacks', envId));
@@ -754,6 +790,10 @@
}));
}
}
// Volumes/binds for the backup picker — derived from this stack's
// containers' mounts (same normalizer used by the other backup surfaces).
await loadStackVolumes(envId);
} catch (e) {
console.error('Failed to fetch stack containers:', e);
}
@@ -770,6 +810,13 @@
originalComposePath = data.composePath || null;
originalEnvPath = data.envPath || null;
// Volumes/binds for the backup picker (managed/internal stack path).
try {
await loadStackVolumes(envId);
} catch (e) {
console.error('Failed to load stack volumes:', e);
}
// Load both env endpoints in parallel, then process results together
const [envResponse, rawEnvResponse] = await Promise.all([
fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId)),
@@ -1151,7 +1198,7 @@
}
function tryClose() {
if (isDirty) {
if (isDirty || backupPanelRef?.isDirty()) {
showConfirmClose = true;
} else {
handleClose();
@@ -1327,8 +1374,9 @@
if (isOpen) {
focusFirstInput();
} else {
// Prevent closing if there are unsaved changes - show confirmation instead
if (isDirty) {
// Prevent closing if there are unsaved changes (stack edits OR a half-edited
// backup schedule in the embedded panel) - show confirmation instead
if (isDirty || backupPanelRef?.isDirty()) {
// Re-open the dialog and show confirmation
open = true;
showConfirmClose = true;
@@ -1358,7 +1406,7 @@
{stackName}
{/if}
{#if $currentEnvironment}
<span class="font-medium">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
<span class="font-semibold">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
{/if}
</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
@@ -1374,51 +1422,51 @@
</div>
</div>
<div class="flex items-center gap-2">
<!-- View toggle -->
<div class="flex items-center gap-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-md p-0.5">
<button
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs transition-colors {activeTab === 'editor' ? 'bg-white dark:bg-zinc-900 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
onclick={() => activeTab = 'editor'}
>
<Code class="w-3.5 h-3.5" />
Editor
</button>
<button
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs transition-colors {activeTab === 'graph' ? 'bg-white dark:bg-zinc-900 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
onclick={() => activeTab = 'graph'}
>
<GitGraph class="w-3.5 h-3.5" />
Graph
</button>
</div>
<!-- Theme toggle (only in editor mode) -->
{#if activeTab === 'editor'}
<button
onclick={toggleEditorTheme}
class="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
title={editorTheme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'}
>
{#if editorTheme === 'light'}
<Moon class="w-4 h-4" />
{:else}
<Sun class="w-4 h-4" />
{/if}
</button>
{/if}
<!-- Close button -->
<button
onclick={tryClose}
class="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
>
<X class="w-4 h-4" />
</button>
</div>
<!-- Close button -->
<button
onclick={tryClose}
class="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
>
<X class="w-4 h-4" />
</button>
</div>
</Dialog.Header>
<!-- View tabs — left-aligned underline bar under the header, matched to
GitStackModal for a consistent look across the stack modals. -->
<div class="flex items-center gap-1 border-b border-zinc-200 px-5 dark:border-zinc-700 flex-shrink-0">
<button
type="button"
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'editor' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'editor'}
>
<Code class="h-3.5 w-3.5" /> Editor
</button>
<button
type="button"
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'graph' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'graph'}
>
<GitGraph class="h-3.5 w-3.5" /> Graph
</button>
<!-- BETA GATE: Backups tab hidden unless FEAT_BACKUPS_ENABLED (see features.ts).
Also hidden for UNTRACKED stacks: with no known compose file the backup
would be incomplete (can't redeploy at restore), so the backend refuses
it (assertStackBackupable) — don't offer it in the UI either. -->
{#if mode === 'edit' && $page.data.backupsEnabled && !needsFileLocation}
<button
type="button"
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'backups' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'backups'}
>
<Archive class="h-3.5 w-3.5" /> Backups
{#if backupCount > 0}<span class="bg-primary/15 text-primary text-[10px] px-1.5 rounded-full font-medium">{backupCount}</span>{/if}
{#if backupTally.ok > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-medium text-emerald-500"><Check class="w-2.5 h-2.5" />{backupTally.ok}</span>{/if}
{#if backupTally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="w-2.5 h-2.5" />{backupTally.failed}</span>{/if}
</button>
{/if}
</div>
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
{#if errors.compose}
<Alert.Root variant="destructive" class="mx-6 mt-4">
@@ -1487,7 +1535,12 @@
<div bind:this={containerRef} class="flex-1 min-h-0 flex flex-col {isDraggingSplit ? 'select-none' : ''}">
{#if activeTab === 'editor'}
<!-- Path bars row -->
<div class="flex border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/30">
<div class="flex items-center border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/30">
{#if readonly}
<span class="ml-4 flex shrink-0 items-center gap-1 rounded-full border border-purple-500/30 bg-purple-500/10 px-2 py-0.5 text-2xs font-medium text-purple-600 dark:text-purple-400" title="This is a Git-managed stack — Dockhand shows its compose read-only; edit it in the repository.">
<GitBranch class="h-3 w-3" /> Git · read-only
</span>
{/if}
<!-- Compose path -->
<div class="flex-shrink-0 px-4 py-2" style="width: {splitRatio}%">
<PathBarItem
@@ -1524,6 +1577,20 @@
}}
/>
</div>
<!-- Theme toggle -->
<div class="flex items-center px-2 shrink-0">
<button
onclick={toggleEditorTheme}
class="p-1 rounded text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
title={editorTheme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'}
>
{#if editorTheme === 'light'}
<Moon class="w-3.5 h-3.5" />
{:else}
<Sun class="w-3.5 h-3.5" />
{/if}
</button>
</div>
</div>
<!-- Editor panels row -->
<div class="flex-1 min-h-0 flex">
@@ -1636,6 +1703,17 @@
onContentChange={readonly ? undefined : handleGraphContentChange}
{readonly}
/>
{:else if activeTab === 'backups' && !needsFileLocation}
<!-- Backups tab (never for untracked stacks — see the tab gate above) -->
<div class="h-full flex-1 overflow-auto p-4">
<BackupPanel
bind:this={backupPanelRef}
containerName={stackName}
volumes={stackVolumes}
type="stack"
onTally={(t) => (backupTally = t)}
/>
</div>
{/if}
</div>
{/if}
+12 -11
View File
@@ -3,6 +3,7 @@
import { HardDrive, Lock, Container } from 'lucide-svelte';
import { Badge } from '$lib/components/ui/badge';
import FileBrowserPanel from '../containers/FileBrowserPanel.svelte';
import ModalHeader from '$lib/components/ModalHeader.svelte';
interface VolumeUsageInfo {
containerId: string;
@@ -50,18 +51,18 @@
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content class="max-w-4xl h-[90vh] sm:h-[80vh] flex flex-col">
<Dialog.Content class="max-w-4xl h-[90vh] sm:h-[80vh] flex flex-col" onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<HardDrive class="w-5 h-5" />
<span>Browse volume - {volumeName}</span>
{#if isInUse}
<Badge variant="secondary" class="flex items-center gap-1 ml-2">
<Lock class="w-3 h-3" />
<span>Read-only</span>
</Badge>
{/if}
</Dialog.Title>
<ModalHeader icon={HardDrive} title="Browse volume" name={volumeName}>
{#snippet extra()}
{#if isInUse}
<Badge variant="secondary" class="flex items-center gap-1">
<Lock class="w-3 h-3" />
<span>Read-only</span>
</Badge>
{/if}
{/snippet}
</ModalHeader>
<Dialog.Description>
{#if isInUse}
<span class="flex items-center gap-1.5 flex-wrap">
+29 -28
View File
@@ -6,6 +6,7 @@
import { Loader2, HardDrive, Layers } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import ModalHeader from '$lib/components/ModalHeader.svelte';
import { formatDateTime } from '$lib/stores/settings';
interface Props {
@@ -50,35 +51,35 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col">
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col" onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2 flex-wrap">
<HardDrive class="w-5 h-5" />
Volume details: <span class="text-muted-foreground font-normal break-all">{volumeName}</span>
{@const composeStack = volumeData?.Labels?.['com.docker.compose.project']}
{#if composeStack && !loading}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
onclick={() => {
open = false;
goto(appendEnvParam(`/stacks?search=${encodeURIComponent(composeStack)}`, $currentEnvironment?.id ?? null));
}}
class="cursor-pointer inline-flex items-center"
>
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors gap-1">
<Layers class="w-3 h-3" />
{composeStack}
</Badge>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-xs whitespace-nowrap">Open stack "{composeStack}"</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</Dialog.Title>
<ModalHeader icon={HardDrive} title="Volume" name={volumeName}>
{#snippet extra()}
{@const composeStack = volumeData?.Labels?.['com.docker.compose.project']}
{#if composeStack && !loading}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
onclick={() => {
open = false;
goto(appendEnvParam(`/stacks?search=${encodeURIComponent(composeStack)}`, $currentEnvironment?.id ?? null));
}}
class="cursor-pointer inline-flex items-center"
>
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors gap-1">
<Layers class="w-3 h-3" />
{composeStack}
</Badge>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-xs whitespace-nowrap">Open stack "{composeStack}"</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/snippet}
</ModalHeader>
</Dialog.Header>
<div class="flex-1 overflow-auto space-y-4 min-h-0">