Stopped hand-rolling HTML. Using Saltcorn APIs.

This commit is contained in:
Scott Duensing 2026-06-19 20:39:41 -05:00
parent bcca593137
commit d3e763e7fc
4 changed files with 208 additions and 298 deletions

View file

@ -1,9 +1,13 @@
// Minimal configuration_workflow. Its only job is to make dev-deploy show the
// standard "Configure" cog on the Settings -> Plugins list, consistent with
// other plugins (the cog renders iff the module exports configuration_workflow
// -- see server/routes/plugins.js cfg_link). dev-deploy is actually configured
// from its own dashboard under /admin/dev-deploy/, so the single step just links
// there.
// The plugin "gear" on Settings -> Plugins always opens /plugins/configure/<name>,
// which renders THIS workflow's form (the core configure route has no redirect
// hook -- server/routes/plugins.js). dev-deploy's real settings live in its own
// dashboard, so this single step bounces straight there with NO extra click.
//
// NOTE: blurb MUST be an ARRAY -- the array path is rendered raw (form.ts join),
// whereas a string blurb is run through the text() XSS whitelist, which strips
// <script>/<meta>. CSP allows 'unsafe-inline', so the inline script redirect is
// the primary path; the <noscript> meta-refresh and the visible button are
// fallbacks (the link is what shows if script/meta are ever stripped).
const Workflow = require("@saltcorn/data/models/workflow");
const Form = require("@saltcorn/data/models/form");
@ -18,11 +22,11 @@ const configurationWorkflow = () =>
name: "dev-deploy",
form: async () =>
new Form({
blurb:
"dev-deploy is configured from its own dashboard " +
"(environments, peers, plan, journal, conflicts).<br><br>" +
`<a class="btn btn-primary" role="button" href="${DASHBOARD_URL}">` +
"Open the dev-deploy dashboard</a>",
blurb: [
`<script>window.location.replace(${JSON.stringify(DASHBOARD_URL)});</script>`,
`<noscript><meta http-equiv="refresh" content="0; url=${DASHBOARD_URL}"></noscript>`,
`<p>Opening the dev-deploy dashboard&hellip; <a class="btn btn-primary" role="button" href="${DASHBOARD_URL}">Open the dev-deploy dashboard</a></p>`,
],
fields: []
})
}

View file

@ -1,7 +1,7 @@
// Compile-time constants for the dev-deploy plugin.
const PLUGIN_NAME = "dev-deploy";
const PLUGIN_VERSION = "0.0.2";
const PLUGIN_VERSION = "0.0.4";
// Namespace UUID for deterministic IDs derived from (kind, name).
// Generated once via crypto.randomUUID() and frozen here forever.

View file

