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

11 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
04-usb-manager-label-printing 05 execute 3
04-03
web/src/components/USBStatusBar.tsx
web/src/hooks/useUSBEvents.ts
web/src/pages/DashboardPage.tsx
true
USB-04
truths artifacts key_links
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
path provides exports
web/src/hooks/useUSBEvents.ts React hook subscribing to GET /api/usb/events SSE stream, returns DeviceEvent[]
useUSBEvents
DeviceEvent
DeviceState
path provides exports
web/src/components/USBStatusBar.tsx USBStatusBar component showing connected USB devices with role icons
USBStatusBar
path provides
web/src/pages/DashboardPage.tsx Updated dashboard with USBStatusBar in header area and Print Label quick action on item cards
from to via pattern
web/src/hooks/useUSBEvents.ts GET /api/usb/events new EventSource('/api/usb/events') with onmessage handler EventSource
from to via pattern
web/src/pages/DashboardPage.tsx POST /api/labels/:id/print fetch('/api/labels/${id}/print', {method:'POST'}) in handlePrintLabel api/labels
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.

<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.tsx

SSE 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
Task 1: useUSBEvents hook + USBStatusBar component web/src/hooks/useUSBEvents.ts, web/src/components/USBStatusBar.tsx Read `web/src/pages/DashboardPage.tsx` to understand the existing component patterns before writing.

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>
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

<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>
After completion, create `.planning/phases/04-usb-manager-label-printing/04-05-SUMMARY.md`