panel-redesign: the queue in the new shell; the old design is removed
Stage 2, steps 7 and 8 in one commit: the static guard fails as soon as
the ratchet holds no page while legacy.css still exists, and the tests
of the old layout need it while that layout exists - the two steps do
not split into states that pass on their own. Written by Sonnet,
reviewed by Opus.
The queue
- out-queue, named after its mockup: today's content - the listing of
postqueue -p in a log pane, polled as before (GET
/outbound/queue/fragment) - and the "How retries work" facts. The
mockup's table of messages is the roadmap's queue-as-a-table; the
outline guard exempts this page for exactly that reason. Routes and
the global-only check are unchanged; the page has no actions and
never had.
- Inbound has no Queue entry and no route serves one.
The old design is gone
- Deleted: layout_legacy.html, legacy.css, logo-compact.svg,
mail_queue.html, mail_queue_body.html.
- view.go has one path: every page is layout.html + components.html +
its own files; kitPages, kitFragments, the map-data branch of Render,
the dict and back template functions and metaOf's map branch are
removed. handlers: pageBase removed.
- legacy_pages.txt holds its header and no entry.
- panel.js: the old copy handler (button.copy in .code-row) is
removed. A show-when rule now applies to every element it matches,
and hiding adds Bulma's is-hidden beside the hidden attribute - a
.box or a grid row has a display of its own and stayed visible.
- The user form hides both domain lists again when the role is Global
(Box.DomainPick, an attribute; data-global-role on the select).
- Tests of the old layout are deleted; what they asserted about the
shell (version only signed in, the menu follows the user's reach,
Inbound only with the feature, Help present, the current entry
marked) is asserted on the kit pages, two tests added for it.
For the owner: is-hidden is a Bulma class outside the subset of
check.py. No template writes it - the script sets it at run time to
hide a block - so no guard sees it; say if it should be on the list.
Evidence
- docs/assets/panel-redesign/evidence/out-queue.png: 1440 px wide,
headless Edge, the real template served locally with its fixture -
NOT from the container (no Docker on this machine).
- panel/audit.js on out-queue, user and backup at 1440 and 1280:
366 texts checked, problems: [].
- In a browser against the local fixture server: the encryption
fields of Backup, the manual / auto limit fields and the custom
report address of Domain settings show and hide with their control;
all 28 fixture pages answer 200 with panel.css, /static/legacy.css
answers 404.
- Not exercised: polling against a real postqueue (none on Windows).
- go test -count=1 ./internal/web/... ./cmd/panel
ok internal/web
ok internal/web/auth
ok internal/web/handlers
ok internal/web/validate
ok internal/web/view
ok cmd/panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+23
-116
@@ -26,13 +26,12 @@ type Engine struct {
|
||||
dmarcEnabled bool
|
||||
}
|
||||
|
||||
// pageFiles maps a logical page name to its template files. Every page
|
||||
// composes with a layout — layout_legacy.html for the pages still in the old
|
||||
// design, layout.html and components.html for those that have been rebuilt
|
||||
// from the component kit (kitPages); pages that embed a polling fragment
|
||||
// (architecture.md § Panel HTTP surface) list that fragment's file too, so the
|
||||
// same {{define}} block renders both the initial page and the fragment's own
|
||||
// refresh responses identically.
|
||||
// pageFiles maps a logical page name to its template files. Every page is built
|
||||
// from the component kit: layout.html and components.html, then the page's own
|
||||
// file. Pages that embed a polling fragment (architecture.md § Panel HTTP
|
||||
// surface) list that fragment's file too, so the same {{define}} block renders
|
||||
// both the initial page and the fragment's own refresh responses identically.
|
||||
// The guard tests read this map.
|
||||
var pageFiles = map[string][]string{
|
||||
"setup": {"templates/setup.html"},
|
||||
"login": {"templates/login.html"},
|
||||
@@ -60,63 +59,20 @@ var pageFiles = map[string][]string{
|
||||
"dmarc": {"templates/dmarc.html"},
|
||||
"dmarc-domain": {"templates/dmarc_domain.html"},
|
||||
"dmarc-report": {"templates/dmarc_report.html"},
|
||||
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
||||
"out-queue": {"templates/out_queue.html", "templates/out_queue_body.html"},
|
||||
"components": {"templates/kit.html"},
|
||||
}
|
||||
|
||||
// kitPages are the pages built from the component kit: they are rendered by
|
||||
// layout.html with the partials of components.html and load panel.css. Every
|
||||
// other page is still the old design — layout_legacy.html and legacy.css — and
|
||||
// is listed in legacy_pages.txt; a page is in exactly one of the two, and
|
||||
// restyling one means moving it from the list to here in the same commit.
|
||||
// pageFiles stays a plain map of file lists because the guard tests read it.
|
||||
var kitPages = map[string]bool{
|
||||
"components": true,
|
||||
"login": true,
|
||||
"setup": true,
|
||||
"overview": true,
|
||||
"health": true,
|
||||
"account": true,
|
||||
"settings": true,
|
||||
|
||||
"out-domains": true,
|
||||
"out-domain": true,
|
||||
"out-domain-settings": true,
|
||||
"out-app": true,
|
||||
"out-app-created": true,
|
||||
"out-domain-delete": true,
|
||||
|
||||
"in-domains": true,
|
||||
"in-domain": true,
|
||||
"in-domain-delete": true,
|
||||
|
||||
"out-log": true,
|
||||
"out-message": true,
|
||||
"dmarc": true,
|
||||
"dmarc-domain": true,
|
||||
"dmarc-report": true,
|
||||
|
||||
"system-log": true,
|
||||
"backup": true,
|
||||
"users": true,
|
||||
"user": true,
|
||||
"user-delete": true,
|
||||
"help": true,
|
||||
}
|
||||
|
||||
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
||||
// template file, for standalone rendering by the HTMX polling endpoints.
|
||||
var fragmentFiles = map[string]string{
|
||||
"out_log_rows": "templates/out_log_rows.html",
|
||||
"mail_queue_body": "templates/mail_queue_body.html",
|
||||
"out_queue_body": "templates/out_queue_body.html",
|
||||
"system_log_body": "templates/system_log_body.html",
|
||||
"health_body": "templates/health_body.html",
|
||||
"overview_poll": "templates/overview_cards.html",
|
||||
}
|
||||
|
||||
// kitFragments are the fragments rendered with the component kit's partials.
|
||||
var kitFragments = map[string]bool{"health_body": true, "overview_poll": true, "out_log_rows": true, "system_log_body": true}
|
||||
|
||||
// New parses embedded templates. version is stamped into every page footer.
|
||||
func New(version string) (*Engine, error) {
|
||||
e := &Engine{
|
||||
@@ -125,13 +81,10 @@ func New(version string) (*Engine, error) {
|
||||
version: version,
|
||||
}
|
||||
for name, files := range pageFiles {
|
||||
// A page parses with the layout of its design. The template is named
|
||||
// after the layout file so that its content is the layout's.
|
||||
layout, shared := "layout_legacy.html", []string(nil)
|
||||
if kitPages[name] {
|
||||
layout, shared = "layout.html", []string{"templates/components.html"}
|
||||
}
|
||||
patterns := append([]string{"templates/" + layout}, append(shared, files...)...)
|
||||
// A page parses with the layout and the kit's partials. The template
|
||||
// is named after the layout file so that its content is the layout's.
|
||||
const layout = "layout.html"
|
||||
patterns := append([]string{"templates/" + layout, "templates/components.html"}, files...)
|
||||
tmpl, err := template.New(layout).Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
||||
@@ -139,12 +92,8 @@ func New(version string) (*Engine, error) {
|
||||
e.pages[name] = tmpl
|
||||
}
|
||||
for name, file := range fragmentFiles {
|
||||
// A fragment of a kit page calls the kit's partials, so it parses with
|
||||
// them; the old fragments define everything they use themselves.
|
||||
patterns := []string{file}
|
||||
if kitFragments[name] {
|
||||
patterns = []string{"templates/components.html", file}
|
||||
}
|
||||
// A fragment calls the kit's partials, so it parses with them.
|
||||
patterns := []string{"templates/components.html", file}
|
||||
tmpl, err := template.New(path.Base(file)).Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse fragment %s: %w", name, err)
|
||||
@@ -168,25 +117,6 @@ func (e *Engine) SetDMARCEnabled(v bool) {
|
||||
// templateFuncs supplies helpers shared across page templates.
|
||||
func templateFuncs() 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
|
||||
// the call site instead of repeating the <a class="back"> markup.
|
||||
"back": func(href, label string) map[string]string {
|
||||
return map[string]string{"Href": href, "Label": label}
|
||||
},
|
||||
// status_tag and wbr_at are the adapters of the component kit
|
||||
// (components.go): the one mapping of a status to Bulma's tag
|
||||
// classes, and the break opportunity after the @ of an address.
|
||||
@@ -214,39 +144,16 @@ func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any
|
||||
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var (
|
||||
buf bytes.Buffer
|
||||
layout string
|
||||
frame = data
|
||||
)
|
||||
if kitPages[page] {
|
||||
// A page of the component kit: the shell is typed data derived from the
|
||||
// page's Meta (components.go), and the page's own data travels beside it.
|
||||
// With no user there is no navigation — the signed-out screen.
|
||||
layout = "layout.html"
|
||||
sh := e.shell(metaOf(data), legal.CopyrightLine, legal.SourceURL)
|
||||
if sh.User == "" {
|
||||
layout = "layout_signed_out"
|
||||
}
|
||||
frame = Frame{Shell: sh, Page: data}
|
||||
} else {
|
||||
layout = "layout_legacy.html"
|
||||
// The layout's navigation compares .Active against each item, so the key
|
||||
// must exist on every authenticated page. Defaulting it here keeps a page
|
||||
// that forgets it from failing to render — it simply highlights nothing.
|
||||
// Footer fields (.Version, .Copyright, .SourceURL) are the same on every
|
||||
// page, so no handler should have to pass them.
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
if _, has := m["Active"]; !has {
|
||||
m["Active"] = ""
|
||||
}
|
||||
m["Version"] = e.version
|
||||
m["Copyright"] = legal.CopyrightLine
|
||||
m["SourceURL"] = legal.SourceURL
|
||||
m["InboundEnabled"] = e.inboundEnabled
|
||||
m["DMARCEnabled"] = e.dmarcEnabled
|
||||
}
|
||||
// The shell is typed data derived from the page's Meta (components.go), and
|
||||
// the page's own data travels beside it. With no user there is no
|
||||
// navigation — the signed-out screen.
|
||||
var buf bytes.Buffer
|
||||
layout := "layout.html"
|
||||
sh := e.shell(metaOf(data), legal.CopyrightLine, legal.SourceURL)
|
||||
if sh.User == "" {
|
||||
layout = "layout_signed_out"
|
||||
}
|
||||
frame := Frame{Shell: sh, Page: data}
|
||||
if err := tmpl.ExecuteTemplate(&buf, layout, frame); err != nil {
|
||||
log.Printf("panel: render %s: %v", page, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
|
||||
Reference in New Issue
Block a user