Fixed admin display.

This commit is contained in:
Scott Duensing 2026-06-19 19:09:05 -05:00
parent 0177cfaef8
commit bcca593137

View file

@ -71,54 +71,60 @@ const csrfField = (req) => {
};
const layout = (title, body, flash) => `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<title>${escape(title)}</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 1.5rem; max-width: 1100px; }
h1 { font-size: 1.4rem; }
h2 { font-size: 1.1rem; margin-top: 1.5rem; }
table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
th, td { border: 1px solid #ddd; padding: 0.3rem 0.5rem; text-align: left; vertical-align: top; }
th { background: #f5f5f5; }
tr:nth-child(even) td { background: #fafafa; }
code, pre { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.8rem; }
pre { background: #f5f5f5; padding: 0.5rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
// Bootstrap nav-pills with the current page marked active. The active link is the
// one whose href is the LONGEST boundary-safe prefix of the request path, so a
// sub-page (e.g. /conflicts/merge) still highlights its parent tab.
const navPills = (req, links) => {
const cur = String((req && (req.originalUrl || req.path)) || "").split("?")[0].replace(/\/+$/, "");
let activeHref = "";
for (const [href] of links) {
const h = href.replace(/\/+$/, "");
if ((cur === h || cur.startsWith(h + "/")) && h.length > activeHref.length) {
activeHref = h;
}
}
const items = links.map(([href, label]) => {
const on = href.replace(/\/+$/, "") === activeHref;
return ` <li class="nav-item"><a class="nav-link${on ? " active" : ""}"${on ? ' aria-current="page"' : ""} href="${escape(href)}">${escape(label)}</a></li>`;
}).join("\n");
return `<ul class="nav nav-pills mb-3">\n${items}\n</ul>`;
};
// Returns the INNER page content only (Bootstrap sub-nav + a small residual
// style for the non-table classes the body markup uses + optional flash + body).
// The page is wrapped in the active Saltcorn theme by res.sendWrap at the call
// sites, so there is no <html>/<head>/<body> here.
const layout = (req, body, flash) => `<style>
.muted { color: #888; }
.pill { display: inline-block; padding: 0.05rem 0.5rem; border-radius: 0.5rem; background: #eef; font-size: 0.75rem; }
nav { margin-bottom: 1rem; }
nav a { margin-right: 1rem; }
.right { float: right; color: #888; font-size: 0.85rem; }
code, pre { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.8rem; }
pre { background: #f5f5f5; padding: 0.5rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
form.inline { display: inline; }
input[type=text], input[type=url] { width: 28rem; padding: 0.2rem; }
fieldset { margin-bottom: 1rem; padding: 0.5rem 1rem; }
legend { font-weight: bold; }
.flash { padding: 0.5rem 1rem; margin-bottom: 1rem; background: #efe; border: 1px solid #cdc; border-radius: 0.3rem; }
.flash.err { background: #fee; border-color: #fcc; }
.secret { font-family: ui-monospace, Menlo, Consolas, monospace; background: #fff8dc; padding: 0.5rem; border: 1px solid #d4af37; border-radius: 0.3rem; word-break: break-all; }
button { padding: 0.25rem 0.6rem; cursor: pointer; }
button:not(.btn) { padding: 0.25rem 0.6rem; cursor: pointer; }
</style>
</head><body>
<nav>
<a href="/admin/dev-deploy/">Dashboard</a>
<a href="/admin/dev-deploy/ops">Journal</a>
<a href="/admin/dev-deploy/peers">Peers</a>
<a href="/admin/dev-deploy/plan">Plan</a>
<a href="/admin/dev-deploy/tables">Tables</a>
<a href="/admin/dev-deploy/conflicts">Conflicts</a>
<span class="right">${escape(PLUGIN_NAME)} v${escape(PLUGIN_VERSION)}</span>
</nav>
${navPills(req, [
["/admin/dev-deploy/", "Dashboard"],
["/admin/dev-deploy/ops", "Journal"],
["/admin/dev-deploy/peers", "Peers"],
["/admin/dev-deploy/plan", "Plan"],
["/admin/dev-deploy/tables", "Tables"],
["/admin/dev-deploy/conflicts", "Conflicts"],
])}
${flash || ""}
${body}
</body></html>`;
<p class="text-muted small mt-3">${escape(PLUGIN_NAME)} v${escape(PLUGIN_VERSION)}</p>`;
const flashMsg = (req) => {
const m = req.query.msg;
const e = req.query.err;
if (m) return `<div class="flash">${escape(m)}</div>`;
if (e) return `<div class="flash err">${escape(e)}</div>`;
if (m) return `<div class="alert alert-success">${escape(m)}</div>`;
if (e) return `<div class="alert alert-danger">${escape(e)}</div>`;
return "";
};
@ -145,7 +151,7 @@ const dashboard = async (req, res) => {
const body = `
<h1>dev-deploy dashboard</h1>
<table>
<table class="table table-sm table-bordered">
<tr><th>Env ID</th><td><code>${escape(env ? env.env_id : "?")}</code></td></tr>
<tr><th>Label</th><td>${env && env.env_label ? escape(env.env_label) : '<span class="muted">(unset)</span>'}</td></tr>
<tr><th>Destructive-op policy</th><td><span class="pill">${escape(env ? env.on_destructive_op : "?")}</span></td></tr>
@ -156,11 +162,11 @@ const dashboard = async (req, res) => {
<tr><th>Pending conflicts</th><td>${conflictCount > 0 ? `<a href="/admin/dev-deploy/conflicts"><strong>${escape(conflictCount)}</strong></a>` : "0"}</td></tr>
</table>
<h2>Ops by type</h2>
<table><tr><th>op_type</th><th>count</th></tr>${opsByKindRows}</table>
<table class="table table-sm table-bordered"><tr><th>op_type</th><th>count</th></tr>${opsByKindRows}</table>
<h2>Entities tracked</h2>
<table><tr><th>kind</th><th>count</th></tr>${entRows}</table>
<table class="table table-sm table-bordered"><tr><th>kind</th><th>count</th></tr>${entRows}</table>
`;
res.type("text/html").send(layout("dev-deploy dashboard", body, flashMsg(req)));
res.sendWrap("dev-deploy dashboard", layout(req, body, flashMsg(req)));
};
@ -225,13 +231,13 @@ const opsView = async (req, res) => {
const body = `
<h1>Journal</h1>
<p class="muted">Showing up to ${escape(limit)} ops${since ? `, since op <code>${escape(since.slice(0, 8))}</code>` : ""}, offset ${escape(offset)}. Newest first. Revert appends a compensating op rather than rewriting history.</p>
<table>
<table class="table table-sm table-bordered">
<tr><th>op</th><th>op_type</th><th>entity</th><th>parent</th><th>status</th><th>created</th><th>actions</th><th>payload</th></tr>
${rows}
</table>
<p>${prevLink} &nbsp; ${nextLink}</p>
`;
res.type("text/html").send(layout("dev-deploy journal", body, flashMsg(req)));
res.sendWrap("dev-deploy journal", layout(req, body, flashMsg(req)));
};
@ -276,7 +282,7 @@ const peersView = async (req, res) => {
const body = `
<h1>Peers</h1>
<p>This instance's <strong>env_id</strong> is <code>${escape(env ? env.env_id : "?")}</code>. Paste this into the other instance's peer form.</p>
<table>
<table class="table table-sm table-bordered">
<tr><th>id</th><th>label</th><th>env_id</th><th>base_url</th><th>last seen</th><th>actions</th></tr>
${rows}
</table>
@ -299,7 +305,7 @@ const peersView = async (req, res) => {
<p><button type="submit">Pair</button></p>
</form>
`;
res.type("text/html").send(layout("dev-deploy peers", body, flashMsg(req)));
res.sendWrap("dev-deploy peers", layout(req, body, flashMsg(req)));
};
@ -327,7 +333,7 @@ const peersAdd = async (req, res) => {
<p>If this is a brand-new pairing, give the peer this side's env_id too.</p>
<p><a href="/admin/dev-deploy/peers">Back to peers</a></p>
`;
res.type("text/html").send(layout("Peer paired", body));
res.sendWrap("Peer paired", layout(req, body));
} catch (err) {
res.redirect("/admin/dev-deploy/peers?err=" + encodeURIComponent(err.message));
}
@ -346,7 +352,7 @@ const peersRotate = async (req, res) => {
<p>Paste this on the other side via Rotate or by re-pairing.</p>
<p><a href="/admin/dev-deploy/peers">Back to peers</a></p>
`;
res.type("text/html").send(layout("Secret rotated", body));
res.sendWrap("Secret rotated", layout(req, body));
} catch (err) {
res.redirect("/admin/dev-deploy/peers?err=" + encodeURIComponent(err.message));
}
@ -382,7 +388,7 @@ const planView = async (req, res) => {
<button type="submit">Show plan</button>
</form>
`;
res.type("text/html").send(layout("dev-deploy plan", body, flashMsg(req)));
res.sendWrap("dev-deploy plan", layout(req, body, flashMsg(req)));
return;
}
const peerId = parseInt(peerIdRaw, 10);
@ -421,7 +427,7 @@ const planView = async (req, res) => {
<h1>Plan: promote to ${escape(peer.label || peer.env_id)}</h1>
<p>Anchor: ${anchor ? `<code>${escape(anchor.last_op_id.slice(0, 8))}</code>` : '<span class="muted">(none — will send from epoch)</span>'}</p>
<p>Ops that would be sent: ${escape(planRows.length)}</p>
<table>
<table class="table table-sm table-bordered">
<tr><th>op</th><th>op_type</th><th>entity</th><th>status</th><th>created</th></tr>
${rowsHtml}
</table>
@ -431,7 +437,7 @@ const planView = async (req, res) => {
<p><button type="submit"${planRows.length === 0 ? " disabled" : ""}>Promote ${escape(planRows.length)} op${planRows.length === 1 ? "" : "s"} to ${escape(peer.label || peer.env_id)}</button></p>
</form>
`;
res.type("text/html").send(layout("dev-deploy plan", body, flashMsg(req)));
res.sendWrap("dev-deploy plan", layout(req, body, flashMsg(req)));
};
@ -628,12 +634,12 @@ const conflictsView = async (req, res) => {
<li><strong>Use theirs</strong>: applies the incoming op now (overwrites local change). The local op stays in the journal but its effect is overwritten.</li>
<li><strong>Use mine</strong>: marks the incoming op as <code>rejected</code>. The local state stands. The peer may re-send the op on future syncs; subsequent pulls will skip it via idempotency.</li>
</ul>
<table>
<table class="table table-sm table-bordered">
<tr><th style="width: 40%">Theirs</th><th style="width: 40%">Mine</th><th>Action</th></tr>
${rowsHtml}
</table>
`;
res.type("text/html").send(layout("dev-deploy conflicts", body, flashMsg(req)));
res.sendWrap("dev-deploy conflicts", layout(req, body, flashMsg(req)));
};
@ -686,7 +692,7 @@ const conflictsMergeView = async (req, res) => {
const ent = diff.instance
? `<p>Entity: <code>${escape(diff.kind)}</code> <strong>${escape(diff.instance.name || diff.instance.role || diff.instance.id)}</strong> (local id ${escape(diff.instance.id)})</p>`
: `<p class="flash err">${escape(diff.reason || "no entity diff available")}</p>`;
: `<p class="alert alert-danger">${escape(diff.reason || "no entity diff available")}</p>`;
let formBody;
if (diffs.length === 0) {
@ -705,7 +711,7 @@ const conflictsMergeView = async (req, res) => {
</td>
</tr>`).join("");
formBody = `
<table>
<table class="table table-sm table-bordered">
<tr><th>field</th><th>current (mine)</th><th>incoming (theirs)</th><th>resolution</th></tr>
${rows}
</table>
@ -727,7 +733,7 @@ const conflictsMergeView = async (req, res) => {
</p>
</form>
`;
res.type("text/html").send(layout("dev-deploy merge", body, flashMsg(req)));
res.sendWrap("dev-deploy merge", layout(req, body, flashMsg(req)));
} catch (err) {
res.redirect("/admin/dev-deploy/conflicts?err=" + encodeURIComponent(err.message));
}
@ -834,12 +840,12 @@ const tablesView = async (req, res) => {
<li><strong>managed</strong> &mdash; rows always sync from source. Source is canonical; target's edits get overwritten or surface as conflicts. Good for catalogs, lookup tables, anything dev-curated.</li>
</ul>
<p>The Saltcorn <code>users</code> table is locked to <strong>user</strong> and cannot be changed.</p>
<table>
<table class="table table-sm table-bordered">
<tr><th>table</th><th>uuid</th><th>local id</th><th>data_mode</th><th>updated_at</th></tr>
${rowsHtml}
</table>
`;
res.type("text/html").send(layout("dev-deploy tables", body, flashMsg(req)));
res.sendWrap("dev-deploy tables", layout(req, body, flashMsg(req)));
};