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}}
+
+
+
+ | ID |
+ Name |
+ Created |
+ Updated |
+
+
+
+ {{range .Customers}}
+
+ | {{.ID}} |
+ {{.Name}} |
+ {{formatTime .CreatedAt}} |
+ {{formatTime .UpdatedAt}} |
+
+ {{end}}
+
+
+ {{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}}