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).
@@ -5,6 +5,142 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.0] - 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
- A page per delivery (`/deliveries/{id}`), reached from the *Details* link on
|
||||
every send-log row. It carries what the log itself no longer shows — the
|
||||
sending domain, the application the message was submitted under, the Postfix
|
||||
queue id to search the system log for, when the status was last reported —
|
||||
so the journal can grow fields without the table having to find columns for
|
||||
them. *Back* returns to the page and filters the row was opened from, rebuilt
|
||||
from the log's own parameters only.
|
||||
|
||||
- A **DNS** badge in the domain list, one per row, carrying the same
|
||||
ok/warn/error/unknown vocabulary as the rest of the panel: the worst of that
|
||||
domain's DKIM, SPF and DMARC checks, so a domain whose records were never
|
||||
published is visible without opening it. The badge links to that domain's
|
||||
DNS status card. The checks run concurrently across the listed domains —
|
||||
each carries its own timeout, and in series a dead resolver would multiply
|
||||
that wait by the number of domains — and share the checker's cache with the
|
||||
domain page, so a repeat view costs no lookups. A domain whose DKIM key
|
||||
cannot be read stays "unknown" rather than being reported as misconfigured,
|
||||
since the missing half is this server's.
|
||||
- Machine metrics on the status page: a **Machine** card reporting the
|
||||
processor (busy percentage, core count, load average), memory and swap, and
|
||||
network throughput and totals per interface, read from the kernel's counters
|
||||
in `/proc` (`internal/health/machine.go`). CPU and throughput are differences
|
||||
between two readings, so they are measured against the previous poll of the
|
||||
status fragment and reported as still being measured until a second reading
|
||||
exists — a page opened after a long idle stretch re-baselines rather than
|
||||
presenting that stretch as the current load. A fully busy processor (≥90%)
|
||||
warns and an exhausted machine (≥97% of memory in use) errors, both counting
|
||||
towards the page's headline verdict, since either delays or kills the mail
|
||||
path; throughput is reported and never graded. Counters that cannot be read
|
||||
— no `/proc` outside Linux — leave the card in place showing "unknown". The
|
||||
usage bars are `<meter>` elements: the panel's CSP has no inline-style
|
||||
exemption, so a bar's length has to travel on an attribute.
|
||||
- An index of the current page's own sections in the navigation column, for the
|
||||
two pages long enough to need one: the domain page (nine cards, from the DNS
|
||||
records to publish down to the danger zone) and the status page (eight). Each
|
||||
card carries an id and the page's template defines the list
|
||||
(`{{define "sections"}}`); every other page defines nothing and shows no
|
||||
index. `panel.js` marks the section currently in view, looking its targets up
|
||||
by id on each pass so the status page swapping its cards out every five
|
||||
seconds cannot leave it measuring boxes that have left the document. The
|
||||
links are plain fragment links and work with JavaScript blocked; only the
|
||||
highlight needs it.
|
||||
|
||||
### Changed
|
||||
|
||||
- Every panel page is laid out in one column of the same width, so moving
|
||||
between them no longer shifts the navigation and the cards sideways. The
|
||||
column used to be the 48rem reading measure, which the send log, the mail
|
||||
queue and the system log widened to 64rem for their tables — and since the
|
||||
navigation and the page are centred as a pair, that difference moved
|
||||
everything on screen on the way between two pages. The column is now 64rem
|
||||
throughout, with the reading measure kept inside it: a page's heading, cards,
|
||||
back link and version footer are held to 48rem and centred in the column,
|
||||
and the three pages made of data opt out and take the column whole. Which
|
||||
pages those are is declared by the page itself (a `wide` block in its
|
||||
template, the same mechanism as the section index) rather than derived from
|
||||
the navigation entry, so a single delivery's page — prose, but filed under
|
||||
the send log — keeps the measure. The scrollbar's width is now reserved on
|
||||
every page as well: without it a short page and a long one were laid out in
|
||||
viewports differing by that width, which moved the same things again.
|
||||
|
||||
- The mark's small-size variant — the tab icon and the `SP` initials it
|
||||
carries — sets its S in Medium where the wordmark sets it in ExtraLight.
|
||||
Against the P's SemiBold the ExtraLight S is a 0.90 stem against 3.40,
|
||||
which at 16px is a quarter of a pixel against most of one, so the pair
|
||||
rasterised to a P with a smudge beside it. Medium gives up the
|
||||
Self/Post weight play, which needs more pixels than this variant exists
|
||||
to work in, in exchange for both letters being there. The variants big
|
||||
enough to carry the contrast keep it. `favicon.png` is regenerated to
|
||||
match, and the outlines are IBM Plex Sans as before — the same
|
||||
font-size, letter-spacing and baseline, with only the S's weight moved.
|
||||
- The stamp's `SELF-HOSTED SMTP RELAY` line is set at 11.5/0.15 instead of
|
||||
7.2/2.8, and no longer carries `opacity=".78"`. At the old size its stems
|
||||
rasterised to about half a device pixel, so more than half its ink landed
|
||||
as antialiasing — the typical pixel reached 2.1:1 against the brown rather
|
||||
than the 7.3:1 the two colours are worth, and none reached full strength.
|
||||
The line keeps its footprint and its monospaced cells: the width the
|
||||
tracking was spending went to the glyphs, whose cap height rises from 5.2
|
||||
to 8.3. The mark is used at 330px on the login and setup pages, which is
|
||||
where this was worst. `internal/web/static/logo.svg` is a copy of
|
||||
`docs/assets/selfpost-stamp.svg` and both carry the change, as does the
|
||||
proof sheet the outlines are drawn from.
|
||||
- The delivery log lists what identifies a message and nothing else: time,
|
||||
sender, recipient, subject, status. Domain and application, which were a
|
||||
column each, remain the log's two filters and now appear per message on the
|
||||
delivery page. The two dropped columns were the widest thing in the table
|
||||
after the addresses, and both repeat down the page whenever a filter is set.
|
||||
- Subjects are decoded for display as well as on the way in, so the rows the
|
||||
journal-milter recorded before it decoded them itself — the ones an operator
|
||||
is most likely to still be reading — show their text rather than
|
||||
`=?utf-8?Q?…?=`. The decoder moved to `internal/mailhdr` and is shared by the
|
||||
milter and the panel; it is idempotent, so a row decoded once passes through
|
||||
unchanged.
|
||||
- The panel's navigation is a column down the left edge instead of a bar across
|
||||
the top. As a bar it did not fit on one row — six page entries and the
|
||||
session block against the panel's width — and had to be split into two,
|
||||
costing the top of every page; standing it up removes the compromise, gives
|
||||
the entries one left edge to scan down, and leaves room under them for the
|
||||
section index above. It is sticky, so both lists stay in view on the long
|
||||
pages, and the current entry is marked down its leading edge rather than
|
||||
underlined. Below the width the two columns need, it lies back down into the
|
||||
wrapping rows it used to be — no drawer and no hamburger, since six entries
|
||||
fit. The markup now lists the blocks in the order they are drawn, so the tab
|
||||
order follows the eye instead of starting at Sign out.
|
||||
- An application's mode and rate-limit fields open under its row of controls
|
||||
instead of inside it. Both panels were `<details>`, so each opened where its
|
||||
own toggle sat and cut the row of four in half, pushing New password and
|
||||
Delete below a block of fields — the buttons moved every time a panel was
|
||||
opened or closed. The toggle is now a hidden checkbox with its label drawn as
|
||||
the button, and the panel is the last child of the row, so the four controls
|
||||
keep their places and what a panel reveals is laid out beneath all of them.
|
||||
It stays keyboard-reachable and, being pure CSS, still works with JavaScript
|
||||
blocked, as the disclosure did. Inside a panel the submit buttons take the
|
||||
ordinary form spacing back from the compact row style that was leaving them
|
||||
flush against the field above, and Save limit and Remove limit — two posts,
|
||||
hence two forms — share one row, the first button bound to its form by the
|
||||
`form` attribute rather than by sitting inside it.
|
||||
- The mark at the head of the navigation column takes the column's full width
|
||||
instead of the 110px it kept from the bar. In a row that size was all there
|
||||
was room for; in a column it left the mark ending halfway across, with no
|
||||
edge shared with anything below it. At the column's width its edges line up
|
||||
with the page entries under it, as the full mark already does with the card
|
||||
beneath it on the signed-out pages. Where the column lies back down into a
|
||||
bar it returns to the compact size, which is what fits beside the entries.
|
||||
- The import card reads the file's extension instead of asking whether the file
|
||||
is encrypted. The checkbox was a question the server never consulted — it
|
||||
decides from the envelope's magic bytes — so the answer could only be wrong.
|
||||
Choosing a `.spde` file reveals the password field and a `.json` file hides
|
||||
it; an unrecognised extension reveals it, and with no file chosen the field
|
||||
stays hidden, since there is nothing yet for a password to open. With
|
||||
JavaScript blocked the field is shown, so an encrypted import still works.
|
||||
|
||||
## [0.5.0] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||

