Files

220 lines
8.7 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import { Panel } from '@/components/cluster/Panel'
import { DEFAULT_KINDS, WAVES, apps, appsInWave, resourceKinds, type App } from '@/lib/clusterMock'
const TOTAL_RESOURCES = apps.reduce((n, a) => n + a.resources, 0)
/** Resource count as discrete cells, capped so a 68-resource app stays readable. */
function CountBar({ n }: { n: number }) {
const cells = Math.min(n, 24)
return (
<div className="mt-2 flex flex-wrap gap-[2px]" aria-label={`${n} resources`}>
{Array.from({ length: cells }, (_, i) => (
<span key={i} className="h-1.5 w-1.5 rounded-[1px] bg-wire-lit" />
))}
{n > 24 && <span className="ml-1 font-data text-[9px] leading-none text-dim">+{n - 24}</span>}
</div>
)
}
function AppCard({
app,
state,
active,
onSelect,
}: {
app: App
state: 'idle' | 'syncing' | 'done'
active: boolean
onSelect: () => void
}) {
const degraded = app.health === 'degraded'
const drifted = app.sync === 'outofsync'
const border = active
? 'border-lamp'
: state === 'syncing'
? 'border-lamp/70'
: degraded
? 'border-rose/50'
: drifted
? 'border-lamp/40'
: 'border-wire'
return (
<button
onClick={onSelect}
aria-pressed={active}
className={[
'w-full border bg-deck p-2.5 text-left transition-all duration-300',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp',
border,
state === 'syncing' ? 'bg-riser' : '',
active ? 'bg-riser' : 'hover:border-wire-lit',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<span className="font-data text-[11px] leading-tight text-chalk">{app.name}</span>
<span
className={[
'mt-1 h-1.5 w-1.5 shrink-0 rounded-full transition-colors',
state === 'syncing' ? 'bg-lamp' : degraded ? 'bg-rose' : drifted ? 'bg-lamp' : 'bg-flux',
].join(' ')}
/>
</div>
<CountBar n={app.resources} />
{(degraded || drifted) && (
<p className={`mt-2 font-data text-[9px] uppercase tracking-[0.14em] ${degraded ? 'text-rose' : 'text-lamp'}`}>
{degraded ? 'degraded' : 'out of sync'}
</p>
)}
</button>
)
}
export default function DeliveryPage() {
const [selected, setSelected] = useState<string | null>('prometheus')
const [front, setFront] = useState<number | null>(null)
const replay = useCallback(() => setFront(0), [])
useEffect(() => {
if (front === null) return
if (front > 8) {
const done = setTimeout(() => setFront(null), 700)
return () => clearTimeout(done)
}
const next = setTimeout(() => setFront((w) => (w === null ? null : w + 1)), 520)
return () => clearTimeout(next)
}, [front])
const selectedApp = apps.find((a) => a.name === selected) ?? null
const kinds = selectedApp ? (resourceKinds[selectedApp.name] ?? DEFAULT_KINDS) : []
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 E Delivery</p>
<h1 className="mt-3 max-w-3xl font-signage text-3xl font-semibold leading-[1.1] tracking-signage md:text-5xl">
Nothing starts until the thing it needs is already running.
</h1>
<p className="mt-5 max-w-2xl font-plex text-sm leading-relaxed text-dim">
{apps.length} applications, {TOTAL_RESOURCES} resources, applied in nine ordered waves. Certificates
before issuers, issuers before ingress, storage before the databases that sit on it. The order is the
design.
</p>
<button
onClick={replay}
disabled={front !== null}
className="mt-6 border border-lamp px-4 py-2 font-data text-[11px] uppercase tracking-[0.16em] text-lamp transition-colors hover:bg-lamp hover:text-void focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-lamp"
>
{front !== null ? `syncing wave ${Math.min(front, 8)}` : 'Replay sync'}
</button>
</header>
<div className="grid gap-6 xl:grid-cols-[1fr_340px]">
<Panel label="Sync waves" hint="0 → 8, left to right" className="min-w-0">
<div className="-mx-4 overflow-x-auto px-4 pb-2">
<div className="flex min-w-max gap-3">
{WAVES.map((wave) => {
const inWave = appsInWave(wave)
const state = front === null ? 'idle' : front === wave ? 'syncing' : front > wave ? 'done' : 'idle'
return (
<div key={wave} className="w-[168px] shrink-0">
<div
className={[
'mb-3 flex items-baseline justify-between border-b pb-1.5 transition-colors',
state === 'syncing' ? 'border-lamp' : 'border-wire',
].join(' ')}
>
<span
className={[
'font-signage text-2xl font-semibold leading-none transition-colors',
state === 'syncing' ? 'text-lamp' : inWave.length ? 'text-chalk' : 'text-wire-lit',
].join(' ')}
>
{wave}
</span>
<span className="font-data text-[9px] uppercase tracking-[0.14em] text-dim">
{inWave.length || '—'}
</span>
</div>
{inWave.length === 0 ? (
<p className="border border-dashed border-wire p-2.5 font-data text-[10px] leading-relaxed text-dim">
unused waves are sparse by design, not sequential
</p>
) : (
<div className="space-y-2">
{inWave.map((app) => (
<AppCard
key={app.name}
app={app}
state={state}
active={selected === app.name}
onSelect={() => setSelected(app.name)}
/>
))}
</div>
)}
</div>
)
})}
</div>
</div>
</Panel>
<Panel
label={selectedApp ? selectedApp.name : 'No selection'}
hint={selectedApp ? `wave ${selectedApp.wave ?? '—'} · ${selectedApp.resources} resources` : 'pick an app'}
>
{selectedApp ? (
<div className="space-y-4">
<dl className="grid grid-cols-2 gap-3 font-data text-[11px]">
<div>
<dt className="text-dim">sync</dt>
<dd className={selectedApp.sync === 'synced' ? 'text-flux' : 'text-lamp'}>{selectedApp.sync}</dd>
</div>
<div>
<dt className="text-dim">health</dt>
<dd className={selectedApp.health === 'healthy' ? 'text-flux' : 'text-rose'}>
{selectedApp.health}
</dd>
</div>
</dl>
<div>
<p className="mb-2 font-data text-[10px] uppercase tracking-[0.16em] text-dim">Resources by kind</p>
<ul className="space-y-1.5">
{kinds.map((k) => (
<li key={k.kind} className="flex items-center gap-3 font-data text-[11px]">
<span className={k.named ? 'text-chalk' : 'text-dim'}>{k.kind}</span>
<span className="h-px flex-1 bg-wire" />
<span className="tabular-nums text-dim">{k.count}</span>
{!k.named && (
<span className="border border-wire-lit px-1 text-[9px] uppercase tracking-[0.1em] text-dim">
count only
</span>
)}
</li>
))}
</ul>
</div>
<p className="border-t border-wire pt-3 font-plex text-[11px] leading-relaxed text-dim">
Secrets appear as counts. Names, source repositories, and condition messages are never sent to the
browser.
</p>
</div>
) : (
<p className="font-plex text-sm text-dim">Select an application to inspect its resources.</p>
)}
</Panel>
</div>
</div>
)
}