Add in-panel Help: CSS-only drawer with seeded Status and domain topics, «?» entry points on cards, and guide.md boundary for what stays in-repo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-08-18
|
||||||
|
|
||||||
|
Built-in operator documentation in the panel: a CSS-only help drawer and Help
|
||||||
|
page so Status checks and domain controls are explained without leaving the
|
||||||
|
panel.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Help** navigation entry and `/help` index page.
|
||||||
|
- CSS-checkbox help drawer (no extra script): seeded topics for Status (Machine,
|
||||||
|
TLS, PTR, mail queue, inbound when enabled) and domain cards (DNS, records,
|
||||||
|
DMARC, connection, applications, settings, export).
|
||||||
|
- «?» entry points on Status and domain card heads open the matching drawer
|
||||||
|
topic.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- [guide.md](docs/guide.md) documents the boundary: in-panel help for card
|
||||||
|
meaning; the repository guide for installation, env vars, and operations.
|
||||||
|
|
||||||
## [1.7.0] - 2026-08-18
|
## [1.7.0] - 2026-08-18
|
||||||
|
|
||||||
Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse
|
Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:1.7.0
|
image: ghcr.io/mixeme/selfpost:1.8.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||||
|
|||||||
@@ -311,6 +311,18 @@ Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
|
|||||||
After sign-in the panel opens on **Status** — the place to answer "is the
|
After sign-in the panel opens on **Status** — the place to answer "is the
|
||||||
service healthy and will mail be accepted?"
|
service healthy and will mail be accepted?"
|
||||||
|
|
||||||
|
### In-panel help
|
||||||
|
|
||||||
|
The panel carries short operator notes so day-to-day work does not require
|
||||||
|
opening this guide. **Help** in the navigation opens an index; each Status or
|
||||||
|
domain card's «?» opens the same text in a side drawer (CSS only, no extra
|
||||||
|
script). That content explains what a check or control *means* — kernel
|
||||||
|
counters, TLS on port 465, forward-confirmed PTR, DNS publish hints, rate
|
||||||
|
limits, and similar. It is not a second copy of this document: installation,
|
||||||
|
reverse-proxy setup, backup/restore, environment variables, and security
|
||||||
|
assumptions stay here in `docs/guide.md` (linked from the drawer and Help
|
||||||
|
page).
|
||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
|
||||||
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
|
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Plan: panel-docs (in-panel operator documentation)
|
# Plan: panel-docs (in-panel operator documentation)
|
||||||
|
|
||||||
**Status:** candidate
|
**Status:** agreed
|
||||||
**Date:** 2026-08-17
|
**Date:** 2026-08-17
|
||||||
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
|
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
|
||||||
|
|
||||||
@@ -46,9 +46,9 @@ Target version cut: **`1.8.0`** (MINOR). One commit per step; code only after
|
|||||||
roadmap status is **agreed**. See [development.md](../development.md) § Plan
|
roadmap status is **agreed**. See [development.md](../development.md) § Plan
|
||||||
checklists.
|
checklists.
|
||||||
|
|
||||||
- [ ] Help drawer / pages shell (CSS checkbox pattern from mockups) — **Sonnet**
|
- [x] Help drawer / pages shell (CSS checkbox pattern from mockups) — **Sonnet**
|
||||||
- [ ] Seed Status blurbs (Machine, TLS, PTR, …) — **Sonnet**
|
- [x] Seed Status blurbs (Machine, TLS, PTR, …) — **Sonnet**
|
||||||
- [ ] «?» entry points on domain cards — **Sonnet**
|
- [x] «?» entry points on domain cards — **Sonnet**
|
||||||
- [ ] [guide.md](../guide.md) boundary: in-panel help vs full guide — **Sonnet**
|
- [x] [guide.md](../guide.md) boundary: in-panel help vs full guide — **Sonnet**
|
||||||
- [ ] Template tests — **Sonnet**
|
- [x] Template tests — **Sonnet**
|
||||||
- [ ] `go vet`, `go test` on touched packages — **Haiku**
|
- [x] `go vet`, `go test` on touched packages — **Haiku**
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
| ID | Topic | Status | Progress | Plan |
|
| ID | Topic | Status | Progress | Plan |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| contributing | `CONTRIBUTING.md` | candidate | — | — |
|
| contributing | `CONTRIBUTING.md` | candidate | — | — |
|
||||||
| panel-docs | In-panel operator documentation | candidate | 0/6 | [plans/panel-docs.md](plans/panel-docs.md) |
|
| panel-docs | In-panel operator documentation | agreed | 6/6 | [plans/panel-docs.md](plans/panel-docs.md) |
|
||||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
|
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
|
||||||
|
|
||||||
**Recommended order** (not binding): the next feature is **panel-docs** once
|
**Recommended order** (not binding): the next feature is **panel-docs** once
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handlers) HandleHelp(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "SelfPost — help",
|
||||||
|
"User": auth.CurrentUser(r),
|
||||||
|
"Active": "help",
|
||||||
|
"IsGlobal": p.IsGlobal(),
|
||||||
|
}
|
||||||
|
h.view.Render(w, http.StatusOK, "help", data)
|
||||||
|
}
|
||||||
@@ -756,3 +756,71 @@ button.copy { flex: none; }
|
|||||||
.encrypt .encrypt-fields { margin-top: 0; }
|
.encrypt .encrypt-fields { margin-top: 0; }
|
||||||
.encrypt-fields label { margin-top: 0.7rem; }
|
.encrypt-fields label { margin-top: 0.7rem; }
|
||||||
.encrypt-fields .muted { margin: 0.5rem 0 0; font-size: 0.85rem; }
|
.encrypt-fields .muted { margin: 0.5rem 0 0; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* Screen-reader accessible controls that drive UI state (help drawer panes). */
|
||||||
|
.ctrl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
|
||||||
|
gap: 0.5rem 1rem; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.page-head h1 { margin: 0; }
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
|
||||||
|
gap: 0.5rem 0.75rem; margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
.card-head h2 { margin: 0; }
|
||||||
|
|
||||||
|
.help-link {
|
||||||
|
flex: none; width: 1.35rem; height: 1.35rem; display: inline-flex; align-items: center;
|
||||||
|
justify-content: center; border-radius: 50%; border: 1px solid var(--control-border);
|
||||||
|
color: var(--muted); text-decoration: none; font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem; font-weight: 600; background: var(--card-bg); cursor: pointer; margin: 0;
|
||||||
|
}
|
||||||
|
.help-link:hover { color: var(--accent-text); border-color: var(--accent-fill); }
|
||||||
|
|
||||||
|
/* Help drawer — CSS checkbox/radio, no script required. */
|
||||||
|
.help-drawer {
|
||||||
|
display: none; position: fixed; top: 0; right: 0; bottom: 0;
|
||||||
|
width: min(26rem, 100vw); z-index: 30;
|
||||||
|
background: var(--card-bg); border-left: 1px solid var(--border);
|
||||||
|
padding: 1.25rem 1.35rem 2rem; overflow-y: auto;
|
||||||
|
}
|
||||||
|
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-drawer,
|
||||||
|
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-scrim { display: block; }
|
||||||
|
.help-pane { display: none; }
|
||||||
|
html:has(#help-index:checked) .help-pane-index,
|
||||||
|
html:has(#help-status:checked) .help-pane-status,
|
||||||
|
html:has(#help-password:checked) .help-pane-password,
|
||||||
|
html:has(#help-dns:checked) .help-pane-dns,
|
||||||
|
html:has(#help-records:checked) .help-pane-records,
|
||||||
|
html:has(#help-dmarc:checked) .help-pane-dmarc,
|
||||||
|
html:has(#help-connection:checked) .help-pane-connection,
|
||||||
|
html:has(#help-apps:checked) .help-pane-apps,
|
||||||
|
html:has(#help-domain-settings:checked) .help-pane-domain-settings,
|
||||||
|
html:has(#help-export:checked) .help-pane-export { display: block; }
|
||||||
|
.help-scrim {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 25;
|
||||||
|
background: rgba(18, 22, 28, 0.28); cursor: pointer;
|
||||||
|
}
|
||||||
|
.help-drawer h2 { margin-top: 1.2rem; }
|
||||||
|
.help-drawer h2:first-of-type,
|
||||||
|
.help-pane h2 { margin-top: 0; }
|
||||||
|
.help-drawer .toc { list-style: none; margin: 0.7rem 0 0; padding: 0; }
|
||||||
|
.help-drawer .toc li { margin: 0.35rem 0 0; }
|
||||||
|
.help-drawer .toc label {
|
||||||
|
margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.help-drawer .more { margin-top: 1.2rem; font-size: 0.85rem; }
|
||||||
|
.help-drawer .more label {
|
||||||
|
display: inline; margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.help-close {
|
||||||
|
position: absolute; top: 0.8rem; right: 0.8rem; margin: 0; padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.8rem; font-weight: 600; background: var(--surface-bg); color: var(--fg);
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px; cursor: pointer;
|
||||||
|
}
|
||||||
|
@media (max-width: 66rem) {
|
||||||
|
.help-drawer { width: 100vw; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,7 +67,10 @@
|
|||||||
|
|
||||||
{{if .NewCred}}
|
{{if .NewCred}}
|
||||||
<div class="card credential" id="new-credential">
|
<div class="card credential" id="new-credential">
|
||||||
<h2>New application password</h2>
|
<div class="card-head">
|
||||||
|
<h2>New application password</h2>
|
||||||
|
{{template "card_help" (dict "ID" "password" "Title" "Why this is shown once")}}
|
||||||
|
</div>
|
||||||
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now
|
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now
|
||||||
— if it is lost, regenerate a new one.</p>
|
— if it is lost, regenerate a new one.</p>
|
||||||
<label>Login</label>
|
<label>Login</label>
|
||||||
@@ -85,7 +88,10 @@
|
|||||||
|
|
||||||
{{/* Two rows of two checks (.check-cols). */}}
|
{{/* Two rows of two checks (.check-cols). */}}
|
||||||
<div class="card" id="dns-status">
|
<div class="card" id="dns-status">
|
||||||
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
|
<div class="card-head">
|
||||||
|
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
|
||||||
|
{{template "card_help" (dict "ID" "dns" "Title" "What these checks mean")}}
|
||||||
|
</div>
|
||||||
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
|
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
|
||||||
publishing.</p>
|
publishing.</p>
|
||||||
|
|
||||||
@@ -141,7 +147,10 @@
|
|||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div class="card" id="dkim-spf">
|
<div class="card" id="dkim-spf">
|
||||||
<h2>DKIM and SPF records</h2>
|
<div class="card-head">
|
||||||
|
<h2>DKIM and SPF records</h2>
|
||||||
|
{{template "card_help" (dict "ID" "records" "Title" "What to publish")}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="check-col-title">DKIM</p>
|
<p class="check-col-title">DKIM</p>
|
||||||
{{template "host_type_copy" .Record.Name}}
|
{{template "host_type_copy" .Record.Name}}
|
||||||
@@ -159,7 +168,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="dmarc">
|
<div class="card" id="dmarc">
|
||||||
<h2>DMARC record</h2>
|
<div class="card-head">
|
||||||
|
<h2>DMARC record</h2>
|
||||||
|
{{template "card_help" (dict "ID" "dmarc" "Title" "What this record does")}}
|
||||||
|
</div>
|
||||||
|
|
||||||
{{template "host_type_copy" .DMARCName}}
|
{{template "host_type_copy" .DMARCName}}
|
||||||
|
|
||||||
@@ -191,7 +203,10 @@
|
|||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div class="card" id="connection">
|
<div class="card" id="connection">
|
||||||
<h2>Connection settings</h2>
|
<div class="card-head">
|
||||||
|
<h2>Connection settings</h2>
|
||||||
|
{{template "card_help" (dict "ID" "connection" "Title" "How to connect")}}
|
||||||
|
</div>
|
||||||
<p class="muted">Same for every domain. Authenticate with an application
|
<p class="muted">Same for every domain. Authenticate with an application
|
||||||
login from below.</p>
|
login from below.</p>
|
||||||
|
|
||||||
@@ -262,7 +277,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="applications">
|
<div class="card" id="applications">
|
||||||
<h2>Applications</h2>
|
<div class="card-head">
|
||||||
|
<h2>Applications</h2>
|
||||||
|
{{template "card_help" (dict "ID" "apps" "Title" "What an application is")}}
|
||||||
|
</div>
|
||||||
<p class="muted">SASL logins for this domain — wildcard (*@domain) or a fixed
|
<p class="muted">SASL logins for this domain — wildcard (*@domain) or a fixed
|
||||||
address list.</p>
|
address list.</p>
|
||||||
|
|
||||||
@@ -376,7 +394,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="domain-settings">
|
<div class="card" id="domain-settings">
|
||||||
<h2>Domain settings</h2>
|
<div class="card-head">
|
||||||
|
<h2>Domain settings</h2>
|
||||||
|
{{template "card_help" (dict "ID" "domain-settings" "Title" "Reports and rate limits")}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="check-cols check-cols-rows">
|
<div class="check-cols check-cols-rows">
|
||||||
<div class="check-col">
|
<div class="check-col">
|
||||||
@@ -464,7 +485,10 @@
|
|||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div class="card" id="export">
|
<div class="card" id="export">
|
||||||
<h2>Export domain</h2>
|
<div class="card-head">
|
||||||
|
<h2>Export domain</h2>
|
||||||
|
{{template "card_help" (dict "ID" "export" "Title" "What the file contains")}}
|
||||||
|
</div>
|
||||||
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
|
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
|
||||||
below as <code>.spde</code>.</p>
|
below as <code>.spde</code>.</p>
|
||||||
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Help</h1>
|
||||||
|
|
||||||
|
<p class="muted">Short operator notes inside the panel — not a second copy of
|
||||||
|
the full guide. The cards keep their readings and controls; this page (and the
|
||||||
|
drawer opened from each card's «?») holds the explanations that do not belong
|
||||||
|
on the card itself.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>On this panel</h2>
|
||||||
|
{{if .IsGlobal}}
|
||||||
|
<ul>
|
||||||
|
<li><label for="help-status">Status checks</label> — Machine, TLS, PTR,
|
||||||
|
queue</li>
|
||||||
|
<li><a href="/mail-queue">Mail queue retries</a> — time-based, no attempt
|
||||||
|
budget (on the queue page)</li>
|
||||||
|
{{if .InboundEnabled}}
|
||||||
|
<li><a href="/inbound">Inbound</a> — not mailboxes; listed recipients or any
|
||||||
|
address at the domain</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
<p class="muted">Domain page</p>
|
||||||
|
<ul>
|
||||||
|
<li><label for="help-dns">DNS status</label></li>
|
||||||
|
<li><label for="help-records">DKIM and SPF records</label></li>
|
||||||
|
<li><label for="help-dmarc">DMARC record</label></li>
|
||||||
|
<li><label for="help-connection">Connection settings</label></li>
|
||||||
|
<li><label for="help-apps">Applications</label></li>
|
||||||
|
<li><label for="help-domain-settings">Domain settings</label></li>
|
||||||
|
<li><label for="help-export">Export domain</label></li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Topics marked as labels open the same text in the help
|
||||||
|
drawer. For installation, backup, and environment variables see the
|
||||||
|
<a href="https://github.com/mixeme/selfpost/blob/main/docs/guide.md">operator
|
||||||
|
guide</a> in the repository.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .IsGlobal}}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Machine</h2>
|
||||||
|
<p>CPU and memory are the container's own readings, not the host's spare
|
||||||
|
capacity. Network is a short window, not a daily total. High CPU with an empty
|
||||||
|
queue usually means something else on the box — not SelfPost being slow to
|
||||||
|
send.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>TLS certificate</h2>
|
||||||
|
<p>Port 465 presents the certificate the reverse proxy (or the image)
|
||||||
|
mounted. The panel does not issue certificates. A warn here is expires soon;
|
||||||
|
an error is missing or unreadable, and clients will refuse submission.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Hostname / reverse DNS</h2>
|
||||||
|
<p>Forward-confirmed reverse DNS: the A/AAAA for
|
||||||
|
<code>SELFPOST_HOSTNAME</code> must reverse to that same name. PTR is set at
|
||||||
|
the hosting provider, not in this panel.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
{{/* Hidden radios drive which help pane is visible — CSS :has(), no script. */}}
|
||||||
|
{{define "help_inputs"}}
|
||||||
|
<input class="ctrl" type="radio" name="help" id="help-off" checked>
|
||||||
|
<input class="ctrl" type="radio" name="help" id="help-index">
|
||||||
|
<input class="ctrl" type="radio" name="help" id="help-status">
|
||||||
|
<input class="ctrl" type="radio" name="help" id="help-password">
|
||||||
|
<input class="ctrl" type="radio" name="help" id="help-dns">
|
||||||
|
<input class="ctrl" type="radio" name="help" id="help-records">
|
||||||
|
<input class="ctrl" type="radio" name="help" id="help-dmarc">
|
||||||
|
<input class="ctrl" type="radio" name="help" id="help-connection">
|
||||||
|
<input class="ctrl" type="radio" name="help" id="help-apps">
|
||||||
|
<input class="ctrl" type="radio" name="help" id="help-domain-settings">
|
||||||
|
<input class="ctrl" type="radio" name="help" id="help-export">
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "help_more"}}<p class="more muted"><label for="help-index">All topics</label>{{if .}} · <a href="/help">Help page</a>{{end}}</p>{{end}}
|
||||||
|
|
||||||
|
{{/* card_help — «?» on a card head; .ID is the topic slug (dns, records, …). */}}
|
||||||
|
{{define "card_help"}}
|
||||||
|
<label class="help-link" for="help-{{.ID}}" title="{{.Title}}">?</label>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "help_drawer"}}
|
||||||
|
<label class="help-scrim" for="help-off"></label>
|
||||||
|
<aside class="help-drawer">
|
||||||
|
<label class="help-close" for="help-off">Close</label>
|
||||||
|
|
||||||
|
<article class="help-pane help-pane-index">
|
||||||
|
<h2>Help</h2>
|
||||||
|
<p>Short notes for the card you opened — not a second copy of the
|
||||||
|
<a href="https://github.com/mixeme/selfpost/blob/main/docs/guide.md">operator guide</a>.</p>
|
||||||
|
{{if .IsGlobal}}
|
||||||
|
<p class="muted">Status</p>
|
||||||
|
<ul class="toc"><li><label for="help-status">Status checks</label></li></ul>
|
||||||
|
{{end}}
|
||||||
|
<p class="muted">Domain</p>
|
||||||
|
<ul class="toc">
|
||||||
|
<li><label for="help-password">New application password</label></li>
|
||||||
|
<li><label for="help-dns">DNS status</label></li>
|
||||||
|
<li><label for="help-records">DKIM and SPF records</label></li>
|
||||||
|
<li><label for="help-dmarc">DMARC record</label></li>
|
||||||
|
<li><label for="help-connection">Connection settings</label></li>
|
||||||
|
<li><label for="help-apps">Applications</label></li>
|
||||||
|
<li><label for="help-domain-settings">Domain settings</label></li>
|
||||||
|
<li><label for="help-export">Export domain</label></li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="help-pane help-pane-status">
|
||||||
|
<h2>Status checks</h2>
|
||||||
|
<p>The cards keep the readings. This drawer is what used to sit under them
|
||||||
|
as paragraphs.</p>
|
||||||
|
<h2>Machine</h2>
|
||||||
|
<p>CPU and memory are the container's readings, not the host's spare
|
||||||
|
capacity. Network is a short rate window between refreshes, not a daily
|
||||||
|
total. High CPU with an empty queue usually means something else on the box
|
||||||
|
— not SelfPost being slow to send.</p>
|
||||||
|
<h2>TLS certificate</h2>
|
||||||
|
<p>Port 465 presents the certificate the reverse proxy (or the image)
|
||||||
|
mounted. The panel does not issue certificates. A warn here is expires
|
||||||
|
soon; an error is missing or unreadable, and clients will refuse
|
||||||
|
submission.</p>
|
||||||
|
<h2>Hostname / reverse DNS</h2>
|
||||||
|
<p>Forward-confirmed reverse DNS: the A/AAAA for
|
||||||
|
<code>SELFPOST_HOSTNAME</code> must reverse to that same name. PTR is set
|
||||||
|
at the hosting provider, not in this panel. Receiving networks use this
|
||||||
|
pair as a cheap reputation check.</p>
|
||||||
|
<h2>Mail queue</h2>
|
||||||
|
<p>Deferred mail is retried on a time schedule (first delay, doubling cap,
|
||||||
|
queue lifetime). There is no attempt budget — see the retry-policy card on
|
||||||
|
<em>Mail queue</em>.</p>
|
||||||
|
{{if .InboundEnabled}}
|
||||||
|
<h2>Inbound</h2>
|
||||||
|
<p>When <code>INBOUND_RELAY_ENABLE</code> is on, port 25 accepts mail for
|
||||||
|
configured domains and forwards it upstream — not to local mailboxes.
|
||||||
|
Recipients are a list or any address at the domain.</p>
|
||||||
|
{{end}}
|
||||||
|
{{template "help_more" true}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="help-pane help-pane-password">
|
||||||
|
<h2>New application password</h2>
|
||||||
|
<p>Shown <strong>once only</strong> and not stored. Copy it now — if it is
|
||||||
|
lost, regenerate a new one. The previous password stops working
|
||||||
|
immediately.</p>
|
||||||
|
{{template "help_more" true}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="help-pane help-pane-dns">
|
||||||
|
<h2>DNS status</h2>
|
||||||
|
<p>The badge is the worst of DKIM, SPF and DMARC. Results are cached a few
|
||||||
|
minutes — use <em>Re-check</em> after publishing.</p>
|
||||||
|
<p>SPF is a shallow check: the literal address only, no
|
||||||
|
<code>include:</code> or <code>redirect=</code>. Report authorization is
|
||||||
|
required only when <code>rua=</code> points at a domain this server does
|
||||||
|
not accept.</p>
|
||||||
|
{{template "help_more" true}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="help-pane help-pane-records">
|
||||||
|
<h2>DKIM and SPF records</h2>
|
||||||
|
<p>DKIM is not a secret. The selector on this page is the one this server
|
||||||
|
signs with. Merge the SPF example into an existing record if the domain
|
||||||
|
already has one — do not publish a second TXT.</p>
|
||||||
|
{{template "help_more" true}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="help-pane help-pane-dmarc">
|
||||||
|
<h2>DMARC record</h2>
|
||||||
|
<p><code>p=none</code> does not affect delivery. Tighten to
|
||||||
|
<code>p=quarantine</code> then <code>p=reject</code> once reports look
|
||||||
|
clean. The report address is set under Domain settings (or the Settings
|
||||||
|
default).</p>
|
||||||
|
{{template "help_more" true}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="help-pane help-pane-connection">
|
||||||
|
<h2>Connection settings</h2>
|
||||||
|
<p>Same host for every domain. Authenticate with an application login from
|
||||||
|
this page. Auth is required on every port. The password is shown once at
|
||||||
|
create or regenerate.</p>
|
||||||
|
<p>465 is implicit TLS; 587 is STARTTLS submission when that port is
|
||||||
|
enabled.</p>
|
||||||
|
{{template "help_more" true}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="help-pane help-pane-apps">
|
||||||
|
<h2>Applications</h2>
|
||||||
|
<p>SASL logins for this domain. Login is unique across domains; letters,
|
||||||
|
digits, <code>.</code>, <code>-</code> and <code>_</code>. The password is
|
||||||
|
shown once.</p>
|
||||||
|
<p>Address mode is which From addresses this application may use: any
|
||||||
|
address of the domain, or a fixed list. A trusted-IP override gives those
|
||||||
|
clients a higher ceiling than the domain (still ≤ level 1) and skips the
|
||||||
|
domain check; everyone else uses the domain limit if set, otherwise level
|
||||||
|
1.</p>
|
||||||
|
{{template "help_more" true}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="help-pane help-pane-domain-settings">
|
||||||
|
<h2>Domain settings</h2>
|
||||||
|
<p>Aggregate reports (<code>rua=</code>) inherit the Settings default, or
|
||||||
|
you override them per domain. Level 2 is an optional ceiling for all
|
||||||
|
senders on this domain; it must be ≤ level 1. Application overrides live on
|
||||||
|
each application.</p>
|
||||||
|
{{template "help_more" true}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="help-pane help-pane-export">
|
||||||
|
<h2>Export domain</h2>
|
||||||
|
<p>The file is a secret: it carries the DKIM key and application
|
||||||
|
passwords, so published DNS does not have to change on the other instance.
|
||||||
|
Transfer it securely, or encrypt it as <code>.spde</code>.</p>
|
||||||
|
{{template "help_more" true}}
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
{{end}}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<script src="/static/panel.js" defer></script>
|
<script src="/static/panel.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
{{if .User}}{{template "help_inputs" .}}{{end}}
|
||||||
{{/* Two columns: the navigation on the left and the page beside it, centred
|
{{/* 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
|
together (see .shell in panel.css). The signed-out pages have no navigation
|
||||||
and the shell holds their card alone. */}}
|
and the shell holds their card alone. */}}
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .User}}{{template "help_drawer" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</html>{{end}}
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@
|
|||||||
{{if .DMARCEnabled}}
|
{{if .DMARCEnabled}}
|
||||||
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/dmarc">{{template "icon-dmarc"}}DMARC</a>{{end}}
|
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/dmarc">{{template "icon-dmarc"}}DMARC</a>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if eq .Active "help"}}<span aria-current="page">{{template "icon-help"}}Help</span>{{else}}<a href="/help">{{template "icon-help"}}Help</a>{{end}}
|
||||||
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||||
{{if .IsGlobal}}
|
{{if .IsGlobal}}
|
||||||
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||||
@@ -120,6 +123,7 @@
|
|||||||
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
|
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
|
||||||
{{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}}
|
{{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}}
|
||||||
{{define "icon-dmarc"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11v7H2.5z"/><path d="M5 7.5h6M5 9.5h4"/></svg>{{end}}
|
{{define "icon-dmarc"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11v7H2.5z"/><path d="M5 7.5h6M5 9.5h4"/></svg>{{end}}
|
||||||
|
{{define "icon-help"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></svg>{{end}}
|
||||||
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
|
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
|
||||||
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
|
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
|
||||||
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
|
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
{{define "wide"}}wide{{end}}
|
{{define "wide"}}wide{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Status</h1>
|
<div class="page-head">
|
||||||
|
<h1>Status</h1>
|
||||||
|
<label class="help-link" for="help-status" title="What these checks mean">?</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -569,9 +569,6 @@ func forEachTemplate(t *testing.T, fn func(name, body string)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The retry-policy card is static HTML on mail_queue (outside the HTMX
|
|
||||||
// fragment), so rendering the page with a fixture must print those strings
|
|
||||||
// rather than falling back to empty template fields.
|
|
||||||
func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
|
func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
|
||||||
engine, err := New("test")
|
engine, err := New("test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -604,3 +601,81 @@ func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
|
|||||||
t.Error("RetryFromDefaults was unset; the fallback note should stay off")
|
t.Error("RetryFromDefaults was unset; the fallback note should stay off")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthenticatedLayoutIncludesHelpDrawer(t *testing.T) {
|
||||||
|
engine, err := New("test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := engine.Page("help").ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||||
|
"Title": "t", "User": "admin", "Active": "help", "IsGlobal": true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("execute help layout: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`id="help-off"`, `class="help-drawer"`, `help-pane-status`,
|
||||||
|
`for="help-dns"`, `href="/help"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("authenticated layout missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginPageOmitsHelpDrawer(t *testing.T) {
|
||||||
|
engine, err := New("test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := engine.Page("login").ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||||
|
"Title": "t", "Active": "",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("execute login: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(buf.String(), "help-drawer") {
|
||||||
|
t.Error("login page should not include the help drawer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusPageHasHelpEntry(t *testing.T) {
|
||||||
|
out := renderStatusPage(t, statusPageData())
|
||||||
|
if !strings.Contains(out, `for="help-status"`) {
|
||||||
|
t.Error("status page is missing the help entry point")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainDetailHasHelpOnCards(t *testing.T) {
|
||||||
|
body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read domain_detail: %v", err)
|
||||||
|
}
|
||||||
|
src := string(body)
|
||||||
|
for _, want := range []string{
|
||||||
|
`"ID" "dns"`, `"ID" "records"`, `"ID" "dmarc"`, `"ID" "connection"`,
|
||||||
|
`"ID" "apps"`, `"ID" "domain-settings"`, `"ID" "export"`,
|
||||||
|
`card-head`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(src, want) {
|
||||||
|
t.Errorf("domain_detail missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNavIncludesHelp(t *testing.T) {
|
||||||
|
engine, err := New("test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||||
|
"User": "admin", "Active": "domains", "IsGlobal": true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("execute nav: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), `href="/help"`) {
|
||||||
|
t.Error("nav is missing Help link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ var pageFiles = map[string][]string{
|
|||||||
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
||||||
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
||||||
"status": {"templates/status.html", "templates/status_body.html"},
|
"status": {"templates/status.html", "templates/status_body.html"},
|
||||||
|
"help": {"templates/help.html"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
||||||
@@ -73,7 +74,7 @@ func New(version string) (*Engine, error) {
|
|||||||
version: version,
|
version: version,
|
||||||
}
|
}
|
||||||
for name, files := range pageFiles {
|
for name, files := range pageFiles {
|
||||||
patterns := append([]string{"templates/layout.html"}, files...)
|
patterns := append([]string{"templates/layout.html", "templates/help_drawer.html"}, files...)
|
||||||
tmpl, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
tmpl, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
||||||
@@ -104,6 +105,20 @@ func (e *Engine) SetDMARCEnabled(v bool) {
|
|||||||
// templateFuncs supplies helpers shared across page templates.
|
// templateFuncs supplies helpers shared across page templates.
|
||||||
func templateFuncs() template.FuncMap {
|
func templateFuncs() template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
|
"dict": func(values ...any) (map[string]any, error) {
|
||||||
|
if len(values)%2 != 0 {
|
||||||
|
return nil, fmt.Errorf("dict: odd argument count")
|
||||||
|
}
|
||||||
|
m := make(map[string]any, len(values)/2)
|
||||||
|
for i := 0; i < len(values); i += 2 {
|
||||||
|
key, ok := values[i].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("dict: key %d is not a string", i)
|
||||||
|
}
|
||||||
|
m[key] = values[i+1]
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
},
|
||||||
// back builds the map back_link reads; keeps href and label paired at
|
// back builds the map back_link reads; keeps href and label paired at
|
||||||
// the call site instead of repeating the <a class="back"> markup.
|
// the call site instead of repeating the <a class="back"> markup.
|
||||||
"back": func(href, label string) map[string]string {
|
"back": func(href, label string) map[string]string {
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
|
|
||||||
authed := http.NewServeMux()
|
authed := http.NewServeMux()
|
||||||
authed.HandleFunc("GET /{$}", redirectHome)
|
authed.HandleFunc("GET /{$}", redirectHome)
|
||||||
|
authed.HandleFunc("GET /help", h.HandleHelp)
|
||||||
|
|
||||||
authed.HandleFunc("GET /status", h.HandleStatus)
|
authed.HandleFunc("GET /status", h.HandleStatus)
|
||||||
authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment)
|
authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment)
|
||||||
authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck)
|
authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck)
|
||||||
|
|||||||
Reference in New Issue
Block a user