mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user