* 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>
744 lines
29 KiB
TypeScript
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>
|
|
)
|
|
}
|