Skip to content
quarry Artifact [755ef09004]
Login

Artifact [755ef09004]

Artifact 755ef0900458ca6fc28195465110f7539726bcdc0a643ade7e1079c1ca3af8f8:


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: "state"},
		{Key: "store", Label: "store size", Class: "n ops-wide"},
		{Key: "index", Label: "index size", 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"
			}
			// The op's own order, worst first, has no reverse, so it
			// shows no arrow that would promise one.
			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"
}