queue-table: Outbound › Queue as the drawn table, Inbound › Queue built
Owner, 2026-10-18: "Фиаско, что очередь не была сделана. Как мы это допустили? Исправляем!" The mockups out-queue and in-queue (design629de5e, guards8525e8e). - 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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user