package web
import (
"fmt"
"html/template"
"net/url"
"slices"
"sort"
"strconv"
"strings"
"time"
)
// The operations page's model beyond what ops.status sends: the
// headline, the figures the strip shows, and the folds that keep the
// stores table short. Rules about what is wrong live in the op, not
// here; this only words what it answered.
// opsFindingView is one finding as ops.status answers it.
type opsFindingView struct {
Key string `json:"key"`
About string `json:"about"`
Severity string `json:"severity"`
Text string `json:"text"`
Since string `json:"since"`
Link string `json:"link"`
Figure string `json:"figure"`
Note string `json:"note"`
}
// opsClosedView is the last finding that stopped being one.
type opsClosedView struct {
Key string `json:"key"`
About string `json:"about"`
Text string `json:"text"`
Since string `json:"since"`
Until string `json:"until"`
Link string `json:"link"`
}
type opsDepView struct {
Name string `json:"name"`
Why string `json:"why"`
Up bool `json:"up"`
Since string `json:"since"`
DownFor string `json:"downFor"`
Err string `json:"err"`
Checked string `json:"checked"`
}
type opsDiskView struct {
Path string `json:"path"`
Total uint64 `json:"total"`
Free uint64 `json:"free"`
Used uint64 `json:"used"`
Stores uint64 `json:"stores"`
Indexes uint64 `json:"indexes"`
Analytics uint64 `json:"analytics"`
Other uint64 `json:"other"`
}
// Full is the disk past the point the op calls a finding.
func (d opsDiskView) Full() bool { return d.Total > 0 && float64(d.Used)/float64(d.Total) >= 0.8 }
type opsStoreView struct {
Name string `json:"name"`
State string `json:"state"`
Why string `json:"why"`
StoreBytes uint64 `json:"storeBytes"`
IndexBytes uint64 `json:"indexBytes"`
LastSync string `json:"lastSync"`
RoundSeconds float64 `json:"roundSeconds"`
Checkins int `json:"checkins"`
}
// opsRunningView is one live zit process as ops.status answers it.
type opsRunningView struct {
Verb string `json:"verb"`
Repo string `json:"repo"`
Cause string `json:"cause"`
Started string `json:"started"`
AgeMs int64 `json:"ageMs"`
UsualMs int64 `json:"usualMs"`
RSS uint64 `json:"rss"`
Slow bool `json:"slow"`
}
// Address is whether the cause is a request's address, which reads as
// code, rather than a background activity, which reads as a word.
func (r opsRunningView) Address() bool {
m, _, ok := strings.Cut(r.Cause, " ")
return ok && m != "" && strings.ToUpper(m) == m && strings.Contains(r.Cause, "/")
}
// slashWrap lets an address wrap after each slash rather than inside
// a word: "GET /r/fossil/timeline" on a phone breaks at "/timeline",
// not at "timelin|e".
func slashWrap(s string) template.HTML {
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "/", "/<wbr>"))
}
// RunningRSS sums the resident memory of the live processes.
func (v opsView) RunningRSS() (n uint64) {
for _, r := range v.Running {
n += r.RSS
}
return n
}
// opsStatusLine is the headline and its tone: start while stores come
// up, bad or warn by the worst finding, ok when there is none.
type opsStatusLine struct {
Tone string // start | bad | warn | ok
Head string
}
// Status words the moment. The phase outranks findings: during startup
// the panels read as an empty forge, and "Starting: 9 of 24 stores
// ready" is the only true sentence on the page.
func (v opsView) Status() opsStatusLine {
switch v.Phase.Name {
case "starting":
return opsStatusLine{Tone: "start", Head: fmt.Sprintf("Starting: %d of %d stores ready", v.Phase.Ready, v.Phase.Repos)}
case "indexing":
return opsStatusLine{Tone: "start", Head: "Indexing: rebuilding " + strings.Join(v.Phase.Working, ", ")}
}
bad, warn := 0, 0
var first *opsFindingView
for i := range v.Findings {
f := &v.Findings[i]
if f.Severity == "bad" {
bad++
} else {
warn++
}
if first == nil {
first = f
}
}
switch {
case bad == 1 && warn == 0, bad == 0 && warn == 1:
tone := "warn"
if bad == 1 {
tone = "bad"
}
return opsStatusLine{Tone: tone, Head: strings.ToUpper(first.About[:1]) + first.About[1:] + ": " + first.Text}
case bad > 0:
return opsStatusLine{Tone: "bad", Head: fmt.Sprintf("Degraded: %d things need attention", bad+warn)}
case warn > 0:
return opsStatusLine{Tone: "warn", Head: fmt.Sprintf("Attention: %d things to look at", warn)}
case v.Phase.Name == "syncing":
return opsStatusLine{Tone: "ok", Head: "Syncing"}
}
return opsStatusLine{Tone: "ok", Head: "All quiet"}
}
// StartedAt is the process start as a time, zero when unknown.
func (v opsView) StartedAt() time.Time {
t, err := time.Parse(time.RFC3339, v.Started)
if err != nil {
return time.Time{}
}
return t
}
// Uptime words how long the process has been up, "" when unknown.
func (v opsView) Uptime() string {
t := v.StartedAt()
if t.IsZero() {
return ""
}
d := time.Since(t)
switch {
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
}
return fmt.Sprintf("%dd %dh", int(d.Hours()/24), int(d.Hours())%24)
}
// RendersPerMinute is the last whole minute's count: the current
// minute is still filling and would read low.
func (v opsView) RendersPerMinute() int {
r := v.Signals.Renders
switch len(r) {
case 0:
return 0
case 1:
return r[0]
}
return r[len(r)-2]
}
// ErrorsLastHour sums the error signal; without a durable journal it
// falls back to the ring's total since start.
func (v opsView) ErrorsLastHour() int {
if v.Signals.Errors == nil {
return v.ErrorTotal
}
n := 0
for _, x := range v.Signals.Errors {
n += x
}
return n
}
// StoreBound is the store subprocess lane's slot count, 0 if unknown.
func (v opsView) StoreBound() int {
for _, l := range v.Lanes {
if l.Name == "store subprocesses" {
return l.Bound
}
}
return 0
}
// StoreBytes and IndexBytes sum the stores table.
func (v opsView) StoreBytes() (n uint64) {
for _, s := range v.Stores {
n += s.StoreBytes
}
return n
}
func (v opsView) IndexBytes() (n uint64) {
for _, s := range v.Stores {
n += s.IndexBytes
}
return n
}
// FindingKinds counts the findings by kind, largest first: "41 store ·
// 10 slow", so the header says what the list is mostly about.
func (v opsView) FindingKinds() string {
n := map[string]int{}
var kinds []string
for _, f := range v.Findings {
if n[f.About] == 0 {
kinds = append(kinds, f.About)
}
n[f.About]++
}
sort.SliceStable(kinds, func(i, j int) bool { return n[kinds[i]] > n[kinds[j]] })
parts := make([]string, len(kinds))
for i, k := range kinds {
parts[i] = strconv.Itoa(n[k]) + " " + k
}
return strings.Join(parts, " · ")
}
// storeOrders are the stores table's sorts, by the ?stores= key. The
// default, "", is the op's own order: worst state first.
var storeOrders = map[string]func(a, b opsStoreView) int{
"name": func(a, b opsStoreView) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) },
"store": func(a, b opsStoreView) int { return cmpDesc(a.StoreBytes, b.StoreBytes) },
"index": func(a, b opsStoreView) int { return cmpDesc(a.IndexBytes, b.IndexBytes) },
"sync": func(a, b opsStoreView) int { return strings.Compare(a.LastSync, b.LastSync) },
"round": func(a, b opsStoreView) int { return cmpDesc(a.RoundSeconds, b.RoundSeconds) },
"checkins": func(a, b opsStoreView) int { return cmpDesc(a.Checkins, b.Checkins) },
}
func cmpDesc[T int | uint64 | float64](a, b T) int {
switch {
case a > b:
return -1
case a < b:
return 1
}
return 0
}
// SortedStores is every store, in the order ?stores= asks for. Each
// sort starts from the one a reader most often wants: names A to Z,
// sizes and counts largest first, syncs oldest first; "-" reverses it.
func (v opsView) SortedStores() []opsStoreView {
key, rev := strings.CutPrefix(v.StoreSort, "-")
out := append([]opsStoreView(nil), v.Stores...)
if by, ok := storeOrders[key]; ok {
slices.SortStableFunc(out, func(a, b opsStoreView) int {
if rev {
return by(b, a)
}
return by(a, b)
})
}
return out
}
// opsStoreColumn is one sortable header of the stores table.
type opsStoreColumn struct {
Key, Label, Class, Href, Arrow, Aria string
}
// StoreColumns are the stores table's headers. A header sorts by its
// column, and the one already sorting reverses; "state", the first
// index column, is the op's own order, worst first.
func (v opsView) StoreColumns() []opsStoreColumn {
cols := []opsStoreColumn{
{Key: "name", Label: "store"},
{Key: "state", Label: "index"},
{Key: "store", Label: "store", Class: "n ops-wide"},
{Key: "index", Label: "index", Class: "n ops-wide"},
{Key: "sync", Label: "last sync", Class: "n"},
{Key: "round", Label: "round", Class: "n ops-wide"},
{Key: "checkins", Label: "check-ins", Class: "n ops-wide"},
}
cur, rev := strings.CutPrefix(v.StoreSort, "-")
if _, ok := storeOrders[cur]; !ok {
cur, rev = "state", false
}
for i := range cols {
c := &cols[i]
next := c.Key
if c.Key == cur {
// Names and syncs start ascending (A to Z, oldest first),
// the rest descending (largest first).
asc := c.Key == "name" || c.Key == "sync"
if rev {
asc = !asc
}
c.Arrow, c.Aria = "▼", "descending"
if asc {
c.Arrow, c.Aria = "▲", "ascending"
}
if c.Key == "state" {
c.Arrow, c.Aria = "▼", "other"
} else if !rev {
next = "-" + c.Key
}
}
q := url.Values{}
for k, vs := range v.Query {
q[k] = vs
}
if next == "state" {
q.Del("stores")
} else {
q.Set("stores", next)
}
c.Href = "?" + q.Encode() + "#stores"
}
return cols
}
// RenderScale is the slowest render listed, which every row's bar is
// drawn against; at least the budget, so a quiet list still shows
// where the budget falls.
func (v opsView) RenderScale() int64 {
scale := v.BudgetMs
for _, r := range v.Renders {
if r.ElapsedMs > scale {
scale = r.ElapsedMs
}
}
return max(scale, 1)
}
// msWord is a duration in the unit a reader thinks in: 41 ms, 1.32 s,
// 91.2 s, 1 m 28 s.
func msWord(ms int64) string {
switch {
case ms < 1000:
return strconv.FormatInt(ms, 10) + " ms"
case ms < 10_000:
return fmt.Sprintf("%.2f s", float64(ms)/1000)
case ms < 60_000:
return fmt.Sprintf("%.1f s", float64(ms)/1000)
}
m, s := ms/60_000, (ms%60_000)/1000
if s == 0 {
return fmt.Sprintf("%d m", m)
}
return fmt.Sprintf("%d m %d s", m, s)
}
// spanMs is a long span in days, hours and minutes: an event stream's
// oldest message is "1d 17h" old, not "2460m".
func spanMs(ms int64) string {
d := time.Duration(ms) * time.Millisecond
switch {
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
}
return fmt.Sprintf("%dd %dh", int(d.Hours()/24), int(d.Hours())%24)
}
// pctBudget is a duration as a percentage of the budget, capped so a
// bar never leaves its track.
func pctBudget(ms, budget int64) string {
if budget <= 0 {
return "0"
}
return fmt.Sprintf("%.1f", min(100, float64(ms)/float64(budget)*100))
}
func pctOf64(n, total int64) string {
if total <= 0 || n <= 0 {
return "0"
}
return fmt.Sprintf("%.1f", min(100, float64(n)/float64(total)*100))
}
// findingGo is the link text for a finding's destination.
func findingGo(about string) string {
switch about {
case "lane":
return "lanes ↓"
case "slow":
return "this route →"
case "errors":
return "exceptions →"
case "store":
return "its store →"
case "history":
return "history →"
case "dependency":
return "fleet →"
case "disk":
return "disk ↓"
case "memory":
return "memory ↓"
}
return "open →"
}
// storeTone is the label colour for an index state.
func storeTone(state string) string {
switch state {
case "current":
return "ov-ok"
case "stalled", "failing":
return "ov-bad"
case "behind", "gaps":
return "ov-warn"
}
return "ov-mute"
}