This commit is contained in:
jarek committed 2026-01-01 16:00:34 +01:00
1 parent 5475112806
commit 242f8df49d
35 files changed
+1892 -1987

No files matched your search

+1
View File
@@ -86,6 +86,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
tlsCa: data.tlsCa,
tlsCert: data.tlsCert,
tlsKey: data.tlsKey,
tlsSkipVerify: data.tlsSkipVerify || false,
icon: data.icon || 'globe',
socketPath: data.socketPath || '/var/run/docker.sock',
collectActivity: data.collectActivity !== false,
@@ -65,6 +65,7 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
tlsCa: data.tlsCa,
tlsCert: data.tlsCert,
tlsKey: data.tlsKey,
tlsSkipVerify: data.tlsSkipVerify,
icon: data.icon,
socketPath: data.socketPath,
collectActivity: data.collectActivity,
+15 -2
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { listComposeStacks, deployStack, saveStackComposeFile } from '$lib/server/stacks';
import { listComposeStacks, deployStack, saveStackComposeFile, saveStackEnvVars } from '$lib/server/stacks';
import { EnvironmentNotFoundError } from '$lib/server/docker';
import { upsertStackSource, getStackSources } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
@@ -78,7 +78,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
try {
const body = await request.json();
const { name, compose, start } = body;
const { name, compose, start, envVars } = body;
if (!name || typeof name !== 'string') {
return json({ error: 'Stack name is required' }, { status: 400 });
@@ -95,6 +95,11 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
return json({ error: result.error }, { status: 400 });
}
// Save environment variables if provided (to both DB and .env file)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum);
}
// Record the stack as internally created
await upsertStackSource({
stackName: name,
@@ -105,6 +110,14 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
return json({ success: true, started: false });
}
// Save environment variables BEFORE deploying so they're available during start
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
// First ensure the stack directory exists by saving compose file
await saveStackComposeFile(name, compose, true);
// Save to both DB and .env file
await saveStackEnvVars(name, envVars, envIdNum);
}
// Deploy and start the stack
const result = await deployStack({
name,
+156 -8
View File
@@ -1,11 +1,96 @@
import { json } from '@sveltejs/kit';
import { getStackEnvVars, setStackEnvVars } from '$lib/server/db';
import { getStacksDir } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { RequestHandler } from './$types';
/**
* Parse a .env file content into key-value pairs
*/
function parseEnvFile(content: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1);
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[key] = value;
}
}
return result;
}
/**
* Merge new variables into existing .env file content.
* - Keeps comments and formatting
* - Updates values for existing keys
* - REMOVES keys that are not in newVars (user deleted them)
* - Appends new keys at the end
*/
function mergeEnvFileContent(
existingContent: string,
newVars: { key: string; value: string }[]
): string {
const newVarsMap = new Map(newVars.map(v => [v.key, v.value]));
const handledKeys = new Set<string>();
const lines = existingContent.split('\n');
const resultLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
// Keep comments and blank lines as-is
if (!trimmed || trimmed.startsWith('#')) {
resultLines.push(line);
continue;
}
// Check if this is a variable line
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
if (newVarsMap.has(key)) {
// Update existing variable with new value from UI
resultLines.push(`${key}=${newVarsMap.get(key)}`);
handledKeys.add(key);
}
// If key not in newVarsMap, it was deleted - skip it (don't add to resultLines)
} else {
// Not a valid variable line, keep as-is
resultLines.push(line);
}
}
// Append any new variables that weren't in the original file
for (const v of newVars) {
if (!handledKeys.has(v.key)) {
resultLines.push(`${v.key}=${v.value}`);
}
}
// Ensure file ends with newline
let result = resultLines.join('\n');
if (!result.endsWith('\n')) {
result += '\n';
}
return result;
}
/**
* GET /api/stacks/[name]/env?env=X
* Get all environment variables for a stack.
* Merges variables from database with .env file (file values shown if different).
* Secrets are masked with '***' in the response.
*/
export const GET: RequestHandler = async ({ params, url, cookies }) => {
@@ -25,15 +110,52 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const stackName = decodeURIComponent(params.name);
const variables = await getStackEnvVars(stackName, envIdNum, true);
return json({
variables: variables.map(v => ({
key: v.key,
value: v.value,
isSecret: v.isSecret
}))
});
// Get variables from database
const dbVariables = await getStackEnvVars(stackName, envIdNum, true);
const dbByKey = new Map(dbVariables.map(v => [v.key, v]));
// Try to read .env file from stack directory
const stacksDir = getStacksDir();
const envFilePath = join(stacksDir, stackName, '.env');
let fileVars: Record<string, string> = {};
if (existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
fileVars = parseEnvFile(content);
} catch (e) {
// Ignore file read errors
}
}
// Merge: start with DB variables, add any new keys from file
const mergedKeys = new Set([...dbByKey.keys(), ...Object.keys(fileVars)]);
const variables: { key: string; value: string; isSecret: boolean }[] = [];
for (const key of mergedKeys) {
const dbVar = dbByKey.get(key);
const fileValue = fileVars[key];
if (dbVar) {
// Variable exists in DB
if (dbVar.isSecret) {
// Keep secret masked
variables.push({ key, value: dbVar.value, isSecret: true });
} else if (fileValue !== undefined && fileValue !== dbVar.value) {
// File has different value - use file value (user may have edited it)
variables.push({ key, value: fileValue, isSecret: false });
} else {
// Use DB value
variables.push({ key, value: dbVar.value, isSecret: false });
}
} else if (fileValue !== undefined) {
// Variable only in file - add it as non-secret
variables.push({ key, value: fileValue, isSecret: false });
}
}
return json({ variables });
} catch (error) {
console.error('Error getting stack env vars:', error);
return json({ error: 'Failed to get environment variables' }, { status: 500 });
@@ -114,6 +236,32 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
await setStackEnvVars(stackName, envIdNum, variablesToSave);
// Also write the .env file to the stack directory
// This allows users to see/edit variables outside of Dockhand
const stacksDir = getStacksDir();
const stackDir = join(stacksDir, stackName);
const envFilePath = join(stackDir, '.env');
// Only write if stack directory exists
if (existsSync(stackDir)) {
// Read existing file to preserve comments and formatting
let existingContent = '';
if (existsSync(envFilePath)) {
try {
existingContent = await Bun.file(envFilePath).text();
} catch {
// File read failed, start fresh
}
}
// Merge UI vars with existing file (preserves comments, keeps file vars)
const envContent = mergeEnvFileContent(
existingContent,
variablesToSave.map((v: { key: string; value: string }) => ({ key: v.key, value: v.value }))
);
await Bun.write(envFilePath, envContent);
}
return json({ success: true, count: variablesToSave.length });
} catch (error) {
console.error('Error setting stack env vars:', error);
+8 -2
View File
@@ -128,9 +128,15 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
}, { status: 201 });
} catch (error: any) {
console.error('Failed to create user:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
console.error('Error details:', {
message: error.message,
code: error.code,
name: error.name,
stack: error.stack
});
if (error.message?.includes('UNIQUE constraint failed') || error.code === '23505') {
return json({ error: 'Username already exists' }, { status: 409 });
}
return json({ error: 'Failed to create user' }, { status: 500 });
return json({ error: 'Failed to create user', details: error.message }, { status: 500 });
}
};
+7 -15
View File
@@ -1,9 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { isEnterprise } from '$lib/server/license';
import {
validateSession,
checkPermission,
generateMfaSetup,
verifyAndEnableMfa,
disableMfa
@@ -11,11 +9,6 @@ import {
// POST /api/users/[id]/mfa - Setup MFA (generate QR code)
export const POST: RequestHandler = async ({ params, request, cookies }) => {
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const currentUser = await validateSession(cookies);
if (!params.id) {
@@ -38,12 +31,16 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'MFA token is required' }, { status: 400 });
}
const success = await verifyAndEnableMfa(userId, body.token);
if (!success) {
const result = await verifyAndEnableMfa(userId, body.token);
if (!result.success) {
return json({ error: 'Invalid MFA code' }, { status: 400 });
}
return json({ success: true, message: 'MFA enabled successfully' });
return json({
success: true,
message: 'MFA enabled successfully',
backupCodes: result.backupCodes
});
}
// Generate new MFA setup
@@ -64,11 +61,6 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
// DELETE /api/users/[id]/mfa - Disable MFA
export const DELETE: RequestHandler = async ({ params, cookies }) => {
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const currentUser = await validateSession(cookies);
if (!params.id) {
+14 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog';
import * as Popover from '$lib/components/ui/popover';
@@ -1626,7 +1627,12 @@
{@const ports = formatPorts(container.ports)}
{@const stack = getComposeProject(container.labels)}
{#if column.id === 'name'}
<span class="text-xs font-medium truncate block" title={container.name}>{container.name}</span>
<button
type="button"
class="text-xs font-medium truncate block text-left hover:text-primary hover:underline cursor-pointer"
title={container.name}
onclick={(e) => { e.stopPropagation(); inspectContainer(container); }}
>{container.name}</button>
{:else if column.id === 'image'}
<div class="flex items-center gap-1.5 {$appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) ? 'update-border' : ''}">
{#if containersWithUpdatesSet.has(container.id)}
@@ -1765,7 +1771,13 @@
{/if}
{:else if column.id === 'stack'}
{#if stack}
<Badge variant="outline" class="text-xs py-0 px-1.5">{stack}</Badge>
<button
type="button"
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/stacks?search=${encodeURIComponent(stack)}`, envId)); }}
class="cursor-pointer"
>
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors">{stack}</Badge>
</button>
{:else}
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
{/if}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2 -5
View File
@@ -270,17 +270,14 @@
<Input
id="mfaToken"
type="text"
placeholder="Enter 6-digit code"
placeholder="Enter code"
bind:value={mfaToken}
required
disabled={loading}
autocomplete="one-time-code"
inputmode="numeric"
pattern="[0-9]*"
maxlength={6}
/>
<p class="text-xs text-muted-foreground">
Enter the code from your authenticator app
Enter the 6-digit code from your authenticator app, or use a backup code
</p>
</div>
{/if}
+2 -1
View File
@@ -294,7 +294,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
}
if (urlContainerId) {
// Single container from URL
// Single container from URL - always switch to single mode
layoutMode = 'single';
const container = fetchedContainers.find(c => c.id === urlContainerId || c.id.startsWith(urlContainerId));
if (container) {
selectContainer(container);
+12 -20
View File
@@ -26,7 +26,6 @@
} from 'lucide-svelte';
import { authStore } from '$lib/stores/auth';
import * as Alert from '$lib/components/ui/alert';
import { licenseStore } from '$lib/stores/license';
import AvatarCropper from '$lib/components/AvatarCropper.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import ChangePasswordModal from './ChangePasswordModal.svelte';
@@ -542,26 +541,19 @@
</p>
</div>
</div>
{#if $licenseStore.isEnterprise}
{#if profile.mfaEnabled}
<Button variant="outline" onclick={() => showDisableMfaModal = true}>
Disable MFA
</Button>
{:else}
<Button onclick={setupMfa} disabled={mfaLoading}>
{#if mfaLoading}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<QrCode class="w-4 h-4 mr-1" />
{/if}
Setup MFA
</Button>
{/if}
{#if profile.mfaEnabled}
<Button variant="outline" onclick={() => showDisableMfaModal = true}>
Disable MFA
</Button>
{:else}
<Badge variant="outline" class="gap-1 rounded-sm">
<Crown class="w-3 h-3" />
Enterprise
</Badge>
<Button onclick={setupMfa} disabled={mfaLoading}>
{#if mfaLoading}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<QrCode class="w-4 h-4 mr-1" />
{/if}
Setup MFA
</Button>
{/if}
</div>
{:else}
+134 -45
View File
@@ -3,7 +3,7 @@
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { QrCode, RefreshCw, ShieldCheck, TriangleAlert } from 'lucide-svelte';
import { QrCode, RefreshCw, ShieldCheck, TriangleAlert, Copy, Download, Check } from 'lucide-svelte';
import * as Alert from '$lib/components/ui/alert';
import { focusFirstInput } from '$lib/utils';
@@ -21,10 +21,16 @@
let token = $state('');
let loading = $state(false);
let error = $state('');
let backupCodes = $state<string[]>([]);
let showBackupCodes = $state(false);
let copied = $state(false);
function resetForm() {
token = '';
error = '';
backupCodes = [];
showBackupCodes = false;
copied = false;
}
async function verifyAndEnableMfa() {
@@ -43,11 +49,12 @@
body: JSON.stringify({ action: 'verify', token })
});
const data = await response.json();
if (response.ok) {
onSuccess();
onClose();
backupCodes = data.backupCodes || [];
showBackupCodes = true;
} else {
const data = await response.json();
error = data.error || 'Invalid verification code';
}
} catch (e) {
@@ -57,61 +64,143 @@
}
}
function formatBackupCodes(): string {
return backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n');
}
async function copyBackupCodes() {
try {
await navigator.clipboard.writeText(formatBackupCodes());
copied = true;
setTimeout(() => copied = false, 2000);
} catch {
error = 'Failed to copy to clipboard';
}
}
function downloadBackupCodes() {
const content = `Dockhand MFA Backup Codes\n${'='.repeat(30)}\n\nThese codes can be used to sign in if you lose access to your authenticator app.\nEach code can only be used once.\n\n${formatBackupCodes()}\n\nGenerated: ${new Date().toISOString()}`;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'dockhand-backup-codes.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function handleDone() {
onSuccess();
onClose();
}
</script>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { resetForm(); focusFirstInput(); } else onClose(); }}>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { resetForm(); focusFirstInput(); } else if (!showBackupCodes) onClose(); }}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<QrCode class="w-5 h-5" />
Setup two-factor authentication
{#if showBackupCodes}
<ShieldCheck class="w-5 h-5 text-green-500" />
MFA enabled successfully
{:else}
<QrCode class="w-5 h-5" />
Setup two-factor authentication
{/if}
</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
{#if error}
<Alert.Root variant="destructive">
{#if showBackupCodes}
<!-- Backup codes view -->
<div class="space-y-4">
<Alert.Root>
<TriangleAlert class="h-4 w-4" />
<Alert.Description>{error}</Alert.Description>
<Alert.Description>
Save these backup codes in a safe place. Each code can only be used once to sign in if you lose access to your authenticator app.
</Alert.Description>
</Alert.Root>
{/if}
<p class="text-sm text-muted-foreground">
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
</p>
{#if qrCode}
<div class="flex justify-center p-4 bg-white rounded-lg">
<img src={qrCode} alt="MFA QR Code" class="w-48 h-48" />
<div class="grid grid-cols-2 gap-2 p-3 bg-muted rounded-lg font-mono text-sm">
{#each backupCodes as code, i}
<div class="flex items-center gap-2">
<span class="text-muted-foreground w-4">{i + 1}.</span>
<span>{code}</span>
</div>
{/each}
</div>
{/if}
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">Or enter this code manually:</Label>
<code class="block p-2 bg-muted rounded text-sm font-mono break-all">{secret}</code>
<div class="flex gap-2">
<Button variant="outline" class="flex-1" onclick={copyBackupCodes}>
{#if copied}
<Check class="w-4 h-4 mr-1" />
Copied!
{:else}
<Copy class="w-4 h-4 mr-1" />
Copy codes
{/if}
</Button>
<Button variant="outline" class="flex-1" onclick={downloadBackupCodes}>
<Download class="w-4 h-4 mr-1" />
Download
</Button>
</div>
</div>
<div class="space-y-2">
<Label>Verification code</Label>
<Input
bind:value={token}
placeholder="Enter 6-digit code"
maxlength={6}
/>
<p class="text-xs text-muted-foreground">
Enter the code from your authenticator app to verify setup
</p>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={onClose}>Cancel</Button>
<Button onclick={verifyAndEnableMfa} disabled={loading || !token}>
{#if loading}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<Dialog.Footer>
<Button onclick={handleDone}>
<ShieldCheck class="w-4 h-4 mr-1" />
Done
</Button>
</Dialog.Footer>
{:else}
<!-- Setup view -->
<div class="space-y-4">
{#if error}
<Alert.Root variant="destructive">
<TriangleAlert class="h-4 w-4" />
<Alert.Description>{error}</Alert.Description>
</Alert.Root>
{/if}
Enable MFA
</Button>
</Dialog.Footer>
<p class="text-sm text-muted-foreground">
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
</p>
{#if qrCode}
<div class="flex justify-center p-4 bg-white rounded-lg">
<img src={qrCode} alt="MFA QR Code" class="w-48 h-48" />
</div>
{/if}
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">Or enter this code manually:</Label>
<code class="block p-2 bg-muted rounded text-sm font-mono break-all">{secret}</code>
</div>
<div class="space-y-2">
<Label>Verification code</Label>
<Input
bind:value={token}
placeholder="Enter 6-digit code"
maxlength={6}
/>
<p class="text-xs text-muted-foreground">
Enter the code from your authenticator app to verify setup
</p>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={onClose}>Cancel</Button>
<Button onclick={verifyAndEnableMfa} disabled={loading || !token}>
{#if loading}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<ShieldCheck class="w-4 h-4 mr-1" />
{/if}
Enable MFA
</Button>
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -235,7 +235,7 @@
toast.success('User created');
} else {
const data = await response.json();
formError = data.error || 'Failed to create user';
formError = data.details ? `${data.error}: ${data.details}` : (data.error || 'Failed to create user');
toast.error(formError);
}
} catch {
@@ -45,6 +45,7 @@
let formSmtpHost = $state('');
let formSmtpPort = $state(587);
let formSmtpSecure = $state(false);
let formSmtpSkipTlsVerify = $state(false);
let formSmtpUsername = $state('');
let formSmtpPassword = $state('');
let formSmtpFromEmail = $state('');
@@ -68,6 +69,7 @@
formSmtpHost = '';
formSmtpPort = 587;
formSmtpSecure = false;
formSmtpSkipTlsVerify = false;
formSmtpUsername = '';
formSmtpPassword = '';
formSmtpFromEmail = '';
@@ -98,6 +100,7 @@
formSmtpHost = notification.config.host || '';
formSmtpPort = notification.config.port || 587;
formSmtpSecure = notification.config.secure || false;
formSmtpSkipTlsVerify = notification.config.skipTlsVerify || false;
formSmtpUsername = notification.config.username || '';
formSmtpPassword = '';
formSmtpFromEmail = notification.config.from_email || '';
@@ -133,6 +136,7 @@
host: formSmtpHost.trim(),
port: formSmtpPort,
secure: formSmtpSecure,
skipTlsVerify: formSmtpSkipTlsVerify || undefined,
username: formSmtpUsername.trim() || undefined,
password: formSmtpPassword || undefined,
from_email: formSmtpFromEmail.trim(),
@@ -350,9 +354,15 @@
<Input id="notif-smtp-port" type="number" bind:value={formSmtpPort} />
</div>
</div>
<div class="flex items-center gap-2">
<Label>TLS/SSL</Label>
<TogglePill bind:checked={formSmtpSecure} onLabel="Yes" offLabel="No" />
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<Label>TLS/SSL</Label>
<TogglePill bind:checked={formSmtpSecure} onLabel="Yes" offLabel="No" />
</div>
<div class="flex items-center gap-2">
<Label class="text-muted-foreground">Skip TLS verify</Label>
<TogglePill bind:checked={formSmtpSkipTlsVerify} onLabel="Yes" offLabel="No" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
+79 -41
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { toast } from 'svelte-sonner';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
@@ -671,8 +672,9 @@
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: name, message: data.error || 'Failed to start stack' };
toast.error(`Failed to start ${name}`);
const errorMsg = data.error || 'Failed to start stack';
operationError = { id: name, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(name);
return;
}
@@ -680,8 +682,9 @@
await fetchStacks();
} catch (error) {
console.error('Failed to start stack:', error);
operationError = { id: name, message: 'Failed to start stack' };
toast.error(`Failed to start ${name}`);
const errorMsg = error instanceof Error ? error.message : 'Failed to start stack';
operationError = { id: name, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(name);
} finally {
stackActionLoading = null;
@@ -695,8 +698,9 @@
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: name, message: data.error || 'Failed to stop stack' };
toast.error(`Failed to stop ${name}`);
const errorMsg = data.error || 'Failed to stop stack';
operationError = { id: name, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(name);
return;
}
@@ -704,8 +708,9 @@
await fetchStacks();
} catch (error) {
console.error('Failed to stop stack:', error);
operationError = { id: name, message: 'Failed to stop stack' };
toast.error(`Failed to stop ${name}`);
const errorMsg = error instanceof Error ? error.message : 'Failed to stop stack';
operationError = { id: name, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(name);
} finally {
stackActionLoading = null;
@@ -719,8 +724,9 @@
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: name, message: data.error || 'Failed to restart stack' };
toast.error(`Failed to restart ${name}`);
const errorMsg = data.error || 'Failed to restart stack';
operationError = { id: name, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(name);
return;
}
@@ -728,8 +734,9 @@
await fetchStacks();
} catch (error) {
console.error('Failed to restart stack:', error);
operationError = { id: name, message: 'Failed to restart stack' };
toast.error(`Failed to restart ${name}`);
const errorMsg = error instanceof Error ? error.message : 'Failed to restart stack';
operationError = { id: name, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(name);
} finally {
stackActionLoading = null;
@@ -743,8 +750,9 @@
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: name, message: data.error || 'Failed to bring down stack' };
toast.error(`Failed to bring down ${name}`);
const errorMsg = data.error || 'Failed to bring down stack';
operationError = { id: name, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(name);
return;
}
@@ -752,8 +760,9 @@
await fetchStacks();
} catch (error) {
console.error('Failed to bring down stack:', error);
operationError = { id: name, message: 'Failed to bring down stack' };
toast.error(`Failed to bring down ${name}`);
const errorMsg = error instanceof Error ? error.message : 'Failed to bring down stack';
operationError = { id: name, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(name);
} finally {
stackActionLoading = null;
@@ -779,8 +788,9 @@
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}?force=true`, envId), { method: 'DELETE' });
if (!response.ok) {
const data = await response.json();
operationError = { id: name, message: data.error || 'Failed to remove stack' };
toast.error(`Failed to remove ${name}`);
const errorMsg = data.error || 'Failed to remove stack';
operationError = { id: name, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(name);
return;
}
@@ -788,8 +798,9 @@
await fetchStacks();
} catch (error) {
console.error('Failed to remove stack:', error);
operationError = { id: name, message: 'Failed to remove stack' };
toast.error(`Failed to remove ${name}`);
const errorMsg = error instanceof Error ? error.message : 'Failed to remove stack';
operationError = { id: name, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(name);
}
}
@@ -835,8 +846,9 @@
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/start`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: containerId, message: data.error || 'Failed to start container' };
toast.error('Failed to start container');
const errorMsg = data.error || 'Failed to start container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
return;
}
@@ -844,8 +856,9 @@
await fetchStacks();
} catch (error) {
console.error('Failed to start container:', error);
operationError = { id: containerId, message: 'Failed to start container' };
toast.error('Failed to start container');
const errorMsg = error instanceof Error ? error.message : 'Failed to start container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
} finally {
containerActionLoading = null;
@@ -859,8 +872,9 @@
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/stop`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: containerId, message: data.error || 'Failed to stop container' };
toast.error('Failed to stop container');
const errorMsg = data.error || 'Failed to stop container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
return;
}
@@ -868,8 +882,9 @@
await fetchStacks();
} catch (error) {
console.error('Failed to stop container:', error);
operationError = { id: containerId, message: 'Failed to stop container' };
toast.error('Failed to stop container');
const errorMsg = error instanceof Error ? error.message : 'Failed to stop container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
} finally {
containerActionLoading = null;
@@ -883,8 +898,9 @@
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/restart`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: containerId, message: data.error || 'Failed to restart container' };
toast.error('Failed to restart container');
const errorMsg = data.error || 'Failed to restart container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
return;
}
@@ -892,8 +908,9 @@
await fetchStacks();
} catch (error) {
console.error('Failed to restart container:', error);
operationError = { id: containerId, message: 'Failed to restart container' };
toast.error('Failed to restart container');
const errorMsg = error instanceof Error ? error.message : 'Failed to restart container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
} finally {
containerActionLoading = null;
@@ -907,8 +924,9 @@
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/pause`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: containerId, message: data.error || 'Failed to pause container' };
toast.error('Failed to pause container');
const errorMsg = data.error || 'Failed to pause container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
return;
}
@@ -916,8 +934,9 @@
await fetchStacks();
} catch (error) {
console.error('Failed to pause container:', error);
operationError = { id: containerId, message: 'Failed to pause container' };
toast.error('Failed to pause container');
const errorMsg = error instanceof Error ? error.message : 'Failed to pause container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
} finally {
containerActionLoading = null;
@@ -932,8 +951,9 @@
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/unpause`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
operationError = { id: containerId, message: data.error || 'Failed to unpause container' };
toast.error('Failed to unpause container');
const errorMsg = data.error || 'Failed to unpause container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
return;
}
@@ -941,8 +961,9 @@
await fetchStacks();
} catch (error) {
console.error('Failed to unpause container:', error);
operationError = { id: containerId, message: 'Failed to unpause container' };
toast.error('Failed to unpause container');
const errorMsg = error instanceof Error ? error.message : 'Failed to unpause container';
operationError = { id: containerId, message: errorMsg };
toast.error(errorMsg);
clearErrorAfterDelay(containerId);
} finally {
containerActionLoading = null;
@@ -1013,6 +1034,13 @@
loadExpandedState();
loadStatusFilter();
// Check for search query param from URL (e.g., from container stack link)
const urlSearch = $page.url.searchParams.get('search');
if (urlSearch) {
searchInput = urlSearch;
searchQuery = urlSearch;
}
// Initial fetch is handled by $effect - no need to duplicate here
// Listen for tab visibility changes to refresh when user returns
@@ -1258,7 +1286,17 @@
{#snippet cell(column, stack, rowState)}
{@const source = getStackSource(stack.name)}
{#if column.id === 'name'}
<span class="font-medium text-xs">{stack.name}</span>
{#if source.sourceType === 'internal'}
<button
type="button"
class="font-medium text-xs hover:text-primary hover:underline cursor-pointer text-left"
onclick={() => editStack(stack.name)}
>
{stack.name}
</button>
{:else}
<span class="font-medium text-xs">{stack.name}</span>
{/if}
{#if stackEnvVarCounts[stack.name]}
<Tooltip.Root>
<Tooltip.Trigger>
+30 -11
View File
@@ -5,7 +5,8 @@
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 } from 'lucide-svelte';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle } 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';
@@ -582,9 +583,23 @@
<p class="text-xs text-muted-foreground">Path to the compose file within the repository</p>
</div>
<!-- .env file path -->
<!-- Additional env file for variable substitution -->
<div class="space-y-2">
<Label for="env-file-path">.env file path</Label>
<div class="flex items-center gap-1.5">
<Label for="env-file-path">Additional .env file (optional)</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help" />
</Tooltip.Trigger>
<Tooltip.Content>
<div class="w-80">
<p class="text-xs">All files in the git repository are cloned automatically, including any files referenced by <code class="bg-muted px-1 rounded">env_file:</code> directives.</p>
<p class="text-xs mt-2">Only use this field for additional environment variables to be passed to Docker Compose.</p>
<p class="text-xs mt-2">The contents will be parsed and passed as shell environment variables.</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
{#if gitStack && envFiles.length > 0}
<!-- Dropdown selector for existing stacks with discovered .env files -->
<Select.Root
@@ -633,7 +648,9 @@
placeholder=".env"
/>
{/if}
<p class="text-xs text-muted-foreground">Path to the .env file within the repository (optional)</p>
<p class="text-xs text-muted-foreground">
For variable substitution from a file outside the compose directory.
</p>
</div>
<!-- Auto-update section -->
@@ -740,15 +757,17 @@
<!-- Deploy now option (only for new stacks) -->
{#if !gitStack}
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-1">
<Rocket class="w-4 h-4 text-muted-foreground" />
<div class="flex-1">
<Label class="text-sm font-normal">Deploy now</Label>
<p class="text-xs text-muted-foreground">Clone and deploy the stack immediately</p>
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-1">
<Rocket class="w-4 h-4 text-muted-foreground" />
<div class="flex-1">
<Label class="text-sm font-normal">Deploy now</Label>
<p class="text-xs text-muted-foreground">Clone and deploy the stack immediately</p>
</div>
</div>
<TogglePill bind:checked={formDeployNow} />
</div>
<TogglePill bind:checked={formDeployNow} />
</div>
{/if}
+188 -54
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -7,12 +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 } from 'lucide-svelte';
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable, HelpCircle, GripVertical } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { focusFirstInput } from '$lib/utils';
import * as Alert from '$lib/components/ui/alert';
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
// localStorage key for persisted split ratio
const STORAGE_KEY_SPLIT = 'dockhand-stack-modal-split';
interface Props {
open: boolean;
mode: 'create' | 'edit';
@@ -39,6 +43,8 @@
// Environment variables state
let envVars = $state<EnvVar[]>([]);
let originalEnvVars = $state<EnvVar[]>([]);
let rawEnvContent = $state('');
let originalRawEnvContent = $state('');
let envValidation = $state<ValidationResult | null>(null);
let validating = $state(false);
let existingSecretKeys = $state<Set<string>>(new Set());
@@ -49,6 +55,11 @@
// ComposeGraphViewer reference for resize on panel toggle
let graphViewerRef: ComposeGraphViewer | null = $state(null);
// Resizable split panel state
let splitRatio = $state(60); // percentage for compose panel
let isDraggingSplit = $state(false);
let containerRef: HTMLDivElement | null = $state(null);
// Debounce timer for validation
let validateTimer: ReturnType<typeof setTimeout> | null = null;
@@ -143,20 +154,22 @@ services:
if (validateTimer) clearTimeout(validateTimer);
validateTimer = setTimeout(() => {
validateEnvVars();
}, 500);
}, 1000);
}
// Explicitly push markers to the editor
// Explicitly push markers to the editor (immediate=true since this is called after validation)
function updateEditorMarkers() {
if (!codeEditorRef) return;
codeEditorRef.updateVariableMarkers(variableMarkers);
codeEditorRef.updateVariableMarkers(variableMarkers, true);
}
// Check for env var changes (compare by serializing)
const hasEnvVarChanges = $derived.by(() => {
const current = JSON.stringify(envVars.filter(v => v.key));
const original = JSON.stringify(originalEnvVars);
return current !== original;
const currentVars = JSON.stringify(envVars.filter(v => v.key));
const originalVars = JSON.stringify(originalEnvVars);
const varsChanged = currentVars !== originalVars;
const rawChanged = rawEnvContent !== originalRawEnvContent;
return varsChanged || rawChanged;
});
const hasChanges = $derived(hasComposeChanges || hasEnvVarChanges);
@@ -173,8 +186,48 @@ services:
// Fallback to system preference
editorTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// 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());
}
}
async function loadComposeFile() {
if (mode !== 'edit' || !stackName) return;
@@ -196,17 +249,29 @@ services:
composeContent = data.content;
originalContent = data.content;
// Load environment variables
// Load environment variables (parsed)
const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId));
if (envResponse.ok) {
const envData = await envResponse.json();
envVars = envData.variables || [];
originalEnvVars = JSON.parse(JSON.stringify(envData.variables || []));
// Track existing secret keys (secrets loaded from DB cannot have visibility toggled)
existingSecretKeys = new Set(
envVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
);
}
// Load raw .env file content
const rawEnvResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId));
if (rawEnvResponse.ok) {
const rawEnvData = await rawEnvResponse.json();
rawEnvContent = rawEnvData.content || '';
}
// Wait for $effects in StackEnvVarsPanel to settle (parses raw content, syncs variables)
// Then set originals to the post-effect state to avoid false "unsaved changes"
await tick();
originalEnvVars = JSON.parse(JSON.stringify(envVars.filter(v => v.key.trim())));
originalRawEnvContent = rawEnvContent;
} catch (e: any) {
loadError = e.message;
} finally {
@@ -276,14 +341,22 @@ services:
try {
const envId = $currentEnvironment?.id ?? null;
// Create the stack
// Collect environment variables
const definedVars = envVars.filter(v => v.key.trim());
// Create the stack (include env vars so they're available before start)
const response = await fetch(appendEnvParam('/api/stacks', envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newStackName.trim(),
compose: content,
start
start,
envVars: definedVars.length > 0 ? definedVars.map(v => ({
key: v.key.trim(),
value: v.value,
isSecret: v.isSecret
})) : undefined
})
});
@@ -292,26 +365,6 @@ services:
throw new Error(data.error || 'Failed to create stack');
}
// Save environment variables if any are defined
const definedVars = envVars.filter(v => v.key.trim());
if (definedVars.length > 0) {
const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(newStackName.trim())}/env`, envId), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
variables: definedVars.map(v => ({
key: v.key.trim(),
value: v.value,
isSecret: v.isSecret
}))
})
});
if (!envResponse.ok) {
console.error('Failed to save environment variables');
}
}
onSuccess();
handleClose();
} catch (e: any) {
@@ -354,31 +407,59 @@ services:
throw new Error(data.error || 'Failed to save compose file');
}
// Save environment variables if any are defined
// Save environment variables
// Always save to raw endpoint for consistency
// If no raw content but has env vars, generate raw content from vars (backward compat)
const definedVars = envVars.filter(v => v.key.trim());
if (definedVars.length > 0 || originalEnvVars.length > 0) {
const envResponse = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId),
let contentToSave = rawEnvContent;
// Backward compatibility: if no raw file but has DB envs, generate raw content
if (!contentToSave.trim() && definedVars.length > 0) {
contentToSave = definedVars.map(v => `${v.key.trim()}=${v.value}`).join('\n') + '\n';
}
// Save if there's any content OR if we need to clear an existing file
if (contentToSave.trim() || originalRawEnvContent.trim() || definedVars.length > 0 || originalEnvVars.length > 0) {
const rawEnvResponse = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId),
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
variables: definedVars.map(v => ({
key: v.key.trim(),
value: v.value,
isSecret: v.isSecret
}))
})
body: JSON.stringify({ content: contentToSave })
}
);
if (!envResponse.ok) {
console.error('Failed to save environment variables');
if (!rawEnvResponse.ok) {
console.error('Failed to save environment file');
}
// Also save to DB for secret tracking
if (definedVars.some(v => v.isSecret)) {
const envResponse = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId),
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
variables: definedVars.map(v => ({
key: v.key.trim(),
value: v.value,
isSecret: v.isSecret
}))
})
}
);
if (!envResponse.ok) {
console.error('Failed to save secret markers to database');
}
}
}
originalContent = composeContent;
originalEnvVars = JSON.parse(JSON.stringify(definedVars));
originalEnvVars = JSON.parse(JSON.stringify(envVars.filter(v => v.key.trim())));
originalRawEnvContent = contentToSave; // Use what was actually saved
rawEnvContent = contentToSave; // Sync raw content if it was generated
onSuccess();
if (!restart) {
@@ -417,6 +498,8 @@ services:
originalContent = '';
envVars = [];
originalEnvVars = [];
rawEnvContent = '';
originalRawEnvContent = '';
envValidation = null;
existingSecretKeys = new Set();
activeTab = 'editor';
@@ -462,7 +545,7 @@ services:
// Debounce to avoid too many API calls while typing
const timeout = setTimeout(() => {
validateEnvVars();
}, 300);
}, 800);
return () => clearTimeout(timeout);
});
@@ -484,8 +567,11 @@ services:
}
}}
>
<Dialog.Content class="max-w-7xl w-[95vw] h-[90vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700" showCloseButton={false}>
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0 bg-zinc-50 dark:bg-zinc-800">
<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"
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="flex items-center gap-2">
@@ -529,7 +615,7 @@ services:
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<!-- Theme toggle (only in editor mode) -->
{#if activeTab === 'editor'}
<button
@@ -612,10 +698,10 @@ services:
{/if}
<!-- Content area -->
<div class="flex-1 min-h-0 flex">
<div bind:this={containerRef} class="flex-1 min-h-0 flex {isDraggingSplit ? 'select-none' : ''}">
{#if activeTab === 'editor'}
<!-- Editor tab: Code editor + Env panel side by side -->
<div class="w-[60%] flex-shrink-0 border-r border-zinc-200 dark:border-zinc-700 flex flex-col min-w-0">
<div class="flex-shrink-0 flex flex-col min-w-0" style="width: {splitRatio}%">
{#if open}
<div class="flex-1 p-3 min-h-0">
<CodeEditor
@@ -630,18 +716,66 @@ services:
</div>
{/if}
</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>
<!-- 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:variables={envVars}
bind:rawContent={rawEnvContent}
validation={envValidation}
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
onchange={() => validateEnvVars()}
onchange={debouncedValidate}
/>
</div>
</div>
@@ -659,7 +793,7 @@ services:
</div>
<!-- Footer -->
<div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0 bg-zinc-50 dark:bg-zinc-800">
<div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0">
<div class="text-xs text-zinc-500 dark:text-zinc-400">
{#if hasChanges}
<span class="text-amber-600 dark:text-amber-500">Unsaved changes</span>