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

14 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
03-dashboard-intake-ui 02 execute 1
internal/api/handlers/inventory.go
internal/api/handlers/inventory_test.go
internal/api/router.go
true
UI-01
UI-04
truths artifacts key_links
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
path provides exports
internal/api/handlers/inventory.go InventoryHandler with ListInventory and GetInventoryItem methods
InventoryHandler
NewInventoryHandler
InventoryNetBoxClient
path provides
internal/api/handlers/inventory_test.go Unit tests for list and detail endpoints with mock NetBox client
path provides
internal/api/router.go GET /api/inventory and GET /api/inventory/:id routes registered
from to via
internal/api/handlers/inventory.go internal/netbox.Client InventoryNetBoxClient interface (ListDevices + GetDevice)
from to via
internal/api/router.go internal/api/handlers.InventoryHandler r.Get('/inventory', ...) and r.Get('/inventory/{id}', ...)
Add GET /api/inventory and GET /api/inventory/:id endpoints to the Go backend so the React dashboard can fetch real inventory data.

Purpose: The dashboard view (Plan 03) depends on these endpoints. They must exist and be testable before the frontend wires up TanStack Query hooks. Output: Two handler methods behind an interface, registered on the chi router, with unit tests.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 ```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
}
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler

// Define a narrow interface (InventoryNetBoxClient) rather than taking *netbox.Client directly. // This allows complete unit testing without a real NetBox.

Task 1: InventoryHandler — list and detail endpoints with interface + unit tests internal/api/handlers/inventory.go, internal/api/handlers/inventory_test.go - TestListInventoryEmpty: mock returns [], handler returns 200 with JSON `[]` - TestListInventoryItems: mock returns 2 devices, handler returns 200 with JSON array of 2 items containing hw_id, name, catalog_status, asset_tag, photo_urls - TestListInventoryNetBoxError: mock returns error, handler returns 502 with {"error": "..."} - TestGetInventoryItemFound: mock returns device with id=42, GET /api/inventory/42 returns 200 with device JSON - TestGetInventoryItemNotFound: mock returns nil device + error containing "404", handler returns 404 - TestGetInventoryItemInvalidID: GET /api/inventory/abc returns 400 - TestGetInventoryItemNetBoxError: mock returns server error, handler returns 502 Read first: `internal/api/handlers/intake.go` (interface-for-testability pattern), `internal/netbox/types.go`, `internal/netbox/client.go`.
**RED phase — write tests first:**

Create `internal/api/handlers/inventory_test.go` with a `mockInventoryNetBoxClient` struct implementing `InventoryNetBoxClient`. Write all 7 tests listed in `<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.
cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -run TestInventory -v 2>&1 | tail -20 All 7 TestInventory* tests pass. `internal/api/handlers/inventory.go` exports `InventoryHandler`, `NewInventoryHandler`, `InventoryNetBoxClient`, `InventoryItemResponse`. `go build ./...` succeeds. Task 2: Wire inventory routes into chi router and main.go internal/api/router.go, cmd/hwlab/main.go Read first: `internal/api/router.go`, `cmd/hwlab/main.go`.
**1. Update internal/api/router.go** — extend `NewRouter` to accept an inventory handler:

Change signature from:
```go
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler
```
To:
```go
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *handlers.InventoryHandler) http.Handler
```

Inside the `/api` route group, add:
```go
r.Get("/inventory", inventoryHandler.ListInventory)
r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem)
```

Full updated `/api` block:
```go
r.Route("/api", func(r chi.Router) {
    r.Get("/health", handlers.Health)
    r.Post("/intake", intakeHandler.ServeHTTP)
    r.Get("/inventory", inventoryHandler.ListInventory)
    r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem)
})
```

**2. Update cmd/hwlab/main.go** — construct InventoryHandler and pass to NewRouter:

After the existing netbox client construction (search for `netbox.NewClient`), add:
```go
inventoryHandler := handlers.NewInventoryHandler(nbClient)
```

Update the `api.NewRouter(...)` call to include `inventoryHandler`:
```go
router := api.NewRouter(webFS, intakeHandler, inventoryHandler)
```

Run `go build ./...` and `go test ./...` to confirm no regressions.
cd /home/mikkel/homelabby && go build ./... 2>&1 && go test ./... 2>&1 | tail -15 `go build ./...` exits 0. `go test ./...` — all existing tests pass (integration tests skip gracefully). `grep "inventory" internal/api/router.go` shows both GET routes. `grep "inventoryHandler" cmd/hwlab/main.go` confirms construction and wiring.

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

<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>
After completion, create `.planning/phases/03-dashboard-intake-ui/03-02-SUMMARY.md` following the summary template.