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
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Stack files on the host" — probes the target host at backup-config time, shows the
|
||||
* resolved HOST path of the stack folder, and lets the user pick which entries to back up.
|
||||
* Load-bearing files (compose, .env) are always kept (non-deselectable). When the folder
|
||||
* can't be located on the host, an info callout tells the user to set the env's stack path
|
||||
* and re-configure. Mirrors VolumePicker's list + BackupPanel's host-path callout.
|
||||
*/
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { FolderOpen, Folder, FileText, Info, Loader2, AlertTriangle, HelpCircle, Unplug, Route, UndoDot, Icon } from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
type Entry = { name: string; type: 'dir' | 'file'; size: number; capturedAs?: 'bind' | 'volume' };
|
||||
type Listing =
|
||||
| { kind: 'listed'; hostPath: string; entries: Entry[] }
|
||||
| { kind: 'tar'; localStackDir: string; entries: Entry[] }
|
||||
| { kind: 'helper-failed'; reason: string }
|
||||
| { kind: 'unknown'; reason: string }
|
||||
| null;
|
||||
|
||||
interface Props {
|
||||
listing: Listing;
|
||||
loading?: boolean;
|
||||
/** Names of stack-dir entries the user has DEselected (excluded from the backup). */
|
||||
excludedStackFiles: string[];
|
||||
/** Environment connection type - the tooltip explains the host path differently per type. */
|
||||
connectionType?: string;
|
||||
/** Environment name, shown in the tooltip ("<name> is a socket environment"). */
|
||||
envName?: string;
|
||||
/** Environment icon (Lucide name or custom) + its id, for the env glyph in the tooltip. */
|
||||
envIcon?: string;
|
||||
envId?: number;
|
||||
/** The user-set "Remote stack path (for backup)" for direct/hawser envs (empty if unset). */
|
||||
configuredStackPath?: string;
|
||||
}
|
||||
|
||||
let { listing, loading = false, excludedStackFiles = $bindable(), connectionType, envName, envIcon, envId, configuredStackPath = '' }: Props = $props();
|
||||
|
||||
// Falls back to a generic subject when the env name isn't available.
|
||||
const envLabel = $derived(envName || 'This environment');
|
||||
|
||||
// The stack folder resolves to a host path by a DIFFERENT route per environment type, so the
|
||||
// "where this path comes from" help must differ too (undefined = socket, the default).
|
||||
const envKind = $derived(
|
||||
connectionType === 'direct' ? 'direct'
|
||||
: connectionType === 'hawser-standard' || connectionType === 'hawser-edge' ? 'hawser'
|
||||
: 'socket'
|
||||
);
|
||||
|
||||
// A stack always needs its compose + env files - never deselectable (matches the server
|
||||
// isLoadBearingStackFile guard, which refuses to exclude them even if the config lists them).
|
||||
function isLoadBearing(name: string): boolean {
|
||||
const n = name.toLowerCase();
|
||||
return (
|
||||
n === 'compose.yaml' || n === 'compose.yml' ||
|
||||
n === 'docker-compose.yml' || n === 'docker-compose.yaml' ||
|
||||
n === '.env' || n.startsWith('.env.')
|
||||
);
|
||||
}
|
||||
|
||||
// An entry the user cannot deselect HERE: either load-bearing (always kept) or captured via
|
||||
// its own bind/volume channel (controlled in the Volumes section, not here).
|
||||
function isLocked(e: Entry): boolean {
|
||||
return isLoadBearing(e.name) || e.capturedAs != null;
|
||||
}
|
||||
|
||||
const entries = $derived(listing?.kind === 'listed' || listing?.kind === 'tar' ? listing.entries : []);
|
||||
const selectable = $derived(entries.filter((e) => !isLocked(e)));
|
||||
// "All files" is on when nothing selectable is excluded.
|
||||
const allFiles = $derived(excludedStackFiles.length === 0);
|
||||
|
||||
function toggleAll(on: boolean) {
|
||||
excludedStackFiles = on ? [] : selectable.map((e) => e.name);
|
||||
}
|
||||
|
||||
function toggleEntry(e: Entry) {
|
||||
if (isLocked(e)) return;
|
||||
excludedStackFiles = excludedStackFiles.includes(e.name)
|
||||
? excludedStackFiles.filter((n) => n !== e.name)
|
||||
: [...excludedStackFiles, e.name];
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium">Stack files on the host</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-md border bg-muted/30 p-2.5 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 shrink-0 animate-spin" />
|
||||
Probing the host for the stack folder...
|
||||
</div>
|
||||
{:else if listing?.kind === 'listed' || listing?.kind === 'tar'}
|
||||
{#if listing.kind === 'listed'}
|
||||
<!-- Resolved host path callout (copied from BackupPanel's stack-path candidate). -->
|
||||
<div class="flex items-start gap-2 rounded-md border bg-muted/30 p-2.5 text-xs">
|
||||
<FolderOpen class="mt-0.5 h-4 w-4 shrink-0 text-sky-500" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-foreground">Captured from the host at</span>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger type="button" class="text-muted-foreground hover:text-foreground">
|
||||
<HelpCircle class="h-3.5 w-3.5" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="w-80 z-[200]" side="right">
|
||||
<div class="space-y-2">
|
||||
<p class="font-medium">Where this path comes from</p>
|
||||
{#if envKind === 'socket'}
|
||||
<p class="flex items-center gap-1.5 text-muted-foreground">
|
||||
{#if envIcon && envId != null}<EnvironmentIcon icon={envIcon} {envId} class="h-3.5 w-3.5 shrink-0" />{/if}
|
||||
<Unplug class="h-3.5 w-3.5 shrink-0 text-cyan-500" />
|
||||
<span><span class="font-medium text-foreground">{envLabel}</span> is a socket
|
||||
environment. Backup reads the stack folder from Dockhand's own stack
|
||||
directory on this host.</span>
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
If this path looks wrong, set <code class="bg-muted px-1 rounded">HOST_DATA_DIR</code>
|
||||
when Dockhand runs in a container, so it points to where the data volume
|
||||
lives on the host.
|
||||
</p>
|
||||
{:else if envKind === 'direct'}
|
||||
<p class="flex items-center gap-1.5 text-muted-foreground">
|
||||
{#if envIcon && envId != null}<EnvironmentIcon icon={envIcon} {envId} class="h-3.5 w-3.5 shrink-0" />{/if}
|
||||
<Icon iconNode={whale} class="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
<span><span class="font-medium text-foreground">{envLabel}</span> is a direct
|
||||
environment. Backup reads the stack folder from this path on the remote
|
||||
Docker host.</span>
|
||||
</p>
|
||||
{#if configuredStackPath}
|
||||
<p class="text-muted-foreground">
|
||||
Remote stack path set on this environment:
|
||||
<code class="bg-muted px-1 rounded break-all">{configuredStackPath}</code>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground">
|
||||
It is derived from the stack's bind mounts. To set it explicitly, use
|
||||
<span class="font-medium text-foreground">Environments > (edit) > Remote stack path (for backup)</span>
|
||||
and re-open this dialog.
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="flex items-center gap-1.5 text-muted-foreground">
|
||||
{#if envIcon && envId != null}<EnvironmentIcon icon={envIcon} {envId} class="h-3.5 w-3.5 shrink-0" />{/if}
|
||||
{#if connectionType === 'hawser-edge'}
|
||||
<UndoDot class="h-3.5 w-3.5 shrink-0 text-green-500" />
|
||||
{:else}
|
||||
<Route class="h-3.5 w-3.5 shrink-0 text-purple-500" />
|
||||
{/if}
|
||||
<span><span class="font-medium text-foreground">{envLabel}</span> is a Hawser
|
||||
environment. Backup reads the stack folder from the Hawser agent's stack
|
||||
directory on its host.</span>
|
||||
</p>
|
||||
{#if configuredStackPath}
|
||||
<p class="text-muted-foreground">
|
||||
Remote stack path set on this environment:
|
||||
<code class="bg-muted px-1 rounded break-all">{configuredStackPath}</code>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground">
|
||||
It defaults to <code class="bg-muted px-1 rounded">/data/stacks</code>. If the
|
||||
agent uses a custom <code class="bg-muted px-1 rounded">STACKS_DIR</code>, set it
|
||||
under <span class="font-medium text-foreground">Environments > (edit) > Remote stack path (for backup)</span>
|
||||
and re-open this dialog.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<div class="mt-0.5 break-all font-mono text-muted-foreground">{listing.hostPath}</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- TAR mode: no host folder to read (direct-remote, no stack path set). Only Dockhand's
|
||||
own copy of the compose/config is captured; bind-mount DATA on the remote host is NOT. -->
|
||||
<div class="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-2.5 text-xs">
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-amber-600 dark:text-amber-400">Only these compose/config files will be captured</div>
|
||||
<div class="mt-0.5 text-muted-foreground">This environment has no stack path set, so the folder can't be read on the remote host. Dockhand backs up its own copy of the files below. <strong>Bind-mount data on the remote host is NOT included.</strong> Set a stack path on this environment for a full backup.</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border rounded-md overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-3 py-2 bg-muted/30 border-b">
|
||||
<Label class="text-xs">Backup all files ({selectable.length})</Label>
|
||||
<TogglePill checked={allFiles} onLabel="Yes" offLabel="No" onchange={() => toggleAll(!allFiles)} />
|
||||
</div>
|
||||
<div class="divide-y max-h-40 overflow-y-auto">
|
||||
{#each entries as entry}
|
||||
{@const locked = isLocked(entry)}
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 text-xs" class:cursor-pointer={!allFiles && !locked} class:opacity-60={locked}>
|
||||
<Checkbox
|
||||
checked={!excludedStackFiles.includes(entry.name)}
|
||||
disabled={allFiles || locked}
|
||||
onCheckedChange={() => toggleEntry(entry)}
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
{#if entry.type === 'dir'}
|
||||
<Folder class="h-3.5 w-3.5 shrink-0 text-sky-500" />
|
||||
{:else}
|
||||
<FileText class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<span class="font-mono truncate">{entry.name}</span>
|
||||
{#if entry.capturedAs}
|
||||
<span class="text-muted-foreground ml-auto shrink-0 italic">captured as a {entry.capturedAs} below</span>
|
||||
{:else if isLoadBearing(entry.name)}
|
||||
<span class="text-muted-foreground ml-auto shrink-0 italic">always kept</span>
|
||||
{:else if entry.type === 'file'}
|
||||
<span class="text-muted-foreground ml-auto shrink-0">{fmtSize(entry.size)}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if listing?.kind === 'helper-failed'}
|
||||
<!-- The probe helper container couldn't run on the target. A backup can't run either (it
|
||||
uses the same helper), so this is a hard block, not a soft warning. -->
|
||||
<div class="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-2.5 text-xs">
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-destructive">The backup helper can't run on this environment</div>
|
||||
<div class="mt-0.5 break-all text-muted-foreground">{listing.reason}</div>
|
||||
<div class="mt-1 text-muted-foreground">A backup can't be created until this is fixed - the same helper container captures the stack files.</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if listing?.kind === 'unknown'}
|
||||
<div class="flex items-start gap-2 rounded-md border bg-muted/30 p-2.5 text-xs">
|
||||
<Info class="mt-0.5 h-4 w-4 shrink-0 text-sky-500" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-foreground">No stack folder found on the host</div>
|
||||
<div class="mt-0.5 text-muted-foreground">{listing.reason}</div>
|
||||
{#if envKind === 'socket'}
|
||||
<div class="mt-1 text-muted-foreground">On a socket environment this usually means <code class="bg-muted px-1 rounded">HOST_DATA_DIR</code> does not match where the data volume is mounted on the host. Fix it, then cancel and re-configure this backup.</div>
|
||||
{:else if envKind === 'direct'}
|
||||
<div class="mt-1 text-muted-foreground">Set the environment's stack path (Environments > edit > Remote stack path for backup), then cancel and re-configure this backup.</div>
|
||||
{:else}
|
||||
<div class="mt-1 text-muted-foreground">On Hawser this usually means the agent uses a custom <code class="bg-muted px-1 rounded">STACKS_DIR</code> (not the default <code class="bg-muted px-1 rounded">/data/stacks</code>). Set the environment's stack path (Environments > edit > Remote stack path for backup), then cancel and re-configure this backup.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in new issue
Block a user