1.0.6
This commit is contained in:
1 parent
66e723052d
commit
bd7b832394
9 files changed
+265
-134
No files matched your search
+11
-3
@@ -6,6 +6,9 @@
|
||||
# - Full transparency (no dependency on pre-built Chainguard images)
|
||||
# - Reproducible builds from open-source Wolfi packages
|
||||
# - Minimal attack surface with only required packages
|
||||
#
|
||||
# Bun is copied from the official oven/bun image (app-builder stage) to ensure
|
||||
# compatibility with all x86_64 CPUs (including those without AVX2 like Celeron).
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -15,13 +18,14 @@
|
||||
# to build our custom Wolfi OS from scratch using open-source packages.
|
||||
FROM alpine:3.21 AS os-builder
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
# Install apko tool (latest stable release)
|
||||
# apko is the tool Chainguard uses to build their images - we use it directly
|
||||
ARG APKO_VERSION=0.30.34
|
||||
ARG TARGETARCH
|
||||
RUN apk add --no-cache curl \
|
||||
RUN apk add --no-cache curl unzip \
|
||||
&& ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") \
|
||||
&& curl -sL "https://github.com/chainguard-dev/apko/releases/download/v${APKO_VERSION}/apko_${APKO_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz --strip-components=1 -C /usr/local/bin \
|
||||
@@ -29,6 +33,7 @@ RUN apk add --no-cache curl \
|
||||
|
||||
# Generate apko.yaml for current target architecture only
|
||||
# We build single-arch to avoid multi-arch layer confusion in extraction
|
||||
# Note: Bun is NOT included here - it's copied from app-builder stage for CPU compatibility
|
||||
RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") \
|
||||
&& printf '%s\n' \
|
||||
"contents:" \
|
||||
@@ -41,7 +46,6 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - ca-certificates" \
|
||||
" - busybox" \
|
||||
" - tzdata" \
|
||||
" - bun" \
|
||||
" - docker-cli" \
|
||||
" - docker-compose" \
|
||||
" - sqlite" \
|
||||
@@ -99,6 +103,10 @@ FROM scratch
|
||||
# Install our custom-built Wolfi OS (now we have /bin/sh!)
|
||||
COPY --from=os-builder /work/rootfs/ /
|
||||
|
||||
# Copy Bun from official image - ensures compatibility with all x86_64 CPUs (no AVX2 requirement)
|
||||
# Wolfi's bun package requires AVX2 which breaks on Celeron/Atom CPUs
|
||||
COPY --from=app-builder /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Set up environment variables
|
||||
|
||||
@@ -287,46 +287,68 @@
|
||||
<div class="flex flex-col h-full {className}">
|
||||
<!-- Header -->
|
||||
<div class="px-4 py-2.5 border-b border-zinc-200 dark:border-zinc-700 flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 flex-nowrap min-w-0">
|
||||
<span class="text-xs text-zinc-500 dark:text-zinc-400">Environment variables</span>
|
||||
{#if infoText}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="w-3.5 h-3.5 text-blue-400" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom" sideOffset={8} class="max-w-xs w-64 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700">
|
||||
<p class="text-xs text-left">{infoText}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
<!-- View mode toggle -->
|
||||
<div class="flex items-center gap-0.5 bg-zinc-100 dark:bg-zinc-800 rounded p-0.5 ml-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'form' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
|
||||
onclick={() => handleViewModeChange('form')}
|
||||
title="Form view"
|
||||
>
|
||||
<List class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'text' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
|
||||
onclick={() => handleViewModeChange('text')}
|
||||
title="Text view (raw .env file)"
|
||||
>
|
||||
<FileText class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Header row: title + info + view toggle + validation pills + actions -->
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<div class="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<span class="text-xs text-zinc-500 dark:text-zinc-400 shrink-0">Environment variables</span>
|
||||
{#if infoText}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom" sideOffset={8} class="max-w-xs w-64 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700">
|
||||
<p class="text-xs text-left">{infoText}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
<!-- View mode toggle -->
|
||||
<div class="flex items-center gap-0.5 bg-zinc-100 dark:bg-zinc-800 rounded p-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'form' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
|
||||
onclick={() => handleViewModeChange('form')}
|
||||
title="Form view"
|
||||
>
|
||||
<List class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'text' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
|
||||
onclick={() => handleViewModeChange('text')}
|
||||
title="Text view (raw .env file)"
|
||||
>
|
||||
<FileText class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Validation status pills -->
|
||||
{#if validation}
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
{#if validation.missing.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{validation.missing.length} missing
|
||||
</span>
|
||||
{/if}
|
||||
{#if validation.required.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
{validation.required.length - validation.missing.length} defined
|
||||
</span>
|
||||
{/if}
|
||||
{#if validation.optional.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
{validation.optional.length} optional
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Actions - right-aligned -->
|
||||
{#if !readonly}
|
||||
<div class="flex items-center gap-1 shrink-0 ml-4">
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button type="button" size="sm" variant="ghost" onclick={handleLoadFromFile} class="h-6 text-xs px-2">
|
||||
<Upload class="w-3.5 h-3.5 mr-1" />
|
||||
Load .env
|
||||
Load
|
||||
</Button>
|
||||
{#if viewMode === 'form'}
|
||||
<Button type="button" size="sm" variant="ghost" onclick={addEnvVariable} class="h-6 text-xs px-2">
|
||||
@@ -334,24 +356,28 @@
|
||||
Add
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="{hasContent ? '' : 'invisible'}">
|
||||
<ConfirmPopover
|
||||
bind:open={confirmClearOpen}
|
||||
title="Clear all variables?"
|
||||
action="clear"
|
||||
itemType="environment variables"
|
||||
confirmText="Clear all"
|
||||
onConfirm={clearAll}
|
||||
onOpenChange={(o) => confirmClearOpen = o}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<Button type="button" size="sm" variant="ghost" class="h-6 text-xs px-2 text-destructive hover:text-destructive">
|
||||
<Trash2 class="w-3.5 h-3.5 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
</div>
|
||||
<ConfirmPopover
|
||||
bind:open={confirmClearOpen}
|
||||
title="Clear all variables?"
|
||||
action="clear"
|
||||
itemType="environment variables"
|
||||
confirmText="Clear all"
|
||||
onConfirm={clearAll}
|
||||
onOpenChange={(o) => confirmClearOpen = o}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-6 text-xs px-2 {hasContent ? 'text-destructive hover:text-destructive' : 'text-muted-foreground/50 cursor-not-allowed'}"
|
||||
disabled={!hasContent}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
</div>
|
||||
<input
|
||||
bind:this={fileInputRef}
|
||||
|
||||
@@ -498,12 +498,18 @@
|
||||
// Row state cache to prevent creating new objects on every scroll
|
||||
let rowStateCache = new WeakMap<object, DataGridRowState>();
|
||||
let rowStateCacheDataRef: T[] | null = null;
|
||||
let rowStateCacheExpandedRef: Set<unknown> | null = null;
|
||||
let rowStateCacheSelectedRef: Set<unknown> | null = null;
|
||||
|
||||
// Clear row state cache when data reference changes
|
||||
// Clear row state cache when data or selection/expansion state changes
|
||||
$effect(() => {
|
||||
if (data !== rowStateCacheDataRef) {
|
||||
if (data !== rowStateCacheDataRef ||
|
||||
expandedKeys !== rowStateCacheExpandedRef ||
|
||||
selectedKeys !== rowStateCacheSelectedRef) {
|
||||
rowStateCache = new WeakMap();
|
||||
rowStateCacheDataRef = data;
|
||||
rowStateCacheExpandedRef = expandedKeys;
|
||||
rowStateCacheSelectedRef = selectedKeys;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.6",
|
||||
"date": "2026-01-03",
|
||||
"changes": [
|
||||
{ "type": "fix", "text": "Legacy CPU support (Celeron, Atom) - Bun binary now copied from official image instead of Wolfi package" },
|
||||
{ "type": "fix", "text": "Stack modal layouts improved with resizable split panels" },
|
||||
{ "type": "fix", "text": "Missing column headers in images overview" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.6"
|
||||
},
|
||||
{
|
||||
"version": "1.0.5",
|
||||
"date": "2026-01-01",
|
||||
|
||||
@@ -307,7 +307,9 @@
|
||||
total = data.total;
|
||||
|
||||
if (append) {
|
||||
events = [...events, ...data.events];
|
||||
// Use push() for O(k) append instead of spread for O(n) copy
|
||||
events.push(...data.events);
|
||||
events = events; // Trigger Svelte reactivity
|
||||
hasMore = events.length < total;
|
||||
// Update eventIds Set with new events
|
||||
for (const evt of data.events) {
|
||||
@@ -517,12 +519,16 @@
|
||||
// Add to beginning of events (prepend new events) - use Set for fast duplicate check
|
||||
if (!eventIds.has(newEvent.id)) {
|
||||
eventIds.add(newEvent.id);
|
||||
events = [newEvent, ...events];
|
||||
// Use unshift() for in-place mutation instead of spread for O(n) copy
|
||||
events.unshift(newEvent);
|
||||
events = events; // Trigger Svelte reactivity
|
||||
total = total + 1;
|
||||
|
||||
// Add container to list if not already there
|
||||
if (newEvent.containerName && !containers.includes(newEvent.containerName)) {
|
||||
containers = [...containers, newEvent.containerName].sort();
|
||||
containers.push(newEvent.containerName);
|
||||
containers.sort();
|
||||
containers = containers; // Trigger Svelte reactivity
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2 } from 'lucide-svelte';
|
||||
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-svelte';
|
||||
import { broom, whale } from '@lucide/lab';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
@@ -720,6 +720,25 @@
|
||||
<Square class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if column.sortable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleSort(column.sortField ?? column.id)}
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors w-full"
|
||||
>
|
||||
{column.label}
|
||||
{#if sortState?.field === (column.sortField ?? column.id)}
|
||||
{#if sortState.direction === 'asc'}
|
||||
<ArrowUp class="w-3 h-3" />
|
||||
{:else}
|
||||
<ArrowDown class="w-3 h-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="w-3 h-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if column.id !== 'expand' && column.id !== 'actions'}
|
||||
{column.label}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet cell(column, group, rowState)}
|
||||
|
||||
@@ -1270,10 +1270,7 @@
|
||||
sortDirection = state.direction;
|
||||
}}
|
||||
onRowClick={(stack, e) => {
|
||||
const hasContainers = stack.containers && stack.containers.length > 0;
|
||||
if (hasContainers) {
|
||||
toggleExpand(stack.name);
|
||||
}
|
||||
toggleExpand(stack.name);
|
||||
}}
|
||||
rowClass={(stack) => {
|
||||
const isExp = expandedStacks.has(stack.name);
|
||||
@@ -1904,6 +1901,13 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 pl-12 shadow-inner bg-muted/30">
|
||||
<div class="flex items-center justify-center gap-2 py-4 text-muted-foreground text-sm">
|
||||
<Box class="w-4 h-4" />
|
||||
<span>No containers</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataGrid>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle } from 'lucide-svelte';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
|
||||
|
||||
// Get sidebar state to adjust modal positioning
|
||||
const sidebar = useSidebar();
|
||||
|
||||
// localStorage key for persisted split ratio
|
||||
const STORAGE_KEY_SPLIT = 'dockhand-git-stack-modal-split';
|
||||
|
||||
interface GitCredential {
|
||||
id: number;
|
||||
@@ -92,6 +100,11 @@
|
||||
let loadingFileVars = $state(false);
|
||||
let existingSecretKeys = $state<Set<string>>(new Set());
|
||||
|
||||
// Resizable split panel state
|
||||
let splitRatio = $state(60); // percentage for form panel
|
||||
let isDraggingSplit = $state(false);
|
||||
let containerRef: HTMLDivElement | null = $state(null);
|
||||
|
||||
// Derived state for merged variables and sources
|
||||
const envVarSources = $derived<Record<string, 'file' | 'override'>>(() => {
|
||||
const sources: Record<string, 'file' | 'override'> = {};
|
||||
@@ -124,6 +137,48 @@
|
||||
// Derived state for selected repository
|
||||
let selectedRepo = $derived(formRepositoryId ? repositories.find(r => r.id === formRepositoryId) : null);
|
||||
|
||||
onMount(() => {
|
||||
// Load saved split ratio
|
||||
const savedSplit = localStorage.getItem(STORAGE_KEY_SPLIT);
|
||||
if (savedSplit) {
|
||||
const ratio = parseFloat(savedSplit);
|
||||
if (!isNaN(ratio) && ratio >= 30 && ratio <= 80) {
|
||||
splitRatio = ratio;
|
||||
}
|
||||
}
|
||||
|
||||
// Add global mouse event listeners for split dragging
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
// Split panel drag handlers
|
||||
function startSplitDrag(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
isDraggingSplit = true;
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (isDraggingSplit && containerRef) {
|
||||
const rect = containerRef.getBoundingClientRect();
|
||||
const newRatio = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
splitRatio = Math.max(30, Math.min(80, newRatio));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
if (isDraggingSplit) {
|
||||
isDraggingSplit = false;
|
||||
// Save split ratio
|
||||
localStorage.setItem(STORAGE_KEY_SPLIT, splitRatio.toString());
|
||||
}
|
||||
}
|
||||
|
||||
function generateWebhookSecret(): string {
|
||||
const array = new Uint8Array(24);
|
||||
crypto.getRandomValues(array);
|
||||
@@ -388,20 +443,40 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (isOpen) focusFirstInput(); }}>
|
||||
<Dialog.Content class="max-w-6xl max-h-[90vh] flex flex-col overflow-hidden p-0">
|
||||
<Dialog.Header class="shrink-0 px-6 pt-6 pb-4 border-b">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<GitBranch class="w-5 h-5" />
|
||||
{gitStack ? 'Edit git stack' : 'Deploy from Git'}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{gitStack ? 'Update git stack settings' : 'Deploy a compose stack from a Git repository'}
|
||||
</Dialog.Description>
|
||||
<Dialog.Content
|
||||
class="max-w-none h-[95vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700 {sidebar.state === 'collapsed' ? 'w-[calc(100vw-6rem)] ml-[1.5rem]' : 'w-[calc(100vw-12rem)] ml-[4.5rem]'}"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
|
||||
<GitBranch class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
{gitStack ? 'Edit git stack' : 'Deploy from Git'}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{gitStack ? 'Update git stack settings' : 'Deploy a compose stack from a Git repository'}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<div bind:this={containerRef} class="flex-1 min-h-0 flex {isDraggingSplit ? 'select-none' : ''}">
|
||||
<!-- Left column: Form fields -->
|
||||
<div class="flex-1 overflow-y-auto space-y-4 py-4 px-6 border-r border-zinc-200 dark:border-zinc-700">
|
||||
<div class="flex-shrink-0 flex flex-col min-w-0 overflow-y-auto" style="width: {splitRatio}%">
|
||||
<div class="space-y-4 py-4 px-6">
|
||||
<!-- Repository selection -->
|
||||
{#if !gitStack}
|
||||
<div class="space-y-3">
|
||||
@@ -774,10 +849,24 @@
|
||||
{#if formError}
|
||||
<p class="text-sm text-destructive">{formError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resizable divider -->
|
||||
<div
|
||||
class="w-1 flex-shrink-0 bg-zinc-200 dark:bg-zinc-700 hover:bg-blue-400 dark:hover:bg-blue-500 cursor-col-resize transition-colors flex items-center justify-center group {isDraggingSplit ? 'bg-blue-500 dark:bg-blue-400' : ''}"
|
||||
onmousedown={startSplitDrag}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="w-4 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity {isDraggingSplit ? 'opacity-100' : ''}">
|
||||
<GripVertical class="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Environment Variables -->
|
||||
<div class="w-[380px] flex-shrink-0 flex flex-col bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<StackEnvVarsPanel
|
||||
bind:variables={envVars}
|
||||
showSource={!!formEnvFilePath && gitStack !== null}
|
||||
@@ -789,7 +878,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="shrink-0 border-t px-6 py-4">
|
||||
<Dialog.Footer class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<Button variant="outline" onclick={onClose}>Cancel</Button>
|
||||
{#if gitStack}
|
||||
<Button variant="outline" onclick={() => saveGitStack(true)} disabled={formSaving}>
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable, HelpCircle, GripVertical, FolderOpen, Copy, Check } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check } from 'lucide-svelte';
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { ErrorDialog } from '$lib/components/ui/error-dialog';
|
||||
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
|
||||
|
||||
// Get sidebar state to adjust modal positioning
|
||||
const sidebar = useSidebar();
|
||||
|
||||
// localStorage key for persisted split ratio
|
||||
const STORAGE_KEY_SPLIT = 'dockhand-stack-modal-split';
|
||||
@@ -596,7 +599,7 @@ services:
|
||||
}}
|
||||
>
|
||||
<Dialog.Content
|
||||
class="max-w-none w-[calc(100vw-12rem)] h-[95vh] ml-[4.5rem] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700"
|
||||
class="max-w-none h-[95vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700 {sidebar.state === 'collapsed' ? 'w-[calc(100vw-6rem)] ml-[1.5rem]' : 'w-[calc(100vw-12rem)] ml-[4.5rem]'}"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
@@ -767,56 +770,16 @@ services:
|
||||
</div>
|
||||
<!-- Environment variables panel -->
|
||||
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-700 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
<Variable class="w-3.5 h-3.5" />
|
||||
Environment variables
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div class="w-64">
|
||||
<p class="text-xs">These variables will be written to a <code class="bg-muted px-1 rounded">.env</code> file in the stack directory.</p>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<!-- Validation status pills -->
|
||||
{#if envValidation}
|
||||
<div class="flex gap-1 ml-auto">
|
||||
{#if envValidation.missing.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{envValidation.missing.length} missing
|
||||
</span>
|
||||
{/if}
|
||||
{#if envValidation.required.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
{envValidation.required.length - envValidation.missing.length} required
|
||||
</span>
|
||||
{/if}
|
||||
{#if envValidation.optional.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
{envValidation.optional.length} optional
|
||||
</span>
|
||||
{/if}
|
||||
{#if envValidation.unused.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
{envValidation.unused.length} unused
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<StackEnvVarsPanel
|
||||
bind:this={envVarsPanelRef}
|
||||
bind:variables={envVars}
|
||||
bind:rawContent={rawEnvContent}
|
||||
validation={envValidation}
|
||||
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
|
||||
onchange={() => { markDirty(); debouncedValidate(); }}
|
||||
theme={editorTheme}
|
||||
/>
|
||||
</div>
|
||||
<StackEnvVarsPanel
|
||||
bind:this={envVarsPanelRef}
|
||||
bind:variables={envVars}
|
||||
bind:rawContent={rawEnvContent}
|
||||
validation={envValidation}
|
||||
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
|
||||
onchange={() => { markDirty(); debouncedValidate(); }}
|
||||
theme={editorTheme}
|
||||
infoText="These variables will be written to a .env file in the stack directory."
|
||||
/>
|
||||
</div>
|
||||
{:else if activeTab === 'graph'}
|
||||
<!-- Graph tab: Full width -->
|
||||
|
||||
Reference in new issue
Block a user