From d3e763e7fc7488989fbd0d27ab79a4f1ace0388d Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Fri, 19 Jun 2026 20:39:41 -0500 Subject: [PATCH] Stopped hand-rolling HTML. Using Saltcorn APIs. --- lib/configWorkflow.js | 26 ++- lib/constants.js | 2 +- lib/routes.js | 476 +++++++++++++++++------------------------- package.json | 2 +- 4 files changed, 208 insertions(+), 298 deletions(-) diff --git a/lib/configWorkflow.js b/lib/configWorkflow.js index e0d957e..6e855ae 100644 --- a/lib/configWorkflow.js +++ b/lib/configWorkflow.js @@ -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/, +// 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 +// `, + ``, + `

Opening the dev-deploy dashboard… Open the dev-deploy dashboard

`, + ], fields: [] }) } diff --git a/lib/constants.js b/lib/constants.js index 96ee5c7..f9c8559 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -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. diff --git a/lib/routes.js b/lib/routes.js index 6372370..ac26d2a 100644 --- a/lib/routes.js +++ b/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 // here. -const layout = (req, body, flash) => ` -${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} -

${escape(PLUGIN_NAME)} v${escape(PLUGIN_VERSION)}

`; +]; -const flashMsg = (req) => { - const m = req.query.msg; - const e = req.query.err; - if (m) return `
${escape(m)}
`; - if (e) return `
${escape(e)}
`; - 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 - ? `No ops recorded yet` - : opsByKind.map((r) => `${escape(r.op_type)}${escape(r.c)}`).join(""); - - const entRows = entCounts.length === 0 - ? `No entities tracked` - : entCounts.map((r) => `${escape(r.kind)}${escape(r.c)}`).join(""); - - const body = ` -

dev-deploy dashboard

- - - - - - - - - -
Env ID${escape(env ? env.env_id : "?")}
Label${env && env.env_label ? escape(env.env_label) : '(unset)'}
Destructive-op policy${escape(env ? env.on_destructive_op : "?")}
Require TLS (default)${env && env.require_tls ? "yes" : "no"}
Bootstrapped at${escape(env ? env.bootstrapped_at : "")}
Ops recorded${escape(opCount)}
Peers configured${escape(peerList.length)}
Pending conflicts${conflictCount > 0 ? `${escape(conflictCount)}` : "0"}
-

Ops by type

- ${opsByKindRows}
op_typecount
-

Entities tracked

- ${entRows}
kindcount
- `; - 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 - ? `Journal is empty` - : ops.map((o) => ` - ${escape(o.op_id.slice(0, 8))} - ${escape(o.op_type)} - ${escape((o.entity_uuid || "").slice(0, 8))} - ${escape((o.parent_op_id || "").slice(0, 8))} - ${escape(o.status)} - ${escape(o.created_at)} - -
- ${csrfField(req)} - - -
- -
${escape(o.payload)}
- `).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 - ? `« Prev` - : `« Prev`; - const nextLink = ops.length === limit - ? `Next »` - : `Next »`; - const body = ` -

Journal

-

Showing up to ${escape(limit)} ops${since ? `, since op ${escape(since.slice(0, 8))}` : ""}, offset ${escape(offset)}. Newest first. Revert appends a compensating op rather than rewriting history.

- - - ${rows} -
opop_typeentityparentstatuscreatedactionspayload
-

${prevLink}   ${nextLink}

- `; - 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 - ? `No peers yet` - : list.map((p) => ` - ${escape(p.peer_id)} - ${escape(p.label || "(unset)")} - ${escape(p.env_id)} - ${escape(p.base_url)} - ${escape(p.last_seen_at || "never")} - -
- ${csrfField(req)} - - -
-
- ${csrfField(req)} - - -
-
- ${csrfField(req)} - - -
-
- ${csrfField(req)} - - -
- - `).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 = ` -

Peers

-

This instance's env_id is ${escape(env ? env.env_id : "?")}. Paste this into the other instance's peer form.

- - - ${rows} -
idlabelenv_idbase_urllast seenactions
+ 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" } }, + ], + }); -

Add peer

-
- ${csrfField(req)} -
- Peer info -

-

-

-

-
-
- Shared secret -

Leave blank to generate one (shown once after submit). Paste the same secret in the peer's own pairing 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 = ` -

Peer ${escape(peer.label || peer.env_id)} paired

-

Copy this secret into the peer's pairing form (it will not be shown again):

-

${escape(secretHex)}

-

If this is a brand-new pairing, give the peer this side's env_id too.

-

Back to peers

- `; - 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 = ` -

Peer ${escape(peer.label || peer.env_id)} secret rotated

-

New secret (shown once):

-

${escape(secret.toString("hex"))}

-

Paste this on the other side via Rotate or by re-pairing.

-

Back to peers

- `; - 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 - ? `` - : peerList.map((p) => ``).join(""); - const body = ` -

Plan

-
- - -
- `; - 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 - ? `No new ops to send` - : planRows.map((o) => ` - ${escape(o.op_id.slice(0, 8))} - ${escape(o.op_type)} - ${escape((o.entity_uuid || "").slice(0, 8))} - ${escape(o.status)} - ${escape(o.created_at)} - `).join(""); - const body = ` -

Plan: promote to ${escape(peer.label || peer.env_id)}

-

Anchor: ${anchor ? `${escape(anchor.last_op_id.slice(0, 8))}` : '(none — will send from epoch)'}

-

Ops that would be sent: ${escape(planRows.length)}

- - - ${rowsHtml} -
opop_typeentitystatuscreated
-
- ${csrfField(req)} - -

-
- `; - 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 - ? `No pending conflicts` - : conflicts.map((c) => ` - - incoming ${escape(c.i_op_id.slice(0, 8))} from ${escape((c.i_source || "").slice(0, 8))}
- ${escape(c.i_op_type)} on entity ${escape((c.i_uuid || "").slice(0, 8))}
- created ${escape(c.i_created)} -
${escape(c.i_payload)}
- - - ${c.l_op_id ? `local ${escape(c.l_op_id.slice(0, 8))}
- ${escape(c.l_op_type)}
- applied ${escape(c.l_applied || "")} -
${escape(c.l_payload)}
` : '(no local op recorded)'} - - - ${isMergeable(c) ? `

` : ""} -
- ${csrfField(req)} - - - -
-
- ${csrfField(req)} - - - -
- - `).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))) + "
" + + escape(c.i_op_type) + " on entity " + code(escape((c.i_uuid || "").slice(0, 8))) + "
" + + 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))) + "
" + escape(c.l_op_type) + "
" + + 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") + "

" : "") + + 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 = ` -

Pending conflicts

-

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.

+ const intro = `

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.

- - - ${rowsHtml} -
TheirsMineAction
- `; - res.sendWrap("dev-deploy conflicts", layout(req, body, flashMsg(req))); + `; + 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 '(unset)'; + return '(unset)'; } if (typeof v === "object") { - return `
${escape(JSON.stringify(v, null, 2))}
`; + return `
${escape(JSON.stringify(v, null, 2))}
`; } return escape(String(v)); }; @@ -696,17 +620,17 @@ const conflictsMergeView = async (req, res) => { let formBody; if (diffs.length === 0) { - formBody = `

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.

`; + formBody = `

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.

`; } else { const rows = diffs.map((d) => ` ${escape(d.field)} ${renderValue(d.currentValue)} ${renderValue(d.incomingValue)} -
-
-