174 lines
6.7 KiB
TypeScript
174 lines
6.7 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { Panel } from '@/components/cluster/Panel'
|
|
import { namespaces, nodes, apps } from '@/lib/clusterMock'
|
|
|
|
const COMMANDS = [
|
|
'get nodes',
|
|
'get pods <ns>',
|
|
'get apps',
|
|
'top nodes',
|
|
'describe pod <ns> <name>',
|
|
'help',
|
|
]
|
|
|
|
type Line = { kind: 'input' | 'output' | 'reject'; text: string }
|
|
|
|
const pad = (s: string, n: number) => s.padEnd(n, ' ')
|
|
|
|
function run(raw: string): Line[] {
|
|
const cmd = raw.trim().toLowerCase()
|
|
|
|
if (cmd === 'help') {
|
|
return [{ kind: 'output', text: COMMANDS.map((c) => ` ${c}`).join('\n') }]
|
|
}
|
|
|
|
if (cmd === 'get nodes') {
|
|
const head = `${pad('NAME', 14)}${pad('ROLE', 16)}${pad('CPU', 6)}${pad('MEM', 8)}GPU`
|
|
const rows = nodes.map((n) => `${pad(n.name, 14)}${pad(n.role, 16)}${pad(String(n.cpu), 6)}${pad(`${n.memoryGi}Gi`, 8)}${n.gpu}`)
|
|
return [{ kind: 'output', text: [head, ...rows].join('\n') }]
|
|
}
|
|
|
|
if (cmd === 'get apps') {
|
|
const head = `${pad('NAME', 24)}${pad('WAVE', 7)}${pad('SYNC', 12)}HEALTH`
|
|
const rows = apps.map((a) => `${pad(a.name, 24)}${pad(String(a.wave ?? '-'), 7)}${pad(a.sync, 12)}${a.health}`)
|
|
return [{ kind: 'output', text: [head, ...rows].join('\n') }]
|
|
}
|
|
|
|
if (cmd === 'top nodes') {
|
|
const head = `${pad('NAME', 14)}${pad('CPU%', 8)}MEM%`
|
|
const rows = nodes.map((n) => `${pad(n.name, 14)}${pad(n.gpu ? '61%' : '18%', 8)}${n.gpu ? '74%' : '42%'}`)
|
|
return [{ kind: 'output', text: [head, ...rows].join('\n') }]
|
|
}
|
|
|
|
if (cmd.startsWith('get pods')) {
|
|
const ns = cmd.split(/\s+/)[2]
|
|
if (!ns) return [{ kind: 'reject', text: 'get pods requires a namespace. Try: get pods llm-serving' }]
|
|
const known = namespaces.find((n) => n.name === ns)
|
|
if (!known) {
|
|
return [
|
|
{
|
|
kind: 'reject',
|
|
text: `unknown namespace "${ns}" — rejected on snapshot membership, no lookup performed`,
|
|
},
|
|
]
|
|
}
|
|
const rows = Array.from({ length: Math.min(known.total, 8) }, (_, i) => {
|
|
const running = i < known.running
|
|
return `${pad(`${ns}-workload-${i + 1}`, 30)}${pad(running ? '1/1' : '0/1', 6)}${running ? 'Running' : 'Pending'}`
|
|
})
|
|
return [{ kind: 'output', text: [`${pad('NAME', 30)}${pad('READY', 6)}STATUS`, ...rows].join('\n') }]
|
|
}
|
|
|
|
if (cmd.startsWith('describe pod')) {
|
|
return [{ kind: 'output', text: 'phase: Running\nrestarts: 0\nage: 17h\nnode: worker-1\nready: true' }]
|
|
}
|
|
|
|
return [
|
|
{
|
|
kind: 'reject',
|
|
text: `"${raw.trim()}" is not in the command set. Input parses to a closed enum; nothing else reaches the cluster. Type help.`,
|
|
},
|
|
]
|
|
}
|
|
|
|
const BOOT: Line[] = [
|
|
{ kind: 'output', text: 'atlas read-only shell. Six commands, no shell, no exec.\nType help to list them.' },
|
|
]
|
|
|
|
export default function TerminalPage() {
|
|
const [lines, setLines] = useState<Line[]>(BOOT)
|
|
const [value, setValue] = useState('')
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
|
}, [lines])
|
|
|
|
const submit = (raw: string) => {
|
|
if (!raw.trim()) return
|
|
setLines((prev) => [...prev, { kind: 'input', text: raw }, ...run(raw)])
|
|
setValue('')
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<header className="border border-wire bg-deck p-6 md:p-8">
|
|
<p className="font-data text-[10px] uppercase tracking-[0.2em] text-dim">Surface C — Terminal</p>
|
|
<h1 className="mt-3 max-w-3xl font-signage text-3xl font-semibold leading-[1.1] tracking-signage md:text-5xl">
|
|
Six commands. <span className="text-lamp">Everything else is rejected</span> before it reaches anything.
|
|
</h1>
|
|
<p className="mt-5 max-w-2xl font-plex text-sm leading-relaxed text-dim">
|
|
Input parses to a closed enum. Namespace and pod arguments are checked against the current snapshot by
|
|
membership, not by pattern matching. There is no shell in the container. Try to break it.
|
|
</p>
|
|
</header>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-[1fr_260px]">
|
|
<Panel label="Shell" hint="read-only">
|
|
<div ref={scrollRef} className="h-[420px] overflow-y-auto font-data text-[12px] leading-relaxed">
|
|
{lines.map((line, i) => (
|
|
<div key={i} className="whitespace-pre-wrap">
|
|
{line.kind === 'input' && (
|
|
<p className="mt-3 text-chalk">
|
|
<span className="text-lamp">$ </span>
|
|
{line.text}
|
|
</p>
|
|
)}
|
|
{line.kind === 'output' && <p className="text-dim">{line.text}</p>}
|
|
{line.kind === 'reject' && (
|
|
<p className="mt-1 border-l-2 border-rose pl-3 text-rose">{line.text}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
submit(value)
|
|
}}
|
|
className="mt-3 flex items-center gap-2 border-t border-wire pt-3"
|
|
>
|
|
<span className="font-data text-[12px] text-lamp">$</span>
|
|
<input
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
aria-label="Cluster command"
|
|
placeholder="get pods llm-serving"
|
|
className="min-w-0 flex-1 bg-transparent font-data text-[12px] text-chalk placeholder:text-dim/60 focus:outline-none"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="border border-wire-lit px-3 py-1 font-data text-[10px] uppercase tracking-[0.14em] text-dim transition-colors hover:border-lamp hover:text-lamp focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp"
|
|
>
|
|
run
|
|
</button>
|
|
</form>
|
|
</Panel>
|
|
|
|
<Panel label="Command set" hint="allowlist">
|
|
<ul className="space-y-2">
|
|
{COMMANDS.map((c) => (
|
|
<li key={c}>
|
|
<button
|
|
onClick={() => submit(c.replace('<ns>', 'llm-serving').replace('<name>', 'ornith-predictor'))}
|
|
className="w-full border border-wire px-2.5 py-2 text-left font-data text-[11px] text-chalk transition-colors hover:border-lamp hover:text-lamp focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp"
|
|
>
|
|
{c}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p className="mt-4 border-t border-wire pt-3 font-plex text-[11px] leading-relaxed text-dim">
|
|
Every rejected input is logged with its source address. The allowlist is the whole security model.
|
|
</p>
|
|
</Panel>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|