feat(profile): render the admin diagnostics (config/logs/whoami) as chrome'd tables

The profile page links to /.profile/{config,logs,whoami}, which returned raw
JSON — so a browser click landed on raw JSON. Render them through the tables
engine instead (header chrome + sortable/filterable columns), content-
negotiated: browsers (Accept: text/html) get the table; scripts (Accept:
application/json) still get the unchanged JSON. New serveDiagTable helper +
kvRow/kvColumns: logs → time/level/message/detail rows (newest first);
config + whoami → Field/Value rows. Dropped the deep effective-policy row
from the profile table (kept JSON-only, not linked).

Extends api-actions.js with a `readOnly` context flag so a server-injected
read-only table (no apiActions) still hides the file-model toolbar buttons
(+ Add row / Save). Export CSV stays.

Completes the bespoke-server-page → tables-engine consolidation: tokens,
profile, and the three admin diagnostics now all render declaratively with
shared chrome; per-role gating stays server-side (diagnostics are elevated-
super-admin only). Full Go suite green; verified in a containerized browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-06 18:23:23 -05:00
parent 0c6396d246
commit d183de434d
4 changed files with 125 additions and 22 deletions

View file

@ -14,8 +14,17 @@
(function (app) { (function (app) {
'use strict'; 'use strict';
function ctxObj() {
return (app && app.context) || {};
}
function cfg() { function cfg() {
return (app && app.context && app.context.apiActions) || null; return ctxObj().apiActions || null;
}
// Active when the table is an API collection (apiActions) OR a read-only
// server-injected view (readOnly) — either way the file-model toolbar
// buttons (+ Add row / Save) don't apply and are hidden.
function active() {
return !!(cfg() || ctxObj().readOnly);
} }
function el(tag, attrs, text) { function el(tag, attrs, text) {
@ -213,9 +222,10 @@
} }
function tick() { function tick() {
var c = cfg(); if (!active()) return;
if (!c) return;
hideNative(); hideNative();
var c = cfg();
if (!c) return; // read-only view: native buttons hidden, nothing more
if (c.create) mountCreate(c.create); if (c.create) mountCreate(c.create);
if (c.deleteRow) ensureRowDelete(c.deleteRow); if (c.deleteRow) ensureRowDelete(c.deleteRow);
if (c.rowNav) ensureRowNav(); if (c.rowNav) ensureRowNav();
@ -227,9 +237,9 @@
// per-row buttons survive sort/filter re-renders. // per-row buttons survive sort/filter re-renders.
var tries = 0; var tries = 0;
var iv = setInterval(function () { var iv = setInterval(function () {
if (cfg() || tries++ > 60) { if (active() || tries++ > 60) {
clearInterval(iv); clearInterval(iv);
if (!cfg()) return; if (!active()) return;
tick(); tick();
var tbody = document.querySelector('#table-root tbody'); var tbody = document.querySelector('#table-root tbody');
if (tbody && window.MutationObserver) { if (tbody && window.MutationObserver) {

View file

@ -330,7 +330,7 @@ func serveProfileWhoami(cfg config.Config, email string, w http.ResponseWriter,
URL string `json:"url"` URL string `json:"url"`
Headers map[string][]string `json:"headers"` Headers map[string][]string `json:"headers"`
} }
writeJSON(w, response{ resp := response{
ConfiguredEmailHeader: cfg.EmailHeader, ConfiguredEmailHeader: cfg.EmailHeader,
ObservedEmail: r.Header.Get(cfg.EmailHeader), ObservedEmail: r.Header.Get(cfg.EmailHeader),
ResolvedEmail: email, ResolvedEmail: email,
@ -338,7 +338,19 @@ func serveProfileWhoami(cfg config.Config, email string, w http.ResponseWriter,
Method: r.Method, Method: r.Method,
URL: r.URL.String(), URL: r.URL.String(),
Headers: headers, Headers: headers,
}) }
rows := []map[string]interface{}{
kvRow("Configured email header", resp.ConfiguredEmailHeader),
kvRow("Observed email (at that header)", resp.ObservedEmail),
kvRow("Resolved email", resp.ResolvedEmail),
kvRow("Remote addr", resp.RemoteAddr),
kvRow("Method", resp.Method),
kvRow("URL", resp.URL),
}
for _, k := range keys {
rows = append(rows, kvRow("header: "+k, strings.Join(headers[k], ", ")))
}
serveDiagTable(w, r, "Whoami", "How the server sees this request (identity + headers).", kvColumns, rows, resp)
} }
// serveProfileConfig dumps the parsed Config. TLS cert/key paths are echoed, // serveProfileConfig dumps the parsed Config. TLS cert/key paths are echoed,
@ -355,7 +367,7 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
EmailHeader string `json:"email_header"` EmailHeader string `json:"email_header"`
CORSOrigins []string `json:"cors_origins"` CORSOrigins []string `json:"cors_origins"`
} }
writeJSON(w, response{ resp := response{
Root: cfg.Root, Root: cfg.Root,
Addr: cfg.Addr, Addr: cfg.Addr,
TLSCert: cfg.TLSCert, TLSCert: cfg.TLSCert,
@ -365,19 +377,70 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
IndexPath: cfg.IndexPath, IndexPath: cfg.IndexPath,
EmailHeader: cfg.EmailHeader, EmailHeader: cfg.EmailHeader,
CORSOrigins: cfg.CORSOrigins, CORSOrigins: cfg.CORSOrigins,
}) }
rows := []map[string]interface{}{
kvRow("Root", resp.Root),
kvRow("Addr", resp.Addr),
kvRow("TLS cert", resp.TLSCert),
kvRow("TLS key", resp.TLSKey),
kvRow("TLS mode", resp.TLSMode),
kvRow("Log level", resp.LogLevel),
kvRow("Index path", resp.IndexPath),
kvRow("Email header", resp.EmailHeader),
kvRow("CORS origins", strings.Join(resp.CORSOrigins, ", ")),
}
serveDiagTable(w, r, "Server config", "Effective server configuration.", kvColumns, rows, resp)
} }
// serveProfileLogs returns the ring buffer's current contents. Optional query // serveProfileLogs returns the ring buffer's current contents. Optional query
// params: level=debug|info|warn|error and since=<RFC3339>. // params: level=debug|info|warn|error and since=<RFC3339>.
func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) { // serveDiagTable renders an admin-diagnostic collection through the shared
if ring == nil { // tables engine (header chrome + sortable/filterable columns) for browsers,
writeJSON(w, []LogEntry{}) // while keeping the raw JSON for scripted callers — content-negotiated on
// Accept. Read-only; no apiActions. rawJSON is the existing JSON body, so the
// machine contract is unchanged. The profile page links to these endpoints,
// so a browser click lands on a real page, not raw JSON.
func serveDiagTable(w http.ResponseWriter, r *http.Request, title, desc string, columns, rows []map[string]interface{}, rawJSON interface{}) {
if !strings.Contains(r.Header.Get("Accept"), "text/html") || len(EmbeddedTablesHTML()) == 0 {
writeJSON(w, rawJSON)
return return
} }
injected, err := injectTableContextObj(EmbeddedTablesHTML(), map[string]interface{}{
"title": title, "description": desc, "addable": false, "readOnly": true,
"columns": columns, "rows": rows,
})
if err != nil {
writeJSON(w, rawJSON)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(injected)
}
entries := ring.Snapshot() func diagCol(field, title, width string) map[string]interface{} {
c := map[string]interface{}{"field": field, "title": title}
if width != "" {
c["width"] = width
}
return c
}
// kvRow / kvColumns render a record as a two-column Field/Value table.
func kvRow(field string, value interface{}) map[string]interface{} {
return map[string]interface{}{"editable": false, "data": map[string]interface{}{"field": field, "value": fmt.Sprintf("%v", value)}}
}
var kvColumns = []map[string]interface{}{
diagCol("field", "Field", "18em"),
diagCol("value", "Value", ""),
}
func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
entries := []LogEntry{}
if ring != nil {
entries = ring.Snapshot()
}
if levelStr := r.URL.Query().Get("level"); levelStr != "" { if levelStr := r.URL.Query().Get("level"); levelStr != "" {
min := levelRank(levelStr) min := levelRank(levelStr)
out := entries[:0] out := entries[:0]
@ -388,7 +451,6 @@ func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
} }
entries = out entries = out
} }
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" { if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
if since, err := time.Parse(time.RFC3339, sinceStr); err == nil { if since, err := time.Parse(time.RFC3339, sinceStr); err == nil {
out := entries[:0] out := entries[:0]
@ -401,7 +463,29 @@ func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
} }
} }
writeJSON(w, entries) rows := make([]map[string]interface{}, 0, len(entries))
for i := len(entries) - 1; i >= 0; i-- { // newest first
e := entries[i]
detail := ""
if len(e.Attrs) > 0 {
if b, err := json.Marshal(e.Attrs); err == nil {
detail = string(b)
}
}
rows = append(rows, map[string]interface{}{"editable": false, "data": map[string]interface{}{
"time": e.Time.Format("2006-01-02 15:04:05"),
"level": e.Level,
"message": e.Message,
"detail": detail,
}})
}
serveDiagTable(w, r, "Server logs", "Recent server log entries (newest first).",
[]map[string]interface{}{
diagCol("time", "Time", "13em"),
diagCol("level", "Level", "6em"),
diagCol("message", "Message", ""),
diagCol("detail", "Detail", ""),
}, rows, entries)
} }
func levelRank(s string) int { func levelRank(s string) int {

View file

@ -102,7 +102,6 @@ func buildProfileTableContext(cfg config.Config, r *http.Request) map[string]int
{"Server config", ProfilePathPrefix + "/config"}, {"Server config", ProfilePathPrefix + "/config"},
{"Server logs", ProfilePathPrefix + "/logs"}, {"Server logs", ProfilePathPrefix + "/logs"},
{"Whoami (request headers)", ProfilePathPrefix + "/whoami"}, {"Whoami (request headers)", ProfilePathPrefix + "/whoami"},
{"Effective policy", ProfilePathPrefix + "/effective-policy"},
} { } {
rows = append(rows, map[string]interface{}{ rows = append(rows, map[string]interface{}{
"url": d.url, "url": d.url,

View file

@ -1632,7 +1632,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-06 21:07:11 · 2a05b77-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-06 23:22:12 · 0c6396d-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -7057,8 +7057,17 @@ body.is-elevated::after {
(function (app) { (function (app) {
'use strict'; 'use strict';
function ctxObj() {
return (app && app.context) || {};
}
function cfg() { function cfg() {
return (app && app.context && app.context.apiActions) || null; return ctxObj().apiActions || null;
}
// Active when the table is an API collection (apiActions) OR a read-only
// server-injected view (readOnly) — either way the file-model toolbar
// buttons (+ Add row / Save) don't apply and are hidden.
function active() {
return !!(cfg() || ctxObj().readOnly);
} }
function el(tag, attrs, text) { function el(tag, attrs, text) {
@ -7256,9 +7265,10 @@ body.is-elevated::after {
} }
function tick() { function tick() {
var c = cfg(); if (!active()) return;
if (!c) return;
hideNative(); hideNative();
var c = cfg();
if (!c) return; // read-only view: native buttons hidden, nothing more
if (c.create) mountCreate(c.create); if (c.create) mountCreate(c.create);
if (c.deleteRow) ensureRowDelete(c.deleteRow); if (c.deleteRow) ensureRowDelete(c.deleteRow);
if (c.rowNav) ensureRowNav(); if (c.rowNav) ensureRowNav();
@ -7270,9 +7280,9 @@ body.is-elevated::after {
// per-row buttons survive sort/filter re-renders. // per-row buttons survive sort/filter re-renders.
var tries = 0; var tries = 0;
var iv = setInterval(function () { var iv = setInterval(function () {
if (cfg() || tries++ > 60) { if (active() || tries++ > 60) {
clearInterval(iv); clearInterval(iv);
if (!cfg()) return; if (!active()) return;
tick(); tick();
var tbody = document.querySelector('#table-root tbody'); var tbody = document.querySelector('#table-root tbody');
if (tbody && window.MutationObserver) { if (tbody && window.MutationObserver) {