Add public /stats rendering /metrics nicelyUnverified
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 | ||
| 841 | 841 | | `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL | |
| 842 | 842 | | `/articles/mark-all-read` | POST | Mark all articles as read | |
| 843 | 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 | 845 | | `/library` | GET | Liked articles and annotations | |
| 846 | 846 | | `/library/create` | POST | Create annotation on an article | |
| 847 | 847 | | `/library/{id}/delete` | POST | Delete an annotation | |
| 848 | +| `/stats` | GET | Application metrics and performance data (Prometheus, public) | | |
| 848 | 849 | | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | |
| 849 | 850 | |
| 850 | 851 | ### 8.2 htmx Patterns |
| @@ -916,6 +917,7 @@ glean/ | ||
| 916 | 917 | │ │ ├── annotations_handler.go # Annotation handlers |
| 917 | 918 | │ │ ├── dashboard_handler.go # Dashboard handler |
| 918 | 919 | │ │ ├── trending_handler.go # Trending handler |
| 920 | +│ │ ├── stats_handler.go # Stats handler (Prometheus metrics display) | |
| 919 | 921 | │ │ ├── index_handler.go # Landing page handler |
| 920 | 922 | │ │ ├── profile_handler.go # Public profile handler |
| 921 | 923 | │ │ ├── pagination.go # Pagination helpers |
| @@ -932,6 +934,7 @@ glean/ | ||
| 932 | 934 | │ ├── articles.html # Article listing |
| 933 | 935 | │ ├── article_detail.html # Article detail |
| 934 | 936 | │ ├── trending.html # Trending articles |
| 937 | +│ ├── stats.html # Application metrics | |
| 935 | 938 | │ ├── library.html # Liked articles + annotations |
| 936 | 939 | │ ├── profile.html # User profile |
| 937 | 940 | │ ├── 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 Patterns | 851 | ### 8.2 htmx Patterns |
| @@ -916,6 +917,7 @@ glean/ | |||
| 916 | │ │ ├── annotations_handler.go # Annotation handlers | 917 | │ │ ├── annotations_handler.go # Annotation handlers |
| 917 | │ │ ├── dashboard_handler.go # Dashboard handler | 918 | │ │ ├── dashboard_handler.go # Dashboard handler |
| 918 | │ │ ├── trending_handler.go # Trending handler | 919 | │ │ ├── trending_handler.go # Trending handler |
| 920 | +│ │ ├── stats_handler.go # Stats handler (Prometheus metrics display) | ||
| 919 | │ │ ├── index_handler.go # Landing page handler | 921 | │ │ ├── index_handler.go # Landing page handler |
| 920 | │ │ ├── profile_handler.go # Public profile handler | 922 | │ │ ├── profile_handler.go # Public profile handler |
| 921 | │ │ ├── pagination.go # Pagination helpers | 923 | │ │ ├── pagination.go # Pagination helpers |
| @@ -932,6 +934,7 @@ glean/ | |||
| 932 | │ ├── articles.html # Article listing | 934 | │ ├── articles.html # Article listing |
| 933 | │ ├── article_detail.html # Article detail | 935 | │ ├── article_detail.html # Article detail |
| 934 | │ ├── trending.html # Trending articles | 936 | │ ├── trending.html # Trending articles |
| 937 | +│ ├── stats.html # Application metrics | ||
| 935 | │ ├── library.html # Liked articles + annotations | 938 | │ ├── library.html # Liked articles + annotations |
| 936 | │ ├── profile.html # User profile | 939 | │ ├── profile.html # User profile |
| 937 | │ ├── error.html # Error page | 940 | │ ├── error.html # Error page |
modified
internal/server/server.go +1 -0 | @@ -214,6 +214,7 @@ func (s *Server) setupRoutes() { | ||
| 214 | 214 | |
| 215 | 215 | s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(static.Files)))) |
| 216 | 216 | s.router.Handle("/metrics", promhttp.Handler()) |
| 217 | + s.router.Get("/stats", s.handleStats) | |
| 217 | 218 | s.router.NotFound(s.handleNotFound) |
| 218 | 219 | } |
| 219 | 220 | |
| @@ -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 @@ | ||
| 65 | 65 | </div> |
| 66 | 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 | 67 | </dialog> |
| 68 | - {{if or .User (eq .ActivePath "/trending")}} | |
| 68 | + {{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}} | |
| 69 | 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 | 70 | <div class="mb-8 px-3"> |
| 71 | 71 | {{template "logo-link"}} |
| @@ -136,8 +136,8 @@ | ||
| 136 | 136 | </nav> |
| 137 | 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"> | |
| 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"))}} | |
| 141 | 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 | 142 | {{template "logo-text"}} |
| 143 | 143 | {{if .User}} |
| @@ -150,7 +150,7 @@ | ||
| 150 | 150 | {{end}} |
| 151 | 151 | </div> |
| 152 | 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 | 154 | {{.Content}} |
| 155 | 155 | </div> |
| 156 | 156 | <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}} | ||