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) {
|
||||
'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) {
|
||||
|
|
|
|||
|
|
@ -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=<RFC3339>.
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1632,7 +1632,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 class="header-right">
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue