S3 backup destinations take an AWS region with a region quick-pick

This commit is contained in:
jarek committed 2026-08-09 10:31:27 +02:00
1 parent 3d1b383ad7
commit 019bea222e
4 files changed
+315 -94

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
/**
* S3 region helpers for the backup destination form. Dependency-free so they can
* be unit-tested without the Svelte component.
*
* An AWS bucket lives in one region; the generic s3.amazonaws.com endpoint doesn't
* know which and returns 301. Selecting a region builds the regional endpoint AND
* passes AWS_DEFAULT_REGION, either of which lets restic reach the bucket.
*/
/** Sentinel value the region picker uses for "not an AWS region" (MinIO/Wasabi/etc). */
export const CUSTOM_REGION = '__custom__';
/** AWS regions offered by the S3 region picker. Selecting one auto-fills the regional
* endpoint; "Custom" leaves the endpoint editable for MinIO / Wasabi / other providers. */
export const AWS_REGIONS = [
'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2',
'eu-north-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-central-1', 'eu-central-2', 'eu-south-1', 'eu-south-2',
'ca-central-1', 'sa-east-1',
'ap-south-1', 'ap-southeast-1', 'ap-southeast-2', 'ap-southeast-3', 'ap-northeast-1', 'ap-northeast-2', 'ap-northeast-3', 'ap-east-1',
'me-south-1', 'me-central-1', 'af-south-1'
];
/** The regional S3 endpoint host for an AWS region, e.g. s3.eu-north-1.amazonaws.com. */
export function regionalEndpoint(region: string): string {
return `s3.${region}.amazonaws.com`;
}
/**
* Recover the AWS region from an endpoint host so the picker can show it on edit.
* Matches s3.<region>.amazonaws.com and s3-<region>.amazonaws.com (older form).
* Returns '' for the generic endpoint or any non-AWS host (MinIO, Wasabi, ...).
*/
export function extractS3Region(host: string): string {
const m = host.match(/^s3[.-]([a-z0-9-]+)\.amazonaws\.com$/i);
// s3.amazonaws.com itself has no region segment (the capture would be undefined).
return m && m[1] ? m[1] : '';
}
/** True when a picker value is a real region (not the custom sentinel or empty). */
export function isConcreteRegion(region: string | undefined | null): boolean {
return !!region && region !== CUSTOM_REGION;
}
+17 -10
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackupDestination } from '$lib/server/audit';
import {
getBackupDestinations,
@@ -11,7 +12,7 @@ import {
} 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';
import { validateRepositoryForSave, validateAndSerializeFlags, validatePolicySchedules } from '$lib/server/backups/helpers';
/**
* Prepare destination for API response — strip password, parse env vars.
@@ -37,9 +38,8 @@ function prepareDestination(dest: any, opts: { includeEnvVars: boolean }): any {
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 denied = await requireBackups(auth, 'view');
if (denied) return denied;
const destinations = await getBackupDestinations();
// LIST: strip envVars (cloud creds). Modal re-fetches single destination to edit.
@@ -49,9 +49,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
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 denied = await requireBackups(auth, 'manage');
if (denied) return denied;
const body = await request.json();
@@ -63,8 +62,16 @@ export const POST: RequestHandler = async (event) => {
// 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 });
// Flags: prefer the split shape (backupFlags/restoreFlags); fall back to a legacy `flags`
// string (treated as backup flags). Validate+serialize to the JSON stored in `flags`.
let flagsColumn: string | null = null;
try {
flagsColumn = (body.backupFlags !== undefined || body.restoreFlags !== undefined)
? validateAndSerializeFlags(body.backupFlags, body.restoreFlags)
: validateAndSerializeFlags(body.flags, '');
} catch (e) {
return json({ error: e instanceof Error ? e.message : 'Invalid restic flags' }, { status: 400 });
}
// Validate any cron schedules in the supplied policies (audit #7)
const policyCronError = validatePolicySchedules(body.policies);
@@ -85,7 +92,7 @@ export const POST: RequestHandler = async (event) => {
repository: body.repository,
password: body.password,
envVars: body.envVars ? JSON.stringify(body.envVars) : null,
flags: body.flags ?? null,
flags: flagsColumn,
hostPath: body.hostPath ?? null,
policies: body.policies ?? defaultPolicies
});
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { auditBackupDestination } from '$lib/server/audit';
import {
getBackupDestination,
@@ -10,7 +11,7 @@ import {
getBackupConfigs
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { validatePolicySchedules, validateRepositoryForSave, validateFlags } from '$lib/server/backups/helpers';
import { validatePolicySchedules, validateRepositoryForSave, validateAndSerializeFlags, parseBackupFlags } from '$lib/server/backups/helpers';
import { destinationHasRunningBackup } from '$lib/server/backups';
/**
@@ -28,14 +29,18 @@ function prepareDestination(dest: any, includeSecrets: boolean): any {
} else {
delete result.envVars;
}
// Split the stored `flags` JSON into separate fields the edit form binds to (legacy
// bare strings surface as backupFlags), so the UI never parses the string-vs-JSON column.
const { backup, restore } = parseBackupFlags(dest.flags);
result.backupFlags = backup;
result.restoreFlags = restore;
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 denied = await requireBackups(auth, 'view');
if (denied) return denied;
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
@@ -52,9 +57,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
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 denied = await requireBackups(auth, 'manage');
if (denied) return denied;
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
@@ -82,8 +86,16 @@ export const PUT: RequestHandler = async (event) => {
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 });
// Flags: prefer the split shape; fall back to a legacy `flags` string. undefined for ALL
// three means "don't touch flags". Validate+serialize to the JSON stored in `flags`.
let flagsColumn: string | null | undefined = undefined;
if (body.backupFlags !== undefined || body.restoreFlags !== undefined) {
try { flagsColumn = validateAndSerializeFlags(body.backupFlags, body.restoreFlags); }
catch (e) { return json({ error: e instanceof Error ? e.message : 'Invalid restic flags' }, { status: 400 }); }
} else if (body.flags !== undefined) {
try { flagsColumn = validateAndSerializeFlags(body.flags, ''); }
catch (e) { return json({ error: e instanceof Error ? e.message : 'Invalid restic flags' }, { status: 400 }); }
}
// A local-path repo is allowed on any env; a wrong-host mount fails loud via
// the helper's localRepoGuard at backup/restore time.
@@ -94,7 +106,7 @@ export const PUT: RequestHandler = async (event) => {
repository: body.repository,
password: body.password,
envVars: body.envVars !== undefined ? JSON.stringify(body.envVars) : undefined,
flags: body.flags,
flags: flagsColumn,
hostPath: body.hostPath,
policies: body.policies
});
@@ -131,9 +143,8 @@ export const PUT: RequestHandler = async (event) => {
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 denied = await requireBackups(auth, 'manage');
if (denied) return denied;
const id = parseInt(params.id);
if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 });
@@ -6,13 +6,16 @@
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, BarChart3, Loader2, Clock, PackageCheck, FolderCheck, Unlock, CircleHelp, AlertTriangle } from 'lucide-svelte';
import { Textarea } from '$lib/components/ui/textarea';
import { Plus, Check, RefreshCw, Wifi, Database, HardDrive, Dices, Copy, BarChart3, Loader2, Clock, PackageCheck, FolderCheck, Unlock, CircleHelp, AlertTriangle, Upload } from 'lucide-svelte';
import { AWS_REGIONS, regionalEndpoint, extractS3Region } from '$lib/utils/s3-region';
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 { FieldLabel } from '$lib/components/ui/field-label';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
@@ -23,6 +26,8 @@
hostPath?: string | null;
envVars?: Record<string, string>;
flags?: string;
backupFlags?: string; // split from the GET endpoint (legacy strings surface here)
restoreFlags?: string;
policies?: string | null;
lastTestStatus?: string | null;
createdAt: string;
@@ -70,6 +75,9 @@
placeholder: string;
secret?: boolean;
envKey?: string; // if set, stored as env var instead of part of repo URL
optional?: boolean; // no red asterisk; not required for Test/Create
multiline?: boolean; // render a textarea (e.g. a pasted JSON credential)
hint?: string; // small helper text under the field
}
interface BackendType {
@@ -85,7 +93,7 @@
{
value: 'local', label: 'Local path', icon: HardDrive,
fields: [
{ key: 'path', label: 'Path', placeholder: '/mnt/backups/dockhand' }
{ key: 'path', label: 'Path', placeholder: '/mnt/backups/dockhand', optional: true }
],
buildRepo: (f) => f.path || '',
parseRepo: (repo) => ({ path: repo })
@@ -93,9 +101,10 @@
{
value: 's3', label: 'Amazon S3', icon: AmazonS3Icon,
fields: [
{ key: 'region', label: 'AWS region', placeholder: '', optional: true },
{ 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: 'path', label: 'Path', placeholder: 'dockhand', optional: true },
{ 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' }
],
@@ -113,9 +122,12 @@
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 };
// Recover the region from an AWS regional endpoint so the picker shows it
// on edit. Non-AWS hosts leave it blank (treated as custom).
const region = extractS3Region(url.host);
return { endpoint, bucket, path, region };
} catch {
return { endpoint: '', bucket: '', path: '' };
return { endpoint: '', bucket: '', path: '', region: '' };
}
}
},
@@ -123,7 +135,7 @@
value: 'b2', label: 'Backblaze B2', icon: BackblazeIcon,
fields: [
{ key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' },
{ key: 'path', label: 'Path (optional)', placeholder: 'dockhand' },
{ key: 'path', label: 'Path', placeholder: 'dockhand', optional: true },
{ key: 'accountId', label: 'Key ID', placeholder: '', envKey: 'B2_ACCOUNT_ID' },
{ key: 'accountKey', label: 'Application key', placeholder: '', secret: true, envKey: 'B2_ACCOUNT_KEY' }
],
@@ -138,11 +150,13 @@
value: 'azure', label: 'Azure Blob', icon: AzureBlobIcon,
fields: [
{ key: 'container', label: 'Container', placeholder: 'my-backup-container' },
{ key: 'path', label: 'Path (optional)', placeholder: 'dockhand' },
{ key: 'path', label: 'Path', placeholder: 'dockhand', optional: true },
{ 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(/:$/, ''),
// Keep the trailing colon: restic's azure parser needs `azure:container:`
// (it splits on ':' and rejects a repo with no second colon). B2/GS differ.
buildRepo: (f) => `azure:${f.container}:${f.path || ''}`,
parseRepo: (repo) => {
const after = repo.replace(/^azure:/, '');
const [container, ...rest] = after.split(':');
@@ -153,8 +167,9 @@
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' }
{ key: 'path', label: 'Path', placeholder: '/dockhand', optional: true },
{ key: 'projectId', label: 'Project ID', placeholder: 'my-gcp-project', envKey: 'GOOGLE_PROJECT_ID' },
{ key: 'saJson', label: 'Service account JSON', placeholder: '{ "type": "service_account", ... }', secret: true, multiline: true, envKey: 'GOOGLE_APPLICATION_CREDENTIALS_JSON', hint: 'Paste or upload a service-account key JSON. It auto-refreshes, so scheduled backups keep working.' }
],
buildRepo: (f) => `gs:${f.bucket}:${f.path || '/'}`,
parseRepo: (repo) => {
@@ -176,8 +191,25 @@
let formName = $state('');
let formBackendType = $state('local');
let formFields = $state<Record<string, string>>({});
// Hidden file-input refs per multiline field, for the "Upload file" button.
let fileInputs = $state<Record<string, HTMLInputElement | null>>({});
// Read a selected file into the field (e.g. a service-account JSON). Kept lenient:
// we accept any text; restic validates the JSON when the destination is tested.
async function uploadJsonToField(key: string, e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
formFields[key] = await file.text();
} catch {
toast.error('Could not read the selected file');
}
input.value = ''; // let the same file be re-picked
}
let formPassword = $state('');
let formFlags = $state('');
let formBackupFlags = $state('');
let formRestoreFlags = $state('');
let formError = $state('');
let formSaving = $state(false);
@@ -198,11 +230,48 @@
// can save + init in one click. Reset whenever the repo fields change (a stale
// verdict must not drive the button after the user edits the target).
let needsInit = $state(false);
// Inline verdict shown next to the Test button after a test. `needs_init` is
// NOT an error (connectivity works, the repo just isn't initialised yet), so it
// reads green, not red. Cleared when the repo fields change (stale verdict).
let testStatus = $state<null | 'ok' | 'needs_init' | 'error'>(null);
let testStatusMsg = $state('');
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));
// A field is mandatory unless it opts out (optional) or is a toggle (skipHostKey).
function fieldRequired(f: FormField): boolean {
return !f.optional && f.key !== 'skipHostKey';
}
// All mandatory fields present? Drives the disabled state of Test/Create. On edit
// the credentials + password are already stored (secrets show blank = "keep
// current"), so credential fields aren't required there - only repo fields are.
const requiredFieldsFilled = $derived(
selectedBackend.fields
.filter(f => fieldRequired(f) && !(isEditing && f.envKey))
.every(f => (formFields[f.key] ?? '').trim().length > 0)
);
const formValid = $derived(
formName.trim().length > 0
&& requiredFieldsFilled
&& (isEditing || formPassword.trim().length > 0)
);
// Collect the destination's env vars from the fields that declare an envKey, plus
// the S3 region (AWS_DEFAULT_REGION) when set. Passing the region lets restic reach
// a bucket even against the generic s3.amazonaws.com endpoint, and is required by
// some S3 providers regardless of endpoint.
function collectEnvVars(): Record<string, string> {
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 region = formFields.region?.trim();
if (region) envVars['AWS_DEFAULT_REGION'] = region;
return envVars;
}
function detectBackendType(repo: string): string {
for (const prefix of ['s3:', 'b2:', 'azure:', 'gs:', 'rest:']) {
if (repo.startsWith(prefix)) return prefix.slice(0, -1);
@@ -212,7 +281,7 @@
function resetForm() {
formName = ''; formBackendType = 'local'; formFields = {}; formPassword = '';
formFlags = ''; formError = '';
formBackupFlags = ''; formRestoreFlags = ''; formError = '';
formSaving = false;
policyPruneEnabled = true; policyPruneSchedule = '0 0 1 * *'; policyPruneMaxUnused = '10';
policyCheckEnabled = true; policyCheckSchedule = '0 0 1 * *';
@@ -234,10 +303,19 @@
const field = backend?.fields.find(f => f.envKey === key);
if (field) fields[field.key] = value as string;
}
// The S3 region is stored as AWS_DEFAULT_REGION (no envKey field), so
// map it back explicitly. It wins over any region recovered from the
// endpoint by parseRepo — the saved env var is authoritative.
if (destination.envVars['AWS_DEFAULT_REGION']) {
fields.region = destination.envVars['AWS_DEFAULT_REGION'];
}
}
formFields = fields;
formPassword = '';
formFlags = destination.flags || '';
// The GET endpoint splits the stored flags JSON into backupFlags/restoreFlags
// (legacy bare strings surface as backupFlags), so the form binds them directly.
formBackupFlags = destination.backupFlags ?? '';
formRestoreFlags = destination.restoreFlags ?? '';
formError = '';
// Load policies
const pol = destination.policies ? (() => { try { return JSON.parse(destination.policies); } catch { return {}; } })() : {};
@@ -296,13 +374,12 @@
// Use saved credentials from DB
payload = { destinationId: destination!.id };
} else {
// Test is disabled until formValid (all mandatory fields incl. credentials),
// so empty creds can't reach restic here - which matters because without
// them restic falls back to ambient auth (Azure ManagedIdentity, AWS
// instance role) that hangs on a retry loop instead of failing fast.
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];
}
const envVars = collectEnvVars();
payload = { repository, password: formPassword, envVars: Object.keys(envVars).length > 0 ? envVars : undefined };
}
const res = await fetch('/api/backup/destinations/test', {
@@ -311,16 +388,25 @@
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success && data.needsInit) {
// needsInit means restic REACHED the repo but it isn't initialised yet —
// connectivity + credentials are fine, so this is NOT an error. The backend
// returns success:false here, so check needsInit on its own.
if (data.needsInit) {
// Only the create flow gets the "Save and init" button; editing an
// existing destination already has its own "Init repo" action.
needsInit = !isEditing;
toast.info(data.message || 'Connection works, but repository needs initialization');
testStatus = 'needs_init';
testStatusMsg = 'Connectivity OK, repository needs init';
} else if (res.ok && data.success) {
needsInit = false;
testStatus = 'ok';
testStatusMsg = 'Connection successful';
} else {
needsInit = false;
toast[res.ok && data.success ? 'success' : 'error'](data.success ? 'Connection test successful' : (data.error || 'Connection test failed'));
testStatus = 'error';
testStatusMsg = data.error || 'Connection test failed';
}
} catch { toast.error('Connection test failed'); } finally { testing = false; }
} catch { testStatus = 'error'; testStatusMsg = 'Connection test failed'; } finally { testing = false; }
}
async function initRepo() {
@@ -358,6 +444,7 @@
$effect(() => {
currentRepo; formFields; formPassword; // track
needsInit = false;
testStatus = null;
});
async function save() {
@@ -379,12 +466,7 @@
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 envVars = collectEnvVars();
const policies = {
pruneEnabled: policyPruneEnabled,
pruneSchedule: policyPruneSchedule,
@@ -399,7 +481,8 @@
const body: Record<string, unknown> = {
name: formName.trim(), repository,
envVars: Object.keys(envVars).length > 0 ? envVars : undefined,
flags: formFlags.trim() || undefined,
backupFlags: formBackupFlags.trim(),
restoreFlags: formRestoreFlags.trim(),
policies: JSON.stringify(policies)
};
if (formPassword || !isEditing) body.password = formPassword || undefined;
@@ -435,7 +518,7 @@
<!-- Left column: connection -->
<div class="space-y-4">
<div class="space-y-2">
<Label for="dest-name">Name</Label>
<FieldLabel label="Name" forId="dest-name" required />
<Input id="dest-name" bind:value={formName} />
</div>
@@ -462,15 +545,49 @@
</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>
{#if field.key === 'region'}
<div class="space-y-1">
<FieldLabel label={field.label} forId="field-region" required={fieldRequired(field)} />
<div class="flex gap-1.5">
<!-- Free-text region: any provider (AWS, Wasabi, R2, ...). Always
passed to restic as AWS_DEFAULT_REGION. -->
<Input
id="field-region"
value={formFields.region ?? ''}
oninput={(e: Event) => { formFields.region = (e.target as HTMLInputElement).value; }}
placeholder="e.g. eu-north-1"
class="flex-1"
/>
<!-- Quick-pick of AWS regions: fills the region AND the regional
endpoint so AWS users don't have to know the URL. -->
<Select.Root type="single" value={formFields.region ?? ''} onValueChange={(v) => {
if (!v) return;
formFields.region = v;
formFields.endpoint = regionalEndpoint(v);
}}>
<Select.Trigger class="w-36 shrink-0" aria-label="AWS regions quick pick">AWS regions</Select.Trigger>
<Select.Content class="max-h-64">
{#each AWS_REGIONS as r}
<Select.Item value={r}>{r}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<p class="text-xs text-muted-foreground">
Passed to restic as <code class="font-mono">AWS_DEFAULT_REGION</code>.
</p>
</div>
{:else}
<div class="space-y-1">
<FieldLabel label={field.label} forId="field-{field.key}" required={fieldRequired(field)} />
<Input
id="field-{field.key}"
value={formFields[field.key] ?? ''}
oninput={(e: Event) => { formFields[field.key] = (e.target as HTMLInputElement).value; }}
placeholder={field.placeholder}
/>
</div>
{/if}
{/each}
</div>
@@ -484,20 +601,45 @@
</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}
/>
{#if field.multiline}
<div class="flex items-center justify-between">
<FieldLabel label={field.label} forId="field-{field.key}" required={fieldRequired(field)} />
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" onclick={() => fileInputs[field.key]?.click()}>
<Upload class="mr-1 h-3 w-3" />Upload file
</Button>
</div>
<input
type="file"
accept="application/json,.json"
class="hidden"
bind:this={fileInputs[field.key]}
onchange={(e) => uploadJsonToField(field.key, e)}
/>
<Textarea
id="field-{field.key}"
rows={5}
value={formFields[field.key] ?? ''}
oninput={(e: Event) => { formFields[field.key] = (e.target as HTMLTextAreaElement).value; }}
placeholder={isEditing && field.secret ? '(leave blank to keep current)' : field.placeholder}
class="field-sizing-fixed max-h-40 resize-y overflow-auto font-mono text-xs"
/>
{:else}
<FieldLabel label={field.label} forId="field-{field.key}" required={fieldRequired(field)} />
<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}
/>
{/if}
{#if field.hint}<p class="text-xs text-muted-foreground">{field.hint}</p>{/if}
</div>
{/if}
{/each}
<div class="space-y-2">
<Label for="dest-password">Encryption password</Label>
<FieldLabel label="Encryption password" forId="dest-password" required={!isEditing} />
<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">
@@ -513,15 +655,26 @@
</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>
<Label for="dest-backup-flags">Extra backup flags</Label>
<Input id="dest-backup-flags" bind:value={formBackupFlags} />
<div class="text-xs text-muted-foreground space-y-0.5">
<p>Applied to backups. 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>
<span class="cursor-pointer hover:text-foreground" onclick={() => { formBackupFlags = (formBackupFlags + ' --limit-upload 5120').trim(); }} title="Limit upload speed to 5 MB/s">--limit-upload</span>
<span class="cursor-pointer hover:text-foreground" onclick={() => { formBackupFlags = (formBackupFlags + ' --limit-download 10240').trim(); }} title="Limit download speed to 10 MB/s">--limit-download</span>
<span class="cursor-pointer hover:text-foreground" onclick={() => { formBackupFlags = (formBackupFlags + ' --verbose').trim(); }} title="Verbose output">--verbose</span>
<span class="cursor-pointer hover:text-foreground" onclick={() => { formBackupFlags = (formBackupFlags + ' --compression max').trim(); }} title="Maximum compression">--compression max</span>
</div>
</div>
</div>
<div class="space-y-2">
<Label for="dest-restore-flags">Extra restore flags</Label>
<Input id="dest-restore-flags" bind:value={formRestoreFlags} />
<div class="text-xs text-muted-foreground space-y-0.5">
<p>Applied only to restores. 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={() => { formRestoreFlags = (formRestoreFlags + ' --exclude-xattr security.selinux').trim(); }} title="Skip the SELinux xattr the helper can't remove on a cross-distro restore (e.g. Ubuntu -> Fedora)">--exclude-xattr security.selinux</span>
</div>
</div>
</div>
@@ -535,7 +688,7 @@
<!-- Prune policy -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<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>
@@ -551,7 +704,7 @@
<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>
<label class="text-xs 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>
@@ -560,7 +713,7 @@
<!-- Check policy -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<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>
@@ -581,7 +734,7 @@
<!-- Data verification policy -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<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>
@@ -597,7 +750,7 @@
<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>
<label class="text-xs 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>
@@ -614,7 +767,7 @@
</div>
<!-- Auto-unlock -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<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>
@@ -678,23 +831,31 @@
</div>
{/if}
<Dialog.Footer class="flex-shrink-0 border-t mt-auto pt-4">
<div class="flex items-center 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}
<div class="flex items-center gap-2 mr-auto min-w-0">
<Button variant="outline" size="sm" onclick={testConnection} disabled={testing || !formValid} title={!formValid ? 'Fill in all required fields first' : undefined}>
{#if testing}<Loader2 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}
{#if initializing}<Loader2 class="w-4 h-4 mr-1 animate-spin" />{:else}<Database class="w-4 h-4 mr-1" />{/if}
Init repo
</Button>
{:else if needsInit}
<span class="text-xs text-muted-foreground">Connection OK — repository will be initialized on save.</span>
{/if}
{#if testStatus === 'ok'}
<span class="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400"><Check class="w-3.5 h-3.5 shrink-0" />{testStatusMsg}</span>
{:else if testStatus === 'needs_init'}
<span class="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400"><Check class="w-3.5 h-3.5 shrink-0" />{testStatusMsg}{#if !isEditing} - done on save{/if}</span>
{:else if testStatus === 'error'}
<span class="flex items-center gap-1.5 min-w-0 text-xs text-destructive" title={testStatusMsg}>
<AlertTriangle class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{testStatusMsg}</span>
</span>
{/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 needsInit && !isEditing}<Database class="w-4 h-4 mr-1" />{:else if isEditing}<Check class="w-4 h-4" />{:else}<Plus class="w-4 h-4" />{/if}
<Button onclick={save} disabled={formSaving || !formValid} variant={repoConflictName ? 'destructive' : 'default'} title={!formValid ? 'Fill in all required fields first' : undefined}>
{#if formSaving}<Loader2 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 mr-1" />{:else}<Plus class="w-4 h-4 mr-1" />{/if}
{repoConflictName ? 'Save anyway' : needsInit && !isEditing ? 'Create and init' : isEditing ? 'Save' : 'Create'}
</Button>
</Dialog.Footer>