This commit is contained in:
jarek
2026-01-02 15:29:49 +01:00
parent b89470e965
commit a1e07b1a10
11 changed files with 292 additions and 72 deletions
-3
View File
@@ -13,6 +13,3 @@
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
// Build trigger: 20260102-121809
+4 -1
View File
@@ -214,7 +214,10 @@
variableMarkers?: VariableMarker[];
}
let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers = [] }: Props = $props();
let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers: variableMarkersProp = [] }: Props = $props();
// Keep markers reactive - destructured props with defaults lose reactivity
const variableMarkers = $derived(variableMarkersProp);
let container: HTMLDivElement;
let view: EditorView | null = null;
+6 -5
View File
@@ -17,6 +17,7 @@
placeholder?: { key: string; value: string };
infoText?: string;
existingSecretKeys?: Set<string>;
theme?: 'light' | 'dark';
class?: string;
onchange?: () => void;
}
@@ -31,6 +32,7 @@
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
infoText,
existingSecretKeys = new Set<string>(),
theme = 'dark',
class: className = '',
onchange
}: Props = $props();
@@ -44,7 +46,6 @@
let confirmClearOpen = $state(false);
let contentAreaRef: HTMLDivElement;
let parseWarnings = $state<string[]>([]);
let editorTheme = $state<'light' | 'dark'>('dark');
let hasMergedOnLoad = $state(false);
// Count of secrets (for display in hint)
@@ -370,9 +371,9 @@
</div>
{:else if secretCount > 0}
<!-- Text view hint about secrets (only shown when secrets exist) -->
<div class="flex items-start gap-2 px-2 py-1.5 rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<ShieldAlert class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<div class="text-2xs text-amber-700 dark:text-amber-300">
<div class="flex items-start gap-2 px-2.5 py-2 rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<ShieldAlert class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<div class="text-xs text-amber-700 dark:text-amber-300">
<span class="font-medium">{secretCount} secret{secretCount === 1 ? '' : 's'} not shown.</span>
<span class="text-amber-600 dark:text-amber-400">Secrets are never written to disk and are injected via shell environment when the stack starts.</span>
</div>
@@ -429,7 +430,7 @@
<CodeEditor
value={rawContent}
language="dotenv"
theme={editorTheme}
theme={theme}
readonly={readonly}
onchange={handleTextChange}
class="h-full min-h-[200px] rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
@@ -0,0 +1,183 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { AlertCircle, Copy, Check, AlertTriangle, CheckCircle2, XCircle } from 'lucide-svelte';
interface Props {
open: boolean;
title: string;
message: string;
details?: string;
onClose: () => void;
}
let { open = $bindable(), title, message, details, onClose }: Props = $props();
let copied = $state(false);
interface ParsedOutput {
warnings: string[];
steps: { action: string; status: 'creating' | 'created' | 'starting' | 'started' | 'error' }[];
error: string | null;
raw: string;
parsed: boolean;
}
// Parse docker compose output into structured format
function parseDockerOutput(text: string): ParsedOutput {
const result: ParsedOutput = {
warnings: [],
steps: [],
error: null,
raw: text,
parsed: false
};
try {
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
for (const line of lines) {
// Parse time="..." level=warning msg="..."
const warningMatch = line.match(/time="[^"]*"\s+level=warning\s+msg="([^"]+)"/);
if (warningMatch) {
result.warnings.push(warningMatch[1]);
result.parsed = true;
continue;
}
// Parse container/network steps: "Network foo Creating" or "Container foo-1 Created"
const stepMatch = line.match(/^\s*(Network|Container|Volume)\s+(\S+)\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i);
if (stepMatch) {
const [, type, name, status] = stepMatch;
const normalizedStatus = status.toLowerCase() as any;
result.steps.push({
action: `${type} ${name}`,
status: normalizedStatus
});
result.parsed = true;
continue;
}
// Parse error lines
if (line.startsWith('Error') || line.includes('error') || line.includes('failed')) {
result.error = result.error ? `${result.error}\n${line}` : line;
result.parsed = true;
continue;
}
}
// If we parsed something but have no clear error, check for remaining unparsed content
if (result.parsed && !result.error) {
const unparsed = lines.filter(line => {
if (line.match(/time="[^"]*"\s+level=warning/)) return false;
if (line.match(/^\s*(Network|Container|Volume)\s+\S+\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i)) return false;
return true;
});
if (unparsed.length > 0) {
result.error = unparsed.join('\n');
}
}
} catch {
// Parsing failed, will show raw message
}
return result;
}
const parsed = $derived(parseDockerOutput(message));
async function copyError() {
const text = details ? `${message}\n\n${details}` : message;
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 2000);
}
function handleClose() {
open = false;
onClose();
}
</script>
<Dialog.Root bind:open onOpenChange={(o) => !o && handleClose()}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-destructive">
<AlertCircle class="w-5 h-5" />
{title}
</Dialog.Title>
</Dialog.Header>
<div class="space-y-3 max-h-[60vh] overflow-y-auto">
{#if parsed.parsed}
<!-- Parsed docker compose output -->
{#if parsed.warnings.length > 0}
<div class="space-y-1">
{#each parsed.warnings as warning}
<div class="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 px-2.5 py-1.5 rounded-md">
<AlertTriangle class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<span>{warning}</span>
</div>
{/each}
</div>
{/if}
{#if parsed.steps.length > 0}
<div class="bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md p-2.5 space-y-1">
{#each parsed.steps as step}
<div class="flex items-center gap-2 text-xs font-mono">
{#if step.status === 'created' || step.status === 'started' || step.status === 'removed' || step.status === 'stopped'}
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
{:else if step.status === 'error'}
<XCircle class="w-3.5 h-3.5 text-red-500" />
{:else}
<div class="w-3.5 h-3.5 rounded-full border-2 border-zinc-400"></div>
{/if}
<span class="text-zinc-600 dark:text-zinc-300">{step.action}</span>
<span class="text-zinc-400 dark:text-zinc-500 capitalize">{step.status}</span>
</div>
{/each}
</div>
{/if}
{#if parsed.error}
<div class="bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md p-3 relative group">
<button
onclick={copyError}
class="absolute top-2 right-2 p-1 rounded text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-opacity"
title="Copy error"
>
{#if copied}
<Check class="w-3.5 h-3.5" />
{:else}
<Copy class="w-3.5 h-3.5" />
{/if}
</button>
<pre class="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap break-words font-mono pr-6">{parsed.error}</pre>
</div>
{/if}
{:else}
<!-- Fallback to raw message -->
<div class="relative group">
<button
onclick={copyError}
class="absolute top-1 right-1 p-1 rounded text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy error"
>
{#if copied}
<Check class="w-3.5 h-3.5" />
{:else}
<Copy class="w-3.5 h-3.5" />
{/if}
</button>
<pre class="text-sm whitespace-pre-wrap font-sans pr-6">{message}</pre>
</div>
{/if}
{#if details}
<pre class="text-xs bg-zinc-100 dark:bg-zinc-800 p-3 rounded-md overflow-auto max-h-64 whitespace-pre-wrap break-all">{details}</pre>
{/if}
</div>
<Dialog.Footer class="flex gap-2 sm:justify-end">
<Button onclick={handleClose}>OK</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,3 @@
import ErrorDialog from './error-dialog.svelte';
export { ErrorDialog };
+8 -1
View File
@@ -58,7 +58,14 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
// Create a temporary SSH key file (use absolute path so SSH can find it)
const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`));
await Bun.write(sshKeyPath, credential.sshPrivateKey);
// Ensure SSH key ends with a newline (newer SSH versions are strict about this)
let keyContent = credential.sshPrivateKey;
if (!keyContent.endsWith('\n')) {
keyContent += '\n';
}
await Bun.write(sshKeyPath, keyContent);
// Ensure SSH key has correct permissions (0600 = owner read/write only)
// Bun.write's mode option doesn't always work reliably, so use chmodSync
chmodSync(sshKeyPath, 0o600);
+17 -22
View File
@@ -263,24 +263,19 @@ export async function getStackComposeFile(
): Promise<{ success: boolean; content?: string; stackDir?: string; error?: string }> {
const stacksDir = getStacksDir();
const stackDir = join(stacksDir, stackName);
const composeFile = join(stackDir, 'docker-compose.yml');
const ymlFile = Bun.file(composeFile);
if (await ymlFile.exists()) {
return {
success: true,
content: await ymlFile.text(),
stackDir
};
}
// Check all common compose file names (Docker Compose v1 and v2 naming conventions)
const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
const yamlFile = Bun.file(join(stackDir, 'docker-compose.yaml'));
if (await yamlFile.exists()) {
return {
success: true,
content: await yamlFile.text(),
stackDir
};
for (const fileName of composeFileNames) {
const file = Bun.file(join(stackDir, fileName));
if (await file.exists()) {
return {
success: true,
content: await file.text(),
stackDir
};
}
}
return {
@@ -1289,8 +1284,10 @@ export async function writeStackEnvFile(
const stacksDir = getStacksDir();
const envFilePath = join(stacksDir, stackName, '.env');
// SECURITY: Only write non-secret variables to .env file
// Secrets are stored in DB and injected via shell environment at runtime
const rawContent = variables
.filter(v => v.key?.trim())
.filter(v => v.key?.trim() && !v.isSecret)
.map(v => `${v.key.trim()}=${v.value}`)
.join('\n') + '\n';
@@ -1299,16 +1296,14 @@ export async function writeStackEnvFile(
/**
* Write raw environment content directly to the .env file (preserves comments/formatting)
*
* NOTE: Raw content should NOT contain secrets. Secrets are managed via the form view,
* stored in DB, and injected via shell environment at runtime.
*/
export async function writeRawStackEnvFile(
stackName: string,
rawContent: string
): Promise<void> {
// Guard against writing masked secret placeholders (would corrupt the file)
if (rawContent.match(/^[A-Za-z_][A-Za-z0-9_]*=\*\*\*$/m)) {
throw new Error('Cannot write masked placeholder "***" to .env file - this would corrupt secret values');
}
const stacksDir = getStacksDir();
const stackDir = join(stacksDir, stackName);
+20 -17
View File
@@ -96,17 +96,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
}
// Save environment variables
// NEW SIMPLIFIED: rawEnvContent contains ALL vars including secrets (with real values)
// Secrets are visually masked in the UI but stored with real values in .env file
// - rawEnvContent: non-secret vars with comments → .env file
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
if (rawEnvContent) {
// Write raw content directly to .env file (includes secrets with real values)
// Write raw content to .env file (should NOT contain secrets)
await writeRawStackEnvFile(name, rawEnvContent);
// Save secret metadata to DB (for UI masking purposes)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
} else if (envVars && Array.isArray(envVars) && envVars.length > 0) {
// Fallback: generate from vars (no raw content provided)
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum);
}
@@ -125,16 +126,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
// First ensure the stack directory exists by saving compose file
await saveStackComposeFile(name, compose, true);
// NEW SIMPLIFIED: rawEnvContent contains ALL vars including secrets (with real values)
// - rawEnvContent: non-secret vars with comments → .env file
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
if (rawEnvContent) {
// Write raw content directly to .env file (includes secrets with real values)
// Write raw content to .env file (should NOT contain secrets)
await writeRawStackEnvFile(name, rawEnvContent);
// Save secret metadata to DB (for UI masking purposes)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
} else {
// Fallback: generate from vars (no raw content provided)
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum);
}
}
@@ -49,11 +49,13 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
let result;
if (restart) {
// Deploy with docker compose up -d (only recreates changed services)
// Deploy with docker compose up -d --force-recreate
// Force recreate ensures env var changes are applied
result = await deployStack({
name,
compose: content,
envId: envIdNum
envId: envIdNum,
forceRecreate: true
});
} else {
// Just save the file without restarting
-2
View File
@@ -1055,8 +1055,6 @@
onDestroy(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
errorTimeouts.forEach(id => clearTimeout(id));
errorTimeouts = [];
});
</script>
+47 -19
View File
@@ -7,7 +7,7 @@
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 } from 'lucide-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 { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { focusFirstInput } from '$lib/utils';
@@ -32,6 +32,7 @@
let newStackName = $state('');
let loading = $state(false);
let saving = $state(false);
let savingWithRestart = $state(false); // Track which save action is in progress
let error = $state<string | null>(null);
let loadError = $state<string | null>(null);
let errors = $state<{ stackName?: string; compose?: string }>({});
@@ -56,6 +57,15 @@
// Stack location (for edit mode)
let stackLocation = $state<string | null>(null);
let pathCopied = $state(false);
function copyPath() {
if (stackLocation) {
navigator.clipboard.writeText(stackLocation);
pathCopied = true;
setTimeout(() => pathCopied = false, 2000);
}
}
// CodeEditor reference for explicit marker updates
let codeEditorRef: CodeEditor | null = $state(null);
@@ -181,13 +191,18 @@ services:
const displayName = $derived(mode === 'edit' ? stackName : (newStackName || 'New stack'));
onMount(() => {
// Follow app theme from localStorage
const appTheme = localStorage.getItem('theme');
if (appTheme === 'dark' || appTheme === 'light') {
editorTheme = appTheme;
// Load saved editor theme, or fall back to app theme / system preference
const savedEditorTheme = localStorage.getItem('dockhand-editor-theme');
if (savedEditorTheme === 'dark' || savedEditorTheme === 'light') {
editorTheme = savedEditorTheme;
} else {
// Fallback to system preference
editorTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const appTheme = localStorage.getItem('theme');
if (appTheme === 'dark' || appTheme === 'light') {
editorTheme = appTheme;
} else {
// Fallback to system preference
editorTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
}
// Load saved split ratio
@@ -395,6 +410,7 @@ services:
}
saving = true;
savingWithRestart = restart;
error = null;
// Prepare env vars for saving - syncs variables and rawContent
@@ -602,9 +618,20 @@ services:
{#if mode === 'create'}
Create a new Docker Compose stack
{:else if stackLocation}
<span class="flex items-center gap-1">
<FolderOpen class="w-3 h-3" />
<code class="bg-zinc-200 dark:bg-zinc-700 px-1 rounded text-2xs">{stackLocation}</code>
<span class="flex items-center gap-1.5">
<FolderOpen class="w-3.5 h-3.5" />
<code class="bg-zinc-200 dark:bg-zinc-700 px-1.5 py-0.5 rounded text-xs">{stackLocation}</code>
<button
onclick={copyPath}
class="p-0.5 rounded hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors"
title="Copy path"
>
{#if pathCopied}
<Check class="w-3.5 h-3.5 text-green-500" />
{:else}
<Copy class="w-3.5 h-3.5" />
{/if}
</button>
</span>
{:else}
Edit compose file and view stack structure
@@ -612,9 +639,11 @@ services:
</Dialog.Description>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<!-- View toggle -->
<div class="flex items-center gap-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-md p-0.5 ml-3">
<div class="flex items-center gap-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-md p-0.5">
<button
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs transition-colors {activeTab === 'editor' ? 'bg-white dark:bg-zinc-900 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={() => activeTab = 'editor'}
@@ -630,9 +659,7 @@ services:
Graph
</button>
</div>
</div>
<div class="flex items-center gap-1">
<!-- Theme toggle (only in editor mode) -->
{#if activeTab === 'editor'}
<button
@@ -787,6 +814,7 @@ services:
validation={envValidation}
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
onchange={() => { markDirty(); debouncedValidate(); }}
theme={editorTheme}
/>
</div>
</div>
@@ -840,8 +868,8 @@ services:
</Button>
{:else}
<!-- Edit mode buttons -->
<Button variant="outline" onclick={() => handleSave(false)} disabled={saving || loading || !!loadError}>
{#if saving}
<Button variant="outline" class="w-24" onclick={() => handleSave(false)} disabled={saving || loading || !!loadError}>
{#if saving && !savingWithRestart}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Saving...
{:else}
@@ -849,13 +877,13 @@ services:
Save
{/if}
</Button>
<Button onclick={() => handleSave(true)} disabled={saving || loading || !!loadError}>
{#if saving}
<Button class="w-36" onclick={() => handleSave(true)} disabled={saving || loading || !!loadError}>
{#if saving && savingWithRestart}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Applying...
Restarting...
{:else}
<Play class="w-4 h-4 mr-2" />
Save & apply
Save & restart
{/if}
</Button>
{/if}