audit 4: cards ordered by number, a notification owed to each recipient
test / test (push) Canceled after 0s

The cards layout (owner, 2026-10-13; the mockup first, b90a2f0): box_open
writes data-order for a box with InCards and a number, and panel.css orders
by it up to twelve - Health's eight and the kit's three.
TestCardsBoxesCarryTheirNumberAsTheirOrder reads the rendered pages.

Notifications (owner, 2026-10-14): migration 0011, notify_addressees. open
sends a subject's first message to every recipient and records who was
handed it; followUp, on every check while the subject is open and still
wrong, hands it to the owed and untold who are still recipients; closed
sends the end to the told (all recipients for a subject from before the
table). Tests: a failed copy goes out at the next check, once; the hourly
ceiling holds one back; a user added mid-subject hears nothing of it.

Also in the roadmap, from the owner today: dns-record-collapse,
notification-mail-recheck, settings-save-row (agreed, design first).

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 644 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 11:49:50 +03:00
co-authored by Claude Opus 5.5
parent b90a2f0321
commit eee089ad4b
17 changed files with 538 additions and 74 deletions
+71
View File
@@ -0,0 +1,71 @@
package view
import (
"net/http"
"net/http/httptest"
"os"
"regexp"
"strings"
"testing"
)
// On a phone the cards layout shows its boxes in the order of their numbers,
// whichever stack they stand in: each box of the layout carries its number as
// data-order, and the stylesheet has a rule for it. Before, the stylesheet
// ordered by a box's place in its stack and knew four to a stack; a fifth
// landed out of place with nothing failing (owner, 2026-10-13).
func TestCardsBoxesCarryTheirNumberAsTheirOrder(t *testing.T) {
css, err := os.ReadFile("static/panel.css")
if err != nil {
t.Fatal(err)
}
box := regexp.MustCompile(`<div class="box[^"]*"[^>]*?(?: data-order="(\d+)")?>\s*<div class="sp-box-head"><span class="sp-no">(\d+)</span>`)
rec := httptest.NewRecorder()
kitEngine(t).Render(rec, http.StatusOK, "components", KitPage())
for name, page := range map[string]string{
"health": renderSignedIn(t, "health", healthFixture(false)),
"kit": rec.Body.String(),
} {
start := strings.Index(page, `<div class="columns sp-cards">`)
if start < 0 {
t.Fatalf("%s has no cards layout", name)
}
// The layout runs to the first page part after it that is not a box
// of it; its boxes are the numbered ones up to there.
region := page[start:]
if end := strings.Index(region, `<footer`); end > 0 {
region = region[:end]
}
found := 0
for _, m := range box.FindAllStringSubmatch(region, -1) {
order, no := m[1], strings.TrimLeft(m[2], "0")
if name == "kit" && found == 3 {
break // the kit's cards hold three numbered boxes; the rest of the page is other sections
}
found++
if order != no {
t.Errorf("%s: box %s carries data-order %q", name, m[2], order)
continue
}
if !strings.Contains(string(css), `.sp-cards [data-order="`+order+`"] { order: `+order+`; }`) {
t.Errorf("%s: panel.css has no rule for data-order %s", name, order)
}
}
if found == 0 {
t.Errorf("%s: no numbered boxes in the cards layout", name)
}
}
}
// Outside the cards layout a box has no order: the attribute is the layout's.
func TestABoxOutsideCardsHasNoOrder(t *testing.T) {
if got := (Box{No: "03"}).Order(); got != "" {
t.Errorf("a box outside cards has order %q", got)
}
if got := (Box{No: "03", InCards: true}).Order(); got != "3" {
t.Errorf("a cards box 03 has order %q", got)
}
if got := (Box{Icon: "ti-x", InCards: true}).Order(); got != "" {
t.Errorf("a cards box without a number has order %q", got)
}
}
+21
View File
@@ -12,6 +12,7 @@ package view
import (
"fmt"
"html/template"
"strconv"
"strings"
)
@@ -301,6 +302,26 @@ type Box struct {
OOB bool
// DomainPick: see Box.
DomainPick bool
// InCards is a box of the cards layout, which orders its boxes on a
// phone by their numbers (Order).
InCards bool
}
// Order is a cards box's number as the layout orders it on a phone
// (data-order, panel.css: the boxes come in the order of their numbers
// whichever stack they stand in): "3" for "03", empty for a box with no
// number or outside the layout. Before, the stylesheet ordered by a box's place in its stack and
// knew four to a stack; a fifth landed out of place with nothing failing
// (owner, 2026-10-13).
func (b Box) Order() string {
if !b.InCards {
return ""
}
n, err := strconv.Atoi(b.No)
if err != nil || n < 1 {
return ""
}
return strconv.Itoa(n)
}
// HelpLink is a Box's link to its Help topic.
+8 -8
View File
@@ -139,16 +139,16 @@ func NewHealth(m Meta, flash string) *Health {
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: "/server/health/recheck"}},
},
// Numbered by importance; the odd ones are the left stack.
Processes: Box{No: "01", Title: "Processes", ID: "processes"},
Sockets: Box{No: "02", Title: "Milter sockets", ID: "sockets"},
SenderCheck: Box{No: "03", Title: "Sender check", ID: "sender-check",
Processes: Box{No: "01", InCards: true, Title: "Processes", ID: "processes"},
Sockets: Box{No: "02", InCards: true, Title: "Milter sockets", ID: "sockets"},
SenderCheck: Box{No: "03", InCards: true, Title: "Sender check", ID: "sender-check",
Help: topicLink(HelpSenderCheck, "What is checked and what these numbers are for")},
Certificate: Box{No: "04", Title: "TLS certificate", ID: "certificate", Help: topicLink(HelpCertificate, "When it warns and what to do")},
Hostname: Box{No: "05", Title: "Hostname and reverse DNS", ID: "hostname", Help: topicLink(HelpRDNS, "Forward and reverse DNS")},
NotifyMail: Box{No: "06", Title: "Notification mail", ID: "notification-mail",
Certificate: Box{No: "04", InCards: true, Title: "TLS certificate", ID: "certificate", Help: topicLink(HelpCertificate, "When it warns and what to do")},
Hostname: Box{No: "05", InCards: true, Title: "Hostname and reverse DNS", ID: "hostname", Help: topicLink(HelpRDNS, "Forward and reverse DNS")},
NotifyMail: Box{No: "06", InCards: true, Title: "Notification mail", ID: "notification-mail",
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
Machine: Box{No: "07", Title: "Machine", ID: "machine", Help: topicLink(HelpChecks, "What these readings mean")},
Configuration: Box{No: "08", Title: "Configuration", ID: "configuration",
Machine: Box{No: "07", InCards: true, Title: "Machine", ID: "machine", Help: topicLink(HelpChecks, "What these readings mean")},
Configuration: Box{No: "08", InCards: true, Title: "Configuration", ID: "configuration",
End: Plain("Safe to run at any time")},
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
}
+3 -3
View File
@@ -86,14 +86,14 @@ func KitPage() *Kit {
{Label: "signed out", Value: Rich("The split brand / form screen, no navbar. ", Link("/login", "Example"))},
},
Postmark: Box{No: "01", Title: "postmark", End: Plain("Worst status of the page's checks")},
Postmark: Box{No: "01", InCards: true, Title: "postmark", End: Plain("Worst status of the page's checks")},
Postmarks: []Postmark{
{Top: "SELFPOST", Word: "OK", Bottom: "6 OF 6 CHECKS", Level: LevelOK},
{Top: "SELFPOST", Word: "WARN", Bottom: "1 OF 6 CHECKS", Level: LevelWarn},
{Top: "DNS", Word: "FAIL", Bottom: "1 OF 3 RECORDS", Level: LevelFail},
},
StatusTag: Box{No: "03", Title: "status_tag", End: Plain("One mapping, one function")},
StatusTag: Box{No: "03", InCards: true, Title: "status_tag", End: Plain("One mapping, one function")},
Sheet: Sheet{ID: "kit-sheet", Tag: Tag{Status: "reject"}, Title: "sheet", Rows: []SheetRow{
{Label: "What it holds", Lines: []string{"What does not fit the row, as name and value"}},
{Label: "How it closes", Lines: []string{"Esc", "a click outside", "the cross"}, Mono: true},
@@ -106,7 +106,7 @@ func KitPage() *Kit {
{"held: quarantine · role: global", Tag{Status: "quarantine"}},
},
BoxHead: Box{No: "02", Title: "box · box_head",
BoxHead: Box{No: "02", InCards: true, Title: "box · box_head",
End: Plain("The end slot: a note, a tag, a link or a filter form"),
Help: &HelpLink{Href: "/help#checks", Title: "Optional: the section's Help topic"}},
BoxFoot: Foot{Text: Plain("box_foot — a legend, paging"), End: Plain("end slot")},
+14 -9
View File
@@ -329,20 +329,25 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.sp-cards { display: flex; flex-direction: column; padding-inline: .75rem; }
.sp-cards > .column { display: contents; }
.sp-cards .box { margin-bottom: 1.5rem; }
.sp-cards > .column:first-child > .box:nth-child(1) { order: 1; }
.sp-cards > .column:first-child > .box:nth-child(2) { order: 3; }
.sp-cards > .column:first-child > .box:nth-child(3) { order: 5; }
.sp-cards > .column:first-child > .box:nth-child(4) { order: 7; }
.sp-cards > .column:last-child > .box:nth-child(1) { order: 2; }
.sp-cards > .column:last-child > .box:nth-child(2) { order: 4; }
.sp-cards > .column:last-child > .box:nth-child(3) { order: 6; }
.sp-cards > .column:last-child > .box:nth-child(4) { order: 8; }
.sp-cards [data-order="1"] { order: 1; }
.sp-cards [data-order="2"] { order: 2; }
.sp-cards [data-order="3"] { order: 3; }
.sp-cards [data-order="4"] { order: 4; }
.sp-cards [data-order="5"] { order: 5; }
.sp-cards [data-order="6"] { order: 6; }
.sp-cards [data-order="7"] { order: 7; }
.sp-cards [data-order="8"] { order: 8; }
.sp-cards [data-order="9"] { order: 9; }
.sp-cards [data-order="10"] { order: 10; }
.sp-cards [data-order="11"] { order: 11; }
.sp-cards [data-order="12"] { order: 12; }
}
/* cards: two stacks of boxes that are read in rows - the odd ones on the left,
the even ones on the right, each stack closed up so a short box leaves no
gap beside a tall one. On a phone the stacks dissolve and the boxes come in
the order of their numbers (the rules above). */
the order of their numbers: each box carries data-order, its own number (the
rules above, up to twelve), wherever it stands in its stack. */
/* sheet: what does not fit a table row, opened over the page from the row's
tag. It is the browser's own popover (the tag is a button with
+4 -4
View File
@@ -84,7 +84,7 @@
sp-credential (a secret shown once). A box foot, a table in its
table-container, a run of records, a log or an empty state sit directly
inside; anything else goes in body_open … body_close. */}}
{{define "box_open"}}<div class="box{{if eq .Variant "danger"}} sp-danger-zone{{else if eq .Variant "credential"}} sp-credential{{end}}"{{if .ID}} id="{{.ID}}"{{end}}{{if .OOB}} hx-swap-oob="true"{{end}}{{if .Poll}} data-poll aria-live="polite" hx-get="{{.Poll}}" hx-trigger="load" hx-swap="outerHTML"{{end}}{{if .DomainPick}} data-domain-pick{{end}}>
{{define "box_open"}}<div class="box{{if eq .Variant "danger"}} sp-danger-zone{{else if eq .Variant "credential"}} sp-credential{{end}}"{{if .ID}} id="{{.ID}}"{{end}}{{if .OOB}} hx-swap-oob="true"{{end}}{{if .Poll}} data-poll aria-live="polite" hx-get="{{.Poll}}" hx-trigger="load" hx-swap="outerHTML"{{end}}{{if .DomainPick}} data-domain-pick{{end}}{{with .Order}} data-order="{{.}}"{{end}}>
{{template "box_head" .}}
{{end}}
{{define "box_close"}}</div>{{end}}
@@ -93,9 +93,9 @@
`column is-6` stacks of numbered boxes that are read in rows, the odd ones
on the left and the even ones on the right, each stack closed up so that a
short box leaves no gap beside a tall one. On a phone the stacks dissolve
and the boxes come in the order of their numbers (panel.css) - which
knows four boxes to a stack and takes every child of a stack for a box:
a fifth, or a flash put between them, needs the rules extended first. */}}
and the boxes come in the order of their numbers: box_open writes each
numbered box's number as data-order, which panel.css orders by, up to
twelve. */}}
{{define "cards_open"}}<div class="columns sp-cards">{{end}}
{{define "cards_close"}}</div>{{end}}