feat(05-03): Cable Test Station page at /test

- Add web/src/api/test.ts with submitCableTest, getRecentTests, streamTestEvents
- Add web/src/pages/CableTestPage.tsx with three-panel layout (readout/label/recent)
- Register /test route in router.tsx
- Add Test nav link (Cable icon) in TopBar
- SSE live readout via EventSource, closed on unmount
- Print & Next mutation with react-hot-toast feedback
- Mobile-responsive: single col on mobile, 3-col on lg+
- ClickHouse design tokens throughout (#000000 bg, #faff69 accent)
This commit is contained in:
Mikkel Georgsen 2026-04-10 07:19:24 +00:00
parent c9f874b7f4
commit 499dbb9929
4 changed files with 505 additions and 2 deletions

63
web/src/api/test.ts Normal file
View file

@ -0,0 +1,63 @@
// Cable test API helpers — typed fetch wrappers for /api/test/* endpoints
export interface TestResult {
cable_type: 0 | 1 | 2 // 0=USB, 1=DP, 2=HDMI
usb_version: string
dp_version: string
hdmi_version: string
speed_gbps: number
max_watts: number
pin_continuity: boolean
has_emarker: boolean
resistance_ohm: number
hw_id: string
}
export interface SubmitResponse {
hw_id: string
netbox_id: number
print_skipped: boolean
}
export interface LiveReading {
voltage: number
current_amps: number
power_watts: number
pd_protocol: string
timestamp: string
}
export async function submitCableTest(result: TestResult): Promise<SubmitResponse> {
const res = await fetch('/api/test/cable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result),
})
if (!res.ok) throw new Error(`submit failed: ${res.status}`)
return res.json()
}
export async function getRecentTests(): Promise<TestResult[]> {
const res = await fetch('/api/test/recent')
if (!res.ok) throw new Error(`recent tests failed: ${res.status}`)
return res.json()
}
export function streamTestEvents(
onReading: (reading: LiveReading) => void,
onError?: (e: Event) => void,
): EventSource {
const es = new EventSource('/api/test/events')
es.addEventListener('message', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data as string) as LiveReading
onReading(data)
} catch {
// ignore malformed SSE frames
}
})
if (onError) {
es.addEventListener('error', onError)
}
return es
}

View file

