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