feat(02-03): wire POST /api/intake route, real WAQ handler, and NetBox defaults in config

- router.go: NewRouter accepts intakeHandler http.Handler, registers POST /api/intake
- config.go: adds NetBoxDefaultDeviceTypeID/RoleID/SiteID fields with defaults and env bindings
- main.go: creates netbox.Client, ai.Orchestrator, inventory.CatalogUpdater, handlers.IntakeHandler
- main.go: replaces NoOpHandler with NewNetBoxOpHandler(nbClient) for WAQ worker
- main.go: uses typed interface variable for WAQ to avoid nil-interface-wrapping bug
This commit is contained in:
Mikkel Georgsen 2026-04-10 05:55:41 +00:00
parent 4fc9362519
commit 59aa89b199
3 changed files with 54 additions and 7 deletions

View file

@ -11,8 +11,12 @@ import (
"time" "time"
hwlab "git.georgsen.dk/hwlab" hwlab "git.georgsen.dk/hwlab"
"git.georgsen.dk/hwlab/internal/ai"
"git.georgsen.dk/hwlab/internal/api" "git.georgsen.dk/hwlab/internal/api"
"git.georgsen.dk/hwlab/internal/api/handlers"
"git.georgsen.dk/hwlab/internal/config" "git.georgsen.dk/hwlab/internal/config"
"git.georgsen.dk/hwlab/internal/inventory"
"git.georgsen.dk/hwlab/internal/netbox"
"git.georgsen.dk/hwlab/internal/queue" "git.georgsen.dk/hwlab/internal/queue"
) )
@ -31,18 +35,49 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
// Start write-ahead queue worker (non-fatal if DragonFlyDB unavailable) // NetBox client (required — fatal if misconfigured)
waq, err := queue.NewWAQ(cfg.DragonflyURL) nbClient, err := netbox.NewClient(cfg.NetBoxURL, cfg.NetBoxToken)
if err != nil { if err != nil {
log.Printf("WARNING: WAQ unavailable (%v) — NetBox operations will not be queued during downtime", err) log.Fatalf("netbox client: %v", err)
}
// AI tier clients and orchestrator
tier1 := ai.NewTierClient(cfg.AI.Tier1)
tier2 := ai.NewTierClient(cfg.AI.Tier2)
orch := ai.NewOrchestrator(tier1, tier2, cfg.AI.ConfidenceThreshold)
// Catalog updater
catalogUpdater := inventory.NewCatalogUpdater(nbClient)
// Start write-ahead queue worker (non-fatal if DragonFlyDB unavailable).
// waqForHandler is typed as the interface so that a nil *WAQ is not wrapped in a non-nil interface.
var waqForHandler handlers.IntakeWAQ
waqInstance, waqErr := queue.NewWAQ(cfg.DragonflyURL)
if waqErr != nil {
log.Printf("WARNING: WAQ unavailable (%v) — NetBox operations will not be queued during downtime", waqErr)
} else { } else {
nbHandler := queue.NewNetBoxOpHandler(nbClient)
retryInterval := time.Duration(cfg.WAQRetryIntervalSeconds) * time.Second retryInterval := time.Duration(cfg.WAQRetryIntervalSeconds) * time.Second
go waq.RunWorker(ctx, queue.NoOpHandler, cfg.WAQMaxAttempts, retryInterval) go waqInstance.RunWorker(ctx, nbHandler, cfg.WAQMaxAttempts, retryInterval)
defer waq.Close() defer waqInstance.Close()
waqForHandler = waqInstance
log.Printf("WAQ worker started") log.Printf("WAQ worker started")
} }
router := api.NewRouter(staticFS) // Intake handler — waqForHandler may be nil; handler handles nil gracefully
intakeHandler := handlers.NewIntakeHandler(
orch,
nbClient,
catalogUpdater,
waqForHandler,
cfg.NetBoxDefaultDeviceTypeID,
cfg.NetBoxDefaultRoleID,
cfg.NetBoxDefaultSiteID,
cfg.AI.QuickAddEnabled,
cfg.AI.QuickAddThreshold,
)
router := api.NewRouter(staticFS, intakeHandler)
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
log.Printf("HWLab starting on %s", addr) log.Printf("HWLab starting on %s", addr)