@ -1,4 +1,4 @@
import { Plus, QrCode } from 'lucide-react'
import { Plus, QrCode, Cable } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
@ -9,6 +9,12 @@ export function TopBar() {
HWLab
</Link>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link to="/test">
<Cable className="w-4 h-4 mr-1.5" />
<span className="hidden sm:inline">Test</span>
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link to="/scan">
<QrCode className="w-4 h-4 mr-1.5" />

View file

@ -0,0 +1,423 @@
import * as React from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Usb, Monitor, Tv2, CheckCircle2, XCircle, Loader2, Zap } from 'lucide-react'
import { AppShell } from '@/components/layout/AppShell'
import {
submitCableTest,
getRecentTests,
streamTestEvents,
type TestResult,
type LiveReading,
} from '@/api/test'
// ---------------------------------------------------------------------------
// Cable type helpers
// ---------------------------------------------------------------------------
const CABLE_TYPES = [
{ label: 'USB', value: 0, icon: Usb },
{ label: 'DisplayPort', value: 1, icon: Monitor },
{ label: 'HDMI', value: 2, icon: Tv2 },
] as const
function CableTypeIcon({ type }: { type: 0 | 1 | 2 }) {
const entry = CABLE_TYPES[type]
const Icon = entry.icon
return <Icon className="w-4 h-4 inline-block mr-1 text-[#888]" aria-label={entry.label} />
}
// ---------------------------------------------------------------------------
// Default blank form
// ---------------------------------------------------------------------------
const DEFAULT_FORM: TestResult = {
cable_type: 0,
usb_version: '',
dp_version: '',
hdmi_version: '',
speed_gbps: 0,
max_watts: 0,
pin_continuity: false,
has_emarker: false,
resistance_ohm: 0,
hw_id: '',
}
// ---------------------------------------------------------------------------
// Left panel — live tester readout
// ---------------------------------------------------------------------------
interface ReadoutPanelProps {
reading: LiveReading | null
}
function ReadoutPanel({ reading }: ReadoutPanelProps) {
return (
<section
className="bg-[#111] border border-[#222] rounded-lg p-4 flex flex-col gap-3"
aria-label="Tester readout"
>
<h2 className="text-white font-bold text-sm uppercase tracking-widest flex items-center gap-2">
<Zap className="w-4 h-4 text-[#faff69]" />
Live Readout
</h2>
{reading ? (
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex flex-col">
<span className="text-[#888] text-xs">Voltage</span>
<span className="text-[#faff69] font-mono font-bold text-lg">
{reading.voltage.toFixed(2)}&nbsp;V
</span>
</div>
<div className="flex flex-col">
<span className="text-[#888] text-xs">Current</span>
<span className="text-[#faff69] font-mono font-bold text-lg">
{reading.current_amps.toFixed(2)}&nbsp;A
</span>
</div>
<div className="flex flex-col">
<span className="text-[#888] text-xs">Power</span>
<span className="text-white font-mono">
{reading.power_watts.toFixed(1)}&nbsp;W
</span>
</div>
<div className="flex flex-col">
<span className="text-[#888] text-xs">Protocol</span>
<span className="text-white font-mono">{reading.pd_protocol || '—'}</span>
</div>
</div>
) : (
<p className="text-[#888] text-sm">No tester connected</p>
)}
</section>
)
}
// ---------------------------------------------------------------------------
// Center panel — label preview + form + Print & Next
// ---------------------------------------------------------------------------
interface LabelPanelProps {
form: TestResult
onChange: (updated: TestResult) => void
onSubmit: () => void
isPending: boolean
}
function LabelPanel({ form, onChange, onSubmit, isPending }: LabelPanelProps) {
function set<K extends keyof TestResult>(key: K, value: TestResult[K]) {
onChange({ ...form, [key]: value })
}
return (
<section
className="bg-[#111] border border-[#222] rounded-lg p-4 flex flex-col gap-4"
aria-label="Label preview and test form"
>
<h2 className="text-white font-bold text-sm uppercase tracking-widest">Label Preview</h2>
{/* HW ID — prominent */}
<div className="bg-black border border-[#222] rounded px-4 py-3 flex items-center justify-between">
<span className="text-[#888] text-xs">HW ID</span>
<input
id="hw_id"
type="text"
value={form.hw_id}
onChange={(e) => set('hw_id', e.target.value)}
placeholder="HW-00000"
className="bg-transparent text-[#faff69] font-mono font-bold text-right text-lg w-32 focus:outline-none placeholder:text-[#444]"
aria-label="HW ID"
/>
</div>
{/* Cable type selector */}
<div className="flex gap-2">
{CABLE_TYPES.map(({ label, value }) => (
<button
key={value}
type="button"
onClick={() => set('cable_type', value as 0 | 1 | 2)}
className={`flex-1 py-1.5 rounded text-xs font-semibold border transition-colors ${
form.cable_type === value
? 'bg-[#faff69] text-black border-[#faff69]'
: 'bg-transparent text-[#888] border-[#333] hover:border-[#555]'
}`}
aria-pressed={form.cable_type === value}
>
{label}
</button>
))}
</div>
{/* Version fields — show relevant ones based on type */}
{form.cable_type === 0 && (
<Field label="USB Version">
<TextInput
value={form.usb_version}
onChange={(v) => set('usb_version', v)}
placeholder="USB 3.2 Gen 2"
/>
</Field>
)}
{form.cable_type === 1 && (
<Field label="DP Version">
<TextInput
value={form.dp_version}
onChange={(v) => set('dp_version', v)}
placeholder="1.4"
/>
</Field>
)}
{form.cable_type === 2 && (
<Field label="HDMI Version">
<TextInput
value={form.hdmi_version}
onChange={(v) => set('hdmi_version', v)}
placeholder="2.1"
/>
</Field>
)}
{/* Numeric fields */}
<div className="grid grid-cols-2 gap-3">
<Field label="Speed (Gbps)">
<NumInput
value={form.speed_gbps}
onChange={(v) => set('speed_gbps', v)}
step={0.5}
/>
</Field>
<Field label="Max Watts">
<NumInput value={form.max_watts} onChange={(v) => set('max_watts', v)} />
</Field>
<Field label="Resistance (Ω)">
<NumInput
value={form.resistance_ohm}
onChange={(v) => set('resistance_ohm', v)}
step={0.01}
/>
</Field>
</div>
{/* Boolean toggles */}
<div className="flex gap-4">
<Toggle
label="Pin Continuity"
checked={form.pin_continuity}
onChange={(v) => set('pin_continuity', v)}
/>
<Toggle
label="eMarker"
checked={form.has_emarker}
onChange={(v) => set('has_emarker', v)}
/>
</div>
{/* Print & Next */}
<button
type="button"
onClick={onSubmit}
disabled={isPending || !form.hw_id.trim()}
className="mt-2 w-full py-3 rounded-lg bg-[#faff69] text-black font-bold text-sm disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 hover:bg-[#e8ed5a] transition-colors"
aria-label="Print label and advance to next cable"
>
{isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Printing
</>
) : (
'Print & Next'
)}
</button>
</section>
)
}
// Small form helpers
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<label className="text-[#888] text-xs">{label}</label>
{children}
</div>
)
}
function TextInput({
value,
onChange,
placeholder,
}: {
value: string
onChange: (v: string) => void
placeholder?: string
}) {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="bg-black border border-[#333] rounded px-2 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-[#faff69] placeholder:text-[#444]"
/>
)
}
function NumInput({
value,
onChange,
step = 1,
}: {
value: number
onChange: (v: number) => void
step?: number
}) {
return (
<input
type="number"
value={value}
step={step}
min={0}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
className="bg-black border border-[#333] rounded px-2 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-[#faff69] w-full"
/>
)
}
function Toggle({
label,
checked,
onChange,
}: {
label: string
checked: boolean
onChange: (v: boolean) => void
}) {
return (
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="accent-[#faff69] w-4 h-4 rounded"
/>
<span className="text-[#888] text-xs">{label}</span>
</label>
)
}
// ---------------------------------------------------------------------------
// Right panel — recent tests
// ---------------------------------------------------------------------------
function RecentTestsPanel() {
const { data: tests, isLoading } = useQuery({
queryKey: ['recentTests'],
queryFn: getRecentTests,
refetchInterval: 5000,
})
return (
<section
className="bg-[#111] border border-[#222] rounded-lg p-4 flex flex-col gap-3"
aria-label="Recent tests"
>
<h2 className="text-white font-bold text-sm uppercase tracking-widest">Recent Tests</h2>
{isLoading && <p className="text-[#888] text-sm">Loading</p>}
{!isLoading && (!tests || tests.length === 0) && (
<p className="text-[#888] text-sm">No tests yet</p>
)}
{tests && tests.length > 0 && (
<ul className="flex flex-col gap-2 overflow-y-auto max-h-[480px]">
{tests.slice(0, 20).map((t, i) => (
<li
key={`${t.hw_id}-${i}`}
className="flex items-center gap-2 p-2 rounded bg-black border border-[#1a1a1a] text-sm"
>
<CableTypeIcon type={t.cable_type} />
<span className="text-[#faff69] font-mono text-xs flex-1 truncate">{t.hw_id}</span>
{t.pin_continuity ? (
<CheckCircle2 className="w-4 h-4 text-green-400 shrink-0" aria-label="Pass" />
) : (
<XCircle className="w-4 h-4 text-red-400 shrink-0" aria-label="Fail" />
)}
<span className="text-[#888] font-mono text-xs shrink-0">
{t.speed_gbps > 0 ? `${t.speed_gbps}G` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function CableTestPage() {
const queryClient = useQueryClient()
const [liveReading, setLiveReading] = React.useState<LiveReading | null>(null)
const [form, setForm] = React.useState<TestResult>(DEFAULT_FORM)
// SSE connection — open on mount, close on unmount
React.useEffect(() => {
const es = streamTestEvents((reading) => {
setLiveReading(reading)
})
return () => {
es.close()
}
}, [])
const { mutate, isPending } = useMutation({
mutationFn: submitCableTest,
onSuccess: (data) => {
if (data.print_skipped) {
toast('Saved — printer not available', { icon: '⚠️' })
} else {
toast.success(`Label printed for ${data.hw_id}`)
}
setForm(DEFAULT_FORM)
void queryClient.invalidateQueries({ queryKey: ['recentTests'] })
},
onError: (err: Error) => {
toast.error(`Submit failed: ${err.message}`)
},
})
function handleSubmit() {
mutate(form)
}
return (
<AppShell>
{/* Page header */}
<div className="mb-6">
<h1 className="font-display font-black text-3xl text-white mb-1">Cable Test Station</h1>
<p className="text-sm text-[#888]">
Test cables, assign HW IDs, and print labels in one workflow
</p>
</div>
{/* Three-panel layout: single col on mobile, three cols on lg+ */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<ReadoutPanel reading={liveReading} />
<LabelPanel
form={form}
onChange={setForm}
onSubmit={handleSubmit}
isPending={isPending}
/>
<RecentTestsPanel />
</div>
</AppShell>
)
}

View file

@ -6,6 +6,7 @@ const DashboardPage = lazy(() => import('./pages/DashboardPage').then((m) => ({
const ItemDetailPage = lazy(() => import('./pages/ItemDetailPage').then((m) => ({ default: m.ItemDetailPage })))
const IntakePage = lazy(() => import('./pages/IntakePage').then((m) => ({ default: m.IntakePage })))
const ScanPage = lazy(() => import('./pages/ScanPage').then((m) => ({ default: m.ScanPage })))
const CableTestPage = lazy(() => import('./pages/CableTestPage').then((m) => ({ default: m.CableTestPage })))
const Spinner = () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
@ -63,7 +64,17 @@ const scanRoute = createRoute({
),
})
const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute])
const cableTestRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/test',
component: () => (
<Suspense fallback={<Spinner />}>
<CableTestPage />
</Suspense>
),
})
const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute, cableTestRoute])
export const router = createRouter({
routeTree,