queue-table: Outbound › Queue as the drawn table, Inbound › Queue built

Owner, 2026-10-18: "Фиаско, что очередь не была сделана. Как мы это
допустили? Исправляем!" The mockups out-queue and in-queue (design 629de5e,
guards 8525e8e).

- postfix: postqueue -j gives each message's size and each recipient's
  delay_reason (src/postqueue/showq_json.c, format_json(): "delay_reason"
  only when LEN(why) > 0); QueueListing reads it with the page's 5 s,
  QueueMessages keeps the notifier's 30 s.
- view: one Queue page type for both directions (queue.go), one polled box
  (queue_body.html): a row per recipient, oldest first, cut at 200 with a
  foot; the head "N messages · size", "Refreshes on its own" while empty;
  Last reply is the delay reason, or where the message is (active, hold,
  incoming) before there was one. Inbound has no Details: an inbound
  message has no page of its own.
- handlers: one read of the queue, split by the recipient's domain against
  the inbound domains; an outbound recipient is linked to its send-log row
  (store.SendLogRefsByQueueID, through idx_send_log_queue_id, plan pinned in
  TestSendLogQueriesUseTheirIndexes), matched without regard to case, else
  the message's first row. Both pages are the global role's; the inbound
  one 404s with the relay off. Routes /inbound/queue (+ /fragment), menu,
  touch menu and sibling strip.
- Overview: Queues · Both empty · outbound and inbound with the relay on.
- Help: the queue topic rewritten, an Inbound queue topic (word for word with
  the mockup; domain administrators do not see it, as with the lists).
- TestUnbuiltScreensAreOnTheRoadmap and its rule in development.md: an
  exempted screen must be named in roadmap.md; queue-table leaves the
  roadmap; guide, architecture, panel-redesign.md updated.

