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:
parent
c9f874b7f4
commit
499dbb9929
4 changed files with 505 additions and 2 deletions
63
web/src/api/test.ts
Normal file
63
web/src/api/test.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
423
web/src/pages/CableTestPage.tsx
Normal file
423
web/src/pages/CableTestPage.tsx
Normal 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)} 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)} 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)} 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue