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>
|
|
)
|
|
}
|