Phase 4: Docker container management (REST CRUD/actions/stats/logs + exec terminal)
Extends the existing Engine-API-based docker integration adapter rather than
porting Termix's SSH+CLI approach, since ArchNest's docker integrations only
ever configure a baseUrl. Adds backend/src/docker/{client,exec}.ts and
backend/src/routes/docker.ts (REST + websocket exec-terminal via raw socket
hijack), and a new Containers page wired into the sidebar/router.
Verified end-to-end against a real dockerd instance and a real container in
this sandbox, which caught and fixed a genuine bug: calling /exec/{id}/resize
before starting the exec hangs the daemon indefinitely; fixed by setting the
initial size via ConsoleSize at exec-create time instead.
This commit is contained in:
parent
7edf4548d9
commit
52646d866d
9 changed files with 793 additions and 2 deletions
|
|
@ -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.
|
**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)
|
### Phase 5 — RDP/VNC/Telnet (NOT STARTED)
|
||||||
|
|
||||||
|
|
|
||||||
68
backend/src/docker/client.ts
Normal file
68
backend/src/docker/client.ts
Normal file
|
|
@ -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<string, string>
|
||||||
|
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<Response> {
|
||||||
|
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<T>(host: DockerHost, path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await dockerFetch(host, path, init)
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
83
backend/src/docker/exec.ts
Normal file
83
backend/src/docker/exec.ts
Normal file
|
|
@ -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<Socket | TLSSocket>((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<void> {
|
||||||
|
await dockerFetch(host, `/exec/${execId}/resize?h=${rows}&w=${cols}`, { method: 'POST' })
|
||||||
|
}
|
||||||
206
backend/src/routes/docker.ts
Normal file
206
backend/src/routes/docker.ts
Normal file
|
|
@ -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<string, unknown>) {
|
||||||
|
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<ReturnType<typeof openExecStream>>['socket'] | null = null
|
||||||
|
let host: ReturnType<typeof loadDockerHost> = 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<ContainerSummary[]>(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<string, { rx_bytes: number; tx_bytes: number }>
|
||||||
|
}>(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' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import { eventRoutes } from './routes/events.js'
|
||||||
import { terminalRoutes } from './routes/terminal.js'
|
import { terminalRoutes } from './routes/terminal.js'
|
||||||
import { tunnelRoutes } from './routes/tunnels.js'
|
import { tunnelRoutes } from './routes/tunnels.js'
|
||||||
import { fileRoutes } from './routes/files.js'
|
import { fileRoutes } from './routes/files.js'
|
||||||
|
import { dockerRoutes, dockerExecRoutes } from './routes/docker.js'
|
||||||
import { startAutoStartTunnels } from './tunnels/manager.js'
|
import { startAutoStartTunnels } from './tunnels/manager.js'
|
||||||
|
|
||||||
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
|
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
|
||||||
|
|
@ -40,6 +41,8 @@ await app.register(eventRoutes)
|
||||||
await app.register(terminalRoutes)
|
await app.register(terminalRoutes)
|
||||||
await app.register(tunnelRoutes)
|
await app.register(tunnelRoutes)
|
||||||
await app.register(fileRoutes)
|
await app.register(fileRoutes)
|
||||||
|
await app.register(dockerRoutes)
|
||||||
|
await app.register(dockerExecRoutes)
|
||||||
|
|
||||||
app.get('/api/health', async () => ({ ok: true }))
|
app.get('/api/health', async () => ({ ok: true }))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import BookNest from './pages/BookNest'
|
||||||
import Terminal from './pages/Terminal'
|
import Terminal from './pages/Terminal'
|
||||||
import Tunnels from './pages/Tunnels'
|
import Tunnels from './pages/Tunnels'
|
||||||
import Files from './pages/Files'
|
import Files from './pages/Files'
|
||||||
|
import Containers from './pages/Containers'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Enrollment from './pages/Enrollment'
|
import Enrollment from './pages/Enrollment'
|
||||||
|
|
@ -86,6 +87,7 @@ function Dashboard() {
|
||||||
<Route path="/terminal" element={<Terminal />} />
|
<Route path="/terminal" element={<Terminal />} />
|
||||||
<Route path="/tunnels" element={<Tunnels />} />
|
<Route path="/tunnels" element={<Tunnels />} />
|
||||||
<Route path="/files" element={<Files />} />
|
<Route path="/files" element={<Files />} />
|
||||||
|
<Route path="/containers" element={<Containers />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
Terminal,
|
Terminal,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Box,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
|
@ -25,6 +26,7 @@ const navItems = [
|
||||||
{ icon: Terminal, label: 'Terminal', route: '/terminal' },
|
{ icon: Terminal, label: 'Terminal', route: '/terminal' },
|
||||||
{ icon: Waypoints, label: 'Tunnels', route: '/tunnels' },
|
{ icon: Waypoints, label: 'Tunnels', route: '/tunnels' },
|
||||||
{ icon: FolderOpen, label: 'Files', route: '/files' },
|
{ icon: FolderOpen, label: 'Files', route: '/files' },
|
||||||
|
{ icon: Box, label: 'Containers', route: '/containers' },
|
||||||
{ icon: Settings, label: 'Settings', route: '/settings' },
|
{ icon: Settings, label: 'Settings', route: '/settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,20 @@ export const api = {
|
||||||
}
|
}
|
||||||
return res.json() as Promise<{ ok: boolean; path: string }>
|
return res.json() as Promise<{ ok: boolean; path: string }>
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listContainers: (integrationId: number) =>
|
||||||
|
apiFetch<{ containers: Container[] }>(`/docker/${integrationId}/containers`),
|
||||||
|
containerStats: (integrationId: number, id: string) =>
|
||||||
|
apiFetch<ContainerStats>(`/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 {
|
export interface AuthUser {
|
||||||
|
|
@ -204,6 +218,23 @@ export interface FileEntry {
|
||||||
mtime: number
|
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 {
|
export interface Resource {
|
||||||
name: string
|
name: string
|
||||||
status: 'healthy' | 'warning' | 'critical' | 'unknown'
|
status: 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||||
|
|
|
||||||
384
src/pages/Containers.tsx
Normal file
384
src/pages/Containers.tsx
Normal file
|
|
@ -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<Integration[]>([])
|
||||||
|
const [integrationId, setIntegrationId] = useState<number | ''>('')
|
||||||
|
const [containers, setContainers] = useState<Container[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
const [statsById, setStatsById] = useState<Record<string, ContainerStats>>({})
|
||||||
|
const [logsContainer, setLogsContainer] = useState<Container | null>(null)
|
||||||
|
const [execContainer, setExecContainer] = useState<Container | null>(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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold" style={{ color: TEXT_PRIMARY }}>
|
||||||
|
Containers
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
Manage Docker containers across your configured hosts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={integrationId}
|
||||||
|
onChange={(e) => setIntegrationId(Number(e.target.value))}
|
||||||
|
className="rounded-md border border-white/10 bg-transparent px-2 py-1.5 text-sm"
|
||||||
|
style={{ color: TEXT_PRIMARY }}
|
||||||
|
>
|
||||||
|
{hosts.length === 0 && <option value="">No Docker integrations</option>}
|
||||||
|
{hosts.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs"
|
||||||
|
style={{ border: '1px solid rgba(255,255,255,0.1)', color: TEXT_PRIMARY }}
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center justify-between rounded-md px-3 py-2 text-sm" style={{ backgroundColor: 'rgba(231,76,60,0.1)', color: '#E74C3C' }}>
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} style={{ color: TEXT_SECONDARY }}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={cardBase} className="overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ color: TEXT_SECONDARY, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Name</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Image</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">State</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">CPU</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Memory</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Ports</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{containers.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-3 py-6 text-center" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
{integrationId ? 'No containers found.' : 'Select a Docker integration to view containers.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{containers.map((c) => {
|
||||||
|
const stats = statsById[c.id]
|
||||||
|
const busy = busyId === c.id
|
||||||
|
return (
|
||||||
|
<tr key={c.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||||
|
<td className="px-3 py-2" style={{ color: TEXT_PRIMARY }}>
|
||||||
|
{c.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
{c.image}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full" style={{ background: stateColor(c.state) }} />
|
||||||
|
<span style={{ color: TEXT_PRIMARY }}>{c.status}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
{stats ? `${stats.cpuPercent.toFixed(1)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
{stats ? `${formatBytes(stats.memUsage)} / ${formatBytes(stats.memLimit)}` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
{c.ports.length === 0 ? '—' : c.ports.map((p) => `${p.publicPort ?? ''}${p.publicPort ? ':' : ''}${p.privatePort}/${p.type}`).join(', ')}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{c.state === 'running' ? (
|
||||||
|
<>
|
||||||
|
<button disabled={busy} onClick={() => runAction(c, 'pause')} title="Pause" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
<Pause size={14} />
|
||||||
|
</button>
|
||||||
|
<button disabled={busy} onClick={() => runAction(c, 'restart')} title="Restart" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
<RotateCw size={14} />
|
||||||
|
</button>
|
||||||
|
<button disabled={busy} onClick={() => runAction(c, 'stop')} title="Stop" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
<Square size={14} />
|
||||||
|
</button>
|
||||||
|
<button disabled={busy} onClick={() => setExecContainer(c)} title="Exec terminal" style={{ color: GOLD }}>
|
||||||
|
<TerminalSquare size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : c.state === 'paused' ? (
|
||||||
|
<button disabled={busy} onClick={() => runAction(c, 'unpause')} title="Unpause" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
<PlayCircle size={14} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button disabled={busy} onClick={() => runAction(c, 'start')} title="Start" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
<Play size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button disabled={busy} onClick={() => setLogsContainer(c)} title="Logs" style={{ color: TEXT_SECONDARY }}>
|
||||||
|
<ScrollText size={14} />
|
||||||
|
</button>
|
||||||
|
<button disabled={busy} onClick={() => removeContainer(c)} title="Remove" style={{ color: '#E74C3C' }}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logsContainer && integrationId && (
|
||||||
|
<LogsModal integrationId={integrationId} container={logsContainer} onClose={() => setLogsContainer(null)} />
|
||||||
|
)}
|
||||||
|
{execContainer && integrationId && (
|
||||||
|
<ExecModal integrationId={integrationId} container={execContainer} onClose={() => setExecContainer(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogsModal({ integrationId, container, onClose }: { integrationId: number; container: Container; onClose: () => void }) {
|
||||||
|
const [logs, setLogs] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6">
|
||||||
|
<div style={cardBase} className="flex h-[70vh] w-full max-w-3xl flex-col p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-medium" style={{ color: TEXT_PRIMARY }}>
|
||||||
|
Logs — {container.name}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={load} style={{ color: TEXT_SECONDARY }} title="Refresh">
|
||||||
|
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} style={{ color: TEXT_SECONDARY }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mb-2 text-xs" style={{ color: '#E74C3C' }}>{error}</p>}
|
||||||
|
<pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap rounded-md p-3 text-xs" style={{ backgroundColor: '#0A0A0C', color: TEXT_PRIMARY }}>
|
||||||
|
{logs || (loading ? 'Loading…' : 'No logs.')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExecModal({ integrationId, container, onClose }: { integrationId: number; container: Container; onClose: () => void }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const wsRef = useRef<WebSocket | null>(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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6">
|
||||||
|
<div style={cardBase} className="flex h-[70vh] w-full max-w-3xl flex-col p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-medium" style={{ color: TEXT_PRIMARY }}>
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full" style={{ background: connected ? '#2ECC71' : '#7A7D85' }} />
|
||||||
|
Exec — {container.name}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} style={{ color: TEXT_SECONDARY }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref={containerRef} className="min-h-0 flex-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue