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

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

Full detail: CHANGELOG.md (0.6.0).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
parent 5e5d157496
commit 83c488cc48
40 changed files with 2207 additions and 242 deletions
+97
View File
@@ -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 ------------------------------