S3 backup destinations take an AWS region with a region quick-pick
This commit is contained in:
4 files changed
+315
-94
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user