|
||||
<p align="center">
|
||||
<img src="docs/assets/selfpost-stamp.svg" alt="SelfPost" width="440">
|
||||
</p>
|
||||
|
||||
# SelfPost
|
||||
|
||||
@@ -190,7 +192,13 @@ service healthy and will mail be accepted?"
|
||||
|
||||
- **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel),
|
||||
TLS certificate validity and expiry, milter socket presence, and a short
|
||||
Postfix queue summary. The hostname block compares `SELFPOST_HOSTNAME`
|
||||
Postfix queue summary. The **Machine** card adds the resource usage of the
|
||||
host underneath — processor (with the load average), memory and swap, and
|
||||
per-interface network throughput and totals — read from the kernel's
|
||||
counters; CPU and throughput are measured between refreshes, so they appear
|
||||
one refresh after the page opens. A fully busy processor or a machine out of
|
||||
memory is a warning here, because both delay or kill the mail path;
|
||||
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
|
||||
against the PTR record the internet publishes for this server's IP
|
||||
(forward-confirmed reverse DNS); use *Re-check* after changing DNS. The
|
||||
**Reload configuration** button re-applies OpenDKIM tables and the Postfix
|
||||
@@ -201,9 +209,13 @@ service healthy and will mail be accepted?"
|
||||
(level 2) are configured here. *Export domain* writes a single-domain archive;
|
||||
*Import a domain* on the Backup page reads one back in.
|
||||
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters
|
||||
by domain and application. Each row shows status `queued` (accepted, not yet
|
||||
by domain and application. A row identifies its message and nothing more —
|
||||
time, sender, recipient, subject and status `queued` (accepted, not yet
|
||||
delivered), `sent` (handed off successfully), or `rejected` (refused — for
|
||||
example by a level-2 rate limit). Retention is controlled by
|
||||
example by a level-2 rate limit); *Details* opens that row's own page
|
||||
(`/deliveries/{id}`) with the sending domain, the application it was
|
||||
submitted under, the Postfix queue id to search the system log for, and when
|
||||
the status was last reported. Retention is controlled by
|
||||
`SEND_LOG_RETENTION_DAYS`.
|
||||
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
|
||||
trying to deliver or deferring.
|
||||
|
||||
@@ -133,9 +133,10 @@ unless noted.
|
||||
| `/healthz` | Liveness (no auth) |
|
||||
| `/setup/*` | One-time admin bootstrap |
|
||||
| `/login`, `/logout` | Session auth |
|
||||
| `/status` | Process, cert, socket, PTR checks |
|
||||
| `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network |
|
||||
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits |
|
||||
| `/deliveries` | Send log with filters |
|
||||
| `/deliveries/{id}` | One send-log row in full |
|
||||
| `/mail-queue` | Postfix queue view |
|
||||
| `/system-log` | `mail.log` tail |
|
||||
| `/reload` | Reload OpenDKIM + Postfix maps |
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
|
||||
file needs no font installed to render as drawn. -->
|
||||
file needs no font installed to render as drawn.
|
||||
This is the mark's small-size variant, and its S is Medium where the
|
||||
wordmark's is ExtraLight. At the wordmark's weights the S stem is
|
||||
0.90 against the P's 3.40, which at 16px is a quarter of a pixel
|
||||
against most of one: the pair rasterised to a P with a smudge beside
|
||||
it. Medium costs the Self/Post weight play, which needs more pixels
|
||||
than this variant exists to work in, and keeps both letters. -->
|
||||
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round" />
|
||||
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E" />
|
||||
<path d="M24.71 43.31Q22.47 43.31 21.01 42.47Q19.56 41.62 18.54 40.3L19.27 39.72Q20.23 40.97 21.49 41.71Q22.76 42.45 24.73 42.45Q26.99 42.45 28.23 41.39Q29.46 40.32 29.46 38.32Q29.46 36.94 28.94 36.15Q28.42 35.36 27.51 34.94Q26.6 34.52 25.49 34.29L23.74 33.95Q21.95 33.61 20.92 32.93Q19.9 32.24 19.44 31.29Q18.99 30.34 18.99 29.22Q18.99 27.63 19.69 26.59Q20.39 25.55 21.66 25.05Q22.94 24.54 24.63 24.54Q26.66 24.54 28.01 25.24Q29.36 25.94 30.14 27.11L29.41 27.66Q28.63 26.54 27.46 25.97Q26.29 25.4 24.58 25.4Q22.42 25.4 21.18 26.33Q19.95 27.27 19.95 29.19Q19.95 30.47 20.48 31.23Q21.01 32 21.92 32.42Q22.83 32.83 23.95 33.07L25.69 33.41Q27.54 33.74 28.57 34.46Q29.59 35.17 30 36.15Q30.4 37.12 30.4 38.24Q30.4 39.91 29.71 41.02Q29.02 42.14 27.73 42.73Q26.45 43.31 24.71 43.31Z M36.45 43H33.01V24.85H41.18Q42.87 24.85 44.06 25.55Q45.26 26.26 45.91 27.52Q46.56 28.78 46.56 30.47Q46.56 32.16 45.91 33.42Q45.26 34.68 44.06 35.38Q42.87 36.08 41.18 36.08H36.45ZM36.45 27.84V33.12H40.87Q41.54 33.12 42.01 32.87Q42.48 32.63 42.74 32.17Q43 31.72 43 31.07V29.87Q43 29.19 42.74 28.75Q42.48 28.31 42.01 28.08Q41.54 27.84 40.87 27.84Z" fill="#F6EDE2" />
|
||||
<path d="M24.55 43.31Q22.29 43.31 20.68 42.47Q19.06 41.62 17.92 40.24L19.97 38.35Q20.91 39.52 22.07 40.11Q23.22 40.71 24.71 40.71Q26.45 40.71 27.33 39.92Q28.22 39.13 28.22 37.83Q28.22 37.12 27.96 36.6Q27.7 36.08 27.07 35.75Q26.45 35.41 25.41 35.2L23.8 34.91Q22.03 34.58 20.84 33.93Q19.66 33.28 19.06 32.22Q18.47 31.17 18.47 29.74Q18.47 28.13 19.25 26.96Q20.03 25.79 21.47 25.16Q22.91 24.54 24.84 24.54Q26.89 24.54 28.4 25.26Q29.91 25.97 30.95 27.32L28.89 29.14Q28.19 28.23 27.18 27.69Q26.16 27.14 24.65 27.14Q23.09 27.14 22.25 27.76Q21.4 28.39 21.4 29.58Q21.4 30.34 21.72 30.83Q22.03 31.33 22.66 31.64Q23.3 31.95 24.26 32.13L25.88 32.47Q27.7 32.81 28.87 33.48Q30.04 34.16 30.59 35.19Q31.15 36.21 31.15 37.67Q31.15 39.36 30.37 40.62Q29.59 41.88 28.11 42.6Q26.63 43.31 24.55 43.31Z M37.02 43H33.59V24.85H41.75Q43.44 24.85 44.64 25.55Q45.83 26.26 46.48 27.52Q47.13 28.78 47.13 30.47Q47.13 32.16 46.48 33.42Q45.83 34.68 44.64 35.38Q43.44 36.08 41.75 36.08H37.02ZM37.02 27.84V33.12H41.44Q42.11 33.12 42.58 32.87Q43.05 32.63 43.31 32.17Q43.57 31.72 43.57 31.07V29.87Q43.57 29.19 43.31 28.75Q43.05 28.31 42.58 28.08Q42.11 27.84 41.44 27.84Z" fill="#F6EDE2" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -95,7 +95,7 @@
|
||||
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||
</svg></div>
|
||||
<div class="swatches">
|
||||
<div class="sw"><i style="background:#7A3B2E"></i>#7A3B2E · печать</div>
|
||||
@@ -118,7 +118,7 @@
|
||||
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||
</svg>
|
||||
<h3>SelfPost</h3>
|
||||
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
|
||||
@@ -132,7 +132,7 @@
|
||||
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||
</svg>
|
||||
<h3>SelfPost</h3>
|
||||
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
|
||||
@@ -170,7 +170,7 @@
|
||||
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
|
||||
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
|
||||
<text x="32" y="43" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="26" letter-spacing="-1" fill="#F6EDE2"><tspan font-weight="200">S</tspan><tspan font-weight="600">P</tspan></text>
|
||||
<text x="32" y="43" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="26" letter-spacing="-1" fill="#F6EDE2"><tspan font-weight="500">S</tspan><tspan font-weight="600">P</tspan></text>
|
||||
</svg><span>SelfPost — Domains</span></div>
|
||||
<div class="tab off"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
@@ -188,7 +188,7 @@
|
||||
<div class="avs">
|
||||
<img class="av" width="80" height="80" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.6' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='31' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='200' letter-spacing='-.4' fill='%23F6EDE2'%3ESelf%3C/text%3E%3Ctext x='32' y='45' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='600' letter-spacing='-.4' fill='%23F6EDE2'%3EPost%3C/text%3E%3C/svg%3E">
|
||||
<img class="av" width="48" height="48" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.6' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='31' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='200' letter-spacing='-.4' fill='%23F6EDE2'%3ESelf%3C/text%3E%3Ctext x='32' y='45' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='600' letter-spacing='-.4' fill='%23F6EDE2'%3EPost%3C/text%3E%3C/svg%3E">
|
||||
<img class="av" width="32" height="32" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.8' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='43' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='26' letter-spacing='-1' fill='%23F6EDE2'%3E%3Ctspan font-weight='200'%3ES%3C/tspan%3E%3Ctspan font-weight='600'%3EP%3C/tspan%3E%3C/text%3E%3C/svg%3E">
|
||||
<img class="av" width="32" height="32" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.8' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='43' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='26' letter-spacing='-1' fill='%23F6EDE2'%3E%3Ctspan font-weight='500'%3ES%3C/tspan%3E%3Ctspan font-weight='600'%3EP%3C/tspan%3E%3C/text%3E%3C/svg%3E">
|
||||
</div>
|
||||
<div class="cap"><b>80 / 48 / 32 px</b><br>Аватар организации на GitHub. Порог перехода на версию SP — 32 px.</div>
|
||||
</div>
|
||||
@@ -205,7 +205,7 @@
|
||||
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||
</svg><span>полная · от 280 px</span></div>
|
||||
<div class="u"><svg class="" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,557 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// machineSampleWindow is the longest gap between two readings that still counts
|
||||
// as "now". CPU busy time and network throughput are differences between two
|
||||
// samples, and the status fragment polls every five seconds — but a page opened
|
||||
// after the panel sat idle for an hour would otherwise average that whole hour
|
||||
// and present it as the current load. Past this the reading only re-baselines,
|
||||
// and the rates say they are still being measured.
|
||||
const machineSampleWindow = time.Minute
|
||||
|
||||
// Thresholds for the two resources whose exhaustion actually threatens mail: a
|
||||
// fully busy processor slows queue processing, and a machine out of memory has
|
||||
// its processes killed. Network throughput has no comparable threshold — what
|
||||
// counts as a lot depends entirely on the link — so it is reported, not graded.
|
||||
const (
|
||||
cpuWarnPct = 90.0
|
||||
memWarnPct = 90.0
|
||||
memErrorPct = 97.0
|
||||
)
|
||||
|
||||
// Machine is the resource usage of the machine this container runs on: the
|
||||
// status page's answer to "is the server itself under strain", next to the
|
||||
// component checks that answer "is mail flowing".
|
||||
//
|
||||
// The numbers come from the kernel's /proc filesystem, which a container shares
|
||||
// with its host unless it was started with its own namespaces — so CPU and
|
||||
// memory describe the host, while /proc/net/dev describes whatever network
|
||||
// namespace the container is in (its own veth under the default bridge, the
|
||||
// host's interfaces under network_mode: host).
|
||||
type Machine struct {
|
||||
CPU CPU
|
||||
Memory Memory
|
||||
Network Network
|
||||
// Window is the interval the rates were measured over; zero until a
|
||||
// second reading exists.
|
||||
Window time.Duration
|
||||
Status Status
|
||||
}
|
||||
|
||||
// WindowText names the sampling interval for the card's description.
|
||||
func (m Machine) WindowText() string {
|
||||
if m.Window <= 0 {
|
||||
return ""
|
||||
}
|
||||
return m.Window.Round(time.Second).String()
|
||||
}
|
||||
|
||||
// CPU is processor load over the sampling window.
|
||||
type CPU struct {
|
||||
// Measured is false until two readings exist to compare; BusyPct means
|
||||
// nothing until it is true.
|
||||
Measured bool
|
||||
BusyPct float64
|
||||
Cores int
|
||||
// Load is the 1/5/15-minute load average, present when /proc/loadavg
|
||||
// could be read. Unlike BusyPct it needs no previous sample, so it is
|
||||
// there on the very first page load.
|
||||
Load [3]float64
|
||||
HasLoad bool
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Percent is BusyPct as a whole number, for the <meter> element's value
|
||||
// attribute. The bar carries its value in an attribute rather than a width in a
|
||||
// style attribute because the panel's CSP has no inline-style exemption
|
||||
// (security.md).
|
||||
func (c CPU) Percent() int { return percent(c.BusyPct) }
|
||||
|
||||
// BusyText is the reading as it appears beside the bar.
|
||||
func (c CPU) BusyText() string { return fmt.Sprintf("%d%%", c.Percent()) }
|
||||
|
||||
// Memory is main memory (and swap, where the machine has any) at the moment of
|
||||
// the reading. Unlike CPU and network it is a level, not a rate, so a single
|
||||
// reading is enough and it is never in the "measuring" state.
|
||||
type Memory struct {
|
||||
Measured bool
|
||||
TotalBytes uint64
|
||||
AvailableBytes uint64
|
||||
UsedBytes uint64
|
||||
UsedPct float64
|
||||
SwapTotalBytes uint64
|
||||
SwapUsedBytes uint64
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Percent, UsedText and TotalText render the reading for the template; see
|
||||
// CPU.Percent for why the bar's value travels as an attribute.
|
||||
func (m Memory) Percent() int { return percent(m.UsedPct) }
|
||||
func (m Memory) UsedText() string { return humanBytes(m.UsedBytes) }
|
||||
func (m Memory) TotalText() string { return humanBytes(m.TotalBytes) }
|
||||
func (m Memory) PctText() string { return fmt.Sprintf("%d%%", m.Percent()) }
|
||||
|
||||
// Interface is one network interface's traffic: the counters since the
|
||||
// interface came up, and the throughput over the sampling window.
|
||||
type Interface struct {
|
||||
Name string
|
||||
RxBytes uint64
|
||||
TxBytes uint64
|
||||
RxRate float64 // bytes per second, valid when the Network is Measured
|
||||
TxRate float64
|
||||
Measured bool
|
||||
}
|
||||
|
||||
func (i Interface) InText() string { return humanBytes(i.RxBytes) }
|
||||
func (i Interface) OutText() string { return humanBytes(i.TxBytes) }
|
||||
func (i Interface) InRateText() string { return humanRate(i.RxRate) }
|
||||
func (i Interface) OutRateText() string { return humanRate(i.TxRate) }
|
||||
|
||||
// Network is the traffic across every interface that has carried any, loopback
|
||||
// excluded — loopback traffic is the container talking to itself (the panel to
|
||||
// SQLite, Postfix to its milters) and says nothing about the link.
|
||||
type Network struct {
|
||||
Measured bool
|
||||
Interfaces []Interface
|
||||
RxRate float64
|
||||
TxRate float64
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (n Network) InRateText() string { return humanRate(n.RxRate) }
|
||||
func (n Network) OutRateText() string { return humanRate(n.TxRate) }
|
||||
|
||||
// MachineSampler reads those counters. Its zero value is ready to use and it is
|
||||
// safe for concurrent use, but one sampler has to be shared by every caller:
|
||||
// the rates are measured against the reading the previous call left behind, so
|
||||
// a fresh sampler per request would never have anything to compare against.
|
||||
type MachineSampler struct {
|
||||
// procRoot replaces /proc in tests; empty means the real one.
|
||||
procRoot string
|
||||
|
||||
mu sync.Mutex
|
||||
prevAt time.Time
|
||||
prevCPU cpuTimes
|
||||
prevNet map[string]netCounters
|
||||
}
|
||||
|
||||
func (m *MachineSampler) root() string {
|
||||
if m.procRoot == "" {
|
||||
return "/proc"
|
||||
}
|
||||
return m.procRoot
|
||||
}
|
||||
|
||||
// cpuTimes is the aggregate of /proc/stat's "cpu" line: all time accounted for,
|
||||
// and the part of it the processor spent doing nothing.
|
||||
type cpuTimes struct {
|
||||
total uint64
|
||||
idle uint64
|
||||
}
|
||||
|
||||
// netCounters is one interface's byte counters from /proc/net/dev.
|
||||
type netCounters struct {
|
||||
rx uint64
|
||||
tx uint64
|
||||
}
|
||||
|
||||
// Sample reads the current counters and reports usage since the previous call.
|
||||
// Like every other check here it never fails: a counter that cannot be read
|
||||
// becomes an unknown status with an explanation, so a kernel that does not
|
||||
// publish one of these files (or a panel run outside Linux for development)
|
||||
// costs one line of the card rather than the page.
|
||||
func (m *MachineSampler) Sample() Machine {
|
||||
root := m.root()
|
||||
now := time.Now()
|
||||
cpuNow, cores, cpuErr := readCPUTimes(root)
|
||||
netNow, netErr := readNetDev(root)
|
||||
|
||||
m.mu.Lock()
|
||||
prevAt, prevCPU, prevNet := m.prevAt, m.prevCPU, m.prevNet
|
||||
if cpuErr == nil {
|
||||
m.prevCPU = cpuNow
|
||||
}
|
||||
if netErr == nil {
|
||||
m.prevNet = netNow
|
||||
}
|
||||
if cpuErr == nil || netErr == nil {
|
||||
m.prevAt = now
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// A window of zero (two calls in the same instant) would divide by zero;
|
||||
// one longer than machineSampleWindow is no longer a description of now.
|
||||
window := now.Sub(prevAt)
|
||||
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
|
||||
|
||||
mach := Machine{
|
||||
CPU: cpuUsage(prevCPU, cpuNow, cores, readLoadAvg(root), fresh, cpuErr),
|
||||
Memory: readMemory(root),
|
||||
Network: networkUsage(prevNet, netNow, window, fresh, netErr),
|
||||
}
|
||||
if fresh {
|
||||
mach.Window = window
|
||||
}
|
||||
mach.Status = Worst(mach.CPU.Status, mach.Memory.Status, mach.Network.Status)
|
||||
return mach
|
||||
}
|
||||
|
||||
// cpuUsage grades the processor over the window. The load average is reported
|
||||
// alongside it because the two answer different questions — how busy the
|
||||
// processor was, and how many tasks were waiting for it — and a machine can
|
||||
// look idle while work queues up behind a slow disk.
|
||||
func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err error) CPU {
|
||||
c := CPU{Cores: cores}
|
||||
if !isZeroLoad(load) {
|
||||
c.Load, c.HasLoad = load, true
|
||||
}
|
||||
if err != nil {
|
||||
c.Status = StatusUnknown
|
||||
c.Detail = "The kernel's processor counters (/proc/stat) could not be read here."
|
||||
return c
|
||||
}
|
||||
if !fresh || cur.total <= prev.total {
|
||||
c.Status = StatusUnknown
|
||||
c.Detail = joinDetail("Measuring — this reading sets the baseline; the next refresh has the figure.", c.contextText())
|
||||
return c
|
||||
}
|
||||
|
||||
total := cur.total - prev.total
|
||||
idle := uint64(0)
|
||||
if cur.idle > prev.idle {
|
||||
idle = cur.idle - prev.idle
|
||||
}
|
||||
if idle > total {
|
||||
idle = total
|
||||
}
|
||||
c.Measured = true
|
||||
c.BusyPct = 100 * float64(total-idle) / float64(total)
|
||||
|
||||
if c.BusyPct >= cpuWarnPct {
|
||||
c.Status = StatusWarn
|
||||
c.Detail = joinDetail(c.contextText(), "The processor is close to fully busy, which slows queue processing and every panel page.")
|
||||
} else {
|
||||
c.Status = StatusOK
|
||||
c.Detail = c.contextText()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// contextText is the CPU's supporting figures: what the percentage is a
|
||||
// percentage of, and how deep the run queue is.
|
||||
func (c CPU) contextText() string {
|
||||
var parts []string
|
||||
if c.Cores > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d core(s)", c.Cores))
|
||||
}
|
||||
if c.HasLoad {
|
||||
parts = append(parts, fmt.Sprintf("load average %.2f, %.2f, %.2f", c.Load[0], c.Load[1], c.Load[2]))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// readMemory reports main memory from /proc/meminfo. Used is derived from
|
||||
// MemAvailable rather than MemFree: Linux spends every spare page on cache, so
|
||||
// MemFree on a healthy machine is near zero and would report a permanent
|
||||
// emergency. MemAvailable is the kernel's own estimate of what a new workload
|
||||
// could actually get.
|
||||
func readMemory(root string) Memory {
|
||||
var m Memory
|
||||
fields, err := readMeminfo(root)
|
||||
if err != nil {
|
||||
m.Status = StatusUnknown
|
||||
m.Detail = "The kernel's memory counters (/proc/meminfo) could not be read here."
|
||||
return m
|
||||
}
|
||||
total, available := fields["MemTotal"], fields["MemAvailable"]
|
||||
if total == 0 {
|
||||
m.Status = StatusUnknown
|
||||
m.Detail = "/proc/meminfo does not report a total memory size."
|
||||
return m
|
||||
}
|
||||
if available > total {
|
||||
available = total
|
||||
}
|
||||
|
||||
m.Measured = true
|
||||
m.TotalBytes = total
|
||||
m.AvailableBytes = available
|
||||
m.UsedBytes = total - available
|
||||
m.UsedPct = 100 * float64(m.UsedBytes) / float64(total)
|
||||
m.SwapTotalBytes = fields["SwapTotal"]
|
||||
if swapFree := fields["SwapFree"]; m.SwapTotalBytes > swapFree {
|
||||
m.SwapUsedBytes = m.SwapTotalBytes - swapFree
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("%s used of %s; %s available to new work.",
|
||||
humanBytes(m.UsedBytes), humanBytes(total), humanBytes(available))
|
||||
if m.SwapTotalBytes > 0 {
|
||||
detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes))
|
||||
}
|
||||
switch {
|
||||
case m.UsedPct >= memErrorPct:
|
||||
m.Status = StatusError
|
||||
m.Detail = detail + " Memory is exhausted; the kernel kills processes to reclaim it, and Postfix or the panel are candidates."
|
||||
case m.UsedPct >= memWarnPct:
|
||||
m.Status = StatusWarn
|
||||
m.Detail = detail + " Little headroom left."
|
||||
default:
|
||||
m.Status = StatusOK
|
||||
m.Detail = detail
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// networkUsage turns two readings of the interface counters into throughput.
|
||||
// It never grades: there is no usage figure that is wrong for a mail server, so
|
||||
// the row is informational and only reports "unknown" when the counters are
|
||||
// unreadable.
|
||||
func networkUsage(prev, cur map[string]netCounters, window time.Duration, fresh bool, err error) Network {
|
||||
var n Network
|
||||
if err != nil {
|
||||
n.Status = StatusUnknown
|
||||
n.Detail = "The kernel's network counters (/proc/net/dev) could not be read here."
|
||||
return n
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(cur))
|
||||
for name := range cur {
|
||||
names = append(names, name)
|
||||
}
|
||||
// Map order is random, and this table is re-rendered every few seconds:
|
||||
// without a sort the rows would shuffle under the reader.
|
||||
sort.Strings(names)
|
||||
|
||||
n.Measured = fresh
|
||||
for _, name := range names {
|
||||
c := cur[name]
|
||||
// An interface that has never carried a byte is a veth or a bridge
|
||||
// the deployment happens to have, not part of the mail path.
|
||||
if c.rx == 0 && c.tx == 0 {
|
||||
continue
|
||||
}
|
||||
iface := Interface{Name: name, RxBytes: c.rx, TxBytes: c.tx, Measured: fresh}
|
||||
if fresh {
|
||||
p := prev[name]
|
||||
secs := window.Seconds()
|
||||
// Counters only go up; a drop means the interface (or the
|
||||
// container) was recreated, so there is no rate to report.
|
||||
if c.rx >= p.rx {
|
||||
iface.RxRate = float64(c.rx-p.rx) / secs
|
||||
}
|
||||
if c.tx >= p.tx {
|
||||
iface.TxRate = float64(c.tx-p.tx) / secs
|
||||
}
|
||||
n.RxRate += iface.RxRate
|
||||
n.TxRate += iface.TxRate
|
||||
}
|
||||
n.Interfaces = append(n.Interfaces, iface)
|
||||
}
|
||||
|
||||
n.Status = StatusOK
|
||||
switch {
|
||||
case len(n.Interfaces) == 0:
|
||||
n.Detail = "No interface outside loopback has carried any traffic."
|
||||
case !fresh:
|
||||
n.Detail = "Measuring — this reading sets the baseline; the next refresh has the throughput."
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// readCPUTimes returns the aggregate processor times and the number of cores
|
||||
// from /proc/stat. The times are in USER_HZ ticks, which cancel out because
|
||||
// only their ratio is used.
|
||||
func readCPUTimes(root string) (cpuTimes, int, error) {
|
||||
data, err := os.ReadFile(filepath.Join(root, "stat"))
|
||||
if err != nil {
|
||||
return cpuTimes{}, 0, err
|
||||
}
|
||||
var (
|
||||
times cpuTimes
|
||||
cores int
|
||||
found bool
|
||||
)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 || !strings.HasPrefix(fields[0], "cpu") {
|
||||
continue
|
||||
}
|
||||
if fields[0] != "cpu" {
|
||||
cores++ // cpu0, cpu1, … — one line per core
|
||||
continue
|
||||
}
|
||||
// user nice system idle iowait irq softirq steal (guest fields are
|
||||
// already counted inside user/nice, so they are left out).
|
||||
for i, f := range fields[1:] {
|
||||
if i >= 8 {
|
||||
break
|
||||
}
|
||||
v, err := strconv.ParseUint(f, 10, 64)
|
||||
if err != nil {
|
||||
return cpuTimes{}, 0, fmt.Errorf("/proc/stat: unreadable cpu time %q", f)
|
||||
}
|
||||
times.total += v
|
||||
// idle plus iowait: both are time the processor had nothing to
|
||||
// run, and separating them tells the reader nothing here.
|
||||
if i == 3 || i == 4 {
|
||||
times.idle += v
|
||||
}
|
||||
}
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
return cpuTimes{}, 0, fmt.Errorf("/proc/stat: no aggregate cpu line")
|
||||
}
|
||||
return times, cores, nil
|
||||
}
|
||||
|
||||
// readLoadAvg reads the 1/5/15-minute load averages. A machine without
|
||||
// /proc/loadavg simply has none reported, so the failure is a zero value rather
|
||||
// than an error.
|
||||
func readLoadAvg(root string) [3]float64 {
|
||||
var load [3]float64
|
||||
data, err := os.ReadFile(filepath.Join(root, "loadavg"))
|
||||
if err != nil {
|
||||
return load
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return load
|
||||
}
|
||||
for i := range load {
|
||||
v, err := strconv.ParseFloat(fields[i], 64)
|
||||
if err != nil {
|
||||
return [3]float64{}
|
||||
}
|
||||
load[i] = v
|
||||
}
|
||||
return load
|
||||
}
|
||||
|
||||
func isZeroLoad(load [3]float64) bool {
|
||||
return load == [3]float64{}
|
||||
}
|
||||
|
||||
// readMeminfo returns the /proc/meminfo entries this package uses, in bytes.
|
||||
// The file reports kB (kibibytes, despite the label) for these fields.
|
||||
func readMeminfo(root string) (map[string]uint64, error) {
|
||||
data, err := os.ReadFile(filepath.Join(root, "meminfo"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := map[string]bool{"MemTotal": true, "MemAvailable": true, "SwapTotal": true, "SwapFree": true}
|
||||
out := make(map[string]uint64, len(want))
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
name, rest, ok := strings.Cut(line, ":")
|
||||
if !ok || !want[name] {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseUint(fields[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(fields) > 1 && strings.EqualFold(fields[1], "kB") {
|
||||
v *= 1024
|
||||
}
|
||||
out[name] = v
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("/proc/meminfo: no readable fields")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readNetDev returns each interface's byte counters from /proc/net/dev, loopback
|
||||
// excluded. Each line is " eth0: <rx bytes> <rx packets> … <tx bytes> …" — the
|
||||
// name is separated by a colon, which may or may not have a space after it, so
|
||||
// the split is on the colon and not on whitespace.
|
||||
func readNetDev(root string) (map[string]netCounters, error) {
|
||||
data, err := os.ReadFile(filepath.Join(root, "net", "dev"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]netCounters)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
name, rest, ok := strings.Cut(line, ":")
|
||||
name = strings.TrimSpace(name)
|
||||
if !ok || name == "" || name == "lo" || strings.Contains(name, " ") {
|
||||
continue // header lines carry no name, or several words
|
||||
}
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) < 9 {
|
||||
continue
|
||||
}
|
||||
rx, err1 := strconv.ParseUint(fields[0], 10, 64)
|
||||
tx, err2 := strconv.ParseUint(fields[8], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
out[name] = netCounters{rx: rx, tx: tx}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// percent rounds a 0–100 reading to a whole number and clamps it, so a bar's
|
||||
// value attribute is always inside the range the element declares.
|
||||
func percent(v float64) int {
|
||||
switch {
|
||||
case v <= 0:
|
||||
return 0
|
||||
case v >= 100:
|
||||
return 100
|
||||
default:
|
||||
return int(v + 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// humanBytes renders a byte count in the binary units memory and traffic are
|
||||
// conventionally read in.
|
||||
func humanBytes(b uint64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n := b / unit; n >= unit && exp < 4; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTP"[exp])
|
||||
}
|
||||
|
||||
// humanRate renders throughput in bytes per second, to match the totals beside
|
||||
// it rather than the bits per second a link is sold in.
|
||||
func humanRate(perSec float64) string {
|
||||
if perSec < 0 {
|
||||
perSec = 0
|
||||
}
|
||||
return humanBytes(uint64(perSec+0.5)) + "/s"
|
||||
}
|
||||
|
||||
// joinDetail joins the non-empty parts of a detail line.
|
||||
func joinDetail(parts ...string) string {
|
||||
var kept []string
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, " ")
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeProc writes a /proc-shaped directory the sampler can be pointed at, so
|
||||
// the parsing and the arithmetic are tested against known counters instead of
|
||||
// whatever the machine running the tests happens to be doing.
|
||||
func fakeProc(t *testing.T, stat, meminfo, loadavg, netdev string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
write := func(name, body string) {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("stat", stat)
|
||||
write("meminfo", meminfo)
|
||||
write("loadavg", loadavg)
|
||||
write(filepath.Join("net", "dev"), netdev)
|
||||
return dir
|
||||
}
|
||||
|
||||
const meminfoSample = `MemTotal: 4194304 kB
|
||||
MemFree: 131072 kB
|
||||
MemAvailable: 2097152 kB
|
||||
Buffers: 262144 kB
|
||||
SwapTotal: 1048576 kB
|
||||
SwapFree: 524288 kB
|
||||
`
|
||||
|
||||
const netdevSample = `Inter-| Receive | Transmit
|
||||
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
||||
lo: 500000 1000 0 0 0 0 0 0 500000 1000 0 0 0 0 0 0
|
||||
eth0: 1048576 2000 0 0 0 0 0 0 524288 1500 0 0 0 0 0 0
|
||||
veth9: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
`
|
||||
|
||||
func TestCPUUsageOverTwoReadings(t *testing.T) {
|
||||
// 1000 ticks pass, 250 of them idle: 75% busy.
|
||||
prev := cpuTimes{total: 10000, idle: 8000}
|
||||
cur := cpuTimes{total: 11000, idle: 8250}
|
||||
got := cpuUsage(prev, cur, 4, [3]float64{0.5, 0.4, 0.3}, true, nil)
|
||||
if !got.Measured {
|
||||
t.Fatalf("reading not marked measured: %+v", got)
|
||||
}
|
||||
if got.Percent() != 75 {
|
||||
t.Errorf("busy = %.2f%% (%s), want 75%%", got.BusyPct, got.BusyText())
|
||||
}
|
||||
if got.Status != StatusOK {
|
||||
t.Errorf("status = %q, want ok", got.Status)
|
||||
}
|
||||
if got.Cores != 4 || !got.HasLoad {
|
||||
t.Errorf("cores/load not reported: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUUsageWarnsWhenFullyBusy(t *testing.T) {
|
||||
got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, [3]float64{}, true, nil)
|
||||
if got.Status != StatusWarn {
|
||||
t.Errorf("99%% busy graded %q, want warn (%s)", got.Status, got.Detail)
|
||||
}
|
||||
if got.HasLoad {
|
||||
t.Error("a missing load average should not be reported as zeros")
|
||||
}
|
||||
}
|
||||
|
||||
// The first reading has nothing to compare against, and one taken after a long
|
||||
// idle stretch would describe that stretch rather than now. Both must report
|
||||
// "unknown" rather than a number the reader would take for the current load.
|
||||
func TestCPUUsageWithoutAUsableWindow(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
prev, cur cpuTimes
|
||||
fresh bool
|
||||
}{
|
||||
{"no previous reading", cpuTimes{}, cpuTimes{total: 11000, idle: 8250}, false},
|
||||
{"counters did not advance", cpuTimes{total: 11000, idle: 8250}, cpuTimes{total: 11000, idle: 8250}, true},
|
||||
} {
|
||||
got := cpuUsage(c.prev, c.cur, 2, [3]float64{}, c.fresh, nil)
|
||||
if got.Measured || got.Status != StatusUnknown {
|
||||
t.Errorf("%s: measured=%v status=%q, want unmeasured/unknown", c.name, got.Measured, got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown {
|
||||
t.Errorf("unreadable /proc/stat: status %q, want unknown", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMemory(t *testing.T) {
|
||||
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "0.1 0.2 0.3 1/2 3\n", netdevSample)
|
||||
got := readMemory(dir)
|
||||
if !got.Measured {
|
||||
t.Fatalf("memory not measured: %+v", got)
|
||||
}
|
||||
if got.TotalBytes != 4*1024*1024*1024 {
|
||||
t.Errorf("total = %d bytes (%s), want 4 GiB", got.TotalBytes, got.TotalText())
|
||||
}
|
||||
// 4 GiB total, 2 GiB available to new work: half used, cache included in
|
||||
// what is available.
|
||||
if got.Percent() != 50 {
|
||||
t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText())
|
||||
}
|
||||
if got.Status != StatusOK {
|
||||
t.Errorf("status = %q, want ok (%s)", got.Status, got.Detail)
|
||||
}
|
||||
if got.SwapUsedBytes != 512*1024*1024 {
|
||||
t.Errorf("swap used = %d bytes, want 512 MiB", got.SwapUsedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMemoryGrades(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
available string
|
||||
want Status
|
||||
}{
|
||||
{"plenty free", "MemAvailable: 2097152 kB\n", StatusOK},
|
||||
{"little headroom", "MemAvailable: 209715 kB\n", StatusWarn},
|
||||
{"exhausted", "MemAvailable: 41943 kB\n", StatusError},
|
||||
}
|
||||
for _, c := range cases {
|
||||
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", "MemTotal: 4194304 kB\n"+c.available, "", netdevSample)
|
||||
if got := readMemory(dir); got.Status != c.want {
|
||||
t.Errorf("%s: status %q, want %q (%s)", c.name, got.Status, c.want, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
if got := readMemory(t.TempDir()); got.Status != StatusUnknown || got.Measured {
|
||||
t.Errorf("missing /proc/meminfo: status %q measured=%v", got.Status, got.Measured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNetDevSkipsLoopbackAndHeaders(t *testing.T) {
|
||||
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "", netdevSample)
|
||||
got, err := readNetDev(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("readNetDev: %v", err)
|
||||
}
|
||||
if _, ok := got["lo"]; ok {
|
||||
t.Error("loopback is counted as network traffic")
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("parsed %d interfaces, want eth0 and veth9: %+v", len(got), got)
|
||||
}
|
||||
if got["eth0"].rx != 1048576 || got["eth0"].tx != 524288 {
|
||||
t.Errorf("eth0 counters = %+v", got["eth0"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkUsageRates(t *testing.T) {
|
||||
prev := map[string]netCounters{"eth0": {rx: 1000, tx: 500}}
|
||||
cur := map[string]netCounters{
|
||||
"eth0": {rx: 11000, tx: 5500},
|
||||
"veth9": {rx: 0, tx: 0}, // never carried anything: not shown
|
||||
}
|
||||
got := networkUsage(prev, cur, 5*time.Second, true, nil)
|
||||
if len(got.Interfaces) != 1 || got.Interfaces[0].Name != "eth0" {
|
||||
t.Fatalf("interfaces = %+v, want eth0 only", got.Interfaces)
|
||||
}
|
||||
// 10000 bytes in and 5000 out over five seconds.
|
||||
if got.RxRate != 2000 || got.TxRate != 1000 {
|
||||
t.Errorf("rates = %.0f in / %.0f out, want 2000/1000", got.RxRate, got.TxRate)
|
||||
}
|
||||
if got.InRateText() != "2.0 KiB/s" {
|
||||
t.Errorf("in rate text = %q", got.InRateText())
|
||||
}
|
||||
if got.Status != StatusOK {
|
||||
t.Errorf("status = %q, want ok — throughput is reported, not graded", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// A recreated container (or interface) resets the counters; the drop must not
|
||||
// be reported as a huge negative or wrapped-around rate.
|
||||
func TestNetworkUsageIgnoresCounterResets(t *testing.T) {
|
||||
prev := map[string]netCounters{"eth0": {rx: 1_000_000, tx: 900_000}}
|
||||
cur := map[string]netCounters{"eth0": {rx: 1000, tx: 900}}
|
||||
got := networkUsage(prev, cur, 5*time.Second, true, nil)
|
||||
if got.RxRate != 0 || got.TxRate != 0 {
|
||||
t.Errorf("rates after a counter reset = %.0f/%.0f, want 0/0", got.RxRate, got.TxRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkUsageWithoutAPreviousReading(t *testing.T) {
|
||||
cur := map[string]netCounters{"eth0": {rx: 1000, tx: 500}}
|
||||
got := networkUsage(nil, cur, 0, false, nil)
|
||||
if got.Measured {
|
||||
t.Error("first reading reported as measured")
|
||||
}
|
||||
if len(got.Interfaces) != 1 || got.Interfaces[0].InText() != "1000 B" {
|
||||
t.Errorf("totals should be shown even before a rate exists: %+v", got.Interfaces)
|
||||
}
|
||||
if got.Detail == "" {
|
||||
t.Error("no explanation for the missing rates")
|
||||
}
|
||||
|
||||
if bad := networkUsage(nil, nil, 0, false, os.ErrNotExist); bad.Status != StatusUnknown {
|
||||
t.Errorf("unreadable /proc/net/dev: status %q, want unknown", bad.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end through the sampler: the first call baselines, the second reports.
|
||||
func TestMachineSamplerNeedsTwoReadings(t *testing.T) {
|
||||
dir := fakeProc(t,
|
||||
"cpu 1000 0 500 8000 500 0 0 0\ncpu0 500 0 250 4000 250 0 0 0\ncpu1 500 0 250 4000 250 0 0 0\n",
|
||||
meminfoSample, "0.42 0.31 0.20 2/300 1234\n", netdevSample)
|
||||
m := &MachineSampler{procRoot: dir}
|
||||
|
||||
first := m.Sample()
|
||||
if first.CPU.Measured || first.CPU.Status != StatusUnknown {
|
||||
t.Errorf("first sample reported a CPU figure: %+v", first.CPU)
|
||||
}
|
||||
if !first.Memory.Measured {
|
||||
t.Error("memory is a level, not a rate: it must be reported on the first sample")
|
||||
}
|
||||
if first.CPU.Cores != 2 {
|
||||
t.Errorf("cores = %d, want 2", first.CPU.Cores)
|
||||
}
|
||||
if !first.CPU.HasLoad || first.CPU.Load[0] != 0.42 {
|
||||
t.Errorf("load average not read: %+v", first.CPU.Load)
|
||||
}
|
||||
// Unknown checks must not drag the card into a warning.
|
||||
if first.Status != StatusOK {
|
||||
t.Errorf("overall machine status = %q, want ok", first.Status)
|
||||
}
|
||||
|
||||
// Second reading: 1000 more ticks, 750 of them idle → 25% busy.
|
||||
if err := os.WriteFile(filepath.Join(dir, "stat"),
|
||||
[]byte("cpu 1250 0 500 8750 500 0 0 0\ncpu0 625 0 250 4375 250 0 0 0\ncpu1 625 0 250 4375 250 0 0 0\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := m.Sample()
|
||||
if !second.CPU.Measured {
|
||||
t.Fatalf("second sample still unmeasured: %+v", second.CPU)
|
||||
}
|
||||
if second.CPU.Percent() != 25 {
|
||||
t.Errorf("busy = %s, want 25%%", second.CPU.BusyText())
|
||||
}
|
||||
if !second.Network.Measured || second.Window <= 0 {
|
||||
t.Errorf("network window not established: measured=%v window=%v", second.Network.Measured, second.Window)
|
||||
}
|
||||
if second.WindowText() == "" {
|
||||
t.Error("no sampling window to show on the card")
|
||||
}
|
||||
}
|
||||
|
||||
// Outside Linux — a developer running the panel on their own machine — there is
|
||||
// no /proc at all. Every metric must degrade to "unknown" rather than failing
|
||||
// the status page.
|
||||
func TestMachineSamplerWithoutProc(t *testing.T) {
|
||||
m := &MachineSampler{procRoot: filepath.Join(t.TempDir(), "absent")}
|
||||
got := m.Sample()
|
||||
if got.Status != StatusUnknown {
|
||||
t.Errorf("status = %q, want unknown", got.Status)
|
||||
}
|
||||
for name, st := range map[string]Status{"cpu": got.CPU.Status, "memory": got.Memory.Status, "network": got.Network.Status} {
|
||||
if st != StatusUnknown {
|
||||
t.Errorf("%s status = %q, want unknown", name, st)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanBytes(t *testing.T) {
|
||||
cases := map[uint64]string{
|
||||
0: "0 B",
|
||||
999: "999 B",
|
||||
1024: "1.0 KiB",
|
||||
1536: "1.5 KiB",
|
||||
4 * 1024 * 1024 * 1024: "4.0 GiB",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := humanBytes(in); got != want {
|
||||
t.Errorf("humanBytes(%d) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if got := humanRate(0); got != "0 B/s" {
|
||||
t.Errorf("humanRate(0) = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package mailhdr turns raw mail header values into display text. It sits
|
||||
// between the journal-milter, which reads headers off the wire, and the panel,
|
||||
// which shows them: both need the same answer for the same header, and the
|
||||
// panel needs it for rows the milter wrote before this decoding existed.
|
||||
package mailhdr
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SubjectMaxRunes caps what the journal keeps of a subject. A Subject header
|
||||
// may legally run to hundreds of characters; the log only needs enough to
|
||||
// recognise the message, and the panel shows one row per recipient.
|
||||
const SubjectMaxRunes = 200
|
||||
|
||||
// DecodeSubject turns a raw Subject header into display text. Anything
|
||||
// non-ASCII arrives as RFC 2047 encoded-words (=?utf-8?Q?=D0=9F…?=), which the
|
||||
// panel would otherwise show verbatim: unreadable, and — being one unbreakable
|
||||
// run — wide enough to push the send-log table out of its card. Go's decoder
|
||||
// covers the UTF-8 and ASCII charsets senders use in practice; for anything
|
||||
// else (windows-1251, koi8-r) it fails and the raw header is kept, which is no
|
||||
// worse than not decoding at all. Truncation is applied after decoding so the
|
||||
// cap counts characters of the subject, not bytes of its encoding.
|
||||
//
|
||||
// It is idempotent: already-decoded text contains no encoded-words, so a second
|
||||
// pass returns it unchanged. That is what lets the panel decode on the way out
|
||||
// as well as the milter on the way in.
|
||||
func DecodeSubject(v string) string {
|
||||
if dec, err := (&mime.WordDecoder{}).DecodeHeader(v); err == nil {
|
||||
v = dec
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
if r := []rune(v); len(r) > SubjectMaxRunes {
|
||||
v = string(r[:SubjectMaxRunes]) + "…"
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package mailhdr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeSubject(t *testing.T) {
|
||||
long := strings.Repeat("я", SubjectMaxRunes+10)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, raw, want string
|
||||
}{
|
||||
{"plain", "Hello there", "Hello there"},
|
||||
{"utf8 q", "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=", "Проверка"},
|
||||
{"utf8 b, folded across two words", "=?utf-8?B?0J/RgNC40LLQtdGC?=\r\n =?utf-8?B?INC80LjRgA==?=", "Привет мир"},
|
||||
// No decoder for the legacy single-byte charsets: keep the header as
|
||||
// sent rather than losing the subject entirely.
|
||||
{"unknown charset", "=?windows-1251?B?z/Do4uXy?=", "=?windows-1251?B?z/Do4uXy?="},
|
||||
{"too long", long, strings.Repeat("я", SubjectMaxRunes) + "…"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := DecodeSubject(tc.raw); got != tc.want {
|
||||
t.Fatalf("DecodeSubject(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The panel decodes on the way out as well as the milter on the way in, so
|
||||
// every already-decoded row in the send log passes through a second time.
|
||||
func TestDecodeSubjectIsIdempotent(t *testing.T) {
|
||||
for _, s := range []string{"Проверка", "Hello there", "", "=?windows-1251?B?z/Do4uXy?="} {
|
||||
if got := DecodeSubject(DecodeSubject(s)); got != DecodeSubject(s) {
|
||||
t.Fatalf("second pass over %q changed it to %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ package milter
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"mime"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
|
||||
"github.com/emersion/go-milter"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/mailhdr"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
@@ -101,35 +101,11 @@ func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, erro
|
||||
// Header captures the Subject. Only the first Subject header is kept.
|
||||
func (s *session) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
|
||||
if s.subject == "" && textproto.CanonicalMIMEHeaderKey(name) == "Subject" {
|
||||
s.subject = decodeSubject(value)
|
||||
s.subject = mailhdr.DecodeSubject(value)
|
||||
}
|
||||
return milter.RespContinue, nil
|
||||
}
|
||||
|
||||
// subjectMaxRunes caps what the journal keeps of a subject. A Subject header
|
||||
// may legally run to hundreds of characters; the log only needs enough to
|
||||
// recognise the message, and the panel shows one row per recipient.
|
||||
const subjectMaxRunes = 200
|
||||
|
||||
// decodeSubject turns the raw Subject header into display text. Anything
|
||||
// non-ASCII arrives as RFC 2047 encoded-words (=?utf-8?Q?=D0=9F…?=), which the
|
||||
// panel would otherwise show verbatim: unreadable, and — being one unbreakable
|
||||
// run — wide enough to push the send-log table out of its card. Go's decoder
|
||||
// covers the UTF-8 and ASCII charsets senders use in practice; for anything
|
||||
// else (windows-1251, koi8-r) it fails and the raw header is kept, which is no
|
||||
// worse than before. Truncation is applied after decoding so the cap counts
|
||||
// characters of the subject, not bytes of its encoding.
|
||||
func decodeSubject(v string) string {
|
||||
if dec, err := (&mime.WordDecoder{}).DecodeHeader(v); err == nil {
|
||||
v = dec
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
if r := []rune(v); len(r) > subjectMaxRunes {
|
||||
v = string(r[:subjectMaxRunes]) + "…"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Body fires at end-of-message, when the queue-id macro {i} is set and the
|
||||
// message is about to be committed to the queue. This is where the "queued"
|
||||
// rows are written. We accept (this milter is done) without ever rejecting.
|
||||
|
||||
@@ -3,7 +3,6 @@ package milter
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -115,30 +114,15 @@ func TestSessionRecordsRowPerRecipient(t *testing.T) {
|
||||
}
|
||||
|
||||
// A subject in any non-ASCII alphabet reaches the milter as RFC 2047
|
||||
// encoded-words; the journal stores the text, not the encoding.
|
||||
// encoded-words; the journal stores the text, not the encoding. The decoding
|
||||
// itself is mailhdr's; what this checks is that Header runs it.
|
||||
func TestHeaderDecodesEncodedSubject(t *testing.T) {
|
||||
long := strings.Repeat("я", subjectMaxRunes+10)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, raw, want string
|
||||
}{
|
||||
{"plain", "Hello there", "Hello there"},
|
||||
{"utf8 q", "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=", "Проверка"},
|
||||
{"utf8 b, folded across two words", "=?utf-8?B?0J/RgNC40LLQtdGC?=\r\n =?utf-8?B?INC80LjRgA==?=", "Привет мир"},
|
||||
// No decoder for the legacy single-byte charsets: keep the header as
|
||||
// sent rather than losing the subject entirely.
|
||||
{"unknown charset", "=?windows-1251?B?z/Do4uXy?=", "=?windows-1251?B?z/Do4uXy?="},
|
||||
{"too long", long, strings.Repeat("я", subjectMaxRunes) + "…"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := &session{rec: &fakeRecorder{}}
|
||||
if _, err := s.Header("Subject", tc.raw, mods(nil)); err != nil {
|
||||
t.Fatalf("Header: %v", err)
|
||||
}
|
||||
if s.subject != tc.want {
|
||||
t.Fatalf("subject = %q, want %q", s.subject, tc.want)
|
||||
}
|
||||
})
|
||||
s := &session{rec: &fakeRecorder{}}
|
||||
if _, err := s.Header("Subject", "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=", mods(nil)); err != nil {
|
||||
t.Fatalf("Header: %v", err)
|
||||
}
|
||||
if s.subject != "Проверка" {
|
||||
t.Fatalf("subject = %q, want %q", s.subject, "Проверка")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -104,6 +106,37 @@ type SendLogRow struct {
|
||||
Subject string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
// UpdatedAt is when the status last changed — the log-tailer's report of
|
||||
// the delivery attempt. It equals CreatedAt while a row is still queued.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ErrSendLogNotFound is returned by GetSendLog for an id that no longer exists.
|
||||
// Send-log rows are pruned on the retention window, so a bookmarked delivery
|
||||
// disappearing is expected, not a fault.
|
||||
var ErrSendLogNotFound = errors.New("send-log entry not found")
|
||||
|
||||
// GetSendLog returns a single send-log row by id, for the delivery detail page
|
||||
// the log links each row to.
|
||||
func (s *Store) GetSendLog(id int64) (SendLogRow, error) {
|
||||
var (
|
||||
row SendLogRow
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at
|
||||
FROM send_log WHERE id = ?`, id,
|
||||
).Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin, &row.From, &row.To,
|
||||
&row.Subject, &row.Status, &createdAt, &updatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SendLogRow{}, ErrSendLogNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return SendLogRow{}, fmt.Errorf("get send_log %d: %w", id, err)
|
||||
}
|
||||
row.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
row.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or
|
||||
@@ -119,7 +152,7 @@ func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLog
|
||||
where, args := sendLogWhere(filter)
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at
|
||||
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at
|
||||
FROM send_log`+where+`
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -133,14 +166,15 @@ func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLog
|
||||
var out []SendLogRow
|
||||
for rows.Next() {
|
||||
var (
|
||||
row SendLogRow
|
||||
createdAt string
|
||||
row SendLogRow
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
if err := rows.Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin,
|
||||
&row.From, &row.To, &row.Subject, &row.Status, &createdAt); err != nil {
|
||||
&row.From, &row.To, &row.Subject, &row.Status, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan send_log row: %w", err)
|
||||
}
|
||||
row.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
row.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
@@ -117,6 +117,7 @@ func TestBackupPageOffersEncryption(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
|
||||
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
|
||||
"data-import-password-fields",
|
||||
fmt.Sprintf("at least %d characters", minSecretFilePasswordLen),
|
||||
"The two passwords do not match.",
|
||||
} {
|
||||
|
||||
@@ -5,10 +5,20 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// domainRow is one line of the domain list: the stored domain plus the rolled-up
|
||||
// verdict of its published DNS records, so the operator sees which domains still
|
||||
// need a record published without opening each one.
|
||||
type domainRow struct {
|
||||
store.Domain
|
||||
DNS health.Status
|
||||
}
|
||||
|
||||
// handleDashboard is the authenticated landing page: the list of sending
|
||||
// domains with their DKIM/selector and application counts, plus the add-domain
|
||||
// form (product.md).
|
||||
@@ -30,13 +40,43 @@ func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status
|
||||
"Title": "SelfPost",
|
||||
"User": currentUser(r),
|
||||
"Active": "domains",
|
||||
"Domains": domains,
|
||||
"Domains": s.domainRows(domains),
|
||||
"Error": formErr,
|
||||
"FormName": formName,
|
||||
"Flash": dashboardFlash(r),
|
||||
})
|
||||
}
|
||||
|
||||
// domainRows attaches each domain's DNS verdict to its row. The checks run
|
||||
// concurrently rather than one after another: each carries its own timeout, so
|
||||
// in series a dead resolver would multiply that wait by the number of domains
|
||||
// and the list would look hung. The checker caches results for a few minutes,
|
||||
// so a repeat view of the list costs no lookups at all, and it is the same
|
||||
// cache the domain page fills — opening a domain after the list is free.
|
||||
func (s *Server) domainRows(domains []store.Domain) []domainRow {
|
||||
rows := make([]domainRow, len(domains))
|
||||
var wg sync.WaitGroup
|
||||
for i, d := range domains {
|
||||
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
record, err := s.domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
// Without the expected key there is nothing to compare DNS
|
||||
// against; leave the row unknown rather than accusing the
|
||||
// domain of a misconfiguration this server caused.
|
||||
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
||||
return
|
||||
}
|
||||
dns, _ := s.domainDNS(d, record, false)
|
||||
rows[i].DNS = dns.Overall
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return rows
|
||||
}
|
||||
|
||||
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
|
||||
// after a redirect is never attacker-influenced.
|
||||
func dashboardFlash(r *http.Request) string {
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/mailhdr"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
@@ -50,6 +52,58 @@ func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderFragment(w, http.StatusOK, "deliveries_rows", data)
|
||||
}
|
||||
|
||||
// handleDelivery renders one send-log row in full. The log itself carries only
|
||||
// what identifies a message at a glance — when, who to and from, what about,
|
||||
// how it ended — and every remaining field (domain, application, queue id, when
|
||||
// the status was last reported) lives here, one page per row, so widening the
|
||||
// journal never costs the table a column.
|
||||
func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
row, err := s.store.GetSendLog(id)
|
||||
if err != nil {
|
||||
// A row pruned on the retention window is gone, not broken.
|
||||
if errors.Is(err, store.ErrSendLogNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logf("panel: delivery %d: %v", id, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
||||
s.render(w, http.StatusOK, "delivery", map[string]any{
|
||||
"Title": "SelfPost — delivery",
|
||||
"User": currentUser(r),
|
||||
"Active": "deliveries",
|
||||
"Row": row,
|
||||
// Where the row came from, so "Back" returns to the page and filters
|
||||
// the operator was looking at rather than the top of an unfiltered log.
|
||||
"BackURL": deliveriesBackURL(r),
|
||||
})
|
||||
}
|
||||
|
||||
// deliveriesBackURL rebuilds the delivery-log URL a detail page was opened
|
||||
// from. Only the log's own parameters are carried over, and each is re-encoded
|
||||
// by url.Values, so nothing a visitor appends to the link can travel back into
|
||||
// the page as markup or as a different destination.
|
||||
func deliveriesBackURL(r *http.Request) string {
|
||||
q := r.URL.Query()
|
||||
back := url.Values{}
|
||||
for _, k := range []string{"domain", "app", "p"} {
|
||||
if v := q.Get(k); v != "" {
|
||||
back.Set(k, v)
|
||||
}
|
||||
}
|
||||
if len(back) == 0 {
|
||||
return "/deliveries"
|
||||
}
|
||||
return "/deliveries?" + back.Encode()
|
||||
}
|
||||
|
||||
// sendLogData reads the domain/app filters and page number off the query
|
||||
// string, queries the store, and assembles everything the template needs
|
||||
// (filter dropdown options plus the current selection, rows, and pagination).
|
||||
@@ -69,6 +123,12 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Decode on the way out as well as on the way in: rows the journal-milter
|
||||
// wrote before it decoded subjects itself still hold the raw header, and
|
||||
// they are the ones an operator is most likely to be looking at.
|
||||
for i := range rows {
|
||||
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
||||
}
|
||||
domains, err := s.store.ListDomains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// After log rotation renames mail.log away, Postfix takes about a second to
|
||||
@@ -19,3 +25,137 @@ func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
|
||||
t.Errorf("errText = %q, want empty (missing file is not an error)", errText)
|
||||
}
|
||||
}
|
||||
|
||||
// The delivery log is a list of messages, not a dump of the journal: it shows
|
||||
// when, from, to, subject and status, and links each row to the page carrying
|
||||
// the rest. A column added back here is one the table has no width for.
|
||||
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
|
||||
s, row := serverWithDelivery(t)
|
||||
|
||||
out := getBody(t, s.handleDeliveries, "/deliveries")
|
||||
for _, want := range []string{
|
||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"noreply@bs.example.ru", "public@example.ru",
|
||||
"Проверка", ">sent<", `href="/deliveries/` + itoa(row.ID),
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivery log is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Domain and application stay available as filters; what the table must not
|
||||
// carry is a column of them per row.
|
||||
for _, unwanted := range []string{"<th>Domain</th>", "<th>App</th>", "Queuer3C"} {
|
||||
if strings.Contains(out, unwanted) {
|
||||
t.Errorf("delivery log still shows %q; that detail belongs on the delivery page", unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subjects reached the journal as RFC 2047 encoded-words before the milter
|
||||
// decoded them, and those rows are still in the send log. Decoding on the way
|
||||
// out is what keeps them readable, so the encoding must not survive to the page.
|
||||
func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
|
||||
s, _ := serverWithDelivery(t)
|
||||
|
||||
for name, out := range map[string]string{
|
||||
"log": getBody(t, s.handleDeliveries, "/deliveries"),
|
||||
"rows": getBody(t, s.handleDeliveriesRows, "/deliveries/rows"),
|
||||
} {
|
||||
if strings.Contains(out, "=?utf-8?Q?") {
|
||||
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
|
||||
}
|
||||
if !strings.Contains(out, "Проверка") {
|
||||
t.Errorf("%s does not show the decoded subject:\n%s", name, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Everything the log dropped has to be somewhere, and that somewhere is the
|
||||
// per-row page — including for a row still holding an encoded subject.
|
||||
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
|
||||
s, row := serverWithDelivery(t)
|
||||
|
||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
|
||||
for _, want := range []string{
|
||||
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
|
||||
"noreply@bs.example.ru", "public@example.ru", "sent",
|
||||
`href="/deliveries?domain=bs.example.ru&p=2"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivery page is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "=?utf-8?Q?") {
|
||||
t.Errorf("delivery page shows the subject's MIME encoding instead of its text:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Send-log rows are pruned on the retention window, so a bookmarked delivery
|
||||
// that no longer exists is a 404, not a 500.
|
||||
func TestDeliveryPageNotFound(t *testing.T) {
|
||||
s, _ := serverWithDelivery(t)
|
||||
|
||||
for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
|
||||
s.handleDelivery(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s = %d, want 404", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serverWithDelivery builds a panel over a store holding one delivery, written
|
||||
// the way the journal-milter wrote them before it decoded subjects itself.
|
||||
func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
if err := st.InsertQueued(store.SendLogEntry{
|
||||
QueueID: "4A1B2C3D",
|
||||
Domain: "bs.example.ru",
|
||||
AppLogin: "Queuer3C",
|
||||
From: "noreply@bs.example.ru",
|
||||
To: "public@example.ru",
|
||||
Subject: "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=",
|
||||
}); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil {
|
||||
t.Fatalf("update status: %v", err)
|
||||
}
|
||||
rows, err := st.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
return &Server{store: st, tmpl: tmpl, cfg: Config{Version: "test"}}, rows[0]
|
||||
}
|
||||
|
||||
// getBody runs one handler over a GET and returns the page it wrote, failing
|
||||
// the test on any non-200. The path's {id} is bound by hand because these calls
|
||||
// bypass the router that would otherwise fill it in.
|
||||
func getBody(t *testing.T, h http.HandlerFunc, target string) string {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
|
||||
req.SetPathValue("id", rest)
|
||||
}
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d, want 200:\n%s", target, rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
|
||||
@@ -40,7 +40,7 @@ func (s *Server) handleStatusRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// statusBody collects the four local checks the fragment renders. Each one
|
||||
// statusBody collects the local checks the fragment renders. Each one
|
||||
// reports its own problem rather than failing the page, so a broken component
|
||||
// costs one line and not the whole screen.
|
||||
func (s *Server) statusBody() map[string]any {
|
||||
@@ -75,7 +75,13 @@ func (s *Server) statusBody() map[string]any {
|
||||
socketStatus = health.Worst(socketStatus, sock.Status)
|
||||
}
|
||||
|
||||
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus)
|
||||
// Resource usage of the machine underneath. It is graded like the rest —
|
||||
// a processor that is fully busy or a machine out of memory delays or
|
||||
// kills the mail path — so it counts towards the headline verdict, and
|
||||
// its rates are measured against the previous poll (internal/health).
|
||||
machine := s.machine.Sample()
|
||||
|
||||
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
|
||||
return map[string]any{
|
||||
"Processes": procs,
|
||||
"ProcessError": procErr != nil,
|
||||
@@ -83,6 +89,7 @@ func (s *Server) statusBody() map[string]any {
|
||||
"QueueSummary": queueSummary(queueText),
|
||||
"QueueError": queueErr,
|
||||
"QueueStatus": queueStatus,
|
||||
"Machine": machine,
|
||||
"Cert": cert,
|
||||
"Sockets": sockets,
|
||||
"SocketStatus": socketStatus,
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,8 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
|
||||
file needs no font installed to render as drawn. -->
|
||||
file needs no font installed to render as drawn.
|
||||
This is the mark's small-size variant, and its S is Medium where the
|
||||
wordmark's is ExtraLight. At the wordmark's weights the S stem is
|
||||
0.90 against the P's 3.40, which at 16px is a quarter of a pixel
|
||||
against most of one: the pair rasterised to a P with a smudge beside
|
||||
it. Medium costs the Self/Post weight play, which needs more pixels
|
||||
than this variant exists to work in, and keeps both letters. -->
|
||||
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round" />
|
||||
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E" />
|
||||
<path d="M24.71 43.31Q22.47 43.31 21.01 42.47Q19.56 41.62 18.54 40.3L19.27 39.72Q20.23 40.97 21.49 41.71Q22.76 42.45 24.73 42.45Q26.99 42.45 28.23 41.39Q29.46 40.32 29.46 38.32Q29.46 36.94 28.94 36.15Q28.42 35.36 27.51 34.94Q26.6 34.52 25.49 34.29L23.74 33.95Q21.95 33.61 20.92 32.93Q19.9 32.24 19.44 31.29Q18.99 30.34 18.99 29.22Q18.99 27.63 19.69 26.59Q20.39 25.55 21.66 25.05Q22.94 24.54 24.63 24.54Q26.66 24.54 28.01 25.24Q29.36 25.94 30.14 27.11L29.41 27.66Q28.63 26.54 27.46 25.97Q26.29 25.4 24.58 25.4Q22.42 25.4 21.18 26.33Q19.95 27.27 19.95 29.19Q19.95 30.47 20.48 31.23Q21.01 32 21.92 32.42Q22.83 32.83 23.95 33.07L25.69 33.41Q27.54 33.74 28.57 34.46Q29.59 35.17 30 36.15Q30.4 37.12 30.4 38.24Q30.4 39.91 29.71 41.02Q29.02 42.14 27.73 42.73Q26.45 43.31 24.71 43.31Z M36.45 43H33.01V24.85H41.18Q42.87 24.85 44.06 25.55Q45.26 26.26 45.91 27.52Q46.56 28.78 46.56 30.47Q46.56 32.16 45.91 33.42Q45.26 34.68 44.06 35.38Q42.87 36.08 41.18 36.08H36.45ZM36.45 27.84V33.12H40.87Q41.54 33.12 42.01 32.87Q42.48 32.63 42.74 32.17Q43 31.72 43 31.07V29.87Q43 29.19 42.74 28.75Q42.48 28.31 42.01 28.08Q41.54 27.84 40.87 27.84Z" fill="#F6EDE2" />
|
||||
<path d="M24.55 43.31Q22.29 43.31 20.68 42.47Q19.06 41.62 17.92 40.24L19.97 38.35Q20.91 39.52 22.07 40.11Q23.22 40.71 24.71 40.71Q26.45 40.71 27.33 39.92Q28.22 39.13 28.22 37.83Q28.22 37.12 27.96 36.6Q27.7 36.08 27.07 35.75Q26.45 35.41 25.41 35.2L23.8 34.91Q22.03 34.58 20.84 33.93Q19.66 33.28 19.06 32.22Q18.47 31.17 18.47 29.74Q18.47 28.13 19.25 26.96Q20.03 25.79 21.47 25.16Q22.91 24.54 24.84 24.54Q26.89 24.54 28.4 25.26Q29.91 25.97 30.95 27.32L28.89 29.14Q28.19 28.23 27.18 27.69Q26.16 27.14 24.65 27.14Q23.09 27.14 22.25 27.76Q21.4 28.39 21.4 29.58Q21.4 30.34 21.72 30.83Q22.03 31.33 22.66 31.64Q23.3 31.95 24.26 32.13L25.88 32.47Q27.7 32.81 28.87 33.48Q30.04 34.16 30.59 35.19Q31.15 36.21 31.15 37.67Q31.15 39.36 30.37 40.62Q29.59 41.88 28.11 42.6Q26.63 43.31 24.55 43.31Z M37.02 43H33.59V24.85H41.75Q43.44 24.85 44.64 25.55Q45.83 26.26 46.48 27.52Q47.13 28.78 47.13 30.47Q47.13 32.16 46.48 33.42Q45.83 34.68 44.64 35.38Q43.44 36.08 41.75 36.08H37.02ZM37.02 27.84V33.12H41.44Q42.11 33.12 42.58 32.87Q43.05 32.63 43.31 32.17Q43.57 31.72 43.57 31.07V29.87Q43.57 29.19 43.31 28.75Q43.05 28.31 42.58 28.08Q42.11 27.84 41.44 27.84Z" fill="#F6EDE2" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
@@ -46,16 +46,52 @@
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
/* The scrollbar's width is reserved whether or not the page is long enough to
|
||||
need one. Without it a short page (Account) and a long one (Status) are laid
|
||||
out in viewports differing by the scrollbar's width, and everything on the
|
||||
page — the navigation column included — shifts sideways on the way between
|
||||
them. The panel is a set of pages an operator moves through, so that shift
|
||||
read as the layout jumping. */
|
||||
html { scrollbar-gutter: stable; }
|
||||
body {
|
||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg);
|
||||
}
|
||||
/* The two columns — navigation on the left, page on the right — centred as a
|
||||
pair. The navigation is a fixed width and the page keeps the measure it had:
|
||||
the column is added beside the content, not taken out of it. */
|
||||
.shell { display: flex; justify-content: center; align-items: flex-start; gap: 1.75rem; }
|
||||
/* One column for every page. It used to be the reading measure, 48rem, which
|
||||
the three pages holding data widened to 64rem — and since the shell centres
|
||||
the navigation and the page as a pair, that difference moved the navigation
|
||||
column and the left edge of every card on the way between two pages. The
|
||||
column is now the wider of the two everywhere, so nothing moves; the reading
|
||||
measure is kept inside it by the rule below rather than by the column.
|
||||
No auto margins: inside the shell they would eat the free space and push the
|
||||
navigation column off to the far edge, so the centring is the shell's job
|
||||
(justify-content above). */
|
||||
main { flex: 1 1 auto; min-width: 0; width: 100%; max-width: 64rem; }
|
||||
/* 48rem is a reading measure: right for the forms and prose that make up most
|
||||
of the panel, and the width the navigation bar's two rows were sized for.
|
||||
Page-specific overrides below widen or narrow it for the pages that need
|
||||
something else. */
|
||||
main { max-width: 48rem; margin: 0 auto; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
|
||||
of the panel, and too narrow for the pages that are tables of data. Every
|
||||
page therefore keeps its content at that measure, centred in the column, and
|
||||
the pages made of data opt out (main.wide below) and take the column whole.
|
||||
The cap is on <main>'s children rather than on .card so a page's heading, its
|
||||
back link and the version footer travel with the cards: capping the cards
|
||||
alone would leave the heading spanning the column with the card it belongs to
|
||||
floating in the middle — the misalignment that gave login and setup their own
|
||||
narrow column (main.page-login further down). */
|
||||
main > * { max-width: 48rem; margin-left: auto; margin-right: auto; }
|
||||
/* The three monitoring pages are the ones that opt out: six columns of send
|
||||
log, and raw mail.log lines that are long by nature, which at the measure
|
||||
left Subject and Status fighting over the last inch and wrapped every second
|
||||
log line. The class is declared by the page itself (the "wide" block in
|
||||
layout.html) rather than derived from the page name, since a delivery's own
|
||||
page is prose and shares the send log's name in the navigation. */
|
||||
main.wide > * { max-width: none; }
|
||||
/* Longhands, not the margin shorthand: the shorthand would re-zero the auto
|
||||
side margins above and pin the heading to the column's edge, a measure's
|
||||
worth to the left of the card under it. */
|
||||
h1 { font-size: 1.4rem; margin-top: 0; margin-bottom: 1rem; }
|
||||
/* The full mark, on the two pages that have no navigation bar to carry the
|
||||
compact one. It takes the column's width so its edges line up with the card
|
||||
below it, capped at that column's own 24rem; height stays automatic because
|
||||
@@ -77,7 +113,7 @@ input {
|
||||
button: a <button>, or an <a> carrying .btn/.danger where the action is a
|
||||
plain navigation (the delete confirmation page, the full queue view).
|
||||
Several of these used to render as bold blue text instead — a POST wrapped
|
||||
in form.inline, a <details> toggle, the delete links — which read as links
|
||||
in form.inline, a disclosure toggle, the delete links — which read as links
|
||||
and left two appearances for the same kind of control. They all get the
|
||||
button look now: filled for a card's own action, and the compact outlined
|
||||
variant further down where actions cluster (table rows, the nav bar). Bare
|
||||
@@ -95,13 +131,6 @@ button:hover, a.btn:hover { background: #1d4ed8; }
|
||||
/* form.inline only exists so a POST can sit next to other content without a
|
||||
form's block layout; its button is styled like any other. */
|
||||
form.inline { display: inline; margin: 0; }
|
||||
/* The three monitoring pages hold data instead of prose — seven columns of
|
||||
send-log, and raw mail.log lines that are long by nature — and at the
|
||||
default measure the send-log's Subject and Status were fighting over the
|
||||
last inch while the log pages wrapped every second line. They get a wider
|
||||
measure; every other page keeps the default one. The class comes from the
|
||||
layout, which stamps the page name onto <main>. */
|
||||
main.page-deliveries, main.page-mail_queue, main.page-system_log { max-width: 64rem; }
|
||||
/* The signed-out pages are a single card and nothing else, and .card.narrow
|
||||
centres itself inside whatever holds it — so at the panel's usual width the
|
||||
card floated in the middle while the mark and the heading stayed at the far
|
||||
@@ -136,7 +165,13 @@ td.subject span {
|
||||
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
||||
.back { display: inline-block; margin-bottom: 1rem; }
|
||||
/* The back link is a block so it takes the measure and lines up with the
|
||||
heading above it and the card below: auto side margins centre a block, but
|
||||
they do nothing for an inline-block, which would have stayed at the column's
|
||||
edge while everything around it moved in. Its own text stays at the left of
|
||||
that block — only the click target grows to the line, as it does for the
|
||||
navigation entries. */
|
||||
.back { display: block; margin-bottom: 1rem; }
|
||||
/* Build version, closing every authenticated page. Quiet on purpose: it is
|
||||
reference material, not something to read on the way past. */
|
||||
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; }
|
||||
@@ -149,10 +184,9 @@ textarea { resize: vertical; }
|
||||
button.danger, a.danger { background: #b42318; }
|
||||
button.danger:hover, a.danger:hover { background: #912018; }
|
||||
/* The disclosure toggle is an action too, so it is drawn as a button (see the
|
||||
compact rule below); the marker is dropped because the pressed background
|
||||
compact rule below); it carries no marker because the pressed background
|
||||
already shows the open state. */
|
||||
.actions summary { display: inline-block; list-style: none; cursor: pointer; }
|
||||
.actions summary::-webkit-details-marker { display: none; }
|
||||
.actions > label.toggle { display: inline-block; cursor: pointer; }
|
||||
/* Applications are a list of blocks, not table rows. As a table it fell apart:
|
||||
four columns of which the last held six controls — two of them <details>
|
||||
panels with textareas — never fit the panel's default width. The controls
|
||||
@@ -166,63 +200,163 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
||||
.app-login { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
|
||||
.app-addr { margin: 0.15rem 0 0; word-break: break-all; }
|
||||
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
|
||||
/* An open panel claims a row to itself: its textareas and number inputs want
|
||||
the block's full width, not the width of the summary that opened them. */
|
||||
.app .actions > details[open] { flex: 1 0 100%; }
|
||||
details form { margin-top: 0.6rem; }
|
||||
/* Mode and rate limit open under the whole row rather than inside it. As
|
||||
<details> the panel sat where its summary was, so opening one cut the row of
|
||||
four controls in half and pushed the rest below a block of fields — the
|
||||
buttons moved every time a panel opened or closed. The panels are therefore
|
||||
the last children of the row, and the checkbox that opens each one is
|
||||
visually hidden earlier in the row with its label drawn as the button. The
|
||||
checkbox stays in the tab order and keeps its focus ring on the label, so it
|
||||
works from the keyboard, and being pure CSS it also works with JavaScript
|
||||
blocked, as <details> did. */
|
||||
.app .actions > .panel-toggle {
|
||||
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
|
||||
}
|
||||
.app .actions > .panel { display: none; flex: 1 0 100%; }
|
||||
.app .actions > .t-mode:checked ~ .panel-mode,
|
||||
.app .actions > .t-limit:checked ~ .panel-limit { display: block; }
|
||||
.app .actions > .t-mode:checked ~ .for-mode,
|
||||
.app .actions > .t-limit:checked ~ .for-limit { background: var(--surface-open-bg); }
|
||||
.app .actions > .t-mode:focus-visible ~ .for-mode,
|
||||
.app .actions > .t-limit:focus-visible ~ .for-limit { outline: 2px solid #2563eb; outline-offset: 2px; }
|
||||
.panel form { margin-top: 0.6rem; }
|
||||
/* A panel's own submit is a form button, not one of the controls in the row
|
||||
above, so it takes back the spacing the compact .actions rule zeroes out:
|
||||
without it Save sits flush against the field it saves and against the
|
||||
Remove button under it, reading as one stack of edges. */
|
||||
.app .actions > .panel button { margin-top: 0.9rem; }
|
||||
/* Where a panel has two of them (save the limit, remove it) they share one row
|
||||
under the fields, which the row itself spaces off instead of each button.
|
||||
The buttons align on their tops rather than stretching: one of the two is
|
||||
wrapped in a form, and that wrapper's margin would otherwise make the row
|
||||
taller than a button and stretch the unwrapped one to match it. */
|
||||
.app .actions > .panel .panel-buttons {
|
||||
display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.4rem; margin-top: 0.9rem;
|
||||
}
|
||||
.app .actions > .panel .panel-buttons button,
|
||||
.app .actions > .panel .panel-buttons form { margin-top: 0; }
|
||||
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
|
||||
/* Panel navigation: rendered once from the layout, so it is present on every
|
||||
authenticated page without each content template having to include it. */
|
||||
/* Two deliberate rows, not one. The six page entries need about 660px and the
|
||||
session block another 260px, against the 738px the panel is wide — so the bar
|
||||
was wrapping on its own, and the session landed left-aligned directly under
|
||||
the entries, reading as a third row of navigation. Shortening labels would buy
|
||||
the ~200px back but undo the point of naming each entry after its page. Rows
|
||||
rather than columns: a two-column grid of entries came out a third taller
|
||||
(107px against 83px) and no easier to read.
|
||||
The session sits on top, at the right edge, and the page entries below — the
|
||||
usual arrangement, and the layout template puts the session first so the
|
||||
reading and tab order follow what the eye sees rather than being flipped by a
|
||||
CSS `order`. The cost is that Sign out is the bar's first tab stop; nothing
|
||||
activates on focus, so that is a reordering, not a hazard. */
|
||||
/* A column down the left edge rather than a bar across the top. The six page
|
||||
entries need about 660px and the session block another 260px, against the
|
||||
738px the panel was wide, so as a bar it had to be split over two rows — and
|
||||
even then it cost the top of every page. Standing it up removes that: the
|
||||
entries share one left edge to scan down, the session sits at the foot where
|
||||
it is out of the way, and there is room between them for the current page's
|
||||
own sections (.sections below), which is what makes the long pages navigable.
|
||||
Sticky, so both lists stay in view while the page scrolls past them. The
|
||||
layout template lists the blocks in the order they are drawn, so reading and
|
||||
tab order follow the eye without a CSS `order`. */
|
||||
.nav {
|
||||
display: flex; flex-direction: column; align-items: stretch;
|
||||
gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 2rem; align-self: flex-start;
|
||||
flex: none; width: 13.5rem;
|
||||
display: flex; flex-direction: column; align-items: stretch; gap: 0.75rem;
|
||||
/* A viewport shorter than the column would otherwise cut off whatever hangs
|
||||
below the fold — with no page scroll left to reach it, since the column is
|
||||
stuck to the viewport. */
|
||||
max-height: calc(100vh - 4rem); overflow-y: auto;
|
||||
}
|
||||
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; }
|
||||
/* The top row: mark at one edge, session block at the other. Splitting them to
|
||||
the edges is what makes the two rows read as two blocks rather than as one
|
||||
ragged list that happened to wrap. */
|
||||
.nav .top { display: flex; align-items: center; justify-content: space-between; gap: 0.9rem; }
|
||||
.nav .session { justify-content: flex-end; }
|
||||
/* The mark is a link, but not one of the bar's entries: it takes none of the
|
||||
.nav .links, .nav .session { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
/* The session is the column's last block and the only one that is not
|
||||
navigation, so it is ruled off from the entries above it. */
|
||||
.nav .session { padding-top: 0.75rem; border-top: 1px solid var(--border); }
|
||||
/* The signed-in administrator's name: a label above the two controls, not one
|
||||
of them. It may be long and there is nothing to break it on, so it is allowed
|
||||
to break mid word rather than widen the column. */
|
||||
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
|
||||
/* The mark is a link, but not one of the column's entries: it takes none of the
|
||||
padding and rounding the entry rule below applies, so its own edge lines up
|
||||
with the page rather than sitting half a step inside it. The stamp carries
|
||||
about 4px of field inside the file at this size, which is what puts it level
|
||||
with the icons of the row underneath. */
|
||||
with the entries' icons rather than sitting half a step inside them. */
|
||||
.nav .brand { padding: 0; }
|
||||
/* width/height are on the element too, so the row reserves the space before the
|
||||
SVG has loaded; these keep the ratio if the box is ever squeezed. The stamp's
|
||||
proportions are part of the mark — it is never scaled unevenly. */
|
||||
.nav .brand img { display: block; width: 110px; height: auto; }
|
||||
/* The mark takes the column's full width, so its edges line up with the entries
|
||||
below it rather than ending halfway across — the same way .mark fills the
|
||||
card column on the pages that carry no navigation. width/height are on the
|
||||
element too, so the column reserves the right height before the SVG has
|
||||
loaded; height stays automatic because the stamp's proportions are part of
|
||||
the mark — it is never scaled unevenly. */
|
||||
.nav .brand img { display: block; width: 100%; height: auto; }
|
||||
/* Each entry pairs an icon with its label, so the entry itself is a flex row
|
||||
rather than a run of text — that is also why the bar centres its items
|
||||
instead of aligning them on the text baseline. Account is included: it is a
|
||||
page like the others and would otherwise be the one bare word in the bar. */
|
||||
rather than a run of text — that is also why the entries centre their items
|
||||
instead of aligning them on the text baseline. In the column the entry fills
|
||||
the width, so the whole strip is the click target and the hover and active
|
||||
backgrounds read as one stack of bars. Account is included: it is a page like
|
||||
the others and would otherwise be the one bare word in the column. */
|
||||
/* No underline: in a row the entries were separated by space alone and the
|
||||
underline was what marked them as links, but a column of them reads as a
|
||||
list of links already, and eight underlines down the left edge is a lot of
|
||||
line for no added meaning. The hover background carries the affordance. */
|
||||
.nav a, .nav [aria-current] {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.2rem 0.5rem; border-radius: 6px;
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.35rem 0.6rem; border-radius: 6px; text-decoration: none;
|
||||
}
|
||||
.nav a:hover { background: var(--surface-bg); }
|
||||
/* The marker moved with the bar: a rule under the entry made sense in a row,
|
||||
where the entries sat side by side; standing up, the current entry is marked
|
||||
down its leading edge, which is the edge every entry shares. */
|
||||
.nav [aria-current] {
|
||||
font-weight: 600; color: var(--fg); background: var(--nav-active-bg); box-shadow: inset 0 -2px 0 #2563eb;
|
||||
font-weight: 600; color: var(--fg); background: var(--nav-active-bg); box-shadow: inset 2px 0 0 #2563eb;
|
||||
}
|
||||
/* Sign out carries an icon too, so it needs the same row layout; its padding
|
||||
and colours come from the compact button rule further down. */
|
||||
.nav button { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
.nav button { display: flex; align-items: center; gap: 0.5rem; }
|
||||
/* The icons draw in the entry's own colour, so the active entry's darker text,
|
||||
a link's blue and Sign out's red all carry through without a rule apiece. */
|
||||
.nav .icon { width: 1rem; height: 1rem; flex: none; }
|
||||
/* The current page's own sections, listed under the page entries. The domain
|
||||
page and the status page are eight and nine cards tall, and the only way to
|
||||
the card you came for was to scroll past all the ones you did not. Only pages
|
||||
that long carry an index — it comes from the page's own "sections" template
|
||||
(see layout.html), so a page with two cards renders nothing here. */
|
||||
.sections {
|
||||
display: flex; flex-direction: column; gap: 0.1rem;
|
||||
padding-top: 0.75rem; border-top: 1px solid var(--border);
|
||||
}
|
||||
.sections-title {
|
||||
margin: 0 0 0.25rem 0.6rem;
|
||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: #6b7280;
|
||||
}
|
||||
/* Quieter and a step in from the page entries: this is an index of one page,
|
||||
subordinate to the list of pages above it. */
|
||||
.nav .sections a {
|
||||
padding: 0.2rem 0.6rem 0.2rem 0.9rem;
|
||||
font-size: 0.85rem; color: #6b7280; text-decoration: none;
|
||||
}
|
||||
.nav .sections a:hover { color: var(--fg); }
|
||||
/* panel.js marks the section the page is scrolled to. Without JavaScript
|
||||
nothing is marked and the list is still a working index. */
|
||||
.nav .sections a.current { color: var(--fg); font-weight: 600; background: var(--nav-active-bg); }
|
||||
/* Jumping to a card should not leave it touching the top edge of the window. */
|
||||
.card[id] { scroll-margin-top: 1rem; }
|
||||
/* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap
|
||||
and the 48rem measure, plus the body's padding — the measure, not the column
|
||||
it sits in: a page column short of its 64rem is only a page column that has
|
||||
not reached its cap, while a measure that no longer fits is a page that has),
|
||||
the column lies back down
|
||||
into a bar above the page — the same wrapping rows it used to be. A drawer
|
||||
behind a hamburger would save more height, but it would need script to open,
|
||||
and the panel's navigation is six entries: they fit. */
|
||||
@media (max-width: 66rem) {
|
||||
.shell { flex-direction: column; align-items: stretch; gap: 1.2rem; }
|
||||
main { margin: 0 auto; }
|
||||
.nav {
|
||||
position: static; width: auto; max-height: none; overflow-y: visible;
|
||||
flex-direction: row; flex-wrap: wrap; align-items: center; gap: 0.4rem 1.2rem;
|
||||
padding-bottom: 0.6rem; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
/* Lying down there is no column for the mark to span, and at its full width
|
||||
it would take a row to itself above the entries, so it goes back to the
|
||||
compact size it had — enough to read beside them. */
|
||||
.nav .brand img { width: 110px; }
|
||||
/* Each block keeps its own group of entries together and wraps as one; the
|
||||
rules that separated the blocks vertically become the space between them. */
|
||||
.nav .links, .nav .session, .nav .sections {
|
||||
flex-direction: row; flex-wrap: wrap; align-items: center;
|
||||
gap: 0.2rem 0.6rem; padding-top: 0; border-top: 0;
|
||||
}
|
||||
.sections-title { margin: 0; }
|
||||
}
|
||||
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
|
||||
status page and the per-domain DNS checks, so a colour means the same thing
|
||||
everywhere. The class suffix is the check's own status value. */
|
||||
@@ -235,6 +369,25 @@ details form { margin-top: 0.6rem; }
|
||||
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
|
||||
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
|
||||
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
|
||||
/* In the domain list the badge is also the link to that domain's DNS section,
|
||||
so it must not pick up the link colour and underline that would fight with
|
||||
the badge's own palette. */
|
||||
a.st, a.st:hover { color: inherit; text-decoration: none; }
|
||||
a.st:hover { filter: brightness(1.08); }
|
||||
/* Usage bars on the status page's machine card. <meter> rather than a div sized
|
||||
from the reading, because the CSP forbids inline styles (see the note at the
|
||||
top of this file) and a bar's length has to travel on an attribute. The
|
||||
element grades itself from low/high/optimum, so the colour matches the
|
||||
badges' meaning without this file restating the thresholds — and a browser
|
||||
that does not render meters falls back to the percentage beside it, which is
|
||||
printed either way. */
|
||||
meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4rem; }
|
||||
/* The card's own two narrow columns. Cells may break mid word by default (see
|
||||
the th, td rule above), which the detail column needs and these two must not
|
||||
have: the resource names and the readings are short, and the long detail
|
||||
beside them would otherwise win the width and leave "Memory" broken across
|
||||
two lines. */
|
||||
.metric { white-space: nowrap; }
|
||||
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||
.code-row .code { flex: 1; min-width: 0; }
|
||||
/* Compact outlined button: same affordance as the filled one but quiet enough
|
||||
@@ -243,34 +396,37 @@ details form { margin-top: 0.6rem; }
|
||||
overrides this with .danger below since signing out is a deliberate,
|
||||
singular action. .actions is the shared hook: a cell that holds controls, or
|
||||
the control row of an application. */
|
||||
button.copy, .actions button, .actions summary, .actions a.danger, .nav button {
|
||||
button.copy, .actions button, .actions > label.toggle, .actions a.danger, .nav button {
|
||||
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
|
||||
border-radius: 6px; white-space: nowrap;
|
||||
background: var(--surface-bg); color: #2563eb; border: 1px solid var(--control-border);
|
||||
}
|
||||
button.copy:hover, .actions button:hover, .actions summary:hover,
|
||||
button.copy:hover, .actions button:hover, .actions > label.toggle:hover,
|
||||
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
|
||||
button.copy { flex: none; margin-top: 0.3rem; }
|
||||
.actions details[open] > summary { background: var(--surface-open-bg); }
|
||||
.actions button.danger, .actions a.danger, .nav button.danger {
|
||||
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
|
||||
}
|
||||
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: var(--danger-bg-hover); }
|
||||
|
||||
/* The optional "encrypt this download" block on the backup, export and import
|
||||
forms. Its label is the one checkbox in the panel, so it opts out of the
|
||||
/* The optional "encrypt this download" block on the backup and export forms.
|
||||
Its label is the one checkbox in the panel, so it opts out of the
|
||||
block-level label rule above and sits on one line with its box; the fields it
|
||||
reveals are indented under it to read as its consequence rather than as three
|
||||
more fields of the form. panel.js hides the inner block until the box is
|
||||
ticked (and empties it when unticked); without JavaScript everything stays
|
||||
visible, which the server handles identically. */
|
||||
visible, which the server handles identically. The import form reuses the
|
||||
same indented .encrypt-fields look for its password field, but reveals it
|
||||
by file extension instead of a checkbox (see panel.js). */
|
||||
.encrypt { margin-top: 1.2rem; }
|
||||
.encrypt label.check {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600;
|
||||
}
|
||||
.encrypt label.check input { width: auto; margin: 0; }
|
||||
.encrypt-fields {
|
||||
margin-left: 1.6rem; padding-left: 0.9rem; border-left: 2px solid var(--border);
|
||||
margin-top: 1.2rem; margin-left: 1.6rem; padding-left: 0.9rem;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
.encrypt .encrypt-fields { margin-top: 0; }
|
||||
.encrypt-fields label { margin-top: 0.7rem; }
|
||||
.encrypt-fields .muted { margin: 0.5rem 0 0; font-size: 0.85rem; }
|
||||
|
||||
@@ -102,9 +102,106 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- Import password field shown based on the chosen file's extension ---
|
||||
// The domain-import file decides for itself whether it is encrypted (the
|
||||
// server checks the envelope magic, not a checkbox), so the panel offers
|
||||
// the password field the same way: reveal it for a .spde file, hide and
|
||||
// clear it for a plain .json one. With no file chosen yet there is nothing
|
||||
// to ask a password for, so the field stays hidden until a file names it.
|
||||
// An unrecognised name leaves the field visible rather than guessing wrong
|
||||
// and hiding a password the file needs.
|
||||
function syncImportPasswordField(input) {
|
||||
var form = input.closest("form");
|
||||
var fields = form && form.querySelector("[data-import-password-fields]");
|
||||
if (!fields) {
|
||||
return;
|
||||
}
|
||||
var name = (input.files && input.files[0] && input.files[0].name || "").toLowerCase();
|
||||
var hide = name === "" || /\.json$/.test(name);
|
||||
fields.hidden = hide;
|
||||
if (hide) {
|
||||
fields.querySelectorAll("input").forEach(function (pw) {
|
||||
pw.value = "";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initImportPasswordField(root) {
|
||||
root.querySelectorAll("input[data-import-file]").forEach(function (input) {
|
||||
syncImportPasswordField(input);
|
||||
input.addEventListener("change", function () {
|
||||
syncImportPasswordField(input);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Section index follows the page -----------------------------------
|
||||
// The long pages list their own sections in the navigation column (the
|
||||
// "sections" template). Marking the one currently in view turns that list
|
||||
// from an index into a position, which is the whole point of it on a page
|
||||
// nine cards tall. The links work without any of this; only the highlight
|
||||
// depends on it.
|
||||
//
|
||||
// Each pass looks its targets up by id rather than holding on to elements
|
||||
// found once: the status page replaces its cards wholesale every five
|
||||
// seconds (hx-swap on #status-body), and anything remembered here would be
|
||||
// measuring boxes that had left the document.
|
||||
var sectionLinks = [];
|
||||
|
||||
function markCurrentSection() {
|
||||
var current = null;
|
||||
sectionLinks.forEach(function (link) {
|
||||
var target = document.getElementById(link.hash.slice(1));
|
||||
// The section in view is the last one whose top has passed the reading
|
||||
// line; the links are in document order, so the last match wins.
|
||||
if (target && target.getBoundingClientRect().top <= 100) {
|
||||
current = link;
|
||||
}
|
||||
});
|
||||
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) {
|
||||
// At the foot of the page there is no scroll left to bring the last
|
||||
// cards up to the reading line, so without this they could never be
|
||||
// marked however far down you are — and the last card of the domain
|
||||
// page is the one that deletes it.
|
||||
current = sectionLinks[sectionLinks.length - 1];
|
||||
} else if (!current) {
|
||||
// Above the first heading nothing has been passed yet, and the page is
|
||||
// still on its first section.
|
||||
current = sectionLinks[0];
|
||||
}
|
||||
sectionLinks.forEach(function (link) {
|
||||
link.classList.toggle("current", link === current);
|
||||
});
|
||||
}
|
||||
|
||||
function initSectionIndex() {
|
||||
sectionLinks = Array.prototype.slice.call(
|
||||
document.querySelectorAll(".sections a[href^='#']")
|
||||
);
|
||||
if (!sectionLinks.length) {
|
||||
return;
|
||||
}
|
||||
var pending = false;
|
||||
// Scroll fires far more often than the highlight can change, so the work
|
||||
// is collapsed onto the next frame.
|
||||
window.addEventListener("scroll", function () {
|
||||
if (pending) {
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
window.requestAnimationFrame(function () {
|
||||
pending = false;
|
||||
markCurrentSection();
|
||||
});
|
||||
}, { passive: true });
|
||||
markCurrentSection();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initAddressFields(document);
|
||||
initEncryptFields(document);
|
||||
initImportPasswordField(document);
|
||||
initSectionIndex();
|
||||
});
|
||||
|
||||
// --- Skip polling while the tab is hidden ------------------------------
|
||||
|
||||
@@ -35,6 +35,7 @@ var pageFiles = map[string][]string{
|
||||
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
|
||||
"domain_delete": {"templates/domain_delete.html"},
|
||||
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
|
||||
"delivery": {"templates/delivery.html"},
|
||||
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
||||
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
||||
"status": {"templates/status.html", "templates/status_body.html"},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{{define "content"}}
|
||||
<h1>Delivery</h1>
|
||||
|
||||
<a class="back" href="{{.BackURL}}">← 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}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -29,6 +29,79 @@ func TestEveryPageResolvesNav(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The section index each long page shows in the navigation column works by
|
||||
// overriding an empty "sections" block defined in the layout, which only holds
|
||||
// as long as the layout is parsed before the page's own files (see pageFiles).
|
||||
// Reverse that order and every index would silently disappear — the empty
|
||||
// definition would win and no page would fail to render — so the two ends are
|
||||
// asserted here: the long pages produce a list, and a page that defines nothing
|
||||
// produces nothing at all.
|
||||
func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
// Anchors the index links to, taken from the page's own cards.
|
||||
wantAnchors := map[string]string{
|
||||
"status": `href="#certificate"`,
|
||||
"domain_detail": `href="#danger"`,
|
||||
}
|
||||
for name, page := range tmpl.pages {
|
||||
var buf bytes.Buffer
|
||||
// The domain page's index hides the freshly generated credential entry
|
||||
// unless one is on the page, so the data map carries the key it reads.
|
||||
if err := page.ExecuteTemplate(&buf, "sections", map[string]any{"NewCred": nil}); err != nil {
|
||||
t.Fatalf("execute sections for %q: %v", name, err)
|
||||
}
|
||||
out := buf.String()
|
||||
anchor, wanted := wantAnchors[name]
|
||||
switch {
|
||||
case wanted && !strings.Contains(out, anchor):
|
||||
t.Errorf("page %q shows no section index (expected %s):\n%s", name, anchor, out)
|
||||
case !wanted && strings.TrimSpace(out) != "":
|
||||
t.Errorf("page %q is not long enough to carry a section index:\n%s", name, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A section link that points at no card is a link that does nothing, and
|
||||
// nothing about rendering the page says so. Every anchor the index offers must
|
||||
// name an element the same page defines an id for.
|
||||
func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
// The pages that carry an index; both are checked with a credential shown,
|
||||
// which is the domain page's one conditional entry.
|
||||
for _, name := range []string{"status", "domain_detail"} {
|
||||
var index bytes.Buffer
|
||||
if err := tmpl.pages[name].ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil {
|
||||
t.Fatalf("execute sections for %q: %v", name, err)
|
||||
}
|
||||
// The cards are spread over the page's template files, so the ids are
|
||||
// collected from the files rather than from a rendered page — rendering
|
||||
// one would need the whole of a handler's data map.
|
||||
ids := map[string]bool{}
|
||||
for _, file := range pageFiles[name] {
|
||||
body, err := fs.ReadFile(assetsFS, file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
// Cards only: a form field's id is not somewhere a section link may
|
||||
// land, so matching those too would weaken the check.
|
||||
for _, m := range regexp.MustCompile(`class="card[^"]*" id="([a-z-]+)"`).FindAllStringSubmatch(string(body), -1) {
|
||||
ids[m[1]] = true
|
||||
}
|
||||
}
|
||||
for _, m := range regexp.MustCompile(`href="#([a-z-]+)"`).FindAllStringSubmatch(index.String(), -1) {
|
||||
if !ids[m[1]] {
|
||||
t.Errorf("page %q indexes #%s, which no card on it carries", name, m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The version comes from render(), not from each handler's data map, so the
|
||||
// footer is only correct as long as every page composes with the layout and
|
||||
// render keeps supplying the key. Both are asserted here rather than trusted.
|
||||
@@ -142,6 +215,32 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Whether a page takes the whole column or the reading measure is declared by
|
||||
// the page's own "wide" block (see layout.html), which the layout stamps into
|
||||
// <main>'s class list. A page that loses the block does not fail to render — it
|
||||
// silently comes back at the measure, with its table squeezed into two thirds
|
||||
// of the column — so the set is asserted here, in both directions.
|
||||
func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
wide := map[string]bool{"deliveries": true, "mail_queue": true, "system_log": true}
|
||||
for name, page := range tmpl.pages {
|
||||
var buf bytes.Buffer
|
||||
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
|
||||
t.Fatalf("execute the wide block of %s: %v", name, err)
|
||||
}
|
||||
got := strings.TrimSpace(buf.String())
|
||||
switch {
|
||||
case wide[name] && got != "wide":
|
||||
t.Errorf("page %q no longer declares itself wide (%q); its data falls back to the reading measure", name, got)
|
||||
case !wide[name] && got != "":
|
||||
t.Errorf("page %q declares itself %q; only the pages that are tables of data or raw log lines take the whole column", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Since the panel root redirects to the status page, a link left pointing at
|
||||
// "/" silently lands on the wrong screen instead of failing — so no template may
|
||||
// contain one.
|
||||
@@ -211,12 +310,68 @@ func TestLayoutReferencesOnlyEmbeddedAssets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStatusPageRendersEveryCheck(t *testing.T) {
|
||||
out := renderStatusPage(t, statusPageData())
|
||||
for _, want := range []string{
|
||||
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
||||
"203.0.113.10 → no PTR record", `action="/reload"`,
|
||||
`hx-get="/status/fragment"`, `class="st st-error"`,
|
||||
// The machine card: the bars carry their reading in an attribute
|
||||
// (the CSP rules out sizing them with a style), and the figures are
|
||||
// printed beside them for anything that does not render a meter.
|
||||
`<meter value="12"`, `<meter value="50"`,
|
||||
"load average 0.31, 0.24, 0.19", "2.0 GiB used of 4.0 GiB",
|
||||
"eth0: 1.0 MiB in, 512.0 KiB out",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("status page is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A machine whose counters could not be read — no /proc, or a first reading
|
||||
// with nothing to compare against — must leave the card in place with its rows
|
||||
// blank, the same way an unreachable supervisord costs one line and not the
|
||||
// page.
|
||||
func TestStatusPageWithoutMachineMetrics(t *testing.T) {
|
||||
data := statusPageData()
|
||||
data["Machine"] = health.Machine{
|
||||
CPU: health.CPU{Status: health.StatusUnknown, Detail: "The kernel's processor counters (/proc/stat) could not be read here."},
|
||||
Memory: health.Memory{Status: health.StatusUnknown, Detail: "The kernel's memory counters (/proc/meminfo) could not be read here."},
|
||||
Network: health.Network{Status: health.StatusUnknown, Detail: "The kernel's network counters (/proc/net/dev) could not be read here."},
|
||||
Status: health.StatusUnknown,
|
||||
}
|
||||
|
||||
out := renderStatusPage(t, data)
|
||||
if strings.Contains(out, "<meter") {
|
||||
t.Error("a bar was drawn for a reading that does not exist")
|
||||
}
|
||||
for _, want := range []string{
|
||||
`<h2>Machine <span class="st st-unknown">`,
|
||||
"/proc/stat", "/proc/meminfo", "/proc/net/dev",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("degraded machine card is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderStatusPage(t *testing.T, data map[string]any) string {
|
||||
t.Helper()
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.pages["status"].ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||
if err := tmpl.pages["status"].ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
t.Fatalf("execute status page: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// statusPageData is one plausible reading of every check the status page shows,
|
||||
// so a test can render the page and vary the one part it is about.
|
||||
func statusPageData() map[string]any {
|
||||
return map[string]any{
|
||||
"Title": "SelfPost — status",
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
@@ -232,6 +387,26 @@ func TestStatusPageRendersEveryCheck(t *testing.T) {
|
||||
NotAfter: time.Now().Add(30 * 24 * time.Hour), DaysLeft: 30,
|
||||
Status: health.StatusOK, Detail: "Valid for another 30 day(s).",
|
||||
},
|
||||
"Machine": health.Machine{
|
||||
CPU: health.CPU{
|
||||
Measured: true, BusyPct: 12.4, Cores: 4,
|
||||
Load: [3]float64{0.31, 0.24, 0.19}, HasLoad: true,
|
||||
Status: health.StatusOK, Detail: "4 core(s) · load average 0.31, 0.24, 0.19",
|
||||
},
|
||||
Memory: health.Memory{
|
||||
Measured: true, TotalBytes: 4 << 30, UsedBytes: 2 << 30, UsedPct: 50,
|
||||
Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB; 2.0 GiB available to new work.",
|
||||
},
|
||||
Network: health.Network{
|
||||
Measured: true, RxRate: 2048, TxRate: 1024,
|
||||
Interfaces: []health.Interface{
|
||||
{Name: "eth0", RxBytes: 1 << 20, TxBytes: 1 << 19, RxRate: 2048, TxRate: 1024, Measured: true},
|
||||
},
|
||||
Status: health.StatusOK,
|
||||
},
|
||||
Window: 5 * time.Second,
|
||||
Status: health.StatusOK,
|
||||
},
|
||||
"Sockets": []health.Socket{
|
||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening."},
|
||||
},
|
||||
@@ -244,19 +419,6 @@ func TestStatusPageRendersEveryCheck(t *testing.T) {
|
||||
Detail: "No address has a reverse record.",
|
||||
Records: []string{"203.0.113.10 → no PTR record"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute status page: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
||||
"203.0.113.10 → no PTR record", `action="/reload"`,
|
||||
`hx-get="/status/fragment"`, `class="st st-error"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("status page is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,11 @@ type Server struct {
|
||||
sessions *sessionStore
|
||||
setup *setupManager
|
||||
dns *dnscheck.Checker
|
||||
// machine reads the host's CPU, memory and network counters for the
|
||||
// status page. It has to be one shared sampler for the whole server:
|
||||
// CPU and throughput are differences between successive readings, so a
|
||||
// per-request sampler would never have a previous one to subtract.
|
||||
machine health.MachineSampler
|
||||
|
||||
loginLimiter *rateLimiter
|
||||
setupLimiter *rateLimiter
|
||||
@@ -193,6 +198,7 @@ func (s *Server) Handler() http.Handler {
|
||||
// and /body endpoints return HTML, not JSON).
|
||||
authed.HandleFunc("GET /deliveries", s.handleDeliveries)
|
||||
authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows)
|
||||
authed.HandleFunc("GET /deliveries/{id}", s.handleDelivery)
|
||||
authed.HandleFunc("GET /mail-queue", s.handleMailQueue)
|
||||
authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody)
|
||||
authed.HandleFunc("GET /system-log", s.handleSystemLog)
|
||||
|
||||
@@ -166,7 +166,7 @@ func TestE2E(t *testing.T) {
|
||||
}
|
||||
|
||||
if err := waitFor("send-log row to reach status=sent", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||
rows, err := sc.panel.sendLogRows(senderDomain)
|
||||
rows, err := sc.panel.sendLogRows(senderDomain, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -54,11 +53,11 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
|
||||
}
|
||||
|
||||
if err := waitFor("a rejected row for l2app in the send log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||
rows, err := sc.panel.sendLogRows(senderDomain)
|
||||
rows, err := sc.panel.sendLogRows(senderDomain, login)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.Contains(rows, "l2app") && containsCell(rows, "rejected") {
|
||||
if containsCell(rows, "rejected") {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("no rejected row for l2app yet")
|
||||
|
||||
@@ -189,9 +189,17 @@ func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSe
|
||||
}
|
||||
|
||||
// sendLogRows returns the raw /deliveries/rows HTML fragment, filtered to one
|
||||
// domain, for polling a row's status without parsing full HTML into structs.
|
||||
func (c *panelClient) sendLogRows(domain string) (string, error) {
|
||||
_, body, err := c.get("/deliveries/rows?domain=" + url.QueryEscape(domain))
|
||||
// domain and optionally to one application login, for polling a row's status
|
||||
// without parsing full HTML into structs. The application is a filter rather
|
||||
// than something to search the returned rows for: the log's columns identify a
|
||||
// message (time, from, to, subject, status) and the sending application is only
|
||||
// named on a row's own /deliveries/{id} page.
|
||||
func (c *panelClient) sendLogRows(domain, app string) (string, error) {
|
||||
q := url.Values{"domain": {domain}}
|
||||
if app != "" {
|
||||
q.Set("app", app)
|
||||
}
|
||||
_, body, err := c.get("/deliveries/rows?" + q.Encode())
|
||||
return body, err
|
||||
}
|
||||
|
||||
@@ -202,15 +210,18 @@ func (c *panelClient) status() (*http.Response, error) {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// applicationID scrapes an application's numeric id off its domain page row,
|
||||
// keyed by login — needed to build /applications/{id}/ratelimit, which the
|
||||
// add-application response (just the login/password) does not carry.
|
||||
// applicationID scrapes an application's numeric id off its block on the domain
|
||||
// page, keyed by login — needed to build /applications/{id}/ratelimit, which the
|
||||
// add-application response (just the login/password) does not carry. The id is
|
||||
// taken from the first action posted under that login, whichever it is, so
|
||||
// reordering the block's controls does not break the scrape; only the login
|
||||
// heading itself is anchored on.
|
||||
func (c *panelClient) applicationID(domainID, login string) (string, error) {
|
||||
_, body, err := c.get("/domains/" + domainID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pattern := `(?s)<td class="code">` + regexp.QuoteMeta(login) + `</td>.*?/applications/(\d+)/mode`
|
||||
pattern := `(?s)<p class="app-login">` + regexp.QuoteMeta(login) + `</p>.*?/applications/(\d+)/`
|
||||
m := regexp.MustCompile(pattern).FindStringSubmatch(body)
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("could not find application id for login %q", login)
|
||||
|
||||