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:
parent
0c6396d246
commit
d183de434d
4 changed files with 125 additions and 22 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue