local-time: the panel in the container's zone, named on Settings
Agreed by the owner on 2026-10-13 (the local day, no "UTC" beside a
time); the zone is the container's TZ in .env and Settings only shows it,
his "Да" of 2026-10-17. Mockups c205106.
Every time the panel shows is formatted in time.Local and says no zone:
the logs' Time and the DMARC Received columns, a message's timeline,
Overview's "Updated", Health's certificate and renewal, the filter lists,
DMARC reports and windows, the auto limit's last recalculation. Stored
instants stay UTC. The days counted are the zone's: delivery_log_days and
sender_check_days key their rows by the local date, the sender check cuts
downtime at local midnights (byDay), the log filters' days run from local
midnight to local midnight. Notification mail and the test message write
the zone's abbreviation after a time - they are read away from the panel.
DMARC's one-day window label stays the reporter's (UTC) day.
Settings box 05, Time zone, read-only: the name (UTC without TZ), its
offset, and a TZ the image cannot load said to leave the times in UTC
(time/zoneinfo_unix.go, initLocal) rather than named. TZ is passed by the
compose file ("${TZ:-}"), in .env.example and the guide's table; tzdata is
named in the image (supervisor's python3.13 brings it today). Postfix
takes TZ through import_environment, so mail.log's stamps are the same
zone, which closes the trap the roadmap wrote down: the tailer has always
read them in time.Local.
Tests: a TestMain runs view, handlers, store and cmd/panel in UTC whatever
the machine's zone (as logtail's); TestSettingsShowsTheTimeZone,
TestTimesAreTheContainersZone, TestByDayInTheContainersZone, a Moscow day
in TestQueryInboundSpamFilters; envdoc's pattern takes two-letter names.
e2e time_zone_is_the_containers recreates the stand with
TZ=Europe/Moscow: date says MSK, Settings names the zone and UTC+03:00, a
test message's page gives its time and its mail.log lines in Moscow's.
go vet + go test (26 packages) and make e2e on the test server for this
tree.
Docs: guide (§ Settings, the TZ row, the days of Health and the logs),
architecture, schema-migrations, help (word for word with the mockup),
CHANGELOG [Unreleased] § Added, roadmap row done with the decision.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,12 @@ package view
|
||||
// user's own — who they are to the panel and their password — and every role
|
||||
// has it. Settings is the instance's.
|
||||
|
||||
import "strconv"
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Account is the data of the Account page. Two forms, each its own POST: the
|
||||
// profile (Username, Email) and the password. The fields hold what was stored,
|
||||
@@ -78,6 +83,11 @@ type Settings struct {
|
||||
// are signed and authorised with, drawn like a domain's.
|
||||
Mail Box
|
||||
MailRecords []Record
|
||||
|
||||
// TimeZone names the container's zone, which every time and day the
|
||||
// panel shows is in; it is set in .env, so the box only shows it.
|
||||
TimeZone Box
|
||||
ZoneFacts []Fact
|
||||
}
|
||||
|
||||
// NewSettings builds the page. retention is what the field shows (the stored
|
||||
@@ -106,6 +116,7 @@ func NewSettings(m Meta, retention, level1 string) *Settings {
|
||||
Notifications: Box{No: "02", Title: "Notifications", ID: "notifications", Help: topicLink(HelpNotifications, "What is sent and when")},
|
||||
Mail: Box{No: "03", 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")},
|
||||
TimeZone: Box{No: "05", Title: "Time zone", ID: "time-zone", End: Plain("Read-only"), Help: topicLink(HelpSettings, "Times and days of the panel")},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +144,29 @@ func (s *Settings) WithMail(records []Record, checked string) *Settings {
|
||||
return s
|
||||
}
|
||||
|
||||
// WithTimeZone names the zone: tz is TZ as the container has it ("" is UTC,
|
||||
// Go's and the C library's default), loc the zone the process took from it
|
||||
// (time.Local), at now for its offset. A TZ Go cannot load leaves time.Local
|
||||
// UTC (time/zoneinfo_unix.go, initLocal), and the box says so rather than name
|
||||
// a zone the times are not in.
|
||||
func (s *Settings) WithTimeZone(tz string, loc *time.Location, now time.Time) *Settings {
|
||||
name := strings.TrimPrefix(tz, ":")
|
||||
if name == "" {
|
||||
name = "UTC"
|
||||
}
|
||||
_, offset := now.In(loc).Zone()
|
||||
sign := "+"
|
||||
if offset < 0 {
|
||||
sign, offset = "-", -offset
|
||||
}
|
||||
note := Rich(fmt.Sprintf("UTC%s%02d:%02d. Set in ", sign, offset/3600, offset%3600/60), Code(".env"), " (", Code("TZ"), "); restart to change.")
|
||||
if name != "UTC" && loc.String() != name {
|
||||
note = Rich("Not a zone this image knows, so the times are UTC. Set in ", Code(".env"), " (", Code("TZ"), "); restart to change.")
|
||||
}
|
||||
s.ZoneFacts = []Fact{{Label: "Zone", Value: Plain(name), Mono: true, Note: note}}
|
||||
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.
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestPageHeadStates(t *testing.T) {
|
||||
Crumbs: []Crumb{{Href: "/outbound/domains", Label: "Outbound domains"}, {Label: "Applications"}},
|
||||
Title: "New password for", Code: "prod-server",
|
||||
Lead: Rich("Lead with ", Code("code")),
|
||||
Note: []string{"Updated 14:02:31 UTC", "refreshes every 10 s"},
|
||||
Note: []string{"Updated 14:02:31", "refreshes every 10 s"},
|
||||
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: "/x/dns-recheck"}, {Label: "Add", Icon: "ti-plus", Href: "/x/new", Primary: true}, {Label: "Delete", Href: "/x/delete", Danger: true}},
|
||||
}))
|
||||
mustContain(t, out,
|
||||
@@ -177,7 +177,7 @@ func TestPageHeadStates(t *testing.T) {
|
||||
`<p class="sp-kicker"><a href="/outbound/domains">Outbound domains</a> / Applications /</p>`,
|
||||
`<h1 class="title is-3">New password for <span class="sp-mono">prod-server</span></h1>`,
|
||||
`<p class="sp-lead">Lead with <code>code</code></p>`,
|
||||
`<div class="sp-muted sp-small">Updated 14:02:31 UTC<br>refreshes every 10 s</div>`,
|
||||
`<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes every 10 s</div>`,
|
||||
`<form method="post" action="/x/dns-recheck"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>`,
|
||||
`<a class="button is-primary" href="/x/new"><i class="ti ti-plus"></i>Add</a>`,
|
||||
`<a class="button is-danger is-light" href="/x/delete">Delete</a>`)
|
||||
@@ -352,15 +352,15 @@ func TestSideMenu(t *testing.T) {
|
||||
|
||||
func TestTimelineLevels(t *testing.T) {
|
||||
out := squash(partial(t, "timeline", []Step{
|
||||
{Time: "13:58:07 UTC", Strong: "Accepted", Text: Rich(" from ", Mono("alerts"))},
|
||||
{Time: "13:58:09 UTC", Strong: "Deferred", Text: Plain(" — later"), Level: LevelWarn},
|
||||
{Time: "14:40:00 UTC", Strong: "Bounced", Level: LevelFail},
|
||||
{Time: "13:58:07", Strong: "Accepted", Text: Rich(" from ", Mono("alerts"))},
|
||||
{Time: "13:58:09", Strong: "Deferred", Text: Plain(" — later"), Level: LevelWarn},
|
||||
{Time: "14:40:00", Strong: "Bounced", Level: LevelFail},
|
||||
{Time: "next attempt", Text: Plain("Still queued"), Level: LevelPending},
|
||||
}))
|
||||
mustContain(t, out,
|
||||
`<ol class="sp-timeline"><li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span></li>`,
|
||||
`<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — later</li>`,
|
||||
`<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong></li>`,
|
||||
`<ol class="sp-timeline"><li><time>13:58:07</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span></li>`,
|
||||
`<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — later</li>`,
|
||||
`<li class="sp-fail"><time>14:40:00</time><strong>Bounced</strong></li>`,
|
||||
`<li class="sp-pending"><time>next attempt</time>Still queued</li>`)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ func DMARCDomainHref(id int64) string { return outboundDMARC + "/domains/" + str
|
||||
func DMARCReportHref(id int64) string { return outboundDMARC + "/reports/" + strconv.FormatInt(id, 10) }
|
||||
|
||||
// FormatReceived is the time a report was received, as the tables show it.
|
||||
func FormatReceived(t time.Time) string { return t.UTC().Format("01-02 15:04") }
|
||||
func FormatReceived(t time.Time) string { return t.Local().Format("01-02 15:04") }
|
||||
|
||||
// ---- The hub
|
||||
|
||||
@@ -97,7 +97,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
|
||||
if in.Last.IsZero() {
|
||||
last.Note = Rich("Set a domain to SelfPost hosted in its Domain settings, publish the DMARC record it then shows, and publish MX for ", Mono(in.Host), " pointing at this server.")
|
||||
} else {
|
||||
last.Value = Plain(in.Last.UTC().Format("2006-01-02 15:04") + " UTC")
|
||||
last.Value = Plain(in.Last.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
return &DMARCHub{
|
||||
Meta: m,
|
||||
@@ -329,8 +329,8 @@ type ReportInput struct {
|
||||
Contact string
|
||||
Pass, Fail int
|
||||
Window string // the window in the title: "20 Sep"
|
||||
Period string // the window with its times: "20 Sep 00:00 – 21 Sep 00:00 UTC"
|
||||
Received string // "2026-09-21 04:40 UTC"
|
||||
Period string // the window with its times: "20 Sep 00:00 – 21 Sep 00:00"
|
||||
Received string // "2026-09-21 04:40"
|
||||
PolicyP string
|
||||
PolicySP string
|
||||
PolicyPct int
|
||||
|
||||
@@ -12,8 +12,8 @@ func TestDMARCHubShowsIngestAndTheLatestReports(t *testing.T) {
|
||||
pageHas(t, "DMARC reports", out,
|
||||
`<h1 class="title is-3">DMARC reports</h1>`, `<code>rua=</code>`,
|
||||
`<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
|
||||
`<dd>2026-09-21 06:12 UTC</dd>`, `<dd>14 kept · 0 parse failures</dd>`, `<dt>Hosted addresses</dt>`, `<dd class="sp-mono sp-small">dmarc-reports+<domain>@mail.example.org</dd>`, `<dd>500 reports a domain or 90 days</dd>`,
|
||||
`<th>Received, UTC</th>`, `09-21 06:12`, `<a href="/outbound/dmarc/domains/1">example.org</a>`,
|
||||
`<dd>2026-09-21 06:12</dd>`, `<dd>14 kept · 0 parse failures</dd>`, `<dt>Hosted addresses</dt>`, `<dd class="sp-mono sp-small">dmarc-reports+<domain>@mail.example.org</dd>`, `<dd>500 reports a domain or 90 days</dd>`,
|
||||
`<th>Received</th>`, `09-21 06:12`, `<a href="/outbound/dmarc/domains/1">example.org</a>`,
|
||||
"<td>1 903</td>", `<span class="tag is-warning is-light">7</span>`, `<td>0</td><td class="sp-actions"><a href="/outbound/dmarc/reports/41">View</a>`,
|
||||
)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestDMARCReportShowsTheReportItsPolicyAndRecords(t *testing.T) {
|
||||
`<h1 class="title is-4">Outlook.com · 20 Sep</h1>`,
|
||||
`<a href="/outbound/dmarc">DMARC reports</a> / <a href="/outbound/dmarc/domains/2">shop.example.org</a> /`,
|
||||
"<span>shop.example.org</span><span class=\"tag is-success is-light\">1 903 pass</span><span class=\"tag is-warning is-light\">7 fail</span>",
|
||||
`<h2>Report</h2>`, `<dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd>`, `<dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd>`, `<dd>2026-09-21 04:40 UTC</dd>`,
|
||||
`<h2>Report</h2>`, `<dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd>`, `<dd>20 Sep 00:00 – 21 Sep 00:00</dd>`, `<dd>2026-09-21 04:40</dd>`,
|
||||
`<h2>Published policy</h2>`, `<dd class="sp-mono">none / none / 100</dd>`, `<dd class="sp-mono">r / r</dd>`, `<dd class="sp-mono sp-small">dmarc@mail.example.org</dd>`,
|
||||
`<h2>Records</h2>`, `Parsed from the aggregate XML`,
|
||||
`203.0.113.25 <span class="tag is-light">this relay</span>`, "<td>1 903</td>",
|
||||
@@ -111,7 +111,7 @@ func TestDMARCReportShowsTheReportItsPolicyAndRecords(t *testing.T) {
|
||||
// nothing failed, and a report with no records says so.
|
||||
func TestDMARCReportOmitsWhatItDoesNotHold(t *testing.T) {
|
||||
p := NewDMARCReport(admin(), ReportInput{ID: 1, Reporter: "google.com", ReportID: "r1", Domain: "example.org", DomainID: 1, Pass: 5,
|
||||
Period: "20 Sep 00:00 – 20 Sep 23:59 UTC", Received: "2026-09-21 04:40 UTC", PolicyP: "none", PolicySP: "none", PolicyPct: 100, PolicyADKIM: "r", PolicyASPF: "r"})
|
||||
Period: "20 Sep 00:00 – 20 Sep 23:59", Received: "2026-09-21 04:40", PolicyP: "none", PolicySP: "none", PolicyPct: 100, PolicyADKIM: "r", PolicyASPF: "r"})
|
||||
out := renderSignedIn(t, "dmarc-report", p.WithRecords(nil))
|
||||
pageHas(t, "DMARC report", out, `<h1 class="title is-4">google.com</h1>`, `<span class="tag is-success is-light">5 pass</span>`, `This report holds no records.`)
|
||||
pageLacks(t, "DMARC report", out, `fail</span>`, `Contact`, `<dt>rua</dt>`, `<table`, `<a href="/outbound/dmarc">`)
|
||||
|
||||
@@ -113,7 +113,7 @@ func healthFixture(refresh bool) *Health {
|
||||
}
|
||||
h.Certificate.End = Verdict("warn")
|
||||
h.CertFacts = []Fact{
|
||||
{Label: "Expires", Value: Plain("2026-10-21 09:14 UTC"), Mono: true},
|
||||
{Label: "Expires", Value: Plain("2026-10-21 09:14"), 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
|
||||
@@ -149,16 +149,21 @@ func accountFixture() *Account {
|
||||
const mailFixtureDKIMHost = "sp2026._domainkey.mail.example.org"
|
||||
|
||||
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
|
||||
// messages an hour, notifications on, and the records of the server's own name
|
||||
// with the DKIM one published and the SPF one missing.
|
||||
// messages an hour, notifications on, the records of the server's own name
|
||||
// with the DKIM one published and the SPF one missing, and Moscow's time.
|
||||
func settingsFixture() *Settings {
|
||||
msk, err := time.LoadLocation("Europe/Moscow")
|
||||
if err != nil {
|
||||
msk = time.FixedZone("Europe/Moscow", 3*3600)
|
||||
}
|
||||
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."}),
|
||||
}, "3 min ago")
|
||||
}, "3 min ago").
|
||||
WithTimeZone("Europe/Moscow", msk, time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
|
||||
@@ -220,7 +225,7 @@ func outDomainSettingsFixture() *OutDomainSettings {
|
||||
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",
|
||||
Computed: "288", Peak: 96, Updated: "2026-10-09 03:00",
|
||||
L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5",
|
||||
})
|
||||
}
|
||||
@@ -338,7 +343,7 @@ func dmarcDomainFixture() *DMARCDomain {
|
||||
func dmarcReportFixture() *DMARCReport {
|
||||
return NewDMARCReport(admin(), ReportInput{
|
||||
ID: 40, Reporter: "Outlook.com", ReportID: "5f1c9a0e7b2d4e61a3", Domain: "shop.example.org", DomainID: 2,
|
||||
Pass: 1903, Fail: 7, Window: "20 Sep", Period: "20 Sep 00:00 – 21 Sep 00:00 UTC", Received: "2026-09-21 04:40 UTC",
|
||||
Pass: 1903, Fail: 7, Window: "20 Sep", Period: "20 Sep 00:00 – 21 Sep 00:00", Received: "2026-09-21 04:40",
|
||||
PolicyP: "none", PolicySP: "none", PolicyPct: 100, PolicyADKIM: "r", PolicyASPF: "r",
|
||||
Recipient: "dmarc@mail.example.org", Hub: true,
|
||||
}).WithRecords([]DMARCRecordRow{
|
||||
|
||||
@@ -263,7 +263,9 @@ var serverTopics = []HelpTopic{
|
||||
" on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions."),
|
||||
}},
|
||||
{ID: HelpSettings, Title: "Settings", Body: []Text{
|
||||
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 ",
|
||||
Rich("Settings holds what is true of the whole instance. The time zone is the container's, ", Code("TZ"), " in ", Code(".env"),
|
||||
" (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in ", Code("mail.log"),
|
||||
", so the two always agree; changing it is a restart of the container. 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. Notifications are turned on or off here for everyone."),
|
||||
|
||||
@@ -141,7 +141,7 @@ func (p *FilterLists) setWhere(in FilterListsInput) {
|
||||
}
|
||||
written := "never"
|
||||
if !in.LastWritten.IsZero() {
|
||||
written = in.LastWritten.UTC().Format("2006-01-02 15:04") + " UTC"
|
||||
written = in.LastWritten.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
p.WhereFacts = []Fact{
|
||||
{Label: "Applied by", Value: applied},
|
||||
@@ -159,7 +159,7 @@ func (p *FilterLists) WithEntries(entries []FilterEntryInput) *FilterLists {
|
||||
window := "in the last " + plural(int64(p.retentionDays), "day", "days")
|
||||
sheet := []SheetRow{
|
||||
{Label: "Note", Lines: []string{orDash(e.Note)}},
|
||||
{Label: "Added", Lines: []string{e.Added.UTC().Format("2006-01-02")}, Mono: true},
|
||||
{Label: "Added", Lines: []string{e.Added.Local().Format("2006-01-02")}, Mono: true},
|
||||
}
|
||||
row := FilterRow{
|
||||
Value: e.Value,
|
||||
@@ -169,7 +169,7 @@ func (p *FilterLists) WithEntries(entries []FilterEntryInput) *FilterLists {
|
||||
row.Matched = strconv.Itoa(e.Hits)
|
||||
sheet = append(sheet,
|
||||
SheetRow{Label: "Matched", Lines: []string{row.Matched + " " + window}},
|
||||
SheetRow{Label: "Last match", Lines: []string{e.LastHit.UTC().Format("2006-01-02 15:04")}, Mono: true})
|
||||
SheetRow{Label: "Last match", Lines: []string{e.LastHit.Local().Format("2006-01-02 15:04")}, Mono: true})
|
||||
} else {
|
||||
sheet = append(sheet, SheetRow{Label: "Matched", Lines: []string{"never " + window}})
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestInLogShowsTheRowsAndTheirFilters(t *testing.T) {
|
||||
`<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">`,
|
||||
`<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">`,
|
||||
`href="/help#inbound-log" title="What the decisions mean"`,
|
||||
`<th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr>`,
|
||||
`<th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr>`,
|
||||
// what the upstream answered: a tag that opens its last reply, a
|
||||
// plain tag while not tried, "—" for refused mail
|
||||
`<button type="button" class="tag is-success is-light" popovertarget="up-1">sent</button><div class="sp-sheet" id="up-1" popover>`,
|
||||
@@ -219,7 +219,7 @@ func TestFilterListsShowsTheFormTheListsAndWhereTheyGo(t *testing.T) {
|
||||
`<h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div>`,
|
||||
`rspamd, from map files it rereads within seconds of a change`,
|
||||
`<span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry`,
|
||||
`2026-09-11 08:40 UTC`, `Deny wins, whichever entries they are.`,
|
||||
`2026-09-11 08:40`, `Deny wins, whichever entries they are.`,
|
||||
)
|
||||
if got := strings.Count(out, `<table class="table is-fullwidth is-hoverable">`); got != 2 {
|
||||
t.Errorf("%d list tables, want the two lists", got)
|
||||
|
||||
@@ -151,9 +151,9 @@ func KitPage() *Kit {
|
||||
|
||||
Timeline: Box{No: "08", Title: "timeline"},
|
||||
Steps: []Step{
|
||||
{Time: "13:58:07 UTC", Strong: "Done", Text: Plain(" — default")},
|
||||
{Time: "13:58:09 UTC", Strong: "Deferred", Text: Plain(" — sp-warn"), Level: LevelWarn},
|
||||
{Time: "14:40:00 UTC", Strong: "Bounced", Text: Plain(" — sp-fail"), Level: LevelFail},
|
||||
{Time: "13:58:07", Strong: "Done", Text: Plain(" — default")},
|
||||
{Time: "13:58:09", Strong: "Deferred", Text: Plain(" — sp-warn"), Level: LevelWarn},
|
||||
{Time: "14:40:00", Strong: "Bounced", Text: Plain(" — sp-fail"), Level: LevelFail},
|
||||
{Time: "not yet", Text: Plain("Expected next — sp-pending"), Level: LevelPending},
|
||||
},
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func FormatAge(t, now time.Time) string {
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%d h ago", int(d/time.Hour))
|
||||
default:
|
||||
return t.UTC().Format("2006-01-02")
|
||||
return t.Local().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,11 +62,11 @@ func LogHref(f OutLogFilter, page int) string {
|
||||
}
|
||||
|
||||
// FormatLogTime is the time of a row of the log: month, day and the time of day
|
||||
// in UTC, as the table's "Time, UTC" column shows it.
|
||||
func FormatLogTime(t time.Time) string { return t.UTC().Format("01-02 15:04:05") }
|
||||
// in the container's zone, as the table's "Time" column shows it.
|
||||
func FormatLogTime(t time.Time) string { return t.Local().Format("01-02 15:04:05") }
|
||||
|
||||
// formatStamp is a moment on the page of one message, with its zone.
|
||||
func formatStamp(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05") + " UTC" }
|
||||
// formatStamp is a moment on the page of one message, in the container's zone.
|
||||
func formatStamp(t time.Time) string { return t.Local().Format("2006-01-02 15:04:05") }
|
||||
|
||||
// ---- The log
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestOutLogShowsTheRowsAndTheirFilters(t *testing.T) {
|
||||
`<form class="sp-end" method="get" action="/outbound/log">`,
|
||||
`<select name="domain" aria-label="Domain"><option value="" selected>All domains</option><option value="example.org">example.org</option>`,
|
||||
`<select name="app" aria-label="Application"><option value="" selected>All applications</option>`,
|
||||
`<th>Time, UTC</th>`, `09-21 14:01:52`,
|
||||
`<th>Time</th>`, `09-21 14:01:52`,
|
||||
`shop@<wbr>shop.example.org`, `m.keller@<wbr>gmx.de`, `title="[FIRING] disk usage above 90 % on db-2"`,
|
||||
`<span class="tag is-success is-light">sent</span>`, `<span class="tag is-warning is-light">deferred</span>`,
|
||||
`<span class="tag is-danger is-light">bounced</span>`, `<span class="tag is-danger is-light">rate-limited</span>`, `<span class="tag is-danger is-light">wrong From</span>`,
|
||||
@@ -113,9 +113,9 @@ func TestOutMessageShowsWhatTheJournalRecorded(t *testing.T) {
|
||||
`<a href="/outbound/log">Outbound log</a> /`,
|
||||
`<p class="sp-route"><span>alerts@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>noc@example.org</span><span class="tag is-warning is-light">deferred</span></p>`,
|
||||
`<a href="/outbound/domains/1">example.org</a>`, `<dd class="sp-mono"><a href="/outbound/domains/1/applications/2">alerts</a></dd>`,
|
||||
`2026-09-21 13:58:07 UTC`, `2026-09-21 13:58:09 UTC`, `<dd class="sp-mono">4XcB7k2Jm9z1</dd>`, `<dd class="sp-mono">184220</dd>`,
|
||||
`<li><time>2026-09-21 13:58:07 UTC</time><strong>Accepted and queued</strong>`,
|
||||
`<li class="sp-warn"><time>2026-09-21 13:58:09 UTC</time><strong>Deferred, will be retried</strong>`,
|
||||
`2026-09-21 13:58:07`, `2026-09-21 13:58:09`, `<dd class="sp-mono">4XcB7k2Jm9z1</dd>`, `<dd class="sp-mono">184220</dd>`,
|
||||
`<li><time>2026-09-21 13:58:07</time><strong>Accepted and queued</strong>`,
|
||||
`<li class="sp-warn"><time>2026-09-21 13:58:09</time><strong>Deferred, will be retried</strong>`,
|
||||
`<li class="sp-pending"><time>not yet</time>`,
|
||||
`Lines from <code>mail.log</code> with this queue id`, `<pre class="sp-log">`, `<span class="sp-d">13:58:07</span> postfix/smtpd[2114]`,
|
||||
`<span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>`,
|
||||
|
||||
@@ -227,7 +227,7 @@ func TestOutDomainSettingsHasAFormPerBox(t *testing.T) {
|
||||
`name="password"`, `name="password_confirm"`, `minlength="12"`, `at least 12 characters`,
|
||||
`action="/outbound/domains/1/settings/ratelimit"`, `name="mode"`, `data-ratelimit-mode`, `data-manual-fields`, `data-auto-fields`,
|
||||
`name="max_messages"`, `max="600"`, `name="window_seconds"`, `name="auto_multiplier"`, `min="1.5" max="5"`, `Multiplier (1.5–5, default 2.5)`,
|
||||
`Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated 2026-10-09 03:00 UTC.`,
|
||||
`Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated 2026-10-09 03:00.`,
|
||||
`<option value="auto" selected>`, `<span class="tag is-success is-light">active</span>`, `class="sp-help" href="/help#limits"`,
|
||||
`600 messages / 3600 s per client IP, set in <code>.env</code>`,
|
||||
`action="/outbound/domains/1/settings/ratelimit/recalc"`, `name="clear" value="1"`, `data-confirm="Remove the domain rate limit?`,
|
||||
@@ -375,7 +375,7 @@ func TestOutAppAutoLimitHasRecalculate(t *testing.T) {
|
||||
p := outAppFixture().WithForm(OutAppForm{Login: "prod-server", Mode: AddressWildcard, LimitMode: LimitAuto, Window: "3600", Multiplier: "2.5"},
|
||||
OutAppState{Limit: true, Auto: true},
|
||||
AppRateLimit{L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5",
|
||||
Computed: "288", Peak: 96, Updated: "2026-10-09 03:00 UTC"})
|
||||
Computed: "288", Peak: 96, Updated: "2026-10-09 03:00"})
|
||||
out := renderSignedIn(t, "out-app", p)
|
||||
pageHas(t, "Application", out, `formaction="/outbound/domains/1/applications/1/ratelimit/recalc"`, `Recalculate rate limit`,
|
||||
`Computed limit: <strong>288</strong> messages / 3600 s.`)
|
||||
|
||||
@@ -118,7 +118,7 @@ func overviewHead(host string, cards []HealthCard, updated time.Time) Head {
|
||||
Kicker: host,
|
||||
Title: title,
|
||||
ID: "overview-head",
|
||||
Note: []string{"Updated " + updated.UTC().Format("15:04:05") + " UTC"},
|
||||
Note: []string{"Updated " + updated.Local().Format("15:04:05")},
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
lead := Text{{Text: "Needs attention: "}}
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestOverviewHeadFollowsTheCards(t *testing.T) {
|
||||
`<h1 class="title is-3">Running, with warnings.</h1>`,
|
||||
`class="sp-postmark sp-warn"`, `<b>WARN</b>`, `1 OF 6 CHECKS`,
|
||||
`Needs attention: <a href="/server/health">TLS certificate</a> (Expires in 12 days).`,
|
||||
`Updated 14:02:31 UTC`, `<p class="sp-kicker">mail.example.org</p>`,
|
||||
`Updated 14:02:31`, `<p class="sp-kicker">mail.example.org</p>`,
|
||||
`class="sp-warn"`, // the certificate card
|
||||
`href="/server/health"`, `>All details</a>`,
|
||||
)
|
||||
@@ -154,7 +154,7 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
`<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`,
|
||||
`pid 41, uptime 18 days`, `<span class="tag is-success is-light">running</span>`,
|
||||
`2026-10-21 09:14 UTC`, `class="mt-4 has-text-danger"`, `Check that renewal on the host still works.`,
|
||||
`2026-10-21 09:14`, `class="mt-4 has-text-danger"`, `Check that renewal on the host still works.`,
|
||||
`dkim-signer <span class="sp-muted">· notifications</span>`, `unix:/run/selfpost/sign-local.sock`, `→ 203.0.113.25<br>← mail.example.org`,
|
||||
`hx-get="/server/health/fragment"`,
|
||||
)
|
||||
@@ -346,6 +346,42 @@ func TestSettingsIsOneFormWithOneSaveButton(t *testing.T) {
|
||||
pageHas(t, "Settings", out, `Save settings`, `<h1 class="title is-3">Settings</h1>`, `href="/account"`)
|
||||
}
|
||||
|
||||
// The time zone is shown, not set: its name and offset, and a TZ the image
|
||||
// does not know is said to give UTC rather than named as the zone in use.
|
||||
func TestSettingsShowsTheTimeZone(t *testing.T) {
|
||||
pageHas(t, "Settings", renderSignedIn(t, "settings", settingsFixture()),
|
||||
`<h2>Time zone</h2>`, `<dd class="sp-mono">Europe/Moscow</dd>`, `UTC+03:00. Set in <code>.env</code> (<code>TZ</code>); restart to change.`,
|
||||
`and so are the stamps of <code>mail.log</code>.`)
|
||||
now := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
|
||||
pageHas(t, "Settings without TZ", renderSignedIn(t, "settings", NewSettings(admin(), "30", "600 / h").WithTimeZone("", time.UTC, now)),
|
||||
`<dd class="sp-mono">UTC</dd>`, `UTC+00:00. Set in`)
|
||||
pageHas(t, "Settings with a wrong TZ", renderSignedIn(t, "settings", NewSettings(admin(), "30", "600 / h").WithTimeZone("Mars/Olympus", time.UTC, now)),
|
||||
`<dd class="sp-mono">Mars/Olympus</dd>`, `Not a zone this image knows, so the times are UTC.`)
|
||||
ny, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Skipf("no zone America/New_York here: %v", err)
|
||||
}
|
||||
pageHas(t, "Settings in New York", renderSignedIn(t, "settings", NewSettings(admin(), "30", "600 / h").WithTimeZone("America/New_York", ny, now)),
|
||||
`UTC-04:00. Set in`)
|
||||
}
|
||||
|
||||
// The panel shows times in the container's zone, with no zone written
|
||||
// beside them (local-time): the row of a log, a message's page, the head
|
||||
// of Overview.
|
||||
func TestTimesAreTheContainersZone(t *testing.T) {
|
||||
inZone(t, "Europe/Moscow")
|
||||
at := time.Date(2026, 9, 21, 22, 30, 5, 0, time.UTC)
|
||||
if got := FormatLogTime(at); got != "09-22 01:30:05" {
|
||||
t.Errorf("FormatLogTime = %q, want the 22nd at 01:30:05 in Moscow", got)
|
||||
}
|
||||
if got := formatStamp(at); got != "2026-09-22 01:30:05" {
|
||||
t.Errorf("formatStamp = %q", got)
|
||||
}
|
||||
if got := FormatReceived(at); got != "09-22 01:30" {
|
||||
t.Errorf("FormatReceived = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -477,7 +513,7 @@ func TestOverviewPageHasThePollAttributesAndNoOOBMark(t *testing.T) {
|
||||
func TestOverviewFragmentIsTheBoxAndTheHeadOutOfBand(t *testing.T) {
|
||||
frag := renderFragment(t, "overview_poll", overviewFixture())
|
||||
pageHas(t, "fragment", frag,
|
||||
`<div class="sp-head" id="overview-head" hx-swap-oob="true">`, `class="sp-postmark sp-warn"`, `Updated 14:02:31 UTC`,
|
||||
`<div class="sp-head" id="overview-head" hx-swap-oob="true">`, `class="sp-postmark sp-warn"`, `Updated 14:02:31`,
|
||||
`<div class="box" id="health" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML">`,
|
||||
`Expires in 12 days`)
|
||||
pageLacks(t, "fragment", frag, `<html`, `<body`, `navbar`, `<table`, `Outbound domains`)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
{{template "box_open" .Recent}}
|
||||
{{if .Rows}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Received}}</td><td>{{if .DomainHref}}<a href="{{.DomainHref}}">{{.Domain}}</a>{{else}}{{.Domain}}{{end}}</td><td class="sp-muted">{{.Reporter}}</td><td>{{.Window}}</td><td>{{.PassText}}</td><td>{{if .Fail}}{{template "tag" .FailTag}}{{else}}0{{end}}</td><td class="sp-actions"><a href="{{.Href}}">View</a></td></tr>{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{{template "box_open" .Reports}}
|
||||
{{if .Rows}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Received}}</td><td class="sp-muted">{{.Reporter}}</td><td>{{.Window}}</td><td>{{.PassText}}</td><td>{{if .Fail}}{{template "tag" .FailTag}}{{else}}0{{end}}</td><td class="sp-actions"><a href="{{.Href}}">View</a></td></tr>{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{template "box_open" .Messages}}
|
||||
{{if .Rows}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}{{if .Client}}<br><span class="sp-mono sp-muted sp-small">{{.Client}}</span>{{end}}</td><td>{{wbr_at .To}}</td>{{if .Subject}}<td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td>{{else}}<td class="sp-clip sp-muted">—</td>{{end}}<td>{{template "sheet_tag" .Why}}</td>{{if .Upstream}}<td>{{template "sheet_tag" .Upstream}}</td>{{else if .Pending}}<td>{{template "tag" .Pending}}</td>{{else}}<td class="sp-muted">—</td>{{end}}<td class="sp-actions">{{if .Action}}<a href="{{.Action.Href}}">{{.Action.Label}}</a>{{end}}</td></tr>{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
{{template "box_open" .Tables}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="{{status_tag "sent"}}">sent</span></td><td class="sp-actions"><a href="#">Details</a>{{template "row_action" .RowAction}}</td></tr>
|
||||
</tbody>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
so the page count follows the rows. */}}
|
||||
{{define "out_log_rows"}}<div class="table-container" id="out-log-rows" data-poll aria-live="polite" hx-get="{{.PollURL}}" hx-trigger="load" hx-swap="outerHTML">
|
||||
{{if .Rows}}<table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}</td><td>{{wbr_at .To}}</td><td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td><td>{{template "tag" .Status}}</td><td class="sp-actions"><a href="{{.Href}}">Details</a></td></tr>{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{{/* Server > Settings (mockup settings.html): what holds for the whole instance.
|
||||
One form, in the left column, with its Save button under its fields; the
|
||||
right column holds what is not a field - the records of the server's own
|
||||
name, the ones notifications are signed and authorised with, and the
|
||||
read-only rate limits. A user's own name, e-mail, password and DMARC
|
||||
name, the ones notifications are signed and authorised with, the
|
||||
read-only rate limits and the read-only time zone. A user's own name, e-mail, password and DMARC
|
||||
address are on Account (account.html). */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
@@ -43,6 +43,13 @@
|
||||
{{template "facts" .RateFacts}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .TimeZone}}
|
||||
{{template "body_open"}}
|
||||
{{template "facts" .ZoneFacts}}
|
||||
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>.</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMain runs the tests in UTC, whatever the zone of the machine they run
|
||||
// on: the panel shows times and counts days in time.Local, the container's
|
||||
// zone (TZ), and the fixtures are written in UTC. The tests of another zone
|
||||
// set it themselves (inZone).
|
||||
func TestMain(m *testing.M) {
|
||||
time.Local = time.UTC
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// inZone runs the rest of the test in the zone name, as a container started
|
||||
// with TZ=name; the test must not be parallel.
|
||||
func inZone(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
t.Skipf("no zone %s here: %v", name, err)
|
||||
}
|
||||
time.Local = loc
|
||||
t.Cleanup(func() { time.Local = time.UTC })
|
||||
}
|
||||
Reference in New Issue
Block a user