panel-notifications: Account, Server > Settings, Server > Health, help; e2e
test / test (push) Canceled after 0s

The pages of the mockups accepted in 87fa0a4, built from the kit's partials
(written by a Sonnet agent into the working tree, reviewed here):

- Account: "Send me notifications" in the Profile form, saved with the
  profile; an unticked box is off.
- Server > Settings: the instance switch (box 03), and box 04 Notification
  mail with the DKIM and SPF records of the server's own name, drawn with the
  record partial of a domain's DNS page and checked in DNS. Sending rate
  limits are box 02 in the left column. A record that is not in place is
  tagged as a warning, not a failure: notifications are sent without it.
- Server > Health: card 06 Notification mail with the state of each record
  and a link to Settings, beside Configuration (07); part of the polled
  fragment; Re-check DNS forces it too. Overview does not run this check.
- help.go: the Notifications topic and the clauses in Settings and Your
  account, word for word with help.html.

If the server's key cannot be read the pages still render: the DKIM record
is left out and SPF is shown unchecked.

e2e: the administrator gets a mailbox behind the sink and the server's DKIM
record from Settings is published; the level-2 refusal of the next step must
then arrive as a notification from postmaster@mail.e2e.test, signed as that
name with the key Settings shows, and must not be a row of the Outbound log.
The rotation step now waits for Postfix to be running: it follows a step that
restarts the container, and logrotate's `postfix reload` failed in between.

