direct remote environments - remote stacks dir configurable, for putting the whole stack onto the remote
This commit is contained in:
19 files changed
+2316
-506
No files matched your search
@@ -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 { runRestore } from '$lib/server/backups';
|
||||
import { validateRestoreRequest, type RestoreMode } from '$lib/server/backups/validate';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
@@ -10,9 +11,8 @@ 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 rbacDenied = await requireBackups(auth, 'manage');
|
||||
if (rbacDenied) return rbacDenied;
|
||||
|
||||
const body = await request.json();
|
||||
const mode: RestoreMode = body.mode === 'in-place' ? 'in-place' : 'new-location';
|
||||
@@ -25,6 +25,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
// The client sends false to bring the stack up without them. Only a stack redeploy
|
||||
// consumes this; container restores ignore it.
|
||||
const restoreSecrets = body.restoreSecrets !== false;
|
||||
// New-location STACK restore: skip the captured compose/.env (data-only restore),
|
||||
// and/or ADOPT the restored stack into Dockhand afterwards (opt-in). Both default off.
|
||||
const skipStackFiles = body.skipStackFiles === true;
|
||||
|
||||
// Authorization gates run BEFORE request validation, so an unauthorized caller
|
||||
// never learns anything about the request shape.
|
||||
@@ -76,6 +79,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
postRestore,
|
||||
volumeDestinations,
|
||||
restoreSecrets,
|
||||
skipStackFiles,
|
||||
},
|
||||
access,
|
||||
// Stream progress to the client (the restore modal's log). Without this the
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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 { probeStackDir } from '$lib/server/backups';
|
||||
|
||||
// GET /api/backup/stack-dir-listing?target=<stack>&env=<id>
|
||||
// Probe the TARGET host and list the actual contents of the stack directory, so the
|
||||
// create/edit backup dialog can show the resolved host path + let the user pick which
|
||||
// stack-dir entries to back up. Returns { kind:'listed', hostPath, entries } or
|
||||
// { kind:'unknown', reason } (never throws for an operational failure).
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
|
||||
const targetName = url.searchParams.get('target');
|
||||
if (!targetName) return json({ error: 'target is required' }, { status: 400 });
|
||||
const envParam = url.searchParams.get('env');
|
||||
const environmentId = envParam ? parseInt(envParam) : null;
|
||||
|
||||
if (environmentId != null && auth.isEnterprise && !await auth.canAccessEnvironment(environmentId)) {
|
||||
return json({ error: 'Environment access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await probeStackDir(targetName, environmentId);
|
||||
return json(result);
|
||||
} catch (e) {
|
||||
return json({ kind: 'unknown', reason: e instanceof Error ? e.message : 'failed to probe stack directory' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
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 { previewStackBackupPath } from '$lib/server/backups';
|
||||
|
||||
// GET /api/backup/stack-path?target=<stack>&env=<id>
|
||||
// Preview WHERE a stack's directory will be captured from on the host (the resolved host
|
||||
// bind path), or `unknown` with a reason - so the create-schedule dialog can show it up front
|
||||
// and warn before scheduling a backup that would hard-fail the stack-dir probe.
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
if (denied) return denied;
|
||||
|
||||
const targetName = url.searchParams.get('target');
|
||||
if (!targetName) return json({ error: 'target is required' }, { status: 400 });
|
||||
const envParam = url.searchParams.get('env');
|
||||
const environmentId = envParam ? parseInt(envParam) : null;
|
||||
|
||||
if (environmentId != null && auth.isEnterprise && !await auth.canAccessEnvironment(environmentId)) {
|
||||
return json({ error: 'Environment access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await previewStackBackupPath(targetName, environmentId);
|
||||
return json(result);
|
||||
} catch (e) {
|
||||
return json({ kind: 'unknown', reason: e instanceof Error ? e.message : 'failed to resolve stack path' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getEnvironment, getEnvSetting, setEnvSetting } from '$lib/server/db';
|
||||
|
||||
// Per-env "remote stacks directory" for `direct` environments. A direct daemon has no
|
||||
// agent and no shared filesystem with Dockhand, so relative bind files (./config.yaml,
|
||||
// ./data) never reach it. When this is set, Dockhand stages the stack files onto the
|
||||
// remote host under this dir before `docker compose up`. Empty/unset = current behavior.
|
||||
// Stored via the generic per-env settings store (no schema column).
|
||||
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('environments', 'view'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const id = parseInt(params.id);
|
||||
const env = await getEnvironment(id);
|
||||
if (!env) return json({ error: 'Environment not found' }, { status: 404 });
|
||||
|
||||
const stored = await getEnvSetting('remote_stacks_dir', id);
|
||||
const remoteStacksDir = stored && typeof stored === 'string' && stored.trim() !== '' ? stored : null;
|
||||
return json({ remoteStacksDir });
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('environments', 'edit'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const id = parseInt(params.id);
|
||||
const env = await getEnvironment(id);
|
||||
if (!env) return json({ error: 'Environment not found' }, { status: 404 });
|
||||
|
||||
const data = await request.json().catch(() => ({}));
|
||||
const raw = data?.remoteStacksDir;
|
||||
|
||||
// null / '' clears the setting (reverts to current behavior).
|
||||
if (raw === null || raw === undefined || raw === '') {
|
||||
await setEnvSetting('remote_stacks_dir', '', id);
|
||||
return json({ success: true, remoteStacksDir: null });
|
||||
}
|
||||
if (typeof raw !== 'string') {
|
||||
return json({ error: 'remoteStacksDir must be a string or null' }, { status: 400 });
|
||||
}
|
||||
// Must be an absolute path on the remote host; reject relative or traversal-y input.
|
||||
const value = raw.trim();
|
||||
if (!value.startsWith('/') || value.includes('..')) {
|
||||
return json({ error: 'remoteStacksDir must be an absolute path with no ".."' }, { status: 400 });
|
||||
}
|
||||
await setEnvSetting('remote_stacks_dir', value, id);
|
||||
return json({ success: true, remoteStacksDir: value });
|
||||
};
|
||||
@@ -13,6 +13,7 @@
|
||||
import { getRepoTypeIcon, formatCron, runBackupAction, type BackupFormState } from '$lib/utils/backup';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import VolumePicker from '$lib/components/backup/VolumePicker.svelte';
|
||||
import StackFilesPicker from '$lib/components/backup/StackFilesPicker.svelte';
|
||||
import DestinationPicker from '$lib/components/backup/DestinationPicker.svelte';
|
||||
import { normalizeMounts, volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
|
||||
|
||||
@@ -47,6 +48,45 @@
|
||||
let allVolumes = $state(true);
|
||||
let selectedVolumes = $state<string[]>([]);
|
||||
|
||||
// Stack files on the host (probe listing) — stack targets only.
|
||||
type StackListing =
|
||||
| { kind: 'listed'; hostPath: string; entries: { name: string; type: 'dir' | 'file'; size: number; capturedAs?: 'bind' | 'volume' }[] }
|
||||
| { kind: 'tar'; localStackDir: string; entries: { name: string; type: 'dir' | 'file'; size: number; capturedAs?: 'bind' | 'volume' }[] }
|
||||
| { kind: 'helper-failed'; reason: string }
|
||||
| { kind: 'unknown'; reason: string }
|
||||
| null;
|
||||
let stackListing = $state<StackListing>(null);
|
||||
// The probe helper couldn't run on the target - a backup can't run either, so block Save.
|
||||
const stackHelperFailed = $derived(stackListing?.kind === 'helper-failed');
|
||||
let loadingStackListing = $state(false);
|
||||
let excludedStackFiles = $state<string[]>([]);
|
||||
// The user-set "Remote stack path (for backup)" for direct/hawser envs, shown in the picker
|
||||
// tooltip so the user sees THEIR configured path (empty when not set / socket env).
|
||||
let stackRemoteDir = $state<string>('');
|
||||
|
||||
async function fetchStackListing(item: ContainerItem) {
|
||||
if (item.type !== 'stack') { stackListing = null; return; }
|
||||
loadingStackListing = true;
|
||||
stackListing = null;
|
||||
excludedStackFiles = [];
|
||||
stackRemoteDir = '';
|
||||
if (item.envId != null) {
|
||||
fetch(`/api/environments/${item.envId}/remote-stacks-dir`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => { stackRemoteDir = d?.remoteStacksDir ?? ''; })
|
||||
.catch(() => { /* non-fatal - tooltip just omits the configured path */ });
|
||||
}
|
||||
try {
|
||||
const envQ = item.envId != null ? `&env=${item.envId}` : '';
|
||||
const res = await fetch(`/api/backup/stack-dir-listing?target=${encodeURIComponent(item.name)}${envQ}`);
|
||||
stackListing = await res.json();
|
||||
} catch (e) {
|
||||
stackListing = { kind: 'unknown', reason: e instanceof Error ? e.message : 'probe failed' };
|
||||
} finally {
|
||||
loadingStackListing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Schedule & Run
|
||||
let saveSchedule = $state(false);
|
||||
let schedule = $state('0 2 * * *');
|
||||
@@ -126,6 +166,7 @@
|
||||
function selectSource(item: ContainerItem) {
|
||||
selectedItem = item;
|
||||
step = 2;
|
||||
fetchStackListing(item);
|
||||
}
|
||||
|
||||
// Save the config, then (when run=true) hand it off to the parent's shared
|
||||
@@ -141,7 +182,8 @@
|
||||
destinationId: selectedDestId,
|
||||
stopBeforeBackup,
|
||||
allVolumes,
|
||||
selectedVolumes
|
||||
selectedVolumes,
|
||||
options: excludedStackFiles.length > 0 ? { excludedStackFiles } : undefined
|
||||
};
|
||||
try {
|
||||
const result = await runBackupAction({
|
||||
@@ -182,7 +224,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen && saving) return; open = isOpen; }}>
|
||||
<Dialog.Content class="max-w-3xl h-[70vh] flex flex-col">
|
||||
<Dialog.Content class="max-w-5xl h-[85vh] 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
|
||||
@@ -316,6 +358,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedItem?.type === 'stack'}
|
||||
<StackFilesPicker
|
||||
listing={stackListing}
|
||||
loading={loadingStackListing}
|
||||
connectionType={selectedEnv?.connectionType}
|
||||
envName={selectedEnv?.name}
|
||||
envIcon={selectedEnv?.icon}
|
||||
envId={selectedEnv?.id}
|
||||
configuredStackPath={stackRemoteDir}
|
||||
bind:excludedStackFiles
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if selectedItem}
|
||||
<VolumePicker
|
||||
volumes={selectedItem.volumes}
|
||||
@@ -367,12 +422,12 @@
|
||||
<Button variant="outline" size="sm" onclick={() => step = 2} disabled={saving}>Back</Button>
|
||||
<div class="flex-1"></div>
|
||||
{#if saveSchedule}
|
||||
<Button variant="outline" size="sm" onclick={() => saveAndMaybeRun(false)} disabled={saving || scheduleInvalid}>
|
||||
<Button variant="outline" size="sm" onclick={() => saveAndMaybeRun(false)} disabled={saving || scheduleInvalid || stackHelperFailed}>
|
||||
{#if saving}<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />{:else}<CheckCircle2 class="w-3.5 h-3.5 mr-1.5" />{/if}
|
||||
Save schedule only
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" onclick={() => saveAndMaybeRun(true)} disabled={saving || (saveSchedule && scheduleInvalid)}>
|
||||
<Button size="sm" onclick={() => saveAndMaybeRun(true)} disabled={saving || (saveSchedule && scheduleInvalid) || stackHelperFailed}>
|
||||
{#if saving}<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>
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
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 { Save, Play, Pause, RefreshCw, CheckCircle, XCircle, ChevronDown, Loader2, Plus, Trash2, Pencil, X, Check, HardDrive, Box, Layers, FileText, FolderOpen, AlertTriangle } from 'lucide-svelte';
|
||||
import RotateCwFadingClock from '$lib/components/icons/RotateCwFadingClock.svelte';
|
||||
import VolumePicker from '$lib/components/backup/VolumePicker.svelte';
|
||||
import StackFilesPicker from '$lib/components/backup/StackFilesPicker.svelte';
|
||||
import DestinationPicker from '$lib/components/backup/DestinationPicker.svelte';
|
||||
import LogConsole from '$lib/components/LogConsole.svelte';
|
||||
import SnapshotsPanel from './SnapshotsPanel.svelte';
|
||||
@@ -23,10 +24,11 @@
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { getRepoTypeIcon, parseRetention, parseOptions, retentionSummary as getRetentionSummary, formatCron, runBackupAction, classifyJobResult, tagLogLine, fetchBackupExecutions, type BackupAction, type BackupFormState } from '$lib/utils/backup';
|
||||
import { reconcileSelectedVolumeKeys } from '$lib/utils/mounts';
|
||||
|
||||
interface Props {
|
||||
containerName: string;
|
||||
volumes: { name: string; mountPoint: string; mountType?: 'volume' | 'bind' }[];
|
||||
volumes: { key: string; name: string; mountPoint: string; mountType?: 'volume' | 'bind' }[];
|
||||
type?: 'container' | 'stack';
|
||||
environmentId?: number;
|
||||
onConfigSaved?: () => void;
|
||||
@@ -238,9 +240,12 @@
|
||||
editLimitDownload = opts.limitDownload || '';
|
||||
editWebhookSuccess = opts.webhookSuccess || '';
|
||||
editWebhookFailure = opts.webhookFailure || '';
|
||||
excludedStackFiles = Array.isArray(opts.excludedStackFiles) ? opts.excludedStackFiles : [];
|
||||
editStopBefore = cfg.stopBeforeBackup;
|
||||
editAllVolumes = cfg.selectedVolumes === null;
|
||||
editSelectedVolumes = cfg.selectedVolumes ? (typeof cfg.selectedVolumes === 'string' ? JSON.parse(cfg.selectedVolumes) : cfg.selectedVolumes) : [];
|
||||
const storedSel = cfg.selectedVolumes ? (typeof cfg.selectedVolumes === 'string' ? JSON.parse(cfg.selectedVolumes) : cfg.selectedVolumes) : [];
|
||||
// Reconcile a stored selection (may hold legacy names) to the current volume keys.
|
||||
editSelectedVolumes = reconcileSelectedVolumeKeys(volumes, storedSel);
|
||||
showAdvanced = false;
|
||||
}
|
||||
|
||||
@@ -283,9 +288,11 @@
|
||||
if (editWebhookFailure !== (opts.webhookFailure || '')) return true;
|
||||
if (editStopBefore !== cfg.stopBeforeBackup) return true;
|
||||
if (editAllVolumes !== (cfg.selectedVolumes === null)) return true;
|
||||
const origVolumes = cfg.selectedVolumes
|
||||
const storedSel = cfg.selectedVolumes
|
||||
? (typeof cfg.selectedVolumes === 'string' ? JSON.parse(cfg.selectedVolumes) : cfg.selectedVolumes)
|
||||
: [];
|
||||
// Reconcile the baseline to keys too, so a name->key format shift alone isn't seen as dirty.
|
||||
const origVolumes = reconcileSelectedVolumeKeys(volumes, storedSel);
|
||||
if (JSON.stringify([...editSelectedVolumes].sort()) !== JSON.stringify([...origVolumes].sort())) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -314,7 +321,8 @@
|
||||
limitUpload: editLimitUpload || undefined,
|
||||
limitDownload: editLimitDownload || undefined,
|
||||
webhookSuccess: editWebhookSuccess || undefined,
|
||||
webhookFailure: editWebhookFailure || undefined
|
||||
webhookFailure: editWebhookFailure || undefined,
|
||||
excludedStackFiles: excludedStackFiles.length > 0 ? excludedStackFiles : undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -468,7 +476,53 @@
|
||||
return `${sel.length} of ${volumes.length} volumes`;
|
||||
}
|
||||
|
||||
// Resolved host stack-dir path (stacks only): WHERE the helper will read the stack folder
|
||||
// from on the target host. Shown in the create form so the user sees what gets captured -
|
||||
// and a clear warning up front when it can't be located (would hard-fail the backup).
|
||||
let stackPath = $state<{ kind: 'candidate'; hostPath: string; composeFile: string } | { kind: 'unknown'; reason: string } | null>(null);
|
||||
async function fetchStackPath() {
|
||||
if (type !== 'stack') return;
|
||||
try {
|
||||
const params = new URLSearchParams({ target: containerName });
|
||||
// Use the derived envId (prop OR the currently selected environment), the SAME
|
||||
// value the rest of the panel uses for save/run - the bare `environmentId` prop is
|
||||
// often undefined (the modal doesn't pass it), which made the server report
|
||||
// "No environment specified".
|
||||
if (envId != null) params.set('env', String(envId));
|
||||
const res = await fetch(`/api/backup/stack-path?${params}`);
|
||||
if (res.ok) stackPath = await res.json();
|
||||
} catch { /* non-fatal: the path preview is informational */ }
|
||||
}
|
||||
|
||||
// Stack files on the host (probe listing) — supersedes the plain stackPath callout for the
|
||||
// picker, but stackPath is still fetched for the compose-file name shown elsewhere.
|
||||
type StackListing =
|
||||
| { kind: 'listed'; hostPath: string; entries: { name: string; type: 'dir' | 'file'; size: number; capturedAs?: 'bind' | 'volume' }[] }
|
||||
| { kind: 'tar'; localStackDir: string; entries: { name: string; type: 'dir' | 'file'; size: number; capturedAs?: 'bind' | 'volume' }[] }
|
||||
| { kind: 'unknown'; reason: string }
|
||||
| null;
|
||||
let stackListing = $state<StackListing>(null);
|
||||
let loadingStackListing = $state(false);
|
||||
let excludedStackFiles = $state<string[]>([]);
|
||||
async function fetchStackListing() {
|
||||
if (type !== 'stack') { stackListing = null; return; }
|
||||
loadingStackListing = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ target: containerName });
|
||||
if (envId != null) params.set('env', String(envId));
|
||||
const res = await fetch(`/api/backup/stack-dir-listing?${params}`);
|
||||
stackListing = await res.json();
|
||||
} catch (e) {
|
||||
stackListing = { kind: 'unknown', reason: e instanceof Error ? e.message : 'probe failed' };
|
||||
} finally {
|
||||
loadingStackListing = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => { fetchDestinations(); fetchConfigs(); });
|
||||
// Resolve the stack path once envId is known (it's derived from $currentEnvironment, which
|
||||
// may not be ready at mount). Re-runs if the environment changes.
|
||||
$effect(() => { if (type === 'stack' && envId != null) { void fetchStackPath(); void fetchStackListing(); } });
|
||||
</script>
|
||||
|
||||
<!-- Sub-tabs: Schedules (cheap) vs Snapshots. SnapshotsPanel is mounted as soon as
|
||||
@@ -610,6 +664,20 @@
|
||||
<TogglePill bind:checked={editStopBefore} />
|
||||
<Label class="text-xs">Stop {type} during backup</Label>
|
||||
</div>
|
||||
<!-- Stack files on the host: probe the host, show the resolved dir + let the user
|
||||
pick which entries to back up. Shown BEFORE the volume list. -->
|
||||
{#if type === 'stack'}
|
||||
<StackFilesPicker
|
||||
listing={stackListing}
|
||||
loading={loadingStackListing}
|
||||
connectionType={fullEnv?.connectionType}
|
||||
envName={fullEnv?.name}
|
||||
envIcon={fullEnv?.icon}
|
||||
envId={fullEnv?.id}
|
||||
bind:excludedStackFiles
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Volume picker (all-or-subset toggle + per-volume checkboxes) -->
|
||||
<VolumePicker
|
||||
volumes={volumes}
|
||||
|
||||
@@ -275,6 +275,9 @@
|
||||
let formHost = $state('');
|
||||
let formPort = $state(2375); // Default for direct Docker connection
|
||||
let formProtocol = $state('http');
|
||||
// direct-only: where Dockhand stages compose + relative-bind files on the remote host,
|
||||
// so `./config`/`./data` binds resolve. Empty = current behavior (relative binds unsupported).
|
||||
let formRemoteStacksDir = $state('');
|
||||
let formTlsCa = $state('');
|
||||
let formTlsCert = $state('');
|
||||
let formTlsKey = $state('');
|
||||
@@ -294,6 +297,11 @@
|
||||
let formDiskWarningThreshold = $state(80);
|
||||
let formDiskWarningThresholdGb = $state(50);
|
||||
let formConnectionType = $state<ConnectionType>('socket');
|
||||
// Envs that keep stack files on a REMOTE host, so backup needs a declared stack path:
|
||||
// direct (Dockhand stages there, drives deploy + backup) and hawser (the agent keeps files
|
||||
// at its STACKS_DIR; the path is backup-only, does not steer deploy).
|
||||
const usesStackPath = (ct: ConnectionType) => ct === 'direct' || ct === 'hawser-standard' || ct === 'hawser-edge';
|
||||
const isHawserConn = (ct: ConnectionType) => ct === 'hawser-standard' || ct === 'hawser-edge';
|
||||
let formHawserToken = $state('');
|
||||
let formLabels = $state<string[]>([]);
|
||||
let newLabelInput = $state('');
|
||||
@@ -598,6 +606,7 @@
|
||||
loadImagePruneSettings(environment.id);
|
||||
loadTimezone(environment.id);
|
||||
loadDiskWarningSettings(environment.id);
|
||||
if (usesStackPath(formConnectionType)) loadRemoteStacksDir(environment.id);
|
||||
// Load Hawser token if edge mode
|
||||
if (formConnectionType === 'hawser-edge') {
|
||||
loadHawserToken(environment.id);
|
||||
@@ -607,6 +616,7 @@
|
||||
formHost = '';
|
||||
formPort = 2375;
|
||||
formProtocol = 'http';
|
||||
formRemoteStacksDir = '';
|
||||
formTlsCa = '';
|
||||
formTlsCert = '';
|
||||
formTlsKey = '';
|
||||
@@ -883,6 +893,7 @@
|
||||
if (newEnv?.id) {
|
||||
await saveTimezone(newEnv.id);
|
||||
await saveDiskWarningSettings(newEnv.id);
|
||||
if (usesStackPath(formConnectionType)) await saveRemoteStacksDir(newEnv.id);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
@@ -1019,6 +1030,7 @@
|
||||
await saveImagePruneSettings(environment.id);
|
||||
await saveTimezone(environment.id);
|
||||
await saveDiskWarningSettings(environment.id);
|
||||
if (usesStackPath(formConnectionType)) await saveRemoteStacksDir(environment.id);
|
||||
toast.success(`Updated environment: ${formName}`);
|
||||
onSaved();
|
||||
onClose();
|
||||
@@ -1049,6 +1061,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRemoteStacksDir(envId: number) {
|
||||
try {
|
||||
const response = await fetch(`/api/environments/${envId}/remote-stacks-dir`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
formRemoteStacksDir = data.remoteStacksDir ?? '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load remote stacks dir:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRemoteStacksDir(envId: number) {
|
||||
try {
|
||||
await fetch(`/api/environments/${envId}/remote-stacks-dir`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ remoteStacksDir: formRemoteStacksDir.trim() || null })
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save remote stacks dir:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDiskWarningSettings(envId: number) {
|
||||
try {
|
||||
await fetch(`/api/environments/${envId}/disk-warning`, {
|
||||
@@ -1607,7 +1643,7 @@
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<div class="overflow-y-auto py-4 h-[520px] [scrollbar-gutter:stable] pr-3">
|
||||
<div class="overflow-y-auto py-4 h-[520px] [scrollbar-gutter:stable] pr-5">
|
||||
<!-- General Tab (Connection Settings) -->
|
||||
<Tabs.Content value="general" class="space-y-4 mt-0 h-full">
|
||||
<!-- Name field -->
|
||||
@@ -1911,6 +1947,75 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stack path for backup (direct = also drives deploy; hawser = backup-only) -->
|
||||
{#if usesStackPath(formConnectionType)}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Label for="edit-env-remote-stacks-dir">
|
||||
{isHawserConn(formConnectionType) ? 'Remote stack path (for backup)' : 'Remote stacks directory'}
|
||||
<span class="text-muted-foreground font-normal">(optional)</span>
|
||||
</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger type="button" class="text-muted-foreground hover:text-foreground">
|
||||
<HelpCircle class="w-3.5 h-3.5" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="w-80 z-[200]" side="right">
|
||||
{#if isHawserConn(formConnectionType)}
|
||||
<div class="space-y-2">
|
||||
<p class="font-medium">Where the agent keeps stack files</p>
|
||||
<p class="text-muted-foreground">
|
||||
The Hawser agent stores each stack's folder at
|
||||
<code class="bg-muted px-1 rounded"><this path>/<stack></code>
|
||||
on <span class="font-medium text-foreground">its own host</span>. Backup reads
|
||||
the compose and config from there. This does <span class="font-medium text-foreground">not</span>
|
||||
change where deploy or restore write - the agent always uses its own
|
||||
<code class="bg-muted px-1 rounded">STACKS_DIR</code>; this only tells backup where to look.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
So set it to MATCH the agent's <code class="bg-muted px-1 rounded">STACKS_DIR</code>
|
||||
(default <code class="bg-muted px-1 rounded">/data/stacks</code>). Leave empty for
|
||||
the default; set it only if the agent runs with a custom one.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<p class="font-medium">How this path is used</p>
|
||||
<p class="text-muted-foreground">
|
||||
A direct daemon shares no filesystem with Dockhand. When set, Dockhand
|
||||
materialises each stack's whole folder - compose, <code class="bg-muted px-1 rounded">.env</code>,
|
||||
and relative-bind files (<code class="bg-muted px-1 rounded">./config</code>,
|
||||
<code class="bg-muted px-1 rounded">./data</code>) - into
|
||||
<code class="bg-muted px-1 rounded"><this path>/<stack></code>
|
||||
<span class="font-medium text-foreground">on the remote host</span>, and runs compose there
|
||||
via <code class="bg-muted px-1 rounded">--project-directory</code>.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
This applies to normal stacks and git sync alike, so relative binds resolve
|
||||
and every stack is backupable. Leave empty to use only named volumes or
|
||||
absolute paths.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Input
|
||||
id="edit-env-remote-stacks-dir"
|
||||
bind:value={formRemoteStacksDir}
|
||||
placeholder={isHawserConn(formConnectionType) ? '/data/stacks' : '/opt/dockhand/stacks'}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{#if isHawserConn(formConnectionType)}
|
||||
Absolute path on the agent's host where it keeps stack folders. Used only to back up
|
||||
each stack's compose and config. Leave empty for the default <code class="bg-muted px-1 rounded">/data/stacks</code>.
|
||||
{:else}
|
||||
Absolute path on the remote host where Dockhand stages each stack's files before deploying.
|
||||
Set it for stacks with relative binds, or to keep stacks backupable on a remote direct daemon.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Direct connection settings -->
|
||||
{#if formConnectionType === 'direct'}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
|
||||
Reference in new issue
Block a user