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