import { useState } from 'react'; import { Plus, GitBranch, FileCode2, Loader2 } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '../ui/dialog'; import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '../ui/tabs'; import { springs } from '@/lib/motion'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { ScrollArea } from '../ui/scroll-area'; import { Checkbox } from '../ui/checkbox'; import { GitSourceFields, type ApplyMode } from '../stack/GitSourceFields'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; export interface CreateStackDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onStackCreated: (stackName: string) => void | Promise; onStacksChanged: () => void | Promise; } export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacksChanged }: CreateStackDialogProps) { const [createMode, setCreateMode] = useState<'empty' | 'git' | 'docker-run'>('empty'); const [newStackName, setNewStackName] = useState(''); const [dockerRunInput, setDockerRunInput] = useState(''); const [convertedYaml, setConvertedYaml] = useState(null); const [isConverting, setIsConverting] = useState(false); const [creatingFromDockerRun, setCreatingFromDockerRun] = useState(false); const [gitRepoUrl, setGitRepoUrl] = useState(''); const [gitBranch, setGitBranch] = useState('main'); const [gitComposePath, setGitComposePath] = useState('compose.yaml'); const [gitSyncEnv, setGitSyncEnv] = useState(false); const [gitAuthType, setGitAuthType] = useState<'none' | 'token'>('none'); const [gitToken, setGitToken] = useState(''); const [gitApplyMode, setGitApplyMode] = useState('review'); const [gitDeployNow, setGitDeployNow] = useState(false); const [creatingFromGit, setCreatingFromGit] = useState(false); const resetCreateFromGitForm = () => { setNewStackName(''); setGitRepoUrl(''); setGitBranch('main'); setGitComposePath('compose.yaml'); setGitSyncEnv(false); setGitAuthType('none'); setGitToken(''); setGitApplyMode('review'); setGitDeployNow(false); }; const resetCreateFromDockerRunForm = () => { setDockerRunInput(''); setConvertedYaml(null); setIsConverting(false); setCreatingFromDockerRun(false); }; const handleCreateStack = async () => { if (!newStackName.trim()) return; // Send stackName directly (no .yml extension - backend creates directory) const stackName = newStackName.trim(); try { const response = await apiFetch('/stacks', { method: 'POST', body: JSON.stringify({ stackName }), }); if (!response.ok) { if (response.status === 409) { throw new Error('Stack already exists'); } else if (response.status === 400) { throw new Error('Invalid stack name (use alphanumeric characters and hyphens only)'); } throw new Error('Failed to create stack'); } onOpenChange(false); setNewStackName(''); await onStackCreated(stackName); } catch (error) { console.error('Failed to create stack:', error); toast.error((error as Error).message || 'Failed to create stack'); } }; const handleCreateStackFromGit = async () => { const stackName = newStackName.trim(); if (!stackName) { toast.error('Stack name is required.'); return; } if (!gitRepoUrl.trim() || !gitBranch.trim() || !gitComposePath.trim()) { toast.error('Repository URL, branch, and compose path are required.'); return; } if (!/^https:\/\//i.test(gitRepoUrl.trim())) { toast.error('Only HTTPS repository URLs are supported.'); return; } setCreatingFromGit(true); const loadingId = toast.loading(gitDeployNow ? 'Fetching, creating, and deploying...' : 'Fetching and creating stack...'); try { const autoApply = gitApplyMode !== 'review'; const autoDeploy = gitApplyMode === 'auto-deploy'; const body: Record = { stack_name: stackName, repo_url: gitRepoUrl.trim(), branch: gitBranch.trim(), compose_path: gitComposePath.trim(), sync_env: gitSyncEnv, auth_type: gitAuthType, auto_apply_on_webhook: autoApply, auto_deploy_on_apply: autoDeploy, deploy_now: gitDeployNow, }; if (gitAuthType === 'token' && gitToken !== '') { body.token = gitToken; } const response = await apiFetch('/stacks/from-git', { method: 'POST', body: JSON.stringify(body), }); if (!response.ok) { const err = await response.json().catch(() => ({})); if (response.status === 409) { throw new Error(err?.error || 'Stack already exists.'); } throw new Error(err?.error || 'Failed to create stack from Git.'); } const data: { deployed?: boolean; deployError?: string; commitSha?: string; warnings?: string[]; } = await response.json(); const shortSha = typeof data.commitSha === 'string' ? data.commitSha.slice(0, 7) : ''; const shaSuffix = shortSha ? ` @ ${shortSha}` : ''; if (gitDeployNow && data.deployError) { toast.warning(`Stack created${shaSuffix}, but deploy failed: ${data.deployError}`); } else if (gitDeployNow && data.deployed) { toast.success(`Stack created and deployed from Git${shaSuffix}.`); } else { toast.success(`Stack created from Git${shaSuffix}.`); } if (Array.isArray(data.warnings) && data.warnings.length > 0) { toast.warning(data.warnings.join(' ')); } onOpenChange(false); resetCreateFromGitForm(); await onStackCreated(stackName); } catch (error) { console.error('Failed to create stack from Git:', error); toast.error((error as Error)?.message || 'Failed to create stack from Git.'); } finally { toast.dismiss(loadingId); setCreatingFromGit(false); } }; const handleConvertDockerRun = async () => { const command = dockerRunInput.trim(); if (!command) { toast.error('Paste a docker run command first.'); return; } setIsConverting(true); try { const response = await apiFetch('/convert', { method: 'POST', body: JSON.stringify({ dockerRun: command }), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data?.error || 'Could not parse command.'); } if (typeof data?.yaml !== 'string' || data.yaml.length === 0) { throw new Error('Converter returned an empty result.'); } setConvertedYaml(data.yaml); toast.success('Converted to compose YAML.'); } catch (error) { setConvertedYaml(null); const err = error as { message?: string; error?: string; data?: { error?: string } }; toast.error( err?.message || err?.error || err?.data?.error || 'Failed to convert docker run command.', ); } finally { setIsConverting(false); } }; const handleCreateStackFromDockerRun = async () => { const stackName = newStackName.trim(); if (!stackName) { toast.error('Stack name is required.'); return; } if (!convertedYaml) { toast.error('Convert the command before creating the stack.'); return; } setCreatingFromDockerRun(true); const loadingId = toast.loading('Creating stack from converted YAML...'); let createdStack = false; try { const createResponse = await apiFetch('/stacks', { method: 'POST', body: JSON.stringify({ stackName }), }); if (!createResponse.ok) { if (createResponse.status === 409) { throw new Error('Stack already exists.'); } if (createResponse.status === 400) { throw new Error('Invalid stack name (use alphanumeric characters and hyphens only).'); } throw new Error('Failed to create stack.'); } createdStack = true; const saveResponse = await apiFetch(`/stacks/${encodeURIComponent(stackName)}`, { method: 'PUT', body: JSON.stringify({ content: convertedYaml }), }); if (!saveResponse.ok) { // Roll back the empty stack we just created so we don't leave an orphan. await apiFetch(`/stacks/${encodeURIComponent(stackName)}`, { method: 'DELETE' }).catch((cleanupError) => { console.error('Failed to roll back orphan stack after save failure:', cleanupError); }); createdStack = false; throw new Error('Could not save the converted YAML. Please try again.'); } toast.success(`Stack "${stackName}" created from docker run.`); onOpenChange(false); resetCreateFromDockerRunForm(); setNewStackName(''); await onStackCreated(stackName); } catch (error) { console.error('Failed to create stack from docker run:', error); const err = error as { message?: string; error?: string; data?: { error?: string } }; toast.error( err?.message || err?.error || err?.data?.error || 'Failed to create stack from docker run.', ); // If we bailed before the createdStack flag got reset, surface that the stack still exists. if (createdStack) { await Promise.resolve(onStacksChanged()).catch(() => undefined); } } finally { toast.dismiss(loadingId); setCreatingFromDockerRun(false); } }; return ( { onOpenChange(o); if (!o) { setCreateMode('empty'); resetCreateFromGitForm(); resetCreateFromDockerRunForm(); } }} > Create New Stack Create a new stack: empty, cloned from a Git repository, or converted from a docker run command.
setCreateMode(v as 'empty' | 'git' | 'docker-run')}> Empty From Git From Docker Run
{createMode === 'empty' && ( <>
setNewStackName(e.target.value)} />
)} {createMode === 'git' && ( <>
setNewStackName(e.target.value)} disabled={creatingFromGit} />
setGitDeployNow(c === true)} disabled={creatingFromGit} />
)} {createMode === 'docker-run' && ( <>
setNewStackName(e.target.value)} disabled={creatingFromDockerRun} />