dev_arc_aws/src/pages/Containers.tsx
Samuel James ae066a738c
Containers/Infrastructure styling fixes + Terminal Nerd Font fallback (#36)
* Add mesh prerequisite gate (NetBird verification before app config)

Implements the design in docs/mesh-prerequisite-gate.md per the user's
DECIDE A-D answers: a permanent admin override, B1 (reachable) verification
with host mesh IP shown informationally, members allowed in with a notice
instead of being blocked, and mesh.required defaulting off so the live
production instance is unaffected.

- system_config kv table + getConfig/setConfig helpers
- /api/system/mesh-status, /mesh/verify, /mesh/override, /mesh/required
- AuthContext gains a 'needs-mesh' status (admins only) and exposes
  meshStatus for a member-facing banner
- MeshGate page reuses the integration create+test flow to connect NetBird

* Make mesh verification universal (CIDR check, not NetBird-specific)

Replace the NetBird-adapter-based "reachable" check with a vendor-agnostic
one: the admin supplies the mesh's IP range (CIDR), and verification just
confirms this host has an address inside it. Works identically for
NetBird, WireGuard, ZeroTier, Tailscale, or any other mesh tech, with no
integration record or vendor API call required.

* Add reachability fallback for routed meshes (VPC peering, etc.)

A host can be on the mesh's "side" of a routed network (e.g. a VPC peered
into a NetBird/WireGuard mesh) without holding a local IP in the mesh's
own CIDR. Local-IP-in-CIDR stays the primary check; if it fails, the admin
can supply a known peer/gateway IP on the mesh and we verify by pinging
it instead. Adds iputils to the backend image for the ping binary.

* Add Mesh section to Settings for configuring/testing the mesh gate

Admins can now toggle mesh.required, run verify/override, and see
current mesh status entirely from the app, without hitting the API
directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hu9pZvJY4BgmcQeAw2ugk

* Show a host-specific Docker remote-API setup script in Settings

When adding/editing a Docker integration with a tcp:// or http:// remote
URL, display a copyable systemd override + curl verification script
scoped to the entered host:port, so enabling the daemon's API doesn't
require looking up the steps separately.

* Expand Help page with quick-start guide and real-world examples

Adds a quick-start ordering card and per-feature example callouts (with icons) so first-time users see concrete use cases, not just descriptions.

* Update HANDOFF/README for handoff: mesh gate shipped, Docker UX work, no feature queued

Corrects the stale 'mesh gate not built' framing (it shipped across 4 commits, all merged) and documents the Docker setup-script hint + Help page expansion done this session. Leaves a clear next-task list for the picking-up agent: decide on merging claude/youthful-cerf-ibvxfb, then check with the user for the next priority.

* Improve Containers table/tab readability: bold centered headers, taller rows, filing-cabinet tabs

* Make Node Status card scrollable with a 5-column layout and invisible-by-default scrollbar

* Add Nerd Font icon fallback to the Terminal so Starship-style prompts render correctly

Bundles Symbols Nerd Font Mono (MIT, ryanoasis/nerd-fonts) as a glyph-only @font-face and appends it to every Terminal font-family option, so distro icons / git branch glyphs / etc. from prompts like Starship show up instead of broken-glyph boxes. It carries no letterforms, so it never changes how normal text renders.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-21 05:01:39 -04:00

