Replaces the super-admin-only /.admin/ surface with a public-by-default /.profile/ page that layers admin tools server-side based on the caller's effective access: - Universal (everyone, anonymous included): identity card, effective access summary, theme picker, localStorage utilities (export / import / clear, landing-presets viewer). - Subtree admins additionally see: editable .zddc files list (linking to the existing form-based editor) and a "Create new project folder" form. - Super-admins additionally see: server config, log viewer, whoami headers (the old /.admin/ JSON endpoints, repointed under /.profile/). Project creation is gated on CanEditZddc(newDir) — the same strict- ancestor rule that already governs .zddc writes — so no new authority concept is introduced. ValidateProjectName mirrors the existing reserved-prefix policy (no leading '.' or '_', no path separators). /.admin/* is hard-cut: no redirect shim. Old URLs fall through to the existing dot-prefix guard and 404. Custom CSS file rename: prefer <root>/.profile.css, fall back to legacy <root>/.admin.css. Per-resource 404 leakage gates preserved on whoami / config / logs / zddc / projects so non-admin callers cannot detect the existence of admin-only sub-resources. Tree-wide gofmt -w applied as a side-effect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
464 lines
21 KiB
Go
464 lines
21 KiB
Go
package handler
|
||
|
||
import (
|
||
"html/template"
|
||
"net/http"
|
||
|
||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||
)
|
||
|
||
// profileView is the data passed to the profile template.
|
||
type profileView struct {
|
||
AccessView
|
||
ProfilePathPrefix string
|
||
AssetsPathPrefix string
|
||
HasCustomCSS bool
|
||
HasEditableSubtrees bool
|
||
EditableParentChoices []treeEntry // AdminSubtrees filtered to CanEdit; used as create-project parents
|
||
}
|
||
|
||
// serveProfilePage renders the universal profile page at GET /.profile/.
|
||
// Reachable to anyone (anonymous included); admin / super-admin sections
|
||
// are conditionally rendered server-side based on the caller's effective
|
||
// access — non-admin HTML contains zero admin markup.
|
||
func serveProfilePage(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
w.Header().Set("Allow", "GET")
|
||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
view := profileView{
|
||
AccessView: enumerateAccess(cfg, EmailFromContext(r)),
|
||
ProfilePathPrefix: ProfilePathPrefix,
|
||
AssetsPathPrefix: zddcAssetsPathPrefix,
|
||
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
|
||
}
|
||
for _, t := range view.AdminSubtrees {
|
||
if t.CanEdit {
|
||
view.EditableParentChoices = append(view.EditableParentChoices, t)
|
||
}
|
||
}
|
||
view.HasEditableSubtrees = len(view.EditableParentChoices) > 0
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
if err := profileTemplate.Execute(w, view); err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
}
|
||
}
|
||
|
||
// profileTemplate is the html/template for the profile page. Single page,
|
||
// three layered blocks (universal / admin / super-admin), inline styles
|
||
// using the same custom-property naming as the editor so a future merge
|
||
// with shared/base.css stays trivial. One inline IIFE handles theme,
|
||
// localStorage, and the create-project AJAX submit.
|
||
var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>zddc-server — profile</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<style>
|
||
:root {
|
||
--bg: #fff; --bg-alt: #f7f7f8; --text: #222; --muted: #666;
|
||
--border: #d0d4dc; --primary: #2563eb; --primary-bg: #eff6ff;
|
||
--danger: #b00020; --warn: #b15c00; --ok: #0a7d2c;
|
||
--radius: 4px;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #1a1c1f; --bg-alt: #23262b; --text: #e8e8ea; --muted: #a0a4ad;
|
||
--border: #353941; --primary: #60a5fa; --primary-bg: #1e293b;
|
||
--danger: #ff7080; --warn: #f5b056; --ok: #4ad27c;
|
||
}
|
||
}
|
||
[data-theme="light"] {
|
||
--bg: #fff; --bg-alt: #f7f7f8; --text: #222; --muted: #666;
|
||
--border: #d0d4dc; --primary: #2563eb; --primary-bg: #eff6ff;
|
||
--danger: #b00020; --warn: #b15c00; --ok: #0a7d2c;
|
||
}
|
||
[data-theme="dark"] {
|
||
--bg: #1a1c1f; --bg-alt: #23262b; --text: #e8e8ea; --muted: #a0a4ad;
|
||
--border: #353941; --primary: #60a5fa; --primary-bg: #1e293b;
|
||
--danger: #ff7080; --warn: #f5b056; --ok: #4ad27c;
|
||
}
|
||
body { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 1.5rem; color: var(--text); background: var(--bg); max-width: 980px; }
|
||
h1 { margin: 0 0 .25rem; font-size: 1.4rem; }
|
||
h2 { margin: 1.5rem 0 .25rem; font-size: 1.05rem; }
|
||
.muted { color: var(--muted); }
|
||
.breadcrumb { color: var(--muted); margin-bottom: 1rem; }
|
||
.breadcrumb a { color: var(--primary); text-decoration: none; }
|
||
.breadcrumb a:hover { text-decoration: underline; }
|
||
.card { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.1rem; margin-bottom: 1rem; }
|
||
.card h2 { margin-top: 0; }
|
||
.card .help { color: var(--muted); font-size: .9em; margin: .3rem 0 .6rem; }
|
||
label { display: block; margin-bottom: .5rem; }
|
||
input[type="text"], select { width: 100%; max-width: 32rem; padding: .35rem .5rem; font: inherit; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); box-sizing: border-box; }
|
||
input[type="text"]:focus, select:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
|
||
.row { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; }
|
||
.row input[type="text"] { flex: 1; max-width: none; }
|
||
.row .err { color: var(--danger); font-size: .85em; margin-left: .5rem; }
|
||
button { font: inherit; padding: .35rem .85rem; cursor: pointer; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius); }
|
||
button:hover { background: var(--primary-bg); }
|
||
button.primary { background: var(--primary); color: white; border-color: var(--primary); }
|
||
button.primary:hover { filter: brightness(1.1); }
|
||
button.danger { color: var(--danger); border-color: var(--danger); }
|
||
button.danger:hover { background: rgba(176, 0, 32, 0.06); }
|
||
table { width: 100%; border-collapse: collapse; font-size: .95em; }
|
||
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid var(--border); }
|
||
th { color: var(--muted); font-weight: 500; }
|
||
td.value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; word-break: break-all; }
|
||
td.numeric { text-align: right; font-variant-numeric: tabular-nums; }
|
||
ul.bare { list-style: none; padding: 0; margin: 0; }
|
||
ul.bare li { padding: .2rem 0; }
|
||
.badge { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8em; border: 1px solid var(--border); background: var(--bg); }
|
||
.badge.yes { background: var(--primary-bg); border-color: var(--primary); color: var(--primary); }
|
||
.ok-banner { background: var(--primary-bg); border: 1px solid var(--primary); border-radius: var(--radius); padding: .55rem .85rem; margin-bottom: 1rem; color: var(--text); }
|
||
pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .7rem; margin: .35rem 0; overflow-x: auto; font-size: 12px; max-height: 20rem; }
|
||
pre.err { border-color: var(--danger); color: var(--danger); }
|
||
code { background: var(--bg); padding: 0 .25rem; border-radius: 2px; font-size: 12px; border: 1px solid var(--border); }
|
||
.theme-pick label { display: inline-flex; align-items: center; gap: .25rem; margin-right: 1rem; }
|
||
.ls-actions { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; }
|
||
</style>
|
||
{{ if .HasCustomCSS }}<link rel="stylesheet" href="{{ .AssetsPathPrefix }}/custom.css">{{ end }}
|
||
</head>
|
||
<body>
|
||
<div class="breadcrumb"><a href="/">← home</a> / <span>profile</span></div>
|
||
|
||
<h1>Your profile</h1>
|
||
|
||
<section class="card">
|
||
<h2>Identity</h2>
|
||
{{ if .Email }}
|
||
<p>Signed in as <code>{{ .Email }}</code>.</p>
|
||
{{ else }}
|
||
<p>Not signed in. The server reads identity from the <code>{{ .EmailHeader }}</code> header. If you expected to be authenticated, your reverse proxy or SSO gateway is not forwarding it.</p>
|
||
{{ end }}
|
||
<p class="muted">Configured email header: <code>{{ .EmailHeader }}</code></p>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>Effective access</h2>
|
||
<p>
|
||
Super-admin: {{ if .IsSuperAdmin }}<span class="badge yes">yes</span>{{ else }}<span class="badge">no</span>{{ end }}
|
||
</p>
|
||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Visible projects</h3>
|
||
{{ if .Projects }}
|
||
<ul class="bare">
|
||
{{ range .Projects }}<li><a href="{{ .URL }}">{{ if .Title }}{{ .Title }}{{ else }}{{ .Name }}{{ end }}</a> <span class="muted">({{ .URL }})</span></li>{{ end }}
|
||
</ul>
|
||
{{ else }}
|
||
<p class="muted">No projects accessible.</p>
|
||
{{ end }}
|
||
{{ if .HasAnyAdminScope }}
|
||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Subtrees you administer</h3>
|
||
{{ if .AdminSubtrees }}
|
||
<ul class="bare">
|
||
{{ range .AdminSubtrees }}<li><code>{{ .Path }}</code>{{ if .Title }} — {{ .Title }}{{ end }} {{ if .CanEdit }}<span class="muted">(editable)</span>{{ else }}<span class="muted">(read-only — you cannot edit the file granting your own authority)</span>{{ end }}</li>{{ end }}
|
||
</ul>
|
||
{{ else }}
|
||
<p class="muted">None.</p>
|
||
{{ end }}
|
||
{{ end }}
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>Theme</h2>
|
||
<p class="help">Applies to every ZDDC tool you open in this browser. Stored in <code>localStorage["zddc-theme"]</code>.</p>
|
||
<div class="theme-pick">
|
||
<label><input type="radio" name="theme" value="auto"> auto (system)</label>
|
||
<label><input type="radio" name="theme" value="light"> light</label>
|
||
<label><input type="radio" name="theme" value="dark"> dark</label>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>Local storage</h2>
|
||
<p class="help">Browser-side state used by the ZDDC tools at this origin. The profile page can read and write it for you.</p>
|
||
<table id="ls-table"><thead><tr><th>Key</th><th>Value</th><th class="numeric">Bytes</th></tr></thead><tbody></tbody></table>
|
||
<div class="ls-actions">
|
||
<button type="button" id="ls-export">Export all (JSON)</button>
|
||
<button type="button" id="ls-import">Import from JSON…</button>
|
||
<input type="file" id="ls-import-file" accept="application/json,.json" hidden>
|
||
<button type="button" id="ls-clear" class="danger">Clear all</button>
|
||
</div>
|
||
</section>
|
||
|
||
{{ if .HasAnyAdminScope }}
|
||
<section class="card">
|
||
<h2>Editable .zddc files</h2>
|
||
<p class="help">Open the form-based editor for any subtree you administer.</p>
|
||
{{ if .HasEditableSubtrees }}
|
||
<ul class="bare">
|
||
{{ range .EditableParentChoices }}<li><a href="{{ $.ProfilePathPrefix }}/zddc/edit?path={{ .Path }}"><code>{{ .Path }}/.zddc</code></a>{{ if .Title }} — {{ .Title }}{{ end }}</li>{{ end }}
|
||
</ul>
|
||
{{ else }}
|
||
<p class="muted">No <code>.zddc</code> files within your edit authority. Subtree admins cannot edit the file that grants their own authority — only an admin from a higher level can.</p>
|
||
{{ end }}
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>Create new project folder</h2>
|
||
<p class="help">Creates a directory under the chosen parent. If you fill in any of title / allow / deny / admins, a starter <code>.zddc</code> is also written; otherwise the directory is empty and inherits ACL from its ancestors.</p>
|
||
<div id="cp-ok" class="ok-banner" hidden>Created.</div>
|
||
<form id="cp-form" autocomplete="off">
|
||
<label>Parent
|
||
<select name="parent" id="cp-parent">
|
||
{{ if .IsSuperAdmin }}<option value="/">/ (root)</option>{{ end }}
|
||
{{ range .AdminSubtrees }}<option value="{{ .Path }}">{{ .Path }}</option>{{ end }}
|
||
</select>
|
||
</label>
|
||
<label>Name
|
||
<input type="text" name="name" id="cp-name" maxlength="64" placeholder="e.g. Site-3" required>
|
||
<span class="err" id="cp-name-err"></span>
|
||
</label>
|
||
<label>Title (optional)
|
||
<input type="text" name="title" id="cp-title" maxlength="200">
|
||
</label>
|
||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL — Allow (optional)</h3>
|
||
<div class="list" data-field="acl.allow"></div>
|
||
<button type="button" class="add" data-target="acl.allow">+ Add allow rule</button>
|
||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL — Deny (optional)</h3>
|
||
<div class="list" data-field="acl.deny"></div>
|
||
<button type="button" class="add" data-target="acl.deny">+ Add deny rule</button>
|
||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Admins (optional)</h3>
|
||
<div class="list" data-field="admins"></div>
|
||
<button type="button" class="add" data-target="admins">+ Add admin</button>
|
||
<div style="margin-top: 1rem;">
|
||
<button type="submit" class="primary">Create</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
{{ end }}
|
||
|
||
{{ if .IsSuperAdmin }}
|
||
<section class="card">
|
||
<h2>Server config <button type="button" data-diag="config">refresh</button></h2>
|
||
<p class="help">Effective config from environment variables. Read-only.</p>
|
||
<pre id="diag-config">loading…</pre>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>Logs <button type="button" data-diag="logs">refresh</button>
|
||
<span class="muted">level: <select id="diag-level"><option value="">all</option><option value="debug">debug+</option><option value="info" selected>info+</option><option value="warn">warn+</option><option value="error">error</option></select></span>
|
||
</h2>
|
||
<pre id="diag-logs">loading…</pre>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>whoami <button type="button" data-diag="whoami">refresh</button></h2>
|
||
<p class="help">Headers as they arrived at the binary. Useful for debugging SSO header passthrough.</p>
|
||
<pre id="diag-whoami">loading…</pre>
|
||
</section>
|
||
{{ end }}
|
||
|
||
<script>
|
||
(function() {
|
||
var prefix = {{ .ProfilePathPrefix }};
|
||
var hasAdminScope = {{ .HasAnyAdminScope }};
|
||
var isSuper = {{ .IsSuperAdmin }};
|
||
|
||
// ── Theme ──────────────────────────────────────────────────────────────
|
||
var THEME_KEY = "zddc-theme";
|
||
function applyTheme(v) {
|
||
if (v === "light" || v === "dark") {
|
||
document.documentElement.setAttribute("data-theme", v);
|
||
} else {
|
||
document.documentElement.removeAttribute("data-theme");
|
||
}
|
||
}
|
||
var current = (function() { try { return localStorage.getItem(THEME_KEY) || "auto"; } catch (e) { return "auto"; } })();
|
||
applyTheme(current);
|
||
document.querySelectorAll('input[name="theme"]').forEach(function(r) {
|
||
if (r.value === current) r.checked = true;
|
||
r.addEventListener("change", function() {
|
||
try { localStorage.setItem(THEME_KEY, r.value); } catch (e) {}
|
||
applyTheme(r.value);
|
||
});
|
||
});
|
||
|
||
// ── Local storage panel ────────────────────────────────────────────────
|
||
function bytes(s) { return new TextEncoder().encode(s).length; }
|
||
function renderLS() {
|
||
var tbody = document.querySelector("#ls-table tbody");
|
||
tbody.textContent = "";
|
||
var keys = [];
|
||
for (var i = 0; i < localStorage.length; i++) keys.push(localStorage.key(i));
|
||
keys.sort();
|
||
if (keys.length === 0) {
|
||
var tr = document.createElement("tr");
|
||
var td = document.createElement("td");
|
||
td.colSpan = 3; td.className = "muted"; td.textContent = "(empty)";
|
||
tr.appendChild(td); tbody.appendChild(tr);
|
||
return;
|
||
}
|
||
keys.forEach(function(k) {
|
||
var v = localStorage.getItem(k) || "";
|
||
var tr = document.createElement("tr");
|
||
var tdK = document.createElement("td"); tdK.textContent = k;
|
||
var tdV = document.createElement("td"); tdV.className = "value";
|
||
tdV.textContent = v.length > 200 ? v.slice(0, 200) + "…" : v;
|
||
var tdB = document.createElement("td"); tdB.className = "numeric"; tdB.textContent = bytes(v);
|
||
tr.appendChild(tdK); tr.appendChild(tdV); tr.appendChild(tdB);
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
renderLS();
|
||
|
||
document.getElementById("ls-export").addEventListener("click", function() {
|
||
var dump = {};
|
||
for (var i = 0; i < localStorage.length; i++) {
|
||
var k = localStorage.key(i);
|
||
dump[k] = localStorage.getItem(k);
|
||
}
|
||
var blob = new Blob([JSON.stringify(dump, null, 2)], { type: "application/json" });
|
||
var a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = "zddc-localstorage.json";
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
});
|
||
|
||
document.getElementById("ls-import").addEventListener("click", function() {
|
||
document.getElementById("ls-import-file").click();
|
||
});
|
||
document.getElementById("ls-import-file").addEventListener("change", function(ev) {
|
||
var file = ev.target.files && ev.target.files[0];
|
||
if (!file) return;
|
||
var reader = new FileReader();
|
||
reader.onload = function() {
|
||
try {
|
||
var parsed = JSON.parse(String(reader.result || ""));
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
alert("Import file must be a JSON object of string→string."); return;
|
||
}
|
||
Object.keys(parsed).forEach(function(k) {
|
||
var v = parsed[k];
|
||
if (typeof v === "string") localStorage.setItem(k, v);
|
||
});
|
||
renderLS();
|
||
alert("Imported " + Object.keys(parsed).length + " keys.");
|
||
} catch (e) {
|
||
alert("Import failed: " + e);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
ev.target.value = "";
|
||
});
|
||
|
||
document.getElementById("ls-clear").addEventListener("click", function() {
|
||
if (!confirm("Clear ALL localStorage at this origin? This includes theme and any saved presets.")) return;
|
||
localStorage.clear();
|
||
renderLS();
|
||
applyTheme("auto");
|
||
document.querySelectorAll('input[name="theme"]').forEach(function(r) { r.checked = (r.value === "auto"); });
|
||
});
|
||
|
||
// ── Create project ────────────────────────────────────────────────────
|
||
if (hasAdminScope) {
|
||
function rowFor(field) {
|
||
var div = document.createElement("div"); div.className = "row";
|
||
var input = document.createElement("input");
|
||
input.type = "text"; input.dataset.field = field;
|
||
var del = document.createElement("button");
|
||
del.type = "button"; del.textContent = "−"; del.className = "del";
|
||
div.appendChild(input); div.appendChild(del);
|
||
return div;
|
||
}
|
||
document.querySelectorAll("#cp-form button.add").forEach(function(btn) {
|
||
btn.addEventListener("click", function() {
|
||
var field = btn.dataset.target;
|
||
document.querySelector('#cp-form .list[data-field="' + field + '"]').appendChild(rowFor(field));
|
||
});
|
||
});
|
||
document.getElementById("cp-form").addEventListener("click", function(e) {
|
||
if (e.target && e.target.classList && e.target.classList.contains("del")) {
|
||
e.target.closest(".row").remove();
|
||
}
|
||
});
|
||
function collectList(field) {
|
||
var out = [];
|
||
document.querySelectorAll('#cp-form .list[data-field="' + field + '"] input').forEach(function(i) {
|
||
if (i.value.trim()) out.push(i.value.trim());
|
||
});
|
||
return out;
|
||
}
|
||
document.getElementById("cp-form").addEventListener("submit", function(ev) {
|
||
ev.preventDefault();
|
||
document.getElementById("cp-name-err").textContent = "";
|
||
document.getElementById("cp-ok").hidden = true;
|
||
var allow = collectList("acl.allow");
|
||
var deny = collectList("acl.deny");
|
||
var admins = collectList("admins");
|
||
var title = document.getElementById("cp-title").value.trim();
|
||
var body = {
|
||
parent: document.getElementById("cp-parent").value,
|
||
name: document.getElementById("cp-name").value.trim()
|
||
};
|
||
if (title) body.title = title;
|
||
if (allow.length || deny.length) body.acl = { allow: allow, deny: deny };
|
||
if (admins.length) body.admins = admins;
|
||
fetch(prefix + "/projects", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||
credentials: "same-origin",
|
||
body: JSON.stringify(body)
|
||
}).then(function(r) {
|
||
return r.text().then(function(t) { return { ok: r.ok, status: r.status, text: t }; });
|
||
}).then(function(res) {
|
||
if (res.ok) {
|
||
var parsed = {};
|
||
try { parsed = JSON.parse(res.text); } catch (e) {}
|
||
var ok = document.getElementById("cp-ok");
|
||
ok.hidden = false;
|
||
ok.textContent = "Created " + (parsed.path || "(unknown path)") + ". Reload to see it in the lists above.";
|
||
return;
|
||
}
|
||
try {
|
||
var p = JSON.parse(res.text);
|
||
if (p && p.errors && p.errors.length) {
|
||
document.getElementById("cp-name-err").textContent = p.errors.map(function(e) { return e.field + ": " + e.message; }).join("; ");
|
||
return;
|
||
}
|
||
} catch (e) {}
|
||
document.getElementById("cp-name-err").textContent = "HTTP " + res.status + ": " + res.text;
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Super-admin diagnostics ───────────────────────────────────────────
|
||
if (isSuper) {
|
||
function loadDiag(target, qs) {
|
||
var el = document.getElementById("diag-" + target);
|
||
el.classList.remove("err");
|
||
el.textContent = "loading…";
|
||
var url = prefix + "/" + target + (qs ? ("?" + qs) : "");
|
||
fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
|
||
.then(function(r) { return r.text().then(function(t) { return { ok: r.ok, status: r.status, text: t }; }); })
|
||
.then(function(res) {
|
||
if (!res.ok) {
|
||
el.classList.add("err");
|
||
el.textContent = "HTTP " + res.status + "\n\n" + res.text;
|
||
return;
|
||
}
|
||
try { el.textContent = JSON.stringify(JSON.parse(res.text), null, 2); }
|
||
catch (e) { el.textContent = res.text; }
|
||
}).catch(function(err) { el.classList.add("err"); el.textContent = String(err); });
|
||
}
|
||
document.querySelectorAll("button[data-diag]").forEach(function(b) {
|
||
b.addEventListener("click", function() {
|
||
var t = b.dataset.diag;
|
||
var qs = t === "logs" ? ("level=" + (document.getElementById("diag-level").value || "")) : "";
|
||
loadDiag(t, qs);
|
||
});
|
||
});
|
||
var lvl = document.getElementById("diag-level");
|
||
if (lvl) lvl.addEventListener("change", function() { loadDiag("logs", "level=" + (lvl.value || "")); });
|
||
loadDiag("config");
|
||
loadDiag("logs", "level=info");
|
||
loadDiag("whoami");
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`))
|