This commit is contained in:
jarek
2026-01-01 16:32:08 +01:00
parent de62327a07
commit 215f52b1f0
5 changed files with 1396 additions and 36 deletions

View File

@@ -27,6 +27,7 @@
sources?: Record<string, 'file' | 'override'>; // Key -> source mapping sources?: Record<string, 'file' | 'override'>; // Key -> source mapping
placeholder?: { key: string; value: string }; placeholder?: { key: string; value: string };
existingSecretKeys?: Set<string>; // Keys of secrets loaded from DB (can't toggle visibility) existingSecretKeys?: Set<string>; // Keys of secrets loaded from DB (can't toggle visibility)
onchange?: () => void;
} }
let { let {
@@ -36,7 +37,8 @@
showSource = false, showSource = false,
sources = {}, sources = {},
placeholder = { key: 'VARIABLE_NAME', value: 'value' }, placeholder = { key: 'VARIABLE_NAME', value: 'value' },
existingSecretKeys = new Set<string>() existingSecretKeys = new Set<string>(),
onchange
}: Props = $props(); }: Props = $props();
// Check if a variable is an existing secret that was loaded from DB // Check if a variable is an existing secret that was loaded from DB
@@ -46,14 +48,17 @@
function addVariable() { function addVariable() {
variables = [...variables, { key: '', value: '', isSecret: false }]; variables = [...variables, { key: '', value: '', isSecret: false }];
onchange?.();
} }
function removeVariable(index: number) { function removeVariable(index: number) {
variables = variables.filter((_, i) => i !== index); variables = variables.filter((_, i) => i !== index);
onchange?.();
} }
function toggleSecret(index: number) { function toggleSecret(index: number) {
variables[index].isSecret = !variables[index].isSecret; variables[index].isSecret = !variables[index].isSecret;
onchange?.();
} }
// Check if a variable key is missing (required but not defined) // Check if a variable key is missing (required but not defined)
@@ -163,6 +168,7 @@
<Input <Input
bind:value={variable.key} bind:value={variable.key}
disabled={readonly} disabled={readonly}
oninput={() => onchange?.()}
class="h-9 font-mono text-xs" class="h-9 font-mono text-xs"
/> />
</div> </div>
@@ -174,6 +180,7 @@
bind:value={variable.value} bind:value={variable.value}
type={variable.isSecret ? 'password' : 'text'} type={variable.isSecret ? 'password' : 'text'}
disabled={readonly} disabled={readonly}
oninput={() => onchange?.()}
class="h-9 font-mono text-xs" class="h-9 font-mono text-xs"
/> />
</div> </div>

View File

@@ -383,6 +383,7 @@
{sources} {sources}
{placeholder} {placeholder}
{existingSecretKeys} {existingSecretKeys}
{onchange}
/> />
{:else} {:else}
<CodeEditor <CodeEditor

View File

