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