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>
114 lines
5.8 KiB
Go
114 lines
5.8 KiB
Go
package view
|
|
|
|
import (
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// pageFixtures is the data each redesigned page is rendered with by the guard
|
|
// tests (guard_panel_test.go): one entry per page that has left
|
|
// legacy_pages.txt, keyed by the page's engine name. The guards render the page
|
|
// through Engine.Render with this value and hold the result to the design
|
|
// contract — its class vocabulary, its page structure and the component
|
|
// skeleton recorded for the mockup of the same name in
|
|
// docs/assets/panel-redesign/panel/outlines.json.
|
|
//
|
|
// A fixture therefore has to fill the page the way its mockup is filled: the
|
|
// same boxes, the same number of records. It is also what the evidence
|
|
// screenshots are compared against, so use plausible values, not "foo".
|
|
//
|
|
// This file belongs to the implementation, not to the guards: the step that
|
|
// restyles a page adds its fixture here in the same commit.
|
|
var pageFixtures = map[string]func() any{
|
|
"components": func() any { return KitPage() },
|
|
"login": func() any { return NewLogin("mail.example.org", "") },
|
|
"setup": func() any { return NewSetup("0123456789abcdef", "") },
|
|
"overview": func() any { return overviewFixture() },
|
|
"health": func() any { return healthFixture(false) },
|
|
"account": func() any { return accountFixture() },
|
|
"settings": func() any { return settingsFixture() },
|
|
}
|
|
|
|
// admin is the signed-in global administrator every fixture is rendered for.
|
|
func admin() Meta { return Meta{User: "admin", IsGlobal: true} }
|
|
|
|
// overviewFixture is the mockup's Overview: one certificate warning among six
|
|
// checks, three outbound domains and two inbound.
|
|
func overviewFixture() *Overview {
|
|
cards := []HealthCard{
|
|
{Name: "Machine", Value: "CPU 12%", Sub: "RAM 41%", Icon: "ti-cpu", Href: "/server/health#machine"},
|
|
{Name: "Processes", Value: "5 of 5 running", Sub: "all programs", Icon: "ti-server-cog", Href: "/server/health#processes"},
|
|
{Name: "TLS certificate", Value: "Expires in 12 days", Sub: "mail.example.org", Icon: "ti-certificate", Href: "/server/health#certificate", Level: LevelWarn},
|
|
{Name: "Queue", Value: "Empty", Sub: "outbound", Icon: "ti-stack-2", Href: "/outbound/queue"},
|
|
{Name: "Milter sockets", Value: "2 of 2 answering", Sub: "OpenDKIM, send-log", Icon: "ti-plug-connected", Href: "/server/health#sockets"},
|
|
{Name: "Reverse DNS", Value: "Forward = reverse", Sub: "mail.example.org", Icon: "ti-arrows-exchange", Href: "/server/health#hostname"},
|
|
}
|
|
o := NewOverview(admin(), "mail.example.org", cards, time.Date(2026, 10, 9, 14, 2, 31, 0, time.UTC), 30, true)
|
|
dns := func(dkim, spf, dmarc string) []Tag {
|
|
return []Tag{{Status: dkim, Label: "DKIM"}, {Status: spf, Label: "SPF"}, {Status: dmarc, Label: "DMARC"}}
|
|
}
|
|
o.OutboundRows = []OverviewDomain{
|
|
{Name: "example.org", Href: "/outbound/domains/1", DNS: dns("ok", "error", "ok"), Apps: 2, Messages: FormatMessages(1284)},
|
|
{Name: "shop.example.org", Href: "/outbound/domains/2", DNS: dns("ok", "ok", "ok"), Apps: 1, Messages: FormatMessages(18920)},
|
|
{Name: "notify.acme.io", Href: "/outbound/domains/3", DNS: dns("ok", "ok", "warn"), Apps: 3, Messages: FormatMessages(402)},
|
|
}
|
|
o.InboundRows = []OverviewInbound{
|
|
{Name: "lists.example.org", Href: "/inbound/domains/1", MX: Tag{Status: "ok", Label: "MX"}, Upstream: "mx.internal:25"},
|
|
{Name: "acme.io", Href: "/inbound/domains/2", MX: Tag{Status: "ok", Label: "MX"}, Upstream: "10.0.4.12:2525"},
|
|
}
|
|
return o
|
|
}
|
|
|
|
// healthFixture is the mockup's Health: a healthy machine and five processes,
|
|
// a certificate close to expiry, the two milter sockets and a good reverse
|
|
// record. refresh renders it as the polled fragment.
|
|
func healthFixture(refresh bool) *Health {
|
|
h := NewHealth(admin(), "")
|
|
h.Refresh = refresh
|
|
h.Machine.End = Verdict("ok")
|
|
h.MachineRows = []MachineRow{
|
|
{Resource: "CPU", Gauge: &Gauge{Percent: 12, Text: "12%", Level: LevelOK}, Detail: []string{"2 cores · 2 threads"}},
|
|
{Resource: "Memory", Gauge: &Gauge{Percent: 41, Text: "41%", Level: LevelOK}, Detail: []string{"812.0 MiB used of 1.9 GiB"}},
|
|
{Resource: "Network", Usage: "↓ 18.0 KiB/s · ↑ 44.0 KiB/s", Detail: []string{"eth0: 2.1 GiB in, 6.4 GiB out"}},
|
|
}
|
|
h.Processes.End = Verdict("ok")
|
|
for i, name := range []string{"postfix", "opendkim", "journal-milter", "panel", "dmarc-ingest"} {
|
|
h.ProcessRows = append(h.ProcessRows, ProcessRow{Name: name, State: Tag{Status: "ok", Label: "running"},
|
|
Detail: "pid " + strconv.Itoa(41-i) + ", uptime 18 days"})
|
|
}
|
|
h.Certificate.End = Verdict("warn")
|
|
h.CertFacts = []Fact{
|
|
{Label: "Expires", Value: Plain("2026-10-21 09:14 UTC"), Mono: true},
|
|
{Label: "Names", Value: Plain("mail.example.org"), Mono: true},
|
|
}
|
|
h.CertDetail, h.CertProblem = "Expires in 12 day(s). Check that renewal on the host still works.", true
|
|
h.Sockets.End = Verdict("ok")
|
|
h.SocketRows = []SocketRow{
|
|
{Name: "OpenDKIM", Path: "inet:127.0.0.1:8891", State: Tag{Status: "ok"}},
|
|
{Name: "send-log", Path: "unix:/run/selfpost/journal.sock", State: Tag{Status: "ok"}},
|
|
}
|
|
h.Hostname.End = Verdict("ok")
|
|
h.HostFacts = []Fact{
|
|
{Label: "Hostname", Value: Plain("mail.example.org"), Mono: true},
|
|
{Label: "Lookup", Value: Rich("→ 203.0.113.25", Br(), "← mail.example.org"), Mono: true},
|
|
}
|
|
return h
|
|
}
|
|
|
|
// accountFixture is the mockup's Account: the global administrator, their
|
|
// e-mail as the DMARC default, and the authorization record that default needs.
|
|
func accountFixture() *Account {
|
|
m := admin()
|
|
a := NewAccount(m, "global", true, "mix@example.org")
|
|
a.Username, a.Email = "admin", "mix@example.org"
|
|
a.DMARCSelected = DMARCChoiceAccount
|
|
a.WithDomainUse(2, 3)
|
|
return a.WithAuthorization("*._report._dmarc.example.org", "v=DMARC1;", "ok", "", nil)
|
|
}
|
|
|
|
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
|
|
// messages an hour.
|
|
func settingsFixture() *Settings {
|
|
return NewSettings(admin(), "30", FormatRate(600, 3600))
|
|
}
|