refactor(frontend): extract CreateStackDialog from EditorLayout (#895)

Continues the EditorLayout decomposition (B4-2). Pulls the inline
three-tab Create Stack dialog (Empty / From Git / From Docker Run) out
of EditorLayout into EditorLayout/CreateStackDialog.tsx.

Parent now owns only the open boolean and a domain callback pair;
all 14 form-state vars and 6 handlers move into the child. The slot
renders a thin trigger button plus the new dialog.

Metrics:
- EditorLayout.tsx: 3,266 -> 2,843 LOC
- new CreateStackDialog.tsx: 463 LOC (under the 500 ceiling)
- useState count in EditorLayout: 81 -> 66
This commit is contained in:
Anso
2026-05-03 12:54:37 -04:00
committed by GitHub
parent d203caf7dc
commit 71b7a52def
2 changed files with 483 additions and 444 deletions
+21 -444
View File
@@ -9,13 +9,11 @@ import type { NotificationItem } from './dashboard/types';
import BashExecModal from './BashExecModal';
import LazyBoundary from './LazyBoundary';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog';
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs';
import { springs } from '@/lib/motion';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy, FolderOpen } from 'lucide-react';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, ShieldCheck, ArrowUpRight, Copy, FolderOpen } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { type Label as StackLabel, type LabelColor } from './label-types';
import { UserProfileDropdown } from './UserProfileDropdown';
@@ -23,10 +21,7 @@ import { NotificationPanel } from './NotificationPanel';
import { apiFetch, fetchForNode } from '@/lib/api';
import { copyToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui/toast-store';
import { Label } from './ui/label';
import { ScrollArea } from './ui/scroll-area';
import { Checkbox } from './ui/checkbox';
import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields';
import { PolicyBlockDialog, type PolicyBlockPayload } from './stack/PolicyBlockDialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -34,6 +29,7 @@ import { TopBar } from './TopBar';
import { cn } from '@/lib/utils';
import type { SectionId } from './settings/types';
import { ViewRouter } from './EditorLayout/ViewRouter';
import { CreateStackDialog } from './EditorLayout/CreateStackDialog';
import { StackAlertSheet } from './StackAlertSheet';
import { StackAutoHealSheet } from '@/components/StackAutoHealSheet';
import { GitSourcePanel } from './stack/GitSourcePanel';
@@ -256,24 +252,7 @@ export default function EditorLayout() {
const pendingStackLoadRef = useRef<string | null>(null);
const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [createMode, setCreateMode] = useState<'empty' | 'git' | 'docker-run'>('empty');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [newStackName, setNewStackName] = useState('');
// "From Docker Run" tab state
const [dockerRunInput, setDockerRunInput] = useState('');
const [convertedYaml, setConvertedYaml] = useState<string | null>(null);
const [isConverting, setIsConverting] = useState(false);
const [creatingFromDockerRun, setCreatingFromDockerRun] = useState(false);
// "From Git" tab state
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<ApplyMode>('review');
const [gitDeployNow, setGitDeployNow] = useState(false);
const [creatingFromGit, setCreatingFromGit] = useState(false);
const [stackToDelete, setStackToDelete] = useState<string | null>(null);
const [pruneVolumesOnDelete, setPruneVolumesOnDelete] = useState(false);
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
@@ -1708,230 +1687,6 @@ export default function EditorLayout() {
}
};
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');
}
setCreateDialogOpen(false);
setNewStackName('');
await refreshStacks();
// Auto-load the new stack in the editor pane
await loadFile(stackName);
} catch (error) {
console.error('Failed to create stack:', error);
toast.error((error as Error).message || 'Failed to create stack');
}
};
const resetCreateFromGitForm = () => {
setNewStackName('');
setGitRepoUrl('');
setGitBranch('main');
setGitComposePath('compose.yaml');
setGitSyncEnv(false);
setGitAuthType('none');
setGitToken('');
setGitApplyMode('review');
setGitDeployNow(false);
};
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<string, unknown> = {
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(' '));
}
setCreateDialogOpen(false);
resetCreateFromGitForm();
await refreshStacks();
await loadFile(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 resetCreateFromDockerRunForm = () => {
setDockerRunInput('');
setConvertedYaml(null);
setIsConverting(false);
setCreatingFromDockerRun(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.`);
setCreateDialogOpen(false);
resetCreateFromDockerRunForm();
setNewStackName('');
await refreshStacks();
await loadFile(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 refreshStacks().catch(() => undefined);
}
} finally {
toast.dismiss(loadingId);
setCreatingFromDockerRun(false);
}
};
const openBashModal = (containerId: string, containerName: string) => {
setSelectedContainer({ id: containerId, name: containerName });
setBashModalOpen(true);
@@ -2183,203 +1938,25 @@ export default function EditorLayout() {
]);
const createStackSlot = can('stack:create') ? (
<Dialog open={createDialogOpen} onOpenChange={(o) => {
setCreateDialogOpen(o);
if (!o) {
setCreateMode('empty');
resetCreateFromGitForm();
resetCreateFromDockerRunForm();
}
}}>
<DialogTrigger asChild>
<Button variant="outline" className="rounded-lg w-full">
<Plus className="w-4 h-4 mr-2" />
Create Stack
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl w-[95vw] p-0 gap-0">
<DialogHeader className="px-6 pt-6 pb-3">
<DialogTitle>Create New Stack</DialogTitle>
<DialogDescription className="sr-only">
Create a new stack: empty, cloned from a Git repository, or converted from a docker run command.
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-2">
<Tabs value={createMode} onValueChange={(v) => setCreateMode(v as 'empty' | 'git' | 'docker-run')}>
<TabsList>
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="empty">
<TabsTrigger value="empty">
<Plus className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
Empty
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="git">
<TabsTrigger value="git">
<GitBranch className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
From Git
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="docker-run">
<TabsTrigger value="docker-run">
<FileCode2 className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
From Docker Run
</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
</Tabs>
</div>
{createMode === 'empty' && (
<>
<div className="px-6 py-4 space-y-2">
<Label htmlFor="create-stack-name">Stack Name</Label>
<Input
id="create-stack-name"
placeholder="Stack name (e.g., myapp)"
value={newStackName}
onChange={(e) => setNewStackName(e.target.value)}
/>
</div>
<DialogFooter className="px-6 pb-6">
<Button onClick={handleCreateStack}>Create</Button>
</DialogFooter>
</>
)}
{createMode === 'git' && (
<>
<ScrollArea className="max-h-[70vh]">
<div className="px-6 py-4 space-y-5">
<div className="space-y-2">
<Label htmlFor="create-git-stack-name">Stack Name</Label>
<Input
id="create-git-stack-name"
placeholder="Stack name (e.g., myapp)"
value={newStackName}
onChange={(e) => setNewStackName(e.target.value)}
disabled={creatingFromGit}
/>
</div>
<GitSourceFields
variant="create"
disabled={creatingFromGit}
repoUrl={gitRepoUrl}
branch={gitBranch}
composePath={gitComposePath}
syncEnv={gitSyncEnv}
authType={gitAuthType}
token={gitToken}
hasStoredToken={false}
applyMode={gitApplyMode}
onRepoUrlChange={setGitRepoUrl}
onBranchChange={setGitBranch}
onComposePathChange={setGitComposePath}
onSyncEnvChange={setGitSyncEnv}
onAuthTypeChange={setGitAuthType}
onTokenChange={setGitToken}
onApplyModeChange={setGitApplyMode}
/>
<div className="flex items-center gap-2">
<Checkbox
id="create-git-deploy-now"
checked={gitDeployNow}
onCheckedChange={(c) => setGitDeployNow(c === true)}
disabled={creatingFromGit}
/>
<Label htmlFor="create-git-deploy-now" className="text-xs cursor-pointer">
Deploy after create
</Label>
</div>
</div>
</ScrollArea>
<DialogFooter className="px-6 py-4 border-t border-glass-border">
<Button onClick={handleCreateStackFromGit} disabled={creatingFromGit}>
{creatingFromGit ? (
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Creating</>
) : (
<><GitBranch className="w-4 h-4 mr-1.5" strokeWidth={1.5} />Create from Git</>
)}
</Button>
</DialogFooter>
</>
)}
{createMode === 'docker-run' && (
<>
<ScrollArea className="max-h-[70vh]">
<div className="px-6 py-4 space-y-5">
<div className="space-y-2">
<Label htmlFor="create-dr-stack-name">Stack Name</Label>
<Input
id="create-dr-stack-name"
placeholder="Stack name (e.g., myapp)"
value={newStackName}
onChange={(e) => setNewStackName(e.target.value)}
disabled={creatingFromDockerRun}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-dr-command">Paste your docker run command</Label>
<textarea
id="create-dr-command"
spellCheck={false}
className="flex w-full rounded-md border border-glass-border bg-input px-3 py-2 text-sm font-mono shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 min-h-[120px] resize-y"
placeholder="docker run -d --name nginx -p 8080:80 nginx:latest"
value={dockerRunInput}
onChange={(e) => {
setDockerRunInput(e.target.value);
// The preview only reflects the previous command; clear it when
// the input changes so the user can't create a stack from stale YAML.
if (convertedYaml !== null) setConvertedYaml(null);
}}
disabled={creatingFromDockerRun}
/>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleConvertDockerRun}
disabled={isConverting || creatingFromDockerRun || !dockerRunInput.trim()}
>
{isConverting ? (
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />Converting</>
) : (
<><FileCode2 className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />Convert</>
)}
</Button>
</div>
</div>
{convertedYaml !== null && (
<div className="space-y-2">
<Label>compose.yaml preview</Label>
<ScrollArea className="max-h-[240px] rounded-md border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<pre className="px-3 py-2 text-xs font-mono whitespace-pre leading-relaxed">
{convertedYaml}
</pre>
</ScrollArea>
</div>
)}
</div>
</ScrollArea>
<DialogFooter className="px-6 py-4 border-t border-glass-border">
<Button
onClick={handleCreateStackFromDockerRun}
disabled={creatingFromDockerRun || !convertedYaml || !newStackName.trim()}
>
{creatingFromDockerRun ? (
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Creating</>
) : (
<><Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} />Create Stack</>
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
<>
<Button
variant="outline"
className="rounded-lg w-full"
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create Stack
</Button>
<CreateStackDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onStackCreated={async (stackName) => {
await refreshStacks();
await loadFile(stackName);
}}
onStacksChanged={async () => { await refreshStacks(); }}
/>
</>
) : null;
return (
@@ -0,0 +1,462 @@
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<void>;
onStacksChanged: () => void | Promise<void>;
}
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<string | null>(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<ApplyMode>('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<string, unknown> = {
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 (
<Dialog
open={open}
onOpenChange={(o) => {
onOpenChange(o);
if (!o) {
setCreateMode('empty');
resetCreateFromGitForm();
resetCreateFromDockerRunForm();
}
}}
>
<DialogContent className="max-w-xl w-[95vw] p-0 gap-0">
<DialogHeader className="px-6 pt-6 pb-3">
<DialogTitle>Create New Stack</DialogTitle>
<DialogDescription className="sr-only">
Create a new stack: empty, cloned from a Git repository, or converted from a docker run command.
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-2">
<Tabs value={createMode} onValueChange={(v) => setCreateMode(v as 'empty' | 'git' | 'docker-run')}>
<TabsList>
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="empty">
<TabsTrigger value="empty">
<Plus className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
Empty
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="git">
<TabsTrigger value="git">
<GitBranch className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
From Git
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="docker-run">
<TabsTrigger value="docker-run">
<FileCode2 className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
From Docker Run
</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
</Tabs>
</div>
{createMode === 'empty' && (
<>
<div className="px-6 py-4 space-y-2">
<Label htmlFor="create-stack-name">Stack Name</Label>
<Input
id="create-stack-name"
placeholder="Stack name (e.g., myapp)"
value={newStackName}
onChange={(e) => setNewStackName(e.target.value)}
/>
</div>
<DialogFooter className="px-6 pb-6">
<Button onClick={handleCreateStack}>Create</Button>
</DialogFooter>
</>
)}
{createMode === 'git' && (
<>
<ScrollArea className="max-h-[70vh]">
<div className="px-6 py-4 space-y-5">
<div className="space-y-2">
<Label htmlFor="create-git-stack-name">Stack Name</Label>
<Input
id="create-git-stack-name"
placeholder="Stack name (e.g., myapp)"
value={newStackName}
onChange={(e) => setNewStackName(e.target.value)}
disabled={creatingFromGit}
/>
</div>
<GitSourceFields
variant="create"
disabled={creatingFromGit}
repoUrl={gitRepoUrl}
branch={gitBranch}
composePath={gitComposePath}
syncEnv={gitSyncEnv}
authType={gitAuthType}
token={gitToken}
hasStoredToken={false}
applyMode={gitApplyMode}
onRepoUrlChange={setGitRepoUrl}
onBranchChange={setGitBranch}
onComposePathChange={setGitComposePath}
onSyncEnvChange={setGitSyncEnv}
onAuthTypeChange={setGitAuthType}
onTokenChange={setGitToken}
onApplyModeChange={setGitApplyMode}
/>
<div className="flex items-center gap-2">
<Checkbox
id="create-git-deploy-now"
checked={gitDeployNow}
onCheckedChange={(c) => setGitDeployNow(c === true)}
disabled={creatingFromGit}
/>
<Label htmlFor="create-git-deploy-now" className="text-xs cursor-pointer">
Deploy after create
</Label>
</div>
</div>
</ScrollArea>
<DialogFooter className="px-6 py-4 border-t border-glass-border">
<Button onClick={handleCreateStackFromGit} disabled={creatingFromGit}>
{creatingFromGit ? (
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Creating</>
) : (
<><GitBranch className="w-4 h-4 mr-1.5" strokeWidth={1.5} />Create from Git</>
)}
</Button>
</DialogFooter>
</>
)}
{createMode === 'docker-run' && (
<>
<ScrollArea className="max-h-[70vh]">
<div className="px-6 py-4 space-y-5">
<div className="space-y-2">
<Label htmlFor="create-dr-stack-name">Stack Name</Label>
<Input
id="create-dr-stack-name"
placeholder="Stack name (e.g., myapp)"
value={newStackName}
onChange={(e) => setNewStackName(e.target.value)}
disabled={creatingFromDockerRun}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-dr-command">Paste your docker run command</Label>
<textarea
id="create-dr-command"
spellCheck={false}
className="flex w-full rounded-md border border-glass-border bg-input px-3 py-2 text-sm font-mono shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 min-h-[120px] resize-y"
placeholder="docker run -d --name nginx -p 8080:80 nginx:latest"
value={dockerRunInput}
onChange={(e) => {
setDockerRunInput(e.target.value);
// The preview only reflects the previous command; clear it when
// the input changes so the user can't create a stack from stale YAML.
if (convertedYaml !== null) setConvertedYaml(null);
}}
disabled={creatingFromDockerRun}
/>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleConvertDockerRun}
disabled={isConverting || creatingFromDockerRun || !dockerRunInput.trim()}
>
{isConverting ? (
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />Converting</>
) : (
<><FileCode2 className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />Convert</>
)}
</Button>
</div>
</div>
{convertedYaml !== null && (
<div className="space-y-2">
<Label>compose.yaml preview</Label>
<ScrollArea className="max-h-[240px] rounded-md border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<pre className="px-3 py-2 text-xs font-mono whitespace-pre leading-relaxed">
{convertedYaml}
</pre>
</ScrollArea>
</div>
)}
</div>
</ScrollArea>
<DialogFooter className="px-6 py-4 border-t border-glass-border">
<Button
onClick={handleCreateStackFromDockerRun}
disabled={creatingFromDockerRun || !convertedYaml || !newStackName.trim()}
>
{creatingFromDockerRun ? (
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Creating</>
) : (
<><Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} />Create Stack</>
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}