direct remote environments - remote stacks dir configurable, for putting the whole stack onto the remote

This commit is contained in:
jarek committed 2026-08-09 10:31:27 +02:00
1 parent 86ef3f743b
commit bd06b340c5
19 files changed
+2316 -506

No files matched your search

+7 -3
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 { 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 });
};
+59 -4
View File
@@ -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>
+73 -5
View File
@@ -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">&lt;this path&gt;/&lt;stack&gt;</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">&lt;this path&gt;/&lt;stack&gt;</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">