Stage 2, step 3. Written by Sonnet, reviewed by Opus.
- Six pages named after their mockups: out-domains, out-domain,
out-domain-settings, out-app, out-app-created, out-domain-delete,
with typed page data (view/outbound.go, outbound_app.go), fixtures
and tests. The engine pages dashboard, domain_detail and
domain_delete are gone with their templates and are off
legacy_pages.txt. GET .../settings has its own handler.
- Handlers pass typed data; routes, POST targets, field names,
validation, authorization (both roles, another tenant's id is a 404)
and Cache-Control: no-store on the password page are unchanged.
- Kit, attribute only: Action.Confirm writes data-confirm on a POST
action's form (panel.js already reads it).
- test/e2e/panel_client.go scrapes the new markup (copy fields, the
application row's Edit link). Not run: no Docker here.
Fixes found on the way
- Report address of a domain that follows another user's default: the
select had one "inherit" option, selected, so saving the form
untouched moved the domain to whoever saved it. It now offers that
user's default as "keep" (selected, saves nothing) and "My default
instead" as the change, as the mockup draws it; "keep" on a domain
that does not follow someone else is refused.
- panel.js: syncShowWhen stopped at the first matching rule, so the
auto-limit fields were never toggled with the limit mode. Every
matching rule applies now.
Differences from the mockups, for the owner:
- application rows carry Edit only. New password and Delete are POSTs,
and the kit has no link-styled POST control for a table row; both are
in the application page's head with a confirmation. Drawing them in
the row needs a design: change (as Sign out was);
- the application login is read-only on edit (the handler refuses a
rename); the submit says Create application on the empty form;
- multiplier bounds are the store's 1.5-5, default 2.5;
- "Recalculate" buttons exist where the stored limit is automatic;
- a domain administrator has no add box and no Delete; Delete domain is
global-only on the domain page and on settings;
- a fourth record, Report authorization, appears when the report
address is on another domain; record states missing / weak / not
checked are tags with the explanation under the value;
- refusals are the kit flash above the first box, and those of the
settings forms now show on the settings page; successful saves still
redirect to the domain page;
- the export checkbox is ticked by default, as drawn.
Evidence
- docs/assets/panel-redesign/evidence/out-domains.png, out-domain.png,
out-domain-settings.png, out-app.png, out-app-created.png,
out-domain-delete.png: 1440 px wide, headless Edge, real templates
served locally with their fixtures - NOT from the container (no
Docker on this machine). Taken before the report-address fix, which
does not change what the fixture renders.
- panel/audit.js on the six pages at 1440 and 1280 (text inside
[hidden] blocks skipped): 866 texts checked, problems: [].
- 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>
209 lines
11 KiB
Go
209 lines
11 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() },
|
|
|
|
"out-domains": func() any { return outDomainsFixture() },
|
|
"out-domain": func() any { return outDomainFixture() },
|
|
"out-domain-settings": func() any { return outDomainSettingsFixture() },
|
|
"out-app": func() any { return outAppFixture() },
|
|
"out-app-created": func() any { return outAppCreatedFixture() },
|
|
"out-domain-delete": func() any { return outDomainDeleteFixture() },
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
|
|
// with a wrong SPF record and the last with a weak DMARC one.
|
|
func outDomainsFixture() *OutDomains {
|
|
dns := func(dkim, spf, dmarc string) []Tag {
|
|
return []Tag{{Status: dkim, Label: "DKIM"}, {Status: spf, Label: "SPF"}, {Status: dmarc, Label: "DMARC"}}
|
|
}
|
|
row := func(id, name string, tags []Tag, selector string, apps int, total, peak int64) OutDomainRow {
|
|
return OutDomainRow{Name: name, Href: "/outbound/domains/" + id, DeleteHref: "/outbound/domains/" + id + "/delete",
|
|
DNS: tags, Selector: selector, Apps: apps, Activity: FormatActivity(total, peak)}
|
|
}
|
|
return NewOutDomains(admin(), true, 30).WithRows([]OutDomainRow{
|
|
row("1", "example.org", dns("ok", "error", "ok"), "sp2026", 2, 1284, 96),
|
|
row("2", "shop.example.org", dns("ok", "ok", "ok"), "sp2026", 1, 18920, 410),
|
|
row("3", "notify.acme.io", dns("ok", "ok", "warn"), "sp2025", 3, 402, 22),
|
|
})
|
|
}
|
|
|
|
// outDomainFixture is the mockup's domain page: the DKIM and DMARC records
|
|
// published, the SPF one pointing at the wrong server, two applications.
|
|
func outDomainFixture() *OutDomain {
|
|
p := NewOutDomain(admin(), 1, "example.org", true)
|
|
p.WithStats(1284, 96, "1.8", 30)
|
|
p.WithRecords([]Record{
|
|
DKIMRecord("sp2026", "sp2026._domainkey.example.org",
|
|
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB",
|
|
DNSCheck{Status: "ok"}),
|
|
SPFRecord("example.org", "v=spf1 ip4:203.0.113.25 ~all", DNSCheck{Status: "error",
|
|
Detail: "The published record does not authorize 203.0.113.25. Add ip4:203.0.113.25 before ~all.",
|
|
Found: []string{"v=spf1 include:_spf.google.com ~all"}}),
|
|
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org",
|
|
Check: DNSCheck{Status: "ok"}, Source: "admin's default", SettingsHref: "/outbound/domains/1/settings",
|
|
ReportsHref: "/outbound/dmarc/domains/1"}),
|
|
}, "3 min ago")
|
|
p.WithApplications([]OutAppRow{
|
|
{Login: "prod-server", Senders: []string{"*@example.org"}, Activity: FormatActivity(1102, 96),
|
|
Limits: []Tag{{Status: "ok", Label: "200 / h"}}, Edit: "/outbound/domains/1/applications/1"},
|
|
{Login: "alerts", Senders: []string{"alerts@example.org", "noc@example.org"}, Activity: FormatActivity(182, 14),
|
|
Limits: []Tag{{Label: "domain"}, {Status: "ok", Label: "2 IPs"}}, Edit: "/outbound/domains/1/applications/2"},
|
|
})
|
|
p.WithConnection("mail.example.org", true)
|
|
p.WithSeeAlso("/outbound/log?domain=example.org", "/outbound/dmarc/domains/1")
|
|
p.WithRateLimit(true, false)
|
|
return p
|
|
}
|
|
|
|
// outDomainSettingsFixture is the mockup's Domain settings: reports go to the
|
|
// default of the signed-in administrator, the rate limit is automatic.
|
|
func outDomainSettingsFixture() *OutDomainSettings {
|
|
p := NewOutDomainSettings(admin(), 1, "example.org", 2, true)
|
|
p.WithReportAddress([]Option{
|
|
{Value: "inherit", Label: "admin's default — mix@example.org"},
|
|
{Value: "hosted", Label: "SelfPost hosted (example.org@dmarc.mail.example.org)"},
|
|
{Value: "none", Label: "No aggregate reports"},
|
|
{Value: "custom", Label: "Custom address"},
|
|
}, "inherit", "", Rich("A default belongs to a user and is set under their ", Link("/account#dmarc", "Account"),
|
|
"; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish."))
|
|
p.WithExport(12)
|
|
return p.WithRateLimit(DomainRateLimit{
|
|
Active: true, Auto: true, MaxMessages: "288", Window: "3600", Multiplier: "2.5",
|
|
Computed: "288", Peak: 96, Updated: "2026-10-09 03:00 UTC",
|
|
L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5",
|
|
})
|
|
}
|
|
|
|
// outAppFixture is the mockup's application form, filled with prod-server: any
|
|
// address of the domain, no client IPs, a manual limit of 200 an hour.
|
|
func outAppFixture() *OutApp {
|
|
p := NewOutApp(admin(), 1, "example.org", "prod-server")
|
|
p.WithApplication(1, 1, 1102, 96, "1.5", 30)
|
|
return p.WithForm(OutAppForm{
|
|
Login: "prod-server", Mode: AddressWildcard, LimitMode: LimitManual, MaxMessages: "200", Window: "3600", Multiplier: "2.5",
|
|
}, OutAppState{Limit: true}, AppRateLimit{L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5",
|
|
DefaultMultiplier: "2.5", DomainLimit: "288 / h", Peak: 96})
|
|
}
|
|
|
|
// outAppCreatedFixture is the mockup's password page: a new password for
|
|
// prod-server, which may send as the whole domain.
|
|
func outAppCreatedFixture() *OutAppCreated {
|
|
return NewOutAppCreated(admin(), 1, "example.org", "prod-server", "kT7v-Qm2x-Lp9d-Ue4s-Hn6b-Rz3w", false,
|
|
"mail.example.org", true, []string{"*@example.org"})
|
|
}
|
|
|
|
// outDomainDeleteFixture is the mockup's confirmation: the domain has the two
|
|
// applications that go with it.
|
|
func outDomainDeleteFixture() *OutDomainDelete {
|
|
return NewOutDomainDelete(admin(), 1, "example.org", []string{"prod-server", "alerts"})
|
|
}
|