mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: docker run to compose converter (#623)
* feat(convert): harden /api/convert endpoint with auth, validation, and tests Applies authMiddleware to the docker run to compose endpoint, validates that the payload is a non-empty string within an 8192 character budget, rejects inputs containing null bytes, and wraps composerize in a try/catch that surfaces a 422 with a clear message when the library cannot produce a services block. Adds a Vitest suite covering the auth gate, happy path, common flag coverage, boundary and null byte placement variants, and malformed command handling. * feat(editor): add From Docker Run tab to create stack dialog Introduces a third tab in the Create New Stack dialog that accepts a docker run command, calls the converter endpoint, and previews the returned compose YAML before writing it to a new stack directory. Uses the defensive toast pattern, clears the stale preview when the input changes, and rolls back the empty stack directory if saving the converted YAML fails so the user never ends up with an orphan stack. * docs(stack-management): document docker run to compose converter Adds a Convert from a docker run command section to the stack management page covering how to use the new tab, the list of supported flags, and troubleshooting for unparseable inputs. Screenshots show the empty tab, a successful conversion with the compose preview, the resulting stack in the editor, and the error toast surfaced when the input cannot be converted. Appends a matching entry to the troubleshooting page.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Tests for the authenticated `POST /api/convert` endpoint that wraps the
|
||||
* composerize library. Verifies input validation, auth gating, graceful
|
||||
* handling of malformed commands, and output shape.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let cookie: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
cookie = await loginAsTestAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('POST /api/convert', () => {
|
||||
describe('auth', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.send({ dockerRun: 'docker run nginx' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('happy path', () => {
|
||||
it('converts a simple docker run command', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: 'docker run nginx' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.yaml).toBe('string');
|
||||
expect(res.body.yaml).toContain('services:');
|
||||
expect(res.body.yaml).toContain('nginx');
|
||||
});
|
||||
|
||||
it('handles common flags (-p, -v, -e, --name, --restart)', async () => {
|
||||
const cmd =
|
||||
'docker run -d --name web -p 8080:80 -v /data:/usr/share/nginx/html -e TZ=UTC --restart unless-stopped nginx:alpine';
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: cmd });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.yaml).toContain('services:');
|
||||
expect(res.body.yaml).toContain('web');
|
||||
expect(res.body.yaml).toContain('nginx:alpine');
|
||||
expect(res.body.yaml).toContain('8080:80');
|
||||
});
|
||||
|
||||
it('handles --label and --network flags', async () => {
|
||||
const cmd =
|
||||
'docker run --name api --label com.example.app=api --network bridge redis:7';
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: cmd });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.yaml).toContain('services:');
|
||||
expect(res.body.yaml).toContain('redis:7');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: ' \n docker run nginx \n ' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.yaml).toContain('services:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('rejects missing body', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/string/i);
|
||||
});
|
||||
|
||||
it('rejects empty string', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: '' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/required/i);
|
||||
});
|
||||
|
||||
it('rejects whitespace-only input', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: ' \n\t ' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/required/i);
|
||||
});
|
||||
|
||||
it('rejects non-string input', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: 12345 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/string/i);
|
||||
});
|
||||
|
||||
it('accepts input at the 8192-char boundary', async () => {
|
||||
const prefix = 'docker run nginx ';
|
||||
const filler = 'a'.repeat(8192 - prefix.length);
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: prefix + filler });
|
||||
// Either composerize accepts (200) or rejects as unparseable (422), but never
|
||||
// a 400 "too long" at exactly the max length.
|
||||
expect([200, 422]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('rejects input one byte over the 8192-char boundary', async () => {
|
||||
const prefix = 'docker run nginx ';
|
||||
const filler = 'a'.repeat(8193 - prefix.length);
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: prefix + filler });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/too long/i);
|
||||
});
|
||||
|
||||
it('rejects oversized input (>8192 chars)', async () => {
|
||||
const big = 'docker run ' + 'x'.repeat(9000);
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: big });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/too long/i);
|
||||
});
|
||||
|
||||
it('rejects input with a trailing null byte', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: 'docker run nginx\0' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid/i);
|
||||
});
|
||||
|
||||
it('rejects input with a leading null byte', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: '\0docker run nginx' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid/i);
|
||||
});
|
||||
|
||||
it('rejects input with an embedded null byte', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: 'docker run \0nginx' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed commands', () => {
|
||||
it('returns 422 when composerize cannot produce services', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: 'this is not a docker run command' });
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toMatch(/parse|supported/i);
|
||||
});
|
||||
|
||||
it('returns 422 for pure gibberish', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('Cookie', cookie)
|
||||
.send({ dockerRun: '!!!@@@###$$$' });
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
});
|
||||
+37
-9
@@ -4947,19 +4947,47 @@ app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) =>
|
||||
}
|
||||
});
|
||||
|
||||
// Docker Run to Compose converter endpoint
|
||||
app.post('/api/convert', async (req: Request, res: Response) => {
|
||||
// Docker Run to Compose converter endpoint.
|
||||
// Accepts a raw `docker run ...` command and returns the equivalent compose
|
||||
// YAML as a string. Authenticated, input-validated, and resilient to
|
||||
// composerize throws / malformed output.
|
||||
const MAX_DOCKER_RUN_LENGTH = 8192;
|
||||
app.post('/api/convert', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
const { dockerRun } = req.body ?? {};
|
||||
if (typeof dockerRun !== 'string') {
|
||||
res.status(400).json({ error: 'dockerRun must be a string' });
|
||||
return;
|
||||
}
|
||||
const trimmed = dockerRun.trim();
|
||||
if (trimmed.length === 0) {
|
||||
res.status(400).json({ error: 'dockerRun command is required' });
|
||||
return;
|
||||
}
|
||||
if (trimmed.length > MAX_DOCKER_RUN_LENGTH) {
|
||||
res.status(400).json({ error: `dockerRun command is too long (max ${MAX_DOCKER_RUN_LENGTH} characters)` });
|
||||
return;
|
||||
}
|
||||
if (trimmed.includes('\0')) {
|
||||
res.status(400).json({ error: 'dockerRun command contains invalid characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
let yaml: unknown;
|
||||
try {
|
||||
const { dockerRun } = req.body;
|
||||
if (!dockerRun || typeof dockerRun !== 'string') {
|
||||
return res.status(400).json({ error: 'dockerRun command is required' });
|
||||
}
|
||||
const yaml = composerize(dockerRun);
|
||||
res.json({ yaml });
|
||||
yaml = composerize(trimmed);
|
||||
} catch (error) {
|
||||
console.error('Conversion error:', error);
|
||||
res.status(500).json({ error: 'Failed to convert docker run command' });
|
||||
res.status(422).json({ error: 'Could not parse command. Check syntax and supported flags.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof yaml !== 'string' || !yaml.includes('services:')) {
|
||||
console.warn('Converter produced unexpected output for input:', trimmed.slice(0, 200));
|
||||
res.status(422).json({ error: 'Could not parse command. Check syntax and supported flags.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ yaml });
|
||||
});
|
||||
|
||||
// Get all containers stats for dashboard.
|
||||
|
||||
@@ -20,6 +20,72 @@ Click **Create Stack** in the left sidebar. Enter a name and click **Create**.
|
||||
|
||||
Sencho creates a new directory inside `COMPOSE_DIR` with a blank `compose.yaml` file. You'll land in the editor automatically.
|
||||
|
||||
## Convert from a docker run command
|
||||
|
||||
If you have a `docker run` command handy (from a README, a forum post, or your shell history), Sencho can turn it into a ready-to-deploy `compose.yaml` without hand translation.
|
||||
|
||||
Open the **Create New Stack** dialog and switch to the **From Docker Run** tab.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-management/convert-tab-empty.png" alt="Create stack dialog on the From Docker Run tab" />
|
||||
</Frame>
|
||||
|
||||
### How to use it
|
||||
|
||||
1. Enter a **Stack Name**. Same rules as the Empty tab (lowercase, hyphens, unique).
|
||||
2. Paste your full command into **Paste your docker run command**. The whole command should be on one logical line; line continuations with `\` are fine.
|
||||
3. Click **Convert**. Sencho parses the command and shows the resulting compose YAML in a read-only preview below.
|
||||
4. Review the YAML. If it looks right, click **Create Stack**. Sencho creates the stack directory and writes the converted YAML into `compose.yaml`.
|
||||
5. The editor opens on the new stack. Click **Start** to deploy it.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-management/convert-tab-result.png" alt="Converted compose YAML preview before creating the stack" />
|
||||
</Frame>
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-management/convert-stack-created.png" alt="Newly created stack loaded in the editor after conversion" />
|
||||
</Frame>
|
||||
|
||||
### Supported flags
|
||||
|
||||
The converter handles the flags you reach for most often:
|
||||
|
||||
| Flag | Purpose |
|
||||
|------|---------|
|
||||
| `-d` / `--detach` | Detached mode (implied by compose) |
|
||||
| `--name` | Container name |
|
||||
| `-p` / `--publish` | Port mappings (`host:container`) |
|
||||
| `-v` / `--volume` | Volumes and bind mounts |
|
||||
| `-e` / `--env` | Environment variables |
|
||||
| `--env-file` | Env file reference |
|
||||
| `--restart` | Restart policy (`no`, `always`, `unless-stopped`, `on-failure`) |
|
||||
| `--network` | Attach to a named network |
|
||||
| `--label` | Container labels |
|
||||
| `--user` | Run as a specific user |
|
||||
| `--workdir` | Set the working directory |
|
||||
| `--entrypoint` | Override the entrypoint |
|
||||
| `--cap-add` / `--cap-drop` | Linux capabilities |
|
||||
| `--privileged` | Privileged mode |
|
||||
| `--read-only` | Read-only root filesystem |
|
||||
| `--tmpfs` | Temporary filesystem mount |
|
||||
|
||||
The image tag is taken from the final positional argument (for example `nginx:alpine`). Any trailing command arguments are preserved as the service `command`.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-management/convert-tab-error.png" alt="Error toast shown when the converter cannot parse the input" />
|
||||
</Frame>
|
||||
|
||||
If the converter cannot produce a usable compose file, the request fails with a clear error toast. Common causes:
|
||||
|
||||
- **Not a docker run command.** The input must begin with `docker run` and include an image reference. Free-form text, `docker compose` commands, and shell pipelines are rejected.
|
||||
- **Unrecognized flag.** The parser supports the flags listed above. Rare flags (such as `--userns`, `--ipc`, custom runtime options) may not be recognized. Remove the flag, convert the rest, and add it back by hand in the editor.
|
||||
- **Quoting issues.** Multi-line commands with embedded quotes sometimes confuse the parser. Paste the command as a single line with escaped quotes, or simplify the command first.
|
||||
- **Command is too long.** The endpoint accepts commands up to 8192 characters. Longer inputs are rejected; trim anything you do not need and convert in pieces.
|
||||
|
||||
When a flag is not supported, paste the output you do get into the **Empty** tab as a starting point and fill in the rest by editing `compose.yaml` directly.
|
||||
|
||||
## The stack list
|
||||
|
||||
All discovered stacks appear in the left sidebar. Each shows a color-coded status indicator:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -561,3 +561,20 @@ docker logs -f sencho
|
||||
```
|
||||
|
||||
The backend logs all route errors and service failures to stdout. This is the first place to look when the UI shows an error with no useful message.
|
||||
|
||||
---
|
||||
|
||||
## "Could not parse command" when converting docker run
|
||||
|
||||
**Symptom:** The **From Docker Run** tab in the Create Stack dialog returns an error toast saying the command could not be parsed.
|
||||
|
||||
**Cause:** The input is not a well-formed `docker run` command, or it uses a flag the converter does not recognize.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Make sure the command starts with `docker run` and ends with an image reference (e.g. `nginx:alpine`).
|
||||
- Check the [list of supported flags](/features/stack-management#supported-flags). Rare flags (such as `--userns` or custom runtime options) are not recognized. Remove the flag before converting and add it back manually in the editor afterwards.
|
||||
- Collapse multi-line commands into a single line. Mixed quoting across `\`-continued lines is a common source of parse errors.
|
||||
- Commands longer than 8192 characters are rejected. Trim anything non-essential and run the converter on the reduced command.
|
||||
|
||||
When only part of a command is supported, convert what you can, paste the resulting YAML into the **Empty** tab as a starting point, and hand-edit the rest in `compose.yaml`.
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highli
|
||||
import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch } from 'lucide-react';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2 } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { LabelPill, LabelDot } from './LabelPill';
|
||||
import { type Label as StackLabel } from './label-types';
|
||||
@@ -131,9 +131,14 @@ export default function EditorLayout() {
|
||||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||
const pendingStackLoadRef = useRef<string | null>(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [createMode, setCreateMode] = useState<'empty' | 'git'>('empty');
|
||||
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');
|
||||
@@ -1401,6 +1406,115 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -1532,6 +1646,7 @@ export default function EditorLayout() {
|
||||
if (!o) {
|
||||
setCreateMode('empty');
|
||||
resetCreateFromGitForm();
|
||||
resetCreateFromDockerRunForm();
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -1544,12 +1659,12 @@ export default function EditorLayout() {
|
||||
<DialogHeader className="px-6 pt-6 pb-3">
|
||||
<DialogTitle>Create New Stack</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Create a new stack, either empty or cloned from a Git repository.
|
||||
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')}>
|
||||
<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">
|
||||
@@ -1564,12 +1679,18 @@ export default function EditorLayout() {
|
||||
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' ? (
|
||||
{createMode === 'empty' && (
|
||||
<>
|
||||
<div className="px-6 py-4 space-y-2">
|
||||
<Label htmlFor="create-stack-name">Stack Name</Label>
|
||||
@@ -1584,7 +1705,8 @@ export default function EditorLayout() {
|
||||
<Button onClick={handleCreateStack}>Create</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{createMode === 'git' && (
|
||||
<>
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
<div className="px-6 py-4 space-y-5">
|
||||
@@ -1643,6 +1765,77 @@ export default function EditorLayout() {
|
||||
</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>
|
||||
<TooltipProvider>
|
||||
|
||||
Reference in New Issue
Block a user