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>
329 lines
11 KiB
Markdown
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>&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>&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>
|