From d183de434df4c18b5d7522f60619d7e93d67eb71 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 6 Jun 2026 18:23:23 -0500 Subject: [PATCH] feat(profile): render the admin diagnostics (config/logs/whoami) as chrome'd tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- tables/js/api-actions.js | 20 +++-- zddc/internal/handler/profilehandler.go | 104 +++++++++++++++++++++--- zddc/internal/handler/profilepage.go | 1 - zddc/internal/handler/tables.html | 22 +++-- 4 files changed, 125 insertions(+), 22 deletions(-) diff --git a/tables/js/api-actions.js b/tables/js/api-actions.js index 56c624d..cf04335 100644 --- a/tables/js/api-actions.js +++ b/tables/js/api-actions.js @@ -14,8 +14,17 @@ (function (app) { 'use strict'; + function ctxObj() { + return (app && app.context) || {}; + } 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) { @@ -213,9 +222,10 @@ } function tick() { - var c = cfg(); - if (!c) return; + if (!active()) return; hideNative(); + var c = cfg(); + if (!c) return; // read-only view: native buttons hidden, nothing more if (c.create) mountCreate(c.create); if (c.deleteRow) ensureRowDelete(c.deleteRow); if (c.rowNav) ensureRowNav(); @@ -227,9 +237,9 @@ // per-row buttons survive sort/filter re-renders. var tries = 0; var iv = setInterval(function () { - if (cfg() || tries++ > 60) { + if (active() || tries++ > 60) { clearInterval(iv); - if (!cfg()) return; + if (!active()) return; tick(); var tbody = document.querySelector('#table-root tbody'); if (tbody && window.MutationObserver) { diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index 9421209..9f5c5c5 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -330,7 +330,7 @@ func serveProfileWhoami(cfg config.Config, email string, w http.ResponseWriter, URL string `json:"url"` Headers map[string][]string `json:"headers"` } - writeJSON(w, response{ + resp := response{ ConfiguredEmailHeader: cfg.EmailHeader, ObservedEmail: r.Header.Get(cfg.EmailHeader), ResolvedEmail: email, @@ -338,7 +338,19 @@ func serveProfileWhoami(cfg config.Config, email string, w http.ResponseWriter, Method: r.Method, URL: r.URL.String(), 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, @@ -355,7 +367,7 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques EmailHeader string `json:"email_header"` CORSOrigins []string `json:"cors_origins"` } - writeJSON(w, response{ + resp := response{ Root: cfg.Root, Addr: cfg.Addr, TLSCert: cfg.TLSCert, @@ -365,19 +377,70 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques IndexPath: cfg.IndexPath, EmailHeader: cfg.EmailHeader, 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 // params: level=debug|info|warn|error and since=. -func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) { - if ring == nil { - writeJSON(w, []LogEntry{}) +// serveDiagTable renders an admin-diagnostic collection through the shared +// tables engine (header chrome + sortable/filterable columns) for browsers, +// 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 } + 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 != "" { min := levelRank(levelStr) out := entries[:0] @@ -388,7 +451,6 @@ func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) { } entries = out } - if sinceStr := r.URL.Query().Get("since"); sinceStr != "" { if since, err := time.Parse(time.RFC3339, sinceStr); err == nil { 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 { diff --git a/zddc/internal/handler/profilepage.go b/zddc/internal/handler/profilepage.go index 4fc5fb3..33cfcf4 100644 --- a/zddc/internal/handler/profilepage.go +++ b/zddc/internal/handler/profilepage.go @@ -102,7 +102,6 @@ func buildProfileTableContext(cfg config.Config, r *http.Request) map[string]int {"Server config", ProfilePathPrefix + "/config"}, {"Server logs", ProfilePathPrefix + "/logs"}, {"Whoami (request headers)", ProfilePathPrefix + "/whoami"}, - {"Effective policy", ProfilePathPrefix + "/effective-policy"}, } { rows = append(rows, map[string]interface{}{ "url": d.url, diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 421fd6d..0158f7b 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1632,7 +1632,7 @@ body.is-elevated::after {
ZDDC Table - v0.0.27-dev · 2026-06-06 21:07:11 · 2a05b77-dirty + v0.0.27-dev · 2026-06-06 23:22:12 · 0c6396d-dirty
@@ -7057,8 +7057,17 @@ body.is-elevated::after { (function (app) { 'use strict'; + function ctxObj() { + return (app && app.context) || {}; + } 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) { @@ -7256,9 +7265,10 @@ body.is-elevated::after { } function tick() { - var c = cfg(); - if (!c) return; + if (!active()) return; hideNative(); + var c = cfg(); + if (!c) return; // read-only view: native buttons hidden, nothing more if (c.create) mountCreate(c.create); if (c.deleteRow) ensureRowDelete(c.deleteRow); if (c.rowNav) ensureRowNav(); @@ -7270,9 +7280,9 @@ body.is-elevated::after { // per-row buttons survive sort/filter re-renders. var tries = 0; var iv = setInterval(function () { - if (cfg() || tries++ > 60) { + if (active() || tries++ > 60) { clearInterval(iv); - if (!cfg()) return; + if (!active()) return; tick(); var tbody = document.querySelector('#table-root tbody'); if (tbody && window.MutationObserver) {