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 | } | |