436 lines
18 KiB
TypeScript
436 lines
18 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { ADMIN_API_BASE } from '@/lib/api'
|
|
|
|
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
|
|
|
|
interface CompanyContainer {
|
|
id: string
|
|
companyId: string
|
|
dockerId: string | null
|
|
containerName: string
|
|
status: ContainerStatus
|
|
port: number
|
|
image: string
|
|
errorMessage: string | null
|
|
createdAt: string
|
|
updatedAt: string
|
|
company: {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
status: string
|
|
}
|
|
}
|
|
|
|
function authHeaders() {
|
|
return { 'Content-Type': 'application/json' }
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: ContainerStatus }) {
|
|
const map: Record<ContainerStatus, { label: string; className: string }> = {
|
|
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
|
|
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
|
|
RUNNING: { label: 'Running', className: 'bg-emerald-900 text-emerald-300' },
|
|
STOPPED: { label: 'Stopped', className: 'bg-orange-900 text-orange-300' },
|
|
RESTARTING: { label: 'Restarting…',className: 'bg-orange-900 text-orange-300 animate-pulse' },
|
|
REMOVING: { label: 'Removing…', className: 'bg-red-900 text-red-300 animate-pulse' },
|
|
ERROR: { label: 'Error', className: 'bg-red-950 text-red-400' },
|
|
}
|
|
const { label, className } = map[status] ?? map.ERROR
|
|
return (
|
|
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
|
|
<span className={`h-1.5 w-1.5 rounded-full ${status === 'RUNNING' ? 'bg-emerald-400' : 'bg-current opacity-60'}`} />
|
|
{label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function LogsModal({ companyId, companyName, onClose }: { companyId: string; companyName: string; onClose: () => void }) {
|
|
const [logs, setLogs] = useState<string>('')
|
|
const [loading, setLoading] = useState(true)
|
|
const [tail, setTail] = useState(150)
|
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
|
|
const fetchLogs = useCallback(async (lines: number) => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders(), credentials: 'include' })
|
|
const json = await res.json()
|
|
setLogs(json.data?.logs ?? '')
|
|
} catch {
|
|
setLogs('Failed to fetch logs.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [companyId])
|
|
|
|
useEffect(() => { fetchLogs(tail) }, [fetchLogs, tail])
|
|
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#07101e]/70 p-4" onClick={onClose}>
|
|
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
|
<div>
|
|
<p className="text-sm font-semibold text-zinc-100">Container Logs</p>
|
|
<p className="text-xs text-zinc-400">{companyName}</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<select
|
|
value={tail}
|
|
onChange={(e) => setTail(Number(e.target.value))}
|
|
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-200 focus:outline-none"
|
|
>
|
|
<option value={50}>Last 50 lines</option>
|
|
<option value={150}>Last 150 lines</option>
|
|
<option value={500}>Last 500 lines</option>
|
|
<option value={1000}>Last 1000 lines</option>
|
|
</select>
|
|
<button onClick={() => fetchLogs(tail)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
|
Refresh
|
|
</button>
|
|
<button onClick={onClose} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{loading ? (
|
|
<p className="text-xs text-zinc-500">Loading…</p>
|
|
) : (
|
|
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-relaxed text-zinc-300">
|
|
{logs || 'No logs available.'}
|
|
</pre>
|
|
)}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type ProvisionResult = { companyId: string; name: string; status: 'created' | 'error'; error?: string }
|
|
|
|
export default function ContainersPage() {
|
|
const [containers, setContainers] = useState<CompanyContainer[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
|
const [logsFor, setLogsFor] = useState<{ companyId: string; companyName: string } | null>(null)
|
|
const [search, setSearch] = useState('')
|
|
const [provisioning, setProvisioning] = useState(false)
|
|
const [provisionResults, setProvisionResults] = useState<ProvisionResult[] | null>(null)
|
|
|
|
const fetchContainers = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders(), credentials: 'include' })
|
|
const json = await res.json().catch(() => null)
|
|
if (!res.ok) {
|
|
throw new Error(json?.message ?? 'Failed to load containers.')
|
|
}
|
|
setContainers(json.data ?? [])
|
|
setError(null)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load containers.')
|
|
/* silent — keep old data */
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchContainers()
|
|
const id = setInterval(fetchContainers, 8000)
|
|
return () => clearInterval(id)
|
|
}, [fetchContainers])
|
|
|
|
async function provisionAll() {
|
|
setProvisioning(true)
|
|
setProvisionResults(null)
|
|
try {
|
|
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders(), credentials: 'include' })
|
|
const json = await res.json().catch(() => null)
|
|
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
|
|
setProvisionResults(json.data?.results ?? [])
|
|
setError(null)
|
|
await fetchContainers()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Provisioning failed.')
|
|
} finally {
|
|
setProvisioning(false)
|
|
}
|
|
}
|
|
|
|
async function act(companyId: string, action: 'start' | 'stop' | 'restart' | 'deploy' | 'remove') {
|
|
setBusy((b) => ({ ...b, [companyId]: true }))
|
|
try {
|
|
const method = action === 'remove' ? 'DELETE' : 'POST'
|
|
const url =
|
|
action === 'remove'
|
|
? `${ADMIN_API_BASE}/admin/containers/${companyId}`
|
|
: `${ADMIN_API_BASE}/admin/containers/${companyId}/${action}`
|
|
const res = await fetch(url, { method, headers: authHeaders(), credentials: 'include' })
|
|
const json = await res.json().catch(() => null)
|
|
if (!res.ok) {
|
|
throw new Error(json?.message ?? `Failed to ${action} container.`)
|
|
}
|
|
setError(null)
|
|
await fetchContainers()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : `Failed to ${action} container.`)
|
|
} finally {
|
|
setBusy((b) => ({ ...b, [companyId]: false }))
|
|
}
|
|
}
|
|
|
|
const filtered = containers.filter(
|
|
(c) =>
|
|
c.company.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
c.containerName.toLowerCase().includes(search.toLowerCase()) ||
|
|
c.company.slug.toLowerCase().includes(search.toLowerCase()),
|
|
)
|
|
|
|
const stats = {
|
|
running: containers.filter((c) => c.status === 'RUNNING').length,
|
|
stopped: containers.filter((c) => c.status === 'STOPPED').length,
|
|
error: containers.filter((c) => c.status === 'ERROR').length,
|
|
total: containers.length,
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-full p-8">
|
|
{logsFor && (
|
|
<LogsModal
|
|
companyId={logsFor.companyId}
|
|
companyName={logsFor.companyName}
|
|
onClose={() => setLogsFor(null)}
|
|
/>
|
|
)}
|
|
|
|
<div className="mb-8 flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-zinc-100">Containers</h1>
|
|
<p className="mt-1 text-sm text-zinc-400">Manage isolated Docker Compose services for each company workspace.</p>
|
|
</div>
|
|
<button
|
|
onClick={provisionAll}
|
|
disabled={provisioning}
|
|
className="flex items-center gap-2 rounded-xl bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
|
>
|
|
{provisioning ? (
|
|
<>
|
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
Provisioning…
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
|
</svg>
|
|
Provision All Accounts
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-6 rounded-xl border border-red-900 bg-red-950/70 px-4 py-3 text-sm text-red-200">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{provisionResults !== null && (
|
|
<div className="mb-6 rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<p className="text-sm font-medium text-zinc-200">
|
|
Provisioning complete —{' '}
|
|
<span className="text-emerald-400">{provisionResults.filter((r) => r.status === 'created').length} created</span>
|
|
{provisionResults.some((r) => r.status === 'error') && (
|
|
<>, <span className="text-red-400">{provisionResults.filter((r) => r.status === 'error').length} failed</span></>
|
|
)}
|
|
</p>
|
|
<button onClick={() => setProvisionResults(null)} className="text-xs text-zinc-500 hover:text-zinc-300">Dismiss</button>
|
|
</div>
|
|
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
|
{provisionResults.map((r) => (
|
|
<div key={r.companyId} className="flex items-center gap-3 rounded-lg px-3 py-2 bg-zinc-800/60">
|
|
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${r.status === 'created' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
|
<span className="text-sm text-zinc-300 flex-1">{r.name}</span>
|
|
{r.status === 'error' && <span className="text-xs text-red-400 truncate max-w-xs">{r.error}</span>}
|
|
{r.status === 'created' && <span className="text-xs text-emerald-500">Service created</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats */}
|
|
<div className="mb-6 grid grid-cols-4 gap-4">
|
|
{[
|
|
{ label: 'Total', value: stats.total, color: 'text-zinc-100' },
|
|
{ label: 'Running', value: stats.running, color: 'text-emerald-400' },
|
|
{ label: 'Stopped', value: stats.stopped, color: 'text-orange-400' },
|
|
{ label: 'Error', value: stats.error, color: 'text-red-400' },
|
|
].map((s) => (
|
|
<div key={s.label} className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
|
<p className="text-xs text-zinc-500">{s.label}</p>
|
|
<p className={`mt-1 text-2xl font-bold ${s.color}`}>{s.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="mb-4">
|
|
<input
|
|
type="text"
|
|
placeholder="Search by company name or container…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full max-w-sm rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="py-16 text-center text-sm text-zinc-500">
|
|
{search ? 'No containers match your search.' : 'No containers yet. They are created automatically on company signup.'}
|
|
</div>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-zinc-800 text-left text-xs text-zinc-500">
|
|
<th className="px-5 py-3 font-medium">Company</th>
|
|
<th className="px-5 py-3 font-medium">Container</th>
|
|
<th className="px-5 py-3 font-medium">Status</th>
|
|
<th className="px-5 py-3 font-medium">Port</th>
|
|
<th className="px-5 py-3 font-medium">Image</th>
|
|
<th className="px-5 py-3 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-800">
|
|
{filtered.map((c) => {
|
|
const isBusy = busy[c.companyId] ?? false
|
|
const isRunning = c.status === 'RUNNING'
|
|
const isStopped = c.status === 'STOPPED' || c.status === 'ERROR'
|
|
const isTransitioning = ['CREATING', 'RESTARTING', 'REMOVING'].includes(c.status)
|
|
|
|
return (
|
|
<tr key={c.id} className="hover:bg-zinc-800/40">
|
|
<td className="px-5 py-4">
|
|
<p className="font-medium text-zinc-100">{c.company.name}</p>
|
|
<p className="text-xs text-zinc-500">{c.company.slug}</p>
|
|
{c.errorMessage && (
|
|
<p className="mt-1 text-xs text-red-400" title={c.errorMessage}>
|
|
{c.errorMessage.slice(0, 60)}{c.errorMessage.length > 60 ? '…' : ''}
|
|
</p>
|
|
)}
|
|
</td>
|
|
<td className="px-5 py-4 font-mono text-xs text-zinc-400">
|
|
{c.containerName}
|
|
{c.dockerId && (
|
|
<p className="mt-0.5 text-zinc-600">{c.dockerId.slice(0, 12)}</p>
|
|
)}
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<StatusBadge status={c.status} />
|
|
</td>
|
|
<td className="px-5 py-4 font-mono text-xs text-zinc-400">:{c.port}</td>
|
|
<td className="px-5 py-4 font-mono text-xs text-zinc-500">{c.image}</td>
|
|
<td className="px-5 py-4">
|
|
<div className="flex items-center gap-1.5">
|
|
{isStopped && (
|
|
<ActionButton
|
|
label="Start"
|
|
color="emerald"
|
|
disabled={isBusy || isTransitioning}
|
|
onClick={() => act(c.companyId, 'start')}
|
|
/>
|
|
)}
|
|
{isRunning && (
|
|
<ActionButton
|
|
label="Stop"
|
|
color="yellow"
|
|
disabled={isBusy || isTransitioning}
|
|
onClick={() => act(c.companyId, 'stop')}
|
|
/>
|
|
)}
|
|
{(isRunning || isStopped) && (
|
|
<ActionButton
|
|
label="Restart"
|
|
color="blue"
|
|
disabled={isBusy || isTransitioning}
|
|
onClick={() => act(c.companyId, 'restart')}
|
|
/>
|
|
)}
|
|
<ActionButton
|
|
label="Redeploy"
|
|
color="purple"
|
|
disabled={isBusy || isTransitioning}
|
|
onClick={() => act(c.companyId, 'deploy')}
|
|
/>
|
|
<ActionButton
|
|
label="Logs"
|
|
color="zinc"
|
|
disabled={isBusy || !c.dockerId}
|
|
onClick={() => setLogsFor({ companyId: c.companyId, companyName: c.company.name })}
|
|
/>
|
|
<ActionButton
|
|
label="Remove"
|
|
color="red"
|
|
disabled={isBusy || isTransitioning}
|
|
onClick={() => {
|
|
if (confirm(`Remove container for ${c.company.name}? This cannot be undone.`)) {
|
|
act(c.companyId, 'remove')
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ActionButton({
|
|
label,
|
|
color,
|
|
disabled,
|
|
onClick,
|
|
}: {
|
|
label: string
|
|
color: 'emerald' | 'yellow' | 'blue' | 'purple' | 'zinc' | 'red'
|
|
disabled: boolean
|
|
onClick: () => void
|
|
}) {
|
|
const colorMap: Record<string, string> = {
|
|
emerald: 'border-emerald-800 text-emerald-400 hover:bg-emerald-900/40',
|
|
yellow: 'border-orange-800 text-orange-400 hover:bg-orange-900/40',
|
|
blue: 'border-blue-800 text-blue-400 hover:bg-blue-900/40',
|
|
purple: 'border-purple-800 text-purple-400 hover:bg-purple-900/40',
|
|
zinc: 'border-zinc-700 text-zinc-400 hover:bg-zinc-700/40',
|
|
red: 'border-red-900 text-red-400 hover:bg-red-900/30',
|
|
}
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className={`rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${colorMap[color]}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
)
|
|
}
|