From 2daf8897a123839d1fd6bbea6015e69c1e3ee6b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 21:12:46 +0000 Subject: [PATCH] Install and update from the admin page: repo catalog, module version requirements, shell updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (/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 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- CLAUDE.md | 12 +- README.md | 47 +- config/proddash.json | 4 +- docs/MODULE-GUIDE.md | 43 +- modules/clock/module.json | 1 + modules/prodcom-transcript/module.json | 1 + modules/propresenter-now-next/module.json | 1 + modules/propresenter-timers/module.json | 1 + package.json | 9 + public/admin/admin.css | 65 +++ public/admin/admin.js | 255 +++++++++- public/admin/index.html | 26 +- server.js | 580 +++++++++++++++++++++- 13 files changed, 1015 insertions(+), 30 deletions(-) create mode 100644 package.json diff --git a/CLAUDE.md b/CLAUDE.md index f08eb4b..b5670ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,16 @@ contract** (manifest, client factory + moduleApi, optional server entry, title-bar controls). The rules that matter most: - A module is one self-contained folder in `modules/` — **never modify the - shell** (`server.js`, `public/`) to add one. Installation = drop the folder, - restart, configure in `/admin`. + shell** (`server.js`, `public/`) to add one. Installation = the admin page's + Available modules (downloads from the repo into the data directory), or + drop the folder and restart; configure in `/admin`. +- **All settings in the admin page** (`configSchema` / `instanceSchema`) — + never a separate setup page, hand-edited file or env var. **Dependencies + ship inside the module folder** (zero deps is the norm). **Everything the + module does runs inside its server entry** — no helper scripts, companion + programs or daemons the operator starts by hand; a listener or decoder is + started in `init()` and stopped in `stop()`. Declare `"proddash": ">=x.y.z"` + and bump the module's `version` on every change. - Browsers never call upstream services directly; the module's server routes proxy everything (no CORS, secrets stay server-side). - Style with the shell's CSS variables only, scoped under diff --git a/README.md b/README.md index 3cd53b3..f585138 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,9 @@ the app folder from `main` and start the new version with everything still configured. The admin page shows the exact path in use, and the server prints it at startup (`Settings : …`). -The directory holds `modules.json` (module config) and `layouts/` (named -layouts), both written by the app itself. It may also hold a `proddash.json` +The directory holds `modules.json` (module config), `layouts/` (named +layouts) and `modules/` (modules installed from the admin page), all written +by the app itself. It may also hold a `proddash.json` with this machine's shell settings — the same keys as the checked-in [config/proddash.json](config/proddash.json), which supplies the defaults: @@ -86,6 +87,47 @@ so and falls back to the app's `config/` folder. directory, so nothing has to be re-entered. The old files are left in place and ignored from then on. +## Installing modules and updating + +Everything below happens in the **admin page**; ProdDash talks to its GitHub +repository (`michaelmcgary2008/ProdDash`, branch `main` — change with +`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable, +`owner/repo#branch`). It downloads one small archive of the branch, at most +every few minutes, only while the admin page is open or for the update check +below. Nothing is installed without someone clicking. + +- **Available modules** lists modules in the repo that aren't installed + here. **Install** downloads the module into the data directory's `modules/` + and starts it right away — nothing is written into the app folder, and no + restart is needed. Modules that ship with your copy of ProdDash are + "bundled"; ones you installed are "installed"; if both exist, the newer + version runs. +- **Uninstall** on a module card removes an installed module's files. For a + bundled module (its folder belongs to the app) it hides the module from + every dashboard instead; it reappears under Available modules and can be + brought back without downloading. Settings are kept either way. +- **Module updates**: when the repo has a newer version of an installed + module, its card shows **Update to vX**. +- **Version requirements**: every module declares the ProdDash versions it + needs (`"proddash": ">=1.1.0"` in its manifest). A module that needs a + newer shell is listed but not loaded, and cannot be installed until + ProdDash is updated. +- **ProdDash updates**: the admin page checks the repo shortly after start + and every six hours, and shows a banner when `main` has a newer version + (or new commits). **Update now** brings the app folder up to date and + restarts the server; dashboards reconnect on their own. A git checkout is + updated with `git pull --ff-only` (only when it is on the repo branch with + 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. +- **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 + just exits and your launcher restarts it on exit code 75. + `PRODDASH_NO_REMOTE_CHECK=1` turns off the periodic check (the admin page + still checks when opened). + ## Using the dashboard - **+** — add any enabled module; multiple tiles of the same module are @@ -172,6 +214,7 @@ ProdDash/ │ ├── clock/ │ ├── prodcom-transcript/ │ └── propresenter-now-next/ (carries its own copy of propresenter-core) +├── package.json name + version (what the update check compares) ├── config/ proddash.json defaults (runtime state lives in the │ per-machine data directory — see "Configure") ├── docs/MODULE-GUIDE.md how to build and install a module diff --git a/config/proddash.json b/config/proddash.json index 869d4c8..d43bd7c 100644 --- a/config/proddash.json +++ b/config/proddash.json @@ -1,4 +1,6 @@ { "port": 24500, - "adminPasscode": "" + "adminPasscode": "", + "repo": "michaelmcgary2008/ProdDash", + "branch": "main" } diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 6741fa1..d03474c 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -9,6 +9,31 @@ their own dashboards, move and resize them, and give each tile its own per-tile settings. Your module renders inside the tile and talks to the outside world only through the small API the shell hands it. +## Rules every module follows + +These are not style preferences — the admin page, the installer and the +people running the booth depend on them. + +1. **All settings live in the ProdDash admin page.** Server-wide settings go + in `configSchema`, per-tile settings in `instanceSchema`. Never a separate + setup page, wizard, config file the operator edits by hand, or environment + variable. +2. **Dependencies ship inside the module folder.** Zero dependencies is the + norm (the shell has none). Anything you genuinely need is vendored into + the module — or fetched by your own `init()` on first start, with the + admin health line saying what is happening. No `npm install`, no + installer, no "run this first". +3. **Everything the module does runs inside the module.** No helper scripts, + companion programs, daemons or services the operator has to start next to + ProdDash. If your feature needs a listener, a decoder or a worker, your + server entry owns it: start it in `init()`, stop it in `stop()`, report + it in `health()`. A child process the module itself spawns and supervises + is fine; a program someone launches by hand is not. +4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the + manifest) and **bump your own `version`** whenever you change the module. + The admin page refuses to install a module whose requirement the running + shell doesn't meet, and it offers updates by comparing versions. + ## The shape of a module A module is one folder dropped into `modules/`: @@ -21,9 +46,16 @@ modules// └── ... assets (css, images…) served statically at /modules// ``` -**Installation is exactly this:** copy the folder into `modules/`, restart -ProdDash (`node server.js`), then configure and enable it at `/admin`. No -shell code changes, ever. +**Installation is exactly this:** in the admin page, open **Available +modules** and press **Install** — the module is downloaded from the ProdDash +repo into the server's data directory (`/modules//`) and starts +immediately. Developing locally? Drop the folder into `modules/` (bundled +with the checkout) or into the data directory's `modules/` and restart. No +shell code changes, ever. Modules bundled with a checkout and modules +installed from the admin page live side by side; when both have the same id +the newer version runs. **Uninstall** in the admin page removes an installed +module's files (a bundled one is hidden instead, since its folder belongs to +the app); its settings are kept for a later reinstall. ## module.json @@ -32,6 +64,7 @@ shell code changes, ever. "id": "my-module", "name": "My Module", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "One line shown in the Add-tile picker and the admin page", "client": "client.js", "server": "server.js", @@ -52,7 +85,9 @@ shell code changes, ever. | --- | --- | --- | | `id` | yes | Must equal the folder name. Lowercase, dashes. | | `name` | yes | Human name shown on the tile header and in menus. | -| `version`, `description` | no | Shown in the admin page / picker. | +| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. | +| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. | +| `description` | no | Shown in the admin page / picker. | | `client` | yes | Client entry file, loaded as an ES module. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `style` | no | A stylesheet the shell injects once per module. | diff --git a/modules/clock/module.json b/modules/clock/module.json index 1f1639b..f95087f 100644 --- a/modules/clock/module.json +++ b/modules/clock/module.json @@ -2,6 +2,7 @@ "id": "clock", "name": "Clock", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "Big booth clock — the smallest possible ProdDash module", "client": "client.js", "style": "style.css", diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json index e35b928..95772dd 100644 --- a/modules/prodcom-transcript/module.json +++ b/modules/prodcom-transcript/module.json @@ -2,6 +2,7 @@ "id": "prodcom-transcript", "name": "ProdCom", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "Live ProdCom transcript stream", "client": "client.js", "server": "server.js", diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json index 5600866..e567176 100644 --- a/modules/propresenter-now-next/module.json +++ b/modules/propresenter-now-next/module.json @@ -2,6 +2,7 @@ "id": "propresenter-now-next", "name": "ProPresenter", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "What is live in ProPresenter and what comes next", "client": "client.js", "server": "server.js", diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 4818d69..2bddace 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -2,6 +2,7 @@ "id": "propresenter-timers", "name": "Timers", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "Live ProPresenter timers and LTC timecode", "client": "client.js", "server": "server.js", diff --git a/package.json b/package.json new file mode 100644 index 0000000..d30ba57 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "proddash", + "version": "1.1.0", + "private": true, + "description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", + "license": "UNLICENSED", + "engines": { "node": ">=18" }, + "scripts": { "start": "node server.js" } +} diff --git a/public/admin/admin.css b/public/admin/admin.css index 4a7c0e7..60e4d3d 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -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; } diff --git a/public/admin/admin.js b/public/admin/admin.js index 9f43bbc..a9b4fa3 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -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(); diff --git a/public/admin/index.html b/public/admin/index.html index c386ec5..ec8864c 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -11,7 +11,7 @@
- ProdDash Admin + ProdDash Admin ← Dashboard
@@ -27,6 +27,18 @@
+ + + +