346 lines
14 KiB
Markdown
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>
|