diff --git a/CLAUDE.md b/CLAUDE.md index 56f7f2a..b5670ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,10 @@ Modular production dashboard web app for Waters Church, developed in this repo. The app lives at the repo root and is built: zero-dependency Node server (`server.js`, Node 18+), dashboard shell client in `public/`, admin page at -`/admin`, modules in `modules/`. [SPEC.md](SPEC.md) was the authority for the +`/admin`, modules in `modules/`. Runtime state (module config, named layouts) +is written to a per-machine data directory outside the checkout +(`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never +add code that writes runtime state into the repo folder. [SPEC.md](SPEC.md) was the authority for the initial build; [README.md](README.md) has run/configure/use instructions. ## Building a new module — read docs/MODULE-GUIDE.md first @@ -13,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 10334f1..c5c1ff6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)). that's where module servers proxy ProdCom, poll ProPresenter, and stream live updates over SSE, so browsers never talk to production gear directly (no CORS, and API keys never leave the server). -4. Stores server-wide module config and named layouts under `config/`. +4. Stores server-wide module config and named layouts in a per-machine data + directory outside the app folder, so updating ProdDash never loses them. Each browser arranges its own tiles (add, drag, resize, remove, per-tile settings); the layout autosaves to that browser's `localStorage`. Named @@ -47,28 +48,111 @@ enable/disable) live in the **admin page** — open **Admin** from the dashboard's top bar, or go to `/admin`. Saving re-initialises the module immediately; open tiles reconnect on their own. -Shell settings live in [config/proddash.json](config/proddash.json): +### Where settings are kept + +Everything you enter in the admin page (module connection settings, API keys, +enabled/disabled flags) and every named layout is written to a **data +directory on the machine running the server** — not into the app folder: + +| OS | Data directory | +| --- | --- | +| Windows | `%APPDATA%\ProdDash` (e.g. `C:\Users\\AppData\Roaming\ProdDash`) | +| macOS | `~/Library/Application Support/ProdDash` | +| Linux | `$XDG_CONFIG_HOME/proddash`, else `~/.config/proddash` | + +Because it lives outside the checkout, you can `git pull`, re-clone, or reset +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), `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: | Key | Meaning | | --- | --- | | `port` | Port ProdDash serves on (default `24500`) | | `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | -Environment variables `PORT` and `PRODDASH_PASSCODE` override the file. -`config/modules.json` (module config) and `config/layouts/` (named layouts) -are written by the app itself. +Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and +`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path +is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old +in-repo location). If the default directory can't be created, the server says +so and falls back to the app's `config/` folder. + +**Upgrading from an earlier version:** the first start copies an existing +`config/modules.json` and `config/layouts/` from the app folder into the data +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 fine (e.g. two transcripts filtered to different channels). -- Drag a tile by its **title bar** to move it; drag the **corner handle** - to resize; **✕** removes it. Tiles sit flush — when two share an edge, - hover the seam and a small grab pill appears; drag it to trade width - between the two tiles (macOS split-view style). +- Drag a tile by its **title bar** to move it; drag either **bottom + corner** to resize; **✕** removes it. Hover a tile and a small pill + appears on its **bottom edge** — drag it to change just the height. If + another tile sits directly below, the pill moves the shared edge instead, + trading rows between the two so the column keeps its total height. Tiles + sit flush — when two share a vertical edge, hover the seam and the same + kind of pill appears; drag it to trade width (macOS split-view style). +- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its + green tab brings it back, minus the Admin button), the occupied columns + stretch to the full width, and every column runs to the + bottom of the screen — the last tile in each column is stretched to end + there. A short tile like the clock is never stretched into a ribbon: under + another tile it slides to the bottom edge and the tile above grows into the + gap; alone in its columns it stays as it is. Nothing is saved — the layout + reverts when you leave fullscreen. A layout deeper than the screen keeps + its normal row height and scrolls. - A tile's actions (menus, text size, clear…) live in its **title bar**; the small **notch** on the bar's bottom edge hides/shows the whole bar - for a clean wall-display look. + for a clean wall-display look. The menu bar has the same kind of tab — + the green one under the ProdDash name, kept at the left so it never sits + on top of a tile's own tab. - The **gear** on a tile holds per-tile settings (filters, text size, display options) — these belong to this browser's layout, not the server. - The **grid icon** — save the current arrangement to the server under a @@ -153,7 +237,9 @@ ProdDash/ │ ├── clock/ │ ├── prodcom-transcript/ │ └── propresenter-now-next/ (carries its own copy of propresenter-core) -├── config/ proddash.json + state written by the app +├── 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 ├── tools/prodcom-mock.js mock ProdCom API for development ├── prodcom-listener/ reference fork — read-only, not served 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 2f2886f..04abf37 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. | @@ -81,8 +116,8 @@ stays selectable even when the route is down or the option has vanished. (See `propresenter-timers`' "LTC audio device" for a worked example.) `password` fields are admin-config only in practice: their values are stored -in `config/modules.json`, handed to your *server* entry, and **never sent to -browsers** — not in `/api/modules`, and never echoed back into the admin +in the server's `modules.json` (in the per-machine data directory, see the +README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin form. Anything upstream that needs the secret must go through your server routes. @@ -114,8 +149,9 @@ web address where protocol or path matter (a page to embed, a webhook). Endpoints belong in admin config, not per-tile settings — connections are server-wide by design. -Admin config lives in `config/modules.json` (written by the admin page — -your module never touches that file). Per-tile settings live inside each +Admin config lives in `modules.json` inside the server's data directory +(written by the admin page — your module never touches that file, and it +survives updates of the app folder). Per-tile settings live inside each browser's layout. ## Client entry (`client.js`) 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 9e4b7e6..abe5435 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -2,17 +2,27 @@ "id": "propresenter-timers", "name": "Timers", "version": "1.2.0", + "proddash": ">=1.2.0", "description": "Live ProPresenter timers and LTC timecode", "client": "client.js", "server": "server.js", "style": "style.css", - "minSize": { "w": 2, "h": 1 }, - "defaultSize": { "w": 4, "h": 2 }, + "minSize": { + "w": 2, + "h": 1 + }, + "defaultSize": { + "w": 4, + "h": 2 + }, "configSchema": { "propresenter": { "type": "endpoint", "label": "ProPresenter", - "default": { "host": "", "port": 1025 } + "default": { + "host": "", + "port": 1025 + } }, "password": { "type": "password", diff --git a/package.json b/package.json new file mode 100644 index 0000000..6875fc7 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "proddash", + "version": "1.2.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 2e54255..60e4d3d 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -35,6 +35,18 @@ body { overflow: auto; } margin-bottom: 14px; } +.admin-storage { margin-top: -6px; font-size: 12px; } +.admin-storage code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11.5px; + color: var(--text); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 5px; + padding: 1px 5px; + overflow-wrap: anywhere; +} + .admin-card { background: var(--panel); border: 1px solid var(--border); @@ -158,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 01c1914..e75a3e1 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(); } @@ -191,9 +206,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'); @@ -205,7 +254,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'); @@ -220,11 +269,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 { @@ -310,8 +361,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'; @@ -403,14 +456,209 @@ 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; + storageNote.hidden = false; + } modulesList.innerHTML = ''; liveEls.clear(); for (const mod of state.modules || []) { 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 3081a90..ec8864c 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -11,7 +11,7 @@
- ProdDash Admin + ProdDash Admin ← Dashboard
@@ -27,14 +27,40 @@
+ + + +