385 lines
15 KiB
TypeScript
385 lines
15 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 ContainerStats, type Integration } 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)',
|
||
|
|
}
|
||
|
|
|
||
|
|
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]}`
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function Containers() {
|
||
|
|
const [hosts, setHosts] = useState<Integration[]>([])
|
||
|
|
const [integrationId, setIntegrationId] = useState<number | ''>('')
|
||
|
|
const [containers, setContainers] = useState<Container[]>([])
|
||
|
|
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 [logsContainer, setLogsContainer] = useState<Container | null>(null)
|
||
|
|
const [execContainer, setExecContainer] = useState<Container | null>(null)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
api.listIntegrations().then(({ integrations }) => {
|
||
|
|
const dockerHosts = integrations.filter((i) => i.type === 'docker')
|
||
|
|
setHosts(dockerHosts)
|
||
|
|
if (dockerHosts.length > 0) setIntegrationId(dockerHosts[0].id)
|
||
|
|
})
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
function refresh() {
|
||
|
|
if (!integrationId) return
|
||
|
|
setLoading(true)
|
||
|
|
setError(null)
|
||
|
|
api
|
||
|
|
.listContainers(integrationId)
|
||
|
|
.then(({ containers }) => {
|
||
|
|
setContainers(containers)
|
||
|
|
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
|
||
|
|
}, [integrationId])
|
||
|
|
|
||
|
|
async function runAction(c: Container, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') {
|
||
|
|
if (!integrationId) return
|
||
|
|
setBusyId(c.id)
|
||
|
|
setError(null)
|
||
|
|
try {
|
||
|
|
await api.containerAction(integrationId, c.id, action)
|
||
|
|
refresh()
|
||
|
|
} catch (err) {
|
||
|
|
setError(err instanceof Error ? err.message : `Failed to ${action} container`)
|
||
|
|
} finally {
|
||
|
|
setBusyId(null)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function removeContainer(c: Container) {
|
||
|
|
if (!integrationId) return
|
||
|
|
if (!confirm(`Remove container "${c.name}"? This cannot be undone.`)) return
|
||
|
|
setBusyId(c.id)
|
||
|
|
setError(null)
|
||
|
|
try {
|
||
|
|
await api.removeContainer(integrationId, c.id, c.state === 'running')
|
||
|
|
refresh()
|
||
|
|
} catch (err) {
|
||
|
|
setError(err instanceof Error ? err.message : 'Failed to remove container')
|
||
|
|
} finally {
|
||
|
|
setBusyId(null)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 Docker containers across your configured hosts.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<select
|
||
|
|
value={integrationId}
|
||
|
|
onChange={(e) => setIntegrationId(Number(e.target.value))}
|
||
|
|
className="rounded-md border border-white/10 bg-transparent px-2 py-1.5 text-sm"
|
||
|
|
style={{ color: TEXT_PRIMARY }}
|
||
|
|
>
|
||
|
|
{hosts.length === 0 && <option value="">No Docker integrations</option>}
|
||
|
|
{hosts.map((h) => (
|
||
|
|
<option key={h.id} value={h.id}>
|
||
|
|
{h.name}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
<button
|
||
|
|
onClick={refresh}
|
||
|
|
className="flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs"
|
||
|
|
style={{ border: '1px solid rgba(255,255,255,0.1)', color: TEXT_PRIMARY }}
|
||
|
|
>
|
||
|
|
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} /> Refresh
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<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>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div style={cardBase} className="overflow-hidden">
|
||
|
|
<table className="w-full text-sm">
|
||
|
|
<thead>
|
||
|
|
<tr style={{ color: TEXT_SECONDARY, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||
|
|
<th className="px-3 py-2 text-left font-medium">Name</th>
|
||
|
|
<th className="px-3 py-2 text-left font-medium">Image</th>
|
||
|
|
<th className="px-3 py-2 text-left font-medium">State</th>
|
||
|
|
<th className="px-3 py-2 text-left font-medium">CPU</th>
|
||
|
|
<th className="px-3 py-2 text-left font-medium">Memory</th>
|
||
|
|
<th className="px-3 py-2 text-left font-medium">Ports</th>
|
||
|
|
<th className="px-3 py-2 text-right font-medium">Actions</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{containers.length === 0 && (
|
||
|
|
<tr>
|
||
|
|
<td colSpan={7} className="px-3 py-6 text-center" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
{integrationId ? 'No containers found.' : 'Select a Docker integration to view containers.'}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
{containers.map((c) => {
|
||
|
|
const stats = statsById[c.id]
|
||
|
|
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-2" style={{ color: TEXT_PRIMARY }}>
|
||
|
|
{c.name}
|
||
|
|
</td>
|
||
|
|
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
{c.image}
|
||
|
|
</td>
|
||
|
|
<td className="px-3 py-2">
|
||
|
|
<span className="flex items-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-2" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
{stats ? `${stats.cpuPercent.toFixed(1)}%` : '—'}
|
||
|
|
</td>
|
||
|
|
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
{stats ? `${formatBytes(stats.memUsage)} / ${formatBytes(stats.memLimit)}` : '—'}
|
||
|
|
</td>
|
||
|
|
<td className="px-3 py-2" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
{c.ports.length === 0 ? '—' : c.ports.map((p) => `${p.publicPort ?? ''}${p.publicPort ? ':' : ''}${p.privatePort}/${p.type}`).join(', ')}
|
||
|
|
</td>
|
||
|
|
<td className="px-3 py-2">
|
||
|
|
<div className="flex items-center justify-end gap-1.5">
|
||
|
|
{c.state === 'running' ? (
|
||
|
|
<>
|
||
|
|
<button disabled={busy} onClick={() => runAction(c, 'pause')} title="Pause" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<Pause size={14} />
|
||
|
|
</button>
|
||
|
|
<button disabled={busy} onClick={() => runAction(c, 'restart')} title="Restart" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<RotateCw size={14} />
|
||
|
|
</button>
|
||
|
|
<button disabled={busy} onClick={() => runAction(c, 'stop')} title="Stop" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<Square size={14} />
|
||
|
|
</button>
|
||
|
|
<button disabled={busy} onClick={() => setExecContainer(c)} title="Exec terminal" style={{ color: GOLD }}>
|
||
|
|
<TerminalSquare size={14} />
|
||
|
|
</button>
|
||
|
|
</>
|
||
|
|
) : c.state === 'paused' ? (
|
||
|
|
<button disabled={busy} onClick={() => runAction(c, 'unpause')} title="Unpause" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<PlayCircle size={14} />
|
||
|
|
</button>
|
||
|
|
) : (
|
||
|
|
<button disabled={busy} onClick={() => runAction(c, 'start')} title="Start" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<Play size={14} />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
<button disabled={busy} onClick={() => setLogsContainer(c)} title="Logs" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<ScrollText size={14} />
|
||
|
|
</button>
|
||
|
|
<button disabled={busy} onClick={() => removeContainer(c)} title="Remove" style={{ color: '#E74C3C' }}>
|
||
|
|
<Trash2 size={14} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{logsContainer && integrationId && (
|
||
|
|
<LogsModal integrationId={integrationId} container={logsContainer} onClose={() => setLogsContainer(null)} />
|
||
|
|
)}
|
||
|
|
{execContainer && integrationId && (
|
||
|
|
<ExecModal integrationId={integrationId} container={execContainer} onClose={() => setExecContainer(null)} />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function LogsModal({ integrationId, container, onClose }: { integrationId: number; container: Container; onClose: () => void }) {
|
||
|
|
const [logs, setLogs] = useState('')
|
||
|
|
const [loading, setLoading] = useState(true)
|
||
|
|
const [error, setError] = useState<string | null>(null)
|
||
|
|
|
||
|
|
function load() {
|
||
|
|
setLoading(true)
|
||
|
|
setError(null)
|
||
|
|
api
|
||
|
|
.containerLogs(integrationId, container.id)
|
||
|
|
.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 — {container.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({ integrationId, container, onClose }: { integrationId: number; container: Container; 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 token = getToken()
|
||
|
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||
|
|
const ws = new WebSocket(`${proto}://${window.location.host}/api/docker/exec?token=${encodeURIComponent(token ?? '')}`)
|
||
|
|
wsRef.current = ws
|
||
|
|
|
||
|
|
ws.onopen = () => {
|
||
|
|
ws.send(JSON.stringify({ type: 'connect', integrationId, containerId: container.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') {
|
||
|
|
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) {
|
||
|
|
ws.send(JSON.stringify({ type: 'input', 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 — {container.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>
|
||
|
|
)
|
||
|
|
}
|