homelabby/.planning/phases/03-dashboard-intake-ui/03-02-PLAN.md
2026-04-10 06:12:02 +00:00

346 lines
14 KiB
Markdown

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