panel-redesign: Overview, Health, Account and Server > Settings on the kit
Stage 2, step 2. Written by Sonnet, reviewed by Opus.
- Four pages named after their mockups: overview, health, account,
settings, each with a typed page struct (view/overview.go, health.go,
account.go), a fixture and tests. The engine page "status" is gone
(status.html, status_body.html deleted); "settings" is Server >
Settings only. status and settings are off legacy_pages.txt.
- Overview: the stamp and its sentence are derived from the six cards
(the worst card sets the level and the lead names the cards that are
not fine); the outbound and inbound domain tables are built from the
existing service calls. Without the inbound relay the outbound box
takes the full width.
- Both pages refresh themselves as the old Status page did. Health
polls its first row of boxes and swaps the second out of band.
Overview polls its Server health box and the page head follows out of
band (GET /overview/fragment), so stamp, sentence and time move with
the cards.
- Kit, attributes only (no class, no CSS, markup unchanged when empty):
Box.Poll and Head.ID / Head.OOB.
- healthCards: the Queue card says "Empty" / "N queued" instead of
postqueue's line, which was clipped in a card; Processes and Milter
sockets carry a sub line. OverallStatus / OverallHeading are gone.
- Handlers pass typed data; routes, POST targets, field names,
validation and authorization are unchanged.
States the mockups do not draw, all from existing partials: errors and
successes as the kit flash above the boxes; Health with an unreadable
process list, a certificate without facts, a missing host name; Account
with the report authorization not published / not found / not checked
and with hosted reports off; empty domain tables as empty_state.
Differences from the mockups, for the owner:
- Overview title is one of "Everything is running normally." /
"Running, with warnings." / "A component needs attention.", not
"Mail is flowing..." (the checks do not prove that); the note says
"Updated HH:MM:SS UTC" without an interval, Health's lead says
"Refreshes automatically" (panel.js polls at 5 s active, 30 s idle);
- one Queue card ("outbound"), two milter sockets: what this build has;
- the days column is the stats window, min(30, retention);
- Health: no "check port 80" paragraph (the certificate's own detail
says what to check); a failing socket shows its detail under the path;
figures come from the sampler ("12%", "MiB");
- Account: the DMARC form keeps the address field for "Another
address" (hidden unless chosen); the count "N of M now" is the
domains following this user's default;
- Settings: Level 2 links the domain list only;
- /help#checks and /help#dmarc open Help at the top until the Help step.
Evidence
- docs/assets/panel-redesign/evidence/overview.png, health.png,
account.png, settings.png: 1440 px wide, full height, headless Edge,
real templates served locally with their fixtures - NOT from the
container (no Docker on this machine).
- panel/audit.js on the four pages at 1440 and 1280: 612 texts
checked, problems: [].
- Polling was exercised in a browser against a local fixture server
with document.hidden overridden (the pane was hidden): one element
per id after a swap, no leftover hx-swap-oob, about one request per
5 s. The first request of a visible tab was not observed.
- go test -count=1 ./internal/web/...
ok internal/web
ok internal/web/auth
ok internal/web/handlers
ok internal/web/validate
ok internal/web/view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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: &HelpLink{Href: "/help#checks", Title: "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. The stamp takes the
|
||||
// worst card, counts the cards that are not fine, and the lead names them with
|
||||
// their detail 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 := "Everything is running normally.", "OK", len(cards)
|
||||
switch level {
|
||||
case LevelWarn:
|
||||
title, word, count = "Running, with warnings.", "WARN", len(bad)
|
||||
case LevelFail:
|
||||
title, word, count = "A component needs attention.", "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.UTC().Format("15:04:05") + " UTC"},
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
lead := Text{{Text: "Needs attention: "}}
|
||||
for i, c := range bad {
|
||||
if i > 0 {
|
||||
lead = append(lead, Inline{Text: ", "})
|
||||
}
|
||||
lead = append(lead, Link(c.Href, c.Name), Inline{Text: " (" + c.Value + ")"})
|
||||
}
|
||||
h.Lead = append(lead, Inline{Text: "."})
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// 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" }
|
||||
Reference in New Issue
Block a user