Phase 7: monitoring UI — send log, queue, mail.log tail

Three HTMX-polled monitoring screens (spec 7.2.11-13): send log with
server-side domain/application filters and pagination, Postfix queue
(postqueue -p), and a mail.log tail. Fragment endpoints return HTML
snippets, not JSON (spec 7.1); all output is auto-escaped via
html/template (spec 7.6.7).

Adds store.QuerySendLog/CountSendLog/ListApplicationLogins,
postfix.Queue(), and logtail.TailLines (a point-in-time reverse read,
independent of the background follow loop). Verified on the dev server:
gofmt/vet/test green, docker build green, container e2e (filters,
60-row pagination, <script> escaping, real postqueue/mail.log output,
existing Reload button unaffected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:22:08 +03:00
parent 901fc0e05a
commit 9d4942aef6
16 changed files with 558 additions and 16 deletions
+168
View File
@@ -0,0 +1,168 @@
package web
import (
"net/http"
"strconv"
"codeberg.org/mix/selfpost/internal/logtail"
"codeberg.org/mix/selfpost/internal/postfix"
"codeberg.org/mix/selfpost/internal/store"
)
// sendLogPageSize bounds each send-log page (spec 7.2's monitoring screens
// call for pagination); logTailLines bounds how much of mail.log the log view
// shows per refresh.
const (
sendLogPageSize = 50
logTailLines = 200
)
// handleSendLog renders the send-log monitoring page: server-side filters by
// domain/application and pagination (spec 7.3.3). The row table itself is the
// "sendlog_rows" fragment, shared verbatim with handleSendLogRows so the
// initial page and its HTMX-polled refreshes never diverge.
func (s *Server) handleSendLog(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
if err != nil {
logf("panel: send log: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data["Title"] = "SelfPost — send log"
data["User"] = currentUser(r)
s.render(w, http.StatusOK, "sendlog", data)
}
// handleSendLogRows serves the HTMX polling fragment for the send-log table
// (spec 7.1: fragment endpoints return HTML, not JSON).
func (s *Server) handleSendLogRows(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
if err != nil {
logf("panel: send log rows: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderFragment(w, http.StatusOK, "sendlog_rows", data)
}
// sendLogData reads the domain/app filters and page number off the query
// string, queries the store, and assembles everything the template needs
// (filter dropdown options plus the current selection, rows, and pagination).
func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
q := r.URL.Query()
filter := store.SendLogFilter{
Domain: q.Get("domain"),
AppLogin: q.Get("app"),
}
page := parsePage(q.Get("p"))
total, err := s.store.CountSendLog(filter)
if err != nil {
return nil, err
}
rows, err := s.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
if err != nil {
return nil, err
}
domains, err := s.store.ListDomains()
if err != nil {
return nil, err
}
domainNames := make([]string, len(domains))
for i, d := range domains {
domainNames[i] = d.Name
}
logins, err := s.store.ListApplicationLogins()
if err != nil {
return nil, err
}
lastPage := 1
if total > 0 {
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
}
return map[string]any{
"Rows": rows,
"FilterDomains": domainNames,
"FilterApps": logins,
"FilterDomain": filter.Domain,
"FilterApp": filter.AppLogin,
"Page": page,
"PrevPage": page - 1,
"NextPage": page + 1,
"LastPage": lastPage,
"HasPrev": page > 1,
"HasNext": page < lastPage,
}, nil
}
// parsePage clamps the "p" query parameter to a valid page number, defaulting
// to 1 for anything missing or malformed rather than rejecting the request.
func parsePage(v string) int {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
return 1
}
return n
}
// handleQueue renders the mail-queue monitoring page (spec 7.2.11).
func (s *Server) handleQueue(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue()
s.render(w, http.StatusOK, "queue", map[string]any{
"Title": "SelfPost — mail queue",
"User": currentUser(r),
"Output": out,
"Error": errText,
})
}
// handleQueueBody serves the HTMX polling fragment for the queue view.
func (s *Server) handleQueueBody(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue()
s.renderFragment(w, http.StatusOK, "queue_body", map[string]any{
"Output": out,
"Error": errText,
})
}
// readQueue runs postqueue -p, returning a friendly message instead of the
// error itself: a transient postqueue failure should degrade the monitoring
// view, not surface internals to the panel.
func readQueue() (string, string) {
out, err := postfix.Queue()
if err != nil {
logf("panel: postqueue -p: %v", err)
return "", "Could not read the mail queue."
}
return out, ""
}
// handleLogTail renders the mail.log monitoring page (spec 7.2.13).
func (s *Server) handleLogTail(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail()
s.render(w, http.StatusOK, "logtail", map[string]any{
"Title": "SelfPost — mail log",
"User": currentUser(r),
"Lines": lines,
"Error": errText,
})
}
// handleLogTailBody serves the HTMX polling fragment for the log-tail view.
func (s *Server) handleLogTailBody(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail()
s.renderFragment(w, http.StatusOK, "logtail_body", map[string]any{
"Lines": lines,
"Error": errText,
})
}
func (s *Server) readLogTail() ([]string, string) {
lines, err := logtail.TailLines(s.cfg.MailLogPath, logTailLines)
if err != nil {
logf("panel: tail %s: %v", s.cfg.MailLogPath, err)
return nil, "Could not read the mail log."
}
return lines, ""
}
+62 -15
View File
@@ -7,32 +7,60 @@ import (
"net/http"
)
// templates holds the parsed page templates. Each page is parsed together with
// the shared base layout so {{ template "base" . }} works. Rendering goes
// through html/template, which auto-escapes all interpolated data (spec 7.6.7).
// templates holds the parsed page and fragment templates. Each page is parsed
// together with the shared base layout so {{ template "base" . }} works.
// Fragments (HTMX polling targets, spec 7.1) are parsed standalone, without
// the layout, so they can be swapped into an existing page as an HTML snippet
// rather than a full document. Rendering always goes through html/template,
// which auto-escapes all interpolated data regardless (spec 7.6.7).
type templates struct {
pages map[string]*template.Template
pages map[string]*template.Template
fragments map[string]*template.Template
}
// pageFiles maps a logical page name to its template file. Every page composes
// with layout.html.
var pageFiles = map[string]string{
"setup": "templates/setup.html",
"login": "templates/login.html",
"dashboard": "templates/dashboard.html",
"domain_detail": "templates/domain_detail.html",
"domain_delete": "templates/domain_delete.html",
// pageFiles maps a logical page name to its template files. Every page
// composes with layout.html; pages that embed a polling fragment (spec 7.1)
// list that fragment's file too, so the same {{define}} block renders both
// the initial page and the fragment's own refresh responses identically.
var pageFiles = map[string][]string{
"setup": {"templates/setup.html"},
"login": {"templates/login.html"},
"dashboard": {"templates/dashboard.html"},
"domain_detail": {"templates/domain_detail.html"},
"domain_delete": {"templates/domain_delete.html"},
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"},
"queue": {"templates/queue.html", "templates/queue_body.html"},
"logtail": {"templates/logtail.html", "templates/logtail_body.html"},
}
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
// template file, for standalone rendering by the HTMX polling endpoints.
var fragmentFiles = map[string]string{
"sendlog_rows": "templates/sendlog_rows.html",
"queue_body": "templates/queue_body.html",
"logtail_body": "templates/logtail_body.html",
}
func loadTemplates() (*templates, error) {
t := &templates{pages: make(map[string]*template.Template)}
for name, file := range pageFiles {
tmpl, err := template.New("layout.html").ParseFS(assetsFS, "templates/layout.html", file)
t := &templates{
pages: make(map[string]*template.Template),
fragments: make(map[string]*template.Template),
}
for name, files := range pageFiles {
patterns := append([]string{"templates/layout.html"}, files...)
tmpl, err := template.New("layout.html").ParseFS(assetsFS, patterns...)
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", name, err)
}
t.pages[name] = tmpl
}
for name, file := range fragmentFiles {
tmpl, err := template.ParseFS(assetsFS, file)
if err != nil {
return nil, fmt.Errorf("parse fragment %s: %w", name, err)
}
t.fragments[name] = tmpl
}
return t, nil
}
@@ -54,3 +82,22 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
// renderFragment writes an HTMX polling fragment as a bare HTML snippet, with
// no surrounding layout (spec 7.1: fragment endpoints return HTML, not JSON).
func (s *Server) renderFragment(w http.ResponseWriter, status int, name string, data any) {
tmpl, ok := s.tmpl.fragments[name]
if !ok {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
logf("panel: render fragment %s: %v", name, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
+3
View File
@@ -3,6 +3,9 @@
<h1>SelfPost</h1>
<div class="actions muted">
<span>{{.User}}</span>
<a href="/sendlog">Send log</a>
<a href="/queue">Queue</a>
<a href="/logtail">Log</a>
<form class="inline" method="post" action="/reload">
<button type="submit">Reload</button>
</form>
+21
View File
@@ -0,0 +1,21 @@
{{define "content"}}
<div class="topbar">
<h1>Mail log</h1>
<div class="actions muted">
<span>{{.User}}</span>
<a href="/">Domains</a>
<a href="/sendlog">Send log</a>
<a href="/queue">Queue</a>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/">&larr; Domains</a>
<div class="card">
<h2>mail.log tail</h2>
{{template "logtail_body" .}}
</div>
{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "logtail_body"}}
<div id="logtail-body" hx-get="/logtail/body" hx-trigger="every 5s" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
{{end}}{{else}}No log lines yet.{{end}}</span>
</div>
{{end}}
+21
View File
@@ -0,0 +1,21 @@
{{define "content"}}
<div class="topbar">
<h1>Mail queue</h1>
<div class="actions muted">
<span>{{.User}}</span>
<a href="/">Domains</a>
<a href="/sendlog">Send log</a>
<a href="/logtail">Log</a>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/">&larr; Domains</a>
<div class="card">
<h2>postqueue -p</h2>
{{template "queue_body" .}}
</div>
{{end}}
+6
View File
@@ -0,0 +1,6 @@
{{define "queue_body"}}
<div id="queue-body" hx-get="/queue/body" hx-trigger="every 5s" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
</div>
{{end}}
+37
View File
@@ -0,0 +1,37 @@
{{define "content"}}
<div class="topbar">
<h1>Send log</h1>
<div class="actions muted">
<span>{{.User}}</span>
<a href="/">Domains</a>
<a href="/queue">Queue</a>
<a href="/logtail">Log</a>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/">&larr; Domains</a>
<div class="card">
<h2>Filter</h2>
<form method="get" action="/sendlog">
<label for="domain">Domain</label>
<select id="domain" name="domain">
<option value="">All domains</option>
{{range .FilterDomains}}<option value="{{.}}" {{if eq . $.FilterDomain}}selected{{end}}>{{.}}</option>{{end}}
</select>
<label for="app">Application</label>
<select id="app" name="app">
<option value="">All applications</option>
{{range .FilterApps}}<option value="{{.}}" {{if eq . $.FilterApp}}selected{{end}}>{{.}}</option>{{end}}
</select>
<button type="submit">Filter</button>
</form>
</div>
<div class="card">
{{template "sendlog_rows" .}}
</div>
{{end}}
+32
View File
@@ -0,0 +1,32 @@
{{define "sendlog_rows"}}
<div id="sendlog-rows" hx-get="/sendlog/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
hx-trigger="every 5s" hx-swap="outerHTML">
{{if .Rows}}
<table>
<thead>
<tr><th>Time</th><th>Domain</th><th>App</th><th>From</th><th>To</th><th>Subject</th><th>Status</th></tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td class="muted">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
<td>{{.Domain}}</td>
<td>{{.AppLogin}}</td>
<td>{{.From}}</td>
<td>{{.To}}</td>
<td>{{.Subject}}</td>
<td>{{.Status}}</td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
{{if .HasPrev}} &middot; <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">&larr; Newer</a>{{end}}
{{if .HasNext}} &middot; <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older &rarr;</a>{{end}}
</p>
{{else}}
<p class="muted">No messages logged yet.</p>
{{end}}
</div>
{{end}}
+14
View File
@@ -27,6 +27,10 @@ type Config struct {
// to true (spec 7.6.6); it exists as a knob only so the panel can be tested
// over plain HTTP in development, never for production.
CookieSecure bool
// MailLogPath is where Postfix's delivery log lives, read by the mail.log
// monitoring view (spec 7.2.13). It is the same path the log-tailer role
// follows in cmd/panel.
MailLogPath string
}
// Server is the panel HTTP application.
@@ -106,6 +110,16 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword)
authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication)
authed.HandleFunc("POST /reload", s.handleReload)
// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling
// fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON).
authed.HandleFunc("GET /sendlog", s.handleSendLog)
authed.HandleFunc("GET /sendlog/rows", s.handleSendLogRows)
authed.HandleFunc("GET /queue", s.handleQueue)
authed.HandleFunc("GET /queue/body", s.handleQueueBody)
authed.HandleFunc("GET /logtail", s.handleLogTail)
authed.HandleFunc("GET /logtail/body", s.handleLogTailBody)
mux.Handle("/", s.requireAuth(authed))
return mux