diff --git a/internal/api/ui.go b/internal/api/ui.go index de3112c..12add0a 100644 --- a/internal/api/ui.go +++ b/internal/api/ui.go @@ -24,12 +24,21 @@ var hardwareExample string 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") + return t.UTC().Format("2006-01-02") }, "formatTimePtr": func(t *time.Time) string { if t == nil { return "—" } + return t.UTC().Format("2006-01-02") + }, + "formatTimeFull": func(t time.Time) string { + return t.UTC().Format("2006-01-02 15:04:05 UTC") + }, + "formatTimePtrFull": 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 { @@ -68,17 +77,24 @@ func RegisterUIRoutes(mux *http.ServeMux, deps UIDependencies) { mux.HandleFunc("/ui/assets/", h.handleAssetPage) mux.HandleFunc("/ui/components", h.handleComponentList) mux.HandleFunc("/ui/components/", h.handleComponentPage) + mux.HandleFunc("/ui/customers", h.handleCustomerList) 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 Breadcrumb struct { + Label string + URL string +} + type uiPage struct { PageTitle string PageSubtitle string HeroTag string ActiveNav string + Breadcrumbs []Breadcrumb } type indexPageData struct { @@ -137,6 +153,11 @@ type failureListPageData struct { Failures []domain.FailureEvent } +type customerListPageData struct { + uiPage + Customers []domain.Customer +} + type ingestPageData struct { uiPage LogbundlePayload string @@ -249,6 +270,10 @@ func (h uiHandlers) handleAssetList(w http.ResponseWriter, r *http.Request) { PageTitle: "Assets", HeroTag: fmt.Sprintf("%d total", len(items)), ActiveNav: "assets", + Breadcrumbs: []Breadcrumb{ + {Label: "Hardware", URL: ""}, + {Label: "Assets", URL: ""}, + }, }, Assets: items, } @@ -312,6 +337,11 @@ func (h uiHandlers) handleAssetPage(w http.ResponseWriter, r *http.Request) { PageTitle: asset.Name, HeroTag: fmt.Sprintf("Asset %d", asset.ID), ActiveNav: "assets", + Breadcrumbs: []Breadcrumb{ + {Label: "Hardware", URL: ""}, + {Label: "Assets", URL: "/ui/assets"}, + {Label: asset.Name, URL: ""}, + }, }, Asset: asset, Components: components, @@ -343,6 +373,10 @@ func (h uiHandlers) handleComponentList(w http.ResponseWriter, r *http.Request) PageTitle: "Components", HeroTag: fmt.Sprintf("%d total", len(items)), ActiveNav: "components", + Breadcrumbs: []Breadcrumb{ + {Label: "Hardware", URL: ""}, + {Label: "Components", URL: ""}, + }, }, Components: items, } @@ -396,6 +430,11 @@ func (h uiHandlers) handleComponentPage(w http.ResponseWriter, r *http.Request) }(), HeroTag: "Component Detail", ActiveNav: "components", + Breadcrumbs: []Breadcrumb{ + {Label: "Hardware", URL: ""}, + {Label: "Components", URL: "/ui/components"}, + {Label: fmt.Sprintf("Component %d", component.ID), URL: ""}, + }, }, Component: component, Events: events, @@ -419,6 +458,10 @@ func (h uiHandlers) handleAnalytics(w http.ResponseWriter, r *http.Request) { PageTitle: "Analytics", PageSubtitle: "AFR / MTBF / Firmware risk / Spare forecast", ActiveNav: "analytics", + Breadcrumbs: []Breadcrumb{ + {Label: "Health", URL: ""}, + {Label: "Analytics", URL: ""}, + }, }, HorizonDays: 30, Multiplier: 1.0, @@ -541,6 +584,10 @@ func (h uiHandlers) handleTicketList(w http.ResponseWriter, r *http.Request) { PageTitle: "Tickets", HeroTag: fmt.Sprintf("%d total", len(items)), ActiveNav: "tickets", + Breadcrumbs: []Breadcrumb{ + {Label: "Health", URL: ""}, + {Label: "Tickets", URL: ""}, + }, }, Tickets: items, } @@ -573,6 +620,10 @@ func (h uiHandlers) handleFailureList(w http.ResponseWriter, r *http.Request) { PageTitle: "Failure Events", HeroTag: fmt.Sprintf("%d total", len(items)), ActiveNav: "failures", + Breadcrumbs: []Breadcrumb{ + {Label: "Health", URL: ""}, + {Label: "Failures", URL: ""}, + }, }, Failures: items, } @@ -584,6 +635,41 @@ func (h uiHandlers) handleFailureList(w http.ResponseWriter, r *http.Request) { } } +func (h uiHandlers) handleCustomerList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if h.deps.Customers == nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + items, err := h.deps.Customers.List(r.Context()) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + data := customerListPageData{ + uiPage: uiPage{ + PageTitle: "Customers", + HeroTag: fmt.Sprintf("%d total", len(items)), + ActiveNav: "customers", + Breadcrumbs: []Breadcrumb{ + {Label: "Customers", URL: ""}, + }, + }, + Customers: items, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := uiTemplate.ExecuteTemplate(w, "customers", 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) @@ -595,6 +681,10 @@ func (h uiHandlers) handleIngestPage(w http.ResponseWriter, r *http.Request) { PageTitle: "Ingest Console", PageSubtitle: "Post logbundles, ticket sync, and failure events", ActiveNav: "ingest", + Breadcrumbs: []Breadcrumb{ + {Label: "Settings", URL: ""}, + {Label: "Ingest", URL: ""}, + }, }, LogbundlePayload: `{ "asset_id": 1, diff --git a/internal/api/ui_analytics.tmpl b/internal/api/ui_analytics.tmpl index 0cf372f..35674e6 100644 --- a/internal/api/ui_analytics.tmpl +++ b/internal/api/ui_analytics.tmpl @@ -4,6 +4,7 @@ {{template "head" .}} {{template "topbar" .}} + {{template "breadcrumbs" .}}
diff --git a/internal/api/ui_assets.tmpl b/internal/api/ui_assets.tmpl index 439f81c..e8d5c62 100644 --- a/internal/api/ui_assets.tmpl +++ b/internal/api/ui_assets.tmpl @@ -4,6 +4,7 @@ {{template "head" .}} {{template "topbar" .}} + {{template "breadcrumbs" .}}
@@ -34,13 +35,13 @@ {{range .Components}} - + {{.ID}} {{.VendorSerial}} {{if .Vendor}}{{.Vendor}}{{else}}—{{end}} {{if .Model}}{{.Model}}{{else}}—{{end}} {{if .LotID}}{{.LotID}}{{else}}—{{end}} - {{formatTimePtr .FirstSeenAt}} + {{formatTimePtr .FirstSeenAt}} {{end}} @@ -57,7 +58,7 @@ {{range .Events}}
-
{{formatTime .EventTime}}
+
{{formatTime .EventTime}}
{{.EventType}}
@@ -79,7 +80,7 @@ {{range .Tickets}}
{{.Title}}
-
{{.Source}} · {{.ExternalID}} · {{.Status}} · Opened {{formatTimePtr .OpenedAt}}
+
{{.Source}} · {{.ExternalID}} · {{.Status}} · Opened {{formatTimePtr .OpenedAt}}
{{if .URL}}{{end}}
{{end}} diff --git a/internal/api/ui_assets_list.tmpl b/internal/api/ui_assets_list.tmpl index 990b669..4a36bc7 100644 --- a/internal/api/ui_assets_list.tmpl +++ b/internal/api/ui_assets_list.tmpl @@ -4,6 +4,7 @@ {{template "head" .}} {{template "topbar" .}} + {{template "breadcrumbs" .}}
@@ -24,7 +25,7 @@ {{range .Assets}} - + {{.ID}} {{.Name}} {{.VendorSerial}} @@ -32,7 +33,7 @@ {{if .Model}}{{.Model}}{{else}}—{{end}} {{.ProjectID}} {{if .LocationID}}{{.LocationID}}{{else}}—{{end}} - {{formatTime .CreatedAt}} + {{formatTime .CreatedAt}} {{end}} diff --git a/internal/api/ui_component.tmpl b/internal/api/ui_component.tmpl index d27b290..5dfa346 100644 --- a/internal/api/ui_component.tmpl +++ b/internal/api/ui_component.tmpl @@ -4,6 +4,7 @@ {{template "head" .}} {{template "topbar" .}} + {{template "breadcrumbs" .}}
@@ -13,8 +14,8 @@
Vendor{{if .Component.Vendor}}{{.Component.Vendor}}{{else}}—{{end}}
Model{{if .Component.Model}}{{.Component.Model}}{{else}}—{{end}}
Lot ID{{if .Component.LotID}}{{.Component.LotID}}{{else}}—{{end}}
-
First Seen{{formatTimePtr .Component.FirstSeenAt}}
-
Created{{formatTime .Component.CreatedAt}}
+
First Seen{{formatTimePtr .Component.FirstSeenAt}}
+
Created{{formatTime .Component.CreatedAt}}
diff --git a/internal/api/ui_components_list.tmpl b/internal/api/ui_components_list.tmpl index 0e28fdd..00c1f51 100644 --- a/internal/api/ui_components_list.tmpl +++ b/internal/api/ui_components_list.tmpl @@ -4,6 +4,7 @@ {{template "head" .}} {{template "topbar" .}} + {{template "breadcrumbs" .}}
@@ -23,14 +24,14 @@ {{range .Components}} - + {{.ID}} {{.VendorSerial}} {{if .Vendor}}{{.Vendor}}{{else}}—{{end}} {{if .Model}}{{.Model}}{{else}}—{{end}} {{if .LotID}}{{.LotID}}{{else}}—{{end}} - {{formatTimePtr .FirstSeenAt}} - {{formatTime .CreatedAt}} + {{formatTimePtr .FirstSeenAt}} + {{formatTime .CreatedAt}} {{end}} diff --git a/internal/api/ui_customers.tmpl b/internal/api/ui_customers.tmpl new file mode 100644 index 0000000..403283b --- /dev/null +++ b/internal/api/ui_customers.tmpl @@ -0,0 +1,40 @@ +{{define "customers"}} + + +{{template "head" .}} + + {{template "topbar" .}} + {{template "breadcrumbs" .}} + +
+
+

All Customers

+ {{if .Customers}} + + + + + + + + + + + {{range .Customers}} + + + + + + + {{end}} + +
IDNameCreatedUpdated
{{.ID}}{{.Name}}{{formatTime .CreatedAt}}{{formatTime .UpdatedAt}}
+ {{else}} +
No customers yet.
+ {{end}} +
+
+ + +{{end}} diff --git a/internal/api/ui_failures.tmpl b/internal/api/ui_failures.tmpl index 6cbd376..6c75fd3 100644 --- a/internal/api/ui_failures.tmpl +++ b/internal/api/ui_failures.tmpl @@ -4,6 +4,7 @@ {{template "head" .}} {{template "topbar" .}} + {{template "breadcrumbs" .}}
@@ -31,7 +32,7 @@ {{.ComponentID}} {{if .AssetID}}{{.AssetID}}{{else}}—{{end}} {{.FailureType}} - {{formatTime .FailureTime}} + {{formatTime .FailureTime}} {{formatFloatPtr .Confidence 2}} {{end}} diff --git a/internal/api/ui_index.tmpl b/internal/api/ui_index.tmpl index 53f8355..b947d4e 100644 --- a/internal/api/ui_index.tmpl +++ b/internal/api/ui_index.tmpl @@ -33,13 +33,13 @@ {{range $i, $item := .Assets}} {{if lt $i 5}} - + {{$item.ID}} {{$item.Name}} {{$item.VendorSerial}} {{$item.ProjectID}} {{if $item.LocationID}}{{$item.LocationID}}{{else}}—{{end}} - {{formatTime $item.CreatedAt}} + {{formatTime $item.CreatedAt}} {{end}} {{end}} @@ -67,12 +67,12 @@ {{range $i, $item := .Components}} {{if lt $i 5}} - + {{$item.ID}} {{$item.VendorSerial}} {{if $item.LotID}}{{$item.LotID}}{{else}}—{{end}} - {{formatTimePtr $item.FirstSeenAt}} - {{formatTime $item.CreatedAt}} + {{formatTimePtr $item.FirstSeenAt}} + {{formatTime $item.CreatedAt}} {{end}} {{end}} diff --git a/internal/api/ui_ingest.tmpl b/internal/api/ui_ingest.tmpl index 3bc56a0..6380f8b 100644 --- a/internal/api/ui_ingest.tmpl +++ b/internal/api/ui_ingest.tmpl @@ -4,6 +4,7 @@ {{template "head" .}} {{template "topbar" .}} + {{template "breadcrumbs" .}}
diff --git a/internal/api/ui_shared.tmpl b/internal/api/ui_shared.tmpl index 228e4d7..4d3b014 100644 --- a/internal/api/ui_shared.tmpl +++ b/internal/api/ui_shared.tmpl @@ -64,15 +64,77 @@ letter-spacing: 0.08em; font-weight: 600; } - .nav a { + .nav-item { + position: relative; + } + .nav a, .nav-group-label { text-decoration: none; color: var(--ink); opacity: 0.7; + cursor: pointer; + display: block; } - .nav a.active { + .nav a.active, .nav-group-label.active { opacity: 1; color: var(--accent); } + .nav-group { + position: relative; + } + .nav-group-label { + display: flex; + align-items: center; + gap: 4px; + } + .nav-group-label::after { + content: '▾'; + font-size: 10px; + opacity: 0.5; + } + .nav-submenu { + display: none; + position: absolute; + top: 100%; + left: 0; + background: white; + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow); + margin-top: 4px; + min-width: 160px; + z-index: 100; + padding: 8px 0; + } + .nav-submenu::before { + content: ''; + position: absolute; + top: -4px; + left: 0; + right: 0; + height: 4px; + background: transparent; + } + .nav-group:hover .nav-submenu, + .nav-submenu:hover { + display: block; + } + .nav-submenu a { + padding: 8px 16px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.7; + transition: all 0.2s; + } + .nav-submenu a:hover { + opacity: 1; + background: var(--accent-soft); + } + .nav-submenu a.active { + opacity: 1; + color: var(--accent); + background: var(--accent-soft); + } .container { max-width: 1100px; margin: 28px auto; @@ -137,6 +199,13 @@ .table tr:last-child td { border-bottom: none; } + .table tr.clickable { + cursor: pointer; + transition: background-color 0.15s; + } + .table tr.clickable:hover { + background: var(--accent-soft); + } .meta { color: var(--muted); font-size: 12px; @@ -261,6 +330,36 @@ font-weight: 600; color: #0f172a; } + .breadcrumbs { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 32px; + background: white; + border-bottom: 1px solid var(--border); + font-size: 13px; + } + .breadcrumbs a { + text-decoration: none; + color: var(--muted); + transition: color 0.2s; + } + .breadcrumbs a:hover { + color: var(--accent); + } + .breadcrumbs .home-icon { + font-size: 16px; + line-height: 1; + } + .breadcrumbs .separator { + color: var(--muted); + opacity: 0.5; + font-size: 11px; + } + .breadcrumbs .current { + color: var(--ink); + font-weight: 600; + } @media (max-width: 720px) { .topbar { flex-direction: column; @@ -275,6 +374,31 @@ } } + {{end}} @@ -288,12 +412,48 @@ {{if .HeroTag}}
{{.HeroTag}}
{{end}} {{end}} + +{{define "breadcrumbs"}} +{{if .Breadcrumbs}} + +{{end}} +{{end}} diff --git a/internal/api/ui_tickets.tmpl b/internal/api/ui_tickets.tmpl index 7fa939a..c771036 100644 --- a/internal/api/ui_tickets.tmpl +++ b/internal/api/ui_tickets.tmpl @@ -4,6 +4,7 @@ {{template "head" .}} {{template "topbar" .}} + {{template "breadcrumbs" .}}
@@ -29,8 +30,8 @@ {{.ExternalID}} {{.Title}} {{.Status}} - {{formatTimePtr .OpenedAt}} - {{formatTime .UpdatedAt}} + {{formatTimePtr .OpenedAt}} + {{formatTime .UpdatedAt}} {{end}}