nandi/gleanpublic Fork 0
6250f4b
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add public /stats rendering /metrics nicelyUnverified

Julien Robert committed 2026-04-24T10:26:07+02:00 Browse files
6250f4b parent: 50ea7de
modified docs/specs.md +4 -1
@@ -841,10 +841,11 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee
841841 | `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL |
842842 | `/articles/mark-all-read` | POST | Mark all articles as read |
843843 | `/articles/dismiss` | POST | Dismiss an article recommendation |
844-| `/trending` | GET | Community feed: articles ranked by likes |
844+| `/trending` | GET | Community feed: articles ranked by likes (public) |
845845 | `/library` | GET | Liked articles and annotations |
846846 | `/library/create` | POST | Create annotation on an article |
847847 | `/library/{id}/delete` | POST | Delete an annotation |
848+| `/stats` | GET | Application metrics and performance data (Prometheus, public) |
848849 | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |
849850
850851 ### 8.2 htmx Patterns
@@ -916,6 +917,7 @@ glean/
916917 │ │ ├── annotations_handler.go # Annotation handlers
917918 │ │ ├── dashboard_handler.go # Dashboard handler
918919 │ │ ├── trending_handler.go # Trending handler
920+│ │ ├── stats_handler.go # Stats handler (Prometheus metrics display)
919921 │ │ ├── index_handler.go # Landing page handler
920922 │ │ ├── profile_handler.go # Public profile handler
921923 │ │ ├── pagination.go # Pagination helpers
@@ -932,6 +934,7 @@ glean/
932934 │ ├── articles.html # Article listing
933935 │ ├── article_detail.html # Article detail
934936 │ ├── trending.html # Trending articles
937+│ ├── stats.html # Application metrics
935938 │ ├── library.html # Liked articles + annotations
936939 │ ├── profile.html # User profile
937940 │ ├── error.html # Error page
@@ -841,10 +841,11 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee
841 | `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL |841 | `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL |
842 | `/articles/mark-all-read` | POST | Mark all articles as read |842 | `/articles/mark-all-read` | POST | Mark all articles as read |
843 | `/articles/dismiss` | POST | Dismiss an article recommendation |843 | `/articles/dismiss` | POST | Dismiss an article recommendation |
844-| `/trending` | GET | Community feed: articles ranked by likes |844+| `/trending` | GET | Community feed: articles ranked by likes (public) |
845 | `/library` | GET | Liked articles and annotations |845 | `/library` | GET | Liked articles and annotations |
846 | `/library/create` | POST | Create annotation on an article |846 | `/library/create` | POST | Create annotation on an article |
847 | `/library/{id}/delete` | POST | Delete an annotation |847 | `/library/{id}/delete` | POST | Delete an annotation |
848+| `/stats` | GET | Application metrics and performance data (Prometheus, public) |
848 | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |849 | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |
849 850
850 ### 8.2 htmx Patterns851 ### 8.2 htmx Patterns
@@ -916,6 +917,7 @@ glean/
916 │ │ ├── annotations_handler.go # Annotation handlers917 │ │ ├── annotations_handler.go # Annotation handlers
917 │ │ ├── dashboard_handler.go # Dashboard handler918 │ │ ├── dashboard_handler.go # Dashboard handler
918 │ │ ├── trending_handler.go # Trending handler919 │ │ ├── trending_handler.go # Trending handler
920+│ │ ├── stats_handler.go # Stats handler (Prometheus metrics display)
919 │ │ ├── index_handler.go # Landing page handler921 │ │ ├── index_handler.go # Landing page handler
920 │ │ ├── profile_handler.go # Public profile handler922 │ │ ├── profile_handler.go # Public profile handler
921 │ │ ├── pagination.go # Pagination helpers923 │ │ ├── pagination.go # Pagination helpers
@@ -932,6 +934,7 @@ glean/
932 │ ├── articles.html # Article listing934 │ ├── articles.html # Article listing
933 │ ├── article_detail.html # Article detail935 │ ├── article_detail.html # Article detail
934 │ ├── trending.html # Trending articles936 │ ├── trending.html # Trending articles
937+│ ├── stats.html # Application metrics
935 │ ├── library.html # Liked articles + annotations938 │ ├── library.html # Liked articles + annotations
936 │ ├── profile.html # User profile939 │ ├── profile.html # User profile
937 │ ├── error.html # Error page940 │ ├── error.html # Error page
modified internal/server/server.go +1 -0
@@ -214,6 +214,7 @@ func (s *Server) setupRoutes() {
214214
215215 s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(static.Files))))
216216 s.router.Handle("/metrics", promhttp.Handler())
217+ s.router.Get("/stats", s.handleStats)
217218 s.router.NotFound(s.handleNotFound)
218219 }
219220
@@ -214,6 +214,7 @@ func (s *Server) setupRoutes() {
214 214
215 s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(static.Files))))215 s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(static.Files))))
216 s.router.Handle("/metrics", promhttp.Handler())216 s.router.Handle("/metrics", promhttp.Handler())
217+ s.router.Get("/stats", s.handleStats)
217 s.router.NotFound(s.handleNotFound)218 s.router.NotFound(s.handleNotFound)
218 }219 }
219 220
added internal/server/stats_handler.go +154 -0
new file mode 100644
@@ -0,0 +1,154 @@
1+package server
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "net/http"
7+ "sort"
8+ "strings"
9+
10+ io_prometheus_client "github.com/prometheus/client_model/go"
11+ "github.com/prometheus/common/expfmt"
12+)
13+
14+type metricFamily struct {
15+ Name string
16+ Type string
17+ Description string
18+ Labels map[string]string
19+ Value float64
20+}
21+
22+func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
23+ metrics, err := s.fetchMetrics()
24+ if err != nil {
25+ s.logger.Warn("failed to fetch metrics", "error", err)
26+ http.Error(w, "Failed to load metrics", http.StatusInternalServerError)
27+ return
28+ }
29+
30+ user := currentUser(r)
31+ s.render(w, r, "stats.html", map[string]any{
32+ "User": user,
33+ "Metrics": metrics,
34+ })
35+}
36+
37+func (s *Server) fetchMetrics() (map[string][]metricFamily, error) {
38+ req, err := http.NewRequest(http.MethodGet, "/metrics", nil)
39+ if err != nil {
40+ return nil, err
41+ }
42+
43+ rr := &responseRecorder{
44+ headerMap: make(http.Header),
45+ }
46+ s.router.ServeHTTP(rr, req)
47+
48+ if rr.Code != http.StatusOK {
49+ return nil, fmt.Errorf("metrics endpoint returned status %d", rr.Code)
50+ }
51+
52+ return parseMetrics(rr.body.Bytes())
53+}
54+
55+type responseRecorder struct {
56+ Code int
57+ body bytes.Buffer
58+ headerMap http.Header
59+}
60+
61+func (r *responseRecorder) Header() http.Header { return r.headerMap }
62+func (r *responseRecorder) Write(b []byte) (int, error) { return r.body.Write(b) }
63+func (r *responseRecorder) WriteHeader(code int) { r.Code = code }
64+
65+func parseMetrics(data []byte) (map[string][]metricFamily, error) {
66+ var parser expfmt.TextParser
67+ families, err := parser.TextToMetricFamilies(bytes.NewReader(data))
68+ if err != nil {
69+ return nil, err
70+ }
71+
72+ result := make(map[string][]metricFamily)
73+ for name, family := range families {
74+ category := categorizeMetric(name)
75+
76+ metricType := strings.ToLower(family.Type.String())
77+ description := family.GetHelp()
78+
79+ for _, m := range family.Metric {
80+ mf := metricFamily{
81+ Name: name,
82+ Type: metricType,
83+ Description: description,
84+ Labels: make(map[string]string),
85+ Value: getValue(m),
86+ }
87+
88+ for _, label := range m.GetLabel() {
89+ mf.Labels[label.GetName()] = label.GetValue()
90+ }
91+
92+ if len(m.GetLabel()) == 0 {
93+ mf.Labels = nil
94+ }
95+
96+ result[category] = append(result[category], mf)
97+ }
98+ }
99+
100+ for _, metrics := range result {
101+ sort.Slice(metrics, func(i, j int) bool {
102+ return metrics[i].Name < metrics[j].Name
103+ })
104+ }
105+
106+ return result, nil
107+}
108+
109+func getValue(m *io_prometheus_client.Metric) float64 {
110+ if m.Counter != nil {
111+ return m.Counter.GetValue()
112+ }
113+ if m.Gauge != nil {
114+ return m.Gauge.GetValue()
115+ }
116+ if m.Histogram != nil {
117+ return float64(m.Histogram.GetSampleCount())
118+ }
119+ if m.Summary != nil {
120+ return float64(m.Summary.GetSampleCount())
121+ }
122+ if m.Untyped != nil {
123+ return m.Untyped.GetValue()
124+ }
125+ return 0
126+}
127+
128+func categorizeMetric(name string) string {
129+ if strings.HasPrefix(name, "glean_feed") {
130+ return "Feeds"
131+ }
132+ if strings.HasPrefix(name, "glean_article") {
133+ return "Articles"
134+ }
135+ if strings.Contains(name, "jetstream") {
136+ return "Jetstream"
137+ }
138+ if strings.HasPrefix(name, "atproto") {
139+ return "ATProto"
140+ }
141+ if strings.HasPrefix(name, "glean_http") {
142+ return "HTTP"
143+ }
144+ if strings.HasPrefix(name, "glean_user") {
145+ return "Users"
146+ }
147+ if strings.HasPrefix(name, "glean_cluster") {
148+ return "Cluster"
149+ }
150+ if strings.HasPrefix(name, "glean_pds_sync") {
151+ return "PDS Sync"
152+ }
153+ return "Other"
154+}
new file mode 100644
@@ -0,0 +1,154 @@
1+package server
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "net/http"
7+ "sort"
8+ "strings"
9+
10+ io_prometheus_client "github.com/prometheus/client_model/go"
11+ "github.com/prometheus/common/expfmt"
12+)
13+
14+type metricFamily struct {
15+ Name string
16+ Type string
17+ Description string
18+ Labels map[string]string
19+ Value float64
20+}
21+
22+func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
23+ metrics, err := s.fetchMetrics()
24+ if err != nil {
25+ s.logger.Warn("failed to fetch metrics", "error", err)
26+ http.Error(w, "Failed to load metrics", http.StatusInternalServerError)
27+ return
28+ }
29+
30+ user := currentUser(r)
31+ s.render(w, r, "stats.html", map[string]any{
32+ "User": user,
33+ "Metrics": metrics,
34+ })
35+}
36+
37+func (s *Server) fetchMetrics() (map[string][]metricFamily, error) {
38+ req, err := http.NewRequest(http.MethodGet, "/metrics", nil)
39+ if err != nil {
40+ return nil, err
41+ }
42+
43+ rr := &responseRecorder{
44+ headerMap: make(http.Header),
45+ }
46+ s.router.ServeHTTP(rr, req)
47+
48+ if rr.Code != http.StatusOK {
49+ return nil, fmt.Errorf("metrics endpoint returned status %d", rr.Code)
50+ }
51+
52+ return parseMetrics(rr.body.Bytes())
53+}
54+
55+type responseRecorder struct {
56+ Code int
57+ body bytes.Buffer
58+ headerMap http.Header
59+}
60+
61+func (r *responseRecorder) Header() http.Header { return r.headerMap }
62+func (r *responseRecorder) Write(b []byte) (int, error) { return r.body.Write(b) }
63+func (r *responseRecorder) WriteHeader(code int) { r.Code = code }
64+
65+func parseMetrics(data []byte) (map[string][]metricFamily, error) {
66+ var parser expfmt.TextParser
67+ families, err := parser.TextToMetricFamilies(bytes.NewReader(data))
68+ if err != nil {
69+ return nil, err
70+ }
71+
72+ result := make(map[string][]metricFamily)
73+ for name, family := range families {
74+ category := categorizeMetric(name)
75+
76+ metricType := strings.ToLower(family.Type.String())
77+ description := family.GetHelp()
78+
79+ for _, m := range family.Metric {
80+ mf := metricFamily{
81+ Name: name,
82+ Type: metricType,
83+ Description: description,
84+ Labels: make(map[string]string),
85+ Value: getValue(m),
86+ }
87+
88+ for _, label := range m.GetLabel() {
89+ mf.Labels[label.GetName()] = label.GetValue()
90+ }
91+
92+ if len(m.GetLabel()) == 0 {
93+ mf.Labels = nil
94+ }
95+
96+ result[category] = append(result[category], mf)
97+ }
98+ }
99+
100+ for _, metrics := range result {
101+ sort.Slice(metrics, func(i, j int) bool {
102+ return metrics[i].Name < metrics[j].Name
103+ })
104+ }
105+
106+ return result, nil
107+}
108+
109+func getValue(m *io_prometheus_client.Metric) float64 {
110+ if m.Counter != nil {
111+ return m.Counter.GetValue()
112+ }
113+ if m.Gauge != nil {
114+ return m.Gauge.GetValue()
115+ }
116+ if m.Histogram != nil {
117+ return float64(m.Histogram.GetSampleCount())
118+ }
119+ if m.Summary != nil {
120+ return float64(m.Summary.GetSampleCount())
121+ }
122+ if m.Untyped != nil {
123+ return m.Untyped.GetValue()
124+ }
125+ return 0
126+}
127+
128+func categorizeMetric(name string) string {
129+ if strings.HasPrefix(name, "glean_feed") {
130+ return "Feeds"
131+ }
132+ if strings.HasPrefix(name, "glean_article") {
133+ return "Articles"
134+ }
135+ if strings.Contains(name, "jetstream") {
136+ return "Jetstream"
137+ }
138+ if strings.HasPrefix(name, "atproto") {
139+ return "ATProto"
140+ }
141+ if strings.HasPrefix(name, "glean_http") {
142+ return "HTTP"
143+ }
144+ if strings.HasPrefix(name, "glean_user") {
145+ return "Users"
146+ }
147+ if strings.HasPrefix(name, "glean_cluster") {
148+ return "Cluster"
149+ }
150+ if strings.HasPrefix(name, "glean_pds_sync") {
151+ return "PDS Sync"
152+ }
153+ return "Other"
154+}
modified internal/tmpl/base.html +4 -4
@@ -65,7 +65,7 @@
6565 </div>
6666 <button onclick="document.getElementById('shortcuts-dialog').close()" class="mt-4 w-full text-sm text-spot-secondary hover:text-spot-text px-4 py-2 rounded-pill border border-spot-outline transition">Close</button>
6767 </dialog>
68- {{if or .User (eq .ActivePath "/trending")}}
68+ {{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}
6969 <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20">
7070 <div class="mb-8 px-3">
7171 {{template "logo-link"}}
@@ -136,8 +136,8 @@
136136 </nav>
137137 {{end}}
138138
139- <main id="main-content" class="{{if or .User (eq .ActivePath "/trending")}}lg:ml-60 pb-20 lg:pb-0{{end}} flex-1 min-h-screen flex flex-col">
140- {{if or .User (eq .ActivePath "/trending")}}
139+ <main id="main-content" class="{{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}lg:ml-60 pb-20 lg:pb-0{{end}} flex-1 min-h-screen flex flex-col">
140+ {{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}
141141 <div class="lg:hidden bg-spot-surface border-b border-spot-divider px-4 py-3 flex items-center justify-between sticky top-0 z-20">
142142 {{template "logo-text"}}
143143 {{if .User}}
@@ -150,7 +150,7 @@
150150 {{end}}
151151 </div>
152152 {{end}}
153- <div class="{{if not (or .User (eq .ActivePath "/trending"))}}w-full{{else}}max-w-6xl mx-auto px-4 lg:px-8 py-6{{end}} flex-1">
153+ <div class="{{if not (or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats")))}}w-full{{else}}max-w-6xl mx-auto px-4 lg:px-8 py-6{{end}} flex-1">
154154 {{.Content}}
155155 </div>
156156 <footer class="mt-auto">
@@ -65,7 +65,7 @@
65 </div>65 </div>
66 <button onclick="document.getElementById('shortcuts-dialog').close()" class="mt-4 w-full text-sm text-spot-secondary hover:text-spot-text px-4 py-2 rounded-pill border border-spot-outline transition">Close</button>66 <button onclick="document.getElementById('shortcuts-dialog').close()" class="mt-4 w-full text-sm text-spot-secondary hover:text-spot-text px-4 py-2 rounded-pill border border-spot-outline transition">Close</button>
67 </dialog>67 </dialog>
68- {{if or .User (eq .ActivePath "/trending")}}68+ {{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}
69 <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20">69 <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20">
70 <div class="mb-8 px-3">70 <div class="mb-8 px-3">
71 {{template "logo-link"}}71 {{template "logo-link"}}
@@ -136,8 +136,8 @@
136 </nav>136 </nav>
137 {{end}}137 {{end}}
138 138
139- <main id="main-content" class="{{if or .User (eq .ActivePath "/trending")}}lg:ml-60 pb-20 lg:pb-0{{end}} flex-1 min-h-screen flex flex-col">139+ <main id="main-content" class="{{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}lg:ml-60 pb-20 lg:pb-0{{end}} flex-1 min-h-screen flex flex-col">
140- {{if or .User (eq .ActivePath "/trending")}}140+ {{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}
141 <div class="lg:hidden bg-spot-surface border-b border-spot-divider px-4 py-3 flex items-center justify-between sticky top-0 z-20">141 <div class="lg:hidden bg-spot-surface border-b border-spot-divider px-4 py-3 flex items-center justify-between sticky top-0 z-20">
142 {{template "logo-text"}}142 {{template "logo-text"}}
143 {{if .User}}143 {{if .User}}
@@ -150,7 +150,7 @@
150 {{end}}150 {{end}}
151 </div>151 </div>
152 {{end}}152 {{end}}
153- <div class="{{if not (or .User (eq .ActivePath "/trending"))}}w-full{{else}}max-w-6xl mx-auto px-4 lg:px-8 py-6{{end}} flex-1">153+ <div class="{{if not (or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats")))}}w-full{{else}}max-w-6xl mx-auto px-4 lg:px-8 py-6{{end}} flex-1">
154 {{.Content}}154 {{.Content}}
155 </div>155 </div>
156 <footer class="mt-auto">156 <footer class="mt-auto">
added internal/tmpl/stats.html +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+{{define "stats.html"}}
2+<div class="flex items-center justify-between mb-2">
3+ <h1 class="text-2xl font-bold text-spot-text">Stats</h1>
4+</div>
5+<p class="text-sm text-spot-secondary mb-6">Application metrics and performance data.</p>
6+
7+{{if .User}}
8+<div hx-get="/stats" hx-trigger="every 30s" hx-target="#metrics-content" hx-select="#metrics-content" hx-swap="innerHTML"></div>
9+{{end}}
10+
11+<div id="metrics-content" class="space-y-3">
12+ {{range $category, $metrics := .Metrics}}
13+ <div class="bg-spot-surface rounded-xl overflow-hidden">
14+ <div class="px-4 py-3 border-b border-spot-divider">
15+ <h2 class="font-semibold text-spot-text">{{$category}}</h2>
16+ </div>
17+ <div class="divide-y divide-spot-divider">
18+ {{range $metrics}}
19+ <div class="px-4 py-3 flex items-start justify-between hover:bg-spot-hover transition">
20+ <div class="flex-1 min-w-0">
21+ <div class="flex items-center gap-2">
22+ <span class="text-sm font-medium text-spot-text">{{.Name}}</span>
23+ {{if .Description}}
24+ <span class="text-xs text-spot-secondary">{{.Description}}</span>
25+ {{end}}
26+ </div>
27+ {{if .Labels}}
28+ <div class="flex gap-2 mt-1">
29+ {{range $key, $value := .Labels}}
30+ <span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-full">{{$key}}={{$value}}</span>
31+ {{end}}
32+ </div>
33+ {{end}}
34+ </div>
35+ <div class="text-right shrink-0 ml-4">
36+ {{if eq .Type "gauge"}}
37+ <span class="text-sm font-mono text-spot-green">{{printf "%.2f" .Value}}</span>
38+ {{else if eq .Type "counter"}}
39+ <span class="text-sm font-mono text-spot-blue">{{printf "%.0f" .Value}}</span>
40+ {{else}}
41+ <span class="text-sm font-mono text-spot-text">{{printf "%.2f" .Value}}</span>
42+ {{end}}
43+ </div>
44+ </div>
45+ {{end}}
46+ </div>
47+ </div>
48+ {{else}}
49+ <div class="bg-spot-surface rounded-xl py-12 px-6 text-center">
50+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto">
51+ <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg>
52+ </div>
53+ <p class="text-sm text-spot-secondary mb-1">No metrics available</p>
54+ <p class="text-xs text-spot-muted">Metrics will appear here once the application has been running for a while.</p>
55+ </div>
56+ {{end}}
57+</div>
58+{{end}}
new file mode 100644
@@ -0,0 +1,58 @@
1+{{define "stats.html"}}
2+<div class="flex items-center justify-between mb-2">
3+ <h1 class="text-2xl font-bold text-spot-text">Stats</h1>
4+</div>
5+<p class="text-sm text-spot-secondary mb-6">Application metrics and performance data.</p>
6+
7+{{if .User}}
8+<div hx-get="/stats" hx-trigger="every 30s" hx-target="#metrics-content" hx-select="#metrics-content" hx-swap="innerHTML"></div>
9+{{end}}
10+
11+<div id="metrics-content" class="space-y-3">
12+ {{range $category, $metrics := .Metrics}}
13+ <div class="bg-spot-surface rounded-xl overflow-hidden">
14+ <div class="px-4 py-3 border-b border-spot-divider">
15+ <h2 class="font-semibold text-spot-text">{{$category}}</h2>
16+ </div>
17+ <div class="divide-y divide-spot-divider">
18+ {{range $metrics}}
19+ <div class="px-4 py-3 flex items-start justify-between hover:bg-spot-hover transition">
20+ <div class="flex-1 min-w-0">
21+ <div class="flex items-center gap-2">
22+ <span class="text-sm font-medium text-spot-text">{{.Name}}</span>
23+ {{if .Description}}
24+ <span class="text-xs text-spot-secondary">{{.Description}}</span>
25+ {{end}}
26+ </div>
27+ {{if .Labels}}
28+ <div class="flex gap-2 mt-1">
29+ {{range $key, $value := .Labels}}
30+ <span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-full">{{$key}}={{$value}}</span>
31+ {{end}}
32+ </div>
33+ {{end}}
34+ </div>
35+ <div class="text-right shrink-0 ml-4">
36+ {{if eq .Type "gauge"}}
37+ <span class="text-sm font-mono text-spot-green">{{printf "%.2f" .Value}}</span>
38+ {{else if eq .Type "counter"}}
39+ <span class="text-sm font-mono text-spot-blue">{{printf "%.0f" .Value}}</span>
40+ {{else}}
41+ <span class="text-sm font-mono text-spot-text">{{printf "%.2f" .Value}}</span>
42+ {{end}}
43+ </div>
44+ </div>
45+ {{end}}
46+ </div>
47+ </div>
48+ {{else}}
49+ <div class="bg-spot-surface rounded-xl py-12 px-6 text-center">
50+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto">
51+ <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg>
52+ </div>
53+ <p class="text-sm text-spot-secondary mb-1">No metrics available</p>
54+ <p class="text-xs text-spot-muted">Metrics will appear here once the application has been running for a while.</p>
55+ </div>
56+ {{end}}
57+</div>
58+{{end}}