View file

@ -32,7 +32,8 @@ func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// NewRouter creates the chi router. staticFiles is the fs.FS rooted at web/dist, // NewRouter creates the chi router. staticFiles is the fs.FS rooted at web/dist,
// passed from main.go where the go:embed directive lives. // passed from main.go where the go:embed directive lives.
func NewRouter(staticFiles fs.FS) http.Handler { // intakeHandler handles POST /api/intake (multipart photo upload).
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
@ -40,6 +41,7 @@ func NewRouter(staticFiles fs.FS) http.Handler {
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Get("/health", handlers.Health) r.Get("/health", handlers.Health)
r.Post("/intake", intakeHandler.ServeHTTP)
}) })
// SPA fallback — serve static files; unknown paths fall back to index.html. // SPA fallback — serve static files; unknown paths fall back to index.html.

View file

@ -24,6 +24,10 @@ type Config struct {
QualityGateConfidenceThreshold float64 `mapstructure:"quality_gate_confidence_threshold"` QualityGateConfidenceThreshold float64 `mapstructure:"quality_gate_confidence_threshold"`
NetBoxDefaultDeviceTypeID int32 `mapstructure:"netbox_default_device_type_id"`
NetBoxDefaultRoleID int32 `mapstructure:"netbox_default_role_id"`
NetBoxDefaultSiteID int32 `mapstructure:"netbox_default_site_id"`
AI ai.AIConfig `mapstructure:"ai"` AI ai.AIConfig `mapstructure:"ai"`
} }
@ -41,6 +45,9 @@ func Load() (*Config, error) {
v.SetDefault("waq_retry_interval_seconds", 30) v.SetDefault("waq_retry_interval_seconds", 30)
v.SetDefault("waq_max_attempts", 5) v.SetDefault("waq_max_attempts", 5)
v.SetDefault("quality_gate_confidence_threshold", 0.75) v.SetDefault("quality_gate_confidence_threshold", 0.75)
v.SetDefault("netbox_default_device_type_id", 1)
v.SetDefault("netbox_default_role_id", 1)
v.SetDefault("netbox_default_site_id", 1)
// AI tier defaults // AI tier defaults
v.SetDefault("ai.tier1.base_url", "http://localhost:8000/v1") v.SetDefault("ai.tier1.base_url", "http://localhost:8000/v1")
@ -76,6 +83,9 @@ func Load() (*Config, error) {
_ = v.BindEnv("waq_retry_interval_seconds", "HWLAB_WAQ_RETRY_INTERVAL_SECONDS") _ = v.BindEnv("waq_retry_interval_seconds", "HWLAB_WAQ_RETRY_INTERVAL_SECONDS")
_ = v.BindEnv("waq_max_attempts", "HWLAB_WAQ_MAX_ATTEMPTS") _ = v.BindEnv("waq_max_attempts", "HWLAB_WAQ_MAX_ATTEMPTS")
_ = v.BindEnv("quality_gate_confidence_threshold", "HWLAB_QUALITY_GATE_CONFIDENCE_THRESHOLD") _ = v.BindEnv("quality_gate_confidence_threshold", "HWLAB_QUALITY_GATE_CONFIDENCE_THRESHOLD")
_ = v.BindEnv("netbox_default_device_type_id", "HWLAB_NETBOX_DEFAULT_DEVICE_TYPE_ID")
_ = v.BindEnv("netbox_default_role_id", "HWLAB_NETBOX_DEFAULT_ROLE_ID")
_ = v.BindEnv("netbox_default_site_id", "HWLAB_NETBOX_DEFAULT_SITE_ID")
// AI env bindings // AI env bindings
_ = v.BindEnv("ai.tier1.base_url", "HWLAB_AI_TIER1_BASE_URL") _ = v.BindEnv("ai.tier1.base_url", "HWLAB_AI_TIER1_BASE_URL")