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
|
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>
|
||||||
|
|||||||
@@ -383,6 +383,7 @@
|
|||||||
{sources}
|
{sources}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
{existingSecretKeys}
|
{existingSecretKeys}
|
||||||
|
{onchange}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<CodeEditor
|
<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 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...
|
||||||
|
|||||||
Reference in New Issue
Block a user