mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-08 05:39:06 +00:00
Initial commit
This commit is contained in:
1934
routes/stacks/+page.svelte
Normal file
1934
routes/stacks/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
3018
routes/stacks/ComposeGraphViewer.svelte
Normal file
3018
routes/stacks/ComposeGraphViewer.svelte
Normal file
File diff suppressed because it is too large
Load Diff
265
routes/stacks/GitDeployProgressPopover.svelte
Normal file
265
routes/stacks/GitDeployProgressPopover.svelte
Normal file
@@ -0,0 +1,265 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import {
|
||||
Rocket,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
GitBranch,
|
||||
FileCode,
|
||||
Server,
|
||||
Link
|
||||
} from 'lucide-svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
|
||||
interface Props {
|
||||
stackId: number;
|
||||
stackName: string;
|
||||
onComplete?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { stackId, stackName, onComplete, children }: Props = $props();
|
||||
|
||||
interface StepProgress {
|
||||
status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'deploying' | 'complete' | 'error';
|
||||
message?: string;
|
||||
step?: number;
|
||||
totalSteps?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let open = $state(false);
|
||||
let overallStatus = $state<'idle' | 'deploying' | 'complete' | 'error'>('idle');
|
||||
let currentStep = $state<StepProgress | null>(null);
|
||||
let steps = $state<StepProgress[]>([]);
|
||||
let errorMessage = $state('');
|
||||
|
||||
function getStepIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'connecting':
|
||||
return Link;
|
||||
case 'cloning':
|
||||
return GitBranch;
|
||||
case 'fetching':
|
||||
return GitBranch;
|
||||
case 'reading':
|
||||
return FileCode;
|
||||
case 'deploying':
|
||||
return Server;
|
||||
case 'complete':
|
||||
return CheckCircle2;
|
||||
case 'error':
|
||||
return XCircle;
|
||||
default:
|
||||
return Loader2;
|
||||
}
|
||||
}
|
||||
|
||||
function getStepColor(status: string, isCurrentStep: boolean): string {
|
||||
if (status === 'complete') {
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
}
|
||||
if (status === 'error') {
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
}
|
||||
if (isCurrentStep) {
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
}
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
|
||||
async function startDeploy() {
|
||||
steps = [];
|
||||
currentStep = null;
|
||||
overallStatus = 'deploying';
|
||||
errorMessage = '';
|
||||
open = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/git/stacks/${stackId}/deploy-stream`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to start deployment');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || !line.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const data: StepProgress = JSON.parse(line.slice(6));
|
||||
|
||||
if (data.status === 'complete') {
|
||||
overallStatus = 'complete';
|
||||
currentStep = data;
|
||||
steps = [...steps, data];
|
||||
onComplete?.();
|
||||
} else if (data.status === 'error') {
|
||||
overallStatus = 'error';
|
||||
errorMessage = data.error || 'Unknown error occurred';
|
||||
currentStep = data;
|
||||
steps = [...steps, data];
|
||||
} else {
|
||||
currentStep = data;
|
||||
steps = [...steps, data];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to deploy git stack:', error);
|
||||
overallStatus = 'error';
|
||||
errorMessage = error.message || 'Failed to deploy';
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
// Only allow closing via the Close button (not by clicking outside)
|
||||
// When deploying, complete, or error - require explicit close
|
||||
if (!isOpen && overallStatus !== 'idle') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start deploy when opening
|
||||
if (isOpen && !open) {
|
||||
startDeploy();
|
||||
return;
|
||||
}
|
||||
|
||||
open = isOpen;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
// Reset state when closed
|
||||
overallStatus = 'idle';
|
||||
steps = [];
|
||||
currentStep = null;
|
||||
errorMessage = '';
|
||||
}
|
||||
|
||||
const progressPercentage = $derived(
|
||||
currentStep?.step && currentStep?.totalSteps
|
||||
? Math.round((currentStep.step / currentStep.totalSteps) * 100)
|
||||
: 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<Popover.Root {open} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger asChild>
|
||||
{@render children()}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="w-80 p-0 overflow-hidden flex flex-col"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
interactOutsideBehavior={overallStatus !== 'idle' ? 'ignore' : 'close'}
|
||||
escapeKeydownBehavior={overallStatus !== 'idle' ? 'ignore' : 'close'}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="p-3 border-b space-y-2">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<Rocket class="w-4 h-4 text-violet-600" />
|
||||
<span class="truncate">{stackName}</span>
|
||||
</div>
|
||||
|
||||
<!-- Overall Progress -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if overallStatus === 'idle'}
|
||||
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">Initializing...</span>
|
||||
{:else if overallStatus === 'deploying'}
|
||||
<Loader2 class="w-4 h-4 animate-spin text-violet-600" />
|
||||
<span class="text-sm">Deploying...</span>
|
||||
{:else if overallStatus === 'complete'}
|
||||
<CheckCircle2 class="w-4 h-4 text-green-600" />
|
||||
<span class="text-sm text-green-600">Complete!</span>
|
||||
{:else if overallStatus === 'error'}
|
||||
<XCircle class="w-4 h-4 text-red-600" />
|
||||
<span class="text-sm text-red-600">Failed</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if currentStep?.step && currentStep?.totalSteps}
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{currentStep.step}/{currentStep.totalSteps}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if currentStep?.message && overallStatus === 'deploying'}
|
||||
<p class="text-xs text-muted-foreground truncate">{currentStep.message}</p>
|
||||
{/if}
|
||||
|
||||
{#if currentStep?.totalSteps}
|
||||
<Progress value={progressPercentage} class="h-1.5 [&>[data-progress]]:bg-violet-600" />
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="flex items-start gap-2 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle class="w-3 h-3 shrink-0 mt-0.5" />
|
||||
<span class="break-all">{errorMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Steps List -->
|
||||
{#if steps.length > 0}
|
||||
<div class="p-2 max-h-48 overflow-auto">
|
||||
<div class="space-y-1">
|
||||
{#each steps as step, index (index)}
|
||||
{@const StepIcon = getStepIcon(step.status)}
|
||||
{@const isCurrentStep = index === steps.length - 1 && overallStatus === 'deploying'}
|
||||
<div class="flex items-center gap-2 py-1 px-1 rounded text-xs hover:bg-muted/50">
|
||||
<StepIcon
|
||||
class="w-3.5 h-3.5 shrink-0 {getStepColor(step.status, isCurrentStep)} {isCurrentStep && step.status !== 'complete' && step.status !== 'error' ? 'animate-spin' : ''}"
|
||||
/>
|
||||
<span class="flex-1 {getStepColor(step.status, isCurrentStep)} truncate">
|
||||
{step.message || step.status}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
{#if overallStatus === 'complete' || overallStatus === 'error'}
|
||||
<div class="p-2 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
onclick={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
805
routes/stacks/GitStackModal.svelte
Normal file
805
routes/stacks/GitStackModal.svelte
Normal file
@@ -0,0 +1,805 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, FolderGit2, Github, Key, KeyRound, Lock, FileText } from 'lucide-svelte';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
|
||||
interface GitCredential {
|
||||
id: number;
|
||||
name: string;
|
||||
authType: string;
|
||||
}
|
||||
|
||||
function getAuthLabel(authType: string) {
|
||||
switch (authType) {
|
||||
case 'ssh': return 'SSH Key';
|
||||
case 'password': return 'Password';
|
||||
default: return 'None';
|
||||
}
|
||||
}
|
||||
|
||||
interface GitRepository {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
branch: string;
|
||||
credential_id: number | null;
|
||||
}
|
||||
|
||||
interface GitStack {
|
||||
id: number;
|
||||
stackName: string;
|
||||
repositoryId: number;
|
||||
composePath: string;
|
||||
envFilePath: string | null;
|
||||
autoUpdate: boolean;
|
||||
autoUpdateSchedule: 'daily' | 'weekly' | 'custom';
|
||||
autoUpdateCron: string;
|
||||
webhookEnabled: boolean;
|
||||
webhookSecret: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
gitStack?: GitStack | null;
|
||||
environmentId?: number | null;
|
||||
repositories: GitRepository[];
|
||||
credentials: GitCredential[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), gitStack = null, environmentId = null, repositories, credentials, onClose, onSaved }: Props = $props();
|
||||
|
||||
// Form state - repository selection or creation
|
||||
let formRepoMode = $state<'existing' | 'new'>('existing');
|
||||
let formRepositoryId = $state<number | null>(null);
|
||||
let formNewRepoName = $state('');
|
||||
let formNewRepoUrl = $state('');
|
||||
let formNewRepoBranch = $state('main');
|
||||
let formNewRepoCredentialId = $state<number | null>(null);
|
||||
|
||||
// Form state - stack deployment config
|
||||
let formStackName = $state('');
|
||||
let formStackNameUserModified = $state(false);
|
||||
let formComposePath = $state('docker-compose.yml');
|
||||
let formAutoUpdate = $state(false);
|
||||
let formAutoUpdateCron = $state('0 3 * * *');
|
||||
let formWebhookEnabled = $state(false);
|
||||
let formWebhookSecret = $state('');
|
||||
let formDeployNow = $state(false);
|
||||
let formError = $state('');
|
||||
let formSaving = $state(false);
|
||||
let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string }>({});
|
||||
let copiedWebhookUrl = $state(false);
|
||||
let copiedWebhookSecret = $state(false);
|
||||
|
||||
// Environment variables state
|
||||
let formEnvFilePath = $state<string | null>(null);
|
||||
let envFiles = $state<string[]>([]);
|
||||
let loadingEnvFiles = $state(false);
|
||||
let envVars = $state<EnvVar[]>([]);
|
||||
let fileEnvVars = $state<Record<string, string>>({});
|
||||
let loadingFileVars = $state(false);
|
||||
let existingSecretKeys = $state<Set<string>>(new Set());
|
||||
|
||||
// Derived state for merged variables and sources
|
||||
const envVarSources = $derived<Record<string, 'file' | 'override'>>(() => {
|
||||
const sources: Record<string, 'file' | 'override'> = {};
|
||||
// File vars
|
||||
for (const key of Object.keys(fileEnvVars)) {
|
||||
sources[key] = 'file';
|
||||
}
|
||||
// Overrides take precedence
|
||||
for (const v of envVars.filter(v => v.key)) {
|
||||
sources[v.key] = 'override';
|
||||
}
|
||||
return sources;
|
||||
});
|
||||
|
||||
// Track which gitStack was initialized to avoid repeated resets
|
||||
let lastInitializedStackId = $state<number | null | undefined>(undefined);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
const currentStackId = gitStack?.id ?? null;
|
||||
if (lastInitializedStackId !== currentStackId) {
|
||||
lastInitializedStackId = currentStackId;
|
||||
resetForm();
|
||||
}
|
||||
} else {
|
||||
lastInitializedStackId = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// Derived state for selected repository
|
||||
let selectedRepo = $derived(formRepositoryId ? repositories.find(r => r.id === formRepositoryId) : null);
|
||||
|
||||
function generateWebhookSecret(): string {
|
||||
const array = new Uint8Array(24);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function getWebhookUrl(stackId: number): string {
|
||||
return `${window.location.origin}/api/git/stacks/${stackId}/webhook`;
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string, type: 'url' | 'secret') {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (type === 'url') {
|
||||
copiedWebhookUrl = true;
|
||||
setTimeout(() => copiedWebhookUrl = false, 2000);
|
||||
} else {
|
||||
copiedWebhookSecret = true;
|
||||
setTimeout(() => copiedWebhookSecret = false, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEnvFiles() {
|
||||
if (!gitStack) return;
|
||||
|
||||
loadingEnvFiles = true;
|
||||
try {
|
||||
const response = await fetch(`/api/git/stacks/${gitStack.id}/env-files`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
envFiles = data.files || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load env files:', e);
|
||||
} finally {
|
||||
loadingEnvFiles = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEnvFileContents(path: string) {
|
||||
if (!gitStack || !path) {
|
||||
fileEnvVars = {};
|
||||
return;
|
||||
}
|
||||
|
||||
loadingFileVars = true;
|
||||
try {
|
||||
const response = await fetch(`/api/git/stacks/${gitStack.id}/env-files`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
fileEnvVars = data.vars || {};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load env file contents:', e);
|
||||
fileEnvVars = {};
|
||||
} finally {
|
||||
loadingFileVars = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEnvVarsOverrides() {
|
||||
if (!gitStack) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/stacks/${encodeURIComponent(gitStack.stackName)}/env${environmentId ? `?env=${environmentId}` : ''}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
envVars = data.variables || [];
|
||||
// Track existing secret keys (secrets loaded from DB cannot have visibility toggled)
|
||||
existingSecretKeys = new Set(
|
||||
envVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load env var overrides:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
if (gitStack) {
|
||||
formRepoMode = 'existing';
|
||||
formRepositoryId = gitStack.repositoryId;
|
||||
formStackName = gitStack.stackName;
|
||||
formComposePath = gitStack.composePath;
|
||||
formEnvFilePath = gitStack.envFilePath;
|
||||
formAutoUpdate = gitStack.autoUpdate;
|
||||
formAutoUpdateCron = gitStack.autoUpdateCron || '0 3 * * *';
|
||||
formWebhookEnabled = gitStack.webhookEnabled;
|
||||
formWebhookSecret = gitStack.webhookSecret || '';
|
||||
formDeployNow = false;
|
||||
// Load env files and overrides for editing
|
||||
loadEnvFiles();
|
||||
loadEnvVarsOverrides();
|
||||
if (gitStack.envFilePath) {
|
||||
loadEnvFileContents(gitStack.envFilePath);
|
||||
}
|
||||
} else {
|
||||
formRepoMode = repositories.length > 0 ? 'existing' : 'new';
|
||||
formRepositoryId = null;
|
||||
formNewRepoName = '';
|
||||
formNewRepoUrl = '';
|
||||
formNewRepoBranch = 'main';
|
||||
formNewRepoCredentialId = null;
|
||||
formStackName = '';
|
||||
formStackNameUserModified = false;
|
||||
formComposePath = 'docker-compose.yml';
|
||||
formEnvFilePath = null;
|
||||
formAutoUpdate = false;
|
||||
formAutoUpdateCron = '0 3 * * *';
|
||||
formWebhookEnabled = false;
|
||||
formWebhookSecret = '';
|
||||
formDeployNow = false;
|
||||
}
|
||||
formError = '';
|
||||
errors = {};
|
||||
copiedWebhookUrl = false;
|
||||
copiedWebhookSecret = false;
|
||||
envFiles = [];
|
||||
envVars = [];
|
||||
fileEnvVars = {};
|
||||
existingSecretKeys = new Set();
|
||||
}
|
||||
|
||||
async function saveGitStack(deployAfterSave: boolean = false) {
|
||||
errors = {};
|
||||
let hasErrors = false;
|
||||
|
||||
if (!formStackName.trim()) {
|
||||
errors.stackName = 'Stack name is required';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (formRepoMode === 'existing' && !formRepositoryId) {
|
||||
errors.repository = 'Please select a repository';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (formRepoMode === 'new' && !formNewRepoName.trim()) {
|
||||
errors.repoName = 'Repository name is required';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (formRepoMode === 'new' && !formNewRepoUrl.trim()) {
|
||||
errors.repoUrl = 'Repository URL is required';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (hasErrors) return;
|
||||
|
||||
formSaving = true;
|
||||
formError = '';
|
||||
|
||||
try {
|
||||
let body: any = {
|
||||
stackName: formStackName,
|
||||
composePath: formComposePath || 'docker-compose.yml',
|
||||
envFilePath: formEnvFilePath,
|
||||
environmentId: environmentId,
|
||||
autoUpdate: formAutoUpdate,
|
||||
autoUpdateCron: formAutoUpdateCron,
|
||||
webhookEnabled: formWebhookEnabled,
|
||||
webhookSecret: formWebhookEnabled ? formWebhookSecret : null,
|
||||
deployNow: deployAfterSave
|
||||
};
|
||||
|
||||
if (formRepoMode === 'existing') {
|
||||
body.repositoryId = formRepositoryId;
|
||||
} else {
|
||||
// Create new repo inline
|
||||
body.repoName = formNewRepoName;
|
||||
body.url = formNewRepoUrl;
|
||||
body.branch = formNewRepoBranch || 'main';
|
||||
body.credentialId = formNewRepoCredentialId;
|
||||
}
|
||||
|
||||
const url = gitStack
|
||||
? `/api/git/stacks/${gitStack.id}`
|
||||
: '/api/git/stacks';
|
||||
const method = gitStack ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
formError = data.error || 'Failed to save git stack';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if deployment failed
|
||||
if (data.deployResult && !data.deployResult.success) {
|
||||
toast.error('Deployment failed', {
|
||||
description: data.deployResult.error || 'Unknown error'
|
||||
});
|
||||
onSaved(); // Still refresh the list to show the new stack
|
||||
onClose(); // Close modal, error shown as toast
|
||||
return;
|
||||
}
|
||||
|
||||
// Save environment variable overrides if we have any
|
||||
const definedVars = envVars.filter(v => v.key.trim());
|
||||
if (definedVars.length > 0) {
|
||||
try {
|
||||
const envResponse = await fetch(
|
||||
`/api/stacks/${encodeURIComponent(formStackName)}/env${environmentId ? `?env=${environmentId}` : ''}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
variables: definedVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
})
|
||||
}
|
||||
);
|
||||
if (!envResponse.ok) {
|
||||
console.error('Failed to save environment variables');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to save environment variables:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
formError = 'Failed to save git stack';
|
||||
} finally {
|
||||
formSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-populate stack name from selected repo and compose path (only if user hasn't manually edited)
|
||||
$effect(() => {
|
||||
if (formRepoMode === 'existing' && formRepositoryId && !gitStack && !formStackNameUserModified) {
|
||||
const repo = repositories.find(r => r.id === formRepositoryId);
|
||||
if (repo) {
|
||||
// Extract compose filename without extension for stack name
|
||||
const composeName = formComposePath
|
||||
.replace(/^.*\//, '') // Remove directory path
|
||||
.replace(/\.(yml|yaml)$/i, '') // Remove extension
|
||||
.replace(/^docker-compose\.?/, ''); // Remove docker-compose prefix
|
||||
|
||||
// Combine repo name with compose name if it's not the default
|
||||
if (composeName && composeName !== 'docker-compose') {
|
||||
formStackName = `${repo.name}-${composeName}`;
|
||||
} else {
|
||||
formStackName = repo.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (isOpen) focusFirstInput(); }}>
|
||||
<Dialog.Content class="max-w-6xl max-h-[90vh] flex flex-col overflow-hidden p-0">
|
||||
<Dialog.Header class="shrink-0 px-6 pt-6 pb-4 border-b">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<GitBranch class="w-5 h-5" />
|
||||
{gitStack ? 'Edit git stack' : 'Deploy from Git'}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{gitStack ? 'Update git stack settings' : 'Deploy a compose stack from a Git repository'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Left column: Form fields -->
|
||||
<div class="flex-1 overflow-y-auto space-y-4 py-4 px-6 border-r border-zinc-200 dark:border-zinc-700">
|
||||
<!-- Repository selection -->
|
||||
{#if !gitStack}
|
||||
<div class="space-y-3">
|
||||
<Label>Repository</Label>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={formRepoMode === 'existing' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => formRepoMode = 'existing'}
|
||||
disabled={repositories.length === 0}
|
||||
>
|
||||
Select existing
|
||||
</Button>
|
||||
<Button
|
||||
variant={formRepoMode === 'new' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => formRepoMode = 'new'}
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if formRepoMode === 'existing'}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={formRepositoryId?.toString() ?? ''}
|
||||
onValueChange={(v) => { formRepositoryId = v ? parseInt(v) : null; errors.repository = undefined; }}
|
||||
>
|
||||
<Select.Trigger class="w-full {errors.repository ? 'border-destructive' : ''}">
|
||||
{#if selectedRepo}
|
||||
{@const repoPath = selectedRepo.url.replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, '')}
|
||||
<div class="flex items-center gap-2 text-left">
|
||||
{#if selectedRepo.url.includes('github.com')}
|
||||
<Github class="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<FolderGit2 class="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<span class="truncate">{selectedRepo.name}</span>
|
||||
<span class="text-muted-foreground text-xs truncate hidden sm:inline">({repoPath})</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">Select a repository...</span>
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each repositories as repo}
|
||||
{@const repoPath = repo.url.replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, '')}
|
||||
<Select.Item value={repo.id.toString()} label={repo.name}>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if repo.url.includes('github.com')}
|
||||
<Github class="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<FolderGit2 class="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<span>{repo.name}</span>
|
||||
<span class="text-muted-foreground text-xs">- {repoPath}</span>
|
||||
<span class="text-muted-foreground text-xs flex items-center gap-1">
|
||||
<GitBranch class="w-3 h-3" />
|
||||
{repo.branch}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if errors.repository}
|
||||
<p class="text-xs text-destructive">{errors.repository}</p>
|
||||
{:else if repositories.length === 0}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
No repositories configured. Click "Add new" to add one.
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-3 p-3 border rounded-md bg-muted/30">
|
||||
<div class="space-y-2">
|
||||
<Label for="new-repo-name">Repository name</Label>
|
||||
<Input
|
||||
id="new-repo-name"
|
||||
bind:value={formNewRepoName}
|
||||
placeholder="e.g., my-stacks"
|
||||
class={errors.repoName ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
oninput={() => errors.repoName = undefined}
|
||||
/>
|
||||
{#if errors.repoName}
|
||||
<p class="text-xs text-destructive">{errors.repoName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="new-repo-url">Repository URL</Label>
|
||||
<Input
|
||||
id="new-repo-url"
|
||||
bind:value={formNewRepoUrl}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
class={errors.repoUrl ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
oninput={() => errors.repoUrl = undefined}
|
||||
/>
|
||||
{#if errors.repoUrl}
|
||||
<p class="text-xs text-destructive">{errors.repoUrl}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="new-repo-branch">Branch</Label>
|
||||
<Input id="new-repo-branch" bind:value={formNewRepoBranch} placeholder="main" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="new-repo-credential">Credential</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={formNewRepoCredentialId?.toString() ?? 'none'}
|
||||
onValueChange={(v) => formNewRepoCredentialId = v === 'none' ? null : parseInt(v)}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{@const selectedCred = credentials.find(c => c.id === formNewRepoCredentialId)}
|
||||
{#if selectedCred}
|
||||
{#if selectedCred.authType === 'ssh'}
|
||||
<KeyRound class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{:else if selectedCred.authType === 'password'}
|
||||
<Lock class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{:else}
|
||||
<Key class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{/if}
|
||||
<span>{selectedCred.name} ({getAuthLabel(selectedCred.authType)})</span>
|
||||
{:else}
|
||||
<Key class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span>None (public)</span>
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="none">
|
||||
<span class="flex items-center gap-2">
|
||||
<Key class="w-4 h-4 text-muted-foreground" />
|
||||
None (public)
|
||||
</span>
|
||||
</Select.Item>
|
||||
{#each credentials as cred}
|
||||
<Select.Item value={cred.id.toString()}>
|
||||
<span class="flex items-center gap-2">
|
||||
{#if cred.authType === 'ssh'}
|
||||
<KeyRound class="w-4 h-4 text-muted-foreground" />
|
||||
{:else if cred.authType === 'password'}
|
||||
<Lock class="w-4 h-4 text-muted-foreground" />
|
||||
{:else}
|
||||
<Key class="w-4 h-4 text-muted-foreground" />
|
||||
{/if}
|
||||
{cred.name} ({getAuthLabel(cred.authType)})
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stack configuration -->
|
||||
<div class="space-y-2">
|
||||
<Label for="stack-name">Stack name</Label>
|
||||
<Input
|
||||
id="stack-name"
|
||||
bind:value={formStackName}
|
||||
placeholder="e.g., my-app"
|
||||
class={errors.stackName ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
oninput={() => { errors.stackName = undefined; formStackNameUserModified = true; }}
|
||||
/>
|
||||
{#if errors.stackName}
|
||||
<p class="text-xs text-destructive">{errors.stackName}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">This will be the name of the deployed stack</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="compose-path">Compose file path</Label>
|
||||
<Input id="compose-path" bind:value={formComposePath} placeholder="docker-compose.yml" />
|
||||
<p class="text-xs text-muted-foreground">Path to the compose file within the repository</p>
|
||||
</div>
|
||||
|
||||
<!-- .env file path -->
|
||||
<div class="space-y-2">
|
||||
<Label for="env-file-path">.env file path</Label>
|
||||
{#if gitStack && envFiles.length > 0}
|
||||
<!-- Dropdown selector for existing stacks with discovered .env files -->
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={formEnvFilePath ?? 'none'}
|
||||
onValueChange={(v) => {
|
||||
formEnvFilePath = v === 'none' ? null : v;
|
||||
if (formEnvFilePath) {
|
||||
loadEnvFileContents(formEnvFilePath);
|
||||
} else {
|
||||
fileEnvVars = {};
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{#if loadingEnvFiles}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
{:else if formEnvFilePath}
|
||||
<FileText class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{formEnvFilePath}
|
||||
{:else}
|
||||
<FileText class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
None
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="none">
|
||||
<span class="text-muted-foreground">None</span>
|
||||
</Select.Item>
|
||||
{#each envFiles as file}
|
||||
<Select.Item value={file}>
|
||||
<span class="flex items-center gap-2">
|
||||
<FileText class="w-4 h-4 text-muted-foreground" />
|
||||
{file}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<!-- Text input for new stacks or when no .env files discovered -->
|
||||
<Input
|
||||
id="env-file-path"
|
||||
bind:value={formEnvFilePath}
|
||||
placeholder=".env"
|
||||
/>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">Path to the .env file within the repository (optional)</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto-update section -->
|
||||
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<RefreshCw class="w-4 h-4 text-muted-foreground" />
|
||||
<Label class="text-sm font-normal">Enable scheduled sync</Label>
|
||||
</div>
|
||||
<TogglePill bind:checked={formAutoUpdate} />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Automatically sync repository and redeploy stack if there are changes.
|
||||
</p>
|
||||
{#if formAutoUpdate}
|
||||
<CronEditor
|
||||
value={formAutoUpdateCron}
|
||||
onchange={(cron) => formAutoUpdateCron = cron}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Webhook section -->
|
||||
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<Webhook class="w-4 h-4 text-muted-foreground" />
|
||||
<Label class="text-sm font-normal">Enable webhook</Label>
|
||||
</div>
|
||||
<TogglePill bind:checked={formWebhookEnabled} />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Receive push events from your Git provider to trigger sync and redeploy.
|
||||
</p>
|
||||
{#if formWebhookEnabled}
|
||||
{#if gitStack}
|
||||
<div class="space-y-2">
|
||||
<Label>Webhook URL</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
value={getWebhookUrl(gitStack.id)}
|
||||
readonly
|
||||
class="font-mono text-xs bg-background"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => copyToClipboard(getWebhookUrl(gitStack.id), 'url')}
|
||||
title="Copy URL"
|
||||
>
|
||||
{#if copiedWebhookUrl}
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-4 h-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-2">
|
||||
<Label for="webhook-secret">Webhook secret (optional)</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="webhook-secret"
|
||||
bind:value={formWebhookSecret}
|
||||
placeholder="Leave empty for no signature verification"
|
||||
class="font-mono text-xs"
|
||||
/>
|
||||
{#if gitStack && formWebhookSecret}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => copyToClipboard(formWebhookSecret, 'secret')}
|
||||
title="Copy secret"
|
||||
>
|
||||
{#if copiedWebhookSecret}
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-4 h-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => formWebhookSecret = generateWebhookSecret()}
|
||||
title="Generate new secret"
|
||||
>
|
||||
<RefreshCcw class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if !gitStack}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
The webhook URL will be available after creating the stack.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Configure this URL in your Git provider. Secret is used for signature verification.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Deploy now option (only for new stacks) -->
|
||||
{#if !gitStack}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<Rocket class="w-4 h-4 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<Label class="text-sm font-normal">Deploy now</Label>
|
||||
<p class="text-xs text-muted-foreground">Clone and deploy the stack immediately</p>
|
||||
</div>
|
||||
</div>
|
||||
<TogglePill bind:checked={formDeployNow} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if formError}
|
||||
<p class="text-sm text-destructive">{formError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right column: Environment Variables -->
|
||||
<div class="w-[380px] flex-shrink-0 flex flex-col bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<StackEnvVarsPanel
|
||||
bind:variables={envVars}
|
||||
showSource={!!formEnvFilePath && gitStack !== null}
|
||||
sources={envVarSources}
|
||||
placeholder={{ key: 'OVERRIDE_VAR', value: 'override value' }}
|
||||
infoText="These environment variables are optional. If a .env file is specified in the repository, these values will be merged with the file values. Variables defined here take precedence over .env file values."
|
||||
existingSecretKeys={gitStack !== null ? existingSecretKeys : new Set()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="shrink-0 border-t px-6 py-4">
|
||||
<Button variant="outline" onclick={onClose}>Cancel</Button>
|
||||
{#if gitStack}
|
||||
<Button variant="outline" onclick={() => saveGitStack(true)} disabled={formSaving}>
|
||||
{#if formSaving}
|
||||
<Loader2 class="w-4 h-4 mr-1 animate-spin" />
|
||||
Deploying...
|
||||
{:else}
|
||||
<Rocket class="w-4 h-4 mr-1" />
|
||||
Save and deploy
|
||||
{/if}
|
||||
</Button>
|
||||
<Button onclick={() => saveGitStack(false)} disabled={formSaving}>
|
||||
{#if formSaving}
|
||||
<Loader2 class="w-4 h-4 mr-1 animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
Save changes
|
||||
{/if}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button onclick={() => saveGitStack(formDeployNow)} disabled={formSaving}>
|
||||
{#if formSaving}
|
||||
<Loader2 class="w-4 h-4 mr-1 animate-spin" />
|
||||
{formDeployNow ? 'Deploying...' : 'Creating...'}
|
||||
{:else}
|
||||
{formDeployNow ? 'Deploy' : 'Create'}
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
740
routes/stacks/StackModal.svelte
Normal file
740
routes/stacks/StackModal.svelte
Normal file
@@ -0,0 +1,740 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
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 } from 'lucide-svelte';
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
mode: 'create' | 'edit';
|
||||
stackName?: string; // Required for edit mode, optional for create
|
||||
onClose: () => void;
|
||||
onSuccess: () => void; // Called after create or save
|
||||
}
|
||||
|
||||
let { open = $bindable(), mode, stackName = '', onClose, onSuccess }: Props = $props();
|
||||
|
||||
// Form state
|
||||
let newStackName = $state('');
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
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 envValidation = $state<ValidationResult | null>(null);
|
||||
let validating = $state(false);
|
||||
let existingSecretKeys = $state<Set<string>>(new Set());
|
||||
|
||||
// CodeEditor reference for explicit marker updates
|
||||
let codeEditorRef: CodeEditor | null = $state(null);
|
||||
|
||||
// ComposeGraphViewer reference for resize on panel toggle
|
||||
let graphViewerRef: ComposeGraphViewer | null = $state(null);
|
||||
|
||||
// Debounce timer for validation
|
||||
let validateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const defaultCompose = `version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
- APP_ENV=\${APP_ENV:-production}
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# Add more services as needed
|
||||
# networks:
|
||||
# default:
|
||||
# driver: bridge
|
||||
`;
|
||||
|
||||
// Count of defined environment variables (with non-empty keys)
|
||||
const envVarCount = $derived(envVars.filter(v => v.key.trim()).length);
|
||||
|
||||
// Build a lookup map from envVars for quick access
|
||||
const envVarMap = $derived.by(() => {
|
||||
const map = new Map<string, { value: string; isSecret: boolean }>();
|
||||
for (const v of envVars) {
|
||||
if (v.key.trim()) {
|
||||
map.set(v.key.trim(), { value: v.value, isSecret: v.isSecret });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// Compute variable markers for the code editor (with values for overlay)
|
||||
const variableMarkers = $derived.by<VariableMarker[]>(() => {
|
||||
if (!envValidation) return [];
|
||||
|
||||
const markers: VariableMarker[] = [];
|
||||
|
||||
// Add missing required variables
|
||||
for (const name of envValidation.missing) {
|
||||
const env = envVarMap.get(name);
|
||||
markers.push({
|
||||
name,
|
||||
type: 'missing',
|
||||
value: env?.value,
|
||||
isSecret: env?.isSecret
|
||||
});
|
||||
}
|
||||
|
||||
// Add defined required variables
|
||||
for (const name of envValidation.required) {
|
||||
if (!envValidation.missing.includes(name)) {
|
||||
const env = envVarMap.get(name);
|
||||
markers.push({
|
||||
name,
|
||||
type: 'required',
|
||||
value: env?.value,
|
||||
isSecret: env?.isSecret
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add optional variables
|
||||
for (const name of envValidation.optional) {
|
||||
const env = envVarMap.get(name);
|
||||
markers.push({
|
||||
name,
|
||||
type: 'optional',
|
||||
value: env?.value,
|
||||
isSecret: env?.isSecret
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
debouncedValidate();
|
||||
}
|
||||
|
||||
// Debounced validation to avoid too many API calls while typing
|
||||
function debouncedValidate() {
|
||||
if (validateTimer) clearTimeout(validateTimer);
|
||||
validateTimer = setTimeout(() => {
|
||||
validateEnvVars();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Explicitly push markers to the editor
|
||||
function updateEditorMarkers() {
|
||||
if (!codeEditorRef) return;
|
||||
codeEditorRef.updateVariableMarkers(variableMarkers);
|
||||
}
|
||||
|
||||
// Check for env var changes (compare by serializing)
|
||||
const hasEnvVarChanges = $derived.by(() => {
|
||||
const current = JSON.stringify(envVars.filter(v => v.key));
|
||||
const original = JSON.stringify(originalEnvVars);
|
||||
return current !== original;
|
||||
});
|
||||
|
||||
const hasChanges = $derived(hasComposeChanges || hasEnvVarChanges);
|
||||
|
||||
// Display title
|
||||
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;
|
||||
} else {
|
||||
// Fallback to system preference
|
||||
editorTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
});
|
||||
|
||||
async function loadComposeFile() {
|
||||
if (mode !== 'edit' || !stackName) return;
|
||||
|
||||
loading = true;
|
||||
loadError = null;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
|
||||
// Load compose file
|
||||
const response = await fetch(`/api/stacks/${encodeURIComponent(stackName)}/compose`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to load compose file');
|
||||
}
|
||||
|
||||
composeContent = data.content;
|
||||
originalContent = data.content;
|
||||
|
||||
// Load environment variables
|
||||
const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId));
|
||||
if (envResponse.ok) {
|
||||
const envData = await envResponse.json();
|
||||
envVars = envData.variables || [];
|
||||
originalEnvVars = JSON.parse(JSON.stringify(envData.variables || []));
|
||||
// Track existing secret keys (secrets loaded from DB cannot have visibility toggled)
|
||||
existingSecretKeys = new Set(
|
||||
envVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateEnvVars() {
|
||||
const content = composeContent || defaultCompose;
|
||||
if (!content.trim()) return;
|
||||
|
||||
validating = true;
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
// Use 'new' as placeholder stack name for new stacks
|
||||
const stackNameForValidation = mode === 'edit' ? stackName : (newStackName.trim() || 'new');
|
||||
// Pass current UI env vars for validation
|
||||
const currentVars = envVars.filter(v => v.key.trim()).map(v => v.key.trim());
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackNameForValidation)}/env/validate`, envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ compose: content, variables: currentVars })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
envValidation = await response.json();
|
||||
// Explicitly update markers in the editor after validation
|
||||
// Use setTimeout to ensure derived variableMarkers has updated
|
||||
setTimeout(() => updateEditorMarkers(), 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to validate env vars:', e);
|
||||
} finally {
|
||||
validating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEditorTheme() {
|
||||
editorTheme = editorTheme === 'light' ? 'dark' : 'light';
|
||||
localStorage.setItem('dockhand-editor-theme', editorTheme);
|
||||
}
|
||||
|
||||
function handleGraphContentChange(newContent: string) {
|
||||
composeContent = newContent;
|
||||
}
|
||||
|
||||
async function handleCreate(start: boolean = false) {
|
||||
errors = {};
|
||||
let hasErrors = false;
|
||||
|
||||
if (!newStackName.trim()) {
|
||||
errors.stackName = 'Stack name is required';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
const content = composeContent || defaultCompose;
|
||||
if (!content.trim()) {
|
||||
errors.compose = 'Compose file content is required';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (hasErrors) return;
|
||||
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
|
||||
// Create the stack
|
||||
const response = await fetch(appendEnvParam('/api/stacks', envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newStackName.trim(),
|
||||
compose: content,
|
||||
start
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to create stack');
|
||||
}
|
||||
|
||||
// Save environment variables if any are defined
|
||||
const definedVars = envVars.filter(v => v.key.trim());
|
||||
if (definedVars.length > 0) {
|
||||
const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(newStackName.trim())}/env`, envId), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
variables: definedVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
if (!envResponse.ok) {
|
||||
console.error('Failed to save environment variables');
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
handleClose();
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(restart = false) {
|
||||
errors = {};
|
||||
|
||||
if (!composeContent.trim()) {
|
||||
errors.compose = 'Compose file content cannot be empty';
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
|
||||
// Save compose file
|
||||
const response = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: composeContent,
|
||||
restart
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to save compose file');
|
||||
}
|
||||
|
||||
// Save environment variables if any are defined
|
||||
const definedVars = envVars.filter(v => v.key.trim());
|
||||
if (definedVars.length > 0 || originalEnvVars.length > 0) {
|
||||
const envResponse = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
variables: definedVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!envResponse.ok) {
|
||||
console.error('Failed to save environment variables');
|
||||
}
|
||||
}
|
||||
|
||||
originalContent = composeContent;
|
||||
originalEnvVars = JSON.parse(JSON.stringify(definedVars));
|
||||
onSuccess();
|
||||
|
||||
if (!restart) {
|
||||
// Show success briefly then close
|
||||
setTimeout(() => handleClose(), 500);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function tryClose() {
|
||||
if (hasChanges) {
|
||||
showConfirmClose = true;
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
// Clear any pending validation timer
|
||||
if (validateTimer) {
|
||||
clearTimeout(validateTimer);
|
||||
validateTimer = null;
|
||||
}
|
||||
// Reset all state
|
||||
newStackName = '';
|
||||
error = null;
|
||||
loadError = null;
|
||||
errors = {};
|
||||
composeContent = '';
|
||||
originalContent = '';
|
||||
envVars = [];
|
||||
originalEnvVars = [];
|
||||
envValidation = null;
|
||||
existingSecretKeys = new Set();
|
||||
activeTab = 'editor';
|
||||
showConfirmClose = false;
|
||||
codeEditorRef = null;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function discardAndClose() {
|
||||
showConfirmClose = false;
|
||||
handleClose();
|
||||
}
|
||||
|
||||
// Initialize when dialog opens - ONLY ONCE per open
|
||||
let hasInitialized = $state(false);
|
||||
$effect(() => {
|
||||
if (open && !hasInitialized) {
|
||||
hasInitialized = true;
|
||||
if (mode === 'edit' && stackName) {
|
||||
loadComposeFile().then(() => {
|
||||
// Auto-validate after loading
|
||||
validateEnvVars();
|
||||
});
|
||||
} else if (mode === 'create') {
|
||||
// Set default compose content for create mode
|
||||
composeContent = defaultCompose;
|
||||
originalContent = defaultCompose; // Track original for change detection
|
||||
loading = false;
|
||||
// Auto-validate default compose
|
||||
validateEnvVars();
|
||||
}
|
||||
} else if (!open) {
|
||||
hasInitialized = false; // Reset when modal closes
|
||||
}
|
||||
});
|
||||
|
||||
// Re-validate when envVars change (adding/removing variables affects missing/defined status)
|
||||
$effect(() => {
|
||||
// Track envVars changes (this triggers on any modification to envVars array)
|
||||
const vars = envVars;
|
||||
if (!open || !envValidation) return;
|
||||
|
||||
// Debounce to avoid too many API calls while typing
|
||||
const timeout = setTimeout(() => {
|
||||
validateEnvVars();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (isOpen) {
|
||||
focusFirstInput();
|
||||
} else {
|
||||
// Prevent closing if there are unsaved changes - show confirmation instead
|
||||
if (hasChanges) {
|
||||
// Re-open the dialog and show confirmation
|
||||
open = true;
|
||||
showConfirmClose = true;
|
||||
}
|
||||
// If no changes, let it close naturally
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-w-7xl w-[95vw] h-[90vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700" showCloseButton={false}>
|
||||
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0 bg-zinc-50 dark:bg-zinc-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
|
||||
<Layers class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
{#if mode === 'create'}
|
||||
Create compose stack
|
||||
{:else}
|
||||
{stackName}
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{#if mode === 'create'}
|
||||
Create a new Docker Compose stack
|
||||
{:else}
|
||||
Edit compose file and view stack structure
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View toggle -->
|
||||
<div class="flex items-center gap-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-md p-0.5 ml-3">
|
||||
<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'}
|
||||
>
|
||||
<Code class="w-3.5 h-3.5" />
|
||||
Editor
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs transition-colors {activeTab === 'graph' ? '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 = 'graph'}
|
||||
>
|
||||
<GitGraph class="w-3.5 h-3.5" />
|
||||
Graph
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Theme toggle (only in editor mode) -->
|
||||
{#if activeTab === 'editor'}
|
||||
<button
|
||||
onclick={toggleEditorTheme}
|
||||
class="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
title={editorTheme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'}
|
||||
>
|
||||
{#if editorTheme === 'light'}
|
||||
<Moon class="w-4 h-4" />
|
||||
{:else}
|
||||
<Sun class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
onclick={tryClose}
|
||||
class="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
{#if error}
|
||||
<Alert.Root variant="destructive" class="mx-6 mt-4">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#if errors.compose}
|
||||
<Alert.Root variant="destructive" class="mx-6 mt-4">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>{errors.compose}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'edit' && loading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="flex items-center gap-3 text-zinc-400 dark:text-zinc-500">
|
||||
<Loader2 class="w-5 h-5 animate-spin" />
|
||||
<span>Loading compose file...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mode === 'edit' && loadError}
|
||||
<div class="flex-1 flex items-center justify-center p-6">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="w-12 h-12 rounded-full bg-amber-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<AlertCircle class="w-6 h-6 text-amber-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium mb-2">Could not load compose file</h3>
|
||||
<p class="text-sm text-zinc-400 dark:text-zinc-500 mb-4">{loadError}</p>
|
||||
<p class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
This stack may have been created outside of Dockhand or the compose file may have been moved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Stack name input (create mode only) -->
|
||||
{#if mode === 'create'}
|
||||
<div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<div class="max-w-md space-y-1">
|
||||
<Label for="stack-name">Stack name</Label>
|
||||
<Input
|
||||
id="stack-name"
|
||||
bind:value={newStackName}
|
||||
placeholder="my-stack"
|
||||
class={errors.stackName ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
oninput={() => errors.stackName = undefined}
|
||||
/>
|
||||
{#if errors.stackName}
|
||||
<p class="text-xs text-destructive">{errors.stackName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
{#if activeTab === 'editor'}
|
||||
<!-- Editor tab: Code editor + Env panel side by side -->
|
||||
<div class="w-[60%] flex-shrink-0 border-r border-zinc-200 dark:border-zinc-700 flex flex-col min-w-0">
|
||||
{#if open}
|
||||
<div class="flex-1 p-3 min-h-0">
|
||||
<CodeEditor
|
||||
bind:this={codeEditorRef}
|
||||
value={composeContent}
|
||||
language="yaml"
|
||||
theme={editorTheme}
|
||||
onchange={handleComposeChange}
|
||||
variableMarkers={variableMarkers}
|
||||
class="h-full rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Environment variables panel -->
|
||||
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-700 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
<Variable class="w-3.5 h-3.5" />
|
||||
Environment variables
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<StackEnvVarsPanel
|
||||
bind:variables={envVars}
|
||||
validation={envValidation}
|
||||
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
|
||||
onchange={() => validateEnvVars()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'graph'}
|
||||
<!-- Graph tab: Full width -->
|
||||
<ComposeGraphViewer
|
||||
bind:this={graphViewerRef}
|
||||
composeContent={composeContent || defaultCompose}
|
||||
class="h-full flex-1"
|
||||
onContentChange={handleGraphContentChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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 bg-zinc-50 dark:bg-zinc-800">
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{#if hasChanges}
|
||||
<span class="text-amber-600 dark:text-amber-500">Unsaved changes</span>
|
||||
{:else}
|
||||
No changes
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" onclick={tryClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
{#if mode === 'create'}
|
||||
<!-- Create mode buttons -->
|
||||
<Button variant="outline" onclick={() => handleCreate(false)} disabled={saving}>
|
||||
{#if saving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
{:else}
|
||||
<Save class="w-4 h-4 mr-2" />
|
||||
Create
|
||||
{/if}
|
||||
</Button>
|
||||
<Button onclick={() => handleCreate(true)} disabled={saving}>
|
||||
{#if saving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Starting...
|
||||
{:else}
|
||||
<Play class="w-4 h-4 mr-2" />
|
||||
Create & Start
|
||||
{/if}
|
||||
</Button>
|
||||
{:else}
|
||||
<!-- Edit mode buttons -->
|
||||
<Button variant="outline" onclick={() => handleSave(false)} disabled={saving || !hasChanges || loading || !!loadError}>
|
||||
{#if saving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Save class="w-4 h-4 mr-2" />
|
||||
Save
|
||||
{/if}
|
||||
</Button>
|
||||
<Button onclick={() => handleSave(true)} disabled={saving || !hasChanges || loading || !!loadError}>
|
||||
{#if saving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Applying...
|
||||
{:else}
|
||||
<Play class="w-4 h-4 mr-2" />
|
||||
Save & apply
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Unsaved changes confirmation dialog -->
|
||||
<Dialog.Root bind:open={showConfirmClose}>
|
||||
<Dialog.Content class="max-w-sm">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Unsaved changes</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
You have unsaved changes. Are you sure you want to close without saving?
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="flex justify-end gap-1.5 mt-4">
|
||||
<Button variant="outline" size="sm" onclick={() => showConfirmClose = false}>
|
||||
Continue editing
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onclick={discardAndClose}>
|
||||
Discard changes
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in New Issue
Block a user