Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Expand the admin status page into an operational dashboard

4506a4d3d279556435fdd414265e7499c3dada16 · Keenan Nemetz · 14d ago · parent 2e024cc9

server/web/status.go +335−66

@@ -1,95 +1,364 @@
1 1 package web
2 2
3 // The status page is the human view of what /healthz answers for a probe: the
4 // stores are open, the queue is readable, and a runner has been seen recently.
5 // It exposes counts, never repository contents.
3 // The status page reports how much disk the instance uses, what the LFS pool
4 // contains, and whether people are reading it. It exposes counts and sizes, never
5 // repository contents; /healthz remains the separate liveness probe.
6 6
7 7 import (
8 8 "fmt"
9 9 "net/http"
10 "strings"
11 "sync"
10 12 "time"
13
14 "github.com/rodent-software/vole/forge/pageview"
15 "github.com/rodent-software/vole/internal/format"
16 "github.com/rodent-software/vole/internal/storage"
11 17 )
12 18
13 type statusCheck struct {
14 Name string
15 State string // ok, warn, fail
16 Detail string
19 const statusStatsTTL = 30 * time.Second
20
21 type statusObjectCount struct {
22 Objects int
23 Bytes int64
24 }
25
26 type statusStorageView struct {
27 Used int64
28 Files int
29 Categories []storage.Category
30 Disk storage.Disk
31 DiskErr string
32 Err string
33 Measured time.Time
34 }
35
36 type statusLFSView struct {
37 Total statusObjectCount
38 Logs statusObjectCount
39 Artifacts statusObjectCount
40 Other statusObjectCount
41 Err string
42 Disabled bool
43 }
44
45 type statusViewsView struct {
46 Today pageview.Count
47 Week pageview.Count
48 Month pageview.Count
49 Err string
50 Disabled bool
51 }
52
53 type statusSetting struct {
54 Key string
55 Value string
56 Note string
57 Warn bool
58 }
59
60 type statusSettingGroup struct {
61 Name string
62 Settings []statusSetting
63 }
64
65 type statusConfigView struct {
66 Path string
67 Loaded bool
68 Groups []statusSettingGroup
69 }
70
71 // statusCache keeps the filesystem walks off the request path when somebody
72 // refreshes the status page repeatedly. Page-view counters are cheap to query and
73 // are not cached.
74 type statusCache struct {
75 mu sync.Mutex
76 at time.Time
77 storage storage.Stats
78 storageErr error
79 lfs statusLFSView
17 80 }
18 81
19 82 func (w *Web) StatusPage(rw http.ResponseWriter, r *http.Request) {
83 if _, ok := w.requireInstanceAdmin(rw, r); !ok {
84 return
85 }
86 tab := statusTab(r.URL.Query().Get("tab"))
20 87 view := struct {
21 88 Page
22 Checks []statusCheck
23 Healthy bool
24 Users int
25 Repos int
26 Queued int
27 Running int
28 Runners int
29 Started time.Time
30 }{Page: w.page(r, "Status"), Started: w.Started}
31 healthy := true
32 add := func(name, state, detail string) {
33 if state == "fail" {
34 healthy = false
89 Tab string
90 Accounts int
91 Repos int
92 Queued int
93 Running int
94 Runners int
95 Started time.Time
96 Storage statusStorageView
97 LFS statusLFSView
98 Views statusViewsView
99 RunnerErr string
100 RunnerSessions []runnerView
101 RunnerQueue []queueView
102 RunnerWindow string
103 Config *statusConfigView
104 }{Page: w.page(r, "Status"), Tab: tab, Started: w.Started}
105
106 if tab == "overview" {
107 if w.Ident != nil {
108 view.Accounts = w.Ident.Snapshot().Count
109 }
110 if w.Repos != nil {
111 if repos, err := w.Repos.List(); err == nil {
112 view.Repos = len(repos)
113 }
114 }
115 sessions, queue, runnerProblems := w.runnerStatus()
116 view.RunnerSessions, view.RunnerQueue = sessions, queue
117 view.RunnerWindow = presenceWindow.String()
118 view.RunnerErr = strings.Join(runnerProblems, "; ")
119 view.Runners = len(sessions)
120 for _, depth := range queue {
121 view.Queued += depth.Queued
122 view.Running += depth.Running
35 123 }
36 view.Checks = append(view.Checks, statusCheck{Name: name, State: state, Detail: detail})
37 }
38 124
39 if w.Ident != nil {
40 view.Users = w.Ident.Snapshot().Count
41 add("identity", "ok", fmt.Sprintf("%d active account(s)", view.Users))
42 } else {
43 add("identity", "fail", "identity store is not wired")
44 }
45 if w.Repos != nil {
46 if repos, err := w.Repos.List(); err == nil {
47 view.Repos = len(repos)
48 add("repositories", "ok", fmt.Sprintf("%d repository(s)", view.Repos))
125 if w.PageViews != nil {
126 stats, err := w.PageViews.Stats(time.Now().UTC())
127 view.Views = statusViewsView{Today: stats.Today, Week: stats.Week, Month: stats.Month}
128 if err != nil {
129 view.Views.Err = err.Error()
130 }
49 131 } else {
50 add("repositories", "fail", err.Error())
132 view.Views.Disabled = true
51 133 }
52 } else {
53 add("repositories", "fail", "repository store is not wired")
54 134 }
55 if w.Index != nil {
56 if repos, err := w.Index.List(); err == nil {
57 add("index", "ok", fmt.Sprintf("%d indexed repository(s)", len(repos)))
58 } else {
59 add("index", "fail", err.Error())
135 if tab == "storage" || tab == "overview" {
136 if w.Cfg != nil {
137 st, err, lfs := w.instanceStats()
138 view.Storage = statusStorageView{
139 Used: st.Bytes, Files: st.Files, Disk: st.Disk, DiskErr: st.DiskErr,
140 Measured: st.Measured,
141 }
142 if err != nil {
143 view.Storage.Err = err.Error()
144 }
145 for _, c := range st.Categories {
146 if c.Bytes > 0 || c.Files > 0 {
147 view.Storage.Categories = append(view.Storage.Categories, c)
148 }
149 }
150 view.LFS = lfs
60 151 }
61 } else {
62 add("index", "fail", "index store is not wired")
63 152 }
153 if tab == "runners" {
154 sessions, queue, runnerProblems := w.runnerStatus()
155 view.RunnerSessions, view.RunnerQueue = sessions, queue
156 view.RunnerWindow = presenceWindow.String()
157 view.RunnerErr = strings.Join(runnerProblems, "; ")
158 }
159 // Configuration is host-level detail. The page is instance-admin only, so it
160 // can show the effective values; the file itself may be package-owned and is
161 // never edited here.
162 if tab == "configuration" && w.Cfg != nil {
163 view.Config = w.statusConfig()
164 }
165 w.render(rw, "status", view, 0)
166 }
167
168 func statusTab(raw string) string {
169 switch raw {
170 case "storage", "runners", "configuration":
171 return raw
172 default:
173 return "overview"
174 }
175 }
176
177 func (w *Web) instanceStats() (storage.Stats, error, statusLFSView) {
178 c := &w.statusCache
179 c.mu.Lock()
180 defer c.mu.Unlock()
181 if !c.at.IsZero() && time.Since(c.at) < statusStatsTTL {
182 return c.storage, c.storageErr, c.lfs
183 }
184 st, err := storage.Measure(w.Cfg.DataDir)
185 c.storage, c.storageErr = st, err
186 c.lfs = w.lfsUsage()
187 c.at = time.Now()
188 return c.storage, c.storageErr, c.lfs
189 }
190
191 // lfsUsage classifies the object pool by what the run queue says produced each
192 // object. The pool itself is the only source of sizes: a stale run row must not
193 // make the storage numbers disagree with the files on disk.
194 func (w *Web) lfsUsage() statusLFSView {
195 var v statusLFSView
196 if w.LFS == nil {
197 v.Disabled = true
198 return v
199 }
200 logs, artifacts := map[string]bool{}, map[string]bool{}
201 var problems []string
64 202 if w.Runs != nil {
65 if queue, err := w.Runs.QueueByRepo(); err == nil {
66 for _, depth := range queue {
67 view.Queued += depth.Queued
68 view.Running += depth.Running
69 }
70 add("queue", "ok", fmt.Sprintf("%d queued · %d running", view.Queued, view.Running))
203 logOIDs, err := w.Runs.LogOIDs()
204 if err != nil {
205 problems = append(problems, "run logs: "+err.Error())
71 206 } else {
72 add("queue", "fail", err.Error())
207 logs = objectSet(logOIDs)
73 208 }
74 if sessions, err := w.Runs.Sessions(time.Now().UTC().Add(-presenceWindow)); err == nil {
75 view.Runners = len(sessions)
76 state := "ok"
77 if view.Runners == 0 {
78 state = "warn"
79 }
80 add("runners", state, fmt.Sprintf("%d seen in the last %s", view.Runners, presenceWindow))
209 artifactOIDs, err := w.Runs.ArtifactOIDs()
210 if err != nil {
211 problems = append(problems, "run artifacts: "+err.Error())
81 212 } else {
82 add("runners", "fail", err.Error())
213 artifacts = objectSet(artifactOIDs)
83 214 }
84 } else {
85 add("queue", "fail", "run queue is not wired")
86 add("runners", "fail", "run queue is not wired")
87 215 }
88 if w.LFS != nil {
89 add("objects", "ok", "LFS object store is available")
90 } else {
91 add("objects", "warn", "LFS object store is disabled")
216 err := w.LFS.Walk(func(oid string, size int64) error {
217 v.Total.Objects++
218 v.Total.Bytes += size
219 switch {
220 case logs[oid]:
221 v.Logs.Objects++
222 v.Logs.Bytes += size
223 case artifacts[oid]:
224 v.Artifacts.Objects++
225 v.Artifacts.Bytes += size
226 default:
227 v.Other.Objects++
228 v.Other.Bytes += size
229 }
230 return nil
231 })
232 if err != nil {
233 problems = append(problems, err.Error())
92 234 }
93 view.Healthy = healthy
94 w.render(rw, "status", view, 0)
235 v.Err = strings.Join(problems, "; ")
236 return v
237 }
238
239 func objectSet(refs map[string][]string) map[string]bool {
240 set := make(map[string]bool)
241 for _, oids := range refs {
242 for _, oid := range oids {
243 if oid != "" {
244 set[oid] = true
245 }
246 }
247 }
248 return set
249 }
250
251 func (w *Web) statusConfig() *statusConfigView {
252 cfg := w.Cfg
253 v := &statusConfigView{Path: cfg.Path, Loaded: cfg.Loaded}
254
255 loginDisabled := w.Auth == nil || !w.Auth.Enabled()
256 publicNote := ""
257 if loginDisabled && w.Auth != nil {
258 publicNote = w.Auth.Disabled
259 }
260 registrationNote := ""
261 if cfg.OpenRegistration && loginDisabled {
262 registrationNote = "inactive while browser sign-in is disabled"
263 }
264 instanceNameNote := ""
265 if strings.TrimSpace(cfg.InstanceName) == "" {
266 instanceNameNote = "default: Vole"
267 }
268 instanceTaglineNote := ""
269 if strings.TrimSpace(cfg.InstanceTagline) == "" {
270 instanceTaglineNote = "built-in tagline"
271 }
272
273 devLogin := statusSetting{Key: "dev_login", Value: "off"}
274 if strings.TrimSpace(cfg.DevLogin) != "" {
275 devLogin.Value = cfg.DevLogin
276 devLogin.Note = "development-only sign-in"
277 devLogin.Warn = true
278 }
279
280 httpsNote := ""
281 if strings.TrimSpace(cfg.HTTPSAddr) == "" {
282 httpsNote = "automatic HTTPS disabled"
283 }
284 acmeDomainsNote := ""
285 if len(cfg.ACMEDomains) == 0 {
286 acmeDomainsNote = "derived from public_url"
287 }
288 acmeCacheNote := ""
289 if strings.TrimSpace(cfg.ACMECacheDir) == "" {
290 acmeCacheNote = "derived from data_dir"
291 }
292 pagesNote := ""
293 if strings.TrimSpace(cfg.PagesDomain) == "" {
294 pagesNote = "sites disabled"
295 }
296 jobsNote := ""
297 if cfg.MaxJobsPerRepo == 0 {
298 jobsNote = "unlimited"
299 }
300 leaseNote := ""
301 if cfg.LeaseSeconds <= 0 {
302 leaseNote = "default"
303 }
304 lfsWarnNote := ""
305 if cfg.LFSWarnBytes == 0 {
306 lfsWarnNote = "warning disabled"
307 }
308
309 v.Groups = []statusSettingGroup{
310 {Name: "Instance and access", Settings: []statusSetting{
311 configSetting("instance_name", cfg.InstanceName, instanceNameNote),
312 configSetting("instance_tagline", cfg.InstanceTagline, instanceTaglineNote),
313 configSetting("public_url", cfg.PublicURL, publicNote),
314 configSetting("open_registration", yesNo(cfg.OpenRegistration), registrationNote),
315 configSetting("open_registration_role", cfg.OpenRegistrationRole, ""),
316 devLogin,
317 }},
318 {Name: "Listeners and TLS", Settings: []statusSetting{
319 configSetting("http_addr", cfg.HTTPAddr, ""),
320 configSetting("https_addr", cfg.HTTPSAddr, httpsNote),
321 configSetting("ssh_addr", cfg.SSHAddr, ""),
322 configSetting("acme_email", cfg.ACMEEmail, ""),
323 configSetting("acme_domains", strings.Join(cfg.ACMEDomains, ", "), acmeDomainsNote),
324 configSetting("acme_cache_dir", cfg.ACMECachePath(), acmeCacheNote),
325 configSetting("acme_directory_url", cfg.ACMEDirectoryURL, ""),
326 }},
327 {Name: "Storage and push limits", Settings: []statusSetting{
328 configSetting("data_dir", cfg.DataDir, ""),
329 configSetting("max_blob_bytes", byteSetting(cfg.MaxBlobBytes), ""),
330 configSetting("receive_max_bytes", byteSetting(cfg.ReceiveMaxBytes), ""),
331 configSetting("lfs_object_max_bytes", byteSetting(cfg.LFSObjectMaxBytes), ""),
332 configSetting("lfs_warn_bytes", byteSetting(cfg.LFSWarnBytes), lfsWarnNote),
333 }},
334 {Name: "CI", Settings: []statusSetting{
335 configSetting("lease_seconds", fmt.Sprintf("%d seconds", int(cfg.Lease().Seconds())), leaseNote),
336 configSetting("max_jobs_per_repo", fmt.Sprintf("%d", cfg.MaxJobsPerRepo), jobsNote),
337 }},
338 {Name: "Sites", Settings: []statusSetting{
339 configSetting("pages_domain", cfg.PagesDomain, pagesNote),
340 configSetting("pages_ref", cfg.PagesRef, ""),
341 configSetting("pages_procs", fmt.Sprintf("%d", cfg.PagesProcs), ""),
342 configSetting("pages_cache_bytes", byteSetting(cfg.PagesCacheBytes), ""),
343 }},
344 }
345 return v
346 }
347
348 func configSetting(key, value, note string) statusSetting {
349 if strings.TrimSpace(value) == "" {
350 value = "not set"
351 }
352 return statusSetting{Key: key, Value: value, Note: note}
353 }
354
355 func byteSetting(n int64) string {
356 return fmt.Sprintf("%s (%d bytes)", format.Bytes(n), n)
357 }
358
359 func yesNo(v bool) string {
360 if v {
361 return "yes"
362 }
363 return "no"
95 364 }