73 lines
3.3 KiB
TypeScript
73 lines
3.3 KiB
TypeScript
import type { HarnessGateStatus, HarnessTraceGraph } from '../../lib/api';
|
||
|
||
const TONE: Record<HarnessGateStatus, {
|
||
ring: string;
|
||
dot: string;
|
||
text: string;
|
||
surface: string;
|
||
}> = {
|
||
queued: { ring: 'border-slate-300', dot: 'bg-slate-300', text: 'text-slate-500', surface: 'bg-slate-50' },
|
||
running: { ring: 'border-cyan-400', dot: 'bg-cyan-400', text: 'text-cyan-800', surface: 'bg-cyan-50' },
|
||
pass: { ring: 'border-emerald-400', dot: 'bg-emerald-500', text: 'text-emerald-800', surface: 'bg-emerald-50' },
|
||
warning: { ring: 'border-amber-400', dot: 'bg-amber-500', text: 'text-amber-800', surface: 'bg-amber-50' },
|
||
blocked: { ring: 'border-rose-500', dot: 'bg-rose-500', text: 'text-rose-800', surface: 'bg-rose-50' },
|
||
error: { ring: 'border-rose-500', dot: 'bg-rose-500', text: 'text-rose-800', surface: 'bg-rose-50' },
|
||
skipped: { ring: 'border-slate-300', dot: 'bg-slate-300', text: 'text-slate-500', surface: 'bg-slate-50' },
|
||
};
|
||
|
||
const MARK: Record<HarnessGateStatus, string> = {
|
||
queued: '○',
|
||
running: '●',
|
||
pass: '✓',
|
||
warning: '!',
|
||
blocked: '×',
|
||
error: '×',
|
||
skipped: '–',
|
||
};
|
||
|
||
export function AssuranceRail({
|
||
graph,
|
||
selectedId,
|
||
onSelect,
|
||
}: {
|
||
graph: HarnessTraceGraph;
|
||
selectedId?: string;
|
||
onSelect?: (id: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="overflow-x-auto pb-2">
|
||
<div className="relative min-w-[860px] px-3 pt-2">
|
||
<div className="absolute left-[4.3rem] right-[4.3rem] top-[2.05rem] h-px bg-slate-200" aria-hidden="true" />
|
||
<div className="relative grid grid-cols-7">
|
||
{graph.nodes.map((node, index) => {
|
||
const tone = TONE[node.status];
|
||
const selected = selectedId === node.id;
|
||
const Element = onSelect ? 'button' : 'div';
|
||
return (
|
||
<Element
|
||
key={node.id}
|
||
{...(onSelect ? {
|
||
type: 'button' as const,
|
||
onClick: () => onSelect(node.id),
|
||
'aria-pressed': selected,
|
||
} : {})}
|
||
className={`group min-w-0 px-1.5 text-center ${onSelect ? 'cursor-pointer focus:outline-none' : ''}`}
|
||
>
|
||
<div className={`relative mx-auto flex h-11 w-11 items-center justify-center rounded-full border-2 bg-white text-sm font-black shadow-[0_0_0_5px_white] transition ${tone.ring} ${tone.text} ${selected ? 'scale-110 ring-4 ring-cyan-100' : onSelect ? 'group-hover:-translate-y-0.5 group-hover:shadow-md' : ''}`}>
|
||
<span className={`absolute right-0 top-0 h-2.5 w-2.5 rounded-full border-2 border-white ${tone.dot} ${node.status === 'running' ? 'animate-pulse' : ''}`} />
|
||
{MARK[node.status]}
|
||
</div>
|
||
<div className={`mx-auto mt-3 rounded-lg px-2 py-2 transition ${selected ? tone.surface : 'bg-transparent'}`}>
|
||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">H{index + 1}</p>
|
||
<p className={`mt-1 truncate text-xs font-semibold ${tone.text}`}>{node.title.split(' · ')[1] ?? node.title}</p>
|
||
<p className="mt-1 truncate text-[10px] capitalize text-slate-400">{node.status}</p>
|
||
</div>
|
||
</Element>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|