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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Queue returns Postfix's own human-readable mail-queue listing (spec 7.2.11):
|
||||
// active, deferred and held messages, exactly as an administrator would see
|
||||
// via the CLI. The command takes a single fixed flag and no user input, so it
|
||||
// never goes through a shell (spec 7.6.3). The panel is responsible for
|
||||
// escaping the output before display (spec 7.6.7); this function returns it
|
||||
// as-is.
|
||||
func Queue() (string, error) {
|
||||
cmd := exec.Command("postqueue", "-p")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("postqueue -p: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -195,6 +195,27 @@ func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListApplicationLogins returns every application login across all domains,
|
||||
// ordered, for the send-log monitoring screen's filter dropdown (spec 7.2).
|
||||
// Logins are globally unique (spec 5.1), so no domain qualifier is needed.
|
||||
func (s *Store) ListApplicationLogins() ([]string, error) {
|
||||
rows, err := s.db.Query("SELECT login FROM applications ORDER BY login")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list application logins: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var login string
|
||||
if err := rows.Scan(&login); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, login)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListLoginsByDomain returns the SASL logins of a domain's applications. Used to
|
||||
// purge sasldb2 entries before a domain (and its applications via cascade) is
|
||||
// deleted, while the logins are still known (spec 7.2.4).
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -65,6 +66,89 @@ func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SendLogRow is one row as returned to the monitoring UI (spec 7.2, 7.3.3): a
|
||||
// SendLogEntry plus the fields that only exist once a row has been written
|
||||
// (id, current status, timestamps).
|
||||
type SendLogRow struct {
|
||||
ID int64
|
||||
QueueID string
|
||||
Domain string
|
||||
AppLogin string
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or
|
||||
// application login. An empty field matches everything.
|
||||
type SendLogFilter struct {
|
||||
Domain string
|
||||
AppLogin string
|
||||
}
|
||||
|
||||
// QuerySendLog returns send-log rows matching filter, newest first, for the
|
||||
// monitoring screen's server-side pagination (spec 7.2's send-log view).
|
||||
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
|
||||
where, args := sendLogWhere(filter)
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at
|
||||
FROM send_log`+where+`
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query send_log: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []SendLogRow
|
||||
for rows.Next() {
|
||||
var (
|
||||
row SendLogRow
|
||||
createdAt string
|
||||
)
|
||||
if err := rows.Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin,
|
||||
&row.From, &row.To, &row.Subject, &row.Status, &createdAt); err != nil {
|
||||
return nil, fmt.Errorf("scan send_log row: %w", err)
|
||||
}
|
||||
row.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountSendLog returns how many send-log rows match filter, so the monitoring
|
||||
// screen can render page numbers/next-prev links.
|
||||
func (s *Store) CountSendLog(filter SendLogFilter) (int64, error) {
|
||||
where, args := sendLogWhere(filter)
|
||||
var n int64
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM send_log`+where, args...).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count send_log: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func sendLogWhere(f SendLogFilter) (string, []any) {
|
||||
var clauses []string
|
||||
var args []any
|
||||
if f.Domain != "" {
|
||||
clauses = append(clauses, "domain = ?")
|
||||
args = append(args, f.Domain)
|
||||
}
|
||||
if f.AppLogin != "" {
|
||||
clauses = append(clauses, "app_login = ?")
|
||||
args = append(args, f.AppLogin)
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
// DeleteSendLogBefore removes send-log rows created before cutoff, implementing
|
||||
// the configurable retention window (spec 7.3, SEND_LOG_RETENTION_DAYS). It
|
||||
// returns the number of rows pruned. created_at is stored as RFC3339 UTC, so a
|
||||
|
||||
@@ -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
@@ -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,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>
|
||||
|
||||
@@ -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="/">← Domains</a>
|
||||
|
||||
<div class="card">
|
||||
<h2>mail.log tail</h2>
|
||||
{{template "logtail_body" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -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}}
|
||||
@@ -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="/">← Domains</a>
|
||||
|
||||
<div class="card">
|
||||
<h2>postqueue -p</h2>
|
||||
{{template "queue_body" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -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}}
|
||||
@@ -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="/">← 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}}
|
||||
@@ -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}} · <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">← Newer</a>{{end}}
|
||||
{{if .HasNext}} · <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older →</a>{{end}}
|
||||
</p>
|
||||
{{else}}
|
||||
<p class="muted">No messages logged yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user