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 SshContainer, type AgentContainer, type ContainerStats, } 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)', } // docker = Engine TCP API; ssh = `docker` CLI over SSH; agent = pushed report. // docker/ssh support management; agent is read-only monitoring. type Source = 'docker' | 'ssh' | 'agent' /** A selectable container host. For docker/ssh it wraps an integration id; for * agent it wraps the string hostId of a reporting agent. */ interface HostOption { source: Source /** integration id (docker/ssh) or agent hostId (agent), as a string key. */ key: string label: string /** numeric integration id for docker/ssh sources. */ integrationId?: number /** agent hostId for agent sources. */ agentHostId?: string } /** Unified table row across all three sources. */ interface Row { id: string name: string image: string state: string status: string ports: string /** Stats embedded in agent reports (docker/ssh fetch stats separately/none). */ embeddedStats?: ContainerStats } function toRowFromDocker(c: Container): Row { return { id: c.id, name: c.name, image: c.image, state: c.state, status: c.status, ports: c.ports.length === 0 ? '' : c.ports.map((p) => `${p.publicPort ?? ''}${p.publicPort ? ':' : ''}${p.privatePort}/${p.type}`).join(', '), } } function toRowFromSsh(c: SshContainer): Row { return { id: c.id, name: c.name, image: c.image, state: c.state.toLowerCase(), status: c.status, ports: c.ports } } function toRowFromAgent(c: AgentContainer): Row { const ports = c.ports .map((p) => `${p.hostPort ? `${p.hostPort}:` : ''}${p.containerPort}/${p.proto}`) .join(', ') const embeddedStats: ContainerStats | undefined = c.stats ? { cpuPercent: c.stats.cpuPercent ?? 0, memUsage: c.stats.memUsage ?? 0, memLimit: c.stats.memLimit ?? 0, netRx: c.stats.netRxBytes ?? 0, netTx: c.stats.netTxBytes ?? 0, } : undefined return { id: c.id, name: c.name, image: c.image, state: c.state.toLowerCase(), status: c.status, ports, embeddedStats } } 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]}` } /** A dynamic detail tab opened by clicking a container name. */ interface DetailTab { tabId: string source: Source integrationId?: number agentHostId?: string containerId: string containerName: string } export default function Containers() { const [hostOptions, setHostOptions] = useState([]) const [selectedKey, setSelectedKey] = useState('') const [rows, setRows] = useState([]) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [busyId, setBusyId] = useState(null) const [statsById, setStatsById] = useState>({}) const [logsRow, setLogsRow] = useState(null) const [execRow, setExecRow] = useState(null) // Intra-page tabs: the containers list plus any opened container-detail tabs. const [detailTabs, setDetailTabs] = useState([]) const [activeTab, setActiveTab] = useState('list') const selected = hostOptions.find((h) => h.key === selectedKey) const source: Source | null = selected?.source ?? null const canManage = source === 'docker' || source === 'ssh' async function loadHosts() { const [{ integrations }, agentRes] = await Promise.all([ api.listIntegrations(), api.listAgentHosts().catch(() => ({ hosts: [] })), ]) const opts: HostOption[] = [] for (const i of integrations) { if (i.type === 'docker') opts.push({ source: 'docker', key: `docker:${i.id}`, label: `${i.name} (Docker API)`, integrationId: i.id }) if (i.type === 'ssh') opts.push({ source: 'ssh', key: `ssh:${i.id}`, label: `${i.name} (SSH)`, integrationId: i.id }) } for (const h of agentRes.hosts) { const label = `${h.hostname || h.hostId} (Agent${h.stale ? ' — stale' : ''})` opts.push({ source: 'agent', key: `agent:${h.hostId}`, label, agentHostId: h.hostId }) } setHostOptions(opts) if (opts.length > 0 && !opts.some((o) => o.key === selectedKey)) setSelectedKey(opts[0].key) } useEffect(() => { loadHosts() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) function refresh() { if (!selected) return setLoading(true) setError(null) setStatsById({}) if (selected.source === 'agent' && selected.agentHostId) { api .listAgentContainers(selected.agentHostId) .then(({ containers }) => setRows(containers.map(toRowFromAgent))) .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load agent report')) .finally(() => setLoading(false)) return } if (selected.source === 'ssh' && selected.integrationId) { api .listSshContainers(selected.integrationId) .then(({ containers }) => setRows(containers.map(toRowFromSsh))) .catch((err) => setError(err instanceof Error ? err.message : 'Failed to list containers')) .finally(() => setLoading(false)) return } if (selected.source === 'docker' && selected.integrationId) { const integrationId = selected.integrationId api .listContainers(integrationId) .then(({ containers }) => { setRows(containers.map(toRowFromDocker)) 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 }, [selectedKey]) async function runAction(c: Row, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') { if (!selected?.integrationId) return setBusyId(c.id) setError(null) try { if (selected.source === 'ssh') await api.sshContainerAction(selected.integrationId, c.id, action) else await api.containerAction(selected.integrationId, c.id, action) refresh() } catch (err) { setError(err instanceof Error ? err.message : `Failed to ${action} container`) } finally { setBusyId(null) } } async function removeRow(c: Row) { if (!selected?.integrationId) return if (!confirm(`Remove container "${c.name}"? This cannot be undone.`)) return setBusyId(c.id) setError(null) try { if (selected.source === 'ssh') await api.removeSshContainer(selected.integrationId, c.id, c.state === 'running') else await api.removeContainer(selected.integrationId, c.id, c.state === 'running') refresh() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to remove container') } finally { setBusyId(null) } } function openDetail(c: Row) { if (!selected) return const tabId = `${selected.key}::${c.id}` setDetailTabs((prev) => (prev.some((t) => t.tabId === tabId) ? prev : [ ...prev, { tabId, source: selected.source, integrationId: selected.integrationId, agentHostId: selected.agentHostId, containerId: c.id, containerName: c.name, }, ])) setActiveTab(tabId) } function closeDetail(tabId: string) { setDetailTabs((prev) => prev.filter((t) => t.tabId !== tabId)) setActiveTab((cur) => (cur === tabId ? 'list' : cur)) } return (

Containers

Manage and monitor Docker containers — via the Docker Engine API, the docker CLI over SSH, or a reporting agent.

{activeTab === 'list' && (
)}
{/* Intra-page tab bar */}
setActiveTab('list')} /> {detailTabs.map((t) => ( setActiveTab(t.tabId)} onClose={() => closeDetail(t.tabId)} /> ))}
{error && activeTab === 'list' && (
{error}
)} {activeTab === 'list' ? (
{rows.length === 0 && ( )} {rows.map((c) => { const stats = statsById[c.id] ?? c.embeddedStats const busy = busyId === c.id return ( ) })}
Name Image State CPU Memory Ports Actions
{selected ? 'No containers found.' : 'Select a container host.'}
{c.image} {c.status} {stats ? `${stats.cpuPercent.toFixed(1)}%` : '—'} {stats ? `${formatBytes(stats.memUsage)} / ${formatBytes(stats.memLimit)}` : '—'} {c.ports || '—'}
{canManage ? ( <> {c.state === 'running' ? ( <> ) : c.state === 'paused' ? ( ) : ( )} ) : ( read-only )}
) : ( (() => { const tab = detailTabs.find((t) => t.tabId === activeTab) if (!tab) return null return })() )} {logsRow && selected?.integrationId && (source === 'docker' || source === 'ssh') && ( setLogsRow(null)} /> )} {execRow && selected?.integrationId && (source === 'docker' || source === 'ssh') && ( setExecRow(null)} /> )}
) } function TabButton({ label, active, onClick, onClose }: { label: string; active: boolean; onClick: () => void; onClose?: () => void }) { return (
{label} {onClose && ( { e.stopPropagation() onClose() }} /> )}
) } function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { return (
{label} {value}
) } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
) } /** * Container detail tab. Agent reports carry the full inspect+stats payload, so * for agent hosts we render everything. For docker/ssh sources we currently * only have the list row data, so we show what we have and note the rest is * available from an agent — graceful degradation per the design. */ function ContainerDetail({ tab }: { tab: DetailTab }) { const [container, setContainer] = useState(null) const [loading, setLoading] = useState(tab.source === 'agent') const [error, setError] = useState(null) useEffect(() => { if (tab.source !== 'agent' || !tab.agentHostId) return setLoading(true) api .getAgentContainer(tab.agentHostId, tab.containerId) .then(({ container }) => setContainer(container)) .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load container detail')) .finally(() => setLoading(false)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab.tabId]) if (tab.source !== 'agent') { return (

{tab.containerName}

Rich container detail (inspect data, mounts, networks, environment) is provided by the monitoring agent. This host is a{' '} {tab.source === 'ssh' ? 'Docker-over-SSH' : 'Docker API'} source — use the list view actions for management, or install the ArchNest agent on this host for full detail.

) } if (loading) return

Loading…

if (error) return

{error}

if (!container) return

No data.

const c = container const masked = (v: string) => v return (
{c.imageId && {c.imageId.slice(0, 19)}} />} {c.id.slice(0, 12)}} /> {c.command && {c.command}} />} {c.createdAt && } {c.startedAt && }
{c.status || c.state} } /> {c.health && c.health !== 'none' && } {c.restartPolicy && }
{c.stats && (
)}
{c.ports.length === 0 ? (

None published.

) : ( c.ports.map((p, i) => ( )) )}
{c.networks.length === 0 ? (

None.

) : ( c.networks.map((n, i) => ) )}
{c.mounts.length === 0 ? (

None.

) : ( c.mounts.map((m, i) => ( )) )}
{c.env.length === 0 ? (

None.

) : ( c.env.map((e, i) => {masked(e.value)}} />) )}
{c.labels && Object.keys(c.labels).length > 0 && (
{Object.entries(c.labels).map(([k, v]) => ( {v}} /> ))}
)}
) } function LogsModal({ source, integrationId, row, onClose }: { source: 'docker' | 'ssh'; integrationId: number; row: Row; onClose: () => void }) { const [logs, setLogs] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) function load() { setLoading(true) setError(null) const req = source === 'ssh' ? api.sshContainerLogs(integrationId, row.id) : api.containerLogs(integrationId, row.id) req .then(({ logs }) => setLogs(logs)) .catch((err) => setError(err instanceof Error ? err.message : 'Failed to fetch logs')) .finally(() => setLoading(false)) } useEffect(load, []) return (

Logs — {row.name}

{error &&

{error}

}
          {logs || (loading ? 'Loading…' : 'No logs.')}
        
) } function ExecModal({ source, integrationId, row, onClose }: { source: 'docker' | 'ssh'; integrationId: number; row: Row; 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 isSsh = source === 'ssh' const token = getToken() const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' const path = isSsh ? '/api/docker-ssh/exec' : '/api/docker/exec' const ws = new WebSocket(`${proto}://${window.location.host}${path}?token=${encodeURIComponent(token ?? '')}`) wsRef.current = ws ws.onopen = () => { ws.send(JSON.stringify({ type: 'connect', integrationId, containerId: row.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') { if (isSsh) term.write(msg.data as string) else 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) return ws.send(JSON.stringify({ type: 'input', data: isSsh ? 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 — {row.name}

) }