mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.5
This commit is contained in:
@@ -13,6 +13,3 @@
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
// Build trigger: 20260102-121809
|
||||
|
||||
@@ -214,7 +214,10 @@
|
||||
variableMarkers?: VariableMarker[];
|
||||
}
|
||||
|
||||
let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers = [] }: Props = $props();
|
||||
let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers: variableMarkersProp = [] }: Props = $props();
|
||||
|
||||
// Keep markers reactive - destructured props with defaults lose reactivity
|
||||
const variableMarkers = $derived(variableMarkersProp);
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let view: EditorView | null = null;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
placeholder?: { key: string; value: string };
|
||||
infoText?: string;
|
||||
existingSecretKeys?: Set<string>;
|
||||
theme?: 'light' | 'dark';
|
||||
class?: string;
|
||||
onchange?: () => void;
|
||||
}
|
||||
@@ -31,6 +32,7 @@
|
||||
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
|
||||
infoText,
|
||||
existingSecretKeys = new Set<string>(),
|
||||
theme = 'dark',
|
||||
class: className = '',
|
||||
onchange
|
||||
}: Props = $props();
|
||||
@@ -44,7 +46,6 @@
|
||||
let confirmClearOpen = $state(false);
|
||||
let contentAreaRef: HTMLDivElement;
|
||||
let parseWarnings = $state<string[]>([]);
|
||||
let editorTheme = $state<'light' | 'dark'>('dark');
|
||||
let hasMergedOnLoad = $state(false);
|
||||
|
||||
// Count of secrets (for display in hint)
|
||||
@@ -370,9 +371,9 @@
|
||||
</div>
|
||||
{:else if secretCount > 0}
|
||||
<!-- Text view hint about secrets (only shown when secrets exist) -->
|
||||
<div class="flex items-start gap-2 px-2 py-1.5 rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
||||
<ShieldAlert class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div class="text-2xs text-amber-700 dark:text-amber-300">
|
||||
<div class="flex items-start gap-2 px-2.5 py-2 rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
||||
<ShieldAlert class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div class="text-xs text-amber-700 dark:text-amber-300">
|
||||
<span class="font-medium">{secretCount} secret{secretCount === 1 ? '' : 's'} not shown.</span>
|
||||
<span class="text-amber-600 dark:text-amber-400">Secrets are never written to disk and are injected via shell environment when the stack starts.</span>
|
||||
</div>
|
||||
@@ -429,7 +430,7 @@
|
||||
<CodeEditor
|
||||
value={rawContent}
|
||||
language="dotenv"
|
||||
theme={editorTheme}
|
||||
theme={theme}
|
||||
readonly={readonly}
|
||||
onchange={handleTextChange}
|
||||
class="h-full min-h-[200px] rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { AlertCircle, Copy, Check, AlertTriangle, CheckCircle2, XCircle } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), title, message, details, onClose }: Props = $props();
|
||||
let copied = $state(false);
|
||||
|
||||
interface ParsedOutput {
|
||||
warnings: string[];
|
||||
steps: { action: string; status: 'creating' | 'created' | 'starting' | 'started' | 'error' }[];
|
||||
error: string | null;
|
||||
raw: string;
|
||||
parsed: boolean;
|
||||
}
|
||||
|
||||
// Parse docker compose output into structured format
|
||||
function parseDockerOutput(text: string): ParsedOutput {
|
||||
const result: ParsedOutput = {
|
||||
warnings: [],
|
||||
steps: [],
|
||||
error: null,
|
||||
raw: text,
|
||||
parsed: false
|
||||
};
|
||||
|
||||
try {
|
||||
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
// Parse time="..." level=warning msg="..."
|
||||
const warningMatch = line.match(/time="[^"]*"\s+level=warning\s+msg="([^"]+)"/);
|
||||
if (warningMatch) {
|
||||
result.warnings.push(warningMatch[1]);
|
||||
result.parsed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse container/network steps: "Network foo Creating" or "Container foo-1 Created"
|
||||
const stepMatch = line.match(/^\s*(Network|Container|Volume)\s+(\S+)\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i);
|
||||
if (stepMatch) {
|
||||
const [, type, name, status] = stepMatch;
|
||||
const normalizedStatus = status.toLowerCase() as any;
|
||||
result.steps.push({
|
||||
action: `${type} ${name}`,
|
||||
status: normalizedStatus
|
||||
});
|
||||
result.parsed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse error lines
|
||||
if (line.startsWith('Error') || line.includes('error') || line.includes('failed')) {
|
||||
result.error = result.error ? `${result.error}\n${line}` : line;
|
||||
result.parsed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If we parsed something but have no clear error, check for remaining unparsed content
|
||||
if (result.parsed && !result.error) {
|
||||
const unparsed = lines.filter(line => {
|
||||
if (line.match(/time="[^"]*"\s+level=warning/)) return false;
|
||||
if (line.match(/^\s*(Network|Container|Volume)\s+\S+\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i)) return false;
|
||||
return true;
|
||||
});
|
||||
if (unparsed.length > 0) {
|
||||
result.error = unparsed.join('\n');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Parsing failed, will show raw message
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const parsed = $derived(parseDockerOutput(message));
|
||||
|
||||
async function copyError() {
|
||||
const text = details ? `${message}\n\n${details}` : message;
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(o) => !o && handleClose()}>
|
||||
<Dialog.Content class="max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle class="w-5 h-5" />
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{#if parsed.parsed}
|
||||
<!-- Parsed docker compose output -->
|
||||
{#if parsed.warnings.length > 0}
|
||||
<div class="space-y-1">
|
||||
{#each parsed.warnings as warning}
|
||||
<div class="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 px-2.5 py-1.5 rounded-md">
|
||||
<AlertTriangle class="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if parsed.steps.length > 0}
|
||||
<div class="bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md p-2.5 space-y-1">
|
||||
{#each parsed.steps as step}
|
||||
<div class="flex items-center gap-2 text-xs font-mono">
|
||||
{#if step.status === 'created' || step.status === 'started' || step.status === 'removed' || step.status === 'stopped'}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
|
||||
{:else if step.status === 'error'}
|
||||
<XCircle class="w-3.5 h-3.5 text-red-500" />
|
||||
{:else}
|
||||
<div class="w-3.5 h-3.5 rounded-full border-2 border-zinc-400"></div>
|
||||
{/if}
|
||||
<span class="text-zinc-600 dark:text-zinc-300">{step.action}</span>
|
||||
<span class="text-zinc-400 dark:text-zinc-500 capitalize">{step.status}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if parsed.error}
|
||||
<div class="bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md p-3 relative group">
|
||||
<button
|
||||
onclick={copyError}
|
||||
class="absolute top-2 right-2 p-1 rounded text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-opacity"
|
||||
title="Copy error"
|
||||
>
|
||||
{#if copied}
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
{:else}
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
<pre class="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap break-words font-mono pr-6">{parsed.error}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Fallback to raw message -->
|
||||
<div class="relative group">
|
||||
<button
|
||||
onclick={copyError}
|
||||
class="absolute top-1 right-1 p-1 rounded text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Copy error"
|
||||
>
|
||||
{#if copied}
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
{:else}
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
<pre class="text-sm whitespace-pre-wrap font-sans pr-6">{message}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if details}
|
||||
<pre class="text-xs bg-zinc-100 dark:bg-zinc-800 p-3 rounded-md overflow-auto max-h-64 whitespace-pre-wrap break-all">{details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer class="flex gap-2 sm:justify-end">
|
||||
<Button onclick={handleClose}>OK</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,3 @@
|
||||
import ErrorDialog from './error-dialog.svelte';
|
||||
|
||||
export { ErrorDialog };
|
||||
@@ -58,7 +58,14 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
|
||||
// Create a temporary SSH key file (use absolute path so SSH can find it)
|
||||
const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`));
|
||||
await Bun.write(sshKeyPath, credential.sshPrivateKey);
|
||||
|
||||
// Ensure SSH key ends with a newline (newer SSH versions are strict about this)
|
||||
let keyContent = credential.sshPrivateKey;
|
||||
if (!keyContent.endsWith('\n')) {
|
||||
keyContent += '\n';
|
||||
}
|
||||
|
||||
await Bun.write(sshKeyPath, keyContent);
|
||||
// Ensure SSH key has correct permissions (0600 = owner read/write only)
|
||||
// Bun.write's mode option doesn't always work reliably, so use chmodSync
|
||||
chmodSync(sshKeyPath, 0o600);
|
||||
|
||||
+17
-22
@@ -263,24 +263,19 @@ export async function getStackComposeFile(
|
||||
): Promise<{ success: boolean; content?: string; stackDir?: string; error?: string }> {
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
const composeFile = join(stackDir, 'docker-compose.yml');
|
||||
|
||||
const ymlFile = Bun.file(composeFile);
|
||||
if (await ymlFile.exists()) {
|
||||
return {
|
||||
success: true,
|
||||
content: await ymlFile.text(),
|
||||
stackDir
|
||||
};
|
||||
}
|
||||
// Check all common compose file names (Docker Compose v1 and v2 naming conventions)
|
||||
const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
||||
|
||||
const yamlFile = Bun.file(join(stackDir, 'docker-compose.yaml'));
|
||||
if (await yamlFile.exists()) {
|
||||
return {
|
||||
success: true,
|
||||
content: await yamlFile.text(),
|
||||
stackDir
|
||||
};
|
||||
for (const fileName of composeFileNames) {
|
||||
const file = Bun.file(join(stackDir, fileName));
|
||||
if (await file.exists()) {
|
||||
return {
|
||||
success: true,
|
||||
content: await file.text(),
|
||||
stackDir
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1289,8 +1284,10 @@ export async function writeStackEnvFile(
|
||||
const stacksDir = getStacksDir();
|
||||
const envFilePath = join(stacksDir, stackName, '.env');
|
||||
|
||||
// SECURITY: Only write non-secret variables to .env file
|
||||
// Secrets are stored in DB and injected via shell environment at runtime
|
||||
const rawContent = variables
|
||||
.filter(v => v.key?.trim())
|
||||
.filter(v => v.key?.trim() && !v.isSecret)
|
||||
.map(v => `${v.key.trim()}=${v.value}`)
|
||||
.join('\n') + '\n';
|
||||
|
||||
@@ -1299,16 +1296,14 @@ export async function writeStackEnvFile(
|
||||
|
||||
/**
|
||||
* Write raw environment content directly to the .env file (preserves comments/formatting)
|
||||
*
|
||||
* NOTE: Raw content should NOT contain secrets. Secrets are managed via the form view,
|
||||
* stored in DB, and injected via shell environment at runtime.
|
||||
*/
|
||||
export async function writeRawStackEnvFile(
|
||||
stackName: string,
|
||||
rawContent: string
|
||||
): Promise<void> {
|
||||
// Guard against writing masked secret placeholders (would corrupt the file)
|
||||
if (rawContent.match(/^[A-Za-z_][A-Za-z0-9_]*=\*\*\*$/m)) {
|
||||
throw new Error('Cannot write masked placeholder "***" to .env file - this would corrupt secret values');
|
||||
}
|
||||
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
|
||||
|
||||
@@ -96,17 +96,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
}
|
||||
|
||||
// Save environment variables
|
||||
// NEW SIMPLIFIED: rawEnvContent contains ALL vars including secrets (with real values)
|
||||
// Secrets are visually masked in the UI but stored with real values in .env file
|
||||
// - rawEnvContent: non-secret vars with comments → .env file
|
||||
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
|
||||
if (rawEnvContent) {
|
||||
// Write raw content directly to .env file (includes secrets with real values)
|
||||
// Write raw content to .env file (should NOT contain secrets)
|
||||
await writeRawStackEnvFile(name, rawEnvContent);
|
||||
// Save secret metadata to DB (for UI masking purposes)
|
||||
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
await saveStackEnvVarsToDb(name, envVars, envIdNum);
|
||||
}
|
||||
} else if (envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
// Fallback: generate from vars (no raw content provided)
|
||||
}
|
||||
// Save ALL vars to DB (secrets for shell injection at runtime)
|
||||
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
await saveStackEnvVarsToDb(name, envVars, envIdNum);
|
||||
}
|
||||
// Fallback: if no rawEnvContent, generate .env from non-secret vars
|
||||
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
await saveStackEnvVars(name, envVars, envIdNum);
|
||||
}
|
||||
|
||||
@@ -125,16 +126,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
// First ensure the stack directory exists by saving compose file
|
||||
await saveStackComposeFile(name, compose, true);
|
||||
|
||||
// NEW SIMPLIFIED: rawEnvContent contains ALL vars including secrets (with real values)
|
||||
// - rawEnvContent: non-secret vars with comments → .env file
|
||||
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
|
||||
if (rawEnvContent) {
|
||||
// Write raw content directly to .env file (includes secrets with real values)
|
||||
// Write raw content to .env file (should NOT contain secrets)
|
||||
await writeRawStackEnvFile(name, rawEnvContent);
|
||||
// Save secret metadata to DB (for UI masking purposes)
|
||||
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
await saveStackEnvVarsToDb(name, envVars, envIdNum);
|
||||
}
|
||||
} else {
|
||||
// Fallback: generate from vars (no raw content provided)
|
||||
}
|
||||
// Save ALL vars to DB (secrets for shell injection at runtime)
|
||||
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
await saveStackEnvVarsToDb(name, envVars, envIdNum);
|
||||
}
|
||||
// Fallback: if no rawEnvContent, generate .env from non-secret vars
|
||||
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
await saveStackEnvVars(name, envVars, envIdNum);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +49,13 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
||||
|
||||
let result;
|
||||
if (restart) {
|
||||
// Deploy with docker compose up -d (only recreates changed services)
|
||||
// Deploy with docker compose up -d --force-recreate
|
||||
// Force recreate ensures env var changes are applied
|
||||
result = await deployStack({
|
||||
name,
|
||||
compose: content,
|
||||
envId: envIdNum
|
||||
envId: envIdNum,
|
||||
forceRecreate: true
|
||||
});
|
||||
} else {
|
||||
// Just save the file without restarting
|
||||
|
||||
@@ -1055,8 +1055,6 @@
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
document.removeEventListener('resume', handleVisibilityChange);
|
||||
errorTimeouts.forEach(id => clearTimeout(id));
|
||||
errorTimeouts = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable, HelpCircle, GripVertical, FolderOpen } from 'lucide-svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable, HelpCircle, GripVertical, FolderOpen, Copy, Check } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
@@ -32,6 +32,7 @@
|
||||
let newStackName = $state('');
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let savingWithRestart = $state(false); // Track which save action is in progress
|
||||
let error = $state<string | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
let errors = $state<{ stackName?: string; compose?: string }>({});
|
||||
@@ -56,6 +57,15 @@
|
||||
|
||||
// Stack location (for edit mode)
|
||||
let stackLocation = $state<string | null>(null);
|
||||
let pathCopied = $state(false);
|
||||
|
||||
function copyPath() {
|
||||
if (stackLocation) {
|
||||
navigator.clipboard.writeText(stackLocation);
|
||||
pathCopied = true;
|
||||
setTimeout(() => pathCopied = false, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// CodeEditor reference for explicit marker updates
|
||||
let codeEditorRef: CodeEditor | null = $state(null);
|
||||
@@ -181,13 +191,18 @@ services:
|
||||
const displayName = $derived(mode === 'edit' ? stackName : (newStackName || 'New stack'));
|
||||
|
||||
onMount(() => {
|
||||
// Follow app theme from localStorage
|
||||
const appTheme = localStorage.getItem('theme');
|
||||
if (appTheme === 'dark' || appTheme === 'light') {
|
||||
editorTheme = appTheme;
|
||||
// Load saved editor theme, or fall back to app theme / system preference
|
||||
const savedEditorTheme = localStorage.getItem('dockhand-editor-theme');
|
||||
if (savedEditorTheme === 'dark' || savedEditorTheme === 'light') {
|
||||
editorTheme = savedEditorTheme;
|
||||
} else {
|
||||
// Fallback to system preference
|
||||
editorTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const appTheme = localStorage.getItem('theme');
|
||||
if (appTheme === 'dark' || appTheme === 'light') {
|
||||
editorTheme = appTheme;
|
||||
} else {
|
||||
// Fallback to system preference
|
||||
editorTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved split ratio
|
||||
@@ -395,6 +410,7 @@ services:
|
||||
}
|
||||
|
||||
saving = true;
|
||||
savingWithRestart = restart;
|
||||
error = null;
|
||||
|
||||
// Prepare env vars for saving - syncs variables and rawContent
|
||||
@@ -602,9 +618,20 @@ services:
|
||||
{#if mode === 'create'}
|
||||
Create a new Docker Compose stack
|
||||
{:else if stackLocation}
|
||||
<span class="flex items-center gap-1">
|
||||
<FolderOpen class="w-3 h-3" />
|
||||
<code class="bg-zinc-200 dark:bg-zinc-700 px-1 rounded text-2xs">{stackLocation}</code>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<FolderOpen class="w-3.5 h-3.5" />
|
||||
<code class="bg-zinc-200 dark:bg-zinc-700 px-1.5 py-0.5 rounded text-xs">{stackLocation}</code>
|
||||
<button
|
||||
onclick={copyPath}
|
||||
class="p-0.5 rounded hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors"
|
||||
title="Copy path"
|
||||
>
|
||||
{#if pathCopied}
|
||||
<Check class="w-3.5 h-3.5 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
Edit compose file and view stack structure
|
||||
@@ -612,9 +639,11 @@ services:
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- View toggle -->
|
||||
<div class="flex items-center gap-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-md p-0.5 ml-3">
|
||||
<div class="flex items-center gap-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-md p-0.5">
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs transition-colors {activeTab === 'editor' ? 'bg-white dark:bg-zinc-900 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
|
||||
onclick={() => activeTab = 'editor'}
|
||||
@@ -630,9 +659,7 @@ services:
|
||||
Graph
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Theme toggle (only in editor mode) -->
|
||||
{#if activeTab === 'editor'}
|
||||
<button
|
||||
@@ -787,6 +814,7 @@ services:
|
||||
validation={envValidation}
|
||||
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
|
||||
onchange={() => { markDirty(); debouncedValidate(); }}
|
||||
theme={editorTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -840,8 +868,8 @@ services:
|
||||
</Button>
|
||||
{:else}
|
||||
<!-- Edit mode buttons -->
|
||||
<Button variant="outline" onclick={() => handleSave(false)} disabled={saving || loading || !!loadError}>
|
||||
{#if saving}
|
||||
<Button variant="outline" class="w-24" onclick={() => handleSave(false)} disabled={saving || loading || !!loadError}>
|
||||
{#if saving && !savingWithRestart}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
@@ -849,13 +877,13 @@ services:
|
||||
Save
|
||||
{/if}
|
||||
</Button>
|
||||
<Button onclick={() => handleSave(true)} disabled={saving || loading || !!loadError}>
|
||||
{#if saving}
|
||||
<Button class="w-36" onclick={() => handleSave(true)} disabled={saving || loading || !!loadError}>
|
||||
{#if saving && savingWithRestart}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Applying...
|
||||
Restarting...
|
||||
{:else}
|
||||
<Play class="w-4 h-4 mr-2" />
|
||||
Save & apply
|
||||
Save & restart
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user