mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 05:28:28 +00:00
cfb42af4e0
* fix(dashboard): replace Stack Health update badge with an icon The pill badge duplicated space already used by the stack name column. A CircleArrowUp icon after the name signals an update is available without competing with the existing ArrowUp/ArrowDown sort indicators in the same table. * fix(dashboard): add accessible name to update-available icon Icon-only indicators need an aria-label directly on the icon; title on a non-interactive span is not reliably announced by screen readers. * test(dashboard): cover the update-available icon's accessible name The icon-only indicator and its aria-label fix had no regression guard, unlike the equivalent update dot in StackRow. * refactor(dashboard): compute the update-available label once per row It was being derived twice (title and aria-label) from the same row.outdatedServices input. * fix: drop Community-tier pricing upsells from settings Community operators no longer see the "See pricing" link in Licensing or the "Need direct support?" callout in Support. The pricing link now only shows for an expired paid license needing to renew. * fix: make Resources images/volumes tables actually scrollable The tables were wrapped in a Radix ScrollArea sized with max-h-[62vh]. Radix's viewport uses height:100%, which cannot resolve against an ancestor whose computed height is auto (max-height alone isn't a definite height), so the viewport silently grew past the visible box and the extra rows were clipped with no way to reach them. Verified live: several image rows were permanently unreachable, with no working internal scrollbar and not enough outer page scroll to compensate. Switched to an explicit h-[62vh], which the viewport can resolve correctly, matching every other working ScrollArea in the codebase. Falls back to h-auto below the md breakpoint so the bespoke mobile layout keeps shrinking to content and scrolling via the outer page instead of gaining a fixed-height inner scroll box. * fix: apply ScrollArea definite-height fix across remaining lists Radix ScrollArea needs an explicit height, not max-height, or the viewport collapses and clipped rows become unreachable. Extend the Resources fix to security, settings, git, and create/import surfaces, and drop redundant outer wrappers where ModalBody already scrolls. * fix: migrate Networking tables to Radix ScrollArea Networks and Findings used native max-h + overflow-auto, which worked but broke glass scrollbar consistency with Resources and the design system. Switch them to ScrollArea with a definite height and the same mobile fallback as the other inventory tables. * fix: warn Classic bar users that the style is retiring soon When Appearance Navigation is set to Classic bar, show the same warn SettingsCallout pattern used for Constrained graphics. Preference is kept until removal; no alternate style is named in the copy. * fix: move Channels delivery retries below channel tabs Put channel configuration first and keep Delivery retries as a shared footer control under the Discord/Slack/Webhook/Apprise tabs. * fix: drop redundant More masthead from Smart bar overflow menu The trigger already reads More, so the dropdown masthead repeated the same label. Leave titled mastheads on Compact Navigate and Add quick link menus. * test: align Smart More E2E with masthead removal The overflow menu no longer shows a More heading. Assert the menu via the Logs item and lock that the redundant masthead stays gone. * fix: consolidate Fleet Map toolbar filters into a single row Adopt the same retractable search control used on Fleet > Overview and move the flag filters (missing deps, port conflicts, orphans, shared) onto the toolbar row right after the Graph/List selector. The node filter becomes a dropdown instead of individual toggle chips so it does not clutter the row as fleet size grows. * fix: move Networking Topology filters onto the search toolbar row Merge the ownership selector and boolean filter chips (include system, exposed, drift, missing external, shared) onto the same row as the stack/network search inputs, matching the Fleet Map toolbar layout. * fix: default the reclaimable-space banner off Resources > Docker & Storage's "Show reclaimable-space banner" toggle now defaults to off instead of on. Also flips the /settings fetch failure path to fail closed (hide the banner) to match the new default, instead of failing open. * fix: raise Compact launcher quick links cap from 5 to 7 * fix: add Discord link to Settings Support Self-serve Gives users a community chat channel alongside Documentation and GitHub Issues, using the official Discord mark since lucide-react has no brand icon for it. * fix: stop container NET I/O metric row height jump Give NET I/O more column share than CPU/MEM and keep metric values on one line with truncate so three-digit rates cannot grow the strip. * fix: elevate Doctor tab between Activity and Drift Make Compose Doctor easier to find in the anatomy strip by placing it with the ops judgment cluster, ahead of Dossier and inventory tabs.
661 lines
30 KiB
TypeScript
661 lines
30 KiB
TypeScript
import { useRef, useState, type FormEvent, type KeyboardEvent } from 'react';
|
|
import { Plus, GitBranch, FileCode2, Loader2, type LucideIcon } from 'lucide-react';
|
|
import { Modal, ModalHeader, ModalBody, ModalFooter } from '../ui/modal';
|
|
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 type { GitBrowseResult } from '../stack/GitComposeFilePicker';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface CreateStackDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
// sourceNodeId is the active node ID captured at the moment the user clicked
|
|
// Create. Parent compares against the current active node before navigating
|
|
// so a mid-flight node switch does not land the user on a 404.
|
|
onStackCreated: (
|
|
stackName: string,
|
|
sourceNodeId: number | null | undefined,
|
|
meta?: { mode: CreateMode },
|
|
) => void | Promise<void>;
|
|
onStacksChanged: () => void | Promise<void>;
|
|
initialMode?: CreateMode;
|
|
onOpenAdopt?: () => void;
|
|
}
|
|
|
|
export type CreateMode = 'empty' | 'git' | 'docker-run';
|
|
|
|
const MODES: ReadonlyArray<{ id: CreateMode; label: string; icon: LucideIcon }> = [
|
|
{ id: 'empty', label: 'Empty', icon: Plus },
|
|
{ id: 'git', label: 'From Git', icon: GitBranch },
|
|
{ id: 'docker-run', label: 'From Docker Run', icon: FileCode2 },
|
|
];
|
|
|
|
const tabId = (m: CreateMode) => `create-stack-tab-${m}`;
|
|
const panelId = (m: CreateMode) => `create-stack-panel-${m}`;
|
|
|
|
export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacksChanged, initialMode = 'empty', onOpenAdopt }: CreateStackDialogProps) {
|
|
const { activeNode } = useNodes();
|
|
const [createMode, setCreateMode] = useState<CreateMode>(initialMode);
|
|
// Reset to the requested starting mode each time the dialog opens (empty for
|
|
// the toolbar button, import for the empty-state entry). Tracked during render
|
|
// via a previous-open sentinel rather than an effect, the pattern React
|
|
// recommends for resetting state in response to a prop change.
|
|
const [prevOpen, setPrevOpen] = useState(open);
|
|
if (open !== prevOpen) {
|
|
setPrevOpen(open);
|
|
if (open) setCreateMode(initialMode);
|
|
}
|
|
const [newStackName, setNewStackName] = useState('');
|
|
// Synchronous guard. The disabled-button + setState pair can race a rapid
|
|
// second click that lands before React has committed the disabled state,
|
|
// re-entering the handler with a stale closure value of creatingEmpty and
|
|
// firing a second POST. The ref check is read/written synchronously inside
|
|
// the handler so the second invocation bails before issuing another POST.
|
|
const creatingEmptyRef = useRef(false);
|
|
const [creatingEmpty, setCreatingEmpty] = useState(false);
|
|
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 [gitComposePaths, setGitComposePaths] = useState<string[]>(['compose.yaml']);
|
|
const [gitContextDir, setGitContextDir] = useState('');
|
|
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');
|
|
setGitComposePaths(['compose.yaml']);
|
|
setGitContextDir('');
|
|
setGitSyncEnv(false);
|
|
setGitAuthType('none');
|
|
setGitToken('');
|
|
setGitApplyMode('review');
|
|
setGitDeployNow(false);
|
|
};
|
|
|
|
const browseGitRepo = async (): Promise<GitBrowseResult | null> => {
|
|
if (!gitRepoUrl.trim() || !gitBranch.trim()) {
|
|
toast.error('Enter a repository URL and branch first.');
|
|
return null;
|
|
}
|
|
try {
|
|
const body: Record<string, unknown> = {
|
|
repo_url: gitRepoUrl.trim(),
|
|
branch: gitBranch.trim(),
|
|
auth_type: gitAuthType,
|
|
};
|
|
if (gitAuthType === 'token' && gitToken !== '') body.token = gitToken;
|
|
const res = await apiFetch('/git-sources/browse', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
return { files: data.files ?? [], truncated: data.truncated ?? false };
|
|
}
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || 'Failed to browse repository.');
|
|
return null;
|
|
} catch (e) {
|
|
toast.error((e as Error)?.message || 'Network error.');
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const resetCreateFromDockerRunForm = () => {
|
|
setDockerRunInput('');
|
|
setConvertedYaml(null);
|
|
setIsConverting(false);
|
|
setCreatingFromDockerRun(false);
|
|
};
|
|
|
|
const handleCreateStack = async () => {
|
|
if (creatingEmptyRef.current) return;
|
|
if (!newStackName.trim()) return;
|
|
const stackName = newStackName.trim();
|
|
const sourceNodeId = activeNode?.id;
|
|
creatingEmptyRef.current = true;
|
|
setCreatingEmpty(true);
|
|
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.');
|
|
}
|
|
if (response.status === 400) {
|
|
throw new Error('Invalid stack name (use alphanumeric characters and hyphens only).');
|
|
}
|
|
const body = await response.json().catch(() => ({}));
|
|
const backendError = (body as { error?: string })?.error;
|
|
if (response.status === 403) {
|
|
throw new Error(backendError || 'You do not have permission to create stacks.');
|
|
}
|
|
throw new Error(backendError || 'Failed to create stack.');
|
|
}
|
|
onOpenChange(false);
|
|
setNewStackName('');
|
|
setCreateMode('empty');
|
|
toast.success(`Stack "${stackName}" created.`);
|
|
await onStackCreated(stackName, sourceNodeId, { mode: 'empty' });
|
|
} catch (error) {
|
|
console.error('Failed to create stack:', error);
|
|
toast.error((error as Error).message || 'Failed to create stack.');
|
|
} finally {
|
|
creatingEmptyRef.current = false;
|
|
setCreatingEmpty(false);
|
|
}
|
|
};
|
|
|
|
const handleEmptyFormSubmit = (e: FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
void handleCreateStack();
|
|
};
|
|
|
|
const handleCreateStackFromGit = async () => {
|
|
const stackName = newStackName.trim();
|
|
if (!stackName) {
|
|
toast.error('Stack name is required.');
|
|
return;
|
|
}
|
|
if (!gitRepoUrl.trim() || !gitBranch.trim() || gitComposePaths.length === 0) {
|
|
toast.error('Repository URL, branch, and at least one compose file are required.');
|
|
return;
|
|
}
|
|
if (!/^https:\/\//i.test(gitRepoUrl.trim())) {
|
|
toast.error('Only HTTPS repository URLs are supported.');
|
|
return;
|
|
}
|
|
const sourceNodeId = activeNode?.id;
|
|
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_paths: gitComposePaths,
|
|
context_dir: gitContextDir.trim() || null,
|
|
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, sourceNodeId);
|
|
} 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;
|
|
}
|
|
const sourceNodeId = activeNode?.id;
|
|
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, sourceNodeId);
|
|
} 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);
|
|
}
|
|
};
|
|
|
|
const busy = creatingEmpty || creatingFromGit || creatingFromDockerRun;
|
|
|
|
return (
|
|
<Modal
|
|
size="xl"
|
|
open={open}
|
|
onOpenChange={(o) => {
|
|
onOpenChange(o);
|
|
if (!o) {
|
|
setCreateMode(initialMode);
|
|
resetCreateFromGitForm();
|
|
resetCreateFromDockerRunForm();
|
|
// Intentionally NOT resetting creatingEmptyRef / creatingEmpty
|
|
// here: the in-flight POST owns its own lifecycle and clears
|
|
// both flags in finally. Resetting on close would let an
|
|
// Escape-then-reopen sequence slip past the guard and fire a
|
|
// second POST before the first request settled.
|
|
}
|
|
}}
|
|
>
|
|
<ModalHeader
|
|
kicker="STACKS · NEW"
|
|
title="New stack"
|
|
description="Create a stack from scratch, clone from a Git repository, or convert a docker run command."
|
|
/>
|
|
<ModeRail mode={createMode} onModeChange={setCreateMode} disabled={busy} />
|
|
|
|
{createMode === 'empty' && (
|
|
<div role="tabpanel" id={panelId('empty')} aria-labelledby={tabId('empty')}>
|
|
<form onSubmit={handleEmptyFormSubmit}>
|
|
<ModalBody>
|
|
<div className="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)}
|
|
disabled={creatingEmpty}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter
|
|
hint="ALPHANUMERIC · HYPHENS"
|
|
secondary={
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={creatingEmpty}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
}
|
|
primary={
|
|
<Button type="submit" disabled={creatingEmpty || !newStackName.trim()}>
|
|
{creatingEmpty ? (
|
|
<><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</>
|
|
)}
|
|
</Button>
|
|
}
|
|
/>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{createMode === 'git' && (
|
|
<div role="tabpanel" id={panelId('git')} aria-labelledby={tabId('git')}>
|
|
<ModalBody>
|
|
<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}
|
|
composePaths={gitComposePaths}
|
|
contextDir={gitContextDir}
|
|
syncEnv={gitSyncEnv}
|
|
authType={gitAuthType}
|
|
token={gitToken}
|
|
hasStoredToken={false}
|
|
applyMode={gitApplyMode}
|
|
onRepoUrlChange={setGitRepoUrl}
|
|
onBranchChange={setGitBranch}
|
|
onComposePathsChange={setGitComposePaths}
|
|
onContextDirChange={setGitContextDir}
|
|
onSyncEnvChange={setGitSyncEnv}
|
|
onAuthTypeChange={setGitAuthType}
|
|
onTokenChange={setGitToken}
|
|
onApplyModeChange={setGitApplyMode}
|
|
onBrowse={browseGitRepo}
|
|
/>
|
|
|
|
<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>
|
|
</ModalBody>
|
|
<ModalFooter
|
|
hint="HTTPS REPOS ONLY"
|
|
secondary={
|
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={creatingFromGit}>
|
|
Cancel
|
|
</Button>
|
|
}
|
|
primary={
|
|
<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>
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{createMode === 'docker-run' && (
|
|
<div role="tabpanel" id={panelId('docker-run')} aria-labelledby={tabId('docker-run')}>
|
|
<ModalBody>
|
|
<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);
|
|
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 block className="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>
|
|
)}
|
|
</ModalBody>
|
|
<ModalFooter
|
|
hint={convertedYaml ? 'YAML READY' : 'CONVERT FIRST'}
|
|
hintAccent={convertedYaml ? `${convertedYaml.split('\n').length} LINES` : undefined}
|
|
secondary={
|
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={creatingFromDockerRun}>
|
|
Cancel
|
|
</Button>
|
|
}
|
|
primary={
|
|
<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>
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{onOpenAdopt ? (
|
|
<div className="border-t border-card-border/60 px-4 py-2.5 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onOpenChange(false);
|
|
onOpenAdopt();
|
|
}}
|
|
className="font-mono text-[10px] uppercase tracking-[0.14em] text-brand hover:underline"
|
|
>
|
|
Adopt existing files instead
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function ModeRail({
|
|
mode,
|
|
onModeChange,
|
|
disabled,
|
|
}: {
|
|
mode: CreateMode;
|
|
onModeChange: (m: CreateMode) => void;
|
|
disabled: boolean;
|
|
}) {
|
|
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
const activeIndex = MODES.findIndex((m) => m.id === mode);
|
|
|
|
const focusTab = (index: number) => {
|
|
const target = tabRefs.current[index];
|
|
if (target) {
|
|
target.focus();
|
|
onModeChange(MODES[index].id);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
if (disabled) return;
|
|
if (e.key === 'ArrowRight') {
|
|
e.preventDefault();
|
|
focusTab((activeIndex + 1) % MODES.length);
|
|
} else if (e.key === 'ArrowLeft') {
|
|
e.preventDefault();
|
|
focusTab((activeIndex - 1 + MODES.length) % MODES.length);
|
|
} else if (e.key === 'Home') {
|
|
e.preventDefault();
|
|
focusTab(0);
|
|
} else if (e.key === 'End') {
|
|
e.preventDefault();
|
|
focusTab(MODES.length - 1);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="tablist"
|
|
aria-label="Stack source"
|
|
className="grid grid-cols-3 border-b border-card-border/60"
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
{MODES.map((m, i) => {
|
|
const isActive = mode === m.id;
|
|
const Icon = m.icon;
|
|
return (
|
|
<button
|
|
key={m.id}
|
|
ref={(el) => { tabRefs.current[i] = el; }}
|
|
type="button"
|
|
role="tab"
|
|
id={tabId(m.id)}
|
|
aria-selected={isActive}
|
|
aria-controls={panelId(m.id)}
|
|
tabIndex={isActive ? 0 : -1}
|
|
disabled={disabled}
|
|
onClick={() => onModeChange(m.id)}
|
|
className={cn(
|
|
'relative flex items-center justify-center gap-2 px-4 py-2.5',
|
|
'font-mono text-[10px] uppercase tracking-[0.18em]',
|
|
'transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
|
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand focus-visible:ring-inset',
|
|
i < MODES.length - 1 && 'border-r border-card-border/60',
|
|
isActive ? 'text-brand' : 'text-stat-subtitle hover:text-brand',
|
|
)}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
<span>{m.label}</span>
|
|
{isActive && (
|
|
<span aria-hidden className="absolute inset-x-3 bottom-0 h-[2px] bg-brand" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|