diff --git a/app/docker/[name]/page.tsx b/app/docker/[name]/page.tsx new file mode 100644 index 0000000..dcf9fad --- /dev/null +++ b/app/docker/[name]/page.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link' +import { redirect } from 'next/navigation' +import { getCurrentUser } from '@/lib/session' + +export const dynamic = 'force-dynamic' + +type Props = { + params: Promise<{ name: string }> +} + +export default async function DockerDetailPage({ params }: Props) { + const user = await getCurrentUser() + if (!user) redirect('/login') + + const { name } = await params + const containerName = decodeURIComponent(name) + + return ( +
+
+
+ + ← Containers + + / +

{containerName}

+
+
+ Log viewer coming in Story 3. +
+
+
+ ) +} diff --git a/app/docker/_components/docker-table.tsx b/app/docker/_components/docker-table.tsx new file mode 100644 index 0000000..8c6b2a4 --- /dev/null +++ b/app/docker/_components/docker-table.tsx @@ -0,0 +1,169 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import Link from 'next/link' +import { type Container, parseDockerPs } from '@/lib/parse-docker' + +async function fetchContainers(): Promise { + const res = await fetch('/api/agent/exec', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command_key: 'docker_ps' }), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`agent ${res.status}: ${text}`) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('no response body') + + const decoder = new TextDecoder() + let buffer = '' + let output = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (line.startsWith('data:')) { + try { + const parsed = JSON.parse(line.slice(5).trim()) as { data?: string } + if (parsed.data !== undefined) output += parsed.data + } catch { + // ignore malformed + } + } + } + } + + return parseDockerPs(output) +} + +function statusBadge(status: string) { + const up = status.toLowerCase().startsWith('up') + return ( + + + {status} + + ) +} + +type Props = { + initialContainers: Container[] + initialError: string | null +} + +export default function DockerTable({ initialContainers, initialError }: Props) { + const [containers, setContainers] = useState(initialContainers) + const [error, setError] = useState(initialError) + const [refreshing, setRefreshing] = useState(false) + const [lastUpdated, setLastUpdated] = useState(new Date()) + + const refresh = useCallback(async () => { + setRefreshing(true) + try { + const data = await fetchContainers() + setContainers(data) + setError(null) + setLastUpdated(new Date()) + } catch (err) { + setError(err instanceof Error ? err.message : 'refresh failed') + } finally { + setRefreshing(false) + } + }, []) + + useEffect(() => { + const id = setInterval(refresh, 5000) + return () => clearInterval(id) + }, [refresh]) + + return ( +
+
+
+ + {containers.length} container{containers.length !== 1 ? 's' : ''} + + {refreshing && ( + refreshing… + )} +
+ + updated {lastUpdated.toLocaleTimeString()} + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + + + + + + + {containers.length === 0 && !error ? ( + + + + ) : ( + containers.map((c) => ( + + + + + + + + )) + )} + +
NameImageStatusPortsUptime
+ No containers running +
+ + {c.name} + + {c.image}{statusBadge(c.status)} + {c.ports || '—'} + {c.created}
+
+
+ ) +} diff --git a/app/docker/page.tsx b/app/docker/page.tsx new file mode 100644 index 0000000..529167b --- /dev/null +++ b/app/docker/page.tsx @@ -0,0 +1,34 @@ +import { redirect } from 'next/navigation' +import { getCurrentUser } from '@/lib/session' +import { execAgent } from '@/lib/agent-client' +import { parseDockerPs, type Container } from '@/lib/parse-docker' +import DockerTable from './_components/docker-table' + +export const dynamic = 'force-dynamic' + +export default async function DockerPage() { + const user = await getCurrentUser() + if (!user) redirect('/login') + + let initialContainers: Container[] = [] + let initialError: string | null = null + + try { + const output = await execAgent('docker_ps') + initialContainers = parseDockerPs(output) + } catch (err) { + initialError = err instanceof Error ? err.message : 'Failed to fetch containers' + } + + return ( +
+
+
+

Docker Containers

+

Auto-refreshes every 5 seconds

+
+ +
+
+ ) +} diff --git a/lib/agent-client.ts b/lib/agent-client.ts new file mode 100644 index 0000000..dda7a43 --- /dev/null +++ b/lib/agent-client.ts @@ -0,0 +1,58 @@ +import 'server-only' + +const AGENT_URL = process.env.OPS_AGENT_URL ?? 'http://127.0.0.1:3099' +const AGENT_SECRET = process.env.OPS_AGENT_SECRET ?? '' + +export async function execAgent( + commandKey: string, + args: string[] = [], + onChunk?: (chunk: string) => void, +): Promise { + const response = await fetch(`${AGENT_URL}/agent/v1/exec`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AGENT_SECRET}`, + }, + body: JSON.stringify({ command_key: commandKey, args }), + cache: 'no-store', + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`agent error ${response.status}: ${text}`) + } + + const reader = response.body?.getReader() + if (!reader) throw new Error('no response body') + + const decoder = new TextDecoder() + let buffer = '' + let output = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (line.startsWith('data:')) { + const jsonStr = line.slice(5).trim() + try { + const parsed = JSON.parse(jsonStr) as { data?: string } + if (parsed.data !== undefined) { + output += parsed.data + onChunk?.(parsed.data) + } + } catch { + // skip malformed SSE data + } + } + } + } + + return output +} diff --git a/lib/parse-docker.ts b/lib/parse-docker.ts new file mode 100644 index 0000000..635dfff --- /dev/null +++ b/lib/parse-docker.ts @@ -0,0 +1,40 @@ +export type Container = { + id: string + image: string + command: string + created: string + status: string + ports: string + name: string +} + +// Parse the fixed-width table output of `docker ps --format table` +export function parseDockerPs(output: string): Container[] { + const lines = output.trim().split('\n').filter(Boolean) + if (lines.length < 2) return [] + + const header = lines[0] + + const COLS = ['CONTAINER ID', 'IMAGE', 'COMMAND', 'CREATED', 'STATUS', 'PORTS', 'NAMES'] as const + const positions = COLS.map((col) => header.indexOf(col)) + + // Must find at least NAMES (last) to produce useful output + if (positions[6] === -1) return [] + + const extract = (line: string, i: number): string => { + const start = positions[i] + if (start === -1) return '' + const nextIdx = positions.slice(i + 1).find((p) => p !== -1) + return line.slice(start, nextIdx).trim() + } + + return lines.slice(1).map((line) => ({ + id: extract(line, 0), + image: extract(line, 1), + command: extract(line, 2), + created: extract(line, 3), + status: extract(line, 4), + ports: extract(line, 5), + name: extract(line, 6), + })) +}