5 plans across 3 waves covering USB-01 through USB-04 and LBL-01 through LBL-05. Mock drivers and goroutine-leak harness tests enable full TDD before hardware arrives. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
11 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-usb-manager-label-printing | 05 | execute | 3 |
|
|
true |
|
|
Purpose: Delivers the user-visible half of USB-04 — device events arrive from the SSE stream and update the UI without polling or page reloads. Also surfaces the print action from the dashboard.
Output: useUSBEvents hook, USBStatusBar component, updated DashboardPage with print button.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md @web/src/pages/DashboardPage.tsxSSE message format — each event is data: {JSON}\n\n where JSON is:
interface DeviceEvent {
VIDPID: string; // e.g. "0525:a4a7"
Spec: {
VID: string;
PID: string;
Name: string; // e.g. "PRT Qutie"
Role: number; // 0=Printer, 1=CableTester, 2=Unknown
BaudRate: number;
};
State: number; // 0=Disconnected, 1=Connected
}
type DeviceState = 0 | 1;
const StateDisconnected: DeviceState = 0;
const StateConnected: DeviceState = 1;
Print endpoint response:
// POST /api/labels/:deviceID/print
// 200: { status: "printed" }
// 200 with print_skipped: { status: "ok", print_skipped: true } (printer unavailable)
// 404: { error: "device not found" }
// 500: { error: "..." }
ClickHouse design system (from CLAUDE.md global):
- Background: #000000 (pure black)
- Accent: #faff69 (neon volt)
- Status indicators: connected = #22c55e (green-500), disconnected = #ef4444 (red-500)
- Font: existing Tailwind classes, no custom CSS
Existing DashboardPage patterns (read the file before editing):
- Uses TanStack Query for inventory data
- Uses react-hot-toast for notifications
- Item cards are rendered via ItemCard component or inline map
- Quick actions are icon buttons in the card's action area
Create web/src/hooks/useUSBEvents.ts:
import { useEffect, useState } from 'react';
export const StateDisconnected = 0;
export const StateConnected = 1;
export interface DeviceSpec {
VID: string;
PID: string;
Name: string;
Role: number; // 0=Printer, 1=CableTester, 2=Unknown
BaudRate: number;
}
export interface DeviceEvent {
VIDPID: string;
Spec: DeviceSpec;
State: number;
}
/**
* useUSBEvents subscribes to the GET /api/usb/events SSE stream and maintains
* the current map of connected USB devices.
*
* Returns: connectedDevices (map of VIDPID → DeviceSpec) and the last raw event.
*/
export function useUSBEvents(): {
connectedDevices: Map<string, DeviceSpec>;
lastEvent: DeviceEvent | null;
} {
const [connectedDevices, setConnectedDevices] = useState<Map<string, DeviceSpec>>(new Map());
const [lastEvent, setLastEvent] = useState<DeviceEvent | null>(null);
useEffect(() => {
const source = new EventSource('/api/usb/events');
source.onmessage = (e) => {
try {
const event: DeviceEvent = JSON.parse(e.data);
setLastEvent(event);
setConnectedDevices((prev) => {
const next = new Map(prev);
if (event.State === StateConnected) {
next.set(event.VIDPID, event.Spec);
} else {
next.delete(event.VIDPID);
}
return next;
});
} catch {
// Malformed SSE data — ignore, keep existing state
}
};
source.onerror = () => {
// SSE error (server down, network issue) — EventSource auto-reconnects
// No state change needed; devices remain as last known
};
return () => {
source.close();
};
}, []);
return { connectedDevices, lastEvent };
}
Create web/src/components/USBStatusBar.tsx:
import { Printer, Cable, Usb } from 'lucide-react';
import { useUSBEvents, DeviceSpec } from '../hooks/useUSBEvents';
const roleIcon = (role: number) => {
switch (role) {
case 0: return <Printer className="w-4 h-4" />;
case 1: return <Cable className="w-4 h-4" />;
default: return <Usb className="w-4 h-4" />;
}
};
/**
* USBStatusBar displays connected USB devices in a compact horizontal bar.
* Shows a dot indicator per device; empty state shows "No USB devices".
* Uses ClickHouse design system: black bg, neon volt accent, green for connected.
*/
export function USBStatusBar() {
const { connectedDevices } = useUSBEvents();
const devices = Array.from(connectedDevices.entries());
if (devices.length === 0) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-black border border-white/10 rounded text-xs text-white/40">
<Usb className="w-3.5 h-3.5" />
<span>No USB devices</span>
</div>
);
}
return (
<div className="flex items-center gap-3 px-3 py-1.5 bg-black border border-white/10 rounded">
{devices.map(([vidpid, spec]: [string, DeviceSpec]) => (
<div key={vidpid} className="flex items-center gap-1.5 text-xs">
<span className="w-2 h-2 rounded-full bg-green-500 inline-block" />
{roleIcon(spec.Role)}
<span className="text-white/80">{spec.Name}</span>
</div>
))}
</div>
);
}
cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | tail -20
TypeScript compilation clean. useUSBEvents hook and USBStatusBar component created with no type errors.
Task 2: Wire USBStatusBar + Print Label button into DashboardPage
web/src/pages/DashboardPage.tsx
Read `web/src/pages/DashboardPage.tsx` fully before editing.
Make two targeted additions to DashboardPage:
1. Import and place USBStatusBar in the page header area (above the inventory grid, alongside the existing filter controls). Find the header/toolbar section and add:
import { USBStatusBar } from '../components/USBStatusBar';
// In the header JSX, alongside existing controls:
<USBStatusBar />
2. Add Print Label quick action button to each item card. Find where item quick actions are rendered (look for the existing "Print Label" or "View in NetBox" / "Edit" action area). If a print action already exists as a stub, wire it; if not, add a Printer icon button:
import toast from 'react-hot-toast';
import { Printer } from 'lucide-react';
// In the item card action area (per item):
const handlePrintLabel = async (deviceId: number) => {
const toastId = toast.loading('Printing label...');
try {
const res = await fetch(`/api/labels/${deviceId}/print`, { method: 'POST' });
const data = await res.json();
if (!res.ok) {
toast.error(data.error ?? 'Print failed', { id: toastId });
return;
}
if (data.print_skipped) {
toast('Label queued — printer not connected', { id: toastId, icon: '⚠️' });
} else {
toast.success('Label printed', { id: toastId });
}
} catch {
toast.error('Network error', { id: toastId });
}
};
// Button (in each card's action area):
<button
onClick={() => handlePrintLabel(item.id)}
className="p-1.5 rounded hover:bg-white/10 text-white/60 hover:text-[#faff69] transition-colors"
title="Print label"
>
<Printer className="w-4 h-4" />
</button>
Move handlePrintLabel outside the map callback (define once at component level, not per-render).
Do not restructure the existing DashboardPage layout — make additive changes only. Preserve all existing functionality (grid/list toggle, filters, item navigation). cd /home/mikkel/homelabby/web && npx tsc --noEmit && npx eslint src/pages/DashboardPage.tsx --max-warnings 0 2>&1 | tail -20 TypeScript and ESLint clean. USBStatusBar renders in dashboard header. Print Label button present on each item card.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| SSE stream → React state | Parsed JSON from server updates UI state |
| Print button → POST /api/labels | User-triggered HTTP call from browser |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-04-14 | Tampering | SSE JSON parse | mitigate | Wrap JSON.parse in try/catch; malformed data is ignored, state unchanged |
| T-04-15 | Denial of Service | EventSource reconnect loop | accept | Browser EventSource auto-reconnects with backoff; solo-operator, not a threat vector |
| T-04-16 | Information Disclosure | Device names in SSE | accept | Device names visible on LAN only; PRT Qutie name is not sensitive |
| </threat_model> |
<success_criteria>
- useUSBEvents hook subscribes to /api/usb/events SSE and maintains connected device map
- USBStatusBar shows connected devices with role icons and green status dots; empty state when none
- DashboardPage header includes USBStatusBar
- Each item card has a Printer icon button wired to POST /api/labels/:id/print
- Print success shows success toast; printer unavailable shows warning toast; error shows error toast
- No TypeScript errors, no ESLint errors </success_criteria>