On the test server, for this tree: go vet clean, go test ./... 23 packages
ok, make e2e ok (147 s); the notification reached the sink 28 s after the
refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-12 16:21:48 +03:00
co-authored by Claude Opus 5.5
parent c9027381e5
commit b905f3e560
22 changed files with 1003 additions and 59 deletions
+34
View File
@@ -19,6 +19,9 @@ type Account struct {
Profile Box
Username string
Email string
// Notify is whether the panel writes to this user: the checkbox under the
// e-mail.
Notify bool
Password Box
}
@@ -63,6 +66,18 @@ type Settings struct {
RateLimits Box
RateFacts []Fact
// Notifications is the instance switch for notification e-mails; Notify is
// its state and NotifyNote the line under it, which says from where and to
// whom.
Notifications Box
Notify bool
NotifyNote Text
// Mail holds the DNS records of the server's own name, which notifications
// are signed and authorised with, drawn like a domain's.
Mail Box
MailRecords []Record
}
// NewSettings builds the page. retention is what the field shows (the stored
@@ -87,9 +102,28 @@ func NewSettings(m Meta, retention, level1 string) *Settings {
{Label: "Level 2 · domain, application",
Value: Rich("Set on each ", Link("/outbound/domains", "domain"), " and on each application of a domain.")},
},
Notifications: Box{No: "03", Title: "Notifications", ID: "notifications", Help: topicLink(HelpNotifications, "What is sent and when")},
Mail: Box{No: "04", Title: "Notification mail", ID: "notification-mail", End: Plain("DNS records of the server's own name"),
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
}
}
// WithNotifications sets the instance switch: on is whether notification
// e-mails are sent at all, from is the address they come from.
func (s *Settings) WithNotifications(on bool, from string) *Settings {
s.Notify = on
s.NotifyNote = Rich("From ", Mono(from), ", to every user who has an e-mail and has not turned them off under ",
Link("/account", "Account"), ".")
return s
}
// WithMail sets the records of the server's own name, in the order they are
// shown (ServerMailDKIM, ServerMailSPF).
func (s *Settings) WithMail(records []Record) *Settings {
s.MailRecords = records
return s
}
// WithInbound adds the retention of the Inbound log to the Log retention box.
// days is what the field shows, like retention in NewSettings. The field exists
// only while the inbound relay is on: with it off there is no journal to keep.
+19 -3
View File
@@ -2,6 +2,7 @@ package view
import (
"strconv"
"strings"
"time"
)
@@ -119,6 +120,10 @@ func healthFixture(refresh bool) *Health {
{Label: "Hostname", Value: Plain("mail.example.org"), Mono: true},
{Label: "Lookup", Value: Rich("→ 203.0.113.25", Br(), "← mail.example.org"), Mono: true},
}
h.WithNotificationMail("postmaster@mail.example.org", []MailRow{
NewMailRow("DKIM", mailFixtureDKIMHost, DNSCheck{Status: "ok"}),
NewMailRow("SPF", "mail.example.org", DNSCheck{Status: "error"}),
}, "warn")
return h
}
@@ -126,14 +131,25 @@ func healthFixture(refresh bool) *Health {
// and e-mail.
func accountFixture() *Account {
a := NewAccount(admin(), "global")
a.Username, a.Email = "admin", "mix@example.org"
a.Username, a.Email, a.Notify = "admin", "mix@example.org", true
return a
}
// mailFixtureDKIMHost is where the mockup publishes the DKIM key of the server's
// own name.
const mailFixtureDKIMHost = "sp2026._domainkey.mail.example.org"
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
// messages an hour.
// messages an hour, notifications on, and the records of the server's own name
// with the DKIM one published and the SPF one missing.
func settingsFixture() *Settings {
return NewSettings(admin(), "30", FormatRate(600, 3600)).WithInbound("90")
key := "v=DKIM1; k=rsa; p=" + strings.Repeat("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A", 10) + "IDAQAB"
return NewSettings(admin(), "30", FormatRate(600, 3600)).WithInbound("90").
WithNotifications(true, "postmaster@mail.example.org").
WithMail([]Record{
ServerMailDKIM("sp2026", mailFixtureDKIMHost, key, DNSCheck{Status: "ok"}),
ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "error", Detail: "No SPF record is published for mail.example.org."}),
})
}
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
+41 -5
View File
@@ -30,10 +30,19 @@ type Health struct {
CertDetail string
CertProblem bool
Sockets Box
SocketRows []SocketRow
Hostname Box
HostFacts []Fact
Sockets Box
SocketRows []SocketRow
Hostname Box
HostFacts []Fact
// NotifyMail is the card of the DNS records notifications are signed and
// authorised with (their values are under Server › Settings); NotifyNote
// is the line under its table. It is polled with the rest, so Refresh swaps
// it into place on its own.
NotifyMail Box
NotifyRows []MailRow
NotifyNote Text
Configuration Box
Reload Action
}
@@ -87,6 +96,20 @@ type SocketRow struct {
Detail string
}
// MailRow is one record of the Notification mail table: its name, the host it
// is published at and its state as a tag.
type MailRow struct {
Name string
Host string
State Tag
}
// NewMailRow is the row of one record of the server's own name, from what the
// DNS check found. A record that is not in place is a warning, as on Settings.
func NewMailRow(name, host string, c DNSCheck) MailRow {
return MailRow{Name: name, Host: host, State: mailTag(c)}
}
// NewHealth builds the page around its content. The handler fills the tables;
// the boxes, the head and the reload action are the page's own.
func NewHealth(m Meta, flash string) *Health {
@@ -104,7 +127,9 @@ func NewHealth(m Meta, flash string) *Health {
Certificate: Box{No: "03", Title: "TLS certificate", ID: "certificate", Help: topicLink(HelpCertificate, "When it warns and what to do")},
Sockets: Box{No: "04", Title: "Milter sockets", ID: "sockets"},
Hostname: Box{No: "05", Title: "Hostname and reverse DNS", ID: "hostname", Help: topicLink(HelpRDNS, "Forward and reverse DNS")},
Configuration: Box{No: "06", Title: "Configuration", ID: "configuration",
NotifyMail: Box{No: "06", Title: "Notification mail", ID: "notification-mail",
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
Configuration: Box{No: "07", Title: "Configuration", ID: "configuration",
End: Plain("Safe to run at any time")},
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
}
@@ -114,5 +139,16 @@ func NewHealth(m Meta, flash string) *Health {
return h
}
// WithNotificationMail fills the Notification mail card: from is the address
// notifications come from, rows the state of each record, and status the
// card's verdict for its head.
func (h *Health) WithNotificationMail(from string, rows []MailRow, status string) *Health {
h.NotifyMail.End = Verdict(status)
h.NotifyRows = rows
h.NotifyNote = Rich("Sent from ", Mono(from), ". What to publish is under ",
Link("/server/settings#notification-mail", "Settings"), ".")
return h
}
// Verdict puts a check's status in the end slot of its box, as the mockup does.
func Verdict(status string) Text { return Text{TagOf(status, "")} }
+13 -2
View File
@@ -59,6 +59,9 @@ const (
HelpBackup = "backup"
HelpUsers = "users"
HelpSettings = "settings"
// HelpNotifications is what the panel writes to its users and what makes
// that mail arrive; Account, Settings and Health link to it.
HelpNotifications = "notifications"
// Account
HelpAccount = "account"
)
@@ -121,6 +124,7 @@ func NewHelp(m Meta, inbound bool) *Help {
{Label: "Backup and moving a domain", Icon: "ti-archive", Href: "#" + HelpBackup},
{Label: "Users and roles", Icon: "ti-users", Href: "#" + HelpUsers},
{Label: "Settings", Icon: "ti-settings", Href: "#" + HelpSettings},
{Label: "Notifications", Icon: "ti-mail", Href: "#" + HelpNotifications},
})
}
add("Account", accountTopics, []MenuItem{
@@ -233,12 +237,19 @@ var serverTopics = []HelpTopic{
Rich("Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast ",
Code("/data"), " grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of ", Code("mail.log"),
", which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in ",
Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it."),
Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone."),
}},
{ID: HelpNotifications, Title: "Notifications", Body: []Text{
Rich("The panel writes to its users when something needs them, from ", Code("postmaster@"), " the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.",
Br(), Br(),
"Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.",
Br(), Br(),
"For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, ", Code("v=spf1 a -all"), ". Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either."),
}},
}
var accountTopics = []HelpTopic{
{ID: HelpAccount, Title: "Your account", Body: []Text{
Plain("Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected."),
Plain("Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected."),
}},
}
+35
View File
@@ -178,6 +178,41 @@ func SPFRecord(domain, value string, c DNSCheck) Record {
return r
}
// mailTag is the tag of a record of the server's own name. The records are not
// needed for notifications to be sent, only for them to arrive, so a record
// that is missing, wrong or weak is a warning here and never the red a
// sending domain's would be; published and not checked keep their meaning.
func mailTag(c DNSCheck) Tag {
t := DNSStatusTag(c.Status, len(c.Found) > 0)
if tagLevel(t.Status) == LevelFail {
t.Status = "warn"
}
return t
}
// ServerMailDKIM is the DKIM record of the server's own name, which the panel
// signs its notifications with: a domain's DKIM record in every other respect.
func ServerMailDKIM(selector, host, value string, c DNSCheck) Record {
r := DKIMRecord(selector, host, value, c)
r.Status = mailTag(c)
return r
}
// ServerMailSPF is the SPF record of the server's own name. Without it
// notifications are still sent, which is said under the value while it is not
// in place.
func ServerMailSPF(host, value string, c DNSCheck) Record {
r := Record{Name: "SPF", Note: Plain("For the server's own name, not for your domains"),
Host: host, Type: "TXT", Value: value}
var standing Text
if c.Status != "ok" {
standing = Plain("Notifications are sent without it, and a receiver may file them as spam.")
}
applyCheck(&r, c, standing, nil)
r.Status = mailTag(c)
return r
}
// DMARCRecordInput is what a DMARC record needs besides its check: where the
// reports go in words (Source: the address, or "no reports") and the page that changes it,
// the page of the reports themselves ("" when the panel has none), and whether
+99 -24
View File
@@ -148,7 +148,7 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
// the two actions: Re-check DNS in the head, Reload configuration under the tables
`action="/server/health/recheck"`, `action="/server/health/reload"`,
// the anchors the Overview cards lead to
`id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="configuration"`,
`id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="notification-mail"`, `id="configuration"`,
// the machine card: bars carry their reading in an attribute (the CSP rules out sizing them with a style)
`<progress class="progress is-small is-success mb-1" value="12" max="100">`, `value="41"`,
`↓ 18.0 KiB/s`, `eth0: 2.1 GiB in, 6.4 GiB out`,
@@ -158,8 +158,8 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
`hx-get="/server/health/fragment"`,
)
pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`)
if got := strings.Count(out, `class="columns"`); got != 2 {
t.Errorf("Health has %d rows of boxes, want 2", got)
if got := strings.Count(out, `class="columns"`); got != 3 {
t.Errorf("Health has %d rows of boxes, want 3", got)
}
// The Configuration box is outside the polled rows: a refresh must not re-render a control.
if strings.Index(out, `id="configuration"`) < strings.Index(out, `id="health-checks"`) ||
@@ -169,29 +169,54 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
}
// The fragment is the polled part of the page and nothing else: the same rows,
// the second one marked to be swapped in by its id, no shell and no actions.
// the second one marked to be swapped in by its id, and the Notification mail
// card, a column of the last row, swapped in the same way. No shell and no
// actions.
func TestHealthFragmentIsThePolledRows(t *testing.T) {
page := renderSignedIn(t, "health", healthFixture(false))
frag := renderFragment(t, "health_body", healthFixture(true))
const card = `<div class="column is-6" id="health-mail"`
// The part of s before the card, and the card itself up to what follows it
// on the page (the Configuration box).
rows := func(s string) string {
a := strings.Index(s, `<div class="columns" id="health-machine"`)
b := strings.Index(s, `id="configuration"`)
if b < 0 {
b = len(s)
}
return s[a:b]
return s[strings.Index(s, `<div class="columns" id="health-machine"`):strings.Index(s, card)]
}
// Same markup, apart from the out-of-band mark and what follows the rows on the page.
got := strings.Replace(strings.TrimSpace(frag), ` hx-swap-oob="true"`, "", 1)
want := strings.TrimSpace(rows(page))
if !strings.HasPrefix(want, got) {
mail := func(s string) string {
a := strings.Index(s, card)
if b := strings.Index(s, `id="configuration"`); b > a {
return s[a:b]
}
return s[a:]
}
// Same markup, apart from the out-of-band marks and what follows the card on the page.
oob := func(s string) string { return strings.ReplaceAll(strings.TrimSpace(s), ` hx-swap-oob="true"`, "") }
if got, want := oob(rows(frag)), oob(rows(page)); !strings.HasPrefix(want, got) {
t.Errorf("the fragment is not the page's polled rows\nfragment: %.400s\npage: %.400s", got, want)
}
pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`)
if got, want := oob(mail(frag)), oob(mail(page)); !strings.HasPrefix(want, got) {
t.Errorf("the fragment's Notification mail card is not the page's\nfragment: %.400s\npage: %.400s", got, want)
}
pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`,
`id="health-mail" hx-swap-oob="true"`, `id="notification-mail"`)
pageLacks(t, "fragment", frag, `<h1`, `navbar`, `action="/server/health/reload"`, `action="/server/health/recheck"`)
}
// The Notification mail card says which record is in place and which is not,
// with the host each is published at; a missing record is a warning, since
// notifications are sent without it. The way to the values is a link.
func TestHealthNotificationMailCard(t *testing.T) {
out := renderSignedIn(t, "health", healthFixture(false))
pageHas(t, "Health", out,
`<h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div>`,
`href="/help#notifications"`,
`DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span>`,
`SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
`Sent from <span class="sp-mono">postmaster@mail.example.org</span>`,
`href="/server/settings#notification-mail"`,
)
}
// A machine whose counters could not be read keeps its rows: the figure becomes
// a dash and the reason stays in the detail column.
func TestHealthMachineWithoutReadings(t *testing.T) {
@@ -289,15 +314,17 @@ func TestAccountEscapesTheFormValues(t *testing.T) {
pageLacks(t, "Account", out, `<script>1`, `"onfocus="x`)
}
// Event notifications are not built (roadmap: panel-notifications), and a page
// carries no box for a feature that does not exist.
func TestAccountAndSettingsHaveNoNotificationsBox(t *testing.T) {
for name, out := range map[string]string{
"Account": renderSignedIn(t, "account", accountFixture()),
"Settings": renderSignedIn(t, "settings", settingsFixture()),
} {
pageLacks(t, name, out, `<h2>Notifications</h2>`, `type="checkbox"`, `/account/notifications`, `name="notify"`)
}
// The checkbox under the e-mail is the user's own switch for notifications: it
// shows what is stored, and an unchecked box is the absence of the attribute.
func TestAccountShowsTheNotifyCheckbox(t *testing.T) {
a := accountFixture()
out := renderSignedIn(t, "account", a)
pageHas(t, "Account", out, `<input type="checkbox" name="notify" checked> Send me notifications`, `href="/help#notifications"`)
a.Notify = false
out = renderSignedIn(t, "account", a)
pageHas(t, "Account", out, `<input type="checkbox" name="notify"> Send me notifications`)
pageLacks(t, "Account", out, `name="notify" checked`)
}
// ---- Settings
@@ -310,6 +337,54 @@ func TestSettingsIsOneFormWithOneSaveButton(t *testing.T) {
pageHas(t, "Settings", out, `Save settings`, `<h1 class="title is-3">Settings</h1>`, `href="/account"`)
}
// Notifications are one switch for the instance, saved with the rest of the
// page, and the records of the server's own name are drawn like a domain's.
func TestSettingsNotificationsSwitchAndRecords(t *testing.T) {
s := settingsFixture()
out := renderSignedIn(t, "settings", s)
pageHas(t, "Settings", out,
`<input type="checkbox" name="notify" checked> Send notification e-mails`,
`From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="/account">Account</a>.`,
`id="notification-mail"`, `href="/help#notifications"`,
// DKIM is published; SPF is not, and says what that costs.
`<h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small>`,
`<h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server&#39;s own name, not for your domains</small>`,
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`,
`Notifications are sent without it, and a receiver may file them as spam.`,
)
// Switched off, and both records in place: the box is unchecked and the
// warning under SPF is gone.
s.Notify = false
s.WithMail([]Record{
ServerMailDKIM("sp2026", mailFixtureDKIMHost, "v=DKIM1; k=rsa; p=AAAA", DNSCheck{Status: "ok"}),
ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "ok"}),
})
out = renderSignedIn(t, "settings", s)
pageHas(t, "Settings", out, `<input type="checkbox" name="notify"> Send notification e-mails`,
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`)
pageLacks(t, "Settings", out, `name="notify" checked`, `a receiver may file them as spam`)
}
// A record of the server's own name that is wrong or missing is a warning, never
// the red of a sending domain's: notifications are sent without it.
func TestMailRecordsAreWarningsNotErrors(t *testing.T) {
for _, c := range []struct {
check DNSCheck
want Tag
}{
{DNSCheck{Status: "ok"}, Tag{Status: "published", Label: "published"}},
{DNSCheck{Status: "error"}, Tag{Status: "warn", Label: "missing"}},
{DNSCheck{Status: "error", Found: []string{"v=spf1 -all"}}, Tag{Status: "warn", Label: "mismatch"}},
{DNSCheck{Status: "warn"}, Tag{Status: "warn", Label: "weak"}},
{DNSCheck{Status: "unknown"}, Tag{Status: "unknown", Label: "not checked"}},
} {
if got := NewMailRow("SPF", "mail.example.org", c.check).State; got != c.want {
t.Errorf("a record that checked as %+v is %+v, want %+v", c.check, got, c.want)
}
}
}
func TestSettingsShowsTheRefusalAndKeepsWhatWasTyped(t *testing.T) {
s := NewSettings(admin(), "3", FormatRate(600, 3600)).WithResult("", "Send log retention must be between 7 and 365.")
out := renderSignedIn(t, "settings", s)
+2
View File
@@ -14,6 +14,8 @@
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" autocapitalize="none" spellcheck="false"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="notify"{{if .Notify}} checked{{end}}> Send me notifications</label>
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="/help#notifications">What is sent and when</a></p></div>
<button type="submit" class="button is-primary">Save profile</button>
</form>
{{template "body_close"}}
+14 -8
View File
@@ -1,17 +1,23 @@
{{/* Server > Health (mockup health.html): every check behind the cards of
Overview, and the one action that is not about DNS, Reload configuration. The
two rows of tables are the polled part (health_body.html); the head and the
Configuration box are static. */}}
two rows of tables are the polled part (health_body.html), and so is the
Notification mail card (health_mail), which sits in the last row beside the
static Configuration box. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
{{template "health_body" .}}
{{template "box_open" .Configuration}}
{{template "body_open"}}
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
{{template "action" .Reload}}
{{template "body_close"}}
{{template "box_close"}}
<div class="columns">
{{template "health_mail" .}}
<div class="column is-6">
{{template "box_open" .Configuration}}
{{template "body_open"}}
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
{{template "action" .Reload}}
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{end}}
+20 -1
View File
@@ -2,7 +2,9 @@
fragment (GET /server/health/fragment) alike. panel.js polls the first row
(data-poll, outerHTML) and htmx swaps the second into place by its id when
the fragment marks it out of band (.Refresh) — the two rows are siblings, and
a fragment can replace only the element that asked for it. */}}
a fragment can replace only the element that asked for it. The Notification
mail card is the third thing the fragment swaps in by id: it sits in a row of
the page with the static Configuration box, so it is a column of its own. */}}
{{define "health_body"}}<div class="columns" id="health-machine" data-poll aria-live="polite" hx-get="/server/health/fragment" hx-trigger="load" hx-swap="outerHTML">
<div class="column is-6">
{{template "box_open" .Machine}}
@@ -58,4 +60,21 @@
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>{{if .Refresh}}
{{template "health_mail" .}}{{end}}{{end}}
{{/* health_mail is the Notification mail card (the page calls it inside its own
row; the fragment sends it out of band). */}}
{{define "health_mail"}}<div class="column is-6" id="health-mail"{{if .Refresh}} hx-swap-oob="true"{{end}}>
{{template "box_open" .NotifyMail}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Record</th><th>State</th></tr></thead>
<tbody>{{range .NotifyRows}}
<tr><td>{{.Name}}<br><span class="sp-muted sp-mono sp-small">{{.Host}}</span></td><td>{{template "tag" .State}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "body_open"}}
<p class="sp-muted">{{template "inline" .NotifyNote}}</p>
{{template "body_close"}}
{{template "box_close"}}
</div>{{end}}
+17 -4
View File
@@ -1,6 +1,8 @@
{{/* Server > Settings (mockup settings.html): what holds for the whole instance.
One form, one Save button; the rate limits are read-only facts. A user's own
name, e-mail, password and DMARC address are on Account (account.html). */}}
One form, one Save button; the rate limits are read-only facts, and the
records of the server's own name are the ones notifications are signed and
authorised with. A user's own name, e-mail, password and DMARC address are on
Account (account.html). */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
@@ -19,15 +21,26 @@
{{end}}
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-6">
{{template "box_open" .RateLimits}}
{{template "body_open"}}
{{template "facts" .RateFacts}}
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-6">
{{template "box_open" .Notifications}}
{{template "body_open"}}
<div class="field"><label class="checkbox"><input type="checkbox" name="notify"{{if .Notify}} checked{{end}}> Send notification e-mails</label>
<p class="help">{{template "inline" .NotifyNote}}</p></div>
{{template "body_close"}}
{{template "box_close"}}
{{template "box_open" .Mail}}
{{range .MailRecords}}{{template "dns_record" .}}
{{end}}{{template "box_close"}}
</div>
</div>
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
</form>