Install and update from the admin page: repo catalog, module version requirements, shell updates
ProdDash now has a version (package.json, 1.1.0) and modules declare the
shell versions they need ("proddash": ">=1.1.0" in the manifest). A module
whose requirement this shell doesn't meet is listed in the admin page but
never loaded, and cannot be installed from the catalog.
Modules come from two places: bundled with the checkout (modules/) and
installed from the admin page into the data directory (<data dir>/modules/).
Same id in both → the newer version runs; a downloaded copy the bundled one
has overtaken is removed. The admin page's new "Available modules" section
lists modules in the ProdDash repo that aren't installed here — one small
tar.gz of the branch is fetched (at most every few minutes, only while the
admin page is open or for the periodic update check) and read with a
dependency-free tar reader (ustar, GNU long names, pax headers; the pax
global comment carries the commit sha). Install downloads the module folder
into the data directory and mounts it immediately; a module card offers
"Update to vX" when the repo has a newer version. Uninstall removes an
installed module's files; a bundled module is hidden instead (its folder
belongs to the app) and can be restored from Available modules without a
download. Settings are kept across uninstall/reinstall.
Shell updates: the server compares the repo's package.json version and
commit sha with its own (git rev-parse when the app folder is a checkout),
shows a banner in the admin page, and "Update now" brings the app folder up
to date — git pull --ff-only for a checkout that is on the repo branch with
no local changes (file modes ignored), otherwise the archive is unpacked over
the folder (runtime state and .git untouched). The server then exits with
code 75 and starts its own replacement unless PRODDASH_LAUNCHER is set; the
replacement waits for the port to free up (EADDRINUSE retry). The page polls
until the new version answers. PRODDASH_REPO (owner/repo#branch),
PRODDASH_ARCHIVE_BASE and PRODDASH_NO_REMOTE_CHECK configure/disable it.
Docs: MODULE-GUIDE gets the rules every module follows (all settings in the
admin page, dependencies shipped inside the module, everything runs inside
the module's server entry — no helper programs, declare the ProdDash
requirement and bump the version), the proddash/version manifest fields and
the new installation story; README gets "Installing modules and updating";
CLAUDE.md summarises the rules.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
@@ -170,3 +170,68 @@ body { overflow: auto; }
|
||||
.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; }
|
||||
.layout-row .meta { color: var(--muted); font-size: 11px; }
|
||||
.layouts-empty { color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* ── version, update banner, catalog ───────────────────────────────── */
|
||||
|
||||
.admin-version {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-left: 4px;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
|
||||
.update-card {
|
||||
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--warn) 7%, var(--panel));
|
||||
margin-top: 18px;
|
||||
}
|
||||
.update-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.update-head strong { font-size: 14px; }
|
||||
.update-head .spacer { flex: 1; }
|
||||
.update-card .admin-copy { margin: 8px 0 0; }
|
||||
|
||||
.btn.small { padding: 4px 9px; font-size: 12px; }
|
||||
.btn.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
|
||||
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 14%, var(--bg)); }
|
||||
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
a.btn { text-decoration: none; }
|
||||
|
||||
.module-source {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.module-update-slot:empty { display: none; }
|
||||
|
||||
.catalog-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.catalog-toolbar .admin-status { margin-top: 0; }
|
||||
|
||||
.catalog-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.catalog-main { flex: 1; min-width: 0; }
|
||||
.catalog-title { display: flex; align-items: center; gap: 8px; }
|
||||
.catalog-row .module-desc { margin: 4px 0 2px; }
|
||||
.catalog-row .module-health { margin-top: 2px; }
|
||||
|
||||
+249
-6
@@ -12,6 +12,19 @@ const modulesList = document.getElementById('modules-list');
|
||||
const layoutsSection = document.getElementById('layouts-section');
|
||||
const layoutsList = document.getElementById('layouts-list');
|
||||
const toastEl = document.getElementById('toast');
|
||||
const availableSection = document.getElementById('available-section');
|
||||
const availableList = document.getElementById('available-list');
|
||||
const catalogStatus = document.getElementById('catalog-status');
|
||||
const updateCard = document.getElementById('update-card');
|
||||
const updateTitle = document.getElementById('update-title');
|
||||
const updateCopy = document.getElementById('update-copy');
|
||||
const updateStatus = document.getElementById('update-status');
|
||||
const updateBtn = document.getElementById('update-btn');
|
||||
const updateChanges = document.getElementById('update-changes');
|
||||
const shellVersionEl = document.getElementById('shell-version');
|
||||
|
||||
/** The shell version the page loaded with — the update flow watches it change. */
|
||||
let shellVersion = '';
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg, isError = false) {
|
||||
@@ -42,7 +55,9 @@ async function api(path, opts = {}) {
|
||||
function showLogin() {
|
||||
loginCard.hidden = false;
|
||||
modulesSection.hidden = true;
|
||||
availableSection.hidden = true;
|
||||
layoutsSection.hidden = true;
|
||||
updateCard.hidden = true;
|
||||
passcodeInput.focus();
|
||||
}
|
||||
|
||||
@@ -170,9 +185,43 @@ function buildModuleCard(mod) {
|
||||
version.className = 'module-version';
|
||||
version.textContent = mod.version ? 'v' + mod.version : '';
|
||||
|
||||
const source = document.createElement('span');
|
||||
source.className = 'module-source';
|
||||
source.textContent = mod.source === 'installed' ? 'installed' : 'bundled';
|
||||
source.title = mod.source === 'installed'
|
||||
? 'Installed from the repo into the data directory'
|
||||
: 'Ships with this copy of ProdDash';
|
||||
|
||||
const spacer = document.createElement('span');
|
||||
spacer.className = 'spacer';
|
||||
|
||||
// filled by the catalog when the repo has a newer version
|
||||
const updateSlot = document.createElement('span');
|
||||
updateSlot.className = 'module-update-slot';
|
||||
|
||||
const uninstall = document.createElement('button');
|
||||
uninstall.className = 'btn small danger';
|
||||
uninstall.textContent = 'Uninstall';
|
||||
uninstall.title = mod.source === 'installed'
|
||||
? 'Remove this module\'s files from the data directory'
|
||||
: 'Hide this bundled module from every dashboard (its folder stays with the app)';
|
||||
uninstall.addEventListener('click', async () => {
|
||||
const what = mod.source === 'installed'
|
||||
? `Uninstall ${mod.name}? Its files are removed from the data directory. Its settings are kept in case you install it again.`
|
||||
: `Uninstall ${mod.name}? It disappears from every dashboard and the picker. It ships with ProdDash, so it can be brought back from "Available modules" any time.`;
|
||||
if (!window.confirm(what)) return;
|
||||
uninstall.disabled = true;
|
||||
try {
|
||||
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}`, { method: 'DELETE' });
|
||||
toast(`${mod.name} uninstalled`);
|
||||
await loadState();
|
||||
} catch (e) {
|
||||
uninstall.disabled = false;
|
||||
if (e.authRequired) showLogin();
|
||||
else toast(e.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
const toggleLabel = document.createElement('label');
|
||||
toggleLabel.className = 'switch';
|
||||
const toggle = document.createElement('input');
|
||||
@@ -184,7 +233,7 @@ function buildModuleCard(mod) {
|
||||
toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
||||
toggleLabel.append(toggle, track, toggleText);
|
||||
|
||||
head.append(dot, name, version, spacer, toggleLabel);
|
||||
head.append(dot, name, version, source, spacer, updateSlot, toggleLabel, uninstall);
|
||||
card.appendChild(head);
|
||||
|
||||
const desc = document.createElement('div');
|
||||
@@ -199,11 +248,13 @@ function buildModuleCard(mod) {
|
||||
|
||||
const mountError = document.createElement('div');
|
||||
mountError.className = 'module-mount-error';
|
||||
mountError.hidden = !mod.mountError;
|
||||
mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
||||
mountError.hidden = !mod.mountError && !mod.incompatible;
|
||||
mountError.textContent = mod.incompatible
|
||||
? `Not loaded — ${mod.incompatible}. Update ProdDash from the banner above.`
|
||||
: (mod.mountError ? `Failed to start: ${mod.mountError}` : '');
|
||||
card.appendChild(mountError);
|
||||
|
||||
liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError });
|
||||
liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError, updateSlot });
|
||||
|
||||
toggle.addEventListener('change', async () => {
|
||||
try {
|
||||
@@ -289,8 +340,10 @@ async function refreshStatus() {
|
||||
els.dot.className = 'dot ' + healthClass(mod);
|
||||
els.healthEl.textContent = healthText(mod);
|
||||
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
||||
els.mountError.hidden = !mod.mountError;
|
||||
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
||||
if (!mod.incompatible) {
|
||||
els.mountError.hidden = !mod.mountError;
|
||||
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
||||
}
|
||||
if (document.activeElement !== els.toggle) {
|
||||
els.toggle.checked = mod.enabled;
|
||||
els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
||||
@@ -382,6 +435,9 @@ async function loadState() {
|
||||
}
|
||||
loginCard.hidden = true;
|
||||
modulesSection.hidden = false;
|
||||
availableSection.hidden = false;
|
||||
shellVersion = String(state.version || '');
|
||||
shellVersionEl.textContent = shellVersion ? 'v' + shellVersion : '';
|
||||
const storageNote = document.getElementById('storage-note');
|
||||
if (state.dataDir) {
|
||||
document.getElementById('storage-path').textContent = state.dataDir;
|
||||
@@ -393,8 +449,195 @@ async function loadState() {
|
||||
modulesList.appendChild(buildModuleCard(mod));
|
||||
}
|
||||
loadLayouts();
|
||||
loadCatalog(false);
|
||||
}
|
||||
|
||||
/* ── the repo: available modules, module updates, shell update ──────── */
|
||||
|
||||
function fmtTime(ms) {
|
||||
return ms ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
}
|
||||
|
||||
async function loadCatalog(refresh) {
|
||||
catalogStatus.className = 'admin-status';
|
||||
catalogStatus.textContent = refresh ? 'Checking the repo…' : 'Checking…';
|
||||
let cat;
|
||||
try {
|
||||
cat = await api('/api/admin/catalog' + (refresh ? '?refresh=1' : ''));
|
||||
} catch (e) {
|
||||
if (e.authRequired) return showLogin();
|
||||
catalogStatus.className = 'admin-status error';
|
||||
catalogStatus.textContent = e.message;
|
||||
return;
|
||||
}
|
||||
renderShell(cat.shell || {});
|
||||
renderAvailable(cat);
|
||||
}
|
||||
|
||||
function renderShell(shell) {
|
||||
const remote = shell.remote || {};
|
||||
if (remote.error) {
|
||||
catalogStatus.className = 'admin-status error';
|
||||
catalogStatus.textContent = `Couldn't reach ${shell.repo}: ${remote.error}`;
|
||||
} else {
|
||||
catalogStatus.className = 'admin-status';
|
||||
catalogStatus.textContent = `${shell.repo}@${shell.branch}` +
|
||||
(remote.version ? ` · latest ProdDash ${remote.version}` : '') +
|
||||
(remote.checkedAt ? ` · checked ${fmtTime(remote.checkedAt)}` : '');
|
||||
}
|
||||
if (!shell.updateAvailable) {
|
||||
updateCard.hidden = true;
|
||||
return;
|
||||
}
|
||||
updateCard.hidden = false;
|
||||
updateTitle.textContent = shell.versionBehind
|
||||
? `ProdDash ${remote.version} is available`
|
||||
: `New commits on ${shell.branch}`;
|
||||
const where = shell.local?.isGit
|
||||
? `a git checkout of ${shell.local.branch} at ${String(shell.local.sha || '').slice(0, 7)}; updating runs git pull`
|
||||
: 'not a git checkout; updating unpacks the repo archive over the app folder';
|
||||
updateCopy.textContent = `You're running ProdDash ${shell.version} (${where}). Settings and layouts live outside the app folder and are not touched.`;
|
||||
updateChanges.href = shell.changesUrl || '#';
|
||||
updateBtn.disabled = Boolean(shell.updateBlocker) || updateBtn.dataset.busy === '1';
|
||||
if (shell.updateBlocker) {
|
||||
updateStatus.className = 'admin-status error';
|
||||
updateStatus.textContent = `Can't update from here: ${shell.updateBlocker}.`;
|
||||
} else if (updateBtn.dataset.busy !== '1') {
|
||||
updateStatus.className = 'admin-status';
|
||||
updateStatus.textContent = 'Updating restarts ProdDash; open dashboards reconnect on their own.';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAvailable(cat) {
|
||||
availableList.innerHTML = '';
|
||||
const updates = cat.updates || [];
|
||||
// newer versions of installed modules → a button on their card
|
||||
for (const els of liveEls.values()) els.updateSlot.innerHTML = '';
|
||||
for (const up of updates) {
|
||||
const els = liveEls.get(up.id);
|
||||
if (!els) continue;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn small primary';
|
||||
btn.textContent = `Update to v${up.version}`;
|
||||
btn.disabled = !up.compatible;
|
||||
btn.title = up.compatible ? `Download v${up.version} from the repo` : up.reason;
|
||||
btn.addEventListener('click', () => installFromCatalog(up, btn));
|
||||
els.updateSlot.appendChild(btn);
|
||||
}
|
||||
const available = cat.available || [];
|
||||
if (!available.length) {
|
||||
const none = document.createElement('div');
|
||||
none.className = 'layouts-empty';
|
||||
none.textContent = cat.shell?.remote?.error
|
||||
? 'The repo could not be reached, and nothing bundled is waiting to be installed.'
|
||||
: 'Everything the repo offers is installed.';
|
||||
availableList.appendChild(none);
|
||||
return;
|
||||
}
|
||||
for (const item of available) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'catalog-row';
|
||||
const main = document.createElement('div');
|
||||
main.className = 'catalog-main';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'catalog-title';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'module-name';
|
||||
name.textContent = item.name;
|
||||
const version = document.createElement('span');
|
||||
version.className = 'module-version';
|
||||
version.textContent = item.version ? 'v' + item.version : '';
|
||||
const source = document.createElement('span');
|
||||
source.className = 'module-source';
|
||||
source.textContent = item.source === 'bundled' ? 'bundled' : 'repo';
|
||||
source.title = item.source === 'bundled' ? 'Ships with this copy of ProdDash — restoring it downloads nothing' : 'Downloaded from the repo';
|
||||
title.append(name, version, source);
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'module-desc';
|
||||
desc.textContent = item.description || '';
|
||||
const req = document.createElement('div');
|
||||
req.className = 'module-health' + (item.compatible ? '' : ' error');
|
||||
req.textContent = item.compatible
|
||||
? (item.requires ? `Requires ProdDash ${item.requires}` : '')
|
||||
: item.reason;
|
||||
main.append(title, desc, req);
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn small primary';
|
||||
btn.textContent = 'Install';
|
||||
btn.disabled = !item.compatible;
|
||||
btn.title = item.compatible ? '' : item.reason;
|
||||
btn.addEventListener('click', () => installFromCatalog(item, btn));
|
||||
row.append(main, btn);
|
||||
availableList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function installFromCatalog(item, btn) {
|
||||
btn.disabled = true;
|
||||
const label = btn.textContent;
|
||||
btn.textContent = 'Installing…';
|
||||
try {
|
||||
const r = await api(`/api/admin/modules/${encodeURIComponent(item.id)}/install`, { method: 'POST', body: '{}' });
|
||||
toast(`${item.name} ${r.downloaded ? 'installed' : 'restored'}${r.version ? ' (v' + r.version + ')' : ''}`);
|
||||
await loadState();
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = label;
|
||||
if (e.authRequired) showLogin();
|
||||
else toast(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('catalog-refresh').addEventListener('click', () => loadCatalog(true));
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** After the update the server exits and comes back; wait for it. */
|
||||
async function waitForRestart(before) {
|
||||
const started = Date.now();
|
||||
let sawDown = false;
|
||||
while (Date.now() - started < 90000) {
|
||||
await sleep(1500);
|
||||
try {
|
||||
const s = await api('/api/admin/state');
|
||||
if (sawDown || s.version !== before) {
|
||||
toast(`ProdDash ${s.version} is running`);
|
||||
await sleep(600);
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.status === 401) { location.reload(); return; } // back up, passcode wanted again
|
||||
sawDown = true;
|
||||
}
|
||||
}
|
||||
updateStatus.className = 'admin-status error';
|
||||
updateStatus.textContent = 'ProdDash did not come back on its own — start it again on the server, then reload this page.';
|
||||
updateBtn.dataset.busy = '';
|
||||
updateBtn.disabled = false;
|
||||
}
|
||||
|
||||
updateBtn.addEventListener('click', async () => {
|
||||
if (!window.confirm('Update ProdDash now? The server restarts and every open dashboard reconnects a few seconds later.')) return;
|
||||
updateBtn.dataset.busy = '1';
|
||||
updateBtn.disabled = true;
|
||||
updateStatus.className = 'admin-status';
|
||||
updateStatus.textContent = 'Updating…';
|
||||
const before = shellVersion;
|
||||
try {
|
||||
const r = await api('/api/admin/update', { method: 'POST', body: '{}' });
|
||||
updateStatus.textContent = `Updated ${r.from} → ${r.to || 'latest commit'} via ${r.method}. Restarting…`;
|
||||
} catch (e) {
|
||||
updateStatus.className = 'admin-status error';
|
||||
updateStatus.textContent = e.message;
|
||||
updateBtn.dataset.busy = '';
|
||||
updateBtn.disabled = false;
|
||||
if (e.authRequired) showLogin();
|
||||
return;
|
||||
}
|
||||
await waitForRestart(before);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (modulesSection.hidden) return;
|
||||
refreshStatus();
|
||||
|
||||
+25
-1
@@ -11,7 +11,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<header id="topbar">
|
||||
<span id="brand">Prod<span class="accent">Dash</span> <span class="admin-tag">Admin</span></span>
|
||||
<span id="brand">Prod<span class="accent">Dash</span> <span class="admin-tag">Admin</span> <span class="admin-version" id="shell-version"></span></span>
|
||||
<span class="spacer"></span>
|
||||
<a class="tb-btn" href="/">← Dashboard</a>
|
||||
</header>
|
||||
@@ -27,6 +27,18 @@
|
||||
<div class="admin-status" id="login-status"></div>
|
||||
</section>
|
||||
|
||||
<section class="admin-card update-card" id="update-card" hidden>
|
||||
<div class="update-head">
|
||||
<span class="dot connecting"></span>
|
||||
<strong id="update-title">Update available</strong>
|
||||
<span class="spacer"></span>
|
||||
<a class="btn small" id="update-changes" href="#" target="_blank" rel="noopener">What changed</a>
|
||||
<button class="btn small primary" id="update-btn">Update now</button>
|
||||
</div>
|
||||
<p class="admin-copy" id="update-copy"></p>
|
||||
<div class="admin-status" id="update-status"></div>
|
||||
</section>
|
||||
|
||||
<section id="modules-section" hidden>
|
||||
<h1 class="admin-h1">Modules</h1>
|
||||
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
|
||||
@@ -37,6 +49,18 @@
|
||||
<div id="modules-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="available-section" hidden>
|
||||
<h1 class="admin-h1">Available modules</h1>
|
||||
<p class="admin-copy">Modules in the ProdDash repo that aren't installed here. Installing downloads the module
|
||||
into the server's data directory — no restart, nothing written into the app folder. A module that needs a
|
||||
newer ProdDash can't be installed until the shell is updated.</p>
|
||||
<div class="catalog-toolbar">
|
||||
<button class="btn small" id="catalog-refresh">Check again</button>
|
||||
<span class="admin-status" id="catalog-status"></span>
|
||||
</div>
|
||||
<div id="available-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="layouts-section" hidden>
|
||||
<h1 class="admin-h1">Named layouts</h1>
|
||||
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
|
||||
|
||||
Reference in New Issue
Block a user