diff --git a/web/src/api/test.ts b/web/src/api/test.ts new file mode 100644 index 0000000..bf75001 --- /dev/null +++ b/web/src/api/test.ts @@ -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 { + 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 { + 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 +} diff --git a/web/src/components/layout/TopBar.tsx b/web/src/components/layout/TopBar.tsx index 757906a..e7eeac4 100644 --- a/web/src/components/layout/TopBar.tsx +++ b/web/src/components/layout/TopBar.tsx @@ -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
+ + ))} +
+ + {/* Version fields — show relevant ones based on type */} + {form.cable_type === 0 && ( + + set('usb_version', v)} + placeholder="USB 3.2 Gen 2" + /> + + )} + {form.cable_type === 1 && ( + + set('dp_version', v)} + placeholder="1.4" + /> + + )} + {form.cable_type === 2 && ( + + set('hdmi_version', v)} + placeholder="2.1" + /> + + )} + + {/* Numeric fields */} +
+ + set('speed_gbps', v)} + step={0.5} + /> + + + set('max_watts', v)} /> + + + set('resistance_ohm', v)} + step={0.01} + /> + +
+ + {/* Boolean toggles */} +
+ set('pin_continuity', v)} + /> + set('has_emarker', v)} + /> +
+ + {/* Print & Next */} + + + ) +} + +// Small form helpers +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +function TextInput({ + value, + onChange, + placeholder, +}: { + value: string + onChange: (v: string) => void + placeholder?: string +}) { + return ( + 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 ( + 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 ( + + ) +} + +// --------------------------------------------------------------------------- +// Right panel — recent tests +// --------------------------------------------------------------------------- + +function RecentTestsPanel() { + const { data: tests, isLoading } = useQuery({ + queryKey: ['recentTests'], + queryFn: getRecentTests, + refetchInterval: 5000, + }) + + return ( +
+

Recent Tests

+ + {isLoading &&

Loading…

} + + {!isLoading && (!tests || tests.length === 0) && ( +

No tests yet

+ )} + + {tests && tests.length > 0 && ( +
    + {tests.slice(0, 20).map((t, i) => ( +
  • + + {t.hw_id} + {t.pin_continuity ? ( + + ) : ( + + )} + + {t.speed_gbps > 0 ? `${t.speed_gbps}G` : '—'} + +
  • + ))} +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- + +export function CableTestPage() { + const queryClient = useQueryClient() + const [liveReading, setLiveReading] = React.useState(null) + const [form, setForm] = React.useState(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 ( + + {/* Page header */} +
+

Cable Test Station

+

+ Test cables, assign HW IDs, and print labels in one workflow +

+
+ + {/* Three-panel layout: single col on mobile, three cols on lg+ */} +
+ + + +
+
+ ) +} diff --git a/web/src/router.tsx b/web/src/router.tsx index d18bd7e..0c74bc7 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -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 = () => (
@@ -63,7 +64,17 @@ const scanRoute = createRoute({ ), }) -const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute]) +const cableTestRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/test', + component: () => ( + }> + + + ), +}) + +const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute, cableTestRoute]) export const router = createRouter({ routeTree,