diff --git a/cmd/hwlab/main.go b/cmd/hwlab/main.go index ede209c..80e7704 100644 --- a/cmd/hwlab/main.go +++ b/cmd/hwlab/main.go @@ -11,8 +11,12 @@ import ( "time" hwlab "git.georgsen.dk/hwlab" + "git.georgsen.dk/hwlab/internal/ai" "git.georgsen.dk/hwlab/internal/api" + "git.georgsen.dk/hwlab/internal/api/handlers" "git.georgsen.dk/hwlab/internal/config" + "git.georgsen.dk/hwlab/internal/inventory" + "git.georgsen.dk/hwlab/internal/netbox" "git.georgsen.dk/hwlab/internal/queue" ) @@ -31,18 +35,49 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - // Start write-ahead queue worker (non-fatal if DragonFlyDB unavailable) - waq, err := queue.NewWAQ(cfg.DragonflyURL) + // NetBox client (required — fatal if misconfigured) + nbClient, err := netbox.NewClient(cfg.NetBoxURL, cfg.NetBoxToken) 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 { + nbHandler := queue.NewNetBoxOpHandler(nbClient) retryInterval := time.Duration(cfg.WAQRetryIntervalSeconds) * time.Second - go waq.RunWorker(ctx, queue.NoOpHandler, cfg.WAQMaxAttempts, retryInterval) - defer waq.Close() + go waqInstance.RunWorker(ctx, nbHandler, cfg.WAQMaxAttempts, retryInterval) + defer waqInstance.Close() + waqForHandler = waqInstance 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) log.Printf("HWLab starting on %s", addr) diff --git a/internal/api/router.go b/internal/api/router.go index 9bb4ba9..a5ac44b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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, // 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.Use(middleware.Logger) r.Use(middleware.Recoverer) @@ -40,6 +41,7 @@ func NewRouter(staticFiles fs.FS) http.Handler { r.Route("/api", func(r chi.Router) { r.Get("/health", handlers.Health) + r.Post("/intake", intakeHandler.ServeHTTP) }) // SPA fallback — serve static files; unknown paths fall back to index.html. diff --git a/internal/config/config.go b/internal/config/config.go index 3df7265..14bad7a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,10 @@ type Config struct { 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"` } @@ -41,6 +45,9 @@ func Load() (*Config, error) { v.SetDefault("waq_retry_interval_seconds", 30) v.SetDefault("waq_max_attempts", 5) 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 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_max_attempts", "HWLAB_WAQ_MAX_ATTEMPTS") _ = 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 _ = v.BindEnv("ai.tier1.base_url", "HWLAB_AI_TIER1_BASE_URL")