From cdecbba59a40a97ca56f3dd5a14876b515874f1e Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Tue, 24 Feb 2026 19:48:56 -0500 Subject: [PATCH] feat: Implement a comprehensive Docker Compose stack management UI with file editing, deployment, container monitoring, and interactive bash access. --- backend/src/index.ts | 105 ++++++- backend/src/services/DockerController.ts | 145 +++++++-- backend/src/services/FileSystemService.ts | 54 ++-- frontend/src/components/BashExecModal.tsx | 196 +++++++++---- frontend/src/components/EditorLayout.tsx | 41 ++- frontend/src/components/MaintenanceModal.tsx | 293 +++++++++++++++++++ 6 files changed, 703 insertions(+), 131 deletions(-) create mode 100644 frontend/src/components/MaintenanceModal.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index d6b90882..4763a8d6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,7 +11,10 @@ import { ConfigService } from './services/ConfigService'; import composerize from 'composerize'; import si from 'systeminformation'; import http from 'http'; -import { spawn } from 'child_process'; +import { spawn, exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); const app = express(); const PORT = 3000; @@ -284,6 +287,12 @@ wss.on('connection', (ws) => { ws.on('message', (message) => { try { const data = JSON.parse(message.toString()); + + // Only handle 'action'-based messages at the global level. + // 'type'-based messages (input, resize, ping) are handled by the + // per-session listener registered inside execContainer's closure. + if (!data.action) return; + if (data.action === 'connectTerminal') { terminalWs = ws; } else if (data.action === 'streamStats') { @@ -291,18 +300,12 @@ wss.on('connection', (ws) => { dockerController.streamStats(data.containerId, ws); } else if (data.action === 'execContainer') { // Handle container exec for bash access + // Input, resize, and cleanup are handled inside execContainer's closure const dockerController = DockerController.getInstance(); dockerController.execContainer(data.containerId, ws); - } else if (data.action === 'input') { - // Forward input to exec stream - const dockerController = DockerController.getInstance(); - dockerController.sendExecInput(data.data); - } else if (data.action === 'resize') { - const dockerController = DockerController.getInstance(); - dockerController.resizeExec(data.cols, data.rows); } } catch (error) { - ws.send(JSON.stringify({ error: 'Invalid message' })); + // Malformed JSON — ignore silently } }); }); @@ -343,6 +346,9 @@ app.get('/api/stacks/:stackName', async (req: Request, res: Response) => { app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; + if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) { + return res.status(400).json({ error: 'Invalid stack name' }); + } const { content } = req.body; console.log('PUT /api/stacks/:stackName', { stackName, contentType: typeof content, contentLength: content?.length }); if (typeof content !== 'string') { @@ -375,6 +381,9 @@ app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => { app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; + if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) { + return res.status(400).json({ error: 'Invalid stack name' }); + } const { content } = req.body; if (typeof content !== 'string') { return res.status(400).json({ error: 'Content must be a string' }); @@ -393,9 +402,15 @@ app.post('/api/stacks', async (req: Request, res: Response) => { if (!stackName || typeof stackName !== 'string') { return res.status(400).json({ error: 'Stack name is required and must be a string' }); } + if (!/^[a-zA-Z0-9-]+$/.test(stackName)) { + return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters and hyphens' }); + } await fileSystemService.createStack(stackName); - res.json({ message: 'Stack created successfully' }); - } catch (error) { + res.json({ message: 'Stack created successfully', name: stackName }); + } catch (error: any) { + if (error.message && error.message.includes('already exists')) { + return res.status(409).json({ error: 'Stack already exists' }); + } console.error('Failed to create stack:', error); res.status(500).json({ error: 'Failed to create stack' }); } @@ -404,6 +419,16 @@ app.post('/api/stacks', async (req: Request, res: Response) => { app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; + + // Tear down the stack first to avoid ghost containers + try { + console.log(`Tearing down stack: ${stackName}`); + // Send the down command synchronously before deleting the files + await composeService.runCommand(stackName, 'down', terminalWs || undefined); + } catch (downError) { + console.warn(`Failed to tear down stack ${stackName}, proceeding with file deletion.`, downError); + } + await fileSystemService.deleteStack(stackName); res.json({ message: 'Stack deleted successfully' }); } catch (error) { @@ -622,6 +647,64 @@ app.get('/api/system/stats', async (req: Request, res: Response) => { } }); +// --- System Maintenance Routes (The System Janitor) --- + +app.get('/api/system/orphans', async (req: Request, res: Response) => { + try { + const knownStacks = await fileSystemService.getStacks(); + const dockerController = DockerController.getInstance(); + const orphans = await dockerController.getOrphanContainers(knownStacks); + res.json(orphans); + } catch (error) { + console.error('Failed to fetch orphan containers:', error); + res.status(500).json({ error: 'Failed to fetch orphan containers' }); + } +}); + +app.post('/api/system/prune/orphans', async (req: Request, res: Response) => { + try { + const { containerIds } = req.body; + if (!Array.isArray(containerIds)) { + return res.status(400).json({ error: 'containerIds must be an array' }); + } + const dockerController = DockerController.getInstance(); + const results = await dockerController.removeContainers(containerIds); + res.json({ results }); + } catch (error) { + console.error('Failed to prune orphan containers:', error); + res.status(500).json({ error: 'Failed to prune orphan containers' }); + } +}); + +app.post('/api/system/prune/system', async (req: Request, res: Response) => { + try { + const { target } = req.body; // 'containers', 'images', 'networks' + let command = ''; + + if (target === 'containers') { + command = 'docker container prune -f'; + } else if (target === 'images') { + command = 'docker image prune -a -f'; + } else if (target === 'networks') { + command = 'docker network prune -f'; + } else { + return res.status(400).json({ error: 'Invalid prune target' }); + } + + const { stdout, stderr } = await execAsync(command, { + env: { + ...process.env, + PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + } + }); + + res.json({ message: 'Prune completed', stdout, stderr }); + } catch (error: any) { + console.error('System prune error:', error); + res.status(500).json({ error: 'System prune failed', details: error.message }); + } +}); + // Serve static files in production (for Docker deployment) if (process.env.NODE_ENV === 'production') { app.use(express.static('public')); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 9a5685b2..28b6fdd6 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -1,6 +1,5 @@ import Docker from 'dockerode'; import WebSocket from 'ws'; -import { Duplex } from 'stream'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; @@ -13,8 +12,6 @@ const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose'; class DockerController { private static instance: DockerController; private docker: Docker; - private execStream: Duplex | null = null; - private currentExec: Docker.Exec | null = null; private constructor() { this.docker = new Docker({ socketPath: '/var/run/docker.sock' }); @@ -178,14 +175,32 @@ class DockerController { } } + // State-safe: silently ignores 304 "already started" errors public async startContainer(containerId: string) { - const container = this.docker.getContainer(containerId); - await container.start(); + try { + const container = this.docker.getContainer(containerId); + await container.start(); + } catch (error: any) { + if (error?.statusCode === 304) { + // Container already running — not an error + return; + } + throw error; + } } + // State-safe: silently ignores 304 "already stopped" errors public async stopContainer(containerId: string) { - const container = this.docker.getContainer(containerId); - await container.stop(); + try { + const container = this.docker.getContainer(containerId); + await container.stop(); + } catch (error: any) { + if (error?.statusCode === 304) { + // Container already stopped — not an error + return; + } + throw error; + } } public async restartContainer(containerId: string) { @@ -193,6 +208,50 @@ class DockerController { await container.restart(); } + public async getOrphanContainers(knownStackNames: string[]) { + // 1. Fetch all containers (running and stopped) + const allContainers = await this.docker.listContainers({ all: true }); + + // 2. Filter and categorize orphans + const orphans: Record = {}; + + allContainers.forEach((container) => { + // Look for the docker compose project label + const projectName = container.Labels?.['com.docker.compose.project']; + + // If it has a project label, but the project is NOT in our known list... + if (projectName && !knownStackNames.includes(projectName)) { + if (!orphans[projectName]) { + orphans[projectName] = []; + } + orphans[projectName].push({ + Id: container.Id, + Names: container.Names, + State: container.State, + Status: container.Status, + Image: container.Image + }); + } + }); + + return orphans; + } + + public async removeContainers(containerIds: string[]) { + const results = []; + for (const id of containerIds) { + try { + const container = this.docker.getContainer(id); + await container.remove({ force: true }); + results.push({ id, success: true }); + } catch (error: any) { + console.error(`Failed to remove container ${id}:`, error.message); + results.push({ id, success: false, error: error.message }); + } + } + return results; + } + public async streamStats(containerId: string, ws: WebSocket) { const container = this.docker.getContainer(containerId); const stats = await container.stats({ stream: true }); @@ -210,6 +269,11 @@ class DockerController { }); } + /** + * Exec into a container with full session isolation. + * All state (exec instance, stream) lives in this closure — no singleton traps. + * The WebSocket message handler is registered here to handle input, resize, and cleanup. + */ public async execContainer(containerId: string, ws: WebSocket) { try { const container = this.docker.getContainer(containerId); @@ -234,13 +298,9 @@ class DockerController { }); } - this.currentExec = exec; - const stream = await exec.start({ hijack: true, stdin: true }); - this.execStream = stream; - - // Handle output from container - send raw text directly + // --- Downstream: container output → client --- stream.on('data', (chunk: Buffer) => { if (ws.readyState === WebSocket.OPEN) { ws.send(chunk.toString()); @@ -252,27 +312,54 @@ class DockerController { }); stream.on('end', () => { - this.execStream = null; - this.currentExec = null; + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } }); + + // --- Upstream: client messages → container --- + ws.on('message', (raw: WebSocket.Data) => { + try { + const msg = JSON.parse(raw.toString()); + + switch (msg.type) { + case 'input': + if (msg.data) { + stream.write(msg.data); + } + break; + + case 'resize': + if (msg.rows && msg.cols) { + exec.resize({ h: msg.rows, w: msg.cols }).catch(() => { + // Ignore resize errors (exec may have ended) + }); + } + break; + + case 'ping': + // Keep-alive, no-op + break; + } + } catch { + // Non-JSON or malformed message — ignore + } + }); + + // --- Cleanup: prevent zombie processes --- + ws.on('close', () => { + try { + stream.destroy(); + } catch { + // Ignore destroy errors + } + }); + } catch (error) { const err = error as Error; console.error('Failed to exec container:', err.message); - } - } - - public sendExecInput(data: string) { - if (this.execStream) { - this.execStream.write(data); - } - } - - public async resizeExec(cols: number, rows: number) { - if (this.currentExec) { - try { - await this.currentExec.resize({ w: cols, h: rows }); - } catch { - // Ignore resize errors + if (ws.readyState === WebSocket.OPEN) { + ws.send(`\r\n\x1b[31mFailed to start shell: ${err.message}\x1b[0m\r\n`); } } } diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 3d7ab6d3..9be14689 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -13,7 +13,7 @@ export class FileSystemService { */ private async hasComposeFile(dir: string): Promise { const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; - + for (const file of composeFiles) { try { await fs.access(path.join(dir, file)); @@ -22,7 +22,7 @@ export class FileSystemService { // Continue checking other options } } - + return false; } @@ -33,7 +33,7 @@ export class FileSystemService { private async getComposeFilePath(stackName: string): Promise { const stackDir = path.join(this.baseDir, stackName); const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; - + for (const file of composeFiles) { const filePath = path.join(stackDir, file); try { @@ -43,7 +43,7 @@ export class FileSystemService { // Continue checking other options } } - + throw new Error(`No compose file found for stack: ${stackName}`); } @@ -55,18 +55,18 @@ export class FileSystemService { try { const items = await fs.readdir(this.baseDir, { withFileTypes: true }); const stackNames: string[] = []; - + for (const item of items) { if (!item.isDirectory()) continue; - + const stackDir = path.join(this.baseDir, item.name); const hasCompose = await this.hasComposeFile(stackDir); - + if (hasCompose) { stackNames.push(item.name); } } - + return stackNames; } catch (error) { console.error('Error reading stacks:', error); @@ -94,9 +94,9 @@ export class FileSystemService { async saveStackContent(stackName: string, content: string): Promise { const stackDir = path.join(this.baseDir, stackName); const filePath = path.join(stackDir, 'compose.yaml'); - + console.log('Saving to path:', filePath); - + try { await fs.writeFile(filePath, content, 'utf-8'); console.log('File written successfully'); @@ -138,7 +138,7 @@ export class FileSystemService { async saveEnvContent(stackName: string, content: string): Promise { const envPath = path.join(this.baseDir, stackName, '.env'); console.log('Saving env to path:', envPath); - + try { await fs.writeFile(envPath, content, 'utf-8'); console.log('Env file written successfully'); @@ -156,9 +156,9 @@ export class FileSystemService { if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) { throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens'); } - + const stackDir = path.join(this.baseDir, stackName); - + // Check if directory already exists try { await fs.access(stackDir); @@ -169,14 +169,18 @@ export class FileSystemService { } // Directory doesn't exist, proceed } - + // Create the directory await fs.mkdir(stackDir, { recursive: true }); - + // Write boilerplate compose.yaml const composePath = path.join(stackDir, 'compose.yaml'); const boilerplate = `services: - # Add your services here + app: + image: nginx:latest + ports: + - "8080:80" + restart: always `; try { await fs.writeFile(composePath, boilerplate, 'utf-8'); @@ -192,7 +196,7 @@ export class FileSystemService { */ async deleteStack(stackName: string): Promise { const stackDir = path.join(this.baseDir, stackName); - + try { await fs.rm(stackDir, { recursive: true, force: true }); console.log('Stack deleted successfully:', stackName); @@ -225,15 +229,15 @@ export class FileSystemService { } const items = await fs.readdir(this.baseDir, { withFileTypes: true }); - + for (const item of items) { // Only process .yml/.yaml files (skip directories and other files) if (!item.isFile()) continue; if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue; - + const stackName = item.name.replace(/\.(yml|yaml)$/, ''); const stackDir = path.join(this.baseDir, stackName); - + // Check if target directory already exists try { await fs.access(stackDir); @@ -242,17 +246,17 @@ export class FileSystemService { } catch { // Directory doesn't exist, proceed with migration } - + console.log(`Migrating stack: ${stackName}`); - + // Create the stack directory await fs.mkdir(stackDir, { recursive: true }); - + // Move compose file to new location (standardize on compose.yaml) const oldComposePath = path.join(this.baseDir, item.name); const newComposePath = path.join(stackDir, 'compose.yaml'); await fs.rename(oldComposePath, newComposePath); - + // Move env file if it exists (old pattern: stackname.env) const oldEnvPath = path.join(this.baseDir, `${stackName}.env`); const newEnvPath = path.join(stackDir, '.env'); @@ -263,7 +267,7 @@ export class FileSystemService { } catch { // No env file to migrate, that's fine } - + console.log(`Successfully migrated stack: ${stackName}`); } } catch (error) { diff --git a/frontend/src/components/BashExecModal.tsx b/frontend/src/components/BashExecModal.tsx index 16d56e85..e69e5be1 100644 --- a/frontend/src/components/BashExecModal.tsx +++ b/frontend/src/components/BashExecModal.tsx @@ -1,7 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; -import { Button } from './ui/button'; -import { Terminal as TerminalIcon, X } from 'lucide-react'; +import { useEffect, useRef, useState, useCallback } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; +import { Terminal as TerminalIcon } from 'lucide-react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import '@xterm/xterm/css/xterm.css'; @@ -18,11 +17,72 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN const xtermRef = useRef(null); const fitAddonRef = useRef(null); const wsRef = useRef(null); + const initTimeoutRef = useRef | null>(null); const [isConnected, setIsConnected] = useState(false); + const cleanup = useCallback(() => { + if (initTimeoutRef.current) { + clearTimeout(initTimeoutRef.current); + initTimeoutRef.current = null; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + if (xtermRef.current) { + xtermRef.current.dispose(); + xtermRef.current = null; + } + fitAddonRef.current = null; + setIsConnected(false); + }, []); + useEffect(() => { - if (isOpen && terminalRef.current && !xtermRef.current) { - // Initialize xterm.js + if (!isOpen) { + cleanup(); + return; + } + + let attempts = 0; + const maxAttempts = 15; // up to 1.5 seconds + + const checkAndInit = () => { + // If already initialized, stop. + if (xtermRef.current) return; + + const container = terminalRef.current; + + // If Radix Dialog Portal hasn't rendered the DOM node yet, poll. + if (!container) { + if (attempts++ < maxAttempts) { + initTimeoutRef.current = setTimeout(checkAndInit, 100); + } else { + console.error('BashExecModal: terminalRef never populated.'); + } + return; + } + + // DOM exists. Now verify it has actual layout size from CSS. + // During initial Radix zoom-in animation, it might be 0x0. + const rect = container.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + if (attempts++ < maxAttempts) { + initTimeoutRef.current = setTimeout(checkAndInit, 100); + } else { + console.warn('BashExecModal: terminal container has zero dimensions after 1.5s, forcing init anyway.'); + initTerminal(container); + } + return; + } + + // Node exists and has layout — safe to initialize xterm! + initTerminal(container); + }; + + // Start polling + initTimeoutRef.current = setTimeout(checkAndInit, 50); + + function initTerminal(containerEl: HTMLDivElement) { const term = new Terminal({ theme: { background: '#1e1e1e', @@ -38,30 +98,55 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN const fitAddon = new FitAddon(); term.loadAddon(fitAddon); - term.open(terminalRef.current); - - setTimeout(() => { - fitAddon.fit(); - }, 100); + term.open(containerEl); xtermRef.current = term; fitAddonRef.current = fitAddon; + // Fit after xterm has rendered its canvas + requestAnimationFrame(() => { + try { + fitAddon.fit(); + } catch { + // Ignore fit errors during initial render + } + }); + // Connect to WebSocket for bash exec const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${wsProtocol}//${window.location.host}`); wsRef.current = ws; ws.onopen = () => { + // Kick off the exec session ws.send(JSON.stringify({ action: 'execContainer', containerId: containerId, })); setIsConnected(true); + + // Focus so user can type immediately + term.focus(); + + // Fit again now that everything is settled, then send dimensions + setTimeout(() => { + try { + fitAddon.fit(); + } catch { + // Ignore + } + if (ws.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { + ws.send(JSON.stringify({ + type: 'resize', + rows: term.rows, + cols: term.cols, + })); + } + }, 100); }; ws.onmessage = (event) => { - // Write raw text directly to terminal + // Raw text from container → write directly to xterm term.write(event.data); }; @@ -75,73 +160,57 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN setIsConnected(false); }; - // Handle user input + // Handle user input — JSON up term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ - action: 'input', + type: 'input', data: data, })); } }); - // Handle resize - const handleResize = () => { - if (fitAddonRef.current) { + // ResizeObserver for modal/window resize events + const resizeObserver = new ResizeObserver(() => { + if (!fitAddonRef.current || !wsRef.current) return; + try { fitAddonRef.current.fit(); - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - action: 'resize', - cols: term.cols, - rows: term.rows, - })); - } + } catch { + return; } - }; + if (wsRef.current.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { + wsRef.current.send(JSON.stringify({ + type: 'resize', + rows: term.rows, + cols: term.cols, + })); + } + }); - window.addEventListener('resize', handleResize); + resizeObserver.observe(containerEl); - return () => { - window.removeEventListener('resize', handleResize); - }; + // Store observer cleanup on the container element for later + (containerEl as any).__resizeObserver = resizeObserver; } return () => { - // Cleanup on close - if (!isOpen) { - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - if (xtermRef.current) { - xtermRef.current.dispose(); - xtermRef.current = null; - } - if (fitAddonRef.current) { - fitAddonRef.current = null; - } - setIsConnected(false); + // Clean up ResizeObserver + if (terminalRef.current && (terminalRef.current as any).__resizeObserver) { + (terminalRef.current as any).__resizeObserver.disconnect(); + delete (terminalRef.current as any).__resizeObserver; } }; - }, [isOpen, containerId]); + }, [isOpen, containerId, cleanup]); const handleClose = () => { - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - if (xtermRef.current) { - xtermRef.current.dispose(); - xtermRef.current = null; - } - setIsConnected(false); + cleanup(); onClose(); }; return ( - + Bash: {containerName} @@ -151,15 +220,18 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN )} - + + Interactive bash terminal session for {containerName} + -
+ {/* Styling wrapper — padding and rounded corners go here */} +
+ {/* Clean xterm container — NO padding, NO overflow-hidden, explicit dimensions */} +
+
); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 93c6f070..55d0fcb1 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -4,13 +4,14 @@ import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; import HomeDashboard from './HomeDashboard'; import BashExecModal from './BashExecModal'; +import MaintenanceModal from './MaintenanceModal'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, DialogTrigger } from './ui/dialog'; import { Tabs, TabsList, TabsTrigger } from './ui/tabs'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Search, Home, LogOut } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Search, Home, LogOut, Brush } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { apiFetch } from '@/lib/api'; @@ -53,6 +54,9 @@ export default function EditorLayout() { const [bashModalOpen, setBashModalOpen] = useState(false); const [selectedContainer, setSelectedContainer] = useState<{ id: string; name: string } | null>(null); + // Maintenance modal state + const [maintenanceModalOpen, setMaintenanceModalOpen] = useState(false); + // Theme toggle effect useEffect(() => { const html = document.documentElement; @@ -337,6 +341,7 @@ export default function EditorLayout() { const deleteStack = async () => { if (!stackToDelete) return; + setIsActionLoading(true); try { const response = await apiFetch(`/stacks/${stackToDelete}`, { method: 'DELETE', @@ -358,6 +363,8 @@ export default function EditorLayout() { } catch (error) { console.error('Failed to delete stack:', error); alert('Failed to delete stack'); + } finally { + setIsActionLoading(false); } }; @@ -370,13 +377,22 @@ export default function EditorLayout() { method: 'POST', body: JSON.stringify({ stackName }), }); - if (!response.ok) throw new Error('Failed to create stack'); + 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(); - } catch (error) { + // Auto-load the new stack in the editor pane + await loadFile(stackName); + } catch (error: any) { console.error('Failed to create stack:', error); - alert('Failed to create stack'); + alert(error.message || 'Failed to create stack'); } }; @@ -532,6 +548,17 @@ export default function EditorLayout() { Console + {/* System Janitor (Maintenance) Toggle */} + {/* Theme Toggle */} + + + + + {isLoadingOrphans ? ( +
+ Hunting for ghosts... +
+ ) : totalOrphansCount === 0 ? ( +
+ +

No orphaned containers detected.

+

Your system is clean!

+
+ ) : ( +
+
+ 0} + className="rounded border-gray-300 focus:ring-primary" + /> + Select All +
+ + {Object.entries(orphans).map(([project, containers]) => ( +
+
+ + Project: {project} +
+
+ {containers.map(container => ( +
+ toggleOrphanSelection(container.Id)} + className="rounded border-gray-300" + /> +
+
+ + {container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12)} + + + {container.State} + +
+
+ Image: {container.Image} +
+
+
+ ))} +
+
+ ))} +
+ )} + + + +

Global Docker Pruning

+ +
+ + + + + +
+ + {pruneResult && ( +
+
Result:
+
{pruneResult.message}
+ {pruneResult.stdout &&
{pruneResult.stdout}
} + {pruneResult.stderr &&
{pruneResult.stderr}
} +
+ )} +
+ + + + ); +}