@@ -0,0 +1,98 @@
import { json } from '@sveltejs/kit';
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';
/**
* GET /api/stacks/[name]/env/raw?env=X
* Get the raw .env file content as-is (with comments, formatting, etc.)
*/
export const GET: RequestHandler = async ({ params, url, cookies }) => {
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 });
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -35,20 +35,20 @@
let loadError = $state<string | null>(null); let loadError = $state<string | null>(null);
let errors = $state<{ stackName?: string; compose?: string }>({}); let errors = $state<{ stackName?: string; compose?: string }>({});
let composeContent = $state(''); let composeContent = $state('');
let originalContent = $state('');
let activeTab = $state<'editor' | 'graph'>('editor'); let activeTab = $state<'editor' | 'graph'>('editor');
let showConfirmClose = $state(false); let showConfirmClose = $state(false);
let editorTheme = $state<'light' | 'dark'>('dark'); let editorTheme = $state<'light' | 'dark'>('dark');
// Environment variables state // Environment variables state
let envVars = $state<EnvVar[]>([]); let envVars = $state<EnvVar[]>([]);
let originalEnvVars = $state<EnvVar[]>([]);
let rawEnvContent = $state(''); let rawEnvContent = $state('');
let originalRawEnvContent = $state('');
let envValidation = $state<ValidationResult | null>(null); let envValidation = $state<ValidationResult | null>(null);
let validating = $state(false); let validating = $state(false);
let existingSecretKeys = $state<Set<string>>(new Set()); let existingSecretKeys = $state<Set<string>>(new Set());
// Simple dirty flag - only set when user touches something
let isDirty = $state(false);
// CodeEditor reference for explicit marker updates // CodeEditor reference for explicit marker updates
let codeEditorRef: CodeEditor | null = $state(null); let codeEditorRef: CodeEditor | null = $state(null);
@@ -140,12 +140,10 @@ services:
return markers; return markers;
}); });
// Check for compose changes
const hasComposeChanges = $derived(composeContent !== originalContent);
// Stable callback for compose content changes - avoids stale closure issues // Stable callback for compose content changes - avoids stale closure issues
function handleComposeChange(value: string) { function handleComposeChange(value: string) {
composeContent = value; composeContent = value;
isDirty = true;
debouncedValidate(); debouncedValidate();
} }
@@ -163,16 +161,10 @@ services:
codeEditorRef.updateVariableMarkers(variableMarkers, true); codeEditorRef.updateVariableMarkers(variableMarkers, true);
} }
// Check for env var changes (compare by serializing) // Mark dirty when env vars change
const hasEnvVarChanges = $derived.by(() => { function markDirty() {
const currentVars = JSON.stringify(envVars.filter(v => v.key)); isDirty = true;
const originalVars = JSON.stringify(originalEnvVars); }
const varsChanged = currentVars !== originalVars;
const rawChanged = rawEnvContent !== originalRawEnvContent;
return varsChanged || rawChanged;
});
const hasChanges = $derived(hasComposeChanges || hasEnvVarChanges);
// Display title // Display title
const displayName = $derived(mode === 'edit' ? stackName : (newStackName || 'New stack')); const displayName = $derived(mode === 'edit' ? stackName : (newStackName || 'New stack'));
@@ -247,7 +239,6 @@ services:
} }
composeContent = data.content; composeContent = data.content;
originalContent = data.content;
// Load environment variables (parsed) // Load environment variables (parsed)
const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId)); 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) // 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(); await tick();
originalEnvVars = JSON.parse(JSON.stringify(envVars.filter(v => v.key.trim()))); // Reset dirty flag after loading completes
originalRawEnvContent = rawEnvContent; isDirty = false;
} catch (e: any) { } catch (e: any) {
loadError = e.message; loadError = e.message;
} finally { } finally {
@@ -418,8 +408,8 @@ services:
contentToSave = definedVars.map(v => `${v.key.trim()}=${v.value}`).join('\n') + '\n'; 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 // Save if there's any content
if (contentToSave.trim() || originalRawEnvContent.trim() || definedVars.length > 0 || originalEnvVars.length > 0) { if (contentToSave.trim() || definedVars.length > 0) {
const rawEnvResponse = await fetch( const rawEnvResponse = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId), 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 rawEnvContent = contentToSave; // Sync raw content if it was generated
isDirty = false; // Reset dirty flag after successful save
onSuccess(); onSuccess();
if (!restart) { if (!restart) {
@@ -476,7 +464,7 @@ services:
} }
function tryClose() { function tryClose() {
if (hasChanges) { if (isDirty) {
showConfirmClose = true; showConfirmClose = true;
} else { } else {
handleClose(); handleClose();
@@ -495,12 +483,10 @@ services:
loadError = null; loadError = null;
errors = {}; errors = {};
composeContent = ''; composeContent = '';
originalContent = '';
envVars = []; envVars = [];
originalEnvVars = [];
rawEnvContent = ''; rawEnvContent = '';
originalRawEnvContent = '';
envValidation = null; envValidation = null;
isDirty = false;
existingSecretKeys = new Set(); existingSecretKeys = new Set();
activeTab = 'editor'; activeTab = 'editor';
showConfirmClose = false; showConfirmClose = false;
@@ -526,7 +512,7 @@ services:
} else if (mode === 'create') { } else if (mode === 'create') {
// Set default compose content for create mode // Set default compose content for create mode
composeContent = defaultCompose; composeContent = defaultCompose;
originalContent = defaultCompose; // Track original for change detection isDirty = false; // Reset dirty flag for new modal
loading = false; loading = false;
// Auto-validate default compose // Auto-validate default compose
validateEnvVars(); validateEnvVars();
@@ -558,7 +544,7 @@ services:
focusFirstInput(); focusFirstInput();
} else { } else {
// Prevent closing if there are unsaved changes - show confirmation instead // Prevent closing if there are unsaved changes - show confirmation instead
if (hasChanges) { if (isDirty) {
// Re-open the dialog and show confirmation // Re-open the dialog and show confirmation
open = true; open = true;
showConfirmClose = true; showConfirmClose = true;
@@ -775,7 +761,7 @@ services:
bind:rawContent={rawEnvContent} bind:rawContent={rawEnvContent}
validation={envValidation} validation={envValidation}
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()} existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
onchange={debouncedValidate} onchange={() => { markDirty(); debouncedValidate(); }}
/> />
</div> </div>
</div> </div>
@@ -795,7 +781,7 @@ services:
<!-- Footer --> <!-- 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"> <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"> <div class="text-xs text-zinc-500 dark:text-zinc-400">
{#if hasChanges} {#if isDirty}
<span class="text-amber-600 dark:text-amber-500">Unsaved changes</span> <span class="text-amber-600 dark:text-amber-500">Unsaved changes</span>
{:else} {:else}
No changes No changes
@@ -829,7 +815,7 @@ services:
</Button> </Button>
{:else} {:else}
<!-- Edit mode buttons --> <!-- Edit mode buttons -->
<Button variant="outline" onclick={() => handleSave(false)} disabled={saving || !hasChanges || loading || !!loadError}> <Button variant="outline" onclick={() => handleSave(false)} disabled={saving || loading || !!loadError}>
{#if saving} {#if saving}
<Loader2 class="w-4 h-4 mr-2 animate-spin" /> <Loader2 class="w-4 h-4 mr-2 animate-spin" />
Saving... Saving...
@@ -838,7 +824,7 @@ services:
Save Save
{/if} {/if}
</Button> </Button>
<Button onclick={() => handleSave(true)} disabled={saving || !hasChanges || loading || !!loadError}> <Button onclick={() => handleSave(true)} disabled={saving || loading || !!loadError}>
{#if saving} {#if saving}
<Loader2 class="w-4 h-4 mr-2 animate-spin" /> <Loader2 class="w-4 h-4 mr-2 animate-spin" />
Applying... Applying...