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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Blame · server/web/status.go

Line attribution on main · 414 line(s)

09b15120
Keenan Nemetz
15d ago
1 package web
2
43e681d8
Keenan Nemetz
4d ago
3 // The status tabs on the Admin page report how much disk the instance uses, what
4 // the LFS pool contains, and whether people are reading it. They expose counts and
ed03b3d1
Keenan Nemetz
3h ago
5 // sizes, never repository contents; /-/healthz remains the separate liveness probe.
09b15120
Keenan Nemetz
15d ago
6
7 import (
8 "fmt"
9 "net/http"
43e681d8
Keenan Nemetz
4d ago
10 "net/url"
4506a4d3
Keenan Nemetz
13d ago
11 "strings"
12 "sync"
09b15120
Keenan Nemetz
15d ago
13 "time"
4506a4d3
Keenan Nemetz
13d ago
14
d37d301f
Keenan Nemetz
15h ago
15 "rodent.software/vole/forge/pageview"
16 "rodent.software/vole/internal/format"
17 "rodent.software/vole/internal/storage"
09b15120
Keenan Nemetz
15d ago
18 )
19
4506a4d3
Keenan Nemetz
13d ago
20 const statusStatsTTL = 30 * time.Second
21
22 type statusObjectCount struct {
23 Objects int
24 Bytes int64
25 }
26
27 type statusStorageView struct {
28 Used int64
29 Files int
30 Categories []storage.Category
31 Disk storage.Disk
32 DiskErr string
33 Err string
34 Measured time.Time
35 }
36
37 type statusLFSView struct {
38 Total 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
09b15120
Keenan Nemetz
15d ago
80 }
81
43e681d8
Keenan Nemetz
4d ago
82 // StatusRedirect keeps old links working: the status tabs now live on the Admin
83 // page, and the tab name is preserved so a bookmark lands on the same section.
84 func (w *Web) StatusRedirect(rw http.ResponseWriter, r *http.Request) {
ed03b3d1
Keenan Nemetz
3h ago
85 target := "/-/admin"
43e681d8
Keenan Nemetz
4d ago
86 if tab := adminTabFrom(r.URL.Query().Get("tab")); tab != "overview" {
87 target += "?tab=" + url.QueryEscape(tab)
4506a4d3
Keenan Nemetz
13d ago
88 }
43e681d8
Keenan Nemetz
4d ago
89 http.Redirect(rw, r, target, http.StatusMovedPermanently)
90 }
4506a4d3
Keenan Nemetz
13d ago
91
43e681d8
Keenan Nemetz
4d ago
92 // fillStatusView reads the instance state the status tabs show. It is a method on
93 // the Admin view because those tabs are sections of that page, not a page of
94 // their own; each branch reads only what its tab renders.
6ce635a8
Keenan Nemetz
1d ago
95 func (w *Web) fillStatusView(r *http.Request, view *adminPageView) {
43e681d8
Keenan Nemetz
4d ago
96 tab := view.Tab
4506a4d3
Keenan Nemetz
13d ago
97 if tab == "overview" {
98 if w.Ident != nil {
99 view.Accounts = w.Ident.Snapshot().Count
100 }
101 if w.Repos != nil {
102 if repos, err := w.Repos.List(); err == nil {
103 view.Repos = len(repos)
104 }
105 }
106 if w.PageViews != nil {
107 stats, err := w.PageViews.Stats(time.Now().UTC())
108 view.Views = statusViewsView{Today: stats.Today, Week: stats.Week, Month: stats.Month}
109 if err != nil {
110 view.Views.Err = err.Error()
111 }
09b15120
Keenan Nemetz
15d ago
112 } else {
4506a4d3
Keenan Nemetz
13d ago
113 view.Views.Disabled = true
09b15120
Keenan Nemetz
15d ago
114 }
0db980ee
Keenan Nemetz
4d ago
115
116 // The overview is the whole admin surface at a glance, so it repeats the
117 // head of the other tabs rather than making the reader visit each one.
118 view.AccountSummary = w.accountSummary()
119 view.RecentAudit = w.recentAudit()
120 view.ConfigWarnings = w.configWarnings()
09b15120
Keenan Nemetz
15d ago
121 }
4506a4d3
Keenan Nemetz
13d ago
122 if tab == "storage" || tab == "overview" {
123 if w.Cfg != nil {
124 st, err, lfs := w.instanceStats()
125 view.Storage = statusStorageView{
126 Used: st.Bytes, Files: st.Files, Disk: st.Disk, DiskErr: st.DiskErr,
127 Measured: st.Measured,
128 }
129 if err != nil {
130 view.Storage.Err = err.Error()
131 }
132 for _, c := range st.Categories {
133 if c.Bytes > 0 || c.Files > 0 {
134 view.Storage.Categories = append(view.Storage.Categories, c)
135 }
136 }
137 view.LFS = lfs
09b15120
Keenan Nemetz
15d ago
138 }
139 }
4506a4d3
Keenan Nemetz
13d ago
140 if tab == "runners" {
141 sessions, queue, runnerProblems := w.runnerStatus()
142 view.RunnerSessions, view.RunnerQueue = sessions, queue
143 view.RunnerWindow = presenceWindow.String()
144 view.RunnerErr = strings.Join(runnerProblems, "; ")
145 }
146 // Configuration is host-level detail. The page is instance-admin only, so it
147 // can show the effective values; the file itself may be package-owned and is
148 // never edited here.
149 if tab == "configuration" && w.Cfg != nil {
150 view.Config = w.statusConfig()
6ce635a8
Keenan Nemetz
1d ago
151 view.InstanceRepo = w.instanceRepoView(r)
4506a4d3
Keenan Nemetz
13d ago
152 }
153 }
154
0db980ee
Keenan Nemetz
4d ago
155 // configWarnings collects the configuration settings worth a second look: a
156 // development-only switch, or a browser that cannot sign anyone in. The full
157 // configuration tab lists every value; this is only what would surprise an
158 // operator reading the overview.
159 func (w *Web) configWarnings() []statusSetting {
160 var out []statusSetting
161 if w.Auth == nil || !w.Auth.Enabled() {
162 note := ""
163 if w.Auth != nil {
164 note = w.Auth.Disabled
165 }
166 out = append(out, statusSetting{Key: "browser sign-in", Value: "disabled", Note: note, Warn: true})
167 }
168 if w.Cfg == nil {
169 return out
170 }
171 for _, group := range w.statusConfig().Groups {
172 for _, setting := range group.Settings {
173 if setting.Warn {
174 out = append(out, setting)
175 }
176 }
177 }
178 return out
179 }
180
4506a4d3
Keenan Nemetz
13d ago
181 func (w *Web) instanceStats() (storage.Stats, error, statusLFSView) {
182 c := &w.statusCache
183 c.mu.Lock()
184 defer c.mu.Unlock()
185 if !c.at.IsZero() && time.Since(c.at) < statusStatsTTL {
186 return c.storage, c.storageErr, c.lfs
187 }
188 st, err := storage.Measure(w.Cfg.DataDir)
189 c.storage, c.storageErr = st, err
190 c.lfs = w.lfsUsage()
191 c.at = time.Now()
192 return c.storage, c.storageErr, c.lfs
193 }
194
195 // lfsUsage classifies the object pool by what the run queue says produced each
196 // object. The pool itself is the only source of sizes: a stale run row must not
197 // make the storage numbers disagree with the files on disk.
198 func (w *Web) lfsUsage() statusLFSView {
199 var v statusLFSView
200 if w.LFS == nil {
201 v.Disabled = true
202 return v
203 }
5ece70a5
Keenan Nemetz
10d ago
204 artifacts := map[string]bool{}
4506a4d3
Keenan Nemetz
13d ago
205 var problems []string
09b15120
Keenan Nemetz
15d ago
206 if w.Runs != nil {
4506a4d3
Keenan Nemetz
13d ago
207 artifactOIDs, err := w.Runs.ArtifactOIDs()
208 if err != nil {
209 problems = append(problems, "run artifacts: "+err.Error())
09b15120
Keenan Nemetz
15d ago
210 } else {
4506a4d3
Keenan Nemetz
13d ago
211 artifacts = objectSet(artifactOIDs)
09b15120
Keenan Nemetz
15d ago
212 }
213 }
4506a4d3
Keenan Nemetz
13d ago
214 err := w.LFS.Walk(func(oid string, size int64) error {
215 v.Total.Objects++
216 v.Total.Bytes += size
217 switch {
218 case artifacts[oid]:
219 v.Artifacts.Objects++
220 v.Artifacts.Bytes += size
221 default:
222 v.Other.Objects++
223 v.Other.Bytes += size
224 }
225 return nil
226 })
227 if err != nil {
228 problems = append(problems, err.Error())
09b15120
Keenan Nemetz
15d ago
229 }
4506a4d3
Keenan Nemetz
13d ago
230 v.Err = strings.Join(problems, "; ")
231 return v
232 }
233
234 func objectSet(refs map[string][]string) map[string]bool {
235 set := make(map[string]bool)
236 for _, oids := range refs {
237 for _, oid := range oids {
238 if oid != "" {
239 set[oid] = true
240 }
241 }
242 }
243 return set
244 }
245
246 func (w *Web) statusConfig() *statusConfigView {
247 cfg := w.Cfg
248 v := &statusConfigView{Path: cfg.Path, Loaded: cfg.Loaded}
249
250 loginDisabled := w.Auth == nil || !w.Auth.Enabled()
251 publicNote := ""
252 if loginDisabled && w.Auth != nil {
253 publicNote = w.Auth.Disabled
254 }
255 registrationNote := ""
256 if cfg.OpenRegistration && loginDisabled {
257 registrationNote = "inactive while browser sign-in is disabled"
258 }
259 instanceNameNote := ""
260 if strings.TrimSpace(cfg.InstanceName) == "" {
261 instanceNameNote = "default: Vole"
262 }
6ce635a8
Keenan Nemetz
1d ago
263 instanceRepoNote := ""
264 if strings.TrimSpace(cfg.InstanceRepo) == "" {
265 instanceRepoNote = "customization disabled"
4506a4d3
Keenan Nemetz
13d ago
266 }
267
ed181212
Keenan Nemetz
13d ago
268 devOnly := statusSetting{Key: "dev_only", Value: yesNo(cfg.DevOnly)}
269 if cfg.DevOnly {
270 devOnly.Note = "development instance"
271 devOnly.Warn = true
272 }
4506a4d3
Keenan Nemetz
13d ago
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 }
0c40a1d3
Keenan Nemetz
7d ago
292 secretsKeyNote := "keep this out of a backup of data_dir"
293 if strings.TrimSpace(cfg.SecretsKey) == "" {
294 secretsKeyNote = "derived from data_dir; keep it out of the data backup"
295 }
4506a4d3
Keenan Nemetz
13d ago
296 pagesNote := ""
297 if strings.TrimSpace(cfg.PagesDomain) == "" {
298 pagesNote = "sites disabled"
299 }
300 jobsNote := ""
301 if cfg.MaxJobsPerRepo == 0 {
302 jobsNote = "unlimited"
303 }
304 leaseNote := ""
305 if cfg.LeaseSeconds <= 0 {
306 leaseNote = "default"
307 }
308 lfsWarnNote := ""
309 if cfg.LFSWarnBytes == 0 {
310 lfsWarnNote = "warning disabled"
311 }
86991e70
Keenan Nemetz
13d ago
312 gitHTTPNote := ""
313 if !cfg.GitHTTP {
314 gitHTTPNote = "SSH only"
315 }
316 clientIPNote := "connection address"
317 if cfg.GitHTTPClientIPHeader != "" {
318 clientIPNote = "from " + cfg.GitHTTPClientIPHeader
319 }
9e8cc155
Keenan Nemetz
12d ago
320 quotaNote := func(limit int64) string {
321 if limit == 0 {
322 return "unlimited"
323 }
324 return ""
325 }
86991e70
Keenan Nemetz
13d ago
326 perClientNote := "unlimited"
327 if cfg.GitHTTPMaxPerClient > 0 {
328 perClientNote = ""
329 }
78cd3437
Keenan Nemetz
12d ago
330 sshConnNote, sshProcNote, sshHandshakeNote := "", "", ""
331 if cfg.SSHMaxConnections == 0 {
332 sshConnNote = "default: 64"
333 }
334 if cfg.SSHMaxGitProcesses == 0 {
335 sshProcNote = "default: 16"
336 }
337 if cfg.SSHHandshakeTimeoutSeconds == 0 {
338 sshHandshakeNote = "default: 20"
339 }
4506a4d3
Keenan Nemetz
13d ago
340
341 v.Groups = []statusSettingGroup{
342 {Name: "Instance and access", Settings: []statusSetting{
343 configSetting("instance_name", cfg.InstanceName, instanceNameNote),
6ce635a8
Keenan Nemetz
1d ago
344 configSetting("instance_repo", cfg.InstanceRepo, instanceRepoNote),
4506a4d3
Keenan Nemetz
13d ago
345 configSetting("public_url", cfg.PublicURL, publicNote),
346 configSetting("open_registration", yesNo(cfg.OpenRegistration), registrationNote),
347 configSetting("open_registration_role", cfg.OpenRegistrationRole, ""),
ed181212
Keenan Nemetz
13d ago
348 devOnly,
4506a4d3
Keenan Nemetz
13d ago
349 devLogin,
350 }},
351 {Name: "Listeners and TLS", Settings: []statusSetting{
352 configSetting("http_addr", cfg.HTTPAddr, ""),
353 configSetting("https_addr", cfg.HTTPSAddr, httpsNote),
354 configSetting("ssh_addr", cfg.SSHAddr, ""),
78cd3437
Keenan Nemetz
12d ago
355 configSetting("ssh_max_connections", fmt.Sprintf("%d", cfg.SSHMaxConnections), sshConnNote),
356 configSetting("ssh_max_git_processes", fmt.Sprintf("%d", cfg.SSHMaxGitProcesses), sshProcNote),
357 configSetting("ssh_handshake_timeout_seconds", fmt.Sprintf("%d seconds", cfg.SSHHandshakeTimeoutSeconds), sshHandshakeNote),
4506a4d3
Keenan Nemetz
13d ago
358 configSetting("acme_email", cfg.ACMEEmail, ""),
359 configSetting("acme_domains", strings.Join(cfg.ACMEDomains, ", "), acmeDomainsNote),
360 configSetting("acme_cache_dir", cfg.ACMECachePath(), acmeCacheNote),
361 configSetting("acme_directory_url", cfg.ACMEDirectoryURL, ""),
362 }},
363 {Name: "Storage and push limits", Settings: []statusSetting{
364 configSetting("data_dir", cfg.DataDir, ""),
0c40a1d3
Keenan Nemetz
7d ago
365 configSetting("secrets_key", cfg.SecretsKeyPath(), secretsKeyNote),
4506a4d3
Keenan Nemetz
13d ago
366 configSetting("max_blob_bytes", byteSetting(cfg.MaxBlobBytes), ""),
367 configSetting("receive_max_bytes", byteSetting(cfg.ReceiveMaxBytes), ""),
368 configSetting("lfs_object_max_bytes", byteSetting(cfg.LFSObjectMaxBytes), ""),
369 configSetting("lfs_warn_bytes", byteSetting(cfg.LFSWarnBytes), lfsWarnNote),
9e8cc155
Keenan Nemetz
12d ago
370 configSetting("repo_max_refs", fmt.Sprintf("%d", cfg.RepoMaxRefs), quotaNote(int64(cfg.RepoMaxRefs))),
371 configSetting("repo_max_metadata_entries", fmt.Sprintf("%d", cfg.RepoMaxMetadataEntries), quotaNote(int64(cfg.RepoMaxMetadataEntries))),
372 configSetting("repo_max_metadata_bytes", byteSetting(cfg.RepoMaxMetadataBytes), quotaNote(cfg.RepoMaxMetadataBytes)),
373 configSetting("repo_max_bytes", byteSetting(cfg.RepoMaxBytes), quotaNote(cfg.RepoMaxBytes)),
374 configSetting("repo_max_lfs_bytes", byteSetting(cfg.RepoMaxLFSBytes), quotaNote(cfg.RepoMaxLFSBytes)),
e5542ac6
Keenan Nemetz
12d ago
375 configSetting("contributor_writes_per_hour", fmt.Sprintf("%d", cfg.ContributorWritesPerHour), quotaNote(int64(cfg.ContributorWritesPerHour))),
4506a4d3
Keenan Nemetz
13d ago
376 }},
86991e70
Keenan Nemetz
13d ago
377 {Name: "Anonymous read limits", Settings: []statusSetting{
378 configSetting("git_http", yesNo(cfg.GitHTTP), gitHTTPNote),
379 configSetting("git_http_max_concurrent", fmt.Sprintf("%d", cfg.GitHTTPMaxConcurrent), ""),
380 configSetting("git_http_max_per_client", fmt.Sprintf("%d", cfg.GitHTTPMaxPerClient), perClientNote),
381 configSetting("git_http_client_ip_header", cfg.GitHTTPClientIPHeader, clientIPNote),
382 configSetting("git_http_timeout_seconds", fmt.Sprintf("%d seconds", cfg.GitHTTPTimeoutSeconds), ""),
383 }},
4506a4d3
Keenan Nemetz
13d ago
384 {Name: "CI", Settings: []statusSetting{
385 configSetting("lease_seconds", fmt.Sprintf("%d seconds", int(cfg.Lease().Seconds())), leaseNote),
386 configSetting("max_jobs_per_repo", fmt.Sprintf("%d", cfg.MaxJobsPerRepo), jobsNote),
387 }},
388 {Name: "Sites", Settings: []statusSetting{
389 configSetting("pages_domain", cfg.PagesDomain, pagesNote),
390 configSetting("pages_ref", cfg.PagesRef, ""),
391 configSetting("pages_procs", fmt.Sprintf("%d", cfg.PagesProcs), ""),
392 configSetting("pages_cache_bytes", byteSetting(cfg.PagesCacheBytes), ""),
393 }},
394 }
395 return v
396 }
397
398 func configSetting(key, value, note string) statusSetting {
399 if strings.TrimSpace(value) == "" {
400 value = "not set"
401 }
402 return statusSetting{Key: key, Value: value, Note: note}
403 }
404
405 func byteSetting(n int64) string {
406 return fmt.Sprintf("%s (%d bytes)", format.Bytes(n), n)
407 }
408
409 func yesNo(v bool) string {
410 if v {
411 return "yes"
412 }
413 return "no"
09b15120
Keenan Nemetz
15d ago
414 }