diff --git a/Dockerfile b/Dockerfile index c51b0cd..a0e3299 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") " - postgresql-client" \ " - git" \ " - openssh-client" \ + " - openssh-keygen" \ " - curl" \ " - tini" \ " - su-exec" \ @@ -86,7 +87,9 @@ ARG TARGETARCH WORKDIR /app # Install build dependencies -RUN apt-get update && apt-get install -y --no-install-recommends jq git curl unzip ca-certificates && rm -rf /var/lib/apt/lists/* +# libnss-wrapper: needed for git SSH with arbitrary UIDs on read-only containers (getpwuid workaround) +RUN apt-get update && apt-get install -y --no-install-recommends jq git curl unzip ca-certificates libnss-wrapper && rm -rf /var/lib/apt/lists/* \ + && cp "$(dpkg -L libnss-wrapper | grep 'libnss_wrapper\.so$')" /usr/local/lib/libnss_wrapper.so # Copy package files and install ALL dependencies (needed for build) COPY package.json bun.lock* bunfig.toml ./ @@ -95,7 +98,7 @@ RUN bun install --frozen-lockfile # Copy source code and build COPY . . -# Build with parallelism - dedicated build VM has 16 CPUs and 32GB RAM +# Build the application RUN NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128" bun run build # Prepare production node_modules (do this in builder where we have compilers) @@ -130,6 +133,9 @@ COPY --from=os-builder /work/rootfs/ / # For regular builds, this contains the standard oven/bun binary COPY --from=app-builder /usr/local/bin/bun /usr/bin/bun +# Copy libnss_wrapper for git SSH with arbitrary UIDs (same cross-copy pattern as Bun above) +COPY --from=app-builder /usr/local/lib/libnss_wrapper.so /usr/lib/libnss_wrapper.so + WORKDIR /app # Set up environment variables diff --git a/package.json b/package.json index ea8dd09..d569215 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.17", + "version": "1.0.18", "type": "module", "scripts": { "dev": "bunx --bun vite dev", diff --git a/src/lib/components/BatchOperationModal.svelte b/src/lib/components/BatchOperationModal.svelte index fa25134..304b0a9 100644 --- a/src/lib/components/BatchOperationModal.svelte +++ b/src/lib/components/BatchOperationModal.svelte @@ -5,6 +5,14 @@ import { Check, X, Loader2, Circle, Ban } from 'lucide-svelte'; import { onDestroy } from 'svelte'; + function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + } + const progressText: Record = { remove: 'removing', start: 'starting', @@ -30,6 +38,7 @@ items: Array<{ id: string; name: string }>; envId?: number; options?: Record; + totalSize?: number; onClose: () => void; onComplete: () => void; } @@ -42,6 +51,7 @@ items, envId, options = {}, + totalSize, onClose, onComplete }: Props = $props(); @@ -233,7 +243,7 @@ {#if isRunning} Processing {items.length} {entityType}... {:else if isComplete} - Completed: {successCount} succeeded{#if failCount > 0}, {failCount} failed{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if} + Completed: {successCount} succeeded{#if failCount > 0}, {failCount} failed{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}{#if totalSize && successCount > 0} ({formatBytes(totalSize)}){/if} {:else} Preparing to {operation} {items.length} {entityType}... {/if} diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index 9934c7f..58a51f9 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -1,5 +1,5 @@ + + { if (!isOpen) handleClose(); }}> + { if (!canClose) e.preventDefault(); }}> + + + + {#if phase === 'confirm'} + Update Dockhand + {:else} + Updating Dockhand + {/if} + + {#if phase !== 'confirm'} + + {#if activeStep} + {activeStep.label}... + ({completedCount}/{ALL_STEPS.length}) + {:else if phase === 'completed'} + Update complete + {:else if phase === 'error'} + Update failed + {:else} + Preparing... + {/if} + + {/if} + + + {#if phase === 'confirm'} + +
+
+
+ Container + + + {containerName} + +
+
+ Image + {currentImage} +
+ {#if currentDigest || newDigest} +
+ Current digest + {currentDigest ? currentDigest.replace('sha256:', '').slice(0, 12) : 'unknown'} +
+
+ New digest + {newDigest ? newDigest.replace('sha256:', '').slice(0, 12) : 'unknown'} +
+ {/if} +
+ + {#if loadingNotes} +
+ + Loading release notes... +
+ {:else if releaseNotes.length > 0} +
+
+

What's new

+
+
+ {#each releaseNotes as entry} +
+
+ v{entry.version} + {entry.date} +
+
    + {#each entry.changes as change} + {@const ChangeIcon = getChangeIcon(change.type)} +
  • + + {change.text} +
  • + {/each} +
+
+ {/each} +
+
+ {/if} + + {#if isComposeManaged} +
+

+ Note: This container is managed by Docker Compose. After update it will continue to work but may lose Compose tracking. Use docker compose pull && docker compose up -d for Compose-aware updates. +

+
+ {/if} +
+ + + + + + + {:else} + +
+ +
+
+ Progress + {completedCount}/{ALL_STEPS.length} +
+ +
+ + + {#if visibleSteps.length > 0} +
+ {#each visibleSteps as step (step.id)} + {@const StepIcon = getIconComponent(step.status)} + {@const hasLogs = step.logs.length > 0} +
+ +
+ +
+
{step.label}
+
+ {#if step.status === 'completed'} + + {:else if step.status === 'error'} + + {/if} +
+ + + {#if hasLogs} +
+ {#each step.logs as line} +
{line}
+ {/each} +
+ {/if} +
+ {/each} +
+ {/if} + + + {#if phase === 'error' && errorMessage} +
+ + {errorMessage} +
+ {/if} +
+ + + {#if phase === 'completed'} + + {:else if phase === 'error'} + + {:else} + + {/if} + + {/if} +
+
diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte index 78114f6..4cda99b 100644 --- a/src/routes/settings/environments/EnvironmentModal.svelte +++ b/src/routes/settings/environments/EnvironmentModal.svelte @@ -57,7 +57,8 @@ X, Tags, ChevronDown, - ChevronRight + ChevronRight, + XCircle } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; import * as Alert from '$lib/components/ui/alert'; @@ -70,6 +71,7 @@ import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill'; import { ShieldOff } from 'lucide-svelte'; import { focusFirstInput } from '$lib/utils'; + import { copyToClipboard } from '$lib/utils/clipboard'; import { authStore, canAccess } from '$lib/stores/auth'; import { licenseStore } from '$lib/stores/license'; import { formatDateTime, formatDate } from '$lib/stores/settings'; @@ -321,8 +323,8 @@ let hawserTokenLoading = $state(false); let generatingToken = $state(false); let generatedToken = $state(null); // Full token shown once after generation - let copySuccess = $state(false); - let copyCmdSuccess = $state(false); + let copySuccess = $state<'ok' | 'error' | null>(null); + let copyCmdSuccess = $state<'ok' | 'error' | null>(null); // For add mode - auto-generated token stored until save let pendingToken = $state(null); @@ -1268,17 +1270,17 @@ await generateHawserToken(envId); } - function copyToken(token: string) { - navigator.clipboard.writeText(token); - copySuccess = true; - setTimeout(() => { copySuccess = false; }, 2000); + async function copyToken(token: string) { + const ok = await copyToClipboard(token); + copySuccess = ok ? 'ok' : 'error'; + setTimeout(() => { copySuccess = null; }, 2000); } - function copyCommand(token: string) { + async function copyCommand(token: string) { const cmd = `DOCKHAND_SERVER_URL=${getConnectionUrl()} TOKEN=${token} hawser`; - navigator.clipboard.writeText(cmd); - copyCmdSuccess = true; - setTimeout(() => { copyCmdSuccess = false; }, 2000); + const ok = await copyToClipboard(cmd); + copyCmdSuccess = ok ? 'ok' : 'error'; + setTimeout(() => { copyCmdSuccess = null; }, 2000); } function getConnectionUrl() { @@ -1883,7 +1885,14 @@ class="font-mono text-xs flex-1" />