release: 1.0.0: Public 1.0 image; delivery log; docs and SECURITY.md (incl. 1.0.1)
[1.0.0]
Added:
- A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
- A **History** block on the same page: the journal's two timestamps stated as
Fixed:
- Postfix config: copy TLS cert/key from the (often `:ro`, host-owned) mount
- Postfix config: allow `maillog_file` under `/data` via
- E2e: read `/data/setup-token` via `docker compose exec` (file is `0600`
- Entrypoint: check `SELFPOST_HOSTNAME` before `/data` setup (so a bad identity
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
… (3 more in CHANGELOG)
Changed:
- Document what `.spbk` and `.spde` stand for (SelfPost backup / SelfPost domain
- Docs aligned with the code: setup URL is `/setup/<token>` (README and
- Full backups no longer carry `/data/log`. It is Postfix's raw log plus its
- Monitoring screens (status, mail queue, system log, deliveries) use adaptive
- Documentation package consolidated into `docs/development.md`: Documentation
… (4 more in CHANGELOG)
[1.0.1]
A documentation and packaging release: no change to the mail path, the database, or the on-disk layout. Upgrading is a tag bump.
Added:
- `SECURITY.md` — how to report a vulnerability privately (GitHub private
- [docs/plans/](docs/plans/) — one document per agreed extension: the optional
Changed:
- The panel's **Account** entry is now called **Settings** — nav link, page
- The signed-in name in the panel's nav is now labelled `User:`, so it reads as
- [docs/product.md](docs/product.md) reframes the future line: the inbound
- [docs/security.md](docs/security.md) is now in English, matching the rest of
- The README documentation table now points at `SECURITY.md` for reporting a
… (2 more in CHANGELOG)
Fixed:
- The panel's static assets are served with a content ETag and
Squashed CHANGELOG sections: [1.0.0], [1.0.1].
Full detail: CHANGELOG.md (1.0.0, 1.0.1).
This commit is contained in:
@@ -81,12 +81,14 @@ main { flex: 1 1 auto; min-width: 0; width: 100%; max-width: 64rem; }
|
||||
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. */
|
||||
/* The four monitoring pages are the ones that opt out: six columns of send
|
||||
log, 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 — and a single delivery's page, which stands two cards side by side over
|
||||
a block of those same log lines. The class is declared by the page itself
|
||||
(the "wide" block in layout.html) rather than derived from the page name: a
|
||||
page's name is the navigation entry it belongs under, which several pages
|
||||
share, and how wide a page needs to be is a property of that page alone. */
|
||||
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
|
||||
@@ -390,6 +392,98 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
|
||||
.metric { white-space: nowrap; }
|
||||
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||
.code-row .code { flex: 1; min-width: 0; }
|
||||
|
||||
/* Two cards abreast, on the one page that has a pair of them worth reading
|
||||
together (a delivery's facts beside its history). auto-fit with a minimum
|
||||
rather than a media query: the pair falls into one column exactly when a
|
||||
column would drop below the width its content needs, which is also what
|
||||
happens inside the narrow column the navigation lies down at. */
|
||||
.split {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||
gap: 1.2rem; margin-top: 1.2rem;
|
||||
}
|
||||
/* The gap already spaces the cards inside the grid; the general .card + .card
|
||||
rule would add a second gap's worth on top of it, and only to the second
|
||||
card, which in one column reads as an uneven stack. The blocks around the
|
||||
grid keep their spacing from it instead. */
|
||||
.split > .card + .card { margin-top: 0; }
|
||||
.split + .card { margin-top: 1.2rem; }
|
||||
/* The subject heads a delivery's page, and it is the one heading in the panel
|
||||
whose text we do not control: it may be a hundred characters with nothing to
|
||||
break on. It wraps to as many lines as it needs (this is the page's name, not
|
||||
a table cell to clip) but never widens the column. */
|
||||
h1.subject { overflow-wrap: anywhere; }
|
||||
/* The line under it: sender, recipient and outcome. Wrapping rather than
|
||||
clipping, since either address may be long and both are the point. The
|
||||
vertical margins are longhands for the reason given at the h1 rule above —
|
||||
the shorthand would re-zero the auto side margins that keep a page's blocks
|
||||
centred on the measure. It sits close under the heading: the two are one
|
||||
statement of what this page is about. */
|
||||
.route {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
|
||||
margin-top: -0.5rem; margin-bottom: 1rem;
|
||||
}
|
||||
.route .addr { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; overflow-wrap: anywhere; }
|
||||
.route .arrow { color: #6b7280; }
|
||||
/* A card's short facts, as a grid of tiles rather than the label-over-value
|
||||
stack the rest of the panel uses. That stack is right for the pages where a
|
||||
value is a DNS record or a password to copy — one per line, full width — and
|
||||
wrong for six values of a few characters each, which down a narrow column
|
||||
would be a page of mostly empty rows. Same auto-fit as .split: as many
|
||||
columns as fit, and one when nothing else does. */
|
||||
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
|
||||
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 8px; background: var(--surface-bg); }
|
||||
.fact-label {
|
||||
display: block; font-size: 0.7rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; color: #6b7280;
|
||||
}
|
||||
/* Nothing here is guaranteed to have a space in it — domains, logins and queue
|
||||
ids are all one run — so a value may break mid word rather than push its tile
|
||||
wider than the column it shares. */
|
||||
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
|
||||
.fact-value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
|
||||
/* A message's history: the steps down a line, each with the time it happened,
|
||||
the status it reached and what that means. The line is the list's own left
|
||||
border and the dots sit on top of it, so nothing has to be positioned against
|
||||
a fixed row height — a step's text is two lines or five depending on what
|
||||
there is to say about it. */
|
||||
.timeline { list-style: none; display: flex; flex-direction: column; gap: 1.1rem;
|
||||
margin: 1rem 0 0; padding: 0.2rem 0 0.2rem 1.4rem; border-left: 2px solid var(--border); }
|
||||
.event { position: relative; min-width: 0; }
|
||||
.event::before {
|
||||
content: ""; position: absolute; left: -1.85rem; top: 0.3rem;
|
||||
width: 0.65rem; height: 0.65rem; border-radius: 50%;
|
||||
background: var(--card-bg); border: 2px solid var(--control-border);
|
||||
}
|
||||
/* The dot carries the step's own verdict, in the same four colours as the badge
|
||||
beside it — so the shape of the history is readable down the line alone. */
|
||||
.event.lvl-ok::before { border-color: var(--st-ok-fg); background: var(--st-ok-bg); }
|
||||
.event.lvl-warn::before { border-color: var(--st-warn-fg); background: var(--st-warn-bg); }
|
||||
.event.lvl-error::before { border-color: var(--st-error-fg); background: var(--st-error-bg); }
|
||||
/* The step that has not happened yet — a queued message's delivery report — is
|
||||
the one hollow dot, and its text is quiet: it is what to expect, not a record
|
||||
of anything. */
|
||||
.event.pending { opacity: 0.7; }
|
||||
.event.pending::before { background: var(--card-bg); border-style: dashed; }
|
||||
.event-time { margin: 0; font-size: 0.75rem; color: #6b7280; }
|
||||
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
|
||||
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
|
||||
/* A message's own mail.log lines, as a table of when and what. The time column
|
||||
is sized to its content — width:1% is as narrow as the automatic layout will
|
||||
take a column, which with the nowrap on td.time settles on exactly the stamp
|
||||
— so everything left over goes to the text, which is the column that needs
|
||||
it. */
|
||||
table.log { margin-top: 1rem; }
|
||||
table.log th:first-child, table.log td.time { width: 1%; }
|
||||
/* The log's own text: monospace, because it is full of addresses, response
|
||||
codes and identifiers that are compared character by character rather than
|
||||
read as words. pre-wrap keeps the spacing a server's reply was written with
|
||||
while still letting a long line wrap inside the cell instead of widening it
|
||||
(the th/td rule above allows the break). */
|
||||
table.log td.log-text {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.8rem; white-space: pre-wrap;
|
||||
}
|
||||
/* Compact outlined button: same affordance as the filled one but quiet enough
|
||||
that several can sit together without shouting — the Copy buttons beside a
|
||||
value, the controls of a table row or of an application block. Sign out
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
//
|
||||
// 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
|
||||
// seconds (adaptive polling on #status-body), and anything remembered here would be
|
||||
// measuring boxes that had left the document.
|
||||
var sectionLinks = [];
|
||||
|
||||
@@ -204,19 +204,95 @@
|
||||
initSectionIndex();
|
||||
});
|
||||
|
||||
// --- Skip polling while the tab is hidden ------------------------------
|
||||
// The monitoring pages (status, mail queue, system log, deliveries) poll
|
||||
// every 5s via hx-trigger="every 5s". htmx has a built-in way to make that
|
||||
// conditional (an event filter, hx-trigger="every 5s [expr]"), but it
|
||||
// evaluates the filter with `new Function`, which the panel's CSP
|
||||
// (default-src 'self', no 'unsafe-eval') would silently break. Skipping the
|
||||
// request here instead needs nothing beyond what the CSP already allows: a
|
||||
// request due while the tab is hidden is simply not sent, and the next
|
||||
// request after it becomes visible again picks up on schedule as usual.
|
||||
// --- Adaptive monitoring polling ---------------------------------------
|
||||
// The four monitoring fragments carry data-poll and hx-trigger="load" for
|
||||
// the first fetch only. panel.js schedules the rest: 5 s while the operator
|
||||
// is active on the page, 30 s when the tab is visible but idle, and nothing
|
||||
// while the tab is hidden. hx-trigger="every Ns [expr]" could express some
|
||||
// of that, but the filter is evaluated with `new Function`, which the
|
||||
// panel's CSP (default-src 'self', no 'unsafe-eval') would silently break.
|
||||
var pollActiveMs = 5000;
|
||||
var pollIdleMs = 30000;
|
||||
// No pointer/keyboard/scroll input for this long → treat the tab as idle.
|
||||
var userIdleMs = 30000;
|
||||
var lastActivity = Date.now();
|
||||
var pollTimers = Object.create(null);
|
||||
|
||||
["mousedown", "mousemove", "keydown", "scroll", "touchstart"].forEach(function (evt) {
|
||||
document.addEventListener(evt, function () {
|
||||
lastActivity = Date.now();
|
||||
}, { passive: true });
|
||||
});
|
||||
|
||||
function pollDelayMs() {
|
||||
return Date.now() - lastActivity < userIdleMs ? pollActiveMs : pollIdleMs;
|
||||
}
|
||||
|
||||
function triggerPoll(el) {
|
||||
htmx.ajax("GET", el.getAttribute("hx-get"), {
|
||||
target: "#" + el.id,
|
||||
swap: el.getAttribute("hx-swap") || "outerHTML"
|
||||
});
|
||||
}
|
||||
|
||||
function schedulePoll(el) {
|
||||
if (!el || !el.id || !el.hasAttribute("data-poll")) {
|
||||
return;
|
||||
}
|
||||
if (pollTimers[el.id]) {
|
||||
clearTimeout(pollTimers[el.id]);
|
||||
delete pollTimers[el.id];
|
||||
}
|
||||
if (document.hidden) {
|
||||
return;
|
||||
}
|
||||
var id = el.id;
|
||||
pollTimers[id] = setTimeout(function () {
|
||||
delete pollTimers[id];
|
||||
var current = document.getElementById(id);
|
||||
if (!current || !current.hasAttribute("data-poll")) {
|
||||
return;
|
||||
}
|
||||
if (document.hidden) {
|
||||
schedulePoll(current);
|
||||
return;
|
||||
}
|
||||
triggerPoll(current);
|
||||
}, pollDelayMs());
|
||||
}
|
||||
|
||||
function onPollElementReady(el) {
|
||||
if (!el || !el.hasAttribute("data-poll")) {
|
||||
return;
|
||||
}
|
||||
// Swapped-in markup still carries hx-trigger="load"; strip it so htmx does
|
||||
// not issue a duplicate GET on top of the response we just received.
|
||||
el.removeAttribute("hx-trigger");
|
||||
schedulePoll(el);
|
||||
}
|
||||
|
||||
document.body.addEventListener("htmx:afterSwap", function (ev) {
|
||||
onPollElementReady(ev.detail.elt);
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function (ev) {
|
||||
onPollElementReady(ev.detail.elt);
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", function (ev) {
|
||||
var trigger = ev.target.getAttribute && ev.target.getAttribute("hx-trigger");
|
||||
if (document.hidden && trigger && trigger.indexOf("every") !== -1) {
|
||||
if (document.hidden && ev.target.hasAttribute && ev.target.hasAttribute("data-poll")) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.hidden) {
|
||||
Object.keys(pollTimers).forEach(function (id) {
|
||||
clearTimeout(pollTimers[id]);
|
||||
delete pollTimers[id];
|
||||
});
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll("[data-poll]").forEach(schedulePoll);
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user