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
+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
}