- internal/api/handlers/search.go: SearchHandler, NewSearchHandler, SearchDevices - Sanitizes query (non-printable stripped, 200 char max) per T-07-05 - LLM extracts catalog_status/name_contains/tag; falls back to substring on parse failure - internal/api/handlers/search_test.go: 4 tests covering 400, fallback, status filter, combined - internal/api/router.go: wires GET /api/search with nil guard (503) - cmd/hwlab/main.go: constructs searchHandler and passes to NewRouter
165 lines
5.6 KiB
Go
165 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
hwlab "git.georgsen.dk/hwlab"
|
|
"git.georgsen.dk/hwlab/internal/advisor"
|
|
"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/printer"
|
|
"git.georgsen.dk/hwlab/internal/queue"
|
|
"git.georgsen.dk/hwlab/internal/research"
|
|
"git.georgsen.dk/hwlab/internal/store"
|
|
"git.georgsen.dk/hwlab/internal/usb"
|
|
)
|
|
|
|
func main() {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
log.Fatalf("config: %v", err)
|
|
}
|
|
|
|
staticFS, err := fs.Sub(hwlab.StaticFiles, "web/dist")
|
|
if err != nil {
|
|
log.Fatalf("embed: %v", err)
|
|
}
|
|
|
|
// Context for graceful shutdown
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
// NetBox client (required — fatal if misconfigured)
|
|
nbClient, err := netbox.NewClient(cfg.NetBoxURL, cfg.NetBoxToken)
|
|
if err != nil {
|
|
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 waqInstance.RunWorker(ctx, nbHandler, cfg.WAQMaxAttempts, retryInterval)
|
|
defer waqInstance.Close()
|
|
waqForHandler = waqInstance
|
|
log.Printf("WAQ worker started")
|
|
}
|
|
|
|
// USB Manager — polls for device connect/disconnect events.
|
|
// Start with 2-second poll interval (production default).
|
|
usbManager := usb.NewManager(2 * time.Second)
|
|
go usbManager.Start(ctx)
|
|
defer usbManager.Stop()
|
|
|
|
// Printer driver — MockDriver until PRT Qutie hardware arrives (2026-04-13).
|
|
// TODO(hardware): replace with printer.NewPrtQutieDriver(9600) after characterization.
|
|
mockDriver := printer.NewMockDriver()
|
|
if err := mockDriver.Connect(); err != nil {
|
|
log.Printf("WARNING: mock printer connect: %v", err)
|
|
}
|
|
|
|
// Intake handler — waqForHandler and mockDriver 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,
|
|
mockDriver,
|
|
)
|
|
|
|
inventoryHandler := handlers.NewInventoryHandler(nbClient)
|
|
labelHandler := handlers.NewLabelHandler(nbClient, mockDriver)
|
|
usbEventsHandler := handlers.NewUSBEventsHandler(usbManager)
|
|
testHandler := handlers.NewTestHandler(nbClient, mockDriver)
|
|
|
|
// Store + advisor — non-fatal if DB unavailable (advisor degrades gracefully).
|
|
var advisorHandler *advisor.AdvisorHandler
|
|
dbDSN := os.Getenv("HWLAB_DATABASE_URL")
|
|
if dbDSN != "" {
|
|
s, storeErr := store.NewStore(ctx, dbDSN)
|
|
if storeErr != nil {
|
|
log.Printf("WARNING: store unavailable (%v) — advisor endpoints will be disabled", storeErr)
|
|
} else {
|
|
defer s.Close()
|
|
if migErr := store.RunMigrations(ctx, s.Pool()); migErr != nil {
|
|
log.Printf("WARNING: store migrations failed (%v) — advisor endpoints may misbehave", migErr)
|
|
}
|
|
ctxBuilder := advisor.NewInventoryContextBuilder(nbClient)
|
|
advisorHandler = advisor.NewAdvisorHandler(s, ctxBuilder, cfg.AI)
|
|
log.Printf("Advisor handler ready")
|
|
}
|
|
} else {
|
|
log.Printf("HWLAB_DATABASE_URL not set — advisor endpoints disabled")
|
|
}
|
|
|
|
// Research agent — enriches needs_research items via SearXNG + Tier 2 LLM.
|
|
searxngClient := research.NewSearXNGClient(cfg.SearXNGURL)
|
|
researchAgent := research.NewAgent(nbClient, searxngClient, tier2, catalogUpdater)
|
|
go researchAgent.Start(ctx, 10*time.Minute)
|
|
researchHandler := handlers.NewResearchHandler(researchAgent)
|
|
searchHandler := handlers.NewSearchHandler(nbClient, tier1)
|
|
|
|
// Wire USB Manager events to cable tester driver when a RoleCableTester device connects.
|
|
// Currently a no-op stub — wires the plumbing for Phase 5 hardware integration.
|
|
go func() {
|
|
for evt := range usbManager.Events() {
|
|
if evt.Spec.Role == usb.RoleCableTester {
|
|
log.Printf("cable tester connected: %s", evt.VIDPID)
|
|
// TODO(hardware): construct tester driver for evt.VIDPID,
|
|
// call driver.Connect(), then testHandler.AttachStream(driver.Stream())
|
|
_ = testHandler
|
|
}
|
|
}
|
|
}()
|
|
|
|
router := api.NewRouter(staticFS, intakeHandler, inventoryHandler, labelHandler, usbEventsHandler, testHandler, advisorHandler, researchHandler, searchHandler)
|
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
|
log.Printf("HWLab starting on %s", addr)
|
|
|
|
srv := &http.Server{Addr: addr, Handler: router}
|
|
go func() {
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("server: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Wait for shutdown signal
|
|
<-ctx.Done()
|
|
log.Println("Shutting down...")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
log.Printf("server shutdown: %v", err)
|
|
}
|
|
log.Println("Shutdown complete")
|
|
}
|