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
placeholder?: { key: string; value: string };
existingSecretKeys?: Set<string>; // 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<string>()
existingSecretKeys = new Set<string>(),
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 @@
<Input
bind:value={variable.key}
disabled={readonly}
oninput={() => onchange?.()}
class="h-9 font-mono text-xs"
/>
</div>
@@ -174,6 +180,7 @@
bind:value={variable.value}
type={variable.isSecret ? 'password' : 'text'}
disabled={readonly}
oninput={() => onchange?.()}
class="h-9 font-mono text-xs"
/>
</div>

View File

@@ -383,6 +383,7 @@
{sources}
{placeholder}
{existingSecretKeys}
{onchange}
/>
{:else}
<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 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<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());
// 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(); }}
/>
</div>
</div>
@@ -795,7 +781,7 @@ services:
<!-- 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="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>
{:else}
No changes
@@ -829,7 +815,7 @@ services:
</Button>
{:else}
<!-- 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}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Saving...
@@ -838,7 +824,7 @@ services:
Save
{/if}
</Button>
<Button onclick={() => handleSave(true)} disabled={saving || !hasChanges || loading || !!loadError}>
<Button onclick={() => handleSave(true)} disabled={saving || loading || !!loadError}>
{#if saving}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Applying...