homelabby/.planning/phases/04-usb-manager-label-printing/04-05-PLAN.md
Mikkel Georgsen 77bf4ebfd6 docs(04): create phase 4 plans — USB manager, label printing, SSE, intake integration
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>
2026-04-10 06:41:26 +00:00

329 lines
11 KiB
Markdown

---
phase: 04-usb-manager-label-printing
plan: "05"
type: execute
wave: 3
depends_on: [04-03]
files_modified:
- web/src/components/USBStatusBar.tsx
- web/src/hooks/useUSBEvents.ts
- web/src/pages/DashboardPage.tsx
autonomous: true
requirements: [USB-04]
must_haves:
truths:
- "USB device connect/disconnect events appear in the frontend in real time"
- "Dashboard shows a USB status bar with connected device names and roles"
- "Print Label button on item cards calls POST /api/labels/:id/print and shows a toast on success/failure"
- "USB status bar updates without a page reload when a device is plugged or unplugged"
artifacts:
- path: web/src/hooks/useUSBEvents.ts
provides: "React hook subscribing to GET /api/usb/events SSE stream, returns DeviceEvent[]"
exports: [useUSBEvents, DeviceEvent, DeviceState]
- path: web/src/components/USBStatusBar.tsx
provides: "USBStatusBar component showing connected USB devices with role icons"
exports: [USBStatusBar]
- path: web/src/pages/DashboardPage.tsx
provides: "Updated dashboard with USBStatusBar in header area and Print Label quick action on item cards"
key_links:
- from: web/src/hooks/useUSBEvents.ts
to: GET /api/usb/events
via: "new EventSource('/api/usb/events') with onmessage handler"
pattern: "EventSource"
- from: web/src/pages/DashboardPage.tsx
to: POST /api/labels/:id/print
via: "fetch('/api/labels/${id}/print', {method:'POST'}) in handlePrintLabel"
pattern: "api/labels"
---
<objective>
Build the frontend USB status bar and wire the print label quick action on dashboard item cards.
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-usb-manager-label-printing/04-CONTEXT.md
@web/src/pages/DashboardPage.tsx
</context>
<interfaces>
<!-- SSE event shape from Plan 04-03 (usb_events.go): -->
SSE message format — each event is `data: {JSON}\n\n` where JSON is:
```typescript
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:
```typescript
// 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
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: useUSBEvents hook + USBStatusBar component</name>
<files>web/src/hooks/useUSBEvents.ts, web/src/components/USBStatusBar.tsx</files>
<action>
Read `web/src/pages/DashboardPage.tsx` to understand the existing component patterns before writing.
Create `web/src/hooks/useUSBEvents.ts`:
```typescript
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`:
```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>
);
}
```
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&amp;1 | tail -20</automated>
</verify>
<done>TypeScript compilation clean. useUSBEvents hook and USBStatusBar component created with no type errors.</done>
</task>
<task type="auto">
<name>Task 2: Wire USBStatusBar + Print Label button into DashboardPage</name>
<files>web/src/pages/DashboardPage.tsx</files>
<action>
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:
```tsx
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:
```tsx
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).
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npx tsc --noEmit && npx eslint src/pages/DashboardPage.tsx --max-warnings 0 2>&amp;1 | tail -20</automated>
</verify>
<done>TypeScript and ESLint clean. USBStatusBar renders in dashboard header. Print Label button present on each item card.</done>
</task>
</tasks>
<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>
<verification>
1. `npx tsc --noEmit` in web/ — clean
2. `npx eslint src/` — no errors or warnings on new files
3. `npm run build` — production build succeeds
</verification>
<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>
<output>
After completion, create `.planning/phases/04-usb-manager-label-printing/04-05-SUMMARY.md`
</output>