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 @@
+ + + +