docs(03): create phase 3 plans — dashboard, intake UI, PWA (5 plans, 2 waves)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
22c504247d
commit
1a9931f900
6 changed files with 3480 additions and 3 deletions
|
|
@ -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 | - |
|
||||
|
|
|
|||
819
.planning/phases/03-dashboard-intake-ui/03-01-PLAN.md
Normal file
819
.planning/phases/03-dashboard-intake-ui/03-01-PLAN.md
Normal file
|
|
@ -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' }"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
|
||||
@assets.go
|
||||
@web/dist/index.html
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing Go embed setup — web/dist is already embedded by assets.go at project root -->
|
||||
<!-- web/dist/index.html is currently a stub placeholder — will be replaced by Vite build output -->
|
||||
<!-- The Go server uses chi router and serves web/dist via go:embed -->
|
||||
|
||||
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})
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Scaffold Vite+React project and apply ClickHouse design tokens</name>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<action>
|
||||
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
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>HWLab</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inconsolata:wght@600&family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="bg-canvas text-white antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -5</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire React app entry point with Router, QueryClient, design primitives, and shadcn/ui components</name>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<action>
|
||||
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<UIStore>((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: () => (
|
||||
<>
|
||||
<Outlet />
|
||||
{import.meta.env.DEV && <TanStackRouterDevtools />}
|
||||
</>
|
||||
),
|
||||
})
|
||||
|
||||
// Routes — components are lazy-imported in Plan 03 and 04; stubs for now
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||
<p className="text-volt font-display font-black text-2xl">HWLab — Dashboard loading…</p>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
const itemRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/item/$id',
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||
<p className="text-volt font-display font-black text-2xl">Item detail loading…</p>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
const intakeRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/intake',
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||
<p className="text-volt font-display font-black text-2xl">Intake wizard loading…</p>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
const scanRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/scan',
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||
<p className="text-volt font-display font-black text-2xl">QR Scanner loading…</p>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: '#141414',
|
||||
color: '#ffffff',
|
||||
border: '1px solid rgba(65,65,65,0.8)',
|
||||
},
|
||||
success: {
|
||||
iconTheme: { primary: '#faff69', secondary: '#000000' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
**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<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
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<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-card border border-charcoal/80 bg-near-black text-white shadow-sm transition-colors',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-4', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-4 pt-0', className)} {...props} />,
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-4 pt-0', className)} {...props} />
|
||||
),
|
||||
)
|
||||
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<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 && ls dist/assets/*.js 2>/dev/null | head -3</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md` following the summary template.
|
||||
</output>
|
||||
346
.planning/phases/03-dashboard-intake-ui/03-02-PLAN.md
Normal file
346
.planning/phases/03-dashboard-intake-ui/03-02-PLAN.md
Normal file
|
|
@ -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}', ...)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- From internal/netbox/client.go — methods the inventory handler will use -->
|
||||
```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)
|
||||
```
|
||||
|
||||
<!-- From internal/netbox/types.go — Device shape the handler returns as JSON -->
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
<!-- From internal/api/router.go — current NewRouter signature to extend -->
|
||||
```go
|
||||
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler
|
||||
```
|
||||
|
||||
<!-- Established pattern from intake.go: interface-for-testability -->
|
||||
// Define a narrow interface (InventoryNetBoxClient) rather than taking *netbox.Client directly.
|
||||
// This allows complete unit testing without a real NetBox.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: InventoryHandler — list and detail endpoints with interface + unit tests</name>
|
||||
<files>
|
||||
internal/api/handlers/inventory.go,
|
||||
internal/api/handlers/inventory_test.go
|
||||
</files>
|
||||
<behavior>
|
||||
- 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
|
||||
</behavior>
|
||||
<action>
|
||||
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 `<behavior>`. 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -run TestInventory -v 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>
|
||||
All 7 TestInventory* tests pass. `internal/api/handlers/inventory.go` exports `InventoryHandler`, `NewInventoryHandler`, `InventoryNetBoxClient`, `InventoryItemResponse`. `go build ./...` succeeds.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire inventory routes into chi router and main.go</name>
|
||||
<files>
|
||||
internal/api/router.go,
|
||||
cmd/hwlab/main.go
|
||||
</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby && go build ./... 2>&1 && go test ./... 2>&1 | tail -15</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-dashboard-intake-ui/03-02-SUMMARY.md` following the summary template.
|
||||
</output>
|
||||
884
.planning/phases/03-dashboard-intake-ui/03-03-PLAN.md
Normal file
884
.planning/phases/03-dashboard-intake-ui/03-03-PLAN.md
Normal file
|
|
@ -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'))"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/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
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 02: InventoryItemResponse — the JSON shape from GET /api/inventory -->
|
||||
```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[]
|
||||
}
|
||||
```
|
||||
|
||||
<!-- From Plan 01: Zustand store for UI state -->
|
||||
```typescript
|
||||
import { useUIStore } from '@/store/ui'
|
||||
const { viewMode, setViewMode } = useUIStore()
|
||||
// viewMode: 'grid' | 'list'
|
||||
```
|
||||
|
||||
<!-- From Plan 01: TanStack Router — route params -->
|
||||
// /item/$id route param access:
|
||||
// import { useParams } from '@tanstack/react-router'
|
||||
// const { id } = useParams({ from: '/item/$id' })
|
||||
|
||||
<!-- From Plan 01: ClickHouse design tokens available as Tailwind classes -->
|
||||
// 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)
|
||||
|
||||
<!-- From Plan 01: shadcn/ui components -->
|
||||
// import { Button } from '@/components/ui/button'
|
||||
// import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card'
|
||||
// import { Badge } from '@/components/ui/badge'
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: API client, TanStack Query hooks, AppShell layout, and ItemCard/ItemRow components</name>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<action>
|
||||
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<T>(url: string): Promise<T> {
|
||||
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<T>
|
||||
}
|
||||
|
||||
export const fetchInventory = (): Promise<InventoryItem[]> =>
|
||||
fetchJSON<InventoryItem[]>(`${BASE}/inventory`)
|
||||
|
||||
export const fetchInventoryItem = (id: number): Promise<InventoryItem> =>
|
||||
fetchJSON<InventoryItem>(`${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<string, string> = {
|
||||
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 <Badge variant={variant}>{STATUS_LABELS[status] ?? status}</Badge>
|
||||
}
|
||||
```
|
||||
|
||||
**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 (
|
||||
<Link to="/item/$id" params={{ id: String(item.id) }} className="block">
|
||||
<Card
|
||||
className={cn(
|
||||
'hover:border-volt/60 transition-colors cursor-pointer h-full flex flex-col',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Photo */}
|
||||
<div className="aspect-video bg-near-black rounded-t-card overflow-hidden flex items-center justify-center border-b border-charcoal/80">
|
||||
{item.photo_urls.length > 0 ? (
|
||||
<img
|
||||
src={item.photo_urls[0]}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<Package className="w-12 h-12 text-charcoal" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardHeader className="pb-2">
|
||||
{/* HW ID */}
|
||||
<p className="font-code text-volt text-xs label-upper">{item.hw_id || item.asset_tag}</p>
|
||||
<CardTitle className="text-white text-sm leading-tight line-clamp-2">{item.name}</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pb-2 flex-1">
|
||||
<StatusBadge status={item.catalog_status} />
|
||||
{item.ai_notes && (
|
||||
<p className="mt-2 text-xs text-[#a0a0a0] line-clamp-2">{item.ai_notes}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-[#a0a0a0] hover:text-volt p-0 h-auto"
|
||||
asChild
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
NetBox
|
||||
</a>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**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 `<tr>` 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<string, string> = {
|
||||
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 (
|
||||
<Link
|
||||
to="/item/$id"
|
||||
params={{ id: String(item.id) }}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-charcoal/40 hover:bg-near-black/60 transition-colors group"
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div className={`w-1 h-8 rounded-full flex-shrink-0 ${dot}`} />
|
||||
|
||||
{/* HW ID */}
|
||||
<span className="font-code text-volt text-xs w-24 flex-shrink-0">{item.hw_id || item.asset_tag}</span>
|
||||
|
||||
{/* Name */}
|
||||
<span className="text-white text-sm font-medium flex-1 truncate">{item.name}</span>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex-shrink-0">
|
||||
<StatusBadge status={item.catalog_status} />
|
||||
</div>
|
||||
|
||||
{/* Notes preview */}
|
||||
{item.ai_notes && (
|
||||
<span className="text-xs text-[#a0a0a0] truncate max-w-xs hidden lg:block">{item.ai_notes}</span>
|
||||
)}
|
||||
|
||||
{/* Quick action */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-[#a0a0a0] hover:text-volt p-1 h-auto opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
asChild
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**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 (
|
||||
<header className="sticky top-0 z-50 flex items-center justify-between px-4 py-3 border-b border-charcoal/80 bg-canvas/95 backdrop-blur-sm">
|
||||
<Link to="/" className="font-display font-black text-xl text-volt tracking-tight">
|
||||
HWLab
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/scan">
|
||||
<QrCode className="w-4 h-4 mr-1.5" />
|
||||
<span className="hidden sm:inline">Scan</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="forest" size="sm" asChild>
|
||||
<Link to="/intake">
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
<span className="hidden sm:inline">Add Item</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**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 (
|
||||
<div className="min-h-screen bg-canvas flex flex-col">
|
||||
<TopBar />
|
||||
<main className="flex-1 px-4 py-6 max-w-7xl mx-auto w-full">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: DashboardPage, FilterBar, ItemDetailPage, and router wiring</name>
|
||||
<files>
|
||||
web/src/components/inventory/FilterBar.tsx,
|
||||
web/src/pages/DashboardPage.tsx,
|
||||
web/src/pages/ItemDetailPage.tsx,
|
||||
web/src/router.tsx
|
||||
</files>
|
||||
<action>
|
||||
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<string, string> = {
|
||||
'': '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 (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#a0a0a0]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search items…"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
className="px-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white focus:outline-none focus:border-volt/60"
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s} className="bg-near-black">
|
||||
{STATUS_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Item count */}
|
||||
<span className="text-xs text-[#a0a0a0] label-upper mr-auto">
|
||||
{totalCount} items
|
||||
</span>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex rounded-sharp border border-charcoal/80 overflow-hidden">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
className="rounded-none h-9 w-9"
|
||||
onClick={() => setViewMode('grid')}
|
||||
title="Grid view"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
className="rounded-none h-9 w-9 border-l border-charcoal/80"
|
||||
onClick={() => setViewMode('list')}
|
||||
title="List view"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**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 (
|
||||
<AppShell>
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display font-black text-3xl text-white mb-1">Inventory</h1>
|
||||
<p className="text-sm text-[#a0a0a0]">All cataloged hardware in your homelab</p>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
statusFilter={statusFilter}
|
||||
onStatusChange={setStatusFilter}
|
||||
totalCount={filtered.length}
|
||||
/>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading inventory…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 border border-red-500/40 rounded-card bg-red-500/10 text-red-400">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm">{(error as Error).message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !error && filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<p className="font-display font-black text-4xl text-volt mb-2">0</p>
|
||||
<p className="text-[#a0a0a0] text-sm">
|
||||
{items && items.length > 0 ? 'No items match your filters' : 'No items cataloged yet — add your first item'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid view */}
|
||||
{!isLoading && !error && filtered.length > 0 && viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{filtered.map((item) => (
|
||||
<ItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List view */}
|
||||
{!isLoading && !error && filtered.length > 0 && viewMode === 'list' && (
|
||||
<div className="border border-charcoal/80 rounded-card overflow-hidden">
|
||||
{filtered.map((item) => (
|
||||
<ItemRow key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**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 (
|
||||
<div className="flex gap-3 py-2 border-b border-charcoal/40 last:border-0">
|
||||
<span className="label-upper text-[#a0a0a0] w-36 flex-shrink-0">{label}</span>
|
||||
<span className="text-white text-sm flex-1 break-all">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemDetailPage() {
|
||||
const { id } = useParams({ from: '/item/$id' })
|
||||
const numericId = parseInt(id, 10)
|
||||
const { data: item, isLoading, error } = useInventoryItem(numericId)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading item…
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !item) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center gap-3 p-4 border border-red-500/40 rounded-card bg-red-500/10 text-red-400">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm">{(error as Error)?.message ?? 'Item not found'}</span>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/`
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
{/* Back nav */}
|
||||
<div className="mb-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/">
|
||||
<ArrowLeft className="w-4 h-4 mr-1.5" />
|
||||
Inventory
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-start gap-4 mb-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-code text-volt text-sm label-upper mb-1">{item.hw_id || item.asset_tag}</p>
|
||||
<h1 className="font-display font-black text-2xl text-white leading-tight">{item.name}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<StatusBadge status={item.catalog_status} />
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
|
||||
NetBox
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout: photos (left on lg+) / fields (right or below) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Photos */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Photos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{item.photo_urls.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{item.photo_urls.map((url, i) => (
|
||||
<a key={i} href={url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={url}
|
||||
alt={`${item.name} photo ${i + 1}`}
|
||||
className="w-full rounded-sharp object-cover aspect-square hover:opacity-80 transition-opacity"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-[#585858]">
|
||||
<Package className="w-12 h-12 mb-2" />
|
||||
<p className="text-sm">No photos</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldRow label="HW ID" value={item.hw_id || item.asset_tag} />
|
||||
<FieldRow label="Status" value={item.catalog_status} />
|
||||
<FieldRow label="Firmware" value={item.firmware_version} />
|
||||
<FieldRow label="Test Date" value={item.test_date} />
|
||||
<FieldRow label="Product URL" value={item.product_url} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{item.ai_notes && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm label-upper text-[#a0a0a0]">AI Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-white whitespace-pre-wrap">{item.ai_notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{item.test_data && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Test Data</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="font-code text-xs text-[#a0a0a0] bg-near-black p-3 rounded-sharp overflow-x-auto whitespace-pre-wrap break-words">
|
||||
{(() => {
|
||||
try { return JSON.stringify(JSON.parse(item.test_data!), null, 2) }
|
||||
catch { return item.test_data }
|
||||
})()}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**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 = () => (
|
||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-volt border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: () => (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<DashboardPage />
|
||||
</Suspense>
|
||||
),
|
||||
})
|
||||
|
||||
const itemRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/item/$id',
|
||||
component: () => (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<ItemDetailPage />
|
||||
</Suspense>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
Keep intakeRoute and scanRoute as stubs (Plan 04 and 05 replace them).
|
||||
|
||||
Run `npm run build` to confirm TypeScript compiles with no errors.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -15</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<what-built>
|
||||
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
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
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.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" or describe visual/functional issues to fix</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-dashboard-intake-ui/03-03-SUMMARY.md` following the summary template.
|
||||
</output>
|
||||
732
.planning/phases/03-dashboard-intake-ui/03-04-PLAN.md
Normal file
732
.planning/phases/03-dashboard-intake-ui/03-04-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/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
|
||||
|
||||
<interfaces>
|
||||
<!-- POST /api/intake — from Phase 2 Plan 03 -->
|
||||
<!-- Request: multipart/form-data -->
|
||||
<!-- photos[]: File (1-3 images) -->
|
||||
<!-- quick_add: "true" | "false" (optional, string in FormData) -->
|
||||
|
||||
<!-- Response (201 success): -->
|
||||
```typescript
|
||||
interface IntakeResponse {
|
||||
hw_id: string
|
||||
model: string
|
||||
manufacturer: string
|
||||
category: string
|
||||
specs: Record<string, string>
|
||||
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)
|
||||
}
|
||||
```
|
||||
<!-- Response (202): queued=true, hw_id assigned but NetBox unavailable -->
|
||||
<!-- Response (400): {"error": "..."} — wrong photo count or bad request -->
|
||||
<!-- Response (503): {"error": "..."} — WAQ also unavailable -->
|
||||
|
||||
<!-- From Plan 01: Zustand pattern -->
|
||||
// Create a separate store for intake (not in ui.ts — different concern)
|
||||
// useIntakeStore with step, photos, aiResult, editedName, isSubmitting, error
|
||||
|
||||
<!-- From Plan 01: design tokens -->
|
||||
// bg-canvas, bg-near-black, text-volt, border-charcoal/80, rounded-card, rounded-sharp
|
||||
// Button variants: default (volt), forest, secondary, outline, ghost
|
||||
|
||||
<!-- react-dropzone usage: -->
|
||||
// import { useDropzone } from 'react-dropzone'
|
||||
// const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
// accept: { 'image/*': [] },
|
||||
// maxFiles: 3,
|
||||
// onDrop: acceptedFiles => { ... }
|
||||
// })
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Intake store, DropZone component, and API mutation</name>
|
||||
<files>
|
||||
web/src/store/intake.ts,
|
||||
web/src/lib/api.ts,
|
||||
web/src/components/intake/DropZone.tsx,
|
||||
web/src/components/intake/PhotoPreview.tsx
|
||||
</files>
|
||||
<action>
|
||||
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<string, string>
|
||||
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<IntakeStore>((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<string, string>
|
||||
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<IntakeResponse> {
|
||||
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<IntakeResponse>
|
||||
}
|
||||
```
|
||||
|
||||
**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 (
|
||||
<div className="flex gap-3 flex-wrap mt-4">
|
||||
{photos.map((file, i) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
return (
|
||||
<div key={i} className="relative w-24 h-24 rounded-sharp overflow-hidden border border-charcoal/80 group">
|
||||
<img src={url} alt={`Photo ${i + 1}`} className="w-full h-full object-cover" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => onRemove(i)}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**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 (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center gap-3 rounded-card border-2 border-dashed p-12 text-center transition-colors',
|
||||
isDragActive
|
||||
? 'border-volt bg-volt/5 text-volt'
|
||||
: disabled
|
||||
? 'border-charcoal/40 text-charcoal cursor-not-allowed opacity-50'
|
||||
: 'border-charcoal/80 text-[#a0a0a0] hover:border-volt/60 hover:text-white cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} capture="environment" />
|
||||
{isDragActive ? (
|
||||
<Upload className="w-10 h-10 text-volt" />
|
||||
) : (
|
||||
<Camera className="w-10 h-10" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-semibold text-sm">
|
||||
{isDragActive
|
||||
? 'Drop photos here'
|
||||
: disabled
|
||||
? 'Maximum 3 photos reached'
|
||||
: 'Drag photos or tap to shoot'}
|
||||
</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{disabled ? '' : `${remaining} of ${MAX} slots remaining · JPEG, PNG, WebP`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Run `npm run build` to confirm TypeScript compiles.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: AIResultReview, ConfirmForm, IntakePage, and router wiring</name>
|
||||
<files>
|
||||
web/src/components/intake/AIResultReview.tsx,
|
||||
web/src/components/intake/ConfirmForm.tsx,
|
||||
web/src/pages/IntakePage.tsx,
|
||||
web/src/router.tsx
|
||||
</files>
|
||||
<action>
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-near-black rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-[#a0a0a0]">{pct}%</span>
|
||||
{pct >= 85 ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AIResultReview({ result, editedName, onNameChange }: AIResultReviewProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Confidence */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm label-upper text-[#a0a0a0]">AI Confidence</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ConfidenceMeter value={result.confidence} />
|
||||
{result.confidence < 0.6 && (
|
||||
<p className="mt-2 text-xs text-yellow-400">
|
||||
Low confidence — item will be flagged for research
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Editable name */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm label-upper text-[#a0a0a0] flex items-center gap-2">
|
||||
Item Name
|
||||
<Edit3 className="w-3 h-3" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<input
|
||||
type="text"
|
||||
value={editedName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Classification */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Classification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex gap-3">
|
||||
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Manufacturer</span>
|
||||
<span className="text-white">{result.manufacturer || '—'}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Model</span>
|
||||
<span className="text-white">{result.model || '—'}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Category</span>
|
||||
<span className="text-white">{result.category || '—'}</span>
|
||||
</div>
|
||||
{result.suggested_tags.length > 0 && (
|
||||
<div className="flex gap-3 flex-wrap items-start pt-1">
|
||||
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Tags</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{result.suggested_tags.map((tag) => (
|
||||
<Badge key={tag} variant="default" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AI notes */}
|
||||
{result.ai_notes && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm label-upper text-[#a0a0a0]">AI Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-white whitespace-pre-wrap">{result.ai_notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**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 (
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
variant="forest"
|
||||
className="flex-1"
|
||||
onClick={onConfirm}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating record…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Confirm & Create Record
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onReset} disabled={isSubmitting}>
|
||||
<RotateCcw className="w-4 h-4 mr-1.5" />
|
||||
Start Over
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**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 (
|
||||
<AppShell>
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display font-black text-3xl text-white mb-1">Add Item</h1>
|
||||
<p className="text-sm text-[#a0a0a0]">Upload 1–3 photos — AI extracts specs automatically</p>
|
||||
</div>
|
||||
|
||||
{/* Step: upload */}
|
||||
{(step === 'upload' || step === 'error') && (
|
||||
<div className="max-w-xl space-y-4">
|
||||
<DropZone photoCount={photos.length} onDrop={(files) => files.forEach(addPhoto)} />
|
||||
<PhotoPreview photos={photos} onRemove={removePhoto} />
|
||||
{step === 'error' && (
|
||||
<Card className="border-red-500/40 bg-red-500/10">
|
||||
<CardContent className="p-4 text-sm text-red-400">
|
||||
Analysis failed — check that the Go backend is running and try again.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Button
|
||||
variant="forest"
|
||||
className="w-full"
|
||||
onClick={handleAnalyze}
|
||||
disabled={photos.length === 0}
|
||||
>
|
||||
Analyze with AI
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: submitting */}
|
||||
{step === 'submitting' && (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-volt" />
|
||||
<p className="text-white font-semibold">Analyzing photos…</p>
|
||||
<p className="text-sm text-[#a0a0a0]">Gemma 4 is extracting specs</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: review */}
|
||||
{step === 'review' && aiResult && (
|
||||
<div className="max-w-xl space-y-4">
|
||||
<AIResultReview
|
||||
result={aiResult}
|
||||
editedName={editedName}
|
||||
onNameChange={setEditedName}
|
||||
/>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="forest" className="flex-1" onClick={handleDone}>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Done — Go to Dashboard
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={reset}>
|
||||
Add Another
|
||||
</Button>
|
||||
</div>
|
||||
{aiResult.queued && (
|
||||
<p className="text-xs text-yellow-400 text-center">
|
||||
NetBox was unavailable — item is queued for sync
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**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: () => (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<IntakePage />
|
||||
</Suspense>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
Run `npm run build`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<what-built>
|
||||
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"
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
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
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-dashboard-intake-ui/03-04-SUMMARY.md` following the summary template.
|
||||
</output>
|
||||
690
.planning/phases/03-dashboard-intake-ui/03-05-PLAN.md
Normal file
690
.planning/phases/03-dashboard-intake-ui/03-05-PLAN.md
Normal file
|
|
@ -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: "<link rel='manifest' href='/manifest.json'>"
|
||||
- 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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
|
||||
@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- @zxing/browser usage (already in package.json from Plan 01) -->
|
||||
```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 code URL format (from Phase 1 PRD and LBL-01): -->
|
||||
// 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) } })
|
||||
|
||||
<!-- From Plan 01: router export -->
|
||||
// import { router } from '@/router'
|
||||
// router.navigate({ to: '/item/$id', params: { id: String(numericId) } })
|
||||
|
||||
<!-- From Plan 01: design tokens -->
|
||||
// bg-canvas, text-volt, bg-near-black, border-charcoal/80
|
||||
// Button variant="forest" for primary actions
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: PWA manifest, service worker, icons, and registration hook</name>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<action>
|
||||
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 `<link rel="manifest" href="/manifest.json" />` inside `<head>`.
|
||||
|
||||
Add `<meta name="theme-color" content="#faff69" />` inside `<head>` if not already present.
|
||||
|
||||
Run `npm run build`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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')"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`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`.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: QR scanner page with @zxing/browser and router wiring</name>
|
||||
<files>
|
||||
web/src/pages/ScanPage.tsx,
|
||||
web/src/router.tsx
|
||||
</files>
|
||||
<action>
|
||||
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 `<video>` element
|
||||
3. On QR decode: parse text to extract HW ID or item URL
|
||||
4. Lookup item ID from inventory: `GET /api/inventory`, find by `hw_id` match
|
||||
5. Navigate to `/item/:id`
|
||||
|
||||
QR text parsing:
|
||||
- If text matches `/hw/(HW-\d+)/i` or just `HW-\d+` → extract HW ID
|
||||
- Look up numeric NetBox ID by matching hw_id in the inventory list
|
||||
- Navigate to `/item/$id`
|
||||
|
||||
```typescript
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { BrowserQRCodeReader, type IScannerControls } from '@zxing/browser'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/layout/AppShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Camera, Loader2, QrCode, AlertCircle } from 'lucide-react'
|
||||
import { fetchInventory } from '@/lib/api'
|
||||
import { useUIStore } from '@/store/ui'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
function extractHWID(text: string): string | null {
|
||||
// Match full URL: http://mac-mini.mg:8080/hw/HW-00001
|
||||
const urlMatch = text.match(/\/hw\/(HW-\d{5,})/i)
|
||||
if (urlMatch) return urlMatch[1].toUpperCase()
|
||||
// Match bare HW ID: HW-00001
|
||||
const bareMatch = text.match(/^(HW-\d{5,})$/i)
|
||||
if (bareMatch) return bareMatch[1].toUpperCase()
|
||||
return null
|
||||
}
|
||||
|
||||
type ScanState = 'idle' | 'requesting' | 'scanning' | 'resolving' | 'error'
|
||||
|
||||
export function ScanPage() {
|
||||
const navigate = useNavigate()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const controlsRef = useRef<IScannerControls | null>(null)
|
||||
const readerRef = useRef<BrowserQRCodeReader | null>(null)
|
||||
const { setScannerActive } = useUIStore()
|
||||
|
||||
const [scanState, setScanState] = useState<ScanState>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const [lastScanned, setLastScanned] = useState<string | null>(null)
|
||||
|
||||
const stopScanner = useCallback(() => {
|
||||
controlsRef.current?.stop()
|
||||
controlsRef.current = null
|
||||
setScannerActive(false)
|
||||
}, [setScannerActive])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => stopScanner(), [stopScanner])
|
||||
|
||||
const startScanner = useCallback(async () => {
|
||||
setScanState('requesting')
|
||||
setErrorMsg(null)
|
||||
|
||||
try {
|
||||
const reader = new BrowserQRCodeReader()
|
||||
readerRef.current = reader
|
||||
|
||||
const devices = await BrowserQRCodeReader.listVideoInputDevices()
|
||||
if (devices.length === 0) {
|
||||
throw new Error('No camera found on this device')
|
||||
}
|
||||
|
||||
// Prefer rear camera (contains "back" or "environment" in label)
|
||||
const rear = devices.find((d) =>
|
||||
/back|rear|environment/i.test(d.label)
|
||||
)
|
||||
const deviceId = rear?.deviceId ?? devices[devices.length - 1].deviceId
|
||||
|
||||
setScanState('scanning')
|
||||
setScannerActive(true)
|
||||
|
||||
const controls = await reader.decodeFromVideoDevice(
|
||||
deviceId,
|
||||
videoRef.current!,
|
||||
async (result, _err) => {
|
||||
if (!result) return
|
||||
const text = result.getText()
|
||||
const hwid = extractHWID(text)
|
||||
|
||||
if (!hwid) {
|
||||
// Not an HWLab QR code — ignore
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce: skip if same code scanned recently
|
||||
if (hwid === lastScanned) return
|
||||
setLastScanned(hwid)
|
||||
|
||||
controls.stop()
|
||||
setScanState('resolving')
|
||||
|
||||
try {
|
||||
const items = await fetchInventory()
|
||||
const item = items.find(
|
||||
(i) => i.hw_id?.toUpperCase() === hwid || i.asset_tag?.toUpperCase() === hwid
|
||||
)
|
||||
|
||||
if (!item) {
|
||||
toast.error(`${hwid} not found in inventory`)
|
||||
setScanState('scanning')
|
||||
// Restart scanner
|
||||
startScanner()
|
||||
return
|
||||
}
|
||||
|
||||
stopScanner()
|
||||
navigate({ to: '/item/$id', params: { id: String(item.id) } })
|
||||
} catch (e) {
|
||||
toast.error('Failed to look up item')
|
||||
setScanState('scanning')
|
||||
startScanner()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
controlsRef.current = controls
|
||||
} catch (e) {
|
||||
setErrorMsg((e as Error).message)
|
||||
setScanState('error')
|
||||
setScannerActive(false)
|
||||
}
|
||||
}, [lastScanned, navigate, setScannerActive, stopScanner])
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mb-6">
|
||||
<h1 className="font-display font-black text-3xl text-white mb-1">Scan QR Code</h1>
|
||||
<p className="text-sm text-[#a0a0a0]">Point camera at an HWLab label to open the item</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-sm mx-auto space-y-4">
|
||||
{/* Camera viewfinder */}
|
||||
<div className="relative aspect-square bg-near-black rounded-card overflow-hidden border border-charcoal/80">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-cover"
|
||||
playsInline
|
||||
muted
|
||||
/>
|
||||
|
||||
{/* Scan reticle overlay */}
|
||||
{scanState === 'scanning' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="w-48 h-48 border-2 border-volt rounded-sharp opacity-80">
|
||||
{/* Corner brackets */}
|
||||
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-volt" />
|
||||
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-volt" />
|
||||
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-volt" />
|
||||
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-volt" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Idle state overlay */}
|
||||
{scanState === 'idle' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-[#a0a0a0]">
|
||||
<QrCode className="w-16 h-16" />
|
||||
<p className="text-sm">Camera not started</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{(scanState === 'requesting' || scanState === 'resolving') && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-canvas/80">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-volt" />
|
||||
<p className="text-sm text-white">
|
||||
{scanState === 'requesting' ? 'Starting camera…' : 'Looking up item…'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{scanState === 'error' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<AlertCircle className="w-10 h-10 text-red-400" />
|
||||
<p className="text-sm text-red-400">{errorMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{scanState === 'idle' || scanState === 'error' ? (
|
||||
<Button variant="forest" className="w-full" onClick={startScanner}>
|
||||
<Camera className="w-4 h-4 mr-2" />
|
||||
{scanState === 'error' ? 'Try Again' : 'Start Camera'}
|
||||
</Button>
|
||||
) : scanState === 'scanning' ? (
|
||||
<Button variant="secondary" className="w-full" onClick={stopScanner}>
|
||||
Stop Scanner
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-center text-[#585858]">
|
||||
Scans QR codes printed on HWLab labels (HW-XXXXX format)
|
||||
</p>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Update web/src/router.tsx** — replace stub scanRoute with lazy ScanPage:
|
||||
Read current `web/src/router.tsx`, then update `scanRoute`:
|
||||
```typescript
|
||||
const ScanPage = lazy(() => import('./pages/ScanPage').then(m => ({ default: m.ScanPage })))
|
||||
|
||||
const scanRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/scan',
|
||||
component: () => (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<ScanPage />
|
||||
</Suspense>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
Run `npm run build`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`npm run build` exits 0 with no TypeScript errors. `web/src/pages/ScanPage.tsx` exports `ScanPage`. `web/src/router.tsx` lazy-loads ScanPage. `extractHWID` function handles both URL and bare HW-ID formats.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<what-built>
|
||||
PWA setup and QR scanner:
|
||||
- web/public/manifest.json — installable PWA config (name, theme_color=#faff69, display=standalone, icons)
|
||||
- web/public/sw.js — app shell cache (API calls always network-only, navigation uses cache-first)
|
||||
- web/public/icons/ — 192px and 512px PNG icons (black canvas, volt "H" monogram)
|
||||
- web/src/pages/ScanPage.tsx — /scan route with live camera, volt reticle overlay, HW ID parsing
|
||||
- Service worker registered on app load via usePWA() hook
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
**PWA installability (Chrome on Android or Chrome DevTools > Application):**
|
||||
1. Build: `cd web && npm run build`
|
||||
2. Start Go backend (which serves the built SPA): `go run ./cmd/hwlab/...`
|
||||
3. Open http://mac-mini.mg:8080 in Chrome
|
||||
4. Open DevTools > Application > Manifest
|
||||
- [ ] Manifest loads with correct name, theme_color #faff69
|
||||
- [ ] Icons show as 192px and 512px (may be the simple monogram placeholder)
|
||||
- [ ] No manifest errors in DevTools
|
||||
5. DevTools > Application > Service Workers
|
||||
- [ ] sw.js registered and status is "Activated and is running"
|
||||
6. On Android Chrome: address bar shows install banner or + icon to "Add to Home screen"
|
||||
|
||||
**QR scanner (http://localhost:5173/scan or installed PWA):**
|
||||
1. Navigate to /scan
|
||||
- [ ] "Scan QR Code" heading, gray QR code icon, "Start Camera" forest green button
|
||||
2. Click "Start Camera"
|
||||
- [ ] Camera permission dialog appears
|
||||
- [ ] After permission: live video feed shows in viewfinder, volt reticle overlay appears
|
||||
3. Point camera at any QR code (or print test): `http://mac-mini.mg:8080/hw/HW-00001`
|
||||
- [ ] Toast appears on match if item exists in NetBox, navigates to /item/:id
|
||||
- [ ] Toast "HW-00001 not found in inventory" if item doesn't exist yet
|
||||
4. Test "Stop Scanner" button stops camera and returns to idle state
|
||||
|
||||
**Mobile layout (390px):**
|
||||
- [ ] /scan viewfinder is square and fills the screen width
|
||||
- [ ] "Start Camera" button is full-width, tap-friendly
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Camera → QR decode | Raw camera frames decoded by @zxing — output is untrusted text |
|
||||
| QR text → navigation | Decoded QR text is parsed before any navigation or API call |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-03-14 | Tampering | QR text → extractHWID | mitigate | `extractHWID` regex only matches `HW-\d{5,}` pattern; arbitrary text cannot trigger navigation; lookup uses numeric NetBox ID from server-returned data, not from QR content directly |
|
||||
| T-03-15 | Info Disclosure | Camera stream | accept | Camera stream is local to the device; no frames are sent to the server; @zxing decodes frames locally |
|
||||
| T-03-16 | Tampering | Service worker cache | accept | SW caches only app shell (static assets); API responses are explicitly excluded from cache (network-only path); no risk of stale auth or inventory data |
|
||||
| T-03-17 | DoS | Rapid QR scan triggers | mitigate | `lastScanned` state debounces repeated scans of the same QR code; scanner stops before resolving item |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `cd web && npm run build` — exits 0
|
||||
2. `ls web/public/manifest.json web/public/sw.js web/public/icons/icon-192.png web/public/icons/icon-512.png` — all exist
|
||||
3. `node -e "require('fs').readFileSync('web/public/manifest.json'); console.log('manifest valid JSON')"` — parses without error
|
||||
4. `grep "ScanPage" web/src/router.tsx` — lazy import present
|
||||
5. `grep "extractHWID" web/src/pages/ScanPage.tsx` — HW ID parsing function present
|
||||
6. Visual/functional verification via checkpoint task
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- manifest.json loads in Chrome DevTools with no errors, display=standalone, theme_color=#faff69
|
||||
- Service worker registered and active (DevTools > Application > Service Workers)
|
||||
- PWA "Add to Home Screen" installable on Android Chrome (PWA-01)
|
||||
- /scan activates rear camera and scans QR codes in real-time
|
||||
- Decoded HW-XXXXX QR navigates to /item/:id (PWA-03)
|
||||
- QR text parsing handles both URL format (http://.../hw/HW-00001) and bare HW-ID
|
||||
- Camera permission denied handled gracefully (error state, not crash)
|
||||
- Human verification checkpoint passed
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-dashboard-intake-ui/03-05-SUMMARY.md` following the summary template.
|
||||
</output>
|
||||
Loading…
Add table
Reference in a new issue