643 lines
16 KiB
Go
643 lines
16 KiB
Go
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
|
|
}
|
|
}
|