From 1a9931f900e93b3d25a4e6170b8e89e58a620977 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 06:12:02 +0000 Subject: [PATCH] =?UTF-8?q?docs(03):=20create=20phase=203=20plans=20?= =?UTF-8?q?=E2=80=94=20dashboard,=20intake=20UI,=20PWA=20(5=20plans,=202?= =?UTF-8?q?=20waves)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .planning/ROADMAP.md | 12 +- .../03-dashboard-intake-ui/03-01-PLAN.md | 819 ++++++++++++++++ .../03-dashboard-intake-ui/03-02-PLAN.md | 346 +++++++ .../03-dashboard-intake-ui/03-03-PLAN.md | 884 ++++++++++++++++++ .../03-dashboard-intake-ui/03-04-PLAN.md | 732 +++++++++++++++ .../03-dashboard-intake-ui/03-05-PLAN.md | 690 ++++++++++++++ 6 files changed, 3480 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/03-dashboard-intake-ui/03-01-PLAN.md create mode 100644 .planning/phases/03-dashboard-intake-ui/03-02-PLAN.md create mode 100644 .planning/phases/03-dashboard-intake-ui/03-03-PLAN.md create mode 100644 .planning/phases/03-dashboard-intake-ui/03-04-PLAN.md create mode 100644 .planning/phases/03-dashboard-intake-ui/03-05-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 1ae2c04..f96a40e 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -69,8 +69,14 @@ Plans: 3. User can view full item detail including photos, specs, test data, and audit history 4. Intake flow accepts 1-3 photo uploads, shows AI classification result inline, and allows correction before creating the NetBox record 5. App is installable as a PWA on Android and the camera-based QR scanner resolves items by HW ID -**Plans**: TBD -**UI hint**: yes +**Plans**: 5 plans + +Plans: +- [ ] 03-01-PLAN.md — Frontend scaffold: Vite 5 + React 18 + TypeScript + Tailwind + shadcn/ui, ClickHouse design tokens, TanStack Router/Query, Zustand +- [ ] 03-02-PLAN.md — Backend inventory endpoints: GET /api/inventory + GET /api/inventory/:id handlers + router wiring +- [ ] 03-03-PLAN.md — Dashboard + item detail views: grid/list toggle, filters, ItemCard/ItemRow, item detail with photos and custom fields +- [ ] 03-04-PLAN.md — Intake flow UI: DropZone, photo preview, AI result review, confidence meter, wired to POST /api/intake +- [ ] 03-05-PLAN.md — PWA setup: manifest.json, service worker, icons, camera QR scanner at /scan ### Phase 4: USB Manager & Label Printing **Goal**: USB peripherals are managed by a goroutine-per-device subsystem and any cataloged item can have a QR-coded label printed by the PRT Qutie without operator intervention after intake @@ -130,7 +136,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 |-------|----------------|--------|-----------| | 1. Foundation | 5/5 | Complete | 2026-04-10 | | 2. AI Pipeline | 4/4 | Complete | 2026-04-10 | -| 3. Dashboard & Intake UI | 0/TBD | Not started | - | +| 3. Dashboard & Intake UI | 0/5 | Not started | - | | 4. USB Manager & Label Printing | 0/TBD | Not started | - | | 5. Cable Test Integration | 0/TBD | Not started | - | | 6. Lab Advisor | 0/TBD | Not started | - | diff --git a/.planning/phases/03-dashboard-intake-ui/03-01-PLAN.md b/.planning/phases/03-dashboard-intake-ui/03-01-PLAN.md new file mode 100644 index 0000000..228fdd9 --- /dev/null +++ b/.planning/phases/03-dashboard-intake-ui/03-01-PLAN.md @@ -0,0 +1,819 @@ +--- +phase: 03-dashboard-intake-ui +plan: "01" +type: execute +wave: 1 +depends_on: [] +files_modified: + - web/package.json + - web/tsconfig.json + - web/tsconfig.node.json + - web/vite.config.ts + - web/tailwind.config.ts + - web/postcss.config.cjs + - web/index.html + - web/src/main.tsx + - web/src/App.tsx + - web/src/router.tsx + - web/src/lib/queryClient.ts + - web/src/store/ui.ts + - web/src/styles/globals.css + - web/components.json + - web/src/components/ui/button.tsx + - web/src/components/ui/card.tsx + - web/src/components/ui/badge.tsx + - Makefile +autonomous: true +requirements: [UI-06] + +must_haves: + truths: + - "Running `npm run dev` inside web/ serves the SPA at http://localhost:5173 with /api proxied to :8080" + - "Running `npm run build` inside web/ produces web/dist/index.html and web/dist/assets/ that Go can embed" + - "The page background is #000000 with neon volt (#faff69) as the accent color" + - "Inter font weight 900 is loaded and applied to display headings" + - "shadcn/ui Button, Card, and Badge components are available with ClickHouse tokens" + - "TanStack Router renders a /health-check route returning a plain text component" + - "TanStack Query QueryClientProvider wraps the app" + - "Zustand uiStore is defined and importable" + artifacts: + - path: "web/package.json" + provides: "npm project manifest with all dependencies pinned" + - path: "web/tailwind.config.ts" + provides: "ClickHouse design tokens as Tailwind CSS custom colors/fonts" + - path: "web/src/main.tsx" + provides: "React 18 entry point with Router + QueryClient providers" + - path: "web/src/router.tsx" + provides: "TanStack Router route tree with placeholder routes" + - path: "web/src/store/ui.ts" + provides: "Zustand UI store (view mode toggle, scanner state)" + - path: "web/components.json" + provides: "shadcn/ui CLI config" + - path: "web/src/components/ui/button.tsx" + provides: "shadcn/ui Button primitive" + - path: "web/src/components/ui/card.tsx" + provides: "shadcn/ui Card primitive" + - path: "web/src/components/ui/badge.tsx" + provides: "shadcn/ui Badge primitive" + key_links: + - from: "web/src/main.tsx" + to: "web/src/router.tsx" + via: "RouterProvider from @tanstack/react-router" + - from: "web/tailwind.config.ts" + to: "web/src/styles/globals.css" + via: "@tailwind directives + CSS custom properties" + - from: "web/vite.config.ts" + to: "http://localhost:8080" + via: "proxy: { '/api': 'http://localhost:8080' }" +--- + + +Bootstrap the React+TypeScript frontend with the full toolchain and ClickHouse design system. + +Purpose: Every subsequent UI plan depends on this scaffold existing. The design tokens, component library, routing shell, and build pipeline must all be correct before feature development begins. +Output: A buildable SPA in web/ with Vite 5, React 18, TypeScript 5, Tailwind 3, shadcn/ui, TanStack Router v1, TanStack Query v5, Zustand v4, and the ClickHouse visual identity. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md +@assets.go +@web/dist/index.html + + + + + + +From assets.go: +```go +//go:embed web/dist +var StaticFiles embed.FS +``` + +From internal/api/router.go (SPA fallback handler): +```go +// Serves static files from embedded FS; falls back to index.html for client-side routing +r.Handle("/*", spaHandler{staticFS: staticFiles}) +``` + + + + + + + Task 1: Scaffold Vite+React project and apply ClickHouse design tokens + + web/package.json, + web/tsconfig.json, + web/tsconfig.node.json, + web/vite.config.ts, + web/tailwind.config.ts, + web/postcss.config.cjs, + web/index.html, + web/src/styles/globals.css, + web/components.json + + + Read first: `web/dist/index.html` (current stub), `assets.go`, `internal/api/router.go`. + + **1. Create web/package.json** with these exact dependencies: + ```json + { + "name": "hwlab", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.1.0", + "@tanstack/react-query": "^5.51.0", + "@tanstack/react-router": "^1.48.0", + "@tanstack/router-devtools": "^1.48.0", + "@zxing/browser": "^0.1.5", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.417.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-dropzone": "^14.2.3", + "react-hot-toast": "^2.4.1", + "tailwind-merge": "^2.5.2", + "zustand": "^4.5.4" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.15.0", + "@typescript-eslint/parser": "^7.15.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "eslint": "^9.7.0", + "postcss": "^8.4.40", + "tailwindcss": "^3.4.7", + "typescript": "^5.5.3", + "vite": "^5.3.5" + } + } + ``` + + **2. Create web/vite.config.ts:** + ```typescript + import { defineConfig } from 'vite' + import react from '@vitejs/plugin-react' + import path from 'path' + + export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, + }) + ``` + + **3. Create web/tsconfig.json** (strict mode, path aliases): + ```json + { + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] + } + ``` + + Create web/tsconfig.app.json: + ```json + { + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] + } + ``` + + Create web/tsconfig.node.json: + ```json + { + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true + }, + "include": ["vite.config.ts"] + } + ``` + + **4. Create web/postcss.config.cjs:** + ```js + module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, + } + ``` + + **5. Create web/tailwind.config.ts** with ClickHouse design tokens: + ```typescript + import type { Config } from 'tailwindcss' + + const config: Config = { + darkMode: ['class'], + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + // ClickHouse design system tokens + 'volt': '#faff69', // neon yellow-green — primary CTA accent + 'volt-pale': '#f4f692', // active/pressed state text + 'forest': '#166534', // primary action button (Get Started) + 'forest-dark':'#14572f', // forest border variant + 'olive-dark': '#161600', // subtle brand text + 'border-olive':'#4f5100', // ghost button borders + // Surfaces + 'canvas': '#000000', // pure black background + 'near-black': '#141414', // button bg, elevated surfaces + 'hover-gray': '#3a3a3a', // button hover bg + 'charcoal': '#414141', // primary border (used at 80% opacity in CSS) + 'deep-charcoal':'#343434',// subtle dividers + }, + fontFamily: { + display: ['Inter', 'system-ui', 'sans-serif'], + body: ['Inter', 'system-ui', 'sans-serif'], + code: ['Inconsolata', 'monospace'], + }, + fontWeight: { + black: '900', + }, + borderRadius: { + sharp: '4px', + card: '8px', + }, + letterSpacing: { + widest: '1.4px', + }, + }, + }, + plugins: [], + } + + export default config + ``` + + **6. Create web/index.html** (replace the stub — this is the Vite entry point, not the final dist): + ```html + + + + + + + + HWLab + + + + + +
+ + + + ``` + + **7. Create web/src/styles/globals.css** with CSS custom properties and Tailwind directives: + ```css + @tailwind base; + @tailwind components; + @tailwind utilities; + + @layer base { + :root { + --background: 0 0% 0%; + --foreground: 0 0% 100%; + --card: 0 0% 8%; + --card-foreground: 0 0% 100%; + --border: 0 0% 25%; + --ring: 64 100% 71%; + --radius: 0.5rem; + } + + * { + border-color: rgba(65, 65, 65, 0.8); + } + + body { + background-color: #000000; + color: #ffffff; + } + + /* Charcoal border utility */ + .border-charcoal-80 { + border-color: rgba(65, 65, 65, 0.8); + } + + /* ClickHouse card elevation */ + .card-elevated { + box-shadow: + rgba(0,0,0,0.1) 0px 10px 15px -3px, + rgba(0,0,0,0.1) 0px 4px 6px -4px; + } + + /* Neon accent glow (use sparingly) */ + .ring-volt { + box-shadow: 0 0 0 2px #faff69; + } + + /* Uppercase tracked label */ + .label-upper { + text-transform: uppercase; + letter-spacing: 1.4px; + font-weight: 600; + font-size: 0.75rem; + } + } + ``` + + **8. Create web/components.json** (shadcn/ui CLI config — dark mode, no rsc, Tailwind aliases): + ```json + { + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/styles/globals.css", + "baseColor": "zinc", + "cssVariables": false, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" + } + ``` + + After creating config files: `cd web && npm install` to generate package-lock.json. +
+ + cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -5 + + + `npm run build` inside web/ completes without errors and produces web/dist/index.html and web/dist/assets/. The tailwind.config.ts contains all ClickHouse color tokens (volt, forest, canvas, charcoal, near-black). The vite.config.ts proxy routes /api to http://localhost:8080. + +
+ + + Task 2: Wire React app entry point with Router, QueryClient, design primitives, and shadcn/ui components + + web/src/main.tsx, + web/src/App.tsx, + web/src/router.tsx, + web/src/lib/utils.ts, + web/src/lib/queryClient.ts, + web/src/store/ui.ts, + web/src/components/ui/button.tsx, + web/src/components/ui/card.tsx, + web/src/components/ui/badge.tsx + + + Read first: `web/src/styles/globals.css`, `web/tailwind.config.ts` (just created in Task 1). + + **1. Create web/src/lib/utils.ts** (shadcn/ui cn helper): + ```typescript + import { clsx, type ClassValue } from 'clsx' + import { twMerge } from 'tailwind-merge' + + export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) + } + ``` + + **2. Create web/src/lib/queryClient.ts:** + ```typescript + import { QueryClient } from '@tanstack/react-query' + + export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, // 30s — inventory data doesn't change rapidly + retry: 2, + refetchOnWindowFocus: false, + }, + }, + }) + ``` + + **3. Create web/src/store/ui.ts** (Zustand — UI state only, not server data): + ```typescript + import { create } from 'zustand' + + type ViewMode = 'grid' | 'list' + + interface UIStore { + viewMode: ViewMode + setViewMode: (mode: ViewMode) => void + scannerActive: boolean + setScannerActive: (active: boolean) => void + intakeStep: number + setIntakeStep: (step: number) => void + } + + export const useUIStore = create((set) => ({ + viewMode: 'grid', + setViewMode: (mode) => set({ viewMode: mode }), + scannerActive: false, + setScannerActive: (active) => set({ scannerActive: active }), + intakeStep: 0, + setIntakeStep: (step) => set({ intakeStep: step }), + })) + ``` + + **4. Create web/src/router.tsx** — TanStack Router v1 route tree with placeholder routes for all Phase 3 views: + ```typescript + import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router' + import { TanStackRouterDevtools } from '@tanstack/router-devtools' + + // Root layout — wraps all routes with the app shell + const rootRoute = createRootRoute({ + component: () => ( + <> + + {import.meta.env.DEV && } + + ), + }) + + // Routes — components are lazy-imported in Plan 03 and 04; stubs for now + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+

HWLab — Dashboard loading…

+
+ ), + }) + + const itemRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/item/$id', + component: () => ( +
+

Item detail loading…

+
+ ), + }) + + const intakeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/intake', + component: () => ( +
+

Intake wizard loading…

+
+ ), + }) + + const scanRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/scan', + component: () => ( +
+

QR Scanner loading…

+
+ ), + }) + + const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute]) + + export const router = createRouter({ + routeTree, + defaultPreload: 'intent', + }) + + // TypeScript type augmentation for TanStack Router + declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } + } + ``` + + **5. Create web/src/App.tsx:** + ```typescript + import { QueryClientProvider } from '@tanstack/react-query' + import { RouterProvider } from '@tanstack/react-router' + import { Toaster } from 'react-hot-toast' + import { queryClient } from '@/lib/queryClient' + import { router } from '@/router' + + export function App() { + return ( + + + + + ) + } + ``` + + **6. Create web/src/main.tsx:** + ```typescript + import React from 'react' + import ReactDOM from 'react-dom/client' + import './styles/globals.css' + import { App } from './App' + + ReactDOM.createRoot(document.getElementById('root')!).render( + + + , + ) + ``` + + **7. Create shadcn/ui Button component at web/src/components/ui/button.tsx** (ClickHouse-themed variants): + ```typescript + import * as React from 'react' + import { Slot } from '@radix-ui/react-slot' + import { cva, type VariantProps } from 'class-variance-authority' + import { cn } from '@/lib/utils' + + const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-volt disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + // Neon primary — #faff69 background, near-black text + default: 'bg-volt text-near-black rounded-sharp border border-volt hover:bg-near-black hover:text-white active:text-volt-pale', + // Forest green — primary action + forest: 'bg-forest text-white rounded-sharp border border-near-black hover:bg-near-black active:text-volt-pale', + // Dark solid + secondary: 'bg-near-black text-white rounded-sharp border border-near-black hover:bg-hover-gray active:text-volt-pale', + // Ghost / outlined + outline: 'bg-transparent text-white rounded-sharp border border-border-olive hover:bg-near-black active:text-volt-pale', + // Destructive + destructive: 'bg-red-900 text-white rounded-sharp hover:bg-red-800', + // Invisible — just text + ghost: 'bg-transparent text-white hover:text-volt', + link: 'text-volt underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 px-3 text-xs', + lg: 'h-11 px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, + ) + + export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean + } + + const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + }, + ) + Button.displayName = 'Button' + + export { Button, buttonVariants } + ``` + + **8. Create web/src/components/ui/card.tsx** (ClickHouse charcoal card): + ```typescript + import * as React from 'react' + import { cn } from '@/lib/utils' + + const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), + ) + Card.displayName = 'Card' + + const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), + ) + CardHeader.displayName = 'CardHeader' + + const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), + ) + CardTitle.displayName = 'CardTitle' + + const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>
, + ) + CardContent.displayName = 'CardContent' + + const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), + ) + CardFooter.displayName = 'CardFooter' + + export { Card, CardHeader, CardTitle, CardContent, CardFooter } + ``` + + **9. Create web/src/components/ui/badge.tsx** (status badges with ClickHouse tokens): + ```typescript + import * as React from 'react' + import { cva, type VariantProps } from 'class-variance-authority' + import { cn } from '@/lib/utils' + + const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors', + { + variants: { + variant: { + default: 'border-volt/40 bg-volt/10 text-volt', + indexed: 'border-green-500/40 bg-green-500/10 text-green-400', + draft: 'border-charcoal/80 bg-charcoal/20 text-gray-400', + needs_research: 'border-yellow-500/40 bg-yellow-500/10 text-yellow-400', + researched: 'border-blue-500/40 bg-blue-500/10 text-blue-400', + complete: 'border-forest/40 bg-forest/20 text-green-300', + destructive: 'border-red-500/40 bg-red-500/10 text-red-400', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, + ) + + export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + + function Badge({ className, variant, ...props }: BadgeProps) { + return
+ } + + export { Badge, badgeVariants } + ``` + + **10. Update Makefile** to add frontend build and dev targets: + - Read current Makefile first. + - Add these targets after existing ones: + ```makefile + frontend: + cd web && npm run build + + dev-frontend: + cd web && npm run dev + + dev: + air -c .air.toml + ``` + (Note: `dev` already exists — if it does, keep the existing `dev` and add only `frontend` and `dev-frontend`.) + + Finally run `cd web && npm run build` to verify the build succeeds and web/dist gets populated. + + + cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 && ls dist/assets/*.js 2>/dev/null | head -3 + + + `npm run build` succeeds (exit 0). `web/dist/assets/` contains at least one .js file. `web/src/store/ui.ts` exports `useUIStore` with `viewMode`, `scannerActive`, `intakeStep`. `web/src/router.tsx` exports `router` with routes for `/`, `/item/$id`, `/intake`, `/scan`. All shadcn/ui primitives (Button, Card, Badge) have ClickHouse variants. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → Go /api | All API requests cross this boundary; frontend must not forge hw_id or bypass intake | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-01 | Tampering | vite.config.ts proxy | accept | Dev proxy is localhost-only; no external exposure; production uses embedded SPA with no proxy | +| T-03-02 | Info Disclosure | google fonts CDN | accept | Only font metrics exposed; no auth tokens; acceptable for homelab context | +| T-03-03 | Spoofing | TanStack Router client-side routes | accept | No auth in Phase 3 — all routes are public; server enforces data access via NetBox token | + + + +From project root: +1. `cd web && npm run build` — exits 0, produces web/dist/index.html +2. `ls web/dist/assets/*.js` — at least one JS bundle present +3. `go build ./...` — Go binary still compiles (embed path unchanged) +4. `grep -r "volt" web/tailwind.config.ts` — confirms ClickHouse tokens present +5. `grep "proxy" web/vite.config.ts` — confirms /api proxy to :8080 + + + +- Vite 5 + React 18 + TypeScript 5 + Tailwind 3 project exists in web/ +- `npm run build` produces web/dist/ ready for Go embed +- ClickHouse design tokens (volt, forest, canvas, charcoal, near-black) available as Tailwind classes +- Inter 900 weight loaded from Google Fonts +- TanStack Router renders placeholder routes for /, /item/$id, /intake, /scan +- TanStack Query QueryClientProvider wraps the app +- Zustand uiStore tracks viewMode, scannerActive, intakeStep +- shadcn/ui Button (neon/forest/secondary/outline variants), Card, Badge primitives exist +- Makefile has `frontend` and `dev-frontend` targets + + + +After completion, create `.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md` following the summary template. + diff --git a/.planning/phases/03-dashboard-intake-ui/03-02-PLAN.md b/.planning/phases/03-dashboard-intake-ui/03-02-PLAN.md new file mode 100644 index 0000000..28e1940 --- /dev/null +++ b/.planning/phases/03-dashboard-intake-ui/03-02-PLAN.md @@ -0,0 +1,346 @@ +--- +phase: 03-dashboard-intake-ui +plan: "02" +type: execute +wave: 1 +depends_on: [] +files_modified: + - internal/api/handlers/inventory.go + - internal/api/handlers/inventory_test.go + - internal/api/router.go +autonomous: true +requirements: [UI-01, UI-04] + +must_haves: + truths: + - "GET /api/inventory returns a JSON array of inventory items with hw_id, name, catalog_status, and specs" + - "GET /api/inventory/:id returns a single item by NetBox device ID including all custom fields" + - "Both endpoints return proper HTTP status codes (200 on success, 404 for missing item, 502 on NetBox error)" + - "Unit tests pass without a live NetBox connection" + artifacts: + - path: "internal/api/handlers/inventory.go" + provides: "InventoryHandler with ListInventory and GetInventoryItem methods" + exports: ["InventoryHandler", "NewInventoryHandler", "InventoryNetBoxClient"] + - path: "internal/api/handlers/inventory_test.go" + provides: "Unit tests for list and detail endpoints with mock NetBox client" + - path: "internal/api/router.go" + provides: "GET /api/inventory and GET /api/inventory/:id routes registered" + key_links: + - from: "internal/api/handlers/inventory.go" + to: "internal/netbox.Client" + via: "InventoryNetBoxClient interface (ListDevices + GetDevice)" + - from: "internal/api/router.go" + to: "internal/api/handlers.InventoryHandler" + via: "r.Get('/inventory', ...) and r.Get('/inventory/{id}', ...)" +--- + + +Add GET /api/inventory and GET /api/inventory/:id endpoints to the Go backend so the React dashboard can fetch real inventory data. + +Purpose: The dashboard view (Plan 03) depends on these endpoints. They must exist and be testable before the frontend wires up TanStack Query hooks. +Output: Two handler methods behind an interface, registered on the chi router, with unit tests. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md +@.planning/phases/02-ai-pipeline/02-03-SUMMARY.md +@internal/api/router.go +@internal/netbox/client.go +@internal/api/handlers/intake.go + + + +```go +// ListDevices returns up to limit devices from NetBox. +func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error) + +// GetDevice retrieves a single device by its NetBox internal ID. +func (c *Client) GetDevice(ctx context.Context, id int) (*Device, error) +``` + + +```go +type Device struct { + ID int + Name string + AssetTag string // HW-XXXXX id + CustomFields CustomFields +} + +type CustomFields struct { + HWID string + CatalogStatus string + ProductURL string + FirmwareVersion string + TestDate string + TestData string + AINotes string + PhotoURLs []string +} +``` + + +```go +func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler +``` + + +// Define a narrow interface (InventoryNetBoxClient) rather than taking *netbox.Client directly. +// This allows complete unit testing without a real NetBox. + + + + + + + Task 1: InventoryHandler — list and detail endpoints with interface + unit tests + + internal/api/handlers/inventory.go, + internal/api/handlers/inventory_test.go + + + - TestListInventoryEmpty: mock returns [], handler returns 200 with JSON `[]` + - TestListInventoryItems: mock returns 2 devices, handler returns 200 with JSON array of 2 items containing hw_id, name, catalog_status, asset_tag, photo_urls + - TestListInventoryNetBoxError: mock returns error, handler returns 502 with {"error": "..."} + - TestGetInventoryItemFound: mock returns device with id=42, GET /api/inventory/42 returns 200 with device JSON + - TestGetInventoryItemNotFound: mock returns nil device + error containing "404", handler returns 404 + - TestGetInventoryItemInvalidID: GET /api/inventory/abc returns 400 + - TestGetInventoryItemNetBoxError: mock returns server error, handler returns 502 + + + Read first: `internal/api/handlers/intake.go` (interface-for-testability pattern), `internal/netbox/types.go`, `internal/netbox/client.go`. + + **RED phase — write tests first:** + + Create `internal/api/handlers/inventory_test.go` with a `mockInventoryNetBoxClient` struct implementing `InventoryNetBoxClient`. Write all 7 tests listed in ``. Run `go test ./internal/api/handlers/... -run TestInventory` — tests must FAIL (handler does not exist yet). + + **GREEN phase — implement to pass tests:** + + Create `internal/api/handlers/inventory.go`: + + ```go + package handlers + + import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "git.georgsen.dk/hwlab/internal/netbox" + ) + + // InventoryNetBoxClient is the narrow interface the inventory handler needs. + // *netbox.Client satisfies this interface. + type InventoryNetBoxClient interface { + ListDevices(ctx context.Context, limit int) ([]netbox.Device, error) + GetDevice(ctx context.Context, id int) (*netbox.Device, error) + } + + // InventoryHandler handles GET /api/inventory and GET /api/inventory/{id}. + type InventoryHandler struct { + nb InventoryNetBoxClient + } + + // NewInventoryHandler creates an InventoryHandler. + func NewInventoryHandler(nb InventoryNetBoxClient) *InventoryHandler { + return &InventoryHandler{nb: nb} + } + + // InventoryItemResponse is the JSON shape the frontend consumes. + type InventoryItemResponse struct { + ID int `json:"id"` + Name string `json:"name"` + AssetTag string `json:"asset_tag"` + HWID string `json:"hw_id"` + CatalogStatus string `json:"catalog_status"` + ProductURL string `json:"product_url,omitempty"` + Firmware string `json:"firmware_version,omitempty"` + TestDate string `json:"test_date,omitempty"` + TestData string `json:"test_data,omitempty"` + AINotes string `json:"ai_notes,omitempty"` + PhotoURLs []string `json:"photo_urls"` + } + + func deviceToResponse(d netbox.Device) InventoryItemResponse { + urls := d.CustomFields.PhotoURLs + if urls == nil { + urls = []string{} + } + return InventoryItemResponse{ + ID: d.ID, + Name: d.Name, + AssetTag: d.AssetTag, + HWID: d.CustomFields.HWID, + CatalogStatus: d.CustomFields.CatalogStatus, + ProductURL: d.CustomFields.ProductURL, + Firmware: d.CustomFields.FirmwareVersion, + TestDate: d.CustomFields.TestDate, + TestData: d.CustomFields.TestData, + AINotes: d.CustomFields.AINotes, + PhotoURLs: urls, + } + } + + // ListInventory handles GET /api/inventory — returns up to 200 items. + func (h *InventoryHandler) ListInventory(w http.ResponseWriter, r *http.Request) { + devices, err := h.nb.ListDevices(r.Context(), 200) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("netbox unavailable: %s", err)}) + return + } + items := make([]InventoryItemResponse, 0, len(devices)) + for _, d := range devices { + items = append(items, deviceToResponse(d)) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(items) + } + + // GetInventoryItem handles GET /api/inventory/{id}. + func (h *InventoryHandler) GetInventoryItem(w http.ResponseWriter, r *http.Request) { + rawID := chi.URLParam(r, "id") + id, err := strconv.Atoi(rawID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "id must be an integer"}) + return + } + + device, err := h.nb.GetDevice(r.Context(), id) + if err != nil { + w.Header().Set("Content-Type", "application/json") + if strings.Contains(err.Error(), "404") || strings.Contains(strings.ToLower(err.Error()), "not found") { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "item not found"}) + return + } + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("netbox unavailable: %s", err)}) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(deviceToResponse(*device)) + } + ``` + + Run `go test ./internal/api/handlers/... -run TestInventory` — all 7 tests must PASS. + + + cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -run TestInventory -v 2>&1 | tail -20 + + + All 7 TestInventory* tests pass. `internal/api/handlers/inventory.go` exports `InventoryHandler`, `NewInventoryHandler`, `InventoryNetBoxClient`, `InventoryItemResponse`. `go build ./...` succeeds. + + + + + Task 2: Wire inventory routes into chi router and main.go + + internal/api/router.go, + cmd/hwlab/main.go + + + Read first: `internal/api/router.go`, `cmd/hwlab/main.go`. + + **1. Update internal/api/router.go** — extend `NewRouter` to accept an inventory handler: + + Change signature from: + ```go + func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler + ``` + To: + ```go + func NewRouter(staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *handlers.InventoryHandler) http.Handler + ``` + + Inside the `/api` route group, add: + ```go + r.Get("/inventory", inventoryHandler.ListInventory) + r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem) + ``` + + Full updated `/api` block: + ```go + r.Route("/api", func(r chi.Router) { + r.Get("/health", handlers.Health) + r.Post("/intake", intakeHandler.ServeHTTP) + r.Get("/inventory", inventoryHandler.ListInventory) + r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem) + }) + ``` + + **2. Update cmd/hwlab/main.go** — construct InventoryHandler and pass to NewRouter: + + After the existing netbox client construction (search for `netbox.NewClient`), add: + ```go + inventoryHandler := handlers.NewInventoryHandler(nbClient) + ``` + + Update the `api.NewRouter(...)` call to include `inventoryHandler`: + ```go + router := api.NewRouter(webFS, intakeHandler, inventoryHandler) + ``` + + Run `go build ./...` and `go test ./...` to confirm no regressions. + + + cd /home/mikkel/homelabby && go build ./... 2>&1 && go test ./... 2>&1 | tail -15 + + + `go build ./...` exits 0. `go test ./...` — all existing tests pass (integration tests skip gracefully). `grep "inventory" internal/api/router.go` shows both GET routes. `grep "inventoryHandler" cmd/hwlab/main.go` confirms construction and wiring. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → GET /api/inventory | Untrusted caller requests inventory list — no auth in Phase 3 | +| Browser → GET /api/inventory/:id | URL parameter id is untrusted input | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-04 | Tampering | GET /api/inventory/{id} URL param | mitigate | `strconv.Atoi` rejects non-integer IDs; returns 400 — no raw string reaches NetBox API call | +| T-03-05 | Info Disclosure | GET /api/inventory error messages | accept | Error messages describe NetBox connectivity issues only; no credentials or internal paths exposed | +| T-03-06 | DoS | GET /api/inventory limit | mitigate | Hard-coded limit=200 in ListDevices call; caller cannot request unbounded result sets | +| T-03-07 | Elevation of Privilege | Inventory endpoints unauthenticated | accept | Phase 3 is homelab single-operator; no PII or critical data; Phase 4+ can add auth middleware if needed | + + + +From project root: +1. `go build ./...` — exits 0 +2. `go test ./internal/api/handlers/... -run TestInventory -v` — 7 tests pass +3. `go test ./...` — all packages pass (integration tests skip) +4. `grep -n "inventory" internal/api/router.go` — shows both GET /inventory and GET /inventory/{id} +5. `grep "NewInventoryHandler" cmd/hwlab/main.go` — confirms wiring + + + +- GET /api/inventory returns JSON array using real NetBox ListDevices (integration test skips gracefully without live NetBox) +- GET /api/inventory/:id returns single item or 404/502 with proper status codes +- ID validation rejects non-integer inputs with 400 +- 7 unit tests pass without live NetBox +- go build ./... succeeds — no regressions to Phase 1/2 code + + + +After completion, create `.planning/phases/03-dashboard-intake-ui/03-02-SUMMARY.md` following the summary template. + diff --git a/.planning/phases/03-dashboard-intake-ui/03-03-PLAN.md b/.planning/phases/03-dashboard-intake-ui/03-03-PLAN.md new file mode 100644 index 0000000..65cc98c --- /dev/null +++ b/.planning/phases/03-dashboard-intake-ui/03-03-PLAN.md @@ -0,0 +1,884 @@ +--- +phase: 03-dashboard-intake-ui +plan: "03" +type: execute +wave: 2 +depends_on: ["03-01", "03-02"] +files_modified: + - web/src/lib/api.ts + - web/src/hooks/useInventory.ts + - web/src/components/layout/AppShell.tsx + - web/src/components/layout/TopBar.tsx + - web/src/components/inventory/ItemCard.tsx + - web/src/components/inventory/ItemRow.tsx + - web/src/components/inventory/FilterBar.tsx + - web/src/components/inventory/StatusBadge.tsx + - web/src/pages/DashboardPage.tsx + - web/src/pages/ItemDetailPage.tsx + - web/src/router.tsx +autonomous: false +requirements: [UI-01, UI-02, UI-04, UI-05, PWA-02] + +must_haves: + truths: + - "Visiting / shows the inventory dashboard with a grid of item cards on desktop" + - "Each card shows: photo (or placeholder icon), HW ID, item name, catalog_status badge, and key spec line from ai_notes" + - "Grid/list toggle switches the layout without a page reload" + - "Filter dropdowns for category, catalog_status, and location narrow the displayed items client-side" + - "Clicking a card navigates to /item/:id and shows the item detail page" + - "Item detail shows all custom fields, photo URLs as images, ai_notes, test_data (raw JSON)" + - "Quick actions on dashboard cards: 'View in NetBox' opens new tab to NetBox device URL" + - "Item detail page is readable on a 390px-wide mobile screen (PWA-02)" + artifacts: + - path: "web/src/lib/api.ts" + provides: "typed fetch wrappers for GET /api/inventory and GET /api/inventory/:id" + exports: ["InventoryItem", "fetchInventory", "fetchInventoryItem"] + - path: "web/src/hooks/useInventory.ts" + provides: "TanStack Query hooks wrapping api.ts" + exports: ["useInventory", "useInventoryItem"] + - path: "web/src/components/inventory/ItemCard.tsx" + provides: "Grid card component (photo, HW ID, name, status badge, quick actions)" + - path: "web/src/components/inventory/FilterBar.tsx" + provides: "Client-side filter controls (catalog_status, search by name)" + - path: "web/src/pages/DashboardPage.tsx" + provides: "/ route — inventory grid/list with filters" + - path: "web/src/pages/ItemDetailPage.tsx" + provides: "/item/$id route — full detail view, mobile responsive" + key_links: + - from: "web/src/pages/DashboardPage.tsx" + to: "web/src/hooks/useInventory.ts" + via: "useInventory() hook → TanStack Query → GET /api/inventory" + - from: "web/src/pages/ItemDetailPage.tsx" + to: "web/src/hooks/useInventory.ts" + via: "useInventoryItem(id) hook → TanStack Query → GET /api/inventory/:id" + - from: "web/src/router.tsx" + to: "web/src/pages/DashboardPage.tsx" + via: "indexRoute component replaced with lazy(() => import('./pages/DashboardPage'))" +--- + + +Build the inventory dashboard and item detail views — the primary UI for browsing the homelab inventory. + +Purpose: Users need to see, filter, and navigate their cataloged hardware. This is the core browsing experience wired to the real backend. +Output: Dashboard page with grid/list toggle and filters; item detail page with all custom fields; mobile-responsive layout following ClickHouse design system. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md +@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md +@.planning/phases/03-dashboard-intake-ui/03-02-SUMMARY.md + + + +```typescript +// TypeScript equivalent of internal/api/handlers.InventoryItemResponse +interface InventoryItem { + id: number + name: string + asset_tag: string + hw_id: string + catalog_status: string // draft | indexed | needs_research | researched | complete + product_url?: string + firmware_version?: string + test_date?: string + test_data?: string // raw JSON string + ai_notes?: string + photo_urls: string[] +} +``` + + +```typescript +import { useUIStore } from '@/store/ui' +const { viewMode, setViewMode } = useUIStore() +// viewMode: 'grid' | 'list' +``` + + +// /item/$id route param access: +// import { useParams } from '@tanstack/react-router' +// const { id } = useParams({ from: '/item/$id' }) + + +// bg-canvas (#000000), bg-near-black (#141414), text-volt (#faff69) +// bg-forest (#166534), border-charcoal/80 (rgba(65,65,65,0.8)) +// rounded-card (8px), rounded-sharp (4px) +// font-display font-black (Inter 900) +// label-upper (uppercase tracked label) + + +// import { Button } from '@/components/ui/button' +// import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card' +// import { Badge } from '@/components/ui/badge' + + + + + + + Task 1: API client, TanStack Query hooks, AppShell layout, and ItemCard/ItemRow components + + web/src/lib/api.ts, + web/src/hooks/useInventory.ts, + web/src/components/layout/AppShell.tsx, + web/src/components/layout/TopBar.tsx, + web/src/components/inventory/ItemCard.tsx, + web/src/components/inventory/ItemRow.tsx, + web/src/components/inventory/StatusBadge.tsx + + + Read first: `web/src/store/ui.ts`, `web/src/components/ui/card.tsx`, `web/src/components/ui/badge.tsx`, `web/src/components/ui/button.tsx`. + + **1. Create web/src/lib/api.ts** — typed fetch wrappers: + ```typescript + export interface InventoryItem { + id: number + name: string + asset_tag: string + hw_id: string + catalog_status: string + product_url?: string + firmware_version?: string + test_date?: string + test_data?: string + ai_notes?: string + photo_urls: string[] + } + + const BASE = '/api' + + async function fetchJSON(url: string): Promise { + const res = await fetch(url) + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(body.error ?? `HTTP ${res.status}`) + } + return res.json() as Promise + } + + export const fetchInventory = (): Promise => + fetchJSON(`${BASE}/inventory`) + + export const fetchInventoryItem = (id: number): Promise => + fetchJSON(`${BASE}/inventory/${id}`) + ``` + + **2. Create web/src/hooks/useInventory.ts:** + ```typescript + import { useQuery } from '@tanstack/react-query' + import { fetchInventory, fetchInventoryItem } from '@/lib/api' + + export function useInventory() { + return useQuery({ + queryKey: ['inventory'], + queryFn: fetchInventory, + }) + } + + export function useInventoryItem(id: number) { + return useQuery({ + queryKey: ['inventory', id], + queryFn: () => fetchInventoryItem(id), + enabled: id > 0, + }) + } + ``` + + **3. Create web/src/components/inventory/StatusBadge.tsx** — maps catalog_status to Badge variant: + ```typescript + import { Badge } from '@/components/ui/badge' + + const STATUS_LABELS: Record = { + draft: 'Draft', + indexed: 'Indexed', + needs_research: 'Needs Research', + researched: 'Researched', + complete: 'Complete', + } + + type BadgeVariant = 'default' | 'indexed' | 'draft' | 'needs_research' | 'researched' | 'complete' | 'destructive' + + export function StatusBadge({ status }: { status: string }) { + const variant = (status in STATUS_LABELS ? status : 'draft') as BadgeVariant + return {STATUS_LABELS[status] ?? status} + } + ``` + + **4. Create web/src/components/inventory/ItemCard.tsx** — grid card (ClickHouse style): + The card shows: photo or placeholder icon, HW ID (volt text, font-code), item name (white, font-semibold), StatusBadge, ai_notes preview (silver, truncated to 2 lines), quick action buttons (View in NetBox). + + ```typescript + import { ExternalLink, Package } from 'lucide-react' + import { Link } from '@tanstack/react-router' + import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' + import { Button } from '@/components/ui/button' + import { StatusBadge } from './StatusBadge' + import type { InventoryItem } from '@/lib/api' + import { cn } from '@/lib/utils' + + interface ItemCardProps { + item: InventoryItem + className?: string + } + + export function ItemCard({ item, className }: ItemCardProps) { + const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/` + + return ( + + + {/* Photo */} +
+ {item.photo_urls.length > 0 ? ( + {item.name} + ) : ( + + )} +
+ + + {/* HW ID */} +

{item.hw_id || item.asset_tag}

+ {item.name} +
+ + + + {item.ai_notes && ( +

{item.ai_notes}

+ )} +
+ + + + +
+ + ) + } + ``` + + **5. Create web/src/components/inventory/ItemRow.tsx** — list-mode row: + Horizontal layout: status indicator bar (left, 4px, volt for indexed/complete, yellow for needs_research, gray for draft), HW ID, name, status badge, ai_notes preview, quick action. Uses `` or a flex div. + + ```typescript + import { ExternalLink } from 'lucide-react' + import { Link } from '@tanstack/react-router' + import { Button } from '@/components/ui/button' + import { StatusBadge } from './StatusBadge' + import type { InventoryItem } from '@/lib/api' + + const STATUS_COLOR: Record = { + indexed: 'bg-green-500', + complete: 'bg-volt', + researched: 'bg-blue-500', + needs_research: 'bg-yellow-500', + draft: 'bg-charcoal', + } + + export function ItemRow({ item }: { item: InventoryItem }) { + const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/` + const dot = STATUS_COLOR[item.catalog_status] ?? 'bg-charcoal' + + return ( + + {/* Status indicator */} +
+ + {/* HW ID */} + {item.hw_id || item.asset_tag} + + {/* Name */} + {item.name} + + {/* Status */} +
+ +
+ + {/* Notes preview */} + {item.ai_notes && ( + {item.ai_notes} + )} + + {/* Quick action */} + + + ) + } + ``` + + **6. Create web/src/components/layout/TopBar.tsx** — app-wide navigation bar: + Left: "HWLab" in Inter Black volt text. Right: intake button (forest green), scan QR button (outline). + + ```typescript + import { Plus, QrCode } from 'lucide-react' + import { Link } from '@tanstack/react-router' + import { Button } from '@/components/ui/button' + + export function TopBar() { + return ( +
+ + HWLab + +
+ + +
+
+ ) + } + ``` + + **7. Create web/src/components/layout/AppShell.tsx** — wraps TopBar + main content area: + ```typescript + import { TopBar } from './TopBar' + + export function AppShell({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ {children} +
+
+ ) + } + ``` + + + cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 + + + `npm run build` succeeds. `web/src/lib/api.ts` exports `InventoryItem`, `fetchInventory`, `fetchInventoryItem`. `web/src/hooks/useInventory.ts` exports `useInventory`, `useInventoryItem`. All five component files exist with no TypeScript errors. + + + + + Task 2: DashboardPage, FilterBar, ItemDetailPage, and router wiring + + web/src/components/inventory/FilterBar.tsx, + web/src/pages/DashboardPage.tsx, + web/src/pages/ItemDetailPage.tsx, + web/src/router.tsx + + + Read first: `web/src/router.tsx` (current stub routes), `web/src/store/ui.ts` (viewMode), `web/src/hooks/useInventory.ts`. + + **1. Create web/src/components/inventory/FilterBar.tsx** — client-side filter controls: + Filter state is local component state (not Zustand — ephemeral UI). Filters: text search (name/hw_id), catalog_status select. + + ```typescript + import { Search, LayoutGrid, List } from 'lucide-react' + import { Button } from '@/components/ui/button' + import { useUIStore } from '@/store/ui' + + interface FilterBarProps { + search: string + onSearchChange: (v: string) => void + statusFilter: string + onStatusChange: (v: string) => void + totalCount: number + } + + const STATUSES = ['', 'draft', 'indexed', 'needs_research', 'researched', 'complete'] + const STATUS_LABELS: Record = { + '': 'All Statuses', + draft: 'Draft', + indexed: 'Indexed', + needs_research: 'Needs Research', + researched: 'Researched', + complete: 'Complete', + } + + export function FilterBar({ search, onSearchChange, statusFilter, onStatusChange, totalCount }: FilterBarProps) { + const { viewMode, setViewMode } = useUIStore() + + return ( +
+ {/* Search */} +
+ + onSearchChange(e.target.value)} + className="w-full pl-9 pr-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white placeholder-[#585858] focus:outline-none focus:border-volt/60 focus:ring-1 focus:ring-volt/30" + /> +
+ + {/* Status filter */} + + + {/* Item count */} + + {totalCount} items + + + {/* View toggle */} +
+ + +
+
+ ) + } + ``` + + **2. Create web/src/pages/DashboardPage.tsx** — the main inventory view: + + ```typescript + import { useState, useMemo } from 'react' + import { AppShell } from '@/components/layout/AppShell' + import { FilterBar } from '@/components/inventory/FilterBar' + import { ItemCard } from '@/components/inventory/ItemCard' + import { ItemRow } from '@/components/inventory/ItemRow' + import { useInventory } from '@/hooks/useInventory' + import { useUIStore } from '@/store/ui' + import { Loader2, AlertCircle } from 'lucide-react' + + export function DashboardPage() { + const { data: items, isLoading, error } = useInventory() + const { viewMode } = useUIStore() + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('') + + const filtered = useMemo(() => { + if (!items) return [] + return items.filter((item) => { + const matchSearch = + !search || + item.name.toLowerCase().includes(search.toLowerCase()) || + (item.hw_id?.toLowerCase() ?? '').includes(search.toLowerCase()) || + (item.asset_tag?.toLowerCase() ?? '').includes(search.toLowerCase()) + const matchStatus = !statusFilter || item.catalog_status === statusFilter + return matchSearch && matchStatus + }) + }, [items, search, statusFilter]) + + return ( + + {/* Page header */} +
+

Inventory

+

All cataloged hardware in your homelab

+
+ + + + {/* Loading */} + {isLoading && ( +
+ + Loading inventory… +
+ )} + + {/* Error */} + {error && ( +
+ + {(error as Error).message} +
+ )} + + {/* Empty state */} + {!isLoading && !error && filtered.length === 0 && ( +
+

0

+

+ {items && items.length > 0 ? 'No items match your filters' : 'No items cataloged yet — add your first item'} +

+
+ )} + + {/* Grid view */} + {!isLoading && !error && filtered.length > 0 && viewMode === 'grid' && ( +
+ {filtered.map((item) => ( + + ))} +
+ )} + + {/* List view */} + {!isLoading && !error && filtered.length > 0 && viewMode === 'list' && ( +
+ {filtered.map((item) => ( + + ))} +
+ )} +
+ ) + } + ``` + + **3. Create web/src/pages/ItemDetailPage.tsx** — full item detail, mobile-responsive: + + Layout: TopBar via AppShell, back button, two-column on desktop (photos left, fields right), single column on mobile. Shows all custom fields, photos, raw test_data in a code block. + + ```typescript + import { useParams, Link } from '@tanstack/react-router' + import { ArrowLeft, ExternalLink, Package } from 'lucide-react' + import { AppShell } from '@/components/layout/AppShell' + import { StatusBadge } from '@/components/inventory/StatusBadge' + import { Button } from '@/components/ui/button' + import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + import { useInventoryItem } from '@/hooks/useInventory' + import { Loader2, AlertCircle } from 'lucide-react' + + function FieldRow({ label, value }: { label: string; value?: string }) { + if (!value) return null + return ( +
+ {label} + {value} +
+ ) + } + + export function ItemDetailPage() { + const { id } = useParams({ from: '/item/$id' }) + const numericId = parseInt(id, 10) + const { data: item, isLoading, error } = useInventoryItem(numericId) + + if (isLoading) { + return ( + +
+ + Loading item… +
+
+ ) + } + + if (error || !item) { + return ( + +
+ + {(error as Error)?.message ?? 'Item not found'} +
+
+ ) + } + + const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/` + + return ( + + {/* Back nav */} +
+ +
+ + {/* Header */} +
+
+

{item.hw_id || item.asset_tag}

+

{item.name}

+
+
+ + +
+
+ + {/* Two-column layout: photos (left on lg+) / fields (right or below) */} +
+ {/* Photos */} + + + Photos + + + {item.photo_urls.length > 0 ? ( +
+ {item.photo_urls.map((url, i) => ( + + {`${item.name} + + ))} +
+ ) : ( +
+ +

No photos

+
+ )} +
+
+ + {/* Fields */} +
+ + + Details + + + + + + + + + + + {item.ai_notes && ( + + + AI Notes + + +

{item.ai_notes}

+
+
+ )} + + {item.test_data && ( + + + Test Data + + +
+                      {(() => {
+                        try { return JSON.stringify(JSON.parse(item.test_data!), null, 2) }
+                        catch { return item.test_data }
+                      })()}
+                    
+
+
+ )} +
+
+
+ ) + } + ``` + + **4. Update web/src/router.tsx** — replace stub components with real pages: + + Read current `web/src/router.tsx`, then update `indexRoute` and `itemRoute` to use the real page components. Use lazy imports to keep bundle splitting: + + ```typescript + import { lazy, Suspense } from 'react' + // ... existing imports ... + + const DashboardPage = lazy(() => import('./pages/DashboardPage').then(m => ({ default: m.DashboardPage }))) + const ItemDetailPage = lazy(() => import('./pages/ItemDetailPage').then(m => ({ default: m.ItemDetailPage }))) + + const Spinner = () => ( +
+
+
+ ) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + }> + + + ), + }) + + const itemRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/item/$id', + component: () => ( + }> + + + ), + }) + ``` + + Keep intakeRoute and scanRoute as stubs (Plan 04 and 05 replace them). + + Run `npm run build` to confirm TypeScript compiles with no errors. + + + cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -15 + + + `npm run build` exits 0 with no TypeScript errors. `web/src/pages/DashboardPage.tsx` renders FilterBar (search + status dropdown + view toggle), grid/list conditional rendering, loading/error/empty states. `web/src/pages/ItemDetailPage.tsx` shows two-column layout with photo grid and field rows. `web/src/router.tsx` lazy-loads DashboardPage and ItemDetailPage. + + + + + + Dashboard and Item detail views wired to real backend inventory API. + + - `/` — inventory grid (5-col on xl, responsive) with search, status filter, view toggle + - `/item/:id` — full detail with photos, custom fields, ai_notes, test_data, NetBox link + - ClickHouse design: black canvas, volt accents, charcoal card borders, Inter 900 display + - Grid/list toggle persists in Zustand (survives navigation within session) + - StatusBadge color-coded for all 5 catalog statuses + + + 1. Start Go backend: `cd /home/mikkel/homelabby && go run ./cmd/hwlab/...` + 2. Start Vite dev server: `cd web && npm run dev` + 3. Open http://localhost:5173 + + Check: + - [ ] Background is pure black (#000000) + - [ ] "HWLab" in the TopBar is volt (#faff69) Inter Black + - [ ] "Add Item" button is forest green, "Scan" is ghost/outlined + - [ ] With no NetBox items: empty state shows "0" in volt color, descriptive text + - [ ] With NetBox items (if live NetBox available): cards render with HW ID, name, status badge + - [ ] Grid/list toggle switches layout + - [ ] Search filters items client-side as you type + - [ ] Status dropdown filters correctly + - [ ] Clicking a card navigates to /item/:id + - [ ] Item detail shows two columns on desktop, single column on mobile (resize to 390px) + - [ ] "NetBox" link on detail page opens new tab + + If NetBox is unavailable: verify the error state renders (red border, alert icon, error message) rather than crashing. + + Type "approved" or describe visual/functional issues to fix + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| React → GET /api/inventory | Frontend fetches inventory; all data comes from backend, not directly from NetBox | +| External links → NetBox | "View in NetBox" links open user-provided URLs — these use device IDs only | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-08 | Info Disclosure | photo_urls displayed as <img> | accept | URLs come from NetBox custom fields (trusted data store); homelab-internal only; no external URL injection path | +| T-03-09 | Tampering | test_data rendered in <pre> | mitigate | React escapes HTML in JSX; test_data is inside a <pre> tag as text content, not dangerouslySetInnerHTML — no XSS risk | +| T-03-10 | Info Disclosure | ai_notes rendered as text | accept | whitespace-pre-wrap rendering does not execute scripts; React's JSX rendering is safe | + + + +1. `cd web && npm run build` — exits 0, no TypeScript errors +2. `go build ./...` — Go still compiles +3. Visual verification via checkpoint task +4. `grep "DashboardPage" web/src/router.tsx` — lazy import present +5. `grep "useInventory" web/src/pages/DashboardPage.tsx` — TanStack Query hook wired + + + +- Dashboard renders grid/list of inventory items fetched from GET /api/inventory +- Filtering by name/hw_id and catalog_status works client-side without page reload +- Grid/list toggle works and persists in Zustand +- Item detail shows all custom fields, photos, and test data +- Mobile responsive: single column at 390px width (PWA-02) +- Quick action "View in NetBox" opens correct URL in new tab (UI-05) +- ClickHouse design system: black canvas, volt text, charcoal borders, Inter 900 display +- Human verification checkpoint passed + + + +After completion, create `.planning/phases/03-dashboard-intake-ui/03-03-SUMMARY.md` following the summary template. + diff --git a/.planning/phases/03-dashboard-intake-ui/03-04-PLAN.md b/.planning/phases/03-dashboard-intake-ui/03-04-PLAN.md new file mode 100644 index 0000000..4c7c2a5 --- /dev/null +++ b/.planning/phases/03-dashboard-intake-ui/03-04-PLAN.md @@ -0,0 +1,732 @@ +--- +phase: 03-dashboard-intake-ui +plan: "04" +type: execute +wave: 2 +depends_on: ["03-01"] +files_modified: + - web/src/lib/api.ts + - web/src/store/intake.ts + - web/src/components/intake/DropZone.tsx + - web/src/components/intake/PhotoPreview.tsx + - web/src/components/intake/AIResultReview.tsx + - web/src/components/intake/ConfirmForm.tsx + - web/src/pages/IntakePage.tsx + - web/src/router.tsx +autonomous: false +requirements: [UI-04, UI-05] + +must_haves: + truths: + - "User can drag-and-drop or click to upload 1-3 photos on the intake screen" + - "After upload, a loading state is shown while POST /api/intake is in flight" + - "AI result is displayed inline: model, manufacturer, category, suggested tags, ai_notes, confidence" + - "User can edit the name field before confirming" + - "Confirming on a high-confidence result (quick_add=true flag in request) creates the NetBox record" + - "After successful intake, a toast notification shows the assigned HW ID" + - "User is returned to dashboard after successful intake" + - "Errors from the backend are shown inline (not just console)" + artifacts: + - path: "web/src/store/intake.ts" + provides: "Zustand intake store — step tracking, photos, AI result, edit state" + exports: ["useIntakeStore", "IntakeResult"] + - path: "web/src/components/intake/DropZone.tsx" + provides: "react-dropzone file upload area, photo count indicator" + - path: "web/src/components/intake/AIResultReview.tsx" + provides: "AI result display with confidence meter and editable name field" + - path: "web/src/components/intake/ConfirmForm.tsx" + provides: "Final confirm/cancel actions" + - path: "web/src/pages/IntakePage.tsx" + provides: "/intake route — multi-step wizard orchestrator" + key_links: + - from: "web/src/pages/IntakePage.tsx" + to: "POST /api/intake" + via: "fetch in submitPhotos using FormData multipart upload" + - from: "web/src/components/intake/AIResultReview.tsx" + to: "web/src/store/intake.ts" + via: "useIntakeStore() — aiResult, editedName, step" + - from: "web/src/router.tsx" + to: "web/src/pages/IntakePage.tsx" + via: "intakeRoute component updated from stub to lazy IntakePage" +--- + + +Build the intake flow UI — photo upload, AI result review, and confirm-to-create workflow — wired to the POST /api/intake endpoint from Phase 2. + +Purpose: Photo intake is the core value proposition of HWLab. The UI must make it effortless: drag photos, review AI output, correct if needed, confirm. +Output: A three-step wizard (Upload → Review → Done) using react-dropzone, Zustand state, and TanStack Query mutations. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md +@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md +@.planning/phases/02-ai-pipeline/02-03-SUMMARY.md + + + + + + + + +```typescript +interface IntakeResponse { + hw_id: string + model: string + manufacturer: string + category: string + specs: Record + suggested_tags: string[] + ai_notes: string + confidence: number // 0.0 – 1.0 + catalog_status: string // indexed | needs_research + netbox_id: number + queued: boolean // true if WAQ-enqueued (202) +} +``` + + + + + +// Create a separate store for intake (not in ui.ts — different concern) +// useIntakeStore with step, photos, aiResult, editedName, isSubmitting, error + + +// bg-canvas, bg-near-black, text-volt, border-charcoal/80, rounded-card, rounded-sharp +// Button variants: default (volt), forest, secondary, outline, ghost + + +// import { useDropzone } from 'react-dropzone' +// const { getRootProps, getInputProps, isDragActive } = useDropzone({ +// accept: { 'image/*': [] }, +// maxFiles: 3, +// onDrop: acceptedFiles => { ... } +// }) + + + + + + + Task 1: Intake store, DropZone component, and API mutation + + web/src/store/intake.ts, + web/src/lib/api.ts, + web/src/components/intake/DropZone.tsx, + web/src/components/intake/PhotoPreview.tsx + + + Read first: `web/src/store/ui.ts` (Zustand pattern), `web/src/lib/api.ts` (current state), `web/src/components/ui/button.tsx`. + + **1. Create web/src/store/intake.ts** — Zustand intake wizard state: + ```typescript + import { create } from 'zustand' + + export interface IntakeResult { + hw_id: string + model: string + manufacturer: string + category: string + specs: Record + suggested_tags: string[] + ai_notes: string + confidence: number + catalog_status: string + netbox_id: number + queued: boolean + } + + type IntakeStep = 'upload' | 'submitting' | 'review' | 'done' | 'error' + + interface IntakeStore { + step: IntakeStep + photos: File[] + aiResult: IntakeResult | null + editedName: string + error: string | null + + addPhoto: (file: File) => void + removePhoto: (index: number) => void + setStep: (step: IntakeStep) => void + setAIResult: (result: IntakeResult) => void + setEditedName: (name: string) => void + setError: (err: string | null) => void + reset: () => void + } + + const initialState = { + step: 'upload' as IntakeStep, + photos: [] as File[], + aiResult: null, + editedName: '', + error: null, + } + + export const useIntakeStore = create((set) => ({ + ...initialState, + addPhoto: (file) => + set((s) => ({ + photos: s.photos.length < 3 ? [...s.photos, file] : s.photos, + })), + removePhoto: (index) => + set((s) => ({ photos: s.photos.filter((_, i) => i !== index) })), + setStep: (step) => set({ step }), + setAIResult: (result) => + set({ aiResult: result, editedName: result.model || result.manufacturer }), + setEditedName: (editedName) => set({ editedName }), + setError: (error) => set({ error }), + reset: () => set(initialState), + })) + ``` + + **2. Update web/src/lib/api.ts** — add intake mutation function: + Read the current file first, then add below the existing exports: + ```typescript + export interface IntakeResponse { + hw_id: string + model: string + manufacturer: string + category: string + specs: Record + suggested_tags: string[] + ai_notes: string + confidence: number + catalog_status: string + netbox_id: number + queued: boolean + } + + export async function submitIntake(photos: File[], quickAdd = false): Promise { + const form = new FormData() + for (const photo of photos) { + form.append('photos[]', photo) + } + form.append('quick_add', String(quickAdd)) + + const res = await fetch('/api/intake', { method: 'POST', body: form }) + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(body.error ?? `HTTP ${res.status}`) + } + return res.json() as Promise + } + ``` + + **3. Create web/src/components/intake/PhotoPreview.tsx** — thumbnail grid with remove button: + ```typescript + import { X } from 'lucide-react' + import { Button } from '@/components/ui/button' + + interface PhotoPreviewProps { + photos: File[] + onRemove: (index: number) => void + } + + export function PhotoPreview({ photos, onRemove }: PhotoPreviewProps) { + if (photos.length === 0) return null + return ( +
+ {photos.map((file, i) => { + const url = URL.createObjectURL(file) + return ( +
+ {`Photo + +
+ ) + })} +
+ ) + } + ``` + + **4. Create web/src/components/intake/DropZone.tsx** — drag-drop upload area: + ```typescript + import { useCallback } from 'react' + import { useDropzone } from 'react-dropzone' + import { Upload, Camera } from 'lucide-react' + import { cn } from '@/lib/utils' + + interface DropZoneProps { + photoCount: number + onDrop: (files: File[]) => void + } + + const MAX = 3 + + export function DropZone({ photoCount, onDrop }: DropZoneProps) { + const remaining = MAX - photoCount + const disabled = remaining === 0 + + const handleDrop = useCallback( + (accepted: File[]) => { + onDrop(accepted.slice(0, remaining)) + }, + [onDrop, remaining], + ) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept: { 'image/*': [] }, + maxFiles: remaining, + disabled, + onDrop: handleDrop, + }) + + return ( +
+ + {isDragActive ? ( + + ) : ( + + )} +
+

+ {isDragActive + ? 'Drop photos here' + : disabled + ? 'Maximum 3 photos reached' + : 'Drag photos or tap to shoot'} +

+

+ {disabled ? '' : `${remaining} of ${MAX} slots remaining · JPEG, PNG, WebP`} +

+
+
+ ) + } + ``` + + Run `npm run build` to confirm TypeScript compiles. +
+ + cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 + + + `npm run build` exits 0. `web/src/store/intake.ts` exports `useIntakeStore` and `IntakeResult`. `web/src/lib/api.ts` exports `submitIntake` and `IntakeResponse`. DropZone and PhotoPreview components exist with no TypeScript errors. + +
+ + + Task 2: AIResultReview, ConfirmForm, IntakePage, and router wiring + + web/src/components/intake/AIResultReview.tsx, + web/src/components/intake/ConfirmForm.tsx, + web/src/pages/IntakePage.tsx, + web/src/router.tsx + + + Read first: `web/src/router.tsx` (current state — has stub intakeRoute), `web/src/store/intake.ts`, `web/src/components/ui/card.tsx`. + + **1. Create web/src/components/intake/AIResultReview.tsx** — display AI extraction results with confidence meter and editable name: + ```typescript + import { AlertTriangle, CheckCircle, Edit3 } from 'lucide-react' + import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + import { Badge } from '@/components/ui/badge' + import type { IntakeResult } from '@/store/intake' + + interface AIResultReviewProps { + result: IntakeResult + editedName: string + onNameChange: (name: string) => void + } + + function ConfidenceMeter({ value }: { value: number }) { + const pct = Math.round(value * 100) + const color = + pct >= 85 ? 'bg-green-500' : + pct >= 60 ? 'bg-yellow-500' : + 'bg-red-500' + return ( +
+
+
+
+ {pct}% + {pct >= 85 ? ( + + ) : ( + + )} +
+ ) + } + + export function AIResultReview({ result, editedName, onNameChange }: AIResultReviewProps) { + return ( +
+ {/* Confidence */} + + + AI Confidence + + + + {result.confidence < 0.6 && ( +

+ Low confidence — item will be flagged for research +

+ )} +
+
+ + {/* Editable name */} + + + + Item Name + + + + + onNameChange(e.target.value)} + className="w-full px-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-white text-sm focus:outline-none focus:border-volt/60 focus:ring-1 focus:ring-volt/30" + placeholder="Item name" + /> + + + + {/* Classification */} + + + Classification + + +
+ Manufacturer + {result.manufacturer || '—'} +
+
+ Model + {result.model || '—'} +
+
+ Category + {result.category || '—'} +
+ {result.suggested_tags.length > 0 && ( +
+ Tags +
+ {result.suggested_tags.map((tag) => ( + {tag} + ))} +
+
+ )} +
+
+ + {/* AI notes */} + {result.ai_notes && ( + + + AI Notes + + +

{result.ai_notes}

+
+
+ )} +
+ ) + } + ``` + + **2. Create web/src/components/intake/ConfirmForm.tsx** — final action buttons: + ```typescript + import { CheckCircle, RotateCcw, Loader2 } from 'lucide-react' + import { Button } from '@/components/ui/button' + + interface ConfirmFormProps { + isSubmitting: boolean + onConfirm: () => void + onReset: () => void + } + + export function ConfirmForm({ isSubmitting, onConfirm, onReset }: ConfirmFormProps) { + return ( +
+ + +
+ ) + } + ``` + + **3. Create web/src/pages/IntakePage.tsx** — the intake wizard orchestrator: + + Three steps: + - `upload`: Show DropZone + PhotoPreview + "Analyze with AI" button (forest green) + - `submitting`: Uploading spinner overlay + - `review`: AIResultReview + ConfirmForm + - `done`: Success card with HW ID (volt) + "Add Another" / "Go to Dashboard" buttons + - `error`: Error card with retry + + The page submits photos to POST /api/intake with `quick_add=false` initially (user reviews). The review ConfirmForm button calls `submitIntake` again with `quick_add=true` to trigger actual NetBox record creation (or relies on the already-created record from the initial call — see INTAKE-04 decision: handler always creates immediately). Check the Phase 2 decision: the backend creates the record on the first POST; there is no separate "confirm" POST. The UI review step is purely a display concern. The flow is: POST photos → show result → user edits name (display only, name was set by AI in NetBox already) → navigate to dashboard. + + Adjust: since the backend creates on the first POST (INTAKE-04 decision from Phase 2 summary), the intake flow is: + 1. Upload + submit (POST /api/intake) → NetBox record created, AI result returned + 2. Review result (readonly display, name already in NetBox) + 3. Done → toast with HW ID → navigate to dashboard + + ```typescript + import { useNavigate, Link } from '@tanstack/react-router' + import toast from 'react-hot-toast' + import { Loader2, CheckCircle2 } from 'lucide-react' + import { AppShell } from '@/components/layout/AppShell' + import { DropZone } from '@/components/intake/DropZone' + import { PhotoPreview } from '@/components/intake/PhotoPreview' + import { AIResultReview } from '@/components/intake/AIResultReview' + import { Button } from '@/components/ui/button' + import { Card, CardContent } from '@/components/ui/card' + import { useIntakeStore } from '@/store/intake' + import { submitIntake } from '@/lib/api' + + export function IntakePage() { + const navigate = useNavigate() + const { + step, photos, aiResult, editedName, + addPhoto, removePhoto, setStep, setAIResult, setEditedName, setError, reset, + } = useIntakeStore() + + async function handleAnalyze() { + if (photos.length === 0) return + setStep('submitting') + setError(null) + try { + const result = await submitIntake(photos, false) + setAIResult(result) + setStep('review') + toast.success(`HW ID assigned: ${result.hw_id}`) + } catch (e) { + setError((e as Error).message) + setStep('error') + } + } + + function handleDone() { + const hwid = aiResult?.hw_id + reset() + if (hwid) { + toast.success(`${hwid} added to inventory`) + } + navigate({ to: '/' }) + } + + return ( + + {/* Header */} +
+

Add Item

+

Upload 1–3 photos — AI extracts specs automatically

+
+ + {/* Step: upload */} + {(step === 'upload' || step === 'error') && ( +
+ files.forEach(addPhoto)} /> + + {step === 'error' && ( + + + Analysis failed — check that the Go backend is running and try again. + + + )} + +
+ )} + + {/* Step: submitting */} + {step === 'submitting' && ( +
+ +

Analyzing photos…

+

Gemma 4 is extracting specs

+
+ )} + + {/* Step: review */} + {step === 'review' && aiResult && ( +
+ +
+ + +
+ {aiResult.queued && ( +

+ NetBox was unavailable — item is queued for sync +

+ )} +
+ )} +
+ ) + } + ``` + + **4. Update web/src/router.tsx** — replace stub intakeRoute with lazy IntakePage: + Read current router.tsx, then update `intakeRoute` component: + ```typescript + const IntakePage = lazy(() => import('./pages/IntakePage').then(m => ({ default: m.IntakePage }))) + + const intakeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/intake', + component: () => ( + }> + + + ), + }) + ``` + + Run `npm run build`. + + + cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 + + + `npm run build` exits 0. `web/src/pages/IntakePage.tsx` exports `IntakePage`. `web/src/router.tsx` lazy-loads IntakePage for /intake route. All intake components exist with no TypeScript errors. + + + + + + Intake flow UI at /intake: + - DropZone with drag-drop, camera capture (capture="environment"), max 3 photos + - PhotoPreview thumbnails with remove button per photo + - "Analyze with AI" forest-green button (disabled until ≥1 photo added) + - Spinner while POST /api/intake is in flight + - AI result review: confidence meter (green ≥85%, yellow 60-84%, red <60%), manufacturer/model/category/tags, editable name, ai_notes + - "Done — Go to Dashboard" routes back to / with toast showing HW ID + - "Add Another" resets wizard state + - Error state on backend failure (red card, retry possible) + - Toast on success: "HW-XXXXX added to inventory" + + + 1. Start Go backend: `cd /home/mikkel/homelabby && go run ./cmd/hwlab/...` + 2. Start Vite dev server: `cd web && npm run dev` + 3. Open http://localhost:5173/intake + + Check: + - [ ] DropZone renders with camera icon, "Drag photos or tap to shoot" text + - [ ] DropZone has volt border glow when dragging a file over it + - [ ] "Analyze with AI" button is forest green and disabled with no photos + - [ ] After dropping/selecting a photo: thumbnail appears with X remove button + - [ ] Maximum 3 photos: 4th drop is silently ignored, DropZone shows "Maximum 3 photos reached" + - [ ] Clicking "Analyze with AI" with photos shows spinner state + - [ ] With live Go backend + oMLX: AI result appears with confidence meter, tags, notes + - [ ] Without live AI: error state shows (red card), no crash + - [ ] "Done — Go to Dashboard" navigates to / and shows toast + - [ ] On mobile (390px): single column, DropZone usable, camera capture works + + Type "approved" or describe issues to fix + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → POST /api/intake | Multipart file upload crosses here — file content is untrusted | +| AI result → display | AI-extracted strings are displayed in UI | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-11 | Tampering | submitIntake FormData | accept | File bytes sent directly; no filename manipulation; backend validates count and MIME; server-side hw_id assignment (T-02-13 already mitigated in Phase 2) | +| T-03-12 | Info Disclosure | AIResultReview renders ai_notes | accept | React JSX text rendering is safe; no dangerouslySetInnerHTML used; ai_notes is plain text | +| T-03-13 | DoS | Multiple rapid intake submits | mitigate | "Analyze" button is disabled during `step === 'submitting'` — prevents duplicate concurrent POSTs from the same session | + + + +1. `cd web && npm run build` — exits 0 +2. `grep "IntakePage" web/src/router.tsx` — lazy import present +3. `grep "submitIntake" web/src/pages/IntakePage.tsx` — confirms API wiring +4. `grep "capture" web/src/components/intake/DropZone.tsx` — confirms camera capture attribute +5. Visual verification via checkpoint task + + + +- Photo upload via drag-drop or file picker works with 1-3 photos +- Camera capture attribute present for mobile PWA use +- POST /api/intake called with correct multipart FormData +- AI result displays confidence, category, tags, ai_notes, manufacturer, model +- Success toast shows HW ID; user navigates to dashboard +- Error state renders on backend failure (no crash) +- Spinner shown during network request +- "Add Another" resets wizard to upload step +- Human verification checkpoint passed + + + +After completion, create `.planning/phases/03-dashboard-intake-ui/03-04-SUMMARY.md` following the summary template. + diff --git a/.planning/phases/03-dashboard-intake-ui/03-05-PLAN.md b/.planning/phases/03-dashboard-intake-ui/03-05-PLAN.md new file mode 100644 index 0000000..bbf1c8d --- /dev/null +++ b/.planning/phases/03-dashboard-intake-ui/03-05-PLAN.md @@ -0,0 +1,690 @@ +--- +phase: 03-dashboard-intake-ui +plan: "05" +type: execute +wave: 2 +depends_on: ["03-01"] +files_modified: + - web/public/manifest.json + - web/public/sw.js + - web/public/icons/icon-192.png + - web/public/icons/icon-512.png + - web/src/hooks/usePWA.ts + - web/src/pages/ScanPage.tsx + - web/src/router.tsx + - web/index.html +autonomous: false +requirements: [PWA-01, PWA-02, PWA-03] + +must_haves: + truths: + - "Chrome on Android shows the 'Add to Home Screen' banner for the app" + - "The installed PWA launches in standalone mode (no browser chrome)" + - "Visiting /scan activates the device rear camera and scans QR codes in real-time" + - "A scanned HW-XXXXX QR code navigates the user to /item/:id for that item" + - "Service worker caches the app shell so the UI loads offline (no network required for the shell)" + artifacts: + - path: "web/public/manifest.json" + provides: "PWA web app manifest (name, icons, display=standalone, theme_color)" + - path: "web/public/sw.js" + provides: "Service worker — app shell cache strategy for offline shell" + - path: "web/src/pages/ScanPage.tsx" + provides: "/scan route — camera QR scanner using @zxing/browser" + - path: "web/src/hooks/usePWA.ts" + provides: "Service worker registration hook" + key_links: + - from: "web/index.html" + to: "web/public/manifest.json" + via: "" + - from: "web/src/hooks/usePWA.ts" + to: "web/public/sw.js" + via: "navigator.serviceWorker.register('/sw.js')" + - from: "web/src/pages/ScanPage.tsx" + to: "http://localhost:8080/api/inventory" + via: "Decoded QR text parsed as HW-XXXXX → navigate to /item/:id" +--- + + +Set up the PWA manifest, service worker, and camera-based QR scanner so HWLab is installable on Android and items can be looked up by scanning their printed QR labels. + +Purpose: PWA installability and QR scanning close the hardware inventory loop — print a label on intake, scan it later to pull up the item record instantly. +Output: Installable PWA with app shell offline caching and a /scan camera QR scanner that resolves HW IDs. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md +@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md + + + +```typescript +import { BrowserQRCodeReader, IScannerControls } from '@zxing/browser' + +const reader = new BrowserQRCodeReader() +// Get list of cameras: +const devices = await BrowserQRCodeReader.listVideoInputDevices() +// Start scanning — calls callback on each decoded result: +const controls: IScannerControls = await reader.decodeFromVideoDevice( + deviceId, // undefined = default (rear) camera + videoElement, // HTMLVideoElement ref + (result, _err, controls) => { + if (result) { + const text = result.getText() + // text will be the QR code content + controls.stop() + } + } +) +// Stop: controls.stop() +``` + + +// QR codes encode: http://mac-mini.mg:8080/hw/XXXXX +// Or just the HW ID: HW-00001 +// Parse: extract HW ID from URL path or direct match +// Lookup: GET /api/inventory returns hw_id field; match by hw_id to get numeric id +// Navigate: router.navigate({ to: '/item/$id', params: { id: String(numericId) } }) + + +// import { router } from '@/router' +// router.navigate({ to: '/item/$id', params: { id: String(numericId) } }) + + +// bg-canvas, text-volt, bg-near-black, border-charcoal/80 +// Button variant="forest" for primary actions + + + + + + + Task 1: PWA manifest, service worker, icons, and registration hook + + web/public/manifest.json, + web/public/sw.js, + web/public/icons/icon-192.png, + web/public/icons/icon-512.png, + web/src/hooks/usePWA.ts, + web/index.html + + + Read first: `web/index.html` (current state from Plan 01). + + **1. Create web/public/ directory structure:** + ``` + web/public/ + manifest.json + sw.js + icons/ + icon-192.png (placeholder — generate programmatically) + icon-512.png (placeholder — generate programmatically) + ``` + + **2. Create web/public/manifest.json** (PWA-01: installable on Android): + ```json + { + "name": "HWLab", + "short_name": "HWLab", + "description": "Self-hosted hardware inventory with AI photo intake", + "start_url": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#faff69", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["utilities", "productivity"], + "screenshots": [] + } + ``` + + **3. Generate placeholder icons using Node.js (no ImageMagick required):** + Run this script to create minimal valid PNG icons programmatically. Create `web/scripts/gen-icons.cjs`: + ```js + #!/usr/bin/env node + // Generates minimal PNG icons for the PWA manifest. + // Uses pure Node.js — no canvas or image library needed. + // Output: a valid 1x1 black PNG with correct PNG header and IDAT chunk. + // For production, replace with properly designed icons. + + const fs = require('fs') + const path = require('path') + const zlib = require('zlib') + + function makePNG(size, bgHex, fgHex) { + // PNG signature + const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) + + // IHDR chunk + const ihdrData = Buffer.alloc(13) + ihdrData.writeUInt32BE(size, 0) + ihdrData.writeUInt32BE(size, 4) + ihdrData[8] = 8 // bit depth + ihdrData[9] = 2 // color type RGB + ihdrData[10] = 0 // compression + ihdrData[11] = 0 // filter + ihdrData[12] = 0 // interlace + const ihdr = makeChunk('IHDR', ihdrData) + + // IDAT chunk — raw RGB pixel data, filtered (filter byte 0 = none per row) + const bg = hexToRgb(bgHex) + const fg = hexToRgb(fgHex) + const rawRows = [] + const margin = Math.floor(size * 0.2) + for (let y = 0; y < size; y++) { + const row = [0] // filter byte + for (let x = 0; x < size; x++) { + // Simple "H" monogram in the center + const inMargin = x >= margin && x < size - margin && y >= margin && y < size - margin + const isStroke = + inMargin && + ((x < margin + Math.floor(size * 0.08) || x > size - margin - Math.floor(size * 0.08)) || + (Math.abs(y - size / 2) < Math.floor(size * 0.06))) + const c = isStroke ? fg : bg + row.push(c[0], c[1], c[2]) + } + rawRows.push(Buffer.from(row)) + } + const rawData = Buffer.concat(rawRows) + const compressed = zlib.deflateSync(rawData) + const idat = makeChunk('IDAT', compressed) + + // IEND + const iend = makeChunk('IEND', Buffer.alloc(0)) + + return Buffer.concat([sig, ihdr, idat, iend]) + } + + function makeChunk(type, data) { + const len = Buffer.alloc(4) + len.writeUInt32BE(data.length) + const typeBytes = Buffer.from(type) + const crc = crc32(Buffer.concat([typeBytes, data])) + const crcBuf = Buffer.alloc(4) + crcBuf.writeInt32BE(crc) + return Buffer.concat([len, typeBytes, data, crcBuf]) + } + + function hexToRgb(hex) { + const n = parseInt(hex.replace('#', ''), 16) + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff] + } + + function crc32(buf) { + let crc = 0xffffffff + const table = makeCRCTable() + for (const b of buf) crc = (crc >>> 8) ^ table[(crc ^ b) & 0xff] + return (crc ^ 0xffffffff) | 0 + } + + function makeCRCTable() { + const t = new Int32Array(256) + for (let i = 0; i < 256; i++) { + let c = i + for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + t[i] = c + } + return t + } + + const iconsDir = path.join(__dirname, '..', 'public', 'icons') + fs.mkdirSync(iconsDir, { recursive: true }) + fs.writeFileSync(path.join(iconsDir, 'icon-192.png'), makePNG(192, '#000000', '#faff69')) + fs.writeFileSync(path.join(iconsDir, 'icon-512.png'), makePNG(512, '#000000', '#faff69')) + console.log('Icons generated: icon-192.png, icon-512.png') + ``` + + Run: `node web/scripts/gen-icons.cjs` + + **4. Create web/public/sw.js** — app shell cache strategy: + ```javascript + const CACHE_NAME = 'hwlab-shell-v1' + + // App shell assets to cache on install + const SHELL_ASSETS = [ + '/', + '/index.html', + ] + + self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)) + ) + self.skipWaiting() + }) + + self.addEventListener('activate', (event) => { + // Remove old caches + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + ) + ) + ) + self.clients.claim() + }) + + self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url) + + // API requests: network only — never cache + if (url.pathname.startsWith('/api/')) { + return + } + + // Navigation requests: serve from cache, fall back to network (app shell) + if (event.request.mode === 'navigate') { + event.respondWith( + caches.match('/').then((cached) => cached ?? fetch(event.request)) + ) + return + } + + // Static assets: cache-first + event.respondWith( + caches.match(event.request).then( + (cached) => cached ?? fetch(event.request).then((response) => { + if (response.ok && event.request.method === 'GET') { + const clone = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)) + } + return response + }) + ) + ) + }) + ``` + + **5. Create web/src/hooks/usePWA.ts** — service worker registration: + ```typescript + import { useEffect } from 'react' + + export function usePWA() { + useEffect(() => { + if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/sw.js') + .then((reg) => { + console.log('[PWA] Service worker registered:', reg.scope) + }) + .catch((err) => { + console.warn('[PWA] Service worker registration failed:', err) + }) + }) + } + }, []) + } + ``` + + **6. Update web/src/App.tsx** — call usePWA() hook: + Read current `web/src/App.tsx`, then add: + ```typescript + import { usePWA } from '@/hooks/usePWA' + + export function App() { + usePWA() // Register service worker + // ... rest unchanged + } + ``` + + **7. Verify web/index.html already has the manifest link** (it was added in Plan 01): + `grep "manifest" web/index.html` — if missing, add `` inside ``. + + Add `` inside `` if not already present. + + Run `npm run build`. + + + cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -5 && ls public/icons/ && node -e "const b = require('fs').readFileSync('public/icons/icon-192.png'); console.log('PNG sig:', b.slice(0,4).toString('hex') === '89504e47' ? 'VALID' : 'INVALID')" + + + `npm run build` exits 0. `web/public/icons/icon-192.png` and `icon-512.png` exist as valid PNG files (PNG signature 89504E47). `web/public/manifest.json` exists with `"display": "standalone"` and `"theme_color": "#faff69"`. `web/public/sw.js` exists. `web/src/hooks/usePWA.ts` exports `usePWA`. + + + + + Task 2: QR scanner page with @zxing/browser and router wiring + + web/src/pages/ScanPage.tsx, + web/src/router.tsx + + + Read first: `web/src/router.tsx` (current state — has stub scanRoute), `web/src/lib/api.ts` (fetchInventory for HW ID lookup). + + **Create web/src/pages/ScanPage.tsx** — camera QR code scanner: + + Flow: + 1. Request camera access via `BrowserQRCodeReader.listVideoInputDevices()` + 2. Stream rear camera to `