Tests: go vet; go test ./internal/web/... ./internal/store ./internal/postfix
./internal/notify pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-18 23:17:14 +03:00
co-authored by Claude Opus 5.5
parent 8525e8ee8d
commit 6db3704711
34 changed files with 893 additions and 246 deletions
+4 -4
View File
@@ -469,18 +469,18 @@ func renderShell(t *testing.T, e *Engine, m Meta) string {
}
// The menu has the entries that are built and none of the ones that are not (plan
// § Routes): no Inbound Queue or Quarantine, no Preflight.
func TestMenuHoldsTheTwoDotZeroEntries(t *testing.T) {
// § Routes): no Quarantine, no Preflight. Inbound › Queue joined it in 2.16.0.
func TestMenuHoldsTheBuiltEntries(t *testing.T) {
out := renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true})
for _, href := range []string{
"/overview", "/outbound/domains", "/outbound/log", "/outbound/queue", "/outbound/dmarc",
"/inbound/domains", "/inbound/log", "/inbound/filter-lists", "/server/health", "/server/log", "/server/backup", "/server/users", "/server/settings",
"/inbound/domains", "/inbound/log", "/inbound/queue", "/inbound/filter-lists", "/server/health", "/server/log", "/server/backup", "/server/users", "/server/settings",
"/account", "/help", "/license",
} {
mustContain(t, out, `href="`+href+`"`)
}
for _, gone := range []string{
"/inbound/queue", "/inbound/quarantine", "/server/preflight",
"/inbound/quarantine", "/server/preflight",
`href="/status"`, `href="/domains"`, `href="/users"`, `href="/settings"`,
} {
mustNotContain(t, out, gone)
+15 -10
View File
@@ -47,6 +47,7 @@ var pageFixtures = map[string]func() any{
"in-domain": func() any { return inDomainFixture() },
"in-domain-delete": func() any { return inDomainDeleteFixture() },
"in-log": func() any { return inLogFixture() },
"in-queue": func() any { return inQueueFixture() },
"in-filter-lists": func() any { return inFilterListsFixture() },
"out-log": func() any { return outLogFixture() },
@@ -395,16 +396,20 @@ func inDomainFixture() *InDomain {
return p
}
// outQueueFixture is the queue as Postfix prints it for the mockup's one waiting
// message, greylisted by the receiving server, and the retry policy it shows.
func outQueueFixture() *OutQueue {
listing := "-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------\n" +
"4XcB7k2Jm9z1 3174 Sun Sep 21 13:58:07 alerts@example.org\n" +
"(host mx1.example.org[192.0.2.10] said: 451 4.7.1 Greylisted, try again in 5 minutes (in reply to RCPT TO command))\n" +
" noc@example.org\n" +
"\n" +
"-- 3 Kbytes in 1 Request.\n"
return NewOutQueue(admin(), listing, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", false)
// outQueueFixture is the mockup's Outbound queue: one message waiting, its one
// recipient greylisted by the receiving server, and the retry policy it shows.
func outQueueFixture() *Queue {
entries := []QueueEntry{{
ID: "4XcB7k2Jm9z1", Queue: "deferred", Arrival: time.Date(2026, 9, 21, 13, 58, 7, 0, time.Local), Size: 3174,
Sender: "alerts@example.org", Recipient: "noc@example.org",
Reason: "451 4.7.1 Greylisted, try again in 5 minutes", Href: "/outbound/log/41",
}}
return NewOutQueue(admin(), entries, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", false)
}
// inQueueFixture is the mockup's Inbound queue: nothing waiting.
func inQueueFixture() *Queue {
return NewInQueue(admin(), nil, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", false)
}
// systemLogFixture is the mockup's System log: nine lines of mail.log, as the
+10 -4
View File
@@ -57,8 +57,9 @@ const (
HelpRecipients = "recipients"
HelpFilter = "filter"
// HelpInboundLog and HelpFilterLists: the plain "log" is the Outbound log's.
HelpInboundLog = "inbound-log"
HelpFilterLists = "filter-lists"
HelpInboundLog = "inbound-log"
HelpInboundQueue = "inbound-queue"
HelpFilterLists = "filter-lists"
// Server
HelpBackup = "backup"
HelpUsers = "users"
@@ -139,13 +140,15 @@ func NewHelp(m Meta, inbound bool) *Help {
{Label: "Valid recipients", Icon: "ti-address-book", Href: "#" + HelpRecipients},
{Label: "Spam filter", Icon: "ti-shield-check", Href: "#" + HelpFilter},
{Label: "Inbound log", Icon: "ti-list-details", Href: "#" + HelpInboundLog},
{Label: "Inbound queue", Icon: "ti-stack-2", Href: "#" + HelpInboundQueue},
{Label: "Filter lists", Icon: "ti-filter", Href: "#" + HelpFilterLists},
}
// The filter lists are the whole server's and their page is 404 to
// a domain administrator (requireInboundGlobal), so their topic is
// not shown to one either.
// not shown to one either; nor is the queue's, the global role's too.
if !m.IsGlobal {
topics, menu = withoutTopic(topics, menu, HelpFilterLists)
topics, menu = withoutTopic(topics, menu, HelpInboundQueue)
}
add("Inbound", topics, menu)
}
@@ -216,7 +219,7 @@ var outboundTopics = []HelpTopic{
Code("mail.log"), ", found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains."),
}},
{ID: HelpQueue, Title: "Queue and retries", Body: []Text{
Rich("The page lists, as ", Code("postqueue -p"), " prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged."),
Plain("The page lists the messages Postfix has accepted and not yet delivered, one row per recipient: mail being sent right now and mail deferred for a later attempt, with the last reply of the receiving server, or why no connection was made. Mail for an inbound domain waits under Inbound › Queue instead. Only the global role can open it, and it only shows; nothing here deletes or forces a message, and Details opens the message in the Outbound log. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering; the last reply says which."),
}},
{ID: HelpDMARC, Title: "DMARC reports", Body: []Text{
Rich("An aggregate report is the summary a receiving provider sends, usually daily, to the ", Code("rua="),
@@ -256,6 +259,9 @@ var inboundTopics = []HelpTopic{
"Accepted mail is recorded as it passes; refused mail is read from ", Code("mail.log"),
", so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000."),
}},
{ID: HelpInboundQueue, Title: "Inbound queue", Body: []Text{
Plain("Mail accepted on port 25 and not yet taken by its upstream, one row per recipient, with the upstream's last reply or why it could not be reached. It is the same Postfix queue as Outbound › Queue, split by the recipient's domain, and retried the same way; an upstream that refuses or does not answer is what makes it grow. Only the global role can open it, and it only shows."),
}},
{ID: HelpFilterLists, Title: "Filter lists", Body: []Text{
Rich("Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, ",
Strong("Deny wins"), ".",
+5 -4
View File
@@ -278,13 +278,14 @@ func TestFilterListsEscapesTheEntries(t *testing.T) {
pageLacks(t, "Filter lists", out, `<img src=x>`, `<script>alert`)
}
// The menu leads to both pages, the lists for the global role only.
func TestInboundMenuHasLogAndFilterLists(t *testing.T) {
// The menu leads to the pages, the queue and the lists for the global role only.
func TestInboundMenuHasLogQueueAndFilterLists(t *testing.T) {
out := renderSignedIn(t, "in-log", inLogFixture())
pageHas(t, "Inbound log", out,
`<a class="navbar-item is-active" href="/inbound/log"><i class="ti ti-list-details"></i>Log</a>`,
`<a class="navbar-item" href="/inbound/queue"><i class="ti ti-stack-2"></i>Queue</a>`,
`<a class="navbar-item" href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>`,
`<a href="/inbound/log" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>`)
`<a href="/inbound/log" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="/inbound/queue"><i class="ti ti-stack-2"></i>Queue</a><a href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>`)
out = renderSignedIn(t, "in-filter-lists", inFilterListsFixture())
pageHas(t, "Filter lists", out,
`<a class="navbar-item is-active" href="/inbound/filter-lists">`, `<a href="/inbound/filter-lists" aria-current="page">`)
@@ -292,7 +293,7 @@ func TestInboundMenuHasLogAndFilterLists(t *testing.T) {
domain := NewInLog(Meta{User: "ops", HasInbound: true}, 90, nil, InLogFilter{}).WithRows(nil, 1, 1)
out = renderSignedIn(t, "in-log", domain)
pageHas(t, "Inbound log", out, `<a class="navbar-item is-active" href="/inbound/log">`, `<a href="/inbound/log" aria-current="page">`)
pageLacks(t, "Inbound log", out, `/inbound/filter-lists`)
pageLacks(t, "Inbound log", out, `/inbound/filter-lists`, `/inbound/queue`)
}
// What a sheet says, from the two shapes a row's detail has: the filter's
-82
View File
@@ -1,82 +0,0 @@
package view
// The data of the Outbound queue (templates/out_queue.html, mockup
// out-queue.html): what Postfix is still holding, and the retry policy that
// decides how long it holds it. The queue box polls; the page and its fragment,
// GET /outbound/queue/fragment, render the same box
// (templates/out_queue_body.html).
//
// Until the queue is a table (docs/roadmap.md, parse postqueue -j) the box
// shows postqueue's own listing in a log pane, as the system log shows
// mail.log: the mockup's table is not drawn from data the panel has.
//
// A page is a typed struct that embeds Meta, built by a constructor and filled
// by With… methods; the handler reads Postfix, the view decides what is said and
// how it is worded.
import "strings"
const outboundQueue = "/outbound/queue"
// OutQueue is the data of the Outbound queue. It is for the global role.
type OutQueue struct {
Meta
Head Head
Queue Box
Lines []LogLine
// Empty is shown in place of the listing when there is none: the queue holds
// nothing, or it could not be read.
Empty EmptyState
Retries Box
Policy []Fact
}
// NewOutQueue builds the page. listing is what postqueue printed, as it was
// read, and errText is why it could not be read, empty when it could.
func NewOutQueue(m Meta, listing, errText string) *OutQueue {
m.Title, m.Section, m.Page = "Outbound queue", "outbound", "queue"
p := &OutQueue{
Meta: m,
Head: Head{
Kicker: "Outbound",
Title: "Outbound queue",
Lead: Rich("What applications handed over and the receiving server has not yet taken, as ", Code("postqueue -p"), " lists it."),
},
Queue: Box{No: "01", Title: "Waiting", ID: "out-queue", Poll: outboundQueue + "/fragment",
Help: topicLink(HelpQueue, "What waits here and how Postfix retries"),
End: Plain("Refreshes on its own")},
Empty: EmptyState{Icon: "ti-stack-2", Text: Plain("Queue is empty.")},
Retries: Box{No: "02", Title: "How retries work"},
}
if errText != "" {
p.Empty = EmptyState{Icon: "ti-alert-circle", Text: Plain(errText)}
return p
}
if strings.TrimSpace(listing) == "" {
return p
}
for _, line := range strings.Split(strings.TrimRight(listing, "\r\n"), "\n") {
p.Lines = append(p.Lines, LogLine{Text: strings.TrimRight(line, "\r")})
}
return p
}
// WithRetryPolicy fills the second box: the first retry, the cap the later ones
// double up to and how long a message is kept, each as the handler words it.
// fromDefaults says the Postfix configuration could not be read and these are
// the compiled-in values.
func (p *OutQueue) WithRetryPolicy(firstRetry, backoffCap, lifetime string, fromDefaults bool) *OutQueue {
p.Retries.End = Plain("This Postfix's policy, read at panel start")
if fromDefaults {
p.Retries.End = Plain("Compiled-in defaults: the Postfix configuration could not be read")
}
p.Policy = []Fact{
{Label: "First retry", Value: Plain(firstRetry), Big: true},
{Label: "Later retries", Value: Plain("doubling"), Big: true, Note: Plain("capped at " + backoffCap)},
{Label: "Kept in queue", Value: Plain(lifetime), Big: true},
{Label: "Then", Value: Plain("bounced"), Big: true, Note: Plain("no attempt counter, only time")},
}
return p
}
-60
View File
@@ -1,60 +0,0 @@
package view
import (
"strings"
"testing"
)
// ---- Outbound queue
// The page prints postqueue's listing as it is and polls from its box; the
// retry policy sits under it, outside the poll.
func TestOutQueueShowsTheListingAndPollsItsBox(t *testing.T) {
out := renderSignedIn(t, "out-queue", outQueueFixture())
pageHas(t, "Outbound queue", out,
`<h1 class="title is-3">Outbound queue</h1>`, `<h2>Waiting</h2>`,
`id="out-queue" data-poll aria-live="polite" hx-get="/outbound/queue/fragment" hx-trigger="load" hx-swap="outerHTML"`,
`class="sp-log"`, `-Queue ID- --Size-- `, `4XcB7k2Jm9z1 3174 Sun Sep 21 13:58:07 alerts@example.org`,
`-- 3 Kbytes in 1 Request.`,
`aria-current="page"><i class="ti ti-stack-2"></i>Queue</a>`,
)
// What Postfix printed is escaped, not interpreted.
pageHas(t, "Outbound queue", out, `(host mx1.example.org[192.0.2.10] said: 451 4.7.1 Greylisted`)
pageLacks(t, "Outbound queue", out, "<table", "Queue is empty.")
frag := renderFragment(t, "out_queue_body", outQueueFixture())
if !strings.HasPrefix(frag, `<div class="box" id="out-queue" data-poll`) || strings.Contains(frag, "sp-head") || strings.Contains(frag, "How retries work") {
t.Errorf("the fragment is not the polled box alone:\n%.200s", frag)
}
if strings.Count(out, `class="sp-log"`) != 1 || strings.Count(frag, `class="sp-log"`) != 1 {
t.Error("the page and its fragment should each carry the listing once")
}
}
// The policy is the page's, wherever it was read from: its facts, and a head
// that says so when they are the compiled-in defaults.
func TestOutQueueStatesTheRetryPolicy(t *testing.T) {
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), "", "").WithRetryPolicy("10 minutes", "about 1 hour 7 minutes", "2 days", false))
pageHas(t, "Outbound queue", out,
`<h2>How retries work</h2>`, `This Postfix&#39;s policy, read at panel start`,
`<dd class="sp-big">10 minutes</dd>`, `<dd class="sp-big">doubling</dd>`, `capped at about 1 hour 7 minutes`,
`<dd class="sp-big">2 days</dd>`, `<dd class="sp-big">bounced</dd>`, `no attempt counter, only time`,
)
pageLacks(t, "Outbound queue", out, "Compiled-in defaults")
out = renderSignedIn(t, "out-queue", NewOutQueue(admin(), "", "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", true))
pageHas(t, "Outbound queue", out, "Compiled-in defaults: the Postfix configuration could not be read")
pageLacks(t, "Outbound queue", out, "read at panel start")
}
// With nothing to list the box says why instead: the queue holds nothing, or
// it could not be read — and never both.
func TestOutQueueSaysWhyItHasNoListing(t *testing.T) {
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), "", ""))
pageHas(t, "empty queue", out, `class="sp-empty"`, "Queue is empty.")
pageLacks(t, "empty queue", out, `class="sp-log"`, "Could not read")
out = renderSignedIn(t, "out-queue", NewOutQueue(admin(), "ignored", "Could not read the mail queue."))
pageHas(t, "unreadable queue", out, `class="sp-empty"`, "Could not read the mail queue.")
pageLacks(t, "unreadable queue", out, `class="sp-log"`, "Queue is empty.", "ignored")
}
+221
View File
@@ -0,0 +1,221 @@
package view
// The data of the two queues, Outbound › Queue and Inbound › Queue
// (templates/out_queue.html and in_queue.html, mockups out-queue.html and
// in-queue.html): what Postfix still holds, a row per recipient, and the retry
// policy that decides how long it holds it. Both are one Postfix queue, split
// by the recipient's domain: a recipient of an inbound domain is the Inbound
// queue's, every other the Outbound queue's. The Waiting box polls; a page and
// its fragment render the same box (templates/queue_body.html).
//
// A page is a typed struct that embeds Meta, built by a constructor and filled
// by With… methods; the handler reads Postfix, the view decides what is said and
// how it is worded.
import (
"fmt"
"sort"
"time"
)
const (
outboundQueue = "/outbound/queue"
inboundQueue = "/inbound/queue"
)
// maxQueueRows is how many recipients a queue page lists. A backlog of
// thousands is one receiver or upstream failing, and the oldest rows say which;
// the page polls, and a table of every one would be drawn again each time.
const maxQueueRows = 200
// QueueEntry is one recipient of a message Postfix holds, as the handler hands
// it over: the message's queue id, which of Postfix's queues it is in, when it
// arrived and its size, its sender and this recipient, why the recipient's
// last attempt did not finish (empty when there was none), and the page of the
// message in the Outbound log ("" when it has none, or for the Inbound queue).
type QueueEntry struct {
ID string
Queue string // "deferred", "active", "hold", "incoming", "maildrop"
Arrival time.Time
Size int64
Sender string
Recipient string
Reason string
Href string
}
// QueueRow is one row of the Waiting table.
type QueueRow struct {
ID string
Arrived string
From string
To string
Reply string
Href string
}
// Queue is the data of either queue page. It is for the global role.
type Queue struct {
Meta
Head Head
Queue Box
Rows []QueueRow
// Details is the table's last column: a row of the Outbound queue opens
// its message in the Outbound log; the Inbound queue has no such page.
Details bool
// Empty is shown in place of the table when there is no row: the queue
// holds nothing for this direction, or it could not be read.
Empty EmptyState
// Foot says that the table is cut at maxQueueRows; nil when it is not.
Foot *Foot
Retries Box
Policy []Fact
// then is the note under "bounced": what the mockup of each page says.
then string
}
// NewOutQueue builds Outbound › Queue from the recipients waiting for a
// receiving server. errText is why the queue could not be read, empty when it
// could.
func NewOutQueue(m Meta, entries []QueueEntry, errText string) *Queue {
m.Title, m.Section, m.Page = "Outbound queue", "outbound", "queue"
p := &Queue{
Meta: m,
Head: Head{
Kicker: "Outbound",
Title: "Outbound queue",
Lead: Plain("Messages accepted from applications and still waiting for the receiving server. Refreshes on its own."),
},
Queue: Box{No: "01", Title: "Waiting", ID: "out-queue", Poll: outboundQueue + "/fragment",
Help: topicLink(HelpQueue, "What waits here and how Postfix retries")},
Details: true,
Empty: EmptyState{Icon: "ti-stack-2", Text: Plain("Nothing is waiting. Every message has reached its receiving server.")},
Retries: Box{No: "02", Title: "How retries work"},
then: "no attempt counter, only time",
}
return p.fill(entries, errText)
}
// NewInQueue builds Inbound › Queue from the recipients of inbound domains
// waiting for their upstream.
func NewInQueue(m Meta, entries []QueueEntry, errText string) *Queue {
m.Title, m.Section, m.Page = "Inbound queue", "inbound", "queue"
p := &Queue{
Meta: m,
Head: Head{
Kicker: "Inbound",
Title: "Inbound queue",
Lead: Rich("Mail accepted on port 25 and still waiting for its upstream. The same Postfix queue as ",
Link(outboundQueue, "outbound"), ", filtered by recipient domain."),
},
Queue: Box{No: "01", Title: "Waiting", ID: "in-queue", Poll: inboundQueue + "/fragment",
Help: topicLink(HelpInboundQueue, "What waits here and why")},
Empty: EmptyState{Icon: "ti-stack-2", Text: Plain("Nothing is waiting. Every accepted message has reached its upstream.")},
Retries: Box{No: "02", Title: "How retries work"},
then: "back to the original sender",
}
return p.fill(entries, errText)
}
// fill lays the rows out, oldest first - the message that has waited longest
// is the one that says what is wrong - and heads the box with the count of
// messages and their size, or with "Refreshes on its own" while there is none.
func (p *Queue) fill(entries []QueueEntry, errText string) *Queue {
p.Queue.End = Plain("Refreshes on its own")
if errText != "" {
p.Empty = EmptyState{Icon: "ti-alert-circle", Text: Plain(errText)}
return p
}
if len(entries) == 0 {
return p
}
sorted := append([]QueueEntry(nil), entries...)
sort.SliceStable(sorted, func(i, j int) bool {
if !sorted[i].Arrival.Equal(sorted[j].Arrival) {
return sorted[i].Arrival.Before(sorted[j].Arrival)
}
return sorted[i].ID < sorted[j].ID
})
sizes := map[string]int64{}
for _, e := range sorted {
sizes[e.ID] = e.Size
}
var total int64
for _, s := range sizes {
total += s
}
p.Queue.End = Plain(countOf(len(sizes), "message") + " · " + byteSize(total))
for i, e := range sorted {
if i == maxQueueRows {
p.Foot = &Foot{Text: Plain(fmt.Sprintf("The oldest %d of %d recipients waiting. postqueue -p on the server lists them all.", maxQueueRows, len(sorted)))}
break
}
from := e.Sender
if from == "" {
from = "<>" // a bounce has no sender
}
p.Rows = append(p.Rows, QueueRow{
ID: e.ID, Arrived: e.Arrival.Local().Format("01-02 15:04:05"),
From: from, To: e.Recipient, Reply: queueReply(e), Href: e.Href,
})
}
return p
}
// queueReply is the Last reply cell: the reason Postfix keeps for the
// recipient's last attempt, or, before there was one, where the message is.
func queueReply(e QueueEntry) string {
if e.Reason != "" {
return e.Reason
}
switch e.Queue {
case "active":
return "Being delivered now"
case "hold":
return "On hold: not tried until it is released"
case "incoming", "maildrop":
return "Just arrived, not tried yet"
}
return "Not tried yet"
}
// countOf is "1 message", "3 messages".
func countOf(n int, noun string) string {
if n == 1 {
return "1 " + noun
}
return fmt.Sprintf("%d %ss", n, noun)
}
// byteSize is a size as the mockup writes it: "3.1 kB" for 3174 bytes, counted
// in 1024s as postqueue counts its Kbytes.
func byteSize(n int64) string {
switch {
case n < 1024:
return fmt.Sprintf("%d B", n)
case n < 1024*1024:
return fmt.Sprintf("%.1f kB", float64(n)/1024)
default:
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
}
// WithRetryPolicy fills the second box: the first retry, the cap the later ones
// double up to and how long a message is kept, each as the handler words it.
// fromDefaults says the Postfix configuration could not be read and these are
// the compiled-in values.
func (p *Queue) WithRetryPolicy(firstRetry, backoffCap, lifetime string, fromDefaults bool) *Queue {
p.Retries.End = Plain("This Postfix's policy, read at panel start")
if fromDefaults {
p.Retries.End = Plain("Compiled-in defaults: the Postfix configuration could not be read")
}
p.Policy = []Fact{
{Label: "First retry", Value: Plain(firstRetry), Big: true},
{Label: "Later retries", Value: Plain("doubling"), Big: true, Note: Plain("capped at " + backoffCap)},
{Label: "Kept in queue", Value: Plain(lifetime), Big: true},
{Label: "Then", Value: Plain("bounced"), Big: true, Note: Plain(p.then)},
}
return p
}
+123
View File
@@ -0,0 +1,123 @@
package view
import (
"fmt"
"strings"
"testing"
"time"
)
// ---- Outbound queue
// The page is the mockup's table, a row per recipient with its last reply and
// the link to the message, and it polls from its box; the retry policy sits
// under it, outside the poll.
func TestOutQueueShowsTheTableAndPollsItsBox(t *testing.T) {
out := renderSignedIn(t, "out-queue", outQueueFixture())
pageHas(t, "Outbound queue", out,
`<h1 class="title is-3">Outbound queue</h1>`, `<h2>Waiting</h2>`,
`Messages accepted from applications and still waiting for the receiving server. Refreshes on its own.`,
`id="out-queue" data-poll aria-live="polite" hx-get="/outbound/queue/fragment" hx-trigger="load" hx-swap="outerHTML"`,
`1 message · 3.1 kB`, `href="/help#queue"`,
`<th>Queue id</th><th>Arrived</th><th>From</th><th>To</th><th>Last reply</th><th></th>`,
`<td class="sp-mono">4XcB7k2Jm9z1</td><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td>`,
`<td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td>`,
`<td class="sp-muted">451 4.7.1 Greylisted, try again in 5 minutes</td><td class="sp-actions"><a href="/outbound/log/41">Details</a></td>`,
`aria-current="page"><i class="ti ti-stack-2"></i>Queue</a>`,
)
pageLacks(t, "Outbound queue", out, `class="sp-log"`, "Nothing is waiting", "postqueue -p")
frag := renderFragment(t, "queue_body", outQueueFixture())
if !strings.HasPrefix(frag, `<div class="box" id="out-queue" data-poll`) || strings.Contains(frag, "sp-head") || strings.Contains(frag, "How retries work") {
t.Errorf("the fragment is not the polled box alone:\n%.200s", frag)
}
if strings.Count(out, "4XcB7k2Jm9z1") != 1 || strings.Count(frag, "4XcB7k2Jm9z1") != 1 {
t.Error("the page and its fragment should each carry the row once")
}
}
// Rows come oldest first, a recipient each; the head counts messages and their
// size once however many recipients a message has; a recipient without a
// reason says where its message is, a bounce shows its empty sender, and a
// recipient whose message has no send-log row has no Details link.
func TestOutQueueRowsAndHead(t *testing.T) {
t0 := time.Date(2026, 10, 18, 9, 0, 0, 0, time.Local)
entries := []QueueEntry{
{ID: "NEWER", Queue: "active", Arrival: t0.Add(time.Hour), Size: 2048, Sender: "a@example.com", Recipient: "x@example.net", Href: "/outbound/log/2"},
{ID: "OLDER", Queue: "deferred", Arrival: t0, Size: 1000, Sender: "", Recipient: "b@example.net", Reason: "connect to mx.example.net[203.0.113.9]:25: Connection timed out"},
{ID: "OLDER", Queue: "deferred", Arrival: t0, Size: 1000, Sender: "", Recipient: "c@example.net", Reason: "connect to mx.example.net[203.0.113.9]:25: Connection timed out"},
{ID: "HELD", Queue: "hold", Arrival: t0.Add(2 * time.Hour), Size: 100, Sender: "a@example.com", Recipient: "y@example.net", Href: "/outbound/log/3"},
}
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), entries, ""))
pageHas(t, "queue", out, `3 messages · 3.1 kB`, `&lt;&gt;`, `Being delivered now`, `On hold: not tried until it is released`)
if i, j, k := strings.Index(out, "OLDER"), strings.Index(out, "NEWER"), strings.Index(out, "HELD"); i < 0 || !(i < j && j < k) {
t.Errorf("rows are not oldest first: OLDER %d, NEWER %d, HELD %d", i, j, k)
}
if strings.Count(out, `<td class="sp-actions"></td>`) != 2 {
t.Error("the two recipients of a message without a send-log row should have an empty Details cell")
}
}
// A backlog is cut at maxQueueRows, oldest kept, and the foot says how many
// there are.
func TestOutQueueCutsALongBacklog(t *testing.T) {
t0 := time.Date(2026, 10, 18, 9, 0, 0, 0, time.Local)
var entries []QueueEntry
for i := 0; i < maxQueueRows+5; i++ {
entries = append(entries, QueueEntry{ID: fmt.Sprintf("Q%04d", i), Queue: "deferred", Arrival: t0.Add(time.Duration(i) * time.Minute), Recipient: "r@example.net"})
}
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), entries, ""))
pageHas(t, "long queue", out, "Q0000", fmt.Sprintf("Q%04d", maxQueueRows-1),
fmt.Sprintf("The oldest %d of %d recipients waiting.", maxQueueRows, maxQueueRows+5), `class="sp-box-foot"`)
pageLacks(t, "long queue", out, fmt.Sprintf("Q%04d", maxQueueRows))
}
// The policy is the page's, wherever it was read from: its facts, and a head
// that says so when they are the compiled-in defaults.
func TestOutQueueStatesTheRetryPolicy(t *testing.T) {
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), nil, "").WithRetryPolicy("10 minutes", "about 1 hour 7 minutes", "2 days", false))
pageHas(t, "Outbound queue", out,
`<h2>How retries work</h2>`, `This Postfix&#39;s policy, read at panel start`,
`<dd class="sp-big">10 minutes</dd>`, `<dd class="sp-big">doubling</dd>`, `capped at about 1 hour 7 minutes`,
`<dd class="sp-big">2 days</dd>`, `<dd class="sp-big">bounced</dd>`, `no attempt counter, only time`,
)
pageLacks(t, "Outbound queue", out, "Compiled-in defaults")
out = renderSignedIn(t, "out-queue", NewOutQueue(admin(), nil, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", true))
pageHas(t, "Outbound queue", out, "Compiled-in defaults: the Postfix configuration could not be read")
pageLacks(t, "Outbound queue", out, "read at panel start")
}
// With nothing to list the box says why instead: the queue holds nothing, or
// it could not be read — and never both.
func TestOutQueueSaysWhyItHasNoRows(t *testing.T) {
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), nil, ""))
pageHas(t, "empty queue", out, `class="sp-empty"`, "Nothing is waiting. Every message has reached its receiving server.", "Refreshes on its own")
pageLacks(t, "empty queue", out, "<table", "Could not read")
out = renderSignedIn(t, "out-queue", NewOutQueue(admin(), []QueueEntry{{ID: "IGNORED"}}, "Could not read the mail queue."))
pageHas(t, "unreadable queue", out, `class="sp-empty"`, "Could not read the mail queue.")
pageLacks(t, "unreadable queue", out, "<table", "Nothing is waiting", "IGNORED")
}
// ---- Inbound queue
// The mockup's Inbound queue: nothing waiting, the help link, and the lead
// that links the Outbound queue; with rows, the same table without Details,
// since an inbound message has no page of its own.
func TestInQueue(t *testing.T) {
out := renderSignedIn(t, "in-queue", inQueueFixture())
pageHas(t, "Inbound queue", out,
`<h1 class="title is-3">Inbound queue</h1>`,
`The same Postfix queue as <a href="/outbound/queue">outbound</a>, filtered by recipient domain.`,
`id="in-queue" data-poll aria-live="polite" hx-get="/inbound/queue/fragment" hx-trigger="load" hx-swap="outerHTML"`,
`Nothing is waiting. Every accepted message has reached its upstream.`, `href="/help#inbound-queue"`,
`back to the original sender`,
)
rows := []QueueEntry{{ID: "IN0001", Queue: "deferred", Arrival: time.Date(2026, 10, 18, 9, 0, 0, 0, time.Local), Size: 5000,
Sender: "someone@example.net", Recipient: "staff@lists.example.com", Reason: "451 4.7.1 Greylisted"}}
out = renderSignedIn(t, "in-queue", NewInQueue(admin(), rows, ""))
pageHas(t, "Inbound queue", out, `1 message · 4.9 kB`, `<td class="sp-mono">IN0001</td>`, `staff@<wbr>lists.example.com`, `451 4.7.1 Greylisted`,
`<th>Last reply</th></tr>`)
pageLacks(t, "Inbound queue", out, "Details", `sp-actions`)
}
+14
View File
@@ -0,0 +1,14 @@
{{/* Inbound queue (mockup in-queue.html): what Postfix is still holding for an
inbound domain's upstream, in one box that polls (queue_body.html), and
under it the retry policy read once at panel start. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{template "queue_body" .}}
{{template "box_open" .Retries}}
{{template "body_open"}}
{{template "facts" .Policy}}
{{template "body_close"}}
{{template "box_close"}}
{{end}}
+4 -3
View File
@@ -115,7 +115,8 @@
<div class="navbar-dropdown">
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "domains")}} is-active{{end}}" href="/inbound/domains"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "log")}} is-active{{end}}" href="/inbound/log"><i class="ti ti-list-details"></i>Log</a>
{{if .ShowFilterLists}} <a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} is-active{{end}}" href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>
{{if .ShowQueue}} <a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "queue")}} is-active{{end}}" href="/inbound/queue"><i class="ti ti-stack-2"></i>Queue</a>
{{end}}{{if .ShowFilterLists}} <a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} is-active{{end}}" href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>
{{end}} </div>
</div>
{{end}}{{if .ShowServer}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "server"}} sp-current{{end}}">
@@ -158,7 +159,7 @@
{{define "touchnav"}}<details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel">
{{if .ShowOverview}}<a href="/overview"{{if eq .Section "overview"}} aria-current="page"{{end}}>Overview</a>
{{end}}{{if .ShowOutbound}}<details class="sp-touchnav-group"{{if eq .Section "outbound"}} open{{end}}><summary>Outbound</summary><a href="/outbound/domains"{{if and (eq .Section "outbound") (eq .Page "domains")}} aria-current="page"{{end}}><i class="ti ti-world-upload"></i>Domains</a><a href="/outbound/log"{{if and (eq .Section "outbound") (eq .Page "log")}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/outbound/queue"{{if and (eq .Section "outbound") (eq .Page "queue")}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowDMARC}}<a href="/outbound/dmarc"{{if and (eq .Section "outbound") (eq .Page "dmarc")}} aria-current="page"{{end}}><i class="ti ti-report-analytics"></i>DMARC reports</a>{{end}}</details>
{{end}}{{if .ShowInbound}}<details class="sp-touchnav-group"{{if eq .Section "inbound"}} open{{end}}><summary>Inbound</summary><a href="/inbound/domains"{{if and (eq .Section "inbound") (eq .Page "domains")}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if and (eq .Section "inbound") (eq .Page "log")}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</details>
{{end}}{{if .ShowInbound}}<details class="sp-touchnav-group"{{if eq .Section "inbound"}} open{{end}}><summary>Inbound</summary><a href="/inbound/domains"{{if and (eq .Section "inbound") (eq .Page "domains")}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if and (eq .Section "inbound") (eq .Page "log")}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/inbound/queue"{{if and (eq .Section "inbound") (eq .Page "queue")}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</details>
{{end}}{{if .ShowServer}}<details class="sp-touchnav-group"{{if eq .Section "server"}} open{{end}}><summary>Server</summary><a href="/server/health"{{if and (eq .Section "server") (eq .Page "health")}} aria-current="page"{{end}}><i class="ti ti-heartbeat"></i>Health</a><a href="/server/log"{{if and (eq .Section "server") (eq .Page "log")}} aria-current="page"{{end}}><i class="ti ti-file-text"></i>System log</a><a href="/server/backup"{{if and (eq .Section "server") (eq .Page "backup")}} aria-current="page"{{end}}><i class="ti ti-archive"></i>Backup</a><a href="/server/users"{{if and (eq .Section "server") (eq .Page "users")}} aria-current="page"{{end}}><i class="ti ti-users"></i>Users</a><a href="/server/settings"{{if and (eq .Section "server") (eq .Page "settings")}} aria-current="page"{{end}}><i class="ti ti-settings"></i>Settings</a></details>
{{end}}<details class="sp-touchnav-group"{{if eq .Section "user"}} open{{end}}><summary><i class="ti ti-user-circle"></i>{{.User}}</summary><a href="/account"><i class="ti ti-key"></i>Account</a><a href="/help"><i class="ti ti-help"></i>Help</a><form method="post" action="/logout"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details>
</div></details>{{end}}
@@ -167,6 +168,6 @@
pages of the current group, the current one marked. A page outside every
group (the user menu, the kit page's neighbours) has none. */}}
{{define "subnav"}}{{if and .ShowOutbound (eq .Section "outbound")}}<div class="sp-subnav"><div class="container"><a href="/outbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-upload"></i>Domains</a><a href="/outbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/outbound/queue"{{if eq .Page "queue"}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowDMARC}}<a href="/outbound/dmarc"{{if eq .Page "dmarc"}} aria-current="page"{{end}}><i class="ti ti-report-analytics"></i>DMARC reports</a>{{end}}</div></div>
{{else if and .ShowInbound (eq .Section "inbound")}}<div class="sp-subnav"><div class="container"><a href="/inbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if eq .Page "filter-lists"}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</div></div>
{{else if and .ShowInbound (eq .Section "inbound")}}<div class="sp-subnav"><div class="container"><a href="/inbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/inbound/queue"{{if eq .Page "queue"}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if eq .Page "filter-lists"}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</div></div>
{{else if and .ShowServer (eq .Section "server")}}<div class="sp-subnav"><div class="container"><a href="/server/health"{{if eq .Page "health"}} aria-current="page"{{end}}><i class="ti ti-heartbeat"></i>Health</a><a href="/server/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-file-text"></i>System log</a><a href="/server/backup"{{if eq .Page "backup"}} aria-current="page"{{end}}><i class="ti ti-archive"></i>Backup</a><a href="/server/users"{{if eq .Page "users"}} aria-current="page"{{end}}><i class="ti ti-users"></i>Users</a><a href="/server/settings"{{if eq .Page "settings"}} aria-current="page"{{end}}><i class="ti ti-settings"></i>Settings</a></div></div>
{{end}}{{end}}
+4 -4
View File
@@ -1,10 +1,10 @@
{{/* Outbound queue (mockup out-queue.html): what Postfix is still holding, in one
box that polls (out_queue_body.html), and under it the retry policy read once
at panel start. The queue is postqueue's own listing until it is a table. */}}
{{/* Outbound queue (mockup out-queue.html): what Postfix is still holding for a
receiving server, in one box that polls (queue_body.html), and under it the
retry policy read once at panel start. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{template "out_queue_body" .}}
{{template "queue_body" .}}
{{template "box_open" .Retries}}
{{template "body_open"}}
@@ -1,6 +0,0 @@
{{/* The polled part of the Outbound queue, in the page and in its fragment (GET
/outbound/queue/fragment) alike: the box itself polls (.Queue.Poll), so the
response replaces it whole. With nothing to list it says why instead. */}}
{{define "out_queue_body"}}{{template "box_open" .Queue}}
{{if .Lines}}{{template "log_pane" .Lines}}{{else}}{{template "empty_state" .Empty}}{{end}}
{{template "box_close"}}{{end}}
@@ -0,0 +1,13 @@
{{/* The polled part of either queue, in the page and in its fragment (GET
/outbound/queue/fragment, /inbound/queue/fragment) alike: the box itself
polls (.Queue.Poll), so the response replaces it whole. A row per recipient,
oldest first; with nothing to list it says why instead. */}}
{{define "queue_body"}}{{template "box_open" .Queue}}
{{if .Rows}}<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Queue id</th><th>Arrived</th><th>From</th><th>To</th><th>Last reply</th>{{if .Details}}<th></th>{{end}}</tr></thead>
<tbody>{{range .Rows}}
<tr><td class="sp-mono">{{.ID}}</td><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Arrived}}</td><td>{{wbr_at .From}}</td><td>{{wbr_at .To}}</td><td class="sp-muted">{{.Reply}}</td>{{if $.Details}}<td class="sp-actions">{{if .Href}}<a href="{{.Href}}">Details</a>{{end}}</td>{{end}}</tr>{{end}}
</tbody>
</table></div>
{{with .Foot}}{{template "box_foot" .}}{{end}}{{else}}{{template "empty_state" .Empty}}{{end}}
{{template "box_close"}}{{end}}
+32
View File
@@ -0,0 +1,32 @@
package view
import (
"os"
"path/filepath"
"strings"
"testing"
)
// A screen the mockups draw and the panel does not build, or builds otherwise,
// is one the guards were told to let pass (deferredScreens, outlineExempt). It
// must be named in docs/roadmap.md, a row or an idea, so that it is not simply
// forgotten while every test is green: the queue as a table and the Inbound
// queue were (development.md § Panel pages: the component kit and its guards).
func TestUnbuiltScreensAreOnTheRoadmap(t *testing.T) {
roadmap, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "roadmap.md"))
if err != nil {
t.Fatalf("read the roadmap: %v", err)
}
var names []string
for name := range deferredScreens {
names = append(names, name)
}
for name := range outlineExempt {
names = append(names, name)
}
for _, name := range names {
if !strings.Contains(string(roadmap), "`"+name+"`") {
t.Errorf("screen %q is drawn and not built as drawn, and docs/roadmap.md does not name it (`%s`)", name, name)
}
}
}
+3 -2
View File
@@ -58,13 +58,14 @@ var pageFiles = map[string][]string{
"in-domain": {"templates/in_domain.html"},
"in-domain-delete": {"templates/in_domain_delete.html"},
"in-log": {"templates/in_log.html"},
"in-queue": {"templates/in_queue.html", "templates/queue_body.html"},
"in-filter-lists": {"templates/in_filter_lists.html"},
"out-log": {"templates/out_log.html", "templates/out_log_rows.html"},
"out-message": {"templates/out_message.html"},
"dmarc": {"templates/dmarc.html"},
"dmarc-domain": {"templates/dmarc_domain.html"},
"dmarc-report": {"templates/dmarc_report.html"},
"out-queue": {"templates/out_queue.html", "templates/out_queue_body.html"},
"out-queue": {"templates/out_queue.html", "templates/queue_body.html"},
"components": {"templates/kit.html"},
}
@@ -72,7 +73,7 @@ var pageFiles = map[string][]string{
// template file, for standalone rendering by the HTMX polling endpoints.
var fragmentFiles = map[string]string{
"out_log_rows": "templates/out_log_rows.html",
"out_queue_body": "templates/out_queue_body.html",
"queue_body": "templates/queue_body.html",
"system_log_body": "templates/system_log_body.html",
"health_body": "templates/health_body.html",
"overview_poll": "templates/overview_cards.html",