Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12f6e22187 | ||
|
|
5025e12b71 | ||
|
|
3009b39453 | ||
|
|
41f64f3a13 | ||
|
|
f72170b4e1 | ||
|
|
ea1eac1c28 | ||
|
|
ea8be2ac76 |
@@ -102,7 +102,7 @@ with this machine's shell settings — the same keys as the checked-in
|
||||
| `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. |
|
||||
| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. |
|
||||
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
|
||||
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember`, `daylight`, `midnight`, `moss`, `plum`, `sepia`, `blackout`, `paper` or `iron`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||
|
||||
**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
|
||||
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
|
||||
@@ -140,12 +140,26 @@ ignored from then on.
|
||||
|
||||
The whole dashboard — every tile, on every browser — draws with one palette,
|
||||
chosen in the admin page's **Theme** section and switched live: pick a swatch
|
||||
and open dashboards change without a reload. Five presets ship: **Booth**
|
||||
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite**
|
||||
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
|
||||
and **Daylight**, a light palette for a front-of-house desk in daytime. Each
|
||||
keeps the status colours apart — the accent is never near the danger red or
|
||||
the warning amber — and muted text readable in a dark booth. The choice is
|
||||
and open dashboards change without a reload. The section starts folded —
|
||||
a building picks its palette once — and holds twelve presets:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Booth** | the default dark, mint accent |
|
||||
| **Harbor** | deep navy, cyan |
|
||||
| **Graphite** | neutral charcoal, electric blue |
|
||||
| **Ember** | warm broadcast black, orange |
|
||||
| **Daylight** | a cool light palette, for a front-of-house desk in daytime |
|
||||
| **Midnight** | indigo night, violet |
|
||||
| **Moss** | forest floor, lime |
|
||||
| **Plum** | aubergine, purple |
|
||||
| **Sepia** | warm brown room, cool teal — Ember's arrangement reversed |
|
||||
| **Blackout** | true black, cyan, for a dark room where any glow carries |
|
||||
| **Paper** | the warm light one, indigo accent |
|
||||
| **Iron** | no colour of its own: a near-white accent, so the only hues on screen are a module's own and the red and amber that mean something |
|
||||
|
||||
Each keeps the status colours apart — the accent is never near the danger red
|
||||
or the warning amber — and muted text readable in a dark booth. The choice is
|
||||
stored as `theme` in the data directory's `proddash.json` (table above), so
|
||||
it holds across restarts and updates; a browser remembers the last theme it
|
||||
saw and applies it before the server answers, so nothing flashes. Modules
|
||||
@@ -186,7 +200,18 @@ below. Nothing is installed without someone clicking.
|
||||
no local changes — otherwise the banner says why and you update with git).
|
||||
A plain folder (no `.git`) has the repo archive unpacked over it; files
|
||||
removed upstream are left behind but harmless. Settings and layouts are
|
||||
never touched. **What changed** opens the commit comparison on GitHub.
|
||||
never touched — nothing inside the data directory is written, even on an
|
||||
install where that directory *is* the app's own `config/`. **What changed**
|
||||
opens the commit comparison on the forge.
|
||||
- **If an update goes wrong**: the download has to parse before anything is
|
||||
swapped in, so a truncated or mangled `server.js` is refused with the
|
||||
reason and the running copy is left exactly as it was. Past that, the copy
|
||||
being replaced is set aside as `app.previous` next to the app folder, and
|
||||
dropped once the new version has stayed up for ninety seconds — so that
|
||||
folder existing means "the last update has not proven itself yet". The
|
||||
macOS app watches for a version that will not stay up (four stops in two
|
||||
minutes) and puts the old one back by itself, once, with a notification.
|
||||
Running some other way, the way back is a rename: `mv app.previous app`.
|
||||
- **Restarting**: after an update the server exits with code 75 and starts
|
||||
its own replacement, which waits for the port to free up. If you supervise
|
||||
ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
|
||||
|
||||
@@ -103,6 +103,45 @@ enum ProdDash {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Go back to the copy ProdDash set aside before its last update.
|
||||
|
||||
A version that will not stay up is the one state this app cannot talk its
|
||||
way out of: the admin page went down with it, and installing a fresh
|
||||
`.app` does nothing, because seeding only replaces an *older* copy and a
|
||||
broken one carries a higher version. So ProdDash writes `app.previous`
|
||||
before it overwrites itself and drops it again once the new version has
|
||||
stayed up — meaning that folder exists only while an update is still
|
||||
unproven. This puts it back.
|
||||
|
||||
Returns the version restored, or nil when there is nothing to go back to.
|
||||
*/
|
||||
@discardableResult
|
||||
static func restorePrevious(root: URL, log: (String) -> Void) -> String? {
|
||||
let fm = FileManager.default
|
||||
let previous = URL(fileURLWithPath: root.path + ".previous")
|
||||
guard isRoot(previous) else { return nil }
|
||||
let version = shellVersion(root: previous)
|
||||
let named = version.isEmpty ? "(unknown version)" : version
|
||||
let broken = URL(fileURLWithPath: root.path + ".broken")
|
||||
do {
|
||||
try? fm.removeItem(at: broken)
|
||||
if fm.fileExists(atPath: root.path) { try fm.moveItem(at: root, to: broken) }
|
||||
try fm.moveItem(at: previous, to: root)
|
||||
makeWritable(root)
|
||||
try? fm.removeItem(at: broken)
|
||||
log("rolled ProdDash back to \(named) — the copy it replaced")
|
||||
return named
|
||||
} catch {
|
||||
log("could not roll back to the previous copy: \(error.localizedDescription)")
|
||||
// Leave something runnable behind rather than nothing at all.
|
||||
if !fm.fileExists(atPath: root.path), fm.fileExists(atPath: broken.path) {
|
||||
try? fm.moveItem(at: broken, to: root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Files copied out of a bundle can arrive read-only, and this copy is
|
||||
/// exactly where ProdDash writes its own updates.
|
||||
private static func makeWritable(_ root: URL) {
|
||||
|
||||
@@ -65,6 +65,8 @@ final class ServerController: ObservableObject {
|
||||
private var restartAfterExit = false
|
||||
private var startedAt: Date?
|
||||
private var crashTimes: [Date] = []
|
||||
/// One roll-back per bad update: a second would just swap copies forever.
|
||||
private var rolledBack = false
|
||||
private var pendingRestart: DispatchWorkItem?
|
||||
private var healthTimer: Timer?
|
||||
private var healthMisses = 0
|
||||
@@ -299,7 +301,7 @@ final class ServerController: ObservableObject {
|
||||
child = nil
|
||||
healthy = false
|
||||
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate
|
||||
if ranFor > 60 { crashTimes.removeAll(); rolledBack = false } // a long healthy run clears the slate
|
||||
|
||||
if restartAfterExit {
|
||||
restartAfterExit = false
|
||||
@@ -328,6 +330,22 @@ final class ServerController: ObservableObject {
|
||||
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
|
||||
|
||||
guard settings.restartOnCrash, crashTimes.count < 4 else {
|
||||
// Before giving up: if an update is still unproven, the copy it
|
||||
// replaced is sitting right there. Going back beats sitting dark,
|
||||
// and only once — if the old one will not run either, the problem
|
||||
// is not the update.
|
||||
if settings.restartOnCrash, !rolledBack, let root = rootURL,
|
||||
let restored = ProdDash.restorePrevious(root: root, log: { [log] in log.launcher($0) }) {
|
||||
rolledBack = true
|
||||
crashTimes.removeAll()
|
||||
state = .stopped
|
||||
message = "Rolled back to ProdDash \(restored) — starting…"
|
||||
Notifier.shared.post(title: "ProdDash rolled back",
|
||||
body: "The new version would not stay up. Back on \(restored).")
|
||||
refreshEnvironment()
|
||||
start()
|
||||
return
|
||||
}
|
||||
let why = settings.restartOnCrash
|
||||
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
|
||||
: "ProdDash \(how)."
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
+30
-8
@@ -451,6 +451,36 @@ a.btn { text-decoration: none; }
|
||||
|
||||
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
|
||||
accent as a status dot) and the name; the current one wears the accent */
|
||||
/* ── a collapsible admin section ─────────────────────────────────────
|
||||
The pattern for any section of this page that folds. The markup is a
|
||||
<details class="admin-fold"> holding the section's contents, headed by a
|
||||
<summary class="admin-h1 admin-fold-title">: the title keeps the same
|
||||
type as every other section heading — a <summary> would otherwise come
|
||||
out at the normal weight — and the caret follows the words, so a folded
|
||||
section still reads as a heading first. The whole row is the hit target. */
|
||||
details.admin-fold > summary.admin-fold-title {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700; /* matches h1.admin-h1; summary defaults to normal */
|
||||
user-select: none;
|
||||
}
|
||||
details.admin-fold > summary.admin-fold-title::-webkit-details-marker { display: none; }
|
||||
/* after the title, not before it — the heading reads first, the caret says
|
||||
there is more behind it */
|
||||
details.admin-fold > summary.admin-fold-title::after {
|
||||
content: "";
|
||||
width: 0; height: 0;
|
||||
border-left: 6px solid var(--muted);
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
|
||||
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
|
||||
|
||||
.theme-swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||
@@ -550,11 +580,3 @@ a.btn { text-decoration: none; }
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.pin-note { margin-top: 12px; }
|
||||
.pin-note code {
|
||||
font-size: 0.92em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,6 @@ function renderPin(state) {
|
||||
: set
|
||||
? 'Admin is locked. This page asks for the PIN, and so does every admin request.'
|
||||
: 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.';
|
||||
if (state.dataDir) document.getElementById('pin-file').textContent = state.dataDir + '/proddash.json';
|
||||
}
|
||||
|
||||
function pinMessage(text, bad = false) {
|
||||
|
||||
@@ -41,9 +41,11 @@
|
||||
</section>
|
||||
|
||||
<section id="theme-section" hidden>
|
||||
<h1 class="admin-h1">Theme</h1>
|
||||
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
||||
<div class="admin-status" id="theme-status"></div>
|
||||
<details class="admin-fold">
|
||||
<summary class="admin-h1 admin-fold-title">Theme</summary>
|
||||
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
||||
<div class="admin-status" id="theme-status"></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="modules-section" hidden>
|
||||
@@ -92,10 +94,6 @@
|
||||
<button class="btn" id="pin-clear" hidden>Turn off</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="admin-copy pin-note">Only this page and the admin API ask for it — dashboards stay
|
||||
open, and it is nothing to do with a module's own PIN, such as Slack's send PIN. Forgotten?
|
||||
Remove <code>adminPin</code> from <code id="pin-file">proddash.json</code> in the settings
|
||||
folder and restart.</p>
|
||||
<div class="admin-status" id="pin-status"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+101
-2
@@ -941,8 +941,10 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
every shell element follows, and a colour a tile is configured with (set
|
||||
on the tile itself) still wins. The :root block at the top of this file is
|
||||
"booth", the default; the list of ids and names lives in server.js
|
||||
(GET /api/theme). All four are booth-grade: high contrast, the accent
|
||||
nowhere near the danger red or the warning amber, muted still readable. */
|
||||
(GET /api/theme). Every one is booth-grade: high contrast, the accent
|
||||
nowhere near the danger red or the warning amber, muted still readable.
|
||||
Each sets --accent-text too when the default (a near-black green, for
|
||||
booth's mint) would look wrong against its own accent. */
|
||||
|
||||
/* Harbor — deep navy, cyan accent */
|
||||
html[data-theme="harbor"] {
|
||||
@@ -993,3 +995,100 @@ html[data-theme="daylight"] {
|
||||
--danger: #d1242f;
|
||||
--warn: #b26a00;
|
||||
}
|
||||
|
||||
/* Midnight — indigo night, violet accent */
|
||||
html[data-theme="midnight"] {
|
||||
--bg: #0a0b18;
|
||||
--panel: #14162b;
|
||||
--border: #272a4a;
|
||||
--text: #e8e9fb;
|
||||
--muted: #9a9cc8;
|
||||
--accent: #9d8cff;
|
||||
--accent-text: #12103a;
|
||||
--danger: #ff5a72;
|
||||
--warn: #f5b93a;
|
||||
}
|
||||
|
||||
/* Moss — forest floor, lime accent (warn pushed orange so the two greens
|
||||
and the amber never read as one another) */
|
||||
html[data-theme="moss"] {
|
||||
--bg: #08120c;
|
||||
--panel: #101e15;
|
||||
--border: #1f3728;
|
||||
--text: #e9f4ea;
|
||||
--muted: #8fb09a;
|
||||
--accent: #8fdb3f;
|
||||
--accent-text: #15250a;
|
||||
--danger: #ff5a52;
|
||||
--warn: #ff9f1c;
|
||||
}
|
||||
|
||||
/* Plum — aubergine, purple accent */
|
||||
html[data-theme="plum"] {
|
||||
--bg: #110a17;
|
||||
--panel: #1d1228;
|
||||
--border: #332042;
|
||||
--text: #f2e9f8;
|
||||
--muted: #b195c2;
|
||||
--accent: #c084fc;
|
||||
--accent-text: #260a37;
|
||||
--danger: #ff4d4f;
|
||||
--warn: #f7b53b;
|
||||
}
|
||||
|
||||
/* Sepia — warm brown room, cool teal accent: the opposite arrangement to
|
||||
Ember, which is warm throughout */
|
||||
html[data-theme="sepia"] {
|
||||
--bg: #12100b;
|
||||
--panel: #1d1913;
|
||||
--border: #33291d;
|
||||
--text: #f4ede0;
|
||||
--muted: #b3a48b;
|
||||
--accent: #5eead4;
|
||||
--accent-text: #06231e;
|
||||
--danger: #ff5a52;
|
||||
--warn: #ffc247;
|
||||
}
|
||||
|
||||
/* Blackout — true black for a dark room, where any glow off the screen
|
||||
carries; everything else is as bright as it can be against it */
|
||||
html[data-theme="blackout"] {
|
||||
--bg: #000000;
|
||||
--panel: #0a0a0a;
|
||||
--border: #1f1f1f;
|
||||
--text: #ffffff;
|
||||
--muted: #9e9e9e;
|
||||
--accent: #00e5ff;
|
||||
--accent-text: #00212a;
|
||||
--danger: #ff3b30;
|
||||
--warn: #ffcc00;
|
||||
}
|
||||
|
||||
/* Paper — the warm light one, next to Daylight's cool white. Danger and warn
|
||||
darken, as they must on a light background, and the indigo accent takes
|
||||
white text. */
|
||||
html[data-theme="paper"] {
|
||||
--bg: #f5f1e8;
|
||||
--panel: #fffdf8;
|
||||
--border: #ddd5c4;
|
||||
--text: #1c1a15;
|
||||
--muted: #6b6455;
|
||||
--accent: #4f46e5;
|
||||
--accent-text: #ffffff;
|
||||
--danger: #c8102e;
|
||||
--warn: #9a6700;
|
||||
}
|
||||
|
||||
/* Iron — no colour of its own: the accent is near-white, so the only hues on
|
||||
screen are a module's own and the red/amber that mean something */
|
||||
html[data-theme="iron"] {
|
||||
--bg: #0c0c0d;
|
||||
--panel: #161617;
|
||||
--border: #2b2b2d;
|
||||
--text: #f2f2f3;
|
||||
--muted: #9a9a9d;
|
||||
--accent: #d8d8dc;
|
||||
--accent-text: #131314;
|
||||
--danger: #ff4d4f;
|
||||
--warn: #ffb224;
|
||||
}
|
||||
|
||||
@@ -712,6 +712,13 @@ const THEMES = [
|
||||
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
|
||||
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
|
||||
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
|
||||
{ id: 'midnight', name: 'Midnight', colors: { bg: '#0a0b18', panel: '#14162b', accent: '#9d8cff' } },
|
||||
{ id: 'moss', name: 'Moss', colors: { bg: '#08120c', panel: '#101e15', accent: '#8fdb3f' } },
|
||||
{ id: 'plum', name: 'Plum', colors: { bg: '#110a17', panel: '#1d1228', accent: '#c084fc' } },
|
||||
{ id: 'sepia', name: 'Sepia', colors: { bg: '#12100b', panel: '#1d1913', accent: '#5eead4' } },
|
||||
{ id: 'blackout', name: 'Blackout', colors: { bg: '#000000', panel: '#0a0a0a', accent: '#00e5ff' } },
|
||||
{ id: 'paper', name: 'Paper', colors: { bg: '#f5f1e8', panel: '#fffdf8', accent: '#4f46e5' } },
|
||||
{ id: 'iron', name: 'Iron', colors: { bg: '#0c0c0d', panel: '#161617', accent: '#d8d8dc' } },
|
||||
];
|
||||
const DEFAULT_THEME = THEMES[0].id;
|
||||
|
||||
@@ -1097,12 +1104,13 @@ function uninstallModule(id) {
|
||||
|
||||
/* — updating the shell itself — */
|
||||
|
||||
function copyTree(src, dst) {
|
||||
function copyTree(src, dst, include = null) {
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (include && !include(e.name)) continue;
|
||||
const s = path.join(src, e.name);
|
||||
const d = path.join(dst, e.name);
|
||||
if (e.isDirectory()) copyTree(s, d);
|
||||
if (e.isDirectory()) copyTree(s, d, include);
|
||||
else fs.copyFileSync(s, d);
|
||||
}
|
||||
}
|
||||
@@ -1122,25 +1130,83 @@ async function updateShell() {
|
||||
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
|
||||
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
|
||||
} else {
|
||||
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
|
||||
const staging = path.join(ROOT, '.proddash-update');
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
for (const [rel, data] of remote.files) {
|
||||
if (!safeRelPath(rel) || keep(rel)) continue;
|
||||
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
|
||||
const target = path.join(staging, rel);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, data);
|
||||
}
|
||||
if (!fs.existsSync(path.join(staging, 'server.js'))) {
|
||||
const newServer = path.join(staging, 'server.js');
|
||||
if (!fs.existsSync(newServer)) {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
|
||||
}
|
||||
// Nothing is swapped in until the new server at least parses. A file that
|
||||
// arrived truncated or mangled would otherwise be found only by a process
|
||||
// that cannot start — the one state this app cannot talk its way out of.
|
||||
try {
|
||||
execFileSync(process.execPath, ['--check', newServer], { timeout: 30000, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
} catch (err) {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
// node --check leads with the staging path and a caret diagram; the
|
||||
// line that names the fault is the one worth repeating.
|
||||
const lines = String(err?.stderr || err?.message || err).split('\n').map((l) => l.trim());
|
||||
const why = lines.find((l) => /^[A-Za-z]*Error\b/.test(l)) || 'it does not parse';
|
||||
throw httpError(502, `The downloaded ProdDash will not run — nothing was changed (${why}).`);
|
||||
}
|
||||
keepPreviousCopy();
|
||||
copyTree(staging, ROOT);
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
|
||||
}
|
||||
|
||||
/* ── what an update must not touch ───────────────────────────────────
|
||||
Anything inside the data directory. Normally it sits well outside the app
|
||||
folder and none of this applies, but when it cannot (an unwritable
|
||||
Application Support, or PRODDASH_DATA_DIR aimed inside the app) the data
|
||||
directory IS the app's config/ — and an update would then write the repo's
|
||||
shipped defaults over this machine's own proddash.json, taking the admin
|
||||
PIN, the theme and the forge with it. The two legacy names stay listed for
|
||||
an install whose state never moved out. */
|
||||
|
||||
const DATA_DIR_INSIDE_ROOT = (() => {
|
||||
const rel = path.relative(ROOT, DATA_DIR);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
|
||||
return rel.split(path.sep).join('/') + '/';
|
||||
})();
|
||||
|
||||
function isKeptOnUpdate(rel) {
|
||||
if (rel.startsWith('.git/')) return true;
|
||||
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
|
||||
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
|
||||
}
|
||||
|
||||
/* ── the way back ────────────────────────────────────────────────────
|
||||
The copy being replaced is set aside first, so there is always one known
|
||||
version to return to. The launcher restores it if the new one will not
|
||||
stay up (see ServerController); by hand it is a rename. It is replaced by
|
||||
the next update, never accumulated. */
|
||||
|
||||
function previousCopyPath() {
|
||||
return `${ROOT}.previous`;
|
||||
}
|
||||
|
||||
function keepPreviousCopy() {
|
||||
const previous = previousCopyPath();
|
||||
try {
|
||||
fs.rmSync(previous, { recursive: true, force: true });
|
||||
copyTree(ROOT, previous, (rel) => rel !== '.proddash-update' && rel !== '.git');
|
||||
fs.writeFileSync(path.join(previous, '.proddash-version'), SHELL_VERSION);
|
||||
console.log(`[proddash] kept ${SHELL_VERSION} at ${previous} in case the new one will not run`);
|
||||
} catch (err) {
|
||||
// Worth saying, not worth refusing the update over.
|
||||
console.warn('[proddash] could not set the current copy aside:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us
|
||||
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
|
||||
@@ -1911,6 +1977,22 @@ server.on('error', (err) => {
|
||||
|
||||
server.listen(PORT, '0.0.0.0');
|
||||
|
||||
/* An update proves itself by staying up. Once this version has, the copy it
|
||||
replaced is dropped — so `app.previous` existing always means "the last
|
||||
update has not proven itself yet", which is exactly what the launcher
|
||||
should roll back to and nothing more. */
|
||||
const UPDATE_PROVEN_AFTER_MS = 90000;
|
||||
setTimeout(() => {
|
||||
const previous = previousCopyPath();
|
||||
if (!fs.existsSync(previous)) return;
|
||||
try {
|
||||
fs.rmSync(previous, { recursive: true, force: true });
|
||||
console.log(`[proddash] ${SHELL_VERSION} has stayed up — dropped the copy it replaced`);
|
||||
} catch (err) {
|
||||
console.warn('[proddash] could not drop the previous copy:', err?.message || err);
|
||||
}
|
||||
}, UPDATE_PROVEN_AFTER_MS).unref();
|
||||
|
||||
// Update check: shortly after boot, then every few hours (the admin page
|
||||
// shows the result; nothing is installed without someone clicking).
|
||||
if (!process.env.PRODDASH_NO_REMOTE_CHECK) {
|
||||
|
||||
Reference in New Issue
Block a user