mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 05:29:05 +00:00
1.0.5
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -383,6 +383,7 @@
|
||||
{sources}
|
||||
{placeholder}
|
||||
{existingSecretKeys}
|
||||
{onchange}
|
||||
/>
|
||||
{:else}
|
||||
<CodeEditor
|
||||
|
||||
98
src/routes/api/stacks/[name]/env/raw/+server.ts
vendored
Normal file
98
src/routes/api/stacks/[name]/env/raw/+server.ts
vendored
Normal 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 });
|
||||
}
|
||||
};
|
||||
1268
src/routes/containers/ContainerSettingsTab.svelte
Normal file
1268
src/routes/containers/ContainerSettingsTab.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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...
|
||||
|
||||
Reference in New Issue
Block a user