diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index d01658d..9e51004 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -96,9 +96,21 @@ Source: `src/backend/ssh/file-manager*.ts` (six files, ~3,900 lines combined: li **Verified end-to-end** against a real filesystem-backed SFTP server built specifically for this (using `ssh2`'s server-side low-level SFTP protocol API — genuine `OPEN`/`READ`/`WRITE`/`READDIR`/`RENAME`/`REMOVE`/`MKDIR`/`STAT`/`SETSTAT` handlers backed by real `fs` calls against a real directory on disk, not a mock). Confirmed by inspecting the actual files/permissions on disk after each operation (`cat`, `ls`, `stat -c '%a'`), not just the HTTP response: list, read, write, mkdir, rename, delete, chmod, upload, and download (byte-for-byte `diff` match against the uploaded source file) all round-tripped correctly. One real bug was caught and fixed during this verification: the download route's wrapping `Promise` was resolving immediately after `reply.send(stream)` instead of waiting for the response to actually finish, which raced Fastify into ending the HTTP response (and the route's `cleanup()` into closing the underlying SSH connection) before the SFTP stream had sent any data — produced a 0-byte download with a "stream closed prematurely" log line. Fixed by letting `reply.send(stream)`'s return value resolve the promise instead of resolving synchronously, and moving connection cleanup to the response's own `finish`/`close` events. All test artifacts (test SFTP server, test backend instance, test DB, tokens, temp files) were cleaned up afterward. -### Phase 4 — Docker Container Management (NOT STARTED) +### Phase 4 — Docker Container Management (DONE, with documented gaps) -Source: `src/backend/ssh/docker.ts` (2,243 lines) + `docker-container-routes.ts` (1,093 lines) + `docker-console.ts` (751 lines) + frontend `src/ui/features/docker/*`. Start/stop/pause/remove containers, view stats, `docker exec` terminal. **Check for overlap** with ArchNest's existing `backend/src/integrations/docker.ts` adapter (currently just used for health-status resources) before porting — may be able to extend the existing adapter rather than bolt on a second, separate Docker code path. +**Architecture decision**: Termix's source (`src/backend/ssh/docker.ts`, `docker-container-routes.ts`, `docker-console.ts`) drives Docker over SSH+CLI. ArchNest's existing `backend/src/integrations/docker.ts` adapter already talks to the **Docker Engine HTTP API directly** via a stored `baseUrl` (the only config field exposed in Settings for a docker integration — no SSH credentials, no TLS client certs). Rather than bolt on a second SSH-based Docker code path, Phase 4 extends the existing Engine-API approach: all new code talks straight to `dockerd`'s HTTP API. + +**What was built:** +- `backend/src/docker/client.ts` — `loadDockerHost(integrationId)`, `dockerFetch`/`dockerJson` thin wrappers over the Engine API, `demuxDockerStream()` (best-effort parser for the 8-byte-frame multiplexed stdout/stderr format used by non-TTY containers' `logs`/`stats` endpoints, falling back to raw text for TTY containers). +- `backend/src/docker/exec.ts` — `openExecStream()` opens a `docker exec` session and performs the raw HTTP "hijack": after `POST /exec/{id}/start`, the daemon switches the TCP socket to a raw bidirectional byte stream (no further HTTP framing), so the implementation connects via `net`/`tls` directly, writes the HTTP request by hand, and strips the response headers before treating the rest as raw I/O. +- `backend/src/routes/docker.ts` — `dockerRoutes` (REST: list/stats/logs/start/stop/restart/pause/unpause/remove, behind the standard `app.authenticate` hook) and `dockerExecRoutes` (websocket `/api/docker/exec`, auth via a `token` query param verified on the `connect` message, mirroring `terminal.ts`'s pattern since websocket upgrades can't carry an `Authorization` header). +- `src/pages/Containers.tsx` — new page (`/containers`, sidebar entry with a `Box` icon): Docker host picker, container table (state, image, live CPU/memory from `stats`, ports) with start/stop/restart/pause/unpause/remove actions, a logs modal, and an exec-terminal modal reusing `Terminal.tsx`'s xterm.js + `FitAddon` pattern (base64-encoded I/O over the websocket). + +**Verified end-to-end** against a real Docker daemon (`dockerd`) started inside the sandbox on a TCP port, with a real container built from a `docker import` of the host's own rootfs (no network access to a registry was available, so a minimal real image was constructed locally rather than pulled). Confirmed via real container state transitions (`docker inspect`) cross-checked against the API responses: list, stats, logs (including the frame-demuxed multi-line case), start/stop/restart/pause/unpause, and remove all worked correctly through the new REST routes. The exec-terminal websocket path was exercised with a real `ws` client driving an interactive shell inside the real container (sent `echo HELLO_FROM_EXEC`, got the echoed output back through the hijacked socket) and a live resize. + +One real bug was caught and fixed during this verification: `openExecStream()` originally called `POST /exec/{id}/resize` immediately after creating the exec instance but before starting it — confirmed via a raw `curl` repro that the Docker daemon blocks that request indefinitely until the exec's process actually exists, which hung every exec session before it ever reached `ready`. Fixed by passing the initial terminal size via `ConsoleSize` in the exec-create payload instead, and only using the explicit resize endpoint for later live resizes (sent after the exec is already running, so it's safe there, and was verified working in that position). + +**Documented gap**: no browser is available in this sandbox, so `Containers.tsx` was verified by type-checking and a production `vite build`, and by manually exercising every backend endpoint it calls against the real daemon above — but it has not been clicked through in an actual browser. All test artifacts (test `dockerd` instance, test image/container, test backend instance, test DB, tokens, temp files) were cleaned up afterward. ### Phase 5 — RDP/VNC/Telnet (NOT STARTED) diff --git a/backend/src/docker/client.ts b/backend/src/docker/client.ts new file mode 100644 index 0000000..0928f96 --- /dev/null +++ b/backend/src/docker/client.ts @@ -0,0 +1,68 @@ +import { db } from '../db/index.js' + +interface IntegrationRow { + id: number + type: string + config_json: string +} + +export interface DockerHost { + id: number + baseUrl: string +} + +export function loadDockerHost(integrationId: number): DockerHost | null { + const row = db + .prepare('SELECT id, type, config_json FROM integrations WHERE id = ?') + .get(integrationId) as IntegrationRow | undefined + if (!row || row.type !== 'docker') return null + const config = JSON.parse(row.config_json) as Record + const baseUrl = config.baseUrl?.replace(/\/$/, '') + if (!baseUrl) return null + return { id: row.id, baseUrl } +} + +/** Thin wrapper over the Docker Engine HTTP API - this app talks to dockerd directly rather than + * shelling out to the `docker` CLI over SSH, since ArchNest's docker integration is already + * configured with a baseUrl (TCP or proxied socket) pointing straight at the Engine API. */ +export async function dockerFetch(host: DockerHost, path: string, init: RequestInit = {}): Promise { + const res = await fetch(`${host.baseUrl}${path}`, init) + if (!res.ok) { + let message = res.statusText + try { + const body = (await res.json()) as { message?: string } + message = body.message ?? message + } catch { + // ignore non-JSON error bodies + } + throw new Error(message) + } + return res +} + +export async function dockerJson(host: DockerHost, path: string, init: RequestInit = {}): Promise { + const res = await dockerFetch(host, path, init) + if (res.status === 204) return undefined as T + return res.json() as Promise +} + +/** Docker's `stats`/`logs` endpoints multiplex stdout/stderr into 8-byte-header frames unless the + * container was created with a tty, in which case the body is already raw text. We don't know + * which mode a given container used ahead of time, so attempt the frame format and fall back to + * treating the whole buffer as raw text if it doesn't look like a valid frame stream. */ +export function demuxDockerStream(buf: Buffer): string { + let result = '' + let offset = 0 + while (offset + 8 <= buf.length) { + const streamType = buf.readUInt8(offset) + if (streamType > 2) return buf.toString('utf8') + const length = buf.readUInt32BE(offset + 4) + const start = offset + 8 + const end = start + length + if (end > buf.length) return buf.toString('utf8') + result += buf.subarray(start, end).toString('utf8') + offset = end + } + if (offset === 0) return buf.toString('utf8') + return result +} diff --git a/backend/src/docker/exec.ts b/backend/src/docker/exec.ts new file mode 100644 index 0000000..a181a66 --- /dev/null +++ b/backend/src/docker/exec.ts @@ -0,0 +1,83 @@ +import { connect as netConnect, type Socket } from 'node:net' +import { connect as tlsConnect, type TLSSocket } from 'node:tls' +import { dockerJson, dockerFetch, type DockerHost } from './client.js' + +const DEFAULT_SHELL_CMD = ['/bin/sh', '-c', 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'] + +/** Opens a `docker exec` session and hands back the raw hijacked socket. + * + * The Docker Engine API doesn't expose exec I/O over a normal request/response or websocket - + * `POST /exec/{id}/start` "hijacks" the underlying HTTP connection: after writing the response + * headers, the daemon switches the same TCP socket to a raw bidirectional byte stream (the + * process's stdin/stdout/stderr, interleaved, since this is opened with Tty:true). There's no + * Node http client option for that, so we open the socket ourselves and write the HTTP request + * by hand, then treat everything after the blank line that ends the response headers as the + * exec session's raw stream. */ +export async function openExecStream( + host: DockerHost, + containerId: string, + cols: number, + rows: number, +): Promise<{ socket: Socket | TLSSocket; execId: string }> { + const { Id: execId } = await dockerJson<{ Id: string }>(host, `/containers/${containerId}/exec`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + Cmd: DEFAULT_SHELL_CMD, + ConsoleSize: [rows, cols], + }), + }) + + const url = new URL(host.baseUrl) + const useTls = url.protocol === 'https:' + const port = Number(url.port) || (useTls ? 443 : 80) + + const socket = await new Promise((resolve, reject) => { + const onError = (err: Error) => reject(err) + const s = useTls + ? tlsConnect({ host: url.hostname, port }, () => resolve(s)) + : netConnect({ host: url.hostname, port }, () => resolve(s)) + s.once('error', onError) + }) + + const body = JSON.stringify({ Detach: false, Tty: true }) + const request = + `POST /exec/${execId}/start HTTP/1.1\r\n` + + `Host: ${url.hostname}\r\n` + + `Connection: Upgrade\r\n` + + `Upgrade: tcp\r\n` + + `Content-Type: application/json\r\n` + + `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}` + socket.write(request) + + return { socket, execId } +} + +/** Strips the HTTP response headers Docker sends before switching the socket to a raw stream, + * forwarding everything after the blank line that ends them. Returns a passthrough function to + * wrap the socket's first few 'data' events with. */ +export function stripHijackHeaders(onData: (chunk: Buffer) => void): (chunk: Buffer) => void { + let headersDone = false + let pending = Buffer.alloc(0) + return (chunk: Buffer) => { + if (headersDone) { + onData(chunk) + return + } + pending = Buffer.concat([pending, chunk]) + const idx = pending.indexOf('\r\n\r\n') + if (idx === -1) return + headersDone = true + const rest = pending.subarray(idx + 4) + pending = Buffer.alloc(0) + if (rest.length > 0) onData(rest) + } +} + +export async function resizeExec(host: DockerHost, execId: string, cols: number, rows: number): Promise { + await dockerFetch(host, `/exec/${execId}/resize?h=${rows}&w=${cols}`, { method: 'POST' }) +} diff --git a/backend/src/routes/docker.ts b/backend/src/routes/docker.ts new file mode 100644 index 0000000..af246e4 --- /dev/null +++ b/backend/src/routes/docker.ts @@ -0,0 +1,206 @@ +import type { FastifyInstance } from 'fastify' +import { z } from 'zod' +import { loadDockerHost, dockerFetch, dockerJson, demuxDockerStream } from '../docker/client.js' +import { openExecStream, stripHijackHeaders, resizeExec } from '../docker/exec.js' + +interface ExecMessage { + type: 'connect' | 'input' | 'resize' | 'disconnect' + integrationId?: number + containerId?: string + cols?: number + rows?: number + data?: string +} + +function sendJson(socket: { send: (data: string) => void }, payload: Record) { + socket.send(JSON.stringify(payload)) +} + +interface ContainerSummary { + Id: string + Names: string[] + Image: string + State: string + Status: string + Ports: { PrivatePort: number; PublicPort?: number; Type: string }[] +} + +function serializeContainer(c: ContainerSummary) { + return { + id: c.Id, + name: c.Names[0]?.replace(/^\//, '') ?? c.Id.slice(0, 12), + image: c.Image, + state: c.State, + status: c.Status, + ports: c.Ports.map((p) => ({ privatePort: p.PrivatePort, publicPort: p.PublicPort, type: p.Type })), + } +} + +export async function dockerExecRoutes(app: FastifyInstance) { + app.get('/api/docker/exec', { websocket: true }, (socket, req) => { + let execSocket: Awaited>['socket'] | null = null + let host: ReturnType = null + let execId: string | null = null + + const cleanup = () => { + execSocket?.destroy() + execSocket = null + } + socket.on('close', cleanup) + + socket.on('message', async (raw: Buffer) => { + let msg: ExecMessage + try { + msg = JSON.parse(raw.toString()) + } catch { + sendJson(socket, { type: 'error', message: 'Invalid JSON' }) + return + } + + if (msg.type === 'connect') { + const query = req.query as { token?: string } + try { + await app.jwt.verify(query.token ?? '') + } catch { + sendJson(socket, { type: 'error', message: 'Unauthorized' }) + socket.close() + return + } + + host = msg.integrationId !== undefined ? loadDockerHost(msg.integrationId) : null + if (!host || !msg.containerId) { + sendJson(socket, { type: 'error', message: 'Docker integration or container not found' }) + return + } + + try { + const result = await openExecStream(host, msg.containerId, msg.cols ?? 80, msg.rows ?? 24) + execSocket = result.socket + execId = result.execId + const forward = stripHijackHeaders((chunk) => { + socket.send(JSON.stringify({ type: 'data', data: chunk.toString('base64') })) + }) + execSocket.on('data', forward) + execSocket.on('error', (err: Error) => sendJson(socket, { type: 'error', message: err.message })) + execSocket.on('close', () => sendJson(socket, { type: 'exit' })) + sendJson(socket, { type: 'ready' }) + } catch (err) { + sendJson(socket, { type: 'error', message: err instanceof Error ? err.message : 'Failed to start exec session' }) + } + return + } + + if (msg.type === 'input' && execSocket && msg.data !== undefined) { + execSocket.write(Buffer.from(msg.data, 'base64')) + return + } + + if (msg.type === 'resize' && host && execId && msg.cols && msg.rows) { + try { + await resizeExec(host, execId, msg.cols, msg.rows) + } catch { + // best-effort - a failed resize shouldn't kill the session + } + return + } + + if (msg.type === 'disconnect') { + cleanup() + socket.close() + } + }) + }) +} + +export async function dockerRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate) + + app.get('/api/docker/:integrationId/containers', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const host = loadDockerHost(integrationId) + if (!host) return reply.code(404).send({ error: 'Docker integration not found' }) + try { + const containers = await dockerJson(host, '/containers/json?all=true') + return { containers: containers.map(serializeContainer) } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to list containers' }) + } + }) + + app.get('/api/docker/:integrationId/containers/:id/stats', async (req, reply) => { + const { integrationId, id } = req.params as { integrationId: string; id: string } + const host = loadDockerHost(Number(integrationId)) + if (!host) return reply.code(404).send({ error: 'Docker integration not found' }) + try { + const stats = await dockerJson<{ + cpu_stats: { cpu_usage: { total_usage: number }; system_cpu_usage: number; online_cpus?: number } + precpu_stats: { cpu_usage: { total_usage: number }; system_cpu_usage: number } + memory_stats: { usage?: number; limit?: number } + networks?: Record + }>(host, `/containers/${id}/stats?stream=false`) + + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage + const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage + const cpuCount = stats.cpu_stats.online_cpus || 1 + const cpuPercent = systemDelta > 0 && cpuDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0 + + const netRx = Object.values(stats.networks ?? {}).reduce((sum, n) => sum + n.rx_bytes, 0) + const netTx = Object.values(stats.networks ?? {}).reduce((sum, n) => sum + n.tx_bytes, 0) + + return { + cpuPercent: Math.round(cpuPercent * 10) / 10, + memUsage: stats.memory_stats.usage ?? 0, + memLimit: stats.memory_stats.limit ?? 0, + netRx, + netTx, + } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to fetch stats' }) + } + }) + + app.get('/api/docker/:integrationId/containers/:id/logs', async (req, reply) => { + const { integrationId, id } = req.params as { integrationId: string; id: string } + const tail = (req.query as { tail?: string }).tail ?? '200' + const host = loadDockerHost(Number(integrationId)) + if (!host) return reply.code(404).send({ error: 'Docker integration not found' }) + try { + const res = await dockerFetch(host, `/containers/${id}/logs?stdout=true&stderr=true&tail=${encodeURIComponent(tail)}`) + const buf = Buffer.from(await res.arrayBuffer()) + return { logs: demuxDockerStream(buf) } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to fetch logs' }) + } + }) + + const actionSchema = z.enum(['start', 'stop', 'restart', 'pause', 'unpause']) + + app.post('/api/docker/:integrationId/containers/:id/:action', async (req, reply) => { + const { integrationId, id, action } = req.params as { integrationId: string; id: string; action: string } + const parsedAction = actionSchema.safeParse(action) + if (!parsedAction.success) return reply.code(400).send({ error: 'Invalid action' }) + const host = loadDockerHost(Number(integrationId)) + if (!host) return reply.code(404).send({ error: 'Docker integration not found' }) + try { + await dockerFetch(host, `/containers/${id}/${parsedAction.data}`, { method: 'POST' }) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : `Failed to ${action} container` }) + } + }) + + app.post('/api/docker/:integrationId/containers/:id/remove', async (req, reply) => { + const { integrationId, id } = req.params as { integrationId: string; id: string } + const bodySchema = z.object({ force: z.boolean().default(false) }) + const parsed = bodySchema.safeParse(req.body ?? {}) + if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' }) + const host = loadDockerHost(Number(integrationId)) + if (!host) return reply.code(404).send({ error: 'Docker integration not found' }) + try { + await dockerFetch(host, `/containers/${id}?force=${parsed.data.force}`, { method: 'DELETE' }) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to remove container' }) + } + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 74e6d03..eff8ef6 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -11,6 +11,7 @@ import { eventRoutes } from './routes/events.js' import { terminalRoutes } from './routes/terminal.js' import { tunnelRoutes } from './routes/tunnels.js' import { fileRoutes } from './routes/files.js' +import { dockerRoutes, dockerExecRoutes } from './routes/docker.js' import { startAutoStartTunnels } from './tunnels/manager.js' const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET @@ -40,6 +41,8 @@ await app.register(eventRoutes) await app.register(terminalRoutes) await app.register(tunnelRoutes) await app.register(fileRoutes) +await app.register(dockerRoutes) +await app.register(dockerExecRoutes) app.get('/api/health', async () => ({ ok: true })) diff --git a/src/App.tsx b/src/App.tsx index de5af04..2ce7962 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import BookNest from './pages/BookNest' import Terminal from './pages/Terminal' import Tunnels from './pages/Tunnels' import Files from './pages/Files' +import Containers from './pages/Containers' import Settings from './pages/Settings' import Login from './pages/Login' import Enrollment from './pages/Enrollment' @@ -86,6 +87,7 @@ function Dashboard() { } /> } /> } /> + } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index aa238fe..a5997ed 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { Terminal, Waypoints, FolderOpen, + Box, Settings, ChevronLeft, ChevronRight, @@ -25,6 +26,7 @@ const navItems = [ { icon: Terminal, label: 'Terminal', route: '/terminal' }, { icon: Waypoints, label: 'Tunnels', route: '/tunnels' }, { icon: FolderOpen, label: 'Files', route: '/files' }, + { icon: Box, label: 'Containers', route: '/containers' }, { icon: Settings, label: 'Settings', route: '/settings' }, ] diff --git a/src/lib/api.ts b/src/lib/api.ts index f596e80..50d79e8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -130,6 +130,20 @@ export const api = { } return res.json() as Promise<{ ok: boolean; path: string }> }, + + listContainers: (integrationId: number) => + apiFetch<{ containers: Container[] }>(`/docker/${integrationId}/containers`), + containerStats: (integrationId: number, id: string) => + apiFetch(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/stats`), + containerLogs: (integrationId: number, id: string, tail = 200) => + apiFetch<{ logs: string }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/logs?tail=${tail}`), + containerAction: (integrationId: number, id: string, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') => + apiFetch<{ ok: boolean }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/${action}`, { method: 'POST' }), + removeContainer: (integrationId: number, id: string, force = false) => + apiFetch<{ ok: boolean }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/remove`, { + method: 'POST', + body: JSON.stringify({ force }), + }), } export interface AuthUser { @@ -204,6 +218,23 @@ export interface FileEntry { mtime: number } +export interface Container { + id: string + name: string + image: string + state: string + status: string + ports: { privatePort: number; publicPort?: number; type: string }[] +} + +export interface ContainerStats { + cpuPercent: number + memUsage: number + memLimit: number + netRx: number + netTx: number +} + export interface Resource { name: string status: 'healthy' | 'warning' | 'critical' | 'unknown' diff --git a/src/pages/Containers.tsx b/src/pages/Containers.tsx new file mode 100644 index 0000000..256bb11 --- /dev/null +++ b/src/pages/Containers.tsx @@ -0,0 +1,384 @@ +import { useEffect, useRef, useState } from 'react' +import { Terminal as XTerm } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import '@xterm/xterm/css/xterm.css' +import { + Play, + Square, + RotateCw, + Pause, + PlayCircle, + Trash2, + RefreshCw, + TerminalSquare, + ScrollText, + X, +} from 'lucide-react' +import { api, getToken, type Container, type ContainerStats, type Integration } from '../lib/api' + +const TEXT_PRIMARY = '#E8E6E0' +const TEXT_SECONDARY = '#7A7D85' +const GOLD = '#C8A434' + +const cardBase: React.CSSProperties = { + backgroundColor: 'rgba(10, 10, 12, 0.92)', + border: '1px solid rgba(200, 164, 52, 0.08)', + borderRadius: '12px', + boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)', +} + +function stateColor(state: string): string { + if (state === 'running') return '#2ECC71' + if (state === 'paused') return '#E0A82E' + if (state === 'exited' || state === 'dead') return '#E74C3C' + return TEXT_SECONDARY +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const units = ['KB', 'MB', 'GB', 'TB'] + let v = bytes + let i = -1 + do { + v /= 1024 + i++ + } while (v >= 1024 && i < units.length - 1) + return `${v.toFixed(1)} ${units[i]}` +} + +export default function Containers() { + const [hosts, setHosts] = useState([]) + const [integrationId, setIntegrationId] = useState('') + const [containers, setContainers] = useState([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [busyId, setBusyId] = useState(null) + const [statsById, setStatsById] = useState>({}) + const [logsContainer, setLogsContainer] = useState(null) + const [execContainer, setExecContainer] = useState(null) + + useEffect(() => { + api.listIntegrations().then(({ integrations }) => { + const dockerHosts = integrations.filter((i) => i.type === 'docker') + setHosts(dockerHosts) + if (dockerHosts.length > 0) setIntegrationId(dockerHosts[0].id) + }) + }, []) + + function refresh() { + if (!integrationId) return + setLoading(true) + setError(null) + api + .listContainers(integrationId) + .then(({ containers }) => { + setContainers(containers) + containers.forEach((c) => { + if (c.state !== 'running') return + api + .containerStats(integrationId, c.id) + .then((stats) => setStatsById((prev) => ({ ...prev, [c.id]: stats }))) + .catch(() => {}) + }) + }) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to list containers')) + .finally(() => setLoading(false)) + } + + useEffect(() => { + refresh() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [integrationId]) + + async function runAction(c: Container, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') { + if (!integrationId) return + setBusyId(c.id) + setError(null) + try { + await api.containerAction(integrationId, c.id, action) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : `Failed to ${action} container`) + } finally { + setBusyId(null) + } + } + + async function removeContainer(c: Container) { + if (!integrationId) return + if (!confirm(`Remove container "${c.name}"? This cannot be undone.`)) return + setBusyId(c.id) + setError(null) + try { + await api.removeContainer(integrationId, c.id, c.state === 'running') + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove container') + } finally { + setBusyId(null) + } + } + + return ( +
+
+
+

+ Containers +

+

+ Manage Docker containers across your configured hosts. +

+
+
+ + +
+
+ + {error && ( +
+ {error} + +
+ )} + +
+ + + + + + + + + + + + + + {containers.length === 0 && ( + + + + )} + {containers.map((c) => { + const stats = statsById[c.id] + const busy = busyId === c.id + return ( + + + + + + + + + + ) + })} + +
NameImageStateCPUMemoryPortsActions
+ {integrationId ? 'No containers found.' : 'Select a Docker integration to view containers.'} +
+ {c.name} + + {c.image} + + + + {c.status} + + + {stats ? `${stats.cpuPercent.toFixed(1)}%` : '—'} + + {stats ? `${formatBytes(stats.memUsage)} / ${formatBytes(stats.memLimit)}` : '—'} + + {c.ports.length === 0 ? '—' : c.ports.map((p) => `${p.publicPort ?? ''}${p.publicPort ? ':' : ''}${p.privatePort}/${p.type}`).join(', ')} + +
+ {c.state === 'running' ? ( + <> + + + + + + ) : c.state === 'paused' ? ( + + ) : ( + + )} + + +
+
+
+ + {logsContainer && integrationId && ( + setLogsContainer(null)} /> + )} + {execContainer && integrationId && ( + setExecContainer(null)} /> + )} +
+ ) +} + +function LogsModal({ integrationId, container, onClose }: { integrationId: number; container: Container; onClose: () => void }) { + const [logs, setLogs] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + function load() { + setLoading(true) + setError(null) + api + .containerLogs(integrationId, container.id) + .then(({ logs }) => setLogs(logs)) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to fetch logs')) + .finally(() => setLoading(false)) + } + + useEffect(load, []) + + return ( +
+
+
+

+ Logs — {container.name} +

+
+ + +
+
+ {error &&

{error}

} +
+          {logs || (loading ? 'Loading…' : 'No logs.')}
+        
+
+
+ ) +} + +function ExecModal({ integrationId, container, onClose }: { integrationId: number; container: Container; onClose: () => void }) { + const containerRef = useRef(null) + const wsRef = useRef(null) + const [connected, setConnected] = useState(false) + + useEffect(() => { + if (!containerRef.current) return + const term = new XTerm({ + cursorBlink: true, + fontSize: 13, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + theme: { background: '#15161A', foreground: '#E8E6E0', cursor: GOLD }, + }) + const fit = new FitAddon() + term.loadAddon(fit) + term.open(containerRef.current) + fit.fit() + + const token = getToken() + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${window.location.host}/api/docker/exec?token=${encodeURIComponent(token ?? '')}`) + wsRef.current = ws + + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'connect', integrationId, containerId: container.id, cols: term.cols, rows: term.rows })) + } + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + if (msg.type === 'ready') { + setConnected(true) + } else if (msg.type === 'data') { + term.write(Uint8Array.from(atob(msg.data), (c) => c.charCodeAt(0))) + } else if (msg.type === 'error') { + term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`) + setConnected(false) + } else if (msg.type === 'exit') { + term.writeln('\r\n\x1b[33mSession ended.\x1b[0m') + setConnected(false) + } + } + ws.onclose = () => setConnected(false) + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'input', data: btoa(data) })) + } + }) + term.onResize(({ cols, rows }) => { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows })) + }) + + const onResize = () => fit.fit() + window.addEventListener('resize', onResize) + return () => { + window.removeEventListener('resize', onResize) + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'disconnect' })) + ws.close() + term.dispose() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+
+
+

+ + Exec — {container.name} +

+ +
+
+
+
+ ) +}