Files
selfpost/internal/web/view/overview.go
T
mixandClaude Opus 5.5 bdeb01681d 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>
2026-10-17 01:34:28 +03:00

153 lines
4.8 KiB
Go

package view
// The data of Overview (templates/overview.html, mockup overview.html): the
// answer to "is mail flowing?" in a postmark and six cards, and under them the
// two kinds of domain at a glance. Everything behind the cards is on Health
// (health.go).
import (
"fmt"
"strconv"
"strings"
"time"
)
// Overview is the data of the Overview page. Cards are the checks of Health
// reduced to a verdict each; the head's postmark and sentence are derived from
// them by NewOverview, so the stamp and the cards cannot disagree.
//
// The inbound box is drawn only when the inbound relay is on (InboundOn):
// without it the outbound box takes the whole width and nothing on the page
// points at /inbound/domains.
type Overview struct {
Meta
Head Head
Cards []HealthCard
Health Box
Outbound Box
Days int
OutboundRows []OverviewDomain
OutboundEmpty EmptyState
InboundOn bool
Inbound Box
InboundRows []OverviewInbound
InboundEmpty EmptyState
}
// OverviewDomain is one row of the Outbound domains table: the domain, the
// verdict of each DNS check as a tag (DKIM, SPF, DMARC), how many applications
// it has and how much mail it sent in the window (see FormatMessages).
type OverviewDomain struct {
Name string
Href string
DNS []Tag
Apps int
Messages string
}
// OverviewInbound is one row of the Inbound domains table: the domain, its MX
// check as a tag and the upstream it forwards to.
type OverviewInbound struct {
Name string
Href string
MX Tag
Upstream string
}
// NewOverview builds the page. host is the server's name (the kicker) and may
// be empty; updated is when the checks were read; days is the length of the
// send window of the last column.
func NewOverview(m Meta, host string, cards []HealthCard, updated time.Time, days int, inboundOn bool) *Overview {
m.Title, m.Section, m.Page = "Overview", "overview", ""
o := &Overview{
Meta: m,
Cards: cards,
Days: days,
Head: overviewHead(host, cards, updated),
Health: Box{No: "01", Title: "Server health", ID: "health", Poll: "/overview/fragment",
End: Rich(Link("/server/health", "All details")),
Help: topicLink(HelpChecks, "What these checks mean")},
Outbound: Box{No: "02", Title: "Outbound domains", End: Rich(Link("/outbound/domains", "Manage"))},
OutboundEmpty: EmptyState{Icon: "ti-world-upload",
Text: Plain("No outbound domains yet. Add the first one under Outbound.")},
InboundOn: inboundOn,
Inbound: Box{No: "03", Title: "Inbound domains", End: Rich(Link("/inbound/domains", "Manage"))},
InboundEmpty: EmptyState{Icon: "ti-world-download",
Text: Plain("No inbound domains yet. Mail is not accepted for any.")},
}
return o
}
// HeadOOB is the head as the polled fragment carries it: the same head, marked
// to be swapped into the page by its id. The page itself never has the mark.
func (o *Overview) HeadOOB() Head {
h := o.Head
h.OOB = true
return h
}
// overviewHead is the stamp and the sentence under it. The stamp takes the
// worst card, counts the cards that are not fine, and the lead names them with
// their detail so the operator sees what to open without scanning the grid. A
// card that could not be read is a warning, never a pass (cardLevel).
func overviewHead(host string, cards []HealthCard, updated time.Time) Head {
level, bad := LevelOK, []HealthCard{}
for _, c := range cards {
if c.Level == "" {
continue
}
bad = append(bad, c)
if c.Level == LevelFail || level == LevelOK {
level = c.Level
}
}
title, word, count := "Everything is running normally.", "OK", len(cards)
switch level {
case LevelWarn:
title, word, count = "Running, with warnings.", "WARN", len(bad)
case LevelFail:
title, word, count = "A component needs attention.", "FAIL", len(bad)
}
h := Head{
Postmark: &Postmark{Top: "SELFPOST", Word: word, Bottom: fmt.Sprintf("%d OF %d CHECKS", count, len(cards)), Level: level},
Kicker: host,
Title: title,
ID: "overview-head",
Note: []string{"Updated " + updated.Local().Format("15:04:05")},
}
if len(bad) > 0 {
lead := Text{{Text: "Needs attention: "}}
for i, c := range bad {
if i > 0 {
lead = append(lead, Inline{Text: ", "})
}
lead = append(lead, Link(c.Href, c.Name), Inline{Text: " (" + c.Value + ")"})
}
h.Lead = append(lead, Inline{Text: "."})
}
return h
}
// FormatCount writes a whole number with its thousands separated by a no-break
// space: "1 284".
func FormatCount(n int64) string {
digits := strconv.FormatInt(n, 10)
var b strings.Builder
for i, r := range digits {
if i > 0 && (len(digits)-i)%3 == 0 && digits[i-1] != '-' {
b.WriteString("\u00a0")
}
b.WriteRune(r)
}
return b.String()
}
// FormatMessages writes a message count as the Overview table shows it:
// "1 284 msg".
func FormatMessages(n int64) string { return FormatCount(n) + "\u00a0msg" }