package web
import (
"fmt"
"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"`
}
// 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, "/")
}
// 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
}
// storesListed is how many current stores the table shows before the
// rest fold into one line.
const storesListed = 6
// StoresShown is every store needing attention and the first few that
// are current; StoresFolded counts the rest.
func (v opsView) StoresShown() []opsStoreView {
var out []opsStoreView
current := 0
for _, s := range v.Stores {
if s.State == "current" {
current++
if current > storesListed {
continue
}
}
out = append(out, s)
}
return out
}
func (v opsView) StoresFolded() int {
current := 0
for _, s := range v.Stores {
if s.State == "current" {
current++
}
}
return max(0, current-storesListed)
}
// 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"
}