Files
selfpost/internal/web/view/overview.go
T
mixandClaude Opus 5.5 1eec1c6011 overview-head: the mockup's sentence, made from the cards
The owner's "1" (2026-10-17), the two shown side by side; design 9aebe1e.

overviewHead writes "Mail is flowing. Nothing needs attention." when every
card is fine, "Mail is flowing. One thing needs attention." (or "N things
need") for warnings, and "Mail may not be flowing. ..." when a card fails;
the lead names each card that is not fine, linked, with its value inside
the sentence ("TLS certificate: expires in 12 days."), separated by
semicolons. lowerFirst lowers a value's first letter unless it is an
abbreviation ("CPU 98 %"); every value the cards write is a phrase.

Tests: TestOverviewHeadSentences (the three states, two cards, an
abbreviation), the Overview page and fragment, TestFilterDownIsAMissingSocket.
go vet + go test (26 packages) and make e2e on the test server for this
tree.

Docs: guide § Overview, CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:48:34 +03:00

177 lines
5.7 KiB
Go

package view
// The data of Overview (templates/overview.html, mockup overview.html): the
// answer to "is mail flowing?" in a postmark and six cards, and under them the
// two kinds of domain at a glance. Everything behind the cards is on Health
// (health.go).
import (
"fmt"
"strconv"
"strings"
"time"
"unicode"
)
// Overview is the data of the Overview page. Cards are the checks of Health
// reduced to a verdict each; the head's postmark and sentence are derived from
// them by NewOverview, so the stamp and the cards cannot disagree.
//
// The inbound box is drawn only when the inbound relay is on (InboundOn):
// without it the outbound box takes the whole width and nothing on the page
// points at /inbound/domains.
type Overview struct {
Meta
Head Head
Cards []HealthCard
Health Box
Outbound Box
Days int
OutboundRows []OverviewDomain
OutboundEmpty EmptyState
InboundOn bool
Inbound Box
InboundRows []OverviewInbound
InboundEmpty EmptyState
}
// OverviewDomain is one row of the Outbound domains table: the domain, the
// verdict of each DNS check as a tag (DKIM, SPF, DMARC), how many applications
// it has and how much mail it sent in the window (see FormatMessages).
type OverviewDomain struct {
Name string
Href string
DNS []Tag
Apps int
Messages string
}
// OverviewInbound is one row of the Inbound domains table: the domain, its MX
// check as a tag and the upstream it forwards to.
type OverviewInbound struct {
Name string
Href string
MX Tag
Upstream string
}
// NewOverview builds the page. host is the server's name (the kicker) and may
// be empty; updated is when the checks were read; days is the length of the
// send window of the last column.
func NewOverview(m Meta, host string, cards []HealthCard, updated time.Time, days int, inboundOn bool) *Overview {
m.Title, m.Section, m.Page = "Overview", "overview", ""
o := &Overview{
Meta: m,
Cards: cards,
Days: days,
Head: overviewHead(host, cards, updated),
Health: Box{No: "01", Title: "Server health", ID: "health", Poll: "/overview/fragment",
End: Rich(Link("/server/health", "All details")),
Help: topicLink(HelpChecks, "What these checks mean")},
Outbound: Box{No: "02", Title: "Outbound domains", End: Rich(Link("/outbound/domains", "Manage"))},
OutboundEmpty: EmptyState{Icon: "ti-world-upload",
Text: Plain("No outbound domains yet. Add the first one under Outbound.")},
InboundOn: inboundOn,
Inbound: Box{No: "03", Title: "Inbound domains", End: Rich(Link("/inbound/domains", "Manage"))},
InboundEmpty: EmptyState{Icon: "ti-world-download",
Text: Plain("No inbound domains yet. Mail is not accepted for any.")},
}
return o
}
// HeadOOB is the head as the polled fragment carries it: the same head, marked
// to be swapped into the page by its id. The page itself never has the mark.
func (o *Overview) HeadOOB() Head {
h := o.Head
h.OOB = true
return h
}
// overviewHead is the stamp and the sentence under it (mockup overview.html,
// the owner's "1" of 2026-10-17). The stamp takes the worst card and counts
// the cards that are not fine; the title says whether mail flows and how many
// things need attention, and the lead names them with their values so the
// operator sees what to open without scanning the grid. A card that could not
// be read is a warning, never a pass (cardLevel).
func overviewHead(host string, cards []HealthCard, updated time.Time) Head {
level, bad := LevelOK, []HealthCard{}
for _, c := range cards {
if c.Level == "" {
continue
}
bad = append(bad, c)
if c.Level == LevelFail || level == LevelOK {
level = c.Level
}
}
title, word, count := "Mail is flowing. Nothing needs attention.", "OK", len(cards)
switch level {
case LevelWarn:
title, word, count = "Mail is flowing. "+needAttention(len(bad)), "WARN", len(bad)
case LevelFail:
// A failed card is Postfix, a milter socket, a queue that cannot be
// read: what can stop mail.
title, word, count = "Mail may not be flowing. "+needAttention(len(bad)), "FAIL", len(bad)
}
h := Head{
Postmark: &Postmark{Top: "SELFPOST", Word: word, Bottom: fmt.Sprintf("%d OF %d CHECKS", count, len(cards)), Level: level},
Kicker: host,
Title: title,
ID: "overview-head",
Note: []string{"Updated " + updated.Local().Format("15:04:05"), "refreshes on its own"},
}
if len(bad) > 0 {
var lead Text
for i, c := range bad {
if i > 0 {
lead = append(lead, Inline{Text: "; "})
}
lead = append(lead, Link(c.Href, c.Name), Inline{Text: ": " + lowerFirst(c.Value)})
}
h.Lead = append(lead, Inline{Text: "."})
}
return h
}
// needAttention is the second sentence of the title for n things.
func needAttention(n int) string {
if n == 1 {
return "One thing needs attention."
}
return strconv.Itoa(n) + " things need attention."
}
// lowerFirst writes a card's value inside a sentence: "Expires in 12 days"
// becomes "expires in 12 days", but a word that starts with two capitals - an
// abbreviation, "CPU 98 %" - stays as it is.
func lowerFirst(s string) string {
r := []rune(s)
if len(r) > 1 && unicode.IsUpper(r[0]) && unicode.IsLower(r[1]) {
r[0] = unicode.ToLower(r[0])
}
return string(r)
}
// FormatCount writes a whole number with its thousands separated by a no-break
// space: "1 284".
func FormatCount(n int64) string {
digits := strconv.FormatInt(n, 10)
var b strings.Builder
for i, r := range digits {
if i > 0 && (len(digits)-i)%3 == 0 && digits[i-1] != '-' {
b.WriteString("\u00a0")
}
b.WriteRune(r)
}
return b.String()
}
// FormatMessages writes a message count as the Overview table shows it:
// "1 284 msg".
func FormatMessages(n int64) string { return FormatCount(n) + "\u00a0msg" }