A social RSS reader built on the AT Protocol. glean.at
glean atproto atmosphere rss feed social app
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add public /stats rendering /metrics nicely

+221 -5
+4 -1
docs/specs.md
··· 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 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 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
+1
internal/server/server.go
··· 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
+154
internal/server/stats_handler.go
··· 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 + }
+4 -4
internal/tmpl/base.html
··· 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 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 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">
+58
internal/tmpl/stats.html
··· 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}}