package api import ( "embed" "fmt" "html/template" "net/http" "time" "reanimator/internal/domain" analyticsrepo "reanimator/internal/repository/analytics" "reanimator/internal/repository/failures" "reanimator/internal/repository/registry" "reanimator/internal/repository/tickets" "reanimator/internal/repository/timeline" ) //go:embed ui_*.tmpl var uiTemplates embed.FS var uiTemplate = template.Must(template.New("ui").Funcs(template.FuncMap{ "formatTime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05 UTC") }, "formatTimePtr": func(t *time.Time) string { if t == nil { return "—" } return t.UTC().Format("2006-01-02 15:04:05 UTC") }, "formatFloat": func(value float64, decimals int) string { return fmt.Sprintf("%.*f", decimals, value) }, "formatFloatPtr": func(value *float64, decimals int) string { if value == nil { return "—" } return fmt.Sprintf("%.*f", decimals, *value) }, }).ParseFS(uiTemplates, "ui_*.tmpl")) type UIDependencies struct { Customers *registry.CustomerRepository Projects *registry.ProjectRepository Assets *registry.AssetRepository Components *registry.ComponentRepository Installations *registry.InstallationRepository Timeline *timeline.EventRepository Tickets *tickets.TicketRepository Failures *failures.FailureRepository Analytics *analyticsrepo.Repository } type uiHandlers struct { deps UIDependencies } func RegisterUIRoutes(mux *http.ServeMux, deps UIDependencies) { h := uiHandlers{deps: deps} mux.HandleFunc("/", h.handleStart) mux.HandleFunc("/ui", h.handleIndex) mux.HandleFunc("/ui/", h.handleIndex) mux.HandleFunc("/ui/assets", h.handleAssetList) mux.HandleFunc("/ui/assets/", h.handleAssetPage) mux.HandleFunc("/ui/components", h.handleComponentList) mux.HandleFunc("/ui/components/", h.handleComponentPage) mux.HandleFunc("/ui/tickets", h.handleTicketList) mux.HandleFunc("/ui/failures", h.handleFailureList) mux.HandleFunc("/ui/ingest", h.handleIngestPage) mux.HandleFunc("/ui/analytics", h.handleAnalytics) } type uiPage struct { PageTitle string PageSubtitle string HeroTag string ActiveNav string } type indexPageData struct { uiPage CustomerCount int ProjectCount int AssetCount int ComponentCount int Assets []domain.Asset Components []domain.Component } type assetListPageData struct { uiPage Assets []domain.Asset } type assetPageData struct { uiPage Asset domain.Asset Components []domain.Component Events []domain.TimelineEvent Tickets []domain.Ticket } type componentListPageData struct { uiPage Components []domain.Component } type componentPageData struct { uiPage Component domain.Component Events []domain.TimelineEvent } type analyticsPageData struct { uiPage Start string End string HorizonDays int Multiplier float64 LotMetrics []analyticsrepo.LotMetrics FirmwareRisk []analyticsrepo.FirmwareRisk SpareForecast []analyticsrepo.SpareForecast Error string } type ticketListPageData struct { uiPage Tickets []domain.Ticket } type failureListPageData struct { uiPage Failures []domain.FailureEvent } type ingestPageData struct { uiPage LogbundlePayload string TicketPayload string FailurePayload string } type startPageData struct { uiPage } func (h uiHandlers) handleStart(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { w.WriteHeader(http.StatusNotFound) return } if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } http.Redirect(w, r, "/ui", http.StatusFound) } func (h uiHandlers) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/ui" && r.URL.Path != "/ui/" { w.WriteHeader(http.StatusNotFound) return } if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } customers := 0 if h.deps.Customers != nil { items, err := h.deps.Customers.List(r.Context()) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } customers = len(items) } projects := 0 if h.deps.Projects != nil { items, err := h.deps.Projects.List(r.Context()) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } projects = len(items) } assets := []domain.Asset{} if h.deps.Assets != nil { items, err := h.deps.Assets.List(r.Context()) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } assets = items } components := []domain.Component{} if h.deps.Components != nil { items, err := h.deps.Components.List(r.Context()) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } components = items } data := indexPageData{ uiPage: uiPage{ PageTitle: "Operations Overview", HeroTag: fmt.Sprintf("Assets %d", len(assets)), ActiveNav: "dashboard", }, CustomerCount: customers, ProjectCount: projects, AssetCount: len(assets), ComponentCount: len(components), Assets: assets, Components: components, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := uiTemplate.ExecuteTemplate(w, "index", data); err != nil { w.WriteHeader(http.StatusInternalServerError) return } } func (h uiHandlers) handleAssetList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Assets == nil { w.WriteHeader(http.StatusInternalServerError) return } items, err := h.deps.Assets.List(r.Context()) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } data := assetListPageData{ uiPage: uiPage{ PageTitle: "Assets", HeroTag: fmt.Sprintf("%d total", len(items)), ActiveNav: "assets", }, Assets: items, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := uiTemplate.ExecuteTemplate(w, "assets_list", data); err != nil { w.WriteHeader(http.StatusInternalServerError) return } } func (h uiHandlers) handleAssetPage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } id, ok := parseID(r.URL.Path, "/ui/assets/") if !ok { w.WriteHeader(http.StatusNotFound) return } asset, err := h.deps.Assets.Get(r.Context(), id) if err != nil { w.WriteHeader(http.StatusNotFound) return } components := []domain.Component{} if h.deps.Installations != nil { components, err = h.deps.Installations.ListCurrentComponentsByAsset(r.Context(), id) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } } events := []domain.TimelineEvent{} if h.deps.Timeline != nil { items, _, err := h.deps.Timeline.List(r.Context(), "asset", id, 100, nil) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } events = items } tickets := []domain.Ticket{} if h.deps.Tickets != nil { items, err := h.deps.Tickets.ListByAsset(r.Context(), id) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } tickets = items } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := uiTemplate.ExecuteTemplate(w, "asset", assetPageData{ uiPage: uiPage{ PageTitle: asset.Name, HeroTag: fmt.Sprintf("Asset %d", asset.ID), ActiveNav: "assets", }, Asset: asset, Components: components, Events: events, Tickets: tickets, }); err != nil { w.WriteHeader(http.StatusInternalServerError) return } } func (h uiHandlers) handleComponentList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Components == nil { w.WriteHeader(http.StatusInternalServerError) return } items, err := h.deps.Components.List(r.Context()) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } data := componentListPageData{ uiPage: uiPage{ PageTitle: "Components", HeroTag: fmt.Sprintf("%d total", len(items)), ActiveNav: "components", }, Components: items, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := uiTemplate.ExecuteTemplate(w, "components_list", data); err != nil { w.WriteHeader(http.StatusInternalServerError) return } } func (h uiHandlers) handleComponentPage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } id, ok := parseID(r.URL.Path, "/ui/components/") if !ok { w.WriteHeader(http.StatusNotFound) return } if h.deps.Components == nil { w.WriteHeader(http.StatusInternalServerError) return } component, err := h.deps.Components.Get(r.Context(), id) if err != nil { w.WriteHeader(http.StatusNotFound) return } events := []domain.TimelineEvent{} if h.deps.Timeline != nil { items, _, err := h.deps.Timeline.List(r.Context(), "component", id, 100, nil) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } events = items } data := componentPageData{ uiPage: uiPage{ PageTitle: fmt.Sprintf("Component %d", component.ID), PageSubtitle: func() string { if component.VendorSerial != "" { return component.VendorSerial } return "" }(), HeroTag: "Component Detail", ActiveNav: "components", }, Component: component, Events: events, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := uiTemplate.ExecuteTemplate(w, "component", data); err != nil { w.WriteHeader(http.StatusInternalServerError) return } } func (h uiHandlers) handleAnalytics(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } data := analyticsPageData{ uiPage: uiPage{ PageTitle: "Analytics", PageSubtitle: "AFR / MTBF / Firmware risk / Spare forecast", ActiveNav: "analytics", }, HorizonDays: 30, Multiplier: 1.0, } if h.deps.Analytics == nil { data.Error = "Analytics unavailable" w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = uiTemplate.ExecuteTemplate(w, "analytics", data) return } now := time.Now().UTC() start := now.AddDate(0, 0, -30) end := now if value := r.URL.Query().Get("start"); value != "" { parsed, err := parseTimeParam(value) if err != nil { data.Error = "Start must be RFC3339" data.Start = value w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = uiTemplate.ExecuteTemplate(w, "analytics", data) return } start = parsed } if value := r.URL.Query().Get("end"); value != "" { parsed, err := parseTimeParam(value) if err != nil { data.Error = "End must be RFC3339" data.End = value w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = uiTemplate.ExecuteTemplate(w, "analytics", data) return } end = parsed } if !start.Before(end) { data.Error = "Start must be before end" data.Start = start.Format(time.RFC3339) data.End = end.Format(time.RFC3339) w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = uiTemplate.ExecuteTemplate(w, "analytics", data) return } if value := r.URL.Query().Get("horizon_days"); value != "" { parsed, err := parseIntParam(value) if err != nil || parsed <= 0 { data.Error = "Horizon days must be a positive integer" data.Start = start.Format(time.RFC3339) data.End = end.Format(time.RFC3339) w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = uiTemplate.ExecuteTemplate(w, "analytics", data) return } data.HorizonDays = parsed } if value := r.URL.Query().Get("multiplier"); value != "" { parsed, err := parseFloatParam(value) if err != nil || parsed < 0 { data.Error = "Multiplier must be >= 0" data.Start = start.Format(time.RFC3339) data.End = end.Format(time.RFC3339) w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = uiTemplate.ExecuteTemplate(w, "analytics", data) return } data.Multiplier = parsed } data.Start = start.Format(time.RFC3339) data.End = end.Format(time.RFC3339) lotMetrics, err := h.deps.Analytics.ListLotMetrics(r.Context(), start, end) if err != nil { data.Error = "Failed to load lot metrics" } else { data.LotMetrics = lotMetrics } firmwareRisk, err := h.deps.Analytics.ListFirmwareRisk(r.Context(), start, end) if err != nil { data.Error = "Failed to load firmware risk" } else { data.FirmwareRisk = firmwareRisk } spareForecast, err := h.deps.Analytics.ForecastSpare(r.Context(), start, end, data.HorizonDays, data.Multiplier) if err != nil { data.Error = "Failed to load spare forecast" } else { data.SpareForecast = spareForecast } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := uiTemplate.ExecuteTemplate(w, "analytics", data); err != nil { w.WriteHeader(http.StatusInternalServerError) return } } func (h uiHandlers) handleTicketList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Tickets == nil { w.WriteHeader(http.StatusInternalServerError) return } items, err := h.deps.Tickets.ListAll(r.Context(), 200) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } data := ticketListPageData{ uiPage: uiPage{ PageTitle: "Tickets", HeroTag: fmt.Sprintf("%d total", len(items)), ActiveNav: "tickets", }, Tickets: items, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := uiTemplate.ExecuteTemplate(w, "tickets", data); err != nil { w.WriteHeader(http.StatusInternalServerError) return } } func (h uiHandlers) handleFailureList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Failures == nil { w.WriteHeader(http.StatusInternalServerError) return } items, err := h.deps.Failures.ListAll(r.Context(), 200) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } data := failureListPageData{ uiPage: uiPage{ PageTitle: "Failure Events", HeroTag: fmt.Sprintf("%d total", len(items)), ActiveNav: "failures", }, Failures: items, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := uiTemplate.ExecuteTemplate(w, "failures", data); err != nil { w.WriteHeader(http.StatusInternalServerError) return } } func (h uiHandlers) handleIngestPage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } data := ingestPageData{ uiPage: uiPage{ PageTitle: "Ingest Console", PageSubtitle: "Post logbundles, ticket sync, and failure events", ActiveNav: "ingest", }, LogbundlePayload: `{ "asset_id": 1, "collected_at": "2026-02-05T10:00:00Z", "source": "ops-collector", "components": [ { "vendor_serial": "SN-001", "vendor": "VendorCo", "model": "Model-A", "lot_id": 1, "firmware_version": "1.2.3" } ] }`, TicketPayload: `{ "source": "zendesk", "tickets": [ { "external_id": "ZD-1001", "title": "Disk SMART failure", "status": "open", "opened_at": "2026-02-05T09:10:00Z", "asset_id": 1, "url": "https://example.com/tickets/1001" } ] }`, FailurePayload: `{ "source": "rma-system", "failures": [ { "external_id": "RMA-2001", "component_id": 10, "asset_id": 1, "failure_type": "SMART", "failure_time": "2026-02-05T08:30:00Z", "details": "SMART 198 failure", "confidence": 0.92 } ] }`, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := uiTemplate.ExecuteTemplate(w, "ingest", data); err != nil { w.WriteHeader(http.StatusInternalServerError) return } }