feat(panel): navigation as a left column, with a section index on the long pages

The navigation was a bar across the top that 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. It is now a column down the left edge:
one left edge to scan, the current entry marked down its leading edge, sticky so
it stays in view, and room under the entries for the current page's own
sections. Below the width the two columns need it lies back down into the same
wrapping rows as before; six entries need no drawer.

The section index is for the two pages long enough to need one — the domain page
(nine cards) and the status page (eight). Each card carries an id and the page's
template defines the list by overriding an empty "sections" block in the layout,
so a page that defines nothing renders no index. panel.js marks the section in
view, looking 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 themselves are plain fragment links and need no script.

Verified against the real pages rendered by a local panel at 1300px, 924px and
481px wide.
This commit is contained in:
2026-08-07 04:11:32 +03:00
parent 76ad20efdf
commit c0c66dfcdb
8 changed files with 349 additions and 70 deletions
+24
View File
@@ -21,6 +21,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
— no `/proc` outside Linux — leave the card in place showing "unknown". The — 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 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. 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
- 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.
## [0.5.0] - 2026-08-06 ## [0.5.0] - 2026-08-06
+100 -36
View File
@@ -50,11 +50,16 @@ body {
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg); 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; }
/* 48rem is a reading measure: right for the forms and prose that make up most /* 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. of the panel. Page-specific overrides below widen or narrow it for the pages
Page-specific overrides below widen or narrow it for the pages that need that need something else. No auto margins: inside the shell they would eat
something else. */ the free space and push the navigation column off to the far edge, so the
main { max-width: 48rem; margin: 0 auto; } centring is the shell's job (justify-content above). */
main { flex: 1 1 auto; min-width: 0; width: 100%; max-width: 48rem; }
h1 { font-size: 1.4rem; margin: 0 0 1rem; } h1 { font-size: 1.4rem; margin: 0 0 1rem; }
/* The full mark, on the two pages that have no navigation bar to carry the /* 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 compact one. It takes the column's width so its edges line up with the card
@@ -173,56 +178,115 @@ details form { margin-top: 0.6rem; }
.credential { border-color: var(--credential-border); background: var(--credential-bg); } .credential { border-color: var(--credential-border); background: var(--credential-bg); }
/* Panel navigation: rendered once from the layout, so it is present on every /* Panel navigation: rendered once from the layout, so it is present on every
authenticated page without each content template having to include it. */ authenticated page without each content template having to include it. */
/* Two deliberate rows, not one. The six page entries need about 660px and the /* A column down the left edge rather than a bar across the top. The six page
session block another 260px, against the 738px the panel is wide — so the bar entries need about 660px and the session block another 260px, against the
was wrapping on its own, and the session landed left-aligned directly under 738px the panel was wide, so as a bar it had to be split over two rows — and
the entries, reading as a third row of navigation. Shortening labels would buy even then it cost the top of every page. Standing it up removes that: the
the ~200px back but undo the point of naming each entry after its page. Rows entries share one left edge to scan down, the session sits at the foot where
rather than columns: a two-column grid of entries came out a third taller it is out of the way, and there is room between them for the current page's
(107px against 83px) and no easier to read. own sections (.sections below), which is what makes the long pages navigable.
The session sits on top, at the right edge, and the page entries below — the Sticky, so both lists stay in view while the page scrolls past them. The
usual arrangement, and the layout template puts the session first so the layout template lists the blocks in the order they are drawn, so reading and
reading and tab order follow what the eye sees rather than being flipped by a tab order follow the eye without a CSS `order`. */
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. */
.nav { .nav {
display: flex; flex-direction: column; align-items: stretch; position: sticky; top: 2rem; align-self: flex-start;
gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem; flex: none; width: 13.5rem;
border-bottom: 1px solid var(--border); 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; } .nav .links, .nav .session { display: flex; flex-direction: column; gap: 0.1rem; }
/* The top row: mark at one edge, session block at the other. Splitting them to /* The session is the column's last block and the only one that is not
the edges is what makes the two rows read as two blocks rather than as one navigation, so it is ruled off from the entries above it. */
ragged list that happened to wrap. */ .nav .session { padding-top: 0.75rem; border-top: 1px solid var(--border); }
.nav .top { display: flex; align-items: center; justify-content: space-between; gap: 0.9rem; } /* The signed-in administrator's name: a label above the two controls, not one
.nav .session { justify-content: flex-end; } of them. It may be long and there is nothing to break it on, so it is allowed
/* The mark is a link, but not one of the bar's entries: it takes none of the 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 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 with the entries' icons rather than sitting half a step inside them. */
about 4px of field inside the file at this size, which is what puts it level
with the icons of the row underneath. */
.nav .brand { padding: 0; } .nav .brand { padding: 0; }
/* width/height are on the element too, so the row reserves the space before the /* 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 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. */ proportions are part of the mark — it is never scaled unevenly. */
.nav .brand img { display: block; width: 110px; height: auto; } .nav .brand img { display: block; width: 110px; height: auto; }
/* Each entry pairs an icon with its label, so the entry itself is a flex row /* 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 rather than a run of text — that is also why the entries centre their items
instead of aligning them on the text baseline. Account is included: it is a instead of aligning them on the text baseline. In the column the entry fills
page like the others and would otherwise be the one bare word in the bar. */ 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] { .nav a, .nav [aria-current] {
display: inline-flex; align-items: center; gap: 0.4rem; display: flex; align-items: center; gap: 0.5rem;
padding: 0.2rem 0.5rem; border-radius: 6px; 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] { .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 /* 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. */ 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, /* 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. */ a link's blue and Sign out's red all carry through without a rule apiece. */
.nav .icon { width: 1rem; height: 1rem; flex: none; } .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 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);
}
/* 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 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 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. */ everywhere. The class suffix is the check's own status value. */
+63
View File
@@ -102,9 +102,72 @@
}); });
} }
// --- 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 () { document.addEventListener("DOMContentLoaded", function () {
initAddressFields(document); initAddressFields(document);
initEncryptFields(document); initEncryptFields(document);
initSectionIndex();
}); });
// --- Skip polling while the tab is hidden ------------------------------ // --- Skip polling while the tab is hidden ------------------------------
+33 -10
View File
@@ -7,7 +7,7 @@
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}} {{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
{{if .NewCred}} {{if .NewCred}}
<div class="card credential"> <div class="card credential" id="new-credential">
<h2>New application password</h2> <h2>New application password</h2>
<p class="muted">This password is shown <strong>once only</strong> and is not <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> stored. Copy it now — if it is lost, regenerate a new one.</p>
@@ -24,7 +24,7 @@
</div> </div>
{{end}} {{end}}
<div class="card"> <div class="card" id="dkim">
<h2>DKIM DNS record</h2> <h2>DKIM DNS record</h2>
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>. <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> 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> <p class="muted">Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div> </div>
<div class="card"> <div class="card" id="spf-dmarc">
<h2>SPF and DMARC records</h2> <h2>SPF and DMARC records</h2>
<p class="muted">These two are not generated the way the DKIM record above is — <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 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> <code>p=reject</code> once the reports come back clean.</p>
</div> </div>
<div class="card"> <div class="card" id="dns-status">
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2> <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 <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 now, checked against the key this server signs with. Results are cached for a
@@ -121,7 +121,7 @@
</form> </form>
</div> </div>
<div class="card"> <div class="card" id="settings">
<h2>Sending server settings</h2> <h2>Sending server settings</h2>
<p class="muted">Point the mail client or script at these settings and <p class="muted">Point the mail client or script at these settings and
authenticate with an application login and password from the 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 {{/* Create form above the list, the same order the domains page uses for
"Add a sending domain" above "Domains". */}} "Add a sending domain" above "Domains". */}}
<div class="card"> <div class="card" id="add-application">
<h2>Add an application</h2> <h2>Add an application</h2>
<form method="post" action="/domains/{{.Domain.ID}}/applications"> <form method="post" action="/domains/{{.Domain.ID}}/applications">
<label for="login">Login</label> <label for="login">Login</label>
@@ -173,7 +173,7 @@
be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p> be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p>
</div> </div>
<div class="card"> <div class="card" id="applications">
<h2>Applications</h2> <h2>Applications</h2>
<p class="muted">Each application is a SASL login/password an app or script <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 uses to send mail as this domain. A login may send from any address of the
@@ -241,7 +241,7 @@
{{end}} {{end}}
</div> </div>
<div class="card"> <div class="card" id="rate-limit">
<h2>Sending rate limit (domain)</h2> <h2>Sending rate limit (domain)</h2>
<p class="muted">Optional level-2 limit (spec 7.4): cap how many messages this <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 domain may send from its expected client IP(s) within a time window, summed
@@ -276,7 +276,7 @@
{{end}} {{end}}
</div> </div>
<div class="card"> <div class="card" id="export">
<h2>Export domain</h2> <h2>Export domain</h2>
<p class="muted">Download this domain to move it to another SelfPost instance: <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 its DKIM key, selector and every application with its working password. On
@@ -292,10 +292,33 @@
</form> </form>
</div> </div>
<div class="card"> <div class="card" id="danger">
<h2>Danger zone</h2> <h2>Danger zone</h2>
<p class="muted">Deleting this domain also deletes its DKIM key and every <p class="muted">Deleting this domain also deletes its DKIM key and every
application bound to it.</p> application bound to it.</p>
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a> <a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
</div> </div>
{{end}} {{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}}
+28 -15
View File
@@ -22,13 +22,17 @@
<script src="/static/panel.js" defer></script> <script src="/static/panel.js" defer></script>
</head> </head>
<body> <body>
{{/* 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 {{/* 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 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 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 for the navigation, rather than a second field every handler would have to
remember to set. */}} remember to set. */}}
<main class="page-{{.Active}}"> <main class="page-{{.Active}}">
{{if .User}}{{template "nav" .}}{{end}}
{{template "content" .}} {{template "content" .}}
{{/* The running version, on every authenticated page: it is what a backup {{/* The running version, on every authenticated page: it is what a backup
manifest is checked against on restore and the first thing to establish manifest is checked against on restore and the first thing to establish
@@ -36,6 +40,7 @@
the login and setup pages must not advertise it to the internet. */}} the login and setup pages must not advertise it to the internet. */}}
{{if .User}}<footer class="version">SelfPost {{.Version}}</footer>{{end}} {{if .User}}<footer class="version">SelfPost {{.Version}}</footer>{{end}}
</main> </main>
</div>
</body> </body>
</html>{{end}} </html>{{end}}
@@ -46,21 +51,11 @@
.User and get no nav. */}} .User and get no nav. */}}
{{define "nav"}} {{define "nav"}}
<nav class="nav"> <nav class="nav">
{{/* The top row is first here because it is drawn first (see .nav in {{/* The order of the blocks is the order they are read in, top to bottom of
panel.css), which keeps the tab order matching the visual one. It holds the column: the mark, the panel's pages, the current page's own sections,
the mark on the left and the session block on the right; the page entries and the session last. The mark goes to /status, the page the panel opens
follow underneath. The mark goes to /status, the page the panel opens on — on — named outright rather than as "/", which is only a redirect to it. */}}
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> <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>
<div class="links"> <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 "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}} {{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 +64,27 @@
{{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 "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}} {{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
</div> </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> </nav>
{{end}} {{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}}
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they {{/* 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 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 need no exemption from the panel's "default-src 'self'" policy. Each is
+21 -2
View File
@@ -5,7 +5,7 @@
{{template "status_body" .}} {{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> <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 <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 resolves to its address <em>and</em> that the address resolves back to the same
@@ -29,7 +29,7 @@
</form> </form>
</div> </div>
<div class="card"> <div class="card" id="configuration">
<h2>Configuration</h2> <h2>Configuration</h2>
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the <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, database and reloads both daemons. Use it if you edited the files by hand,
@@ -41,3 +41,22 @@
</form> </form>
</div> </div>
{{end}} {{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}}
+6 -6
View File
@@ -1,11 +1,11 @@
{{define "status_body"}} {{define "status_body"}}
<div id="status-body" hx-get="/status/fragment" hx-trigger="every 5s" hx-swap="outerHTML"> <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> <h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
<p class="muted">{{.OverallHeading}}</p> <p class="muted">{{.OverallHeading}}</p>
</div> </div>
<div class="card"> <div class="card" id="processes">
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2> <h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
{{if .ProcessError}} {{if .ProcessError}}
<p class="error">Could not ask supervisord for the process list.</p> <p class="error">Could not ask supervisord for the process list.</p>
@@ -25,7 +25,7 @@
{{end}} {{end}}
</div> </div>
<div class="card"> <div class="card" id="machine">
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2> <h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
<p class="muted">Processor, memory and network of the machine this container <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 runs on, read from the kernel's counters. CPU and throughput are rates, so
@@ -72,7 +72,7 @@
</table> </table>
</div> </div>
<div class="card"> <div class="card" id="queue">
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2> <h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
{{if .QueueError}} {{if .QueueError}}
<p class="error">{{.QueueError}}</p> <p class="error">{{.QueueError}}</p>
@@ -82,7 +82,7 @@
{{end}} {{end}}
</div> </div>
<div class="card"> <div class="card" id="certificate">
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2> <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}}. <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> It is supplied by the reverse proxy through a read-only mount; SelfPost only reads it.</p>
@@ -93,7 +93,7 @@
<p class="{{if eq .Cert.Status "ok"}}muted{{else}}error{{end}}">{{.Cert.Detail}}</p> <p class="{{if eq .Cert.Status "ok"}}muted{{else}}error{{end}}">{{.Cert.Detail}}</p>
</div> </div>
<div class="card"> <div class="card" id="sockets">
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2> <h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
<table> <table>
<thead><tr><th>Milter</th><th>Socket</th><th>State</th></tr></thead> <thead><tr><th>Milter</th><th>Socket</th><th>State</th></tr></thead>
+73
View File
@@ -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 // 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 // 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. // render keeps supplying the key. Both are asserted here rather than trusted.