release: 0.6.0: Delivery detail page, DNS badge, machine metrics, wide layout

A page per delivery (`/deliveries/{id}`), reached from the *Details* link on
Added:
  - A page per delivery (`/deliveries/{id}`), reached from the *Details* link on
  - A **DNS** badge in the domain list, one per row, carrying the same
  - Machine metrics on the status page: a **Machine** card reporting the
  - An index of the current page's own sections in the navigation column, for the
Changed:
  - Every panel page is laid out in one column of the same width, so moving
  - The mark's small-size variant — the tab icon and the `SP` initials it
  - The stamp's `SELF-HOSTED SMTP RELAY` line is set at 11.5/0.15 instead of
  - The delivery log lists what identifies a message and nothing else: time,
  - Subjects are decoded for display as well as on the way in, so the rows the
  … (4 more in CHANGELOG)

Full detail: CHANGELOG.md (0.6.0).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
parent 5e5d157496
commit 83c488cc48
40 changed files with 2207 additions and 242 deletions
+4 -10
View File
@@ -28,16 +28,10 @@
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
<form method="post" action="/domains/import" enctype="multipart/form-data">
<label for="importfile">Domain export file</label>
<input id="importfile" name="file" type="file" accept=".json,.spde,application/json" required>
<div class="encrypt">
<label class="check">
<input type="checkbox" data-encrypt-toggle>
<span>The file is encrypted (<code>.spde</code>)</span>
</label>
<div class="encrypt-fields" data-encrypt-fields>
<label for="importpw">Password</label>
<input id="importpw" name="import_password" type="password" autocomplete="off">
</div>
<input id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file>
<div class="encrypt-fields" data-import-password-fields>
<label for="importpw">Password</label>
<input id="importpw" name="import_password" type="password" autocomplete="off">
</div>
<button type="submit">Import domain</button>
</form>
+5 -1
View File
@@ -22,12 +22,13 @@
{{if .Domains}}
<table>
<thead>
<tr><th>Domain</th><th>Selector</th><th>Apps</th><th></th></tr>
<tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th></th></tr>
</thead>
<tbody>
{{range .Domains}}
<tr>
<td><a href="/domains/{{.ID}}">{{.Name}}</a></td>
<td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
<td class="muted">{{.DKIMSelector}}</td>
<td>{{.AppCount}}</td>
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>
@@ -35,6 +36,9 @@
{{end}}
</tbody>
</table>
<p class="muted">The <em>DNS</em> badge is the worst of the domain's DKIM, SPF
and DMARC checks. Results are cached for a few minutes; open a domain for the
details and a <em>Re-check</em> button.</p>
{{else}}
<p class="muted">No domains yet. Add one above to get started.</p>
{{end}}
+6
View File
@@ -1,3 +1,9 @@
{{/* The send log is six columns of addresses, subjects and timestamps, so this
page takes the whole column rather than the reading measure (see the "wide"
block in layout.html). A single delivery's page carries prose and keeps the
measure, which is why this is declared per page and not per page name. */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>Deliveries</h1>
+2 -3
View File
@@ -4,18 +4,17 @@
{{if .Rows}}
<table>
<thead>
<tr><th>Time</th><th>Domain</th><th>App</th><th>From</th><th>To</th><th>Subject</th><th>Status</th></tr>
<tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td class="time muted">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
<td>{{.Domain}}</td>
<td>{{.AppLogin}}</td>
<td>{{.From}}</td>
<td>{{.To}}</td>
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
<td>{{.Status}}</td>
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain}}&app={{$.FilterApp}}&p={{$.Page}}">Details</a></td>
</tr>
{{end}}
</tbody>
+55
View File
@@ -0,0 +1,55 @@
{{define "content"}}
<h1>Delivery</h1>
<a class="back" href="{{.BackURL}}">&larr; Back to deliveries</a>
<div class="card">
<h2>Message</h2>
<p class="muted">One recipient of one message, as the journal-milter recorded
it when Postfix accepted it. The delivery log lists these; everything the list
has no room for is here.</p>
<label>Time</label>
<span class="code">{{.Row.CreatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
<label>From</label>
<span class="code">{{.Row.From}}</span>
<label>To</label>
<span class="code">{{if .Row.To}}{{.Row.To}}{{else}}—{{end}}</span>
<label>Subject</label>
<span class="code">{{if .Row.Subject}}{{.Row.Subject}}{{else}}—{{end}}</span>
<label>Status</label>
<span class="code">{{.Row.Status}}</span>
</div>
<div class="card">
<h2>Origin</h2>
<p class="muted">Which sending domain and which application's SASL account the
message was submitted under — the two filters the delivery log offers.</p>
<label>Domain</label>
<span class="code">{{if .Row.Domain}}{{.Row.Domain}}{{else}}—{{end}}</span>
<label>Application</label>
<span class="code">{{if .Row.AppLogin}}{{.Row.AppLogin}}{{else}}—{{end}}</span>
</div>
<div class="card">
<h2>Handling</h2>
<p class="muted">Postfix's own identifiers for this message. The queue id is
what to search the system log for; a rejected message never got one, because
it was refused before it was queued.</p>
<label>Queue id</label>
<span class="code">{{if .Row.QueueID}}{{.Row.QueueID}}{{else}}—{{end}}</span>
<label>Status last reported</label>
<span class="code">{{.Row.UpdatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
<label>Journal id</label>
<span class="code">{{.Row.ID}}</span>
</div>
{{end}}
+71 -33
View File
@@ -7,7 +7,7 @@
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
{{if .NewCred}}
<div class="card credential">
<div class="card credential" id="new-credential">
<h2>New application password</h2>
<p class="muted">This password is shown <strong>once only</strong> and is not
stored. Copy it now — if it is lost, regenerate a new one.</p>
@@ -24,7 +24,7 @@
</div>
{{end}}
<div class="card">
<div class="card" id="dkim">
<h2>DKIM DNS record</h2>
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>.
It is not a secret and can be viewed at any time.</p>
@@ -47,7 +47,7 @@
<p class="muted">Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div>
<div class="card">
<div class="card" id="spf-dmarc">
<h2>SPF and DMARC records</h2>
<p class="muted">These two are not generated the way the DKIM record above is —
they are policy, and the domain may already publish an SPF record for other
@@ -91,7 +91,7 @@
<code>p=reject</code> once the reports come back clean.</p>
</div>
<div class="card">
<div class="card" id="dns-status">
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
<p class="muted">What DNS publishes for <strong>{{.Domain.Name}}</strong> right
now, checked against the key this server signs with. Results are cached for a
@@ -121,7 +121,7 @@
</form>
</div>
<div class="card">
<div class="card" id="settings">
<h2>Sending server settings</h2>
<p class="muted">Point the mail client or script at these settings and
authenticate with an application login and password from the
@@ -146,7 +146,7 @@
{{/* Create form above the list, the same order the domains page uses for
"Add a sending domain" above "Domains". */}}
<div class="card">
<div class="card" id="add-application">
<h2>Add an application</h2>
<form method="post" action="/domains/{{.Domain.ID}}/applications">
<label for="login">Login</label>
@@ -173,7 +173,7 @@
be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p>
</div>
<div class="card">
<div class="card" id="applications">
<h2>Applications</h2>
<p class="muted">Each application is a SASL login/password an app or script
uses to send mail as this domain. A login may send from any address of the
@@ -188,9 +188,26 @@
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
</p>
<!-- The two panels are opened by a checkbox and a label rather than by
<details>, so that the controls stay a single row of buttons and
what a panel reveals is laid out under all four of them instead of
splitting the row in two. The checkbox precedes both its label and
its panel, which is what lets the CSS alone show them (no script
involved) — see .panel-toggle in panel.css. -->
<div class="actions">
<details>
<summary>Edit mode</summary>
<input class="panel-toggle t-mode" id="mode-{{.ID}}" type="checkbox">
<label class="toggle for-mode" for="mode-{{.ID}}">Edit mode</label>
<input class="panel-toggle t-limit" id="limit-{{.ID}}" type="checkbox">
<label class="toggle for-limit" for="limit-{{.ID}}">Rate limit{{if .HasLimit}} (active){{end}}</label>
<form class="inline" method="post" action="/applications/{{.ID}}/password"
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
<button type="submit">New password</button>
</form>
<form class="inline" method="post" action="/applications/{{.ID}}/delete"
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
<button type="submit" class="danger">Delete</button>
</form>
<div class="panel panel-mode">
<form method="post" action="/applications/{{.ID}}/mode">
<label>Address mode</label>
<select name="mode" data-list-mode="{{$.List}}">
@@ -204,34 +221,32 @@
</div>
<button type="submit">Save mode</button>
</form>
</details>
<details>
<summary>Rate limit{{if .HasLimit}} (active){{end}}</summary>
<form method="post" action="/applications/{{.ID}}/ratelimit">
</div>
<div class="panel panel-limit">
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
<label>Expected client IPs (one per line or comma-separated)</label>
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
<label>Message limit</label>
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
<label>Window (seconds)</label>
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
<button type="submit">Save limit</button>
</form>
{{if .HasLimit}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
data-confirm="Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</details>
<form class="inline" method="post" action="/applications/{{.ID}}/password"
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
<button type="submit">New password</button>
</form>
<form class="inline" method="post" action="/applications/{{.ID}}/delete"
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
<button type="submit" class="danger">Delete</button>
</form>
<!-- Saving and removing the limit are two posts, so they are two
forms and could not share a line while the Save button sat
inside the block form above. It is bound to that form by id
instead (the form attribute, no script involved), which lets
both buttons stand together in one row under the fields. -->
<div class="panel-buttons">
<button type="submit" form="rl-{{.ID}}">Save limit</button>
{{if .HasLimit}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
data-confirm="Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</div>
</div>
</div>
</li>
{{end}}
@@ -241,7 +256,7 @@
{{end}}
</div>
<div class="card">
<div class="card" id="rate-limit">
<h2>Sending rate limit (domain)</h2>
<p class="muted">Optional level-2 limit (spec 7.4): cap how many messages this
domain may send from its expected client IP(s) within a time window, summed
@@ -276,7 +291,7 @@
{{end}}
</div>
<div class="card">
<div class="card" id="export">
<h2>Export domain</h2>
<p class="muted">Download this domain to move it to another SelfPost instance:
its DKIM key, selector and every application with its working password. On
@@ -292,10 +307,33 @@
</form>
</div>
<div class="card">
<div class="card" id="danger">
<h2>Danger zone</h2>
<p class="muted">Deleting this domain also deletes its DKIM key and every
application bound to it.</p>
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
</div>
{{end}}
{{/* The domain page's section index, shown in the navigation column (see the
"sections" block in layout.html). This is the panel's longest page — the
DNS records to publish, the checks on them, the applications and two rate
limits — and setting a domain up means going back and forth between them.
The freshly generated password is only listed while it is on the page: it
is the one card that is not always there, and the one nothing should scroll
away from silently. */}}
{{define "sections"}}
<div class="sections">
<p class="sections-title">On this page</p>
{{if .NewCred}}<a href="#new-credential">New application password</a>{{end}}
<a href="#dkim">DKIM DNS record</a>
<a href="#spf-dmarc">SPF and DMARC records</a>
<a href="#dns-status">DNS status</a>
<a href="#settings">Sending server settings</a>
<a href="#add-application">Add an application</a>
<a href="#applications">Applications</a>
<a href="#rate-limit">Sending rate limit</a>
<a href="#export">Export domain</a>
<a href="#danger">Danger zone</a>
</div>
{{end}}
+45 -21
View File
@@ -22,13 +22,20 @@
<script src="/static/panel.js" defer></script>
</head>
<body>
{{/* The page name travels onto <main> as a class so the stylesheet can size a
page to what it holds — the monitoring pages are wider than the panel's
form pages (see main.page-* in panel.css). It is .Active, already carried
for the navigation, rather than a second field every handler would have to
remember to set. */}}
<main class="page-{{.Active}}">
{{/* Two columns: the navigation on the left and the page beside it, centred
together (see .shell in panel.css). The signed-out pages have no navigation
and the shell holds their card alone. */}}
<div class="shell">
{{if .User}}{{template "nav" .}}{{end}}
{{/* The page name travels onto <main> as a class so the stylesheet can size a
page to what it holds — the signed-out pages are narrower than the rest
(see main.page-* in panel.css). It is .Active, already carried for the
navigation, rather than a second field every handler would have to
remember to set. The "wide" block beside it is how a page made of data
asks for the whole column instead of the reading measure; it is the page's
own declaration rather than a name in the stylesheet, since .Active cannot
tell the send log from a single delivery's page. */}}
<main class="page-{{.Active}} {{template "wide" .}}">
{{template "content" .}}
{{/* The running version, on every authenticated page: it is what a backup
manifest is checked against on restore and the first thing to establish
@@ -36,6 +43,7 @@
the login and setup pages must not advertise it to the internet. */}}
{{if .User}}<footer class="version">SelfPost {{.Version}}</footer>{{end}}
</main>
</div>
</body>
</html>{{end}}
@@ -46,21 +54,11 @@
.User and get no nav. */}}
{{define "nav"}}
<nav class="nav">
{{/* The top row is first here because it is drawn first (see .nav in
panel.css), which keeps the tab order matching the visual one. It holds
the mark on the left and the session block on the right; the page entries
follow underneath. The mark goes to /status, the page the panel opens on —
named outright rather than as "/", which is only a redirect to it. */}}
<div class="top">
<a class="brand" href="/status"><img src="/static/logo-compact.svg" width="110" height="50" alt="SelfPost"></a>
<div class="session">
<span class="muted">{{.User}}</span>
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
<form class="inline" method="post" action="/logout">
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form>
</div>
</div>
{{/* The order of the blocks is the order they are read in, top to bottom of
the column: the mark, the panel's pages, the current page's own sections,
and the session last. The mark goes to /status, the page the panel opens
on — named outright rather than as "/", which is only a redirect to it. */}}
<a class="brand" href="/status"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
<div class="links">
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
@@ -69,9 +67,35 @@
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
</div>
{{template "sections" .}}
<div class="session">
<span class="muted">{{.User}}</span>
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
<form class="inline" method="post" action="/logout">
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form>
</div>
</nav>
{{end}}
{{/* The index of the current page's own sections, shown in the navigation
column under the page entries. Most pages are one or two cards and need no
index, so the block is empty here and only the pages that are genuinely
long — the domain page, the status page — redefine it with their own list
(see .sections in panel.css for what it looks like, and panel.js for the
marking of the section in view). Each page's template file is parsed after
this one, so its definition replaces this empty one; a page that defines
nothing keeps it and renders no index. */}}
{{define "sections"}}{{end}}
{{/* Whether the page takes the column whole. Empty here, so a page is held to
the reading measure unless it says otherwise; the three pages that are
tables of data or raw log lines redefine this as the word "wide", which
lands in <main>'s class list (see main.wide in panel.css). Same mechanism
as "sections" above: each page's template file is parsed after this one,
so its definition replaces the empty one. */}}
{{define "wide"}}{{end}}
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
inherit the link's colour through currentColor, cost no extra request, and
need no exemption from the panel's "default-src 'self'" policy. Each is
+4
View File
@@ -1,3 +1,7 @@
{{/* A table of queue ids, sizes, senders and recipients: the whole column
rather than the reading measure (see the "wide" block in layout.html). */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>Mail queue</h1>
+21 -2
View File
@@ -5,7 +5,7 @@
{{template "status_body" .}}
<div class="card">
<div class="card" id="hostname">
<h2>Hostname and reverse DNS <span class="st st-{{.PTR.Status}}">{{.PTR.Status}}</span></h2>
<p class="muted">Receiving servers check that the name this server announces
resolves to its address <em>and</em> that the address resolves back to the same
@@ -29,7 +29,7 @@
</form>
</div>
<div class="card">
<div class="card" id="configuration">
<h2>Configuration</h2>
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
database and reloads both daemons. Use it if you edited the files by hand,
@@ -41,3 +41,22 @@
</form>
</div>
{{end}}
{{/* The status page's section index, shown in the navigation column (see the
"sections" block in layout.html). The first six cards are the ones the
polling fragment replaces every five seconds; their ids are part of
status_body.html and do not change with the reading, so the links here hold
across a refresh. */}}
{{define "sections"}}
<div class="sections">
<p class="sections-title">On this page</p>
<a href="#overall">Overall</a>
<a href="#processes">Processes</a>
<a href="#machine">Machine</a>
<a href="#queue">Mail queue</a>
<a href="#certificate">TLS certificate</a>
<a href="#sockets">Milter sockets</a>
<a href="#hostname">Hostname and reverse DNS</a>
<a href="#configuration">Configuration</a>
</div>
{{end}}
+52 -5
View File
@@ -1,11 +1,11 @@
{{define "status_body"}}
<div id="status-body" hx-get="/status/fragment" hx-trigger="every 5s" hx-swap="outerHTML">
<div class="card">
<div class="card" id="overall">
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
<p class="muted">{{.OverallHeading}}</p>
</div>
<div class="card">
<div class="card" id="processes">
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
{{if .ProcessError}}
<p class="error">Could not ask supervisord for the process list.</p>
@@ -25,7 +25,54 @@
{{end}}
</div>
<div class="card">
<div class="card" id="machine">
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
<p class="muted">Processor, memory and network of the machine this container
runs on, read from the kernel's counters. CPU and throughput are rates, so
they describe{{if .Machine.WindowText}} the {{.Machine.WindowText}}{{end}}
since the previous refresh rather than this instant.</p>
<table>
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr>
<td class="metric">CPU</td>
<td class="metric">
{{if .Machine.CPU.Measured}}
<meter value="{{.Machine.CPU.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.CPU.BusyText}}</meter>
{{.Machine.CPU.BusyText}}
{{else}}<span class="muted">—</span>{{end}}
</td>
<td class="muted">{{.Machine.CPU.Detail}}</td>
</tr>
<tr>
<td class="metric">Memory</td>
<td class="metric">
{{if .Machine.Memory.Measured}}
<meter value="{{.Machine.Memory.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.Memory.PctText}}</meter>
{{.Machine.Memory.PctText}}
{{else}}<span class="muted">—</span>{{end}}
</td>
<td class="muted">{{.Machine.Memory.Detail}}</td>
</tr>
<tr>
<td class="metric">Network</td>
<td class="metric">
{{if .Machine.Network.Measured}}
↓ {{.Machine.Network.InRateText}}<br>↑ {{.Machine.Network.OutRateText}}
{{else}}<span class="muted">—</span>{{end}}
</td>
<td class="muted">
{{range .Machine.Network.Interfaces}}
<div>{{.Name}}: {{.InText}} in, {{.OutText}} out{{if .Measured}} (↓ {{.InRateText}} ↑ {{.OutRateText}}){{end}}</div>
{{end}}
{{if .Machine.Network.Detail}}<div>{{.Machine.Network.Detail}}</div>{{end}}
</td>
</tr>
</tbody>
</table>
</div>
<div class="card" id="queue">
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
{{if .QueueError}}
<p class="error">{{.QueueError}}</p>
@@ -35,7 +82,7 @@
{{end}}
</div>
<div class="card">
<div class="card" id="certificate">
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2>
<p class="muted">The certificate Postfix serves on port 465{{if .Cert.Subject}} ({{.Cert.Subject}}){{end}}.
It is supplied by the reverse proxy through a read-only mount; SelfPost only reads it.</p>
@@ -46,7 +93,7 @@
<p class="{{if eq .Cert.Status "ok"}}muted{{else}}error{{end}}">{{.Cert.Detail}}</p>
</div>
<div class="card">
<div class="card" id="sockets">
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
<table>
<thead><tr><th>Milter</th><th>Socket</th><th>State</th></tr></thead>
+4
View File
@@ -1,3 +1,7 @@
{{/* Raw mail.log lines, long by nature: the whole column rather than the
reading measure (see the "wide" block in layout.html). */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>System log</h1>