Stopped hand-rolling HTML. Using Saltcorn APIs.
This commit is contained in:
parent
bcca593137
commit
d3e763e7fc
4 changed files with 208 additions and 298 deletions
|
|
@ -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… <a class="btn btn-primary" role="button" href="${DASHBOARD_URL}">Open the dev-deploy dashboard</a></p>`,
|
||||
],
|
||||
fields: []
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
476
lib/routes.js
476
lib/routes.js
|
|
@ -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)))}">« Prev</a>`
|
||||
: `<span class="muted">« Prev</span>`;
|
||||
const nextLink = ops.length === limit
|
||||
? `<a href="${escape(qs(offset + limit))}">Next »</a>`
|
||||
: `<span class="muted">Next »</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} ${nextLink}</p>
|
||||
`;
|
||||
res.sendWrap("dev-deploy journal", layout(req, body, flashMsg(req)));
|
||||
const prevLink = offset > 0 ? link(qs(Math.max(offset - limit, 0)), "« Prev") : span({ class: "text-muted" }, "« Prev");
|
||||
const nextLink = ops.length === limit ? link(qs(offset + limit), "Next »") : span({ class: "text-muted" }, "Next »");
|
||||
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, " ", 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 — 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) — rows belong to the local environment; deploys never touch them. The only safe choice for end-user-entered data.</li>
|
||||
<li><strong>starter</strong> — 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> — 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 }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue