690 lines
26 KiB
Markdown
690 lines
26 KiB
Markdown
---
|
|
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: true
|
|
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>
|