@ -25,6 +25,13 @@ const { applyBatch, resolveConflict, resolveConflictByMerge, conflictFieldDiff }
const { revertOp } = require("./revert");
const { DATA_MODES } = require("./constants");
// Saltcorn native markup primitives -- the same ones core admin pages use, so
// these pages inherit the active theme by construction (mkTable/renderForm/
// post_btn instead of hand-rolled HTML + manual Bootstrap classes).
const { mkTable, post_btn, renderForm, link, alert, badge, tags } = require("@saltcorn/markup");
const { p, code, pre, div, span, strong } = tags;
const Form = require("@saltcorn/data/models/form");
const getInboundAnchor = async (peerId) => {
return await db.selectMaybeOne("_dd_anchors", { peer_id: peerId, direction: "inbound" });
@ -91,41 +98,28 @@ const navPills = (req, links) => {
};
// 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; }
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; }
.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:not(.btn) { padding: 0.25rem 0.6rem; cursor: pointer; }
</style>
${navPills(req, [
const DD_NAV = [
["/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}
<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="alert alert-success">${escape(m)}</div>`;
if (e) return `<div class="alert alert-danger">${escape(e)}</div>`;
return "";
// Render an admin page in the ACTIVE Saltcorn theme: breadcrumbs + sub-nav +
// flash alerts (from ?msg/?err) + the given content segments (cards or strings),
// all via res.sendWrap structured layout. This is the one rendering path; pages
// build their content with mkTable / renderForm / post_btn (no raw page HTML).
const adminPage = (req, res, title, ...segments) => {
const above = [
{ type: "breadcrumbs", crumbs: [{ text: "Settings", href: "/settings" }, { text: "dev-deploy" }, { text: title }] },
navPills(req, DD_NAV),
];
if (req.query.msg) above.push(alert("success", escape(req.query.msg)));
if (req.query.err) above.push(alert("danger", escape(req.query.err)));
res.sendWrap(`dev-deploy ${title}`, { above: above.concat(segments) });
};
@ -141,32 +135,25 @@ const dashboard = async (req, res) => {
const peerList = await peers.listPeers();
const conflictCount = (await db.query(`SELECT COUNT(*) AS c FROM ${schema}_dd_ops WHERE status='conflict'`)).rows[0].c;
const opsByKindRows = opsByKind.length === 0
? `<tr><td colspan="2" class="muted">No ops recorded yet</td></tr>`
: opsByKind.map((r) => `<tr><td>${escape(r.op_type)}</td><td>${escape(r.c)}</td></tr>`).join("");
const entRows = entCounts.length === 0
? `<tr><td colspan="2" class="muted">No entities tracked</td></tr>`
: entCounts.map((r) => `<tr><td>${escape(r.kind)}</td><td>${escape(r.c)}</td></tr>`).join("");
const body = `
<h1>dev-deploy dashboard</h1>
<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>
<tr><th>Require TLS (default)</th><td>${env && env.require_tls ? "yes" : "no"}</td></tr>
<tr><th>Bootstrapped at</th><td>${escape(env ? env.bootstrapped_at : "")}</td></tr>
<tr><th>Ops recorded</th><td>${escape(opCount)}</td></tr>
<tr><th>Peers configured</th><td>${escape(peerList.length)}</td></tr>
<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 class="table table-sm table-bordered"><tr><th>op_type</th><th>count</th></tr>${opsByKindRows}</table>
<h2>Entities tracked</h2>
<table class="table table-sm table-bordered"><tr><th>kind</th><th>count</th></tr>${entRows}</table>
`;
res.sendWrap("dev-deploy dashboard", layout(req, body, flashMsg(req)));
const envRows = [
{ k: "Env ID", v: code(escape(env ? env.env_id : "?")) },
{ k: "Label", v: env && env.env_label ? escape(env.env_label) : span({ class: "text-muted" }, "(unset)") },
{ k: "Destructive-op policy", v: span({ class: "badge bg-info" }, escape(env ? env.on_destructive_op : "?")) },
{ k: "Require TLS (default)", v: env && env.require_tls ? "yes" : "no" },
{ k: "Bootstrapped at", v: escape(env ? env.bootstrapped_at : "") },
{ k: "Ops recorded", v: escape(opCount) },
{ k: "Peers configured", v: escape(peerList.length) },
{ k: "Pending conflicts", v: conflictCount > 0 ? link("/admin/dev-deploy/conflicts", strong(escape(conflictCount))) : "0" },
];
const kv = mkTable([{ label: "Setting", key: "k" }, { label: "Value", key: (r) => r.v }], envRows);
const countTable = (rows, c1) => rows.length === 0
? p({ class: "text-muted" }, "None")
: mkTable([{ label: c1, key: (r) => escape(r[c1]) }, { label: "count", key: (r) => escape(r.c) }], rows);
adminPage(req, res, "dashboard",
{ type: "card", title: "Environment", contents: kv },
{ type: "card", title: "Ops by type", contents: countTable(opsByKind, "op_type") },
{ type: "card", title: "Entities tracked", contents: countTable(entCounts, "kind") }
);
};
@ -199,45 +186,31 @@ const opsView = async (req, res) => {
const since = req.query.since;
const ops = await fetchOps(limit, since, offset);
if (wantJson) { res.json({ ops: ops }); return; }
const rows = ops.length === 0
? `<tr><td colspan="8" class="muted">Journal is empty</td></tr>`
: ops.map((o) => `<tr>
<td><code>${escape(o.op_id.slice(0, 8))}</code></td>
<td>${escape(o.op_type)}</td>
<td><code>${escape((o.entity_uuid || "").slice(0, 8))}</code></td>
<td><code>${escape((o.parent_op_id || "").slice(0, 8))}</code></td>
<td>${escape(o.status)}</td>
<td>${escape(o.created_at)}</td>
<td>
<form class="inline" method="post" action="/admin/dev-deploy/revert" onsubmit="return confirm('Append a compensating op to revert ${escape(o.op_type)} ${escape(o.op_id.slice(0,8))}?')">
${csrfField(req)}
<input type="hidden" name="op_id" value="${escape(o.op_id)}">
<button type="submit">Revert</button>
</form>
</td>
<td><pre>${escape(o.payload)}</pre></td>
</tr>`).join("");
const opsTable = ops.length === 0
? p({ class: "text-muted" }, "Journal is empty")
: mkTable([
{ label: "op", key: (o) => code(escape(o.op_id.slice(0, 8))) },
{ label: "op_type", key: (o) => escape(o.op_type) },
{ label: "entity", key: (o) => code(escape((o.entity_uuid || "").slice(0, 8))) },
{ label: "parent", key: (o) => code(escape((o.parent_op_id || "").slice(0, 8))) },
{ label: "status", key: (o) => escape(o.status) },
{ label: "created", key: (o) => escape(o.created_at) },
{ label: "actions", key: (o) => post_btn("/admin/dev-deploy/revert", "Revert", req.csrfToken(), { req, btnClass: "btn-danger", small: true, formClass: "d-inline", body: { op_id: o.op_id }, confirm: true }) },
{ label: "payload", key: (o) => pre({ class: "mb-0 p-1 bg-light text-dark", style: "white-space:pre-wrap;word-break:break-all;max-width:32rem" }, escape(o.payload)) },
], ops);
const qs = (off) => {
const parts = [`offset=${off}`, `limit=${limit}`];
if (since) { parts.push(`since=${encodeURIComponent(since)}`); }
return "/admin/dev-deploy/ops?" + parts.join("&");
};
const prevLink = offset > 0
? `<a href="${escape(qs(Math.max(offset - limit, 0)))}">&laquo; Prev</a>`
: `<span class="muted">&laquo; Prev</span>`;
const nextLink = ops.length === limit
? `<a href="${escape(qs(offset + limit))}">Next &raquo;</a>`
: `<span class="muted">Next &raquo;</span>`;
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 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.sendWrap("dev-deploy journal", layout(req, body, flashMsg(req)));
const prevLink = offset > 0 ? link(qs(Math.max(offset - limit, 0)), "&laquo; Prev") : span({ class: "text-muted" }, "&laquo; Prev");
const nextLink = ops.length === limit ? link(qs(offset + limit), "Next &raquo;") : span({ class: "text-muted" }, "Next &raquo;");
adminPage(req, res, "journal",
{ type: "card", title: "Journal", contents:
p({ class: "text-muted" }, `Showing up to ${escape(limit)} ops${since ? `, since op ${code(escape(since.slice(0, 8)))}` : ""}, offset ${escape(offset)}. Newest first. Revert appends a compensating op rather than rewriting history.`) +
opsTable +
p(prevLink, " &nbsp; ", nextLink) }
);
};
@ -247,65 +220,40 @@ const peersView = async (req, res) => {
if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; }
const env = await getEnv();
const list = await peers.listPeers();
const rows = list.length === 0
? `<tr><td colspan="6" class="muted">No peers yet</td></tr>`
: list.map((p) => `<tr>
<td>${escape(p.peer_id)}</td>
<td>${escape(p.label || "(unset)")}</td>
<td><code>${escape(p.env_id)}</code></td>
<td>${escape(p.base_url)}</td>
<td>${escape(p.last_seen_at || "never")}</td>
<td>
<form class="inline" method="post" action="/admin/dev-deploy/promote">
${csrfField(req)}
<input type="hidden" name="peer_id" value="${escape(p.peer_id)}">
<button type="submit">Promote</button>
</form>
<form class="inline" method="post" action="/admin/dev-deploy/pull">
${csrfField(req)}
<input type="hidden" name="peer_id" value="${escape(p.peer_id)}">
<button type="submit">Pull</button>
</form>
<form class="inline" method="post" action="/admin/dev-deploy/peers/rotate">
${csrfField(req)}
<input type="hidden" name="peer_id" value="${escape(p.peer_id)}">
<button type="submit">Rotate</button>
</form>
<form class="inline" method="post" action="/admin/dev-deploy/peers/delete" onsubmit="return confirm('Delete peer ${escape(p.label || p.env_id)}?')">
${csrfField(req)}
<input type="hidden" name="peer_id" value="${escape(p.peer_id)}">
<button type="submit">Delete</button>
</form>
</td>
</tr>`).join("");
const actBtn = (peer, url, label, cls) =>
post_btn(url, label, req.csrfToken(), { req, btnClass: cls, small: true, formClass: "d-inline me-1", body: { peer_id: peer.peer_id } });
const peerTable = mkTable([
{ label: "id", key: "peer_id" },
{ label: "label", key: (peer) => escape(peer.label || "(unset)") },
{ label: "env_id", key: (peer) => code(escape(peer.env_id)) },
{ label: "base_url", key: (peer) => escape(peer.base_url) },
{ label: "last seen", key: (peer) => escape(peer.last_seen_at || "never") },
{ label: "actions", key: (peer) =>
actBtn(peer, "/admin/dev-deploy/promote", "Promote", "btn-primary") +
actBtn(peer, "/admin/dev-deploy/pull", "Pull", "btn-secondary") +
actBtn(peer, "/admin/dev-deploy/peers/rotate", "Rotate", "btn-warning") +
post_btn("/admin/dev-deploy/peers/delete", "Delete", req.csrfToken(), { req, btnClass: "btn-danger", small: true, formClass: "d-inline", body: { peer_id: peer.peer_id }, confirm: true }),
},
], list);
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 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>
const addForm = new Form({
action: "/admin/dev-deploy/peers/add",
submitLabel: "Pair",
blurb: "Leave the shared secret blank to generate one (shown once after submit). Paste the same secret in the peer's own pairing form.",
fields: [
{ name: "env_id", label: "Peer env_id", type: "String", required: true },
{ name: "label", label: "Label", type: "String", attributes: { placeholder: "test, prod, etc." } },
{ name: "base_url", label: "Base URL", type: "String", required: true, attributes: { placeholder: "http://localhost:3001" } },
{ name: "require_tls", label: "Require TLS", type: "Bool" },
{ name: "existing_secret", label: "Existing secret (hex)", type: "String", attributes: { placeholder: "64 hex characters" } },
],
});
<h2>Add peer</h2>
<form method="post" action="/admin/dev-deploy/peers/add">
${csrfField(req)}
<fieldset>
<legend>Peer info</legend>
<p><label>Peer env_id <input type="text" name="env_id" required></label></p>
<p><label>Label <input type="text" name="label" placeholder="test, prod, etc."></label></p>
<p><label>Base URL <input type="url" name="base_url" required placeholder="http://localhost:3001"></label></p>
<p><label><input type="checkbox" name="require_tls"> Require TLS</label></p>
</fieldset>
<fieldset>
<legend>Shared secret</legend>
<p>Leave blank to generate one (shown once after submit). Paste the same secret in the peer's own pairing form.</p>
<p><label>Existing secret (hex) <input type="text" name="existing_secret" placeholder="64 hex characters"></label></p>
</fieldset>
<p><button type="submit">Pair</button></p>
</form>
`;
res.sendWrap("dev-deploy peers", layout(req, body, flashMsg(req)));
adminPage(req, res, "peers",
{ type: "card", title: "Peers", contents:
p("This instance's ", strong("env_id"), " is ", code(escape(env ? env.env_id : "?")), ". Paste this into the other instance's peer form.") + peerTable },
{ type: "card", title: "Add peer", contents: renderForm(addForm, req.csrfToken()) }
);
};
@ -326,14 +274,13 @@ const peersAdd = async (req, res) => {
}
const { peer, secret } = await peers.addPeer({ envId: envId, label: label, baseUrl: baseUrl, requireTls: requireTls, existingSecret: existingSecret });
const secretHex = secret.toString("hex");
const body = `
<h1>Peer ${escape(peer.label || peer.env_id)} paired</h1>
<p>Copy this secret into the peer's pairing form (it will not be shown again):</p>
<p class="secret">${escape(secretHex)}</p>
<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.sendWrap("Peer paired", layout(req, body));
adminPage(req, res, "peer paired",
{ type: "card", title: `Peer ${escape(peer.label || peer.env_id)} paired`, contents:
p("Copy this secret into the peer's pairing form (it will not be shown again):") +
pre({ class: "user-select-all p-2 bg-light text-dark border rounded", style: "white-space:pre-wrap;word-break:break-all" }, escape(secretHex)) +
p("If this is a brand-new pairing, give the peer this side's env_id too.") +
p(link("/admin/dev-deploy/peers", "Back to peers")) }
);
} catch (err) {
res.redirect("/admin/dev-deploy/peers?err=" + encodeURIComponent(err.message));
}
@ -345,14 +292,13 @@ const peersRotate = async (req, res) => {
try {
const peerId = parseInt(req.body.peer_id, 10);
const { peer, secret } = await peers.rotatePeerSecret(peerId);
const body = `
<h1>Peer ${escape(peer.label || peer.env_id)} secret rotated</h1>
<p>New secret (shown once):</p>
<p class="secret">${escape(secret.toString("hex"))}</p>
<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.sendWrap("Secret rotated", layout(req, body));
adminPage(req, res, "secret rotated",
{ type: "card", title: `Peer ${escape(peer.label || peer.env_id)} secret rotated`, contents:
p("New secret (shown once):") +
pre({ class: "user-select-all p-2 bg-light text-dark border rounded", style: "white-space:pre-wrap;word-break:break-all" }, escape(secret.toString("hex"))) +
p("Paste this on the other side via Rotate or by re-pairing.") +
p(link("/admin/dev-deploy/peers", "Back to peers")) }
);
} catch (err) {
res.redirect("/admin/dev-deploy/peers?err=" + encodeURIComponent(err.message));
}
@ -378,17 +324,19 @@ const planView = async (req, res) => {
const peerIdRaw = req.query.peer;
const peerList = await peers.listPeers();
if (!peerIdRaw) {
const opts = peerList.length === 0
? `<option value="" disabled selected>No peers configured</option>`
: peerList.map((p) => `<option value="${escape(p.peer_id)}">${escape(p.label || p.env_id)}</option>`).join("");
const body = `
<h1>Plan</h1>
<form method="get" action="/admin/dev-deploy/plan">
<label>Peer <select name="peer">${opts}</select></label>
<button type="submit">Show plan</button>
</form>
`;
res.sendWrap("dev-deploy plan", layout(req, body, flashMsg(req)));
const selForm = new Form({
methodGET: true,
action: "/admin/dev-deploy/plan",
submitLabel: "Show plan",
fields: [
{ name: "peer", label: "Peer", input_type: "select",
options: peerList.map((peer) => ({ label: peer.label || peer.env_id, value: String(peer.peer_id) })) },
],
});
adminPage(req, res, "plan",
{ type: "card", title: "Plan", contents:
peerList.length === 0 ? p({ class: "text-muted" }, "No peers configured.") : renderForm(selForm, req.csrfToken()) }
);
return;
}
const peerId = parseInt(peerIdRaw, 10);
@ -414,30 +362,24 @@ const planView = async (req, res) => {
sql += ` OFFSET $${params.length + 1}`;
params.push(offset);
const planRows = (await db.query(sql, params)).rows;
const rowsHtml = planRows.length === 0
? `<tr><td colspan="5" class="muted">No new ops to send</td></tr>`
: planRows.map((o) => `<tr>
<td><code>${escape(o.op_id.slice(0, 8))}</code></td>
<td>${escape(o.op_type)}</td>
<td><code>${escape((o.entity_uuid || "").slice(0, 8))}</code></td>
<td>${escape(o.status)}</td>
<td>${escape(o.created_at)}</td>
</tr>`).join("");
const body = `
<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 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>
<form method="post" action="/admin/dev-deploy/promote">
${csrfField(req)}
<input type="hidden" name="peer_id" value="${escape(peerId)}">
<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.sendWrap("dev-deploy plan", layout(req, body, flashMsg(req)));
const planTable = planRows.length === 0
? p({ class: "text-muted" }, "No new ops to send")
: mkTable([
{ label: "op", key: (o) => code(escape(o.op_id.slice(0, 8))) },
{ label: "op_type", key: (o) => escape(o.op_type) },
{ label: "entity", key: (o) => code(escape((o.entity_uuid || "").slice(0, 8))) },
{ label: "status", key: (o) => escape(o.status) },
{ label: "created", key: (o) => escape(o.created_at) },
], planRows);
const promoteBtn = planRows.length === 0 ? "" :
post_btn("/admin/dev-deploy/promote", `Promote ${escape(planRows.length)} op${planRows.length === 1 ? "" : "s"} to ${escape(peer.label || peer.env_id)}`, req.csrfToken(), { req, btnClass: "btn-primary", body: { peer_id: peerId } });
adminPage(req, res, "plan",
{ type: "card", title: `Plan: promote to ${escape(peer.label || peer.env_id)}`, contents:
p("Anchor: ", anchor ? code(escape(anchor.last_op_id.slice(0, 8))) : span({ class: "text-muted" }, "(none - will send from epoch)")) +
p(`Ops that would be sent: ${escape(planRows.length)}`) +
planTable +
promoteBtn }
);
};
@ -595,51 +537,33 @@ const conflictsView = async (req, res) => {
c.l_op_type.startsWith("update_") &&
c.i_op_type === c.l_op_type;
const rowsHtml = conflicts.length === 0
? `<tr><td colspan="3" class="muted">No pending conflicts</td></tr>`
: conflicts.map((c) => `<tr>
<td>
<strong>incoming</strong> <code>${escape(c.i_op_id.slice(0, 8))}</code> from <code>${escape((c.i_source || "").slice(0, 8))}</code><br>
${escape(c.i_op_type)} on entity <code>${escape((c.i_uuid || "").slice(0, 8))}</code><br>
<span class="muted">created ${escape(c.i_created)}</span>
<pre>${escape(c.i_payload)}</pre>
</td>
<td>
${c.l_op_id ? `<strong>local</strong> <code>${escape(c.l_op_id.slice(0, 8))}</code><br>
${escape(c.l_op_type)}<br>
<span class="muted">applied ${escape(c.l_applied || "")}</span>
<pre>${escape(c.l_payload)}</pre>` : '<span class="muted">(no local op recorded)</span>'}
</td>
<td>
${isMergeable(c) ? `<a href="/admin/dev-deploy/conflicts/merge?op_id=${encodeURIComponent(c.i_op_id)}"><button type="button">Merge per field</button></a><br><br>` : ""}
<form class="inline" method="post" action="/admin/dev-deploy/conflicts/resolve">
${csrfField(req)}
<input type="hidden" name="op_id" value="${escape(c.i_op_id)}">
<input type="hidden" name="action" value="theirs">
<button type="submit">Use theirs</button>
</form>
<form class="inline" method="post" action="/admin/dev-deploy/conflicts/resolve">
${csrfField(req)}
<input type="hidden" name="op_id" value="${escape(c.i_op_id)}">
<input type="hidden" name="action" value="mine">
<button type="submit">Use mine</button>
</form>
</td>
</tr>`).join("");
const conflictsTable = conflicts.length === 0
? p({ class: "text-muted" }, "No pending conflicts")
: mkTable([
{ label: "Theirs", key: (c) =>
strong("incoming") + " " + code(escape(c.i_op_id.slice(0, 8))) + " from " + code(escape((c.i_source || "").slice(0, 8))) + "<br>" +
escape(c.i_op_type) + " on entity " + code(escape((c.i_uuid || "").slice(0, 8))) + "<br>" +
span({ class: "text-muted" }, "created " + escape(c.i_created)) +
pre({ class: "mb-0 p-1 bg-light text-dark", style: "white-space:pre-wrap;word-break:break-all" }, escape(c.i_payload)) },
{ label: "Mine", key: (c) => c.l_op_id
? strong("local") + " " + code(escape(c.l_op_id.slice(0, 8))) + "<br>" + escape(c.l_op_type) + "<br>" +
span({ class: "text-muted" }, "applied " + escape(c.l_applied || "")) +
pre({ class: "mb-0 p-1 bg-light text-dark", style: "white-space:pre-wrap;word-break:break-all" }, escape(c.l_payload))
: span({ class: "text-muted" }, "(no local op recorded)") },
{ label: "Action", key: (c) =>
(isMergeable(c) ? link("/admin/dev-deploy/conflicts/merge?op_id=" + encodeURIComponent(c.i_op_id), "Merge per field") + "<br><br>" : "") +
post_btn("/admin/dev-deploy/conflicts/resolve", "Use theirs", req.csrfToken(), { req, btnClass: "btn-primary", small: true, formClass: "d-inline me-1", body: { op_id: c.i_op_id, action: "theirs" } }) +
post_btn("/admin/dev-deploy/conflicts/resolve", "Use mine", req.csrfToken(), { req, btnClass: "btn-secondary", small: true, formClass: "d-inline", body: { op_id: c.i_op_id, action: "mine" } }) },
], conflicts);
const body = `
<h1>Pending conflicts</h1>
<p>A conflict means an incoming op and a local op both touched the same entity since the last sync. The incoming op was NOT applied; pick which version wins.</p>
const intro = `<p>A conflict means an incoming op and a local op both touched the same entity since the last sync. The incoming op was NOT applied; pick which version wins.</p>
<ul>
<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 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.sendWrap("dev-deploy conflicts", layout(req, body, flashMsg(req)));
</ul>`;
adminPage(req, res, "conflicts",
{ type: "card", title: "Pending conflicts", contents: intro + conflictsTable }
);
};
@ -669,10 +593,10 @@ const conflictsResolve = async (req, res) => {
const renderValue = (v) => {
if (v === null || v === undefined) {
return '<span class="muted">(unset)</span>';
return '<span class="text-muted">(unset)</span>';
}
if (typeof v === "object") {
return `<pre>${escape(JSON.stringify(v, null, 2))}</pre>`;
return `<pre class="mb-0 p-1 bg-light text-dark" style="white-space:pre-wrap;word-break:break-all">${escape(JSON.stringify(v, null, 2))}</pre>`;
}
return escape(String(v));
};
@ -696,17 +620,17 @@ const conflictsMergeView = async (req, res) => {
let formBody;
if (diffs.length === 0) {
formBody = `<p class="muted">No field-level differences detected (current state already matches the incoming op's patch on every field). Applying the merge will just mark this conflict as resolved.</p>`;
formBody = `<p class="text-muted">No field-level differences detected (current state already matches the incoming op's patch on every field). Applying the merge will just mark this conflict as resolved.</p>`;
} else {
const rows = diffs.map((d) => `<tr>
<td><code>${escape(d.field)}</code></td>
<td>${renderValue(d.currentValue)}</td>
<td>${renderValue(d.incomingValue)}</td>
<td>
<label><input type="radio" name="choice_${escape(d.field)}" value="current" checked> keep current</label><br>
<label><input type="radio" name="choice_${escape(d.field)}" value="incoming"> take incoming</label><br>
<label><input type="radio" name="choice_${escape(d.field)}" value="custom"> custom:
<input type="text" name="custom_${escape(d.field)}" value="${escape(typeof d.currentValue === "string" ? d.currentValue : "")}">
<label><input type="radio" class="form-check-input me-1" name="choice_${escape(d.field)}" value="current" checked> keep current</label><br>
<label><input type="radio" class="form-check-input me-1" name="choice_${escape(d.field)}" value="incoming"> take incoming</label><br>
<label><input type="radio" class="form-check-input me-1" name="choice_${escape(d.field)}" value="custom"> custom:
<input type="text" class="form-control form-control-sm d-inline-block w-auto" name="custom_${escape(d.field)}" value="${escape(typeof d.currentValue === "string" ? d.currentValue : "")}">
</label>
</td>
</tr>`).join("");
@ -719,21 +643,18 @@ const conflictsMergeView = async (req, res) => {
`;
}
const body = `
<h1>Merge conflict per field</h1>
${ent}
const contents = `${ent}
<p>Incoming op <code>${escape(op.op_id.slice(0, 8))}</code> from <code>${escape((op.source_env_id || "").slice(0, 8))}</code>; ${escape(op.op_type)}.</p>
<form method="post" action="/admin/dev-deploy/conflicts/merge/apply">
${csrfField(req)}
<input type="hidden" name="op_id" value="${escape(op.op_id)}">
${formBody}
<p>
<button type="submit">Apply merge</button>
<a href="/admin/dev-deploy/conflicts">Cancel</a>
<button type="submit" class="btn btn-primary">Apply merge</button>
<a class="btn btn-link" href="/admin/dev-deploy/conflicts">Cancel</a>
</p>
</form>
`;
res.sendWrap("dev-deploy merge", layout(req, body, flashMsg(req)));
</form>`;
adminPage(req, res, "merge", { type: "card", title: "Merge conflict per field", contents: contents });
} catch (err) {
res.redirect("/admin/dev-deploy/conflicts?err=" + encodeURIComponent(err.message));
}
@ -807,45 +728,30 @@ const tablesView = async (req, res) => {
const lockedNames = new Set(["users"]);
const modeOpts = [DATA_MODES.USER, DATA_MODES.STARTER, DATA_MODES.MANAGED];
const rowsHtml = rows.length === 0
? `<tr><td colspan="6" class="muted">No tables tracked yet</td></tr>`
: rows.map((r) => {
const locked = lockedNames.has(r.current_name);
const select = locked
? `<span class="pill">user (locked)</span>`
: `<form class="inline" method="post" action="/admin/dev-deploy/tables/set" onsubmit="return confirm('Switching to managed/starter ADDS a hidden _dd_row_uuid column to this table and ships the current rows. Existing rows on target instances may be overwritten on next promote. Continue?');">
${csrfField(req)}
<input type="hidden" name="table_uuid" value="${escape(r.uuid)}">
<select name="data_mode">
${modeOpts.map((m) => `<option value="${m}"${m === r.data_mode ? " selected" : ""}>${m}</option>`).join("")}
</select>
<button type="submit">Set</button>
</form>`;
const shipped = r.starter_shipped_at ? `<br><span class="muted">shipped ${escape(r.starter_shipped_at)}</span>` : "";
return `<tr>
<td>${escape(r.current_name)}</td>
<td><code>${escape(r.uuid.slice(0, 8))}</code></td>
<td>${escape(r.current_id)}</td>
<td>${select}${shipped}</td>
<td>${escape(r.updated_at || "—")}</td>
</tr>`;
}).join("");
const body = `
<h1>Tables &mdash; data mode</h1>
<p>Controls how each table's row content propagates between environments. Choose carefully — switching from <strong>user</strong> to <strong>managed</strong> or <strong>starter</strong> rewrites the table's schema (adds a hidden <code>_dd_row_uuid</code> column) and ships existing rows.</p>
const tablesTable = rows.length === 0
? p({ class: "text-muted" }, "No tables tracked yet")
: mkTable([
{ label: "table", key: (r) => escape(r.current_name) },
{ label: "uuid", key: (r) => code(escape(r.uuid.slice(0, 8))) },
{ label: "local id", key: (r) => escape(r.current_id) },
{ label: "data_mode", key: (r) => {
const shipped = r.starter_shipped_at ? "<br>" + span({ class: "text-muted" }, "shipped " + escape(r.starter_shipped_at)) : "";
if (lockedNames.has(r.current_name)) return span({ class: "badge bg-secondary" }, "user (locked)") + shipped;
const opts = modeOpts.map((m) => `<option value="${m}"${m === r.data_mode ? " selected" : ""}>${m}</option>`).join("");
return `<form class="d-inline" method="post" action="/admin/dev-deploy/tables/set" onsubmit="return confirm('Switching to managed/starter ADDS a hidden _dd_row_uuid column to this table and ships the current rows. Existing rows on target instances may be overwritten on next promote. Continue?');">${csrfField(req)}<input type="hidden" name="table_uuid" value="${escape(r.uuid)}"><select class="form-select form-select-sm d-inline-block w-auto me-1" name="data_mode">${opts}</select><button type="submit" class="btn btn-primary btn-sm">Set</button></form>` + shipped;
} },
{ label: "updated_at", key: (r) => escape(r.updated_at || "-") },
], rows);
const intro = `<p>Controls how each table's row content propagates between environments. Choose carefully - switching from <strong>user</strong> to <strong>managed</strong> or <strong>starter</strong> rewrites the table's schema (adds a hidden <code>_dd_row_uuid</code> column) and ships existing rows.</p>
<ul>
<li><strong>user</strong> (default) &mdash; rows belong to the local environment; deploys never touch them. The only safe choice for end-user-entered data.</li>
<li><strong>starter</strong> &mdash; rows ship to target on first install, then the target owns them; future changes on this side don't propagate. Good for default user roles, sample categories, template data the user expects to customize.</li>
<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 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.sendWrap("dev-deploy tables", layout(req, body, flashMsg(req)));
<p>The Saltcorn <code>users</code> table is locked to <strong>user</strong> and cannot be changed.</p>`;
adminPage(req, res, "tables",
{ type: "card", title: "Tables - data mode", contents: intro + tablesTable }
);
};

View file

@ -1,6 +1,6 @@
{
"name": "dev-deploy",
"version": "0.0.2",
"version": "0.0.4",
"description": "Saltcorn plugin: migrate metadata changes (tables, fields, views, pages, triggers, roles, library, tags) across Dev/Test/Prod environments via an ops journal with stable UUIDs and HMAC-authenticated peer endpoints. Detects concurrent-edit conflicts and offers theirs/mine resolution. User-table row data is never touched.",
"main": "index.js",
"scripts": {