diff --git a/src/lib/components/StackEnvVarsEditor.svelte b/src/lib/components/StackEnvVarsEditor.svelte index 53b1f0b..54a7b10 100644 --- a/src/lib/components/StackEnvVarsEditor.svelte +++ b/src/lib/components/StackEnvVarsEditor.svelte @@ -27,6 +27,7 @@ sources?: Record; // Key -> source mapping placeholder?: { key: string; value: string }; existingSecretKeys?: Set; // Keys of secrets loaded from DB (can't toggle visibility) + onchange?: () => void; } let { @@ -36,7 +37,8 @@ showSource = false, sources = {}, placeholder = { key: 'VARIABLE_NAME', value: 'value' }, - existingSecretKeys = new Set() + existingSecretKeys = new Set(), + onchange }: Props = $props(); // Check if a variable is an existing secret that was loaded from DB @@ -46,14 +48,17 @@ function addVariable() { variables = [...variables, { key: '', value: '', isSecret: false }]; + onchange?.(); } function removeVariable(index: number) { variables = variables.filter((_, i) => i !== index); + onchange?.(); } function toggleSecret(index: number) { variables[index].isSecret = !variables[index].isSecret; + onchange?.(); } // Check if a variable key is missing (required but not defined) @@ -163,6 +168,7 @@ onchange?.()} class="h-9 font-mono text-xs" /> @@ -174,6 +180,7 @@ bind:value={variable.value} type={variable.isSecret ? 'password' : 'text'} disabled={readonly} + oninput={() => onchange?.()} class="h-9 font-mono text-xs" /> diff --git a/src/lib/components/StackEnvVarsPanel.svelte b/src/lib/components/StackEnvVarsPanel.svelte index 482a896..cdf9162 100644 --- a/src/lib/components/StackEnvVarsPanel.svelte +++ b/src/lib/components/StackEnvVarsPanel.svelte @@ -383,6 +383,7 @@ {sources} {placeholder} {existingSecretKeys} + {onchange} /> {:else} { + const auth = await authorize(cookies); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : null; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'view', envIdNum ?? undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const stackName = decodeURIComponent(params.name); + const stacksDir = getStacksDir(); + const envFilePath = join(stacksDir, stackName, '.env'); + + let content = ''; + if (existsSync(envFilePath)) { + try { + content = await Bun.file(envFilePath).text(); + } catch { + // File read failed + } + } + + return json({ content }); + } catch (error) { + console.error('Error getting raw env file:', error); + return json({ error: 'Failed to get environment file' }, { status: 500 }); + } +}; + +/** + * PUT /api/stacks/[name]/env/raw?env=X + * Save raw .env file content directly to disk. + * Body: { content: string } + */ +export const PUT: RequestHandler = async ({ params, url, cookies, request }) => { + const auth = await authorize(cookies); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : null; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'edit', envIdNum ?? undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const stackName = decodeURIComponent(params.name); + const body = await request.json(); + + if (typeof body.content !== 'string') { + return json({ error: 'Invalid request body: content string required' }, { status: 400 }); + } + + const stacksDir = getStacksDir(); + const stackDir = join(stacksDir, stackName); + const envFilePath = join(stackDir, '.env'); + + // Only write if stack directory exists + if (!existsSync(stackDir)) { + return json({ error: 'Stack directory not found' }, { status: 404 }); + } + + // Ensure content ends with newline + let content = body.content; + if (content && !content.endsWith('\n')) { + content += '\n'; + } + + await Bun.write(envFilePath, content); + + return json({ success: true }); + } catch (error) { + console.error('Error saving raw env file:', error); + return json({ error: 'Failed to save environment file' }, { status: 500 }); + } +}; diff --git a/src/routes/containers/ContainerSettingsTab.svelte b/src/routes/containers/ContainerSettingsTab.svelte new file mode 100644 index 0000000..ee0c604 --- /dev/null +++ b/src/routes/containers/ContainerSettingsTab.svelte @@ -0,0 +1,1268 @@ + + +
+ + {#if mode === 'create' && imageSummary} +
+
+ +
+

Image: {image || 'Not set'}

+ {#if imageSummary.isPulling || imageSummary.isScanning} +

+ + {imageSummary.isScanning ? 'Scanning...' : 'Pulling...'} +

+ {:else if imageSummary.imageReady} +

+ + Image pulled and ready + {#if imageSummary.scanResults && imageSummary.scanResults.length > 0} + • {imageSummary.totalVulnerabilities ?? 0} vulnerabilities + {/if} +

+ {:else if !image} +

+ + Go to "Pull" tab to set the image +

+ {/if} +
+
+
+ {/if} + + + {#if configSets.length > 0} +
+
+ +

{mode === 'edit' ? 'Apply config set' : 'Config set'}

+
+
+
+ + + {selectedConfigSetId ? configSets.find(c => c.id === parseInt(selectedConfigSetId))?.name : (mode === 'edit' ? 'Select a config set to merge values...' : 'Select a config set to pre-fill values...')} + + + {#each configSets as configSet} + +
+ {configSet.name} + {#if configSet.description} + {configSet.description} + {/if} +
+
+ {/each} +
+
+
+
+ {#if mode === 'edit'} +

Note: Values from the config set will be merged with existing settings. Existing keys won't be overwritten.

+ {/if} +
+ {/if} + + +
+
+

Basic settings

+
+ +
+
+ + errors.name = undefined} + /> + {#if errors.name} +

{errors.name}

+ {/if} +
+ {#if mode === 'edit'} +
+ + errors.image = undefined} + /> + {#if errors.image} +

{errors.image}

+ {/if} +
+ {/if} +
+ +
+ + +
+ +
+
+ + + + + {#if restartPolicy === 'no'} + + {:else if restartPolicy === 'always'} + + {:else if restartPolicy === 'on-failure'} + + {:else} + + {/if} + {restartPolicy === 'no' ? 'No' : restartPolicy === 'always' ? 'Always' : restartPolicy === 'on-failure' ? 'On failure' : 'Unless stopped'} + + + + + {#snippet children()} + + No + {/snippet} + + + {#snippet children()} + + Always + {/snippet} + + + {#snippet children()} + + On failure + {/snippet} + + + {#snippet children()} + + Unless stopped + {/snippet} + + + + {#if restartPolicy === 'on-failure'} +
+ + +

Leave empty for unlimited retries

+
+ {/if} +
+ +
+ + + + + {#if networkMode === 'bridge'} + + {:else if networkMode === 'host'} + + {:else} + + {/if} + {networkMode === 'bridge' ? 'Bridge' : networkMode === 'host' ? 'Host' : 'None'} + + + + + {#snippet children()} + + Bridge + {/snippet} + + + {#snippet children()} + + Host + {/snippet} + + + {#snippet children()} + + None + {/snippet} + + + +
+
+ +
+ + +
+
+ + + {#if availableNetworks.length > 0} +
+
+
+ +

Networks

+
+
+ +
+ + + Select network to add... + + + {#each availableNetworks.filter(n => !selectedNetworks.includes(n.name) && !['bridge', 'host', 'none'].includes(n.name)) as network} + + {#snippet children()} +
+ {network.name} + {network.driver} +
+ {/snippet} +
+ {/each} +
+
+ + {#if selectedNetworks.length > 0} +
+ {#each selectedNetworks as networkName} + {@const network = availableNetworks.find(n => n.name === networkName)} + + {networkName} + {#if network} + {network.driver} + {/if} + + + {/each} +
+ {/if} + {#if mode === 'edit'} +

Container will be connected to selected networks in addition to the network mode above

+ {/if} +
+
+ {/if} + + +
+
+

Port mappings

+ +
+ +
+ {#each portMappings as mapping, index} +
+
+ Host + +
+
+ Container + +
+ { portMappings[index].protocol = v; }} + /> + +
+ {/each} +
+
+ + +
+
+

Volume mappings

+ +
+ +
+ {#each volumeMappings as mapping, index} +
+
+ Host path + +
+
+ Container path + +
+ { volumeMappings[index].mode = v; }} + /> + +
+ {/each} +
+
+ + +
+
+

Environment variables

+ +
+ +
+ {#each envVars as envVar, index} +
+
+ Key + +
+
+ Value + +
+ +
+ {/each} +
+
+ + +
+
+

Labels

+ +
+ +
+ {#each labels as label, index} +
+
+ Key + +
+
+ Value + +
+ +
+ {/each} +
+
+ + +
+

Advanced container options (click to expand)

+
+ + +
+ + {#if showResources} +
+

Configure memory and CPU limits for this container

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +

Microseconds per period

+
+
+ + +

Period in microseconds

+
+
+
+ {/if} +
+ + +
+ + {#if showSecurity} +
+
+
+ + +
+
+
+ + +
+
+
+ +
+ + { addCapability('add', v); }}> + + Select capability to add... + + + {#each commonCapabilities.filter(c => !capAdd.includes(c)) as cap} + + {/each} + + + {#if capAdd.length > 0} +
+ {#each capAdd as cap} + + +{cap} + + + {/each} +
+ {/if} +
+ +
+ + { addCapability('drop', v); }}> + + Select capability to drop... + + + {#each commonCapabilities.filter(c => !capDrop.includes(c)) as cap} + + {/each} + + + {#if capDrop.length > 0} +
+ {#each capDrop as cap} + + -{cap} + + + {/each} +
+ {/if} +
+ +
+ +
+ { if (e.key === 'Enter') { e.preventDefault(); addSecurityOption(); } }} + /> + +
+ {#if securityOptions.length > 0} +
+ {#each securityOptions as option} + + {option} + + + {/each} +
+ {/if} +

Common options: no-new-privileges, seccomp=unconfined, apparmor=unconfined

+
+
+ {/if} +
+ + +
+ + {#if showHealth} +
+
+ + +
+ {#if healthcheckEnabled} +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} +
+ {/if} +
+ + +
+ + {#if showDns} +
+
+ +
+ { if (e.key === 'Enter') { e.preventDefault(); addDnsServer(); } }} + /> + +
+ {#if dnsServers.length > 0} +
+ {#each dnsServers as server} + + {server} + + + {/each} +
+ {/if} +
+ + +
+ +
+ { if (e.key === 'Enter') { e.preventDefault(); addDnsSearch(); } }} + /> + +
+ {#if dnsSearch.length > 0} +
+ {#each dnsSearch as domain} + + {domain} + + + {/each} +
+ {/if} +
+ + +
+ +
+ { if (e.key === 'Enter') { e.preventDefault(); addDnsOption(); } }} + /> + +
+ {#if dnsOptions.length > 0} +
+ {#each dnsOptions as option} + + {option} + + + {/each} +
+ {/if} +
+
+ {/if} +
+ + +
+ + {#if showDevices} +
+
+ +
+ {#each deviceMappings as mapping, index} +
+ + + +
+ {/each} +
+ {/if} +
+ + +
+ + {#if showUlimits} +
+
+ +
+ {#each ulimits as ulimit, index} +
+ + + {ulimit.name} + + + {#each commonUlimits as name} + + {/each} + + + + + +
+ {/each} +
+ {/if} +
+ + +
+
+ +

Auto-update

+
+ +
+
diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index 7171cd9..a872d21 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -35,20 +35,20 @@ let loadError = $state(null); let errors = $state<{ stackName?: string; compose?: string }>({}); let composeContent = $state(''); - let originalContent = $state(''); let activeTab = $state<'editor' | 'graph'>('editor'); let showConfirmClose = $state(false); let editorTheme = $state<'light' | 'dark'>('dark'); // Environment variables state let envVars = $state([]); - let originalEnvVars = $state([]); let rawEnvContent = $state(''); - let originalRawEnvContent = $state(''); let envValidation = $state(null); let validating = $state(false); let existingSecretKeys = $state>(new Set()); + // Simple dirty flag - only set when user touches something + let isDirty = $state(false); + // CodeEditor reference for explicit marker updates let codeEditorRef: CodeEditor | null = $state(null); @@ -140,12 +140,10 @@ services: return markers; }); - // Check for compose changes - const hasComposeChanges = $derived(composeContent !== originalContent); - // Stable callback for compose content changes - avoids stale closure issues function handleComposeChange(value: string) { composeContent = value; + isDirty = true; debouncedValidate(); } @@ -163,16 +161,10 @@ services: codeEditorRef.updateVariableMarkers(variableMarkers, true); } - // Check for env var changes (compare by serializing) - const hasEnvVarChanges = $derived.by(() => { - 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); + // Mark dirty when env vars change + function markDirty() { + isDirty = true; + } // Display title const displayName = $derived(mode === 'edit' ? stackName : (newStackName || 'New stack')); @@ -247,7 +239,6 @@ services: } composeContent = data.content; - originalContent = data.content; // Load environment variables (parsed) const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId)); @@ -268,10 +259,9 @@ services: } // 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; + // Reset dirty flag after loading completes + isDirty = false; } catch (e: any) { loadError = e.message; } finally { @@ -418,8 +408,8 @@ services: 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) { + // Save if there's any content + if (contentToSave.trim() || definedVars.length > 0) { const rawEnvResponse = await fetch( appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId), { @@ -456,10 +446,8 @@ services: } } - originalContent = composeContent; - 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 + isDirty = false; // Reset dirty flag after successful save onSuccess(); if (!restart) { @@ -476,7 +464,7 @@ services: } function tryClose() { - if (hasChanges) { + if (isDirty) { showConfirmClose = true; } else { handleClose(); @@ -495,12 +483,10 @@ services: loadError = null; errors = {}; composeContent = ''; - originalContent = ''; envVars = []; - originalEnvVars = []; rawEnvContent = ''; - originalRawEnvContent = ''; envValidation = null; + isDirty = false; existingSecretKeys = new Set(); activeTab = 'editor'; showConfirmClose = false; @@ -526,7 +512,7 @@ services: } else if (mode === 'create') { // Set default compose content for create mode composeContent = defaultCompose; - originalContent = defaultCompose; // Track original for change detection + isDirty = false; // Reset dirty flag for new modal loading = false; // Auto-validate default compose validateEnvVars(); @@ -558,7 +544,7 @@ services: focusFirstInput(); } else { // Prevent closing if there are unsaved changes - show confirmation instead - if (hasChanges) { + if (isDirty) { // Re-open the dialog and show confirmation open = true; showConfirmClose = true; @@ -775,7 +761,7 @@ services: bind:rawContent={rawEnvContent} validation={envValidation} existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()} - onchange={debouncedValidate} + onchange={() => { markDirty(); debouncedValidate(); }} /> @@ -795,7 +781,7 @@ services:
- {#if hasChanges} + {#if isDirty} Unsaved changes {:else} No changes @@ -829,7 +815,7 @@ services: {:else} - -