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 ab25d24706
commit cb25923a7b
16 changed files with 558 additions and 16 deletions
+47
View File
@@ -8,6 +8,7 @@ package logtail
import (
"bufio"
"bytes"
"context"
"io"
"log"
@@ -118,6 +119,52 @@ func retentionLoop(ctx context.Context, st StatusStore, retentionDays int) {
}
}
// TailLines returns up to n of the most recent lines from path, for the
// panel's mail.log monitoring view (spec 7.2.13). It is a one-shot,
// point-in-time read on request — unrelated to the background follow loop
// above — that reads backwards in chunks so it stays cheap against a
// multi-megabyte log rather than reading the whole file every poll.
func TailLines(path string, n int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
const chunkSize = 8192
var (
buf []byte
offset = info.Size()
)
for offset > 0 && bytes.Count(buf, []byte("\n")) <= n {
size := int64(chunkSize)
if size > offset {
size = offset
}
offset -= size
chunk := make([]byte, size)
if _, err := f.ReadAt(chunk, offset); err != nil {
return nil, err
}
buf = append(chunk, buf...)
}
text := strings.TrimRight(string(buf), "\n")
if text == "" {
return nil, nil
}
lines := strings.Split(text, "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return lines, nil
}
// follow tails path line by line, calling handle for each complete line, until
// ctx is cancelled. It starts at end-of-file (so a restart does not reprocess
// history) and reopens the file when it is rotated (inode change from