744 lines
29 KiB
TypeScript

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<HostOption[]>([])
const [selectedKey, setSelectedKey] = useState<string>('')
const [rows, setRows] = useState<Row[]>([])
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 [logsRow, setLogsRow] = useState<Row | null>(null)
const [execRow, setExecRow] = useState<Row | null>(null)
// Intra-page tabs: the containers list plus any opened container-detail tabs.
const [detailTabs, setDetailTabs] = useState<DetailTab[]>([])
const [activeTab, setActiveTab] = useState<string>('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 (
<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 and monitor Docker containers via the Docker Engine API, the
<code> docker</code> CLI over SSH, or a reporting agent.
</p>
</div>
{activeTab === 'list' && (
<div className="flex items-center gap-2">
<select
value={selectedKey}
onChange={(e) => setSelectedKey(e.target.value)}
className="rounded-md border border-white/10 bg-transparent px-2 py-1.5 text-sm"
style={{ color: TEXT_PRIMARY }}
>
{hostOptions.length === 0 && <option value="">No container hosts</option>}
{hostOptions.map((h) => (
<option key={h.key} value={h.key}>
{h.label}
</option>
))}
</select>
<button
onClick={refresh}
className="flex items-center gap-1 rounded-md px-3.5 py-1.5 text-xs"
style={{ border: '1px solid rgba(200,164,52,0.2)', color: TEXT_PRIMARY }}
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</div>
)}
</div>
{/* Intra-page tab bar */}
<div className="flex items-end gap-1 border-b border-white/10">
<TabButton label="Containers" active={activeTab === 'list'} onClick={() => setActiveTab('list')} />
{detailTabs.map((t) => (
<TabButton
key={t.tabId}
label={t.containerName}
active={activeTab === t.tabId}
onClick={() => setActiveTab(t.tabId)}
onClose={() => closeDetail(t.tabId)}
/>
))}
</div>
{error && activeTab === 'list' && (
<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>
)}
{activeTab === 'list' ? (
<div style={cardBase} className="overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr style={{ color: TEXT_PRIMARY, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<th className="px-3 py-3 text-center text-base font-bold">Name</th>
<th className="px-3 py-3 text-center text-base font-bold">Image</th>
<th className="px-3 py-3 text-center text-base font-bold">State</th>
<th className="px-3 py-3 text-center text-base font-bold">CPU</th>
<th className="px-3 py-3 text-center text-base font-bold">Memory</th>
<th className="px-3 py-3 text-center text-base font-bold">Ports</th>
<th className="px-3 py-3 text-center text-base font-bold">Actions</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={7} className="px-3 py-6 text-center" style={{ color: TEXT_SECONDARY }}>
{selected ? 'No containers found.' : 'Select a container host.'}
</td>
</tr>
)}
{rows.map((c) => {
const stats = statsById[c.id] ?? c.embeddedStats
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-4 text-center text-[15px]">
<button
onClick={() => openDetail(c)}
className="hover:underline"
style={{ color: GOLD }}
title="View details"
>
{c.name}
</button>
</td>
<td className="px-3 py-4 text-center text-[15px]" style={{ color: TEXT_SECONDARY }}>
{c.image}
</td>
<td className="px-3 py-4 text-center text-[15px]">
<span className="flex items-center justify-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-4 text-center text-[15px]" style={{ color: TEXT_SECONDARY }}>
{stats ? `${stats.cpuPercent.toFixed(1)}%` : '—'}
</td>
<td className="px-3 py-4 text-center text-[15px]" style={{ color: TEXT_SECONDARY }}>
{stats ? `${formatBytes(stats.memUsage)} / ${formatBytes(stats.memLimit)}` : '—'}
</td>
<td className="px-3 py-4 text-center text-[15px]" style={{ color: TEXT_SECONDARY }}>
{c.ports || '—'}
</td>
<td className="px-3 py-4 text-[15px]">
<div className="flex items-center justify-center gap-2">
{canManage ? (
<>
{c.state === 'running' ? (
<>
<button disabled={busy} onClick={() => runAction(c, 'pause')} title="Pause" style={{ color: TEXT_SECONDARY }}>
<Pause size={16} />
</button>
<button disabled={busy} onClick={() => runAction(c, 'restart')} title="Restart" style={{ color: TEXT_SECONDARY }}>
<RotateCw size={16} />
</button>
<button disabled={busy} onClick={() => runAction(c, 'stop')} title="Stop" style={{ color: TEXT_SECONDARY }}>
<Square size={16} />
</button>
<button disabled={busy} onClick={() => setExecRow(c)} title="Exec terminal" style={{ color: GOLD }}>
<TerminalSquare size={16} />
</button>
</>
) : c.state === 'paused' ? (
<button disabled={busy} onClick={() => runAction(c, 'unpause')} title="Unpause" style={{ color: TEXT_SECONDARY }}>
<PlayCircle size={16} />
</button>
) : (
<button disabled={busy} onClick={() => runAction(c, 'start')} title="Start" style={{ color: TEXT_SECONDARY }}>
<Play size={16} />
</button>
)}
<button disabled={busy} onClick={() => setLogsRow(c)} title="Logs" style={{ color: TEXT_SECONDARY }}>
<ScrollText size={16} />
</button>
<button disabled={busy} onClick={() => removeRow(c)} title="Remove" style={{ color: '#E74C3C' }}>
<Trash2 size={16} />
</button>
</>
) : (
<span className="text-xs" style={{ color: TEXT_SECONDARY }}>read-only</span>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
(() => {
const tab = detailTabs.find((t) => t.tabId === activeTab)
if (!tab) return null
return <ContainerDetail key={tab.tabId} tab={tab} />
})()
)}
{logsRow && selected?.integrationId && (source === 'docker' || source === 'ssh') && (
<LogsModal source={source} integrationId={selected.integrationId} row={logsRow} onClose={() => setLogsRow(null)} />
)}
{execRow && selected?.integrationId && (source === 'docker' || source === 'ssh') && (
<ExecModal source={source} integrationId={selected.integrationId} row={execRow} onClose={() => setExecRow(null)} />
)}
</div>
)
}
function TabButton({ label, active, onClick, onClose }: { label: string; active: boolean; onClick: () => void; onClose?: () => void }) {
return (
<div
onClick={onClick}
className="flex cursor-pointer items-center gap-2 rounded-t-md px-4 py-2 text-sm font-semibold"
style={{
color: active ? GOLD : TEXT_SECONDARY,
backgroundColor: active ? 'rgba(200,164,52,0.12)' : 'rgba(255,255,255,0.02)',
border: active ? '1px solid rgba(200,164,52,0.35)' : '1px solid rgba(255,255,255,0.06)',
borderBottom: active ? '1px solid rgba(200,164,52,0.12)' : '1px solid rgba(255,255,255,0.06)',
marginBottom: '-1px',
maxWidth: '200px',
}}
>
<span className="truncate">{label}</span>
{onClose && (
<X
size={13}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
/>
)}
</div>
)
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex gap-3 py-1 text-sm">
<span className="w-40 shrink-0" style={{ color: TEXT_SECONDARY }}>{label}</span>
<span className="min-w-0 break-words" style={{ color: TEXT_PRIMARY }}>{value}</span>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={cardBase} className="p-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide" style={{ color: TEXT_SECONDARY }}>{title}</h3>
{children}
</div>
)
}
/**
* 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<AgentContainer | null>(null)
const [loading, setLoading] = useState(tab.source === 'agent')
const [error, setError] = useState<string | null>(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 (
<div style={cardBase} className="p-4">
<p className="text-sm" style={{ color: TEXT_PRIMARY }}>{tab.containerName}</p>
<p className="mt-2 text-sm" style={{ color: TEXT_SECONDARY }}>
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.
</p>
</div>
)
}
if (loading) return <p className="text-sm" style={{ color: TEXT_SECONDARY }}>Loading</p>
if (error) return <p className="text-sm" style={{ color: '#E74C3C' }}>{error}</p>
if (!container) return <p className="text-sm" style={{ color: TEXT_SECONDARY }}>No data.</p>
const c = container
const masked = (v: string) => v
return (
<div className="grid gap-4" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))' }}>
<Section title="Overview">
<DetailRow label="Name" value={c.name} />
<DetailRow label="Image" value={c.image} />
{c.imageId && <DetailRow label="Image ID" value={<code className="text-xs">{c.imageId.slice(0, 19)}</code>} />}
<DetailRow label="Container ID" value={<code className="text-xs">{c.id.slice(0, 12)}</code>} />
{c.command && <DetailRow label="Command" value={<code className="text-xs">{c.command}</code>} />}
{c.createdAt && <DetailRow label="Created" value={new Date(c.createdAt).toLocaleString()} />}
{c.startedAt && <DetailRow label="Started" value={new Date(c.startedAt).toLocaleString()} />}
</Section>
<Section title="State & Health">
<DetailRow
label="State"
value={
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full" style={{ background: stateColor(c.state.toLowerCase()) }} />
{c.status || c.state}
</span>
}
/>
{c.health && c.health !== 'none' && <DetailRow label="Health" value={c.health} />}
<DetailRow label="Restart count" value={String(c.restartCount ?? 0)} />
{c.restartPolicy && <DetailRow label="Restart policy" value={c.restartPolicy} />}
</Section>
{c.stats && (
<Section title="Stats (snapshot)">
<DetailRow label="CPU" value={`${(c.stats.cpuPercent ?? 0).toFixed(1)}%`} />
<DetailRow label="Memory" value={`${formatBytes(c.stats.memUsage ?? 0)} / ${formatBytes(c.stats.memLimit ?? 0)}`} />
<DetailRow label="Network" value={`${formatBytes(c.stats.netRxBytes ?? 0)}${formatBytes(c.stats.netTxBytes ?? 0)}`} />
<DetailRow label="Block I/O" value={`R ${formatBytes(c.stats.blockReadBytes ?? 0)} W ${formatBytes(c.stats.blockWriteBytes ?? 0)}`} />
</Section>
)}
<Section title="Ports">
{c.ports.length === 0 ? (
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>None published.</p>
) : (
c.ports.map((p, i) => (
<DetailRow key={i} label={`${p.containerPort}/${p.proto}`} value={p.hostPort ? `${p.hostIp || '0.0.0.0'}:${p.hostPort}` : 'not published'} />
))
)}
</Section>
<Section title="Networks">
{c.networks.length === 0 ? (
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>None.</p>
) : (
c.networks.map((n, i) => <DetailRow key={i} label={n.name} value={n.ip || '—'} />)
)}
</Section>
<Section title="Mounts">
{c.mounts.length === 0 ? (
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>None.</p>
) : (
c.mounts.map((m, i) => (
<DetailRow key={i} label={m.destination || '—'} value={`${m.source || ''}${m.rw === false ? ' (ro)' : ''}`} />
))
)}
</Section>
<Section title="Environment">
{c.env.length === 0 ? (
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>None.</p>
) : (
c.env.map((e, i) => <DetailRow key={i} label={e.key} value={<code className="text-xs">{masked(e.value)}</code>} />)
)}
</Section>
{c.labels && Object.keys(c.labels).length > 0 && (
<Section title="Labels">
{Object.entries(c.labels).map(([k, v]) => (
<DetailRow key={k} label={k} value={<span className="text-xs">{v}</span>} />
))}
</Section>
)}
</div>
)
}
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<string | null>(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 (
<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 {row.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({ source, integrationId, row, onClose }: { source: 'docker' | 'ssh'; integrationId: number; row: Row; 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 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 (
<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 {row.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>
)
}