inbound-antispam-panel: Inbound log, Why as a sheet; no Deny on a Postfix reject
Follows the two design commits before it (owner's decisions quoted there). Kit: a new component, Sheet / SheetRow (components.go), partials sheet_tag and sheet (components.html), its rules in panel.css as in theme.css, and its sample on the kit page next to status_tag. It is the browser's popover: no script. Inbound log: the Why column is gone; the decision tag opens a sheet with who decided, then either the filter's verdict and its symbols one a line (a detail the journal milter wrote) or the reply (a detail the tailer read from mail.log - it starts with its code), and the subject whole. The sheet's id is the journal row's. A row Postfix itself refused no longer links to the filter lists. Tests: the page fixture and view tests for the sheet's markup and content (TestWhyRows), the link rule, three Deny and two Allow on the mockup's six rows; e2e reads "Decided by" and "Reply" from the sheet and wants no sender link on the refused row. Seen: the fixture page with a sheet open, headless Edge at 1360 px - the same as the accepted drawing. Checked on the test server, uncommitted tree: go vet ./... ok, go test ./... ok (22 packages), make e2e ok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -90,7 +90,7 @@ func (h *Handlers) HandleInboundLog(w http.ResponseWriter, r *http.Request) {
|
||||
table := make([]view.InLogRow, len(rows))
|
||||
for i, row := range rows {
|
||||
table[i] = out.NewRow(view.InLogRowInput{
|
||||
At: row.CreatedAt, From: row.From, Client: row.ClientIP, To: row.To, Subject: row.Subject,
|
||||
ID: row.ID, At: row.CreatedAt, From: row.From, Client: row.ClientIP, To: row.To, Subject: row.Subject,
|
||||
Decision: row.Decision, Engine: row.Engine, Detail: row.Detail,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,9 +94,9 @@ func TestInboundLogForTheGlobalRole(t *testing.T) {
|
||||
`Rows older than 30 days are deleted — change that in <a href="/server/settings">Settings</a>.`,
|
||||
`alice@<wbr>a.example.com`, `bob@<wbr>b.example.com`, `Hello A`, `Hello B`,
|
||||
`<option value="a.example.com">a.example.com</option>`, `<option value="b.example.com">b.example.com</option>`,
|
||||
`<span class="tag is-success is-light">accept</span>`, `<span class="tag is-primary is-light">quarantine</span>`,
|
||||
`class="tag is-success is-light" popovertarget="why-`, `class="tag is-primary is-light" popovertarget="why-`,
|
||||
`<span class="sp-mono sp-muted sp-small">203.0.113.9</span>`,
|
||||
`rspamd · detail of Hello A`,
|
||||
`<tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>detail of Hello A</td></tr>`,
|
||||
`<a href="/inbound/filter-lists?list=deny&kind=address&value=sender%40example.net">Deny sender</a>`,
|
||||
`<a href="/inbound/filter-lists?list=allow&kind=address&value=sender%40example.net">Allow sender</a>`,
|
||||
`Page 1 of 1`, `Allow / Deny sender opens <a href="/inbound/filter-lists">Filter lists</a>`,
|
||||
|
||||
@@ -105,6 +105,27 @@ type Tag struct {
|
||||
Label string
|
||||
}
|
||||
|
||||
// Sheet is what does not fit a table row, opened over the page from the row's
|
||||
// tag (partials "sheet_tag" and "sheet"; used by the Inbound log, where it says
|
||||
// why a message was decided the way it was). It is the browser's own popover:
|
||||
// the tag is a button that names the sheet by ID, so ID is unique on the page.
|
||||
// Its content is a table of name and value.
|
||||
type Sheet struct {
|
||||
ID string
|
||||
Tag Tag
|
||||
Title string
|
||||
Rows []SheetRow
|
||||
}
|
||||
|
||||
// SheetRow is one name and its value in a sheet. A value of several lines is
|
||||
// one line each; Mono is for what is read as written - a reply, a list of
|
||||
// symbols.
|
||||
type SheetRow struct {
|
||||
Label string
|
||||
Lines []string
|
||||
Mono bool
|
||||
}
|
||||
|
||||
// statusClass is the one mapping of status to Bulma tag classes — box 03 of the
|
||||
// kit page. A page passes the status it knows (a DKIM check says ok, a message
|
||||
// says deferred) and never picks a colour.
|
||||
|
||||
@@ -420,12 +420,14 @@ func userDeleteFixture() *UserDelete {
|
||||
func inLogFixture() *InLog {
|
||||
p := NewInLog(admin(), 90, []string{"lists.example.org", "acme.io"},
|
||||
InLogFilter{From: "2026-09-14", To: "2026-09-21"})
|
||||
var id int64
|
||||
row := func(clock, from, client, to, subject, decision, engine, detail string) InLogRow {
|
||||
at, err := time.Parse("2006-01-02 15:04:05", "2026-09-21 "+clock)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return p.NewRow(InLogRowInput{At: at, From: from, Client: client, To: to, Subject: subject,
|
||||
id++
|
||||
return p.NewRow(InLogRowInput{ID: id, At: at, From: from, Client: client, To: to, Subject: subject,
|
||||
Decision: decision, Engine: engine, Detail: detail})
|
||||
}
|
||||
return p.WithRows([]InLogRow{
|
||||
|
||||
@@ -12,6 +12,7 @@ package view
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -67,23 +68,24 @@ type InLog struct {
|
||||
|
||||
// InLogRow is one recipient of one message: when it was decided, who the mail
|
||||
// was from (and the address of the server that offered it) and for, its subject,
|
||||
// the decision as a tag, who decided and why, and — for the global role, who
|
||||
// alone edits the filter lists — the way to correct the decision.
|
||||
// the decision as a tag that opens the sheet saying who decided and why, and —
|
||||
// for the global role, who alone edits the filter lists — the way to correct
|
||||
// the decision.
|
||||
type InLogRow struct {
|
||||
Time string
|
||||
From string
|
||||
Client string
|
||||
To string
|
||||
Subject string
|
||||
Decision Tag
|
||||
Why string
|
||||
Action *Anchor
|
||||
Time string
|
||||
From string
|
||||
Client string
|
||||
To string
|
||||
Subject string
|
||||
Why Sheet
|
||||
Action *Anchor
|
||||
}
|
||||
|
||||
// InLogRowInput is what the journal holds about one row. Subject, Detail and
|
||||
// From may be empty: refused mail is read from mail.log, which has no subject,
|
||||
// and a filter that said nothing has no detail.
|
||||
type InLogRowInput struct {
|
||||
ID int64
|
||||
At time.Time
|
||||
From string
|
||||
Client string
|
||||
@@ -140,13 +142,12 @@ func NewInLog(m Meta, retentionDays int, domains []string, filter InLogFilter) *
|
||||
|
||||
// NewRow words one row of the journal for the viewer of the page.
|
||||
func (p *InLog) NewRow(in InLogRowInput) InLogRow {
|
||||
why := in.Engine
|
||||
if in.Detail != "" {
|
||||
why += " · " + in.Detail
|
||||
}
|
||||
r := InLogRow{
|
||||
Time: FormatLogTime(in.At), From: orDash(in.From), Client: in.Client, To: in.To, Subject: in.Subject,
|
||||
Decision: Tag{Status: in.Decision}, Why: why,
|
||||
Why: Sheet{
|
||||
ID: "why-" + strconv.FormatInt(in.ID, 10), Tag: Tag{Status: in.Decision}, Title: "Why",
|
||||
Rows: whyRows(in.Engine, in.Detail, in.Subject),
|
||||
},
|
||||
}
|
||||
if p.IsGlobal {
|
||||
r.Action = senderAction(in.Decision, in.Engine, in.From)
|
||||
@@ -154,11 +155,36 @@ func (p *InLog) NewRow(in InLogRowInput) InLogRow {
|
||||
return r
|
||||
}
|
||||
|
||||
// whyRows is the content of a row's sheet: who decided, what they said, and
|
||||
// the subject whole, which the table clips. What was said has one of two
|
||||
// shapes, told apart by how it starts. The inbound journal milter writes the
|
||||
// filter's verdict on mail that passed - an action and a score, then the
|
||||
// symbols after a semicolon. The log tailer writes the reply a refusal was
|
||||
// given, which starts with its code.
|
||||
func whyRows(engine, detail, subject string) []SheetRow {
|
||||
rows := []SheetRow{{Label: "Decided by", Lines: []string{engine}}}
|
||||
switch {
|
||||
case detail == "":
|
||||
case detail[0] >= '0' && detail[0] <= '9':
|
||||
rows = append(rows, SheetRow{Label: "Reply", Lines: []string{detail}, Mono: true})
|
||||
default:
|
||||
verdict, symbols, _ := strings.Cut(detail, "; ")
|
||||
rows = append(rows, SheetRow{Label: "Verdict", Lines: []string{verdict}})
|
||||
if symbols != "" {
|
||||
rows = append(rows, SheetRow{Label: "Symbols", Lines: strings.Fields(symbols), Mono: true})
|
||||
}
|
||||
}
|
||||
if subject != "" {
|
||||
rows = append(rows, SheetRow{Label: "Subject", Lines: []string{subject}})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// senderAction is the way to correct a decision from its row, if there is one:
|
||||
// a message that got through is the one to deny its sender for, a message the
|
||||
// filter turned away or put off is the one to allow its sender for, and a
|
||||
// refusal by Postfix's own policy (an unknown recipient, say) can only be
|
||||
// answered by denying — allowing the sender would not change what Postfix
|
||||
// a message that got through is the one to deny its sender for, and a message
|
||||
// the filter turned away, put off or held is the one to allow its sender for.
|
||||
// A refusal by Postfix's own policy (an unknown recipient, say) offers nothing:
|
||||
// the message is refused already and the lists would not change what Postfix
|
||||
// thinks of the recipient. A row with no sender has nobody to list.
|
||||
func senderAction(decision, engine, from string) *Anchor {
|
||||
if from == "" {
|
||||
@@ -170,8 +196,6 @@ func senderAction(decision, engine, from string) *Anchor {
|
||||
list = "deny"
|
||||
case engine == "rspamd":
|
||||
list = "allow"
|
||||
case decision == "reject" && engine == "postfix":
|
||||
list = "deny"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -199,7 +223,9 @@ func (p *InLog) WithRows(rows []InLogRow, page, last int) *InLog {
|
||||
p.Foot.Link = &Anchor{Href: InLogHref(p.filter, page+1), Label: "Older →"}
|
||||
}
|
||||
if p.IsGlobal {
|
||||
p.Foot.End = Rich("Allow / Deny sender opens ", Link(inboundFilterLists, "Filter lists"), " with the entry filled in")
|
||||
p.Foot.End = Rich("A decision opens its reason · Allow / Deny sender opens ", Link(inboundFilterLists, "Filter lists"), " with the entry filled in")
|
||||
} else {
|
||||
p.Foot.End = Plain("A decision opens its reason")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -22,19 +22,29 @@ func TestInLogShowsTheRowsAndTheirFilters(t *testing.T) {
|
||||
`<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">`,
|
||||
`<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">`,
|
||||
`href="/help#inbound-log" title="What the decisions mean"`,
|
||||
`<th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th>`,
|
||||
`<th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th></th></tr>`,
|
||||
`09-21 14:00:18`,
|
||||
`t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span>`,
|
||||
`dev@<wbr>lists.example.org`, `title="Re: [dev] patch series v3"`,
|
||||
`<td class="sp-clip sp-muted">—</td>`,
|
||||
`<span class="tag is-success is-light">accept</span>`, `<span class="tag is-danger is-light">reject</span>`,
|
||||
`<span class="tag is-warning is-light">tempfail</span>`,
|
||||
`rspamd · no action, score 0.40 / 15.00`,
|
||||
`postfix · 550 5.1.1 <sales@lists.example.org>: Recipient address rejected`,
|
||||
// the decision is a tag that opens the sheet with who decided and why
|
||||
`<button type="button" class="tag is-success is-light" popovertarget="why-1">accept</button><div class="sp-sheet" id="why-1" popover>`,
|
||||
`<div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-1" popovertargetaction="hide" aria-label="Close">`,
|
||||
`<button type="button" class="tag is-danger is-light" popovertarget="why-3">reject</button>`,
|
||||
`<button type="button" class="tag is-warning is-light" popovertarget="why-5">tempfail</button>`,
|
||||
`<tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 0.40 / 15.00</td></tr><tr><th>Subject</th><td>Re: [dev] patch series v3</td></tr>`,
|
||||
`<tr><th>Verdict</th><td>add header, score 9.80 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">PHISHING(5.00)<br>FORGED_SENDER(3.00)<br>R_SPF_FAIL(1.00)</td></tr>`,
|
||||
`<tr><th>Decided by</th><td>postfix</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">550 5.1.1 <sales@lists.example.org>: Recipient address rejected: User unknown in relay recipient table</td></tr></tbody>`,
|
||||
`Page 1 of 38`, `<a href="/inbound/log?from=2026-09-14&p=2&to=2026-09-21">Older →</a>`,
|
||||
`Allow / Deny sender opens <a href="/inbound/filter-lists">Filter lists</a> with the entry filled in`,
|
||||
`A decision opens its reason · Allow / Deny sender opens <a href="/inbound/filter-lists">Filter lists</a> with the entry filled in`,
|
||||
)
|
||||
pageLacks(t, "Inbound log", out, `← Newer`, `data-poll`, `hx-get`)
|
||||
pageLacks(t, "Inbound log", out, `← Newer`, `data-poll`, `hx-get`, `<th>Why</th>`)
|
||||
// A message Postfix itself refused offers no way into the lists: one Deny
|
||||
// for each of the three accepted messages, one Allow for each the filter
|
||||
// refused or put off.
|
||||
if deny, allow := strings.Count(out, `>Deny sender</a>`), strings.Count(out, `>Allow sender</a>`); deny != 3 || allow != 2 {
|
||||
t.Errorf("the fixture's six rows offer Deny %d times and Allow %d times, want 3 and 2", deny, allow)
|
||||
}
|
||||
|
||||
// Without the global role there is nowhere to change the retention, and
|
||||
// nowhere to send a sender.
|
||||
@@ -96,11 +106,11 @@ func TestInLogRowWording(t *testing.T) {
|
||||
p := NewInLog(admin(), 90, nil, InLogFilter{})
|
||||
at := time.Date(2026, 9, 21, 14, 0, 18, 0, time.UTC)
|
||||
r := p.NewRow(InLogRowInput{At: at, From: "a@example.net", To: "b@acme.io", Decision: "accept", Engine: "selfpost"})
|
||||
if r.Why != "selfpost" || r.Time != "09-21 14:00:18" || r.Client != "" {
|
||||
if len(r.Why.Rows) != 1 || r.Why.Rows[0].Lines[0] != "selfpost" || r.Time != "09-21 14:00:18" || r.Client != "" {
|
||||
t.Errorf("row = %+v", r)
|
||||
}
|
||||
r = p.NewRow(InLogRowInput{At: at, To: "b@acme.io", Decision: "reject", Engine: "postfix", Detail: "550 5.1.1 no"})
|
||||
if r.Why != "postfix · 550 5.1.1 no" || r.From != "—" || r.Action != nil {
|
||||
r = p.NewRow(InLogRowInput{ID: 7, At: at, To: "b@acme.io", Decision: "reject", Engine: "postfix", Detail: "550 5.1.1 no"})
|
||||
if r.Why.ID != "why-7" || r.Why.Tag.Status != "reject" || len(r.Why.Rows) != 2 || r.Why.Rows[1].Label != "Reply" || r.From != "—" || r.Action != nil {
|
||||
t.Errorf("row = %+v", r)
|
||||
}
|
||||
out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{r}, 1, 1))
|
||||
@@ -110,7 +120,7 @@ func TestInLogRowWording(t *testing.T) {
|
||||
|
||||
// Which decisions lead to which list, for the global role: the sender of mail
|
||||
// that got through can be denied, the sender of mail the filter turned away or
|
||||
// put off can be allowed, and a refusal that is Postfix's own can only be denied.
|
||||
// put off can be allowed, and a refusal that is Postfix's own offers neither.
|
||||
func TestInLogSenderAction(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
decision, engine, from, label, href string
|
||||
@@ -120,7 +130,7 @@ func TestInLogSenderAction(t *testing.T) {
|
||||
{"reject", "rspamd", "a+b@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%2Bb%40example.net"},
|
||||
{"tempfail", "rspamd", "a@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%40example.net"},
|
||||
{"quarantine", "rspamd", "a@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%40example.net"},
|
||||
{"reject", "postfix", "a@example.net", "Deny sender", "/inbound/filter-lists?list=deny&kind=address&value=a%40example.net"},
|
||||
{"reject", "postfix", "a@example.net", "", ""},
|
||||
{"tempfail", "postfix", "a@example.net", "", ""},
|
||||
{"reject", "selfpost", "a@example.net", "", ""},
|
||||
{"accept", "rspamd", "", "", ""},
|
||||
@@ -254,3 +264,29 @@ func TestInboundMenuHasLogAndFilterLists(t *testing.T) {
|
||||
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`)
|
||||
}
|
||||
|
||||
// What a sheet says, from the two shapes a row's detail has: the filter's
|
||||
// verdict on mail that passed, and the reply a refusal was given.
|
||||
func TestWhyRows(t *testing.T) {
|
||||
flat := func(rows []SheetRow) string {
|
||||
var out []string
|
||||
for _, r := range rows {
|
||||
out = append(out, r.Label+"="+strings.Join(r.Lines, "|"))
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
for _, c := range []struct{ engine, detail, subject, want string }{
|
||||
{"selfpost", "", "", "Decided by=selfpost"},
|
||||
{"selfpost", "", "Hello", "Decided by=selfpost; Subject=Hello"},
|
||||
{"rspamd", "no action, score 0.40 / 15.00", "Hi", "Decided by=rspamd; Verdict=no action, score 0.40 / 15.00; Subject=Hi"},
|
||||
{"rspamd", "add header, score 9.80 / 15.00; A(5.00) B(-0.10)", "", "Decided by=rspamd; Verdict=add header, score 9.80 / 15.00; Symbols=A(5.00)|B(-0.10)"},
|
||||
// a verdict with symbols and no action or score
|
||||
{"rspamd", "A(5.00)", "", "Decided by=rspamd; Verdict=A(5.00)"},
|
||||
{"rspamd", "5.7.1 Spam message rejected", "", "Decided by=rspamd; Reply=5.7.1 Spam message rejected"},
|
||||
{"postfix", "450 4.1.8 <a@nx.example>: Sender address rejected; see x", "", "Decided by=postfix; Reply=450 4.1.8 <a@nx.example>: Sender address rejected; see x"},
|
||||
} {
|
||||
if got := flat(whyRows(c.engine, c.detail, c.subject)); got != c.want {
|
||||
t.Errorf("whyRows(%q, %q, %q)\n got: %s\nwant: %s", c.engine, c.detail, c.subject, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type Kit struct {
|
||||
Postmarks []Postmark
|
||||
StatusTag Box
|
||||
StatusRows []KitStatusRow
|
||||
Sheet Sheet
|
||||
BoxHead Box
|
||||
BoxFoot Foot
|
||||
DangerZone Box
|
||||
@@ -91,6 +92,10 @@ func KitPage() *Kit {
|
||||
},
|
||||
|
||||
StatusTag: Box{No: "03", Title: "status_tag", End: Plain("One mapping, one function")},
|
||||
Sheet: Sheet{ID: "kit-sheet", Tag: Tag{Status: "reject"}, Title: "sheet", Rows: []SheetRow{
|
||||
{Label: "What it holds", Lines: []string{"What does not fit the row, as name and value"}},
|
||||
{Label: "How it closes", Lines: []string{"Esc", "a click outside", "the cross"}, Mono: true},
|
||||
}},
|
||||
StatusRows: []KitStatusRow{
|
||||
{"ok, published, running, sent, accept", Tag{Status: "ok"}},
|
||||
{"warn, deferred, quiet, tempfail", Tag{Status: "warn"}},
|
||||
|
||||
@@ -327,3 +327,27 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
|
||||
.sp-record-head small { margin-left: 0; text-align: left; flex-basis: 100%; }
|
||||
.sp-postmark { display: none; }
|
||||
}
|
||||
|
||||
/* sheet: what does not fit a table row, opened over the page from the row's
|
||||
tag. It is the browser's own popover (the tag is a button with
|
||||
popovertarget), so it needs no script: Esc, a click outside and the close
|
||||
button all shut it, and focus returns to the tag. It rises from the bottom
|
||||
edge across the whole window - the one component that is not laid out by the
|
||||
page, hence the widths - with its content, a table of name and value, in a
|
||||
column of reading width in the middle. */
|
||||
.sp-sheet {
|
||||
inset: auto 0 0 0; width: auto; margin: 0; padding: 0; border: 0; border-top: 3px solid var(--sp-brick);
|
||||
max-height: 75vh; overflow: auto; background: #fff; color: inherit; box-shadow: 0 -12px 40px rgba(40, 20, 15, .22);
|
||||
}
|
||||
.sp-sheet-in { max-width: 44rem; margin: 0 auto; padding: 0 1.25rem; }
|
||||
.sp-sheet::backdrop { background: rgba(40, 20, 15, .38); }
|
||||
.sp-sheet-head { display: flex; align-items: center; gap: .7rem; padding: 1rem 0 .8rem; border-bottom: 1px solid var(--sp-rule); }
|
||||
.sp-sheet-head h2 { font-size: 1.15rem; font-weight: 600; margin-right: auto; }
|
||||
.sp-sheet-head button { border: 0; background: none; cursor: pointer; font-size: 1.3rem; color: var(--sp-muted); }
|
||||
.sp-sheet-body { padding: .4rem 0 1.4rem; }
|
||||
/* The sheet's content is a table of name and value: the name is a label, set like the dt of facts. */
|
||||
.sp-sheet .table th { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); white-space: nowrap; vertical-align: top; width: 1%; padding-right: 2rem; }
|
||||
.sp-sheet .table td { overflow-wrap: anywhere; }
|
||||
/* A tag that opens a sheet is a button: it keeps the tag's look and says it can
|
||||
be pressed with the dotted underline links to more detail carry. */
|
||||
button.tag { border: 0; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
mapping of status to Bulma classes; the label defaults to the status. */}}
|
||||
{{define "tag"}}<span class="{{status_tag .Status}}">{{if .Label}}{{.Label}}{{else}}{{.Status}}{{end}}</span>{{end}}
|
||||
|
||||
{{/* sheet_tag is a status tag that opens a sheet (input: Sheet): the tag as a
|
||||
button, and the sheet it names right after it. sheet is what opens: the tag
|
||||
again, a title, the cross that closes it, and a table of name and value.
|
||||
The browser's popover does the rest - no script. */}}
|
||||
{{define "sheet_tag"}}<button type="button" class="{{status_tag .Tag.Status}}" popovertarget="{{.ID}}">{{if .Tag.Label}}{{.Tag.Label}}{{else}}{{.Tag.Status}}{{end}}</button>{{template "sheet" .}}{{end}}
|
||||
{{define "sheet"}}<div class="sp-sheet" id="{{.ID}}" popover><div class="sp-sheet-in"><div class="sp-sheet-head">{{template "tag" .Tag}}<h2>{{.Title}}</h2><button type="button" popovertarget="{{.ID}}" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody>{{range .Rows}}<tr><th>{{.Label}}</th><td{{if .Mono}} class="sp-mono sp-small"{{end}}>{{range $i, $line := .Lines}}{{if $i}}<br>{{end}}{{$line}}{{end}}</td></tr>{{end}}</tbody></table></div></div></div></div>{{end}}
|
||||
|
||||
{{/* kicker is the upper-case mono micro-label on its own (input: string).
|
||||
page_head writes its own kicker as the line above the title. */}}
|
||||
{{define "kicker"}}<span class="sp-kicker">{{.}}</span>{{end}}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
{{/* Inbound log (mockup in-log.html): what port 25 decided about each message
|
||||
offered for an inbound domain, one row per recipient, newest first,
|
||||
filtered by domain, decision and day. It does not poll. The last cell is
|
||||
the way to correct a decision in Filter lists, and only the global role,
|
||||
who alone edits the lists, is given it. */}}
|
||||
filtered by domain, decision and day. It does not poll. The decision is a
|
||||
tag that opens a sheet with who decided and why. The last cell is the way
|
||||
to correct a decision in Filter lists, and only the global role, who alone
|
||||
edits the lists, is given it. */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
|
||||
{{template "box_open" .Messages}}
|
||||
{{if .Rows}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}{{if .Client}}<br><span class="sp-mono sp-muted sp-small">{{.Client}}</span>{{end}}</td><td>{{wbr_at .To}}</td>{{if .Subject}}<td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td>{{else}}<td class="sp-clip sp-muted">—</td>{{end}}<td>{{template "tag" .Decision}}</td><td class="sp-muted sp-small">{{.Why}}</td><td class="sp-actions">{{if .Action}}<a href="{{.Action.Href}}">{{.Action.Label}}</a>{{end}}</td></tr>{{end}}
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}{{if .Client}}<br><span class="sp-mono sp-muted sp-small">{{.Client}}</span>{{end}}</td><td>{{wbr_at .To}}</td>{{if .Subject}}<td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td>{{else}}<td class="sp-clip sp-muted">—</td>{{end}}<td>{{template "sheet_tag" .Why}}</td><td class="sp-actions">{{if .Action}}<a href="{{.Action.Href}}">{{.Action.Label}}</a>{{end}}</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{template "box_foot" .Foot}}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
|
||||
<tbody>{{range .StatusRows}}
|
||||
<tr><td>{{.Words}}</td><td>{{template "tag" .Tag}}</td><td class="sp-mono sp-small">{{status_tag .Tag.Status}}</td></tr>{{end}}
|
||||
<tr><td>a tag that opens a <code>sheet</code></td><td>{{template "sheet_tag" .Sheet}}</td><td class="sp-mono sp-small">button.tag + sp-sheet (popover)</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{template "box_close"}}
|
||||
|
||||
Reference in New Issue
Block a user