Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10ac7d1e09 | ||
|
|
706cab2362 | ||
|
|
35ef54e248 | ||
|
|
22aa2f0e6d | ||
|
|
2daf8897a1 | ||
|
|
a3a68ebd73 | ||
|
|
483c353843 | ||
|
|
66f45609f0 | ||
|
|
1292ba33ba | ||
|
|
f80790e3a1 | ||
|
|
18ab8e92f1 | ||
|
|
a06bc921ce | ||
|
|
6412dd9524 |
@@ -3,7 +3,10 @@
|
|||||||
Modular production dashboard web app for Waters Church, developed in this repo.
|
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
|
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
|
(`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.
|
initial build; [README.md](README.md) has run/configure/use instructions.
|
||||||
|
|
||||||
## Building a new module — read docs/MODULE-GUIDE.md first
|
## 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:
|
title-bar controls). The rules that matter most:
|
||||||
|
|
||||||
- A module is one self-contained folder in `modules/` — **never modify the
|
- A module is one self-contained folder in `modules/` — **never modify the
|
||||||
shell** (`server.js`, `public/`) to add one. Installation = drop the folder,
|
shell** (`server.js`, `public/`) to add one. Installation = the admin page's
|
||||||
restart, configure in `/admin`.
|
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
|
- Browsers never call upstream services directly; the module's server routes
|
||||||
proxy everything (no CORS, secrets stay server-side).
|
proxy everything (no CORS, secrets stay server-side).
|
||||||
- Style with the shell's CSS variables only, scoped under
|
- Style with the shell's CSS variables only, scoped under
|
||||||
|
|||||||
@@ -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
|
that's where module servers proxy ProdCom, poll ProPresenter, and stream
|
||||||
live updates over SSE, so browsers never talk to production gear
|
live updates over SSE, so browsers never talk to production gear
|
||||||
directly (no CORS, and API keys never leave the server).
|
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
|
Each browser arranges its own tiles (add, drag, resize, remove, per-tile
|
||||||
settings); the layout autosaves to that browser's `localStorage`. Named
|
settings); the layout autosaves to that browser's `localStorage`. Named
|
||||||
@@ -47,28 +48,113 @@ enable/disable) live in the **admin page** — open **Admin** from the
|
|||||||
dashboard's top bar, or go to `/admin`. Saving re-initialises the module
|
dashboard's top bar, or go to `/admin`. Saving re-initialises the module
|
||||||
immediately; open tiles reconnect on their own.
|
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\<you>\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 |
|
| Key | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `port` | Port ProdDash serves on (default `24500`) |
|
| `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. |
|
| `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.
|
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
||||||
`config/modules.json` (module config) and `config/layouts/` (named layouts)
|
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
|
||||||
are written by the app itself.
|
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
|
## Using the dashboard
|
||||||
|
|
||||||
- **+** — add any enabled module; multiple tiles of the same module are
|
- **+** — add any enabled module; multiple tiles of the same module are
|
||||||
fine (e.g. two transcripts filtered to different channels).
|
fine (e.g. two transcripts filtered to different channels).
|
||||||
- Drag a tile by its **title bar** to move it; drag the **corner handle**
|
- Drag a tile by its **title bar** to move it; drag either **bottom
|
||||||
to resize; **✕** removes it. Tiles sit flush — when two share an edge,
|
corner** to resize; **✕** removes it. Hover a tile and a small pill
|
||||||
hover the seam and a small grab pill appears; drag it to trade width
|
appears on its **bottom edge** — drag it to change just the height. If
|
||||||
between the two tiles (macOS split-view style).
|
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 lip at the top-left 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**;
|
- 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
|
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's handle is the **ProdDash
|
||||||
|
name itself**, wrapped in a translucent green pill at the left — click it
|
||||||
|
to hide the bar; the slim green lip it leaves at the top-left edge brings
|
||||||
|
the bar back. It stays 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,
|
- The **gear** on a tile holds per-tile settings (filters, text size,
|
||||||
display options) — these belong to this browser's layout, not the server.
|
display options) — these belong to this browser's layout, not the server.
|
||||||
- The **grid icon** — save the current arrangement to the server under a
|
- The **grid icon** — save the current arrangement to the server under a
|
||||||
@@ -83,16 +169,16 @@ are written by the app itself.
|
|||||||
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
||||||
speech, channel colors, per-tile channel visibility toggles and group
|
speech, channel colors, per-tile channel visibility toggles and group
|
||||||
presets, timestamps, text size and jump-to-latest in the title bar.
|
presets, timestamps, text size and jump-to-latest in the title bar.
|
||||||
Admin config: ProdCom URL, API key.
|
Admin config: ProdCom server (host : port), API key.
|
||||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||||
Admin config: host, port, password.
|
Admin config: ProPresenter endpoint (host : port), password.
|
||||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||||
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
||||||
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
||||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||||
unmistakably red. Read-only — no start/stop controls. Admin config: host,
|
unmistakably red. Read-only — no start/stop controls. Admin config:
|
||||||
port, password.
|
ProPresenter endpoint (host : port), password.
|
||||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||||
reference for module authors.
|
reference for module authors.
|
||||||
|
|
||||||
@@ -130,7 +216,9 @@ ProdDash/
|
|||||||
│ ├── clock/
|
│ ├── clock/
|
||||||
│ ├── prodcom-transcript/
|
│ ├── prodcom-transcript/
|
||||||
│ └── propresenter-now-next/ (carries its own copy of propresenter-core)
|
│ └── 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
|
├── docs/MODULE-GUIDE.md how to build and install a module
|
||||||
├── tools/prodcom-mock.js mock ProdCom API for development
|
├── tools/prodcom-mock.js mock ProdCom API for development
|
||||||
├── prodcom-listener/ reference fork — read-only, not served
|
├── prodcom-listener/ reference fork — read-only, not served
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"port": 24500,
|
"port": 24500,
|
||||||
"adminPasscode": ""
|
"adminPasscode": "",
|
||||||
|
"repo": "michaelmcgary2008/ProdDash",
|
||||||
|
"branch": "main"
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-9
@@ -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
|
per-tile settings. Your module renders inside the tile and talks to the
|
||||||
outside world only through the small API the shell hands it.
|
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
|
## The shape of a module
|
||||||
|
|
||||||
A module is one folder dropped into `modules/`:
|
A module is one folder dropped into `modules/`:
|
||||||
@@ -21,9 +46,16 @@ modules/<id>/
|
|||||||
└── ... assets (css, images…) served statically at /modules/<id>/
|
└── ... assets (css, images…) served statically at /modules/<id>/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Installation is exactly this:** copy the folder into `modules/`, restart
|
**Installation is exactly this:** in the admin page, open **Available
|
||||||
ProdDash (`node server.js`), then configure and enable it at `/admin`. No
|
modules** and press **Install** — the module is downloaded from the ProdDash
|
||||||
shell code changes, ever.
|
repo into the server's data directory (`<data dir>/modules/<id>/`) 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
|
## module.json
|
||||||
|
|
||||||
@@ -32,6 +64,7 @@ shell code changes, ever.
|
|||||||
"id": "my-module",
|
"id": "my-module",
|
||||||
"name": "My Module",
|
"name": "My Module",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "One line shown in the Add-tile picker and the admin page",
|
"description": "One line shown in the Add-tile picker and the admin page",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
@@ -52,7 +85,9 @@ shell code changes, ever.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | yes | Must equal the folder name. Lowercase, dashes. |
|
| `id` | yes | Must equal the folder name. Lowercase, dashes. |
|
||||||
| `name` | yes | Human name shown on the tile header and in menus. |
|
| `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. |
|
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||||
| `style` | no | A stylesheet the shell injects once per module. |
|
| `style` | no | A stylesheet the shell injects once per module. |
|
||||||
@@ -64,7 +99,7 @@ shell code changes, ever.
|
|||||||
Both schemas map field names to specs:
|
Both schemas map field names to specs:
|
||||||
|
|
||||||
```
|
```
|
||||||
{ "type": "string" | "number" | "boolean" | "select" | "password",
|
{ "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint",
|
||||||
"label": "Shown next to the field",
|
"label": "Shown next to the field",
|
||||||
"default": <value>,
|
"default": <value>,
|
||||||
"options": [ { "value": "a", "label": "A" }, … ] // "select" only
|
"options": [ { "value": "a", "label": "A" }, … ] // "select" only
|
||||||
@@ -72,13 +107,42 @@ Both schemas map field names to specs:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`password` fields are admin-config only in practice: their values are stored
|
`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
|
in the server's `modules.json` (in the per-machine data directory, see the
|
||||||
browsers** — not in `/api/modules`, and never echoed back into the admin
|
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
|
form. Anything upstream that needs the secret must go through your server
|
||||||
routes.
|
routes.
|
||||||
|
|
||||||
Admin config lives in `config/modules.json` (written by the admin page —
|
### Connection settings: use `endpoint`
|
||||||
your module never touches that file). Per-tile settings live inside each
|
|
||||||
|
Anything that points at a device or service on the network — ProPresenter,
|
||||||
|
ProdCom, a camera, a mixer — must be **one `endpoint` field**, so every
|
||||||
|
module's connection is entered the same way. The admin page renders an
|
||||||
|
endpoint as a single "Host / IP : Port" control; the value is stored and
|
||||||
|
handed to your server as an object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"propresenter": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"label": "ProPresenter",
|
||||||
|
"default": { "host": "", "port": 1025 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// in your server entry
|
||||||
|
const { host, port } = config.propresenter;
|
||||||
|
const base = `http://${host}:${port}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** split host and port into separate `string`/`number` fields, and
|
||||||
|
do **not** ask for a full `http://…` URL unless the setting genuinely is a
|
||||||
|
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 `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.
|
browser's layout.
|
||||||
|
|
||||||
## Client entry (`client.js`)
|
## Client entry (`client.js`)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"id": "clock",
|
"id": "clock",
|
||||||
"name": "Clock",
|
"name": "Clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "Big booth clock — the smallest possible ProdDash module",
|
"description": "Big booth clock — the smallest possible ProdDash module",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
|
|||||||
@@ -296,11 +296,17 @@ export default function create({ root, moduleApi }) {
|
|||||||
const text = document.createElement('span');
|
const text = document.createElement('span');
|
||||||
text.className = 'text';
|
text.className = 'text';
|
||||||
|
|
||||||
|
// channel name with its timestamp to the right; the clock button
|
||||||
|
// only toggles the timestamp's visibility
|
||||||
|
const chLine = document.createElement('span');
|
||||||
|
chLine.className = 'channel-line';
|
||||||
|
chLine.append(ch, time);
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'body';
|
body.className = 'body';
|
||||||
body.append(ch, text);
|
body.append(chLine, text);
|
||||||
|
|
||||||
el.append(time, avatar, body);
|
el.append(avatar, body);
|
||||||
entryEls.set(id, el);
|
entryEls.set(id, el);
|
||||||
applyFilter(el);
|
applyFilter(el);
|
||||||
entriesEl.appendChild(el);
|
entriesEl.appendChild(el);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript stream",
|
"description": "Live ProdCom transcript stream",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
@@ -9,10 +10,10 @@
|
|||||||
"minSize": { "w": 3, "h": 2 },
|
"minSize": { "w": 3, "h": 2 },
|
||||||
"defaultSize": { "w": 4, "h": 4 },
|
"defaultSize": { "w": 4, "h": 4 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"prodcomUrl": {
|
"prodcom": {
|
||||||
"type": "string",
|
"type": "endpoint",
|
||||||
"label": "ProdCom URL",
|
"label": "ProdCom server",
|
||||||
"default": "http://10.3.11.152:24480"
|
"default": { "host": "10.3.11.152", "port": 24480 }
|
||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -71,11 +71,22 @@ function proxy(req, res, target, apiKey) {
|
|||||||
req.pipe(preq);
|
req.pipe(preq);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
|
||||||
|
before the endpoint field existed) can only still be present until the
|
||||||
|
admin form is re-saved, so when it exists it wins over the endpoint's
|
||||||
|
schema default. */
|
||||||
|
function resolveTarget(config) {
|
||||||
|
if (config.prodcomUrl) return new URL(config.prodcomUrl);
|
||||||
|
const ep = config.prodcom;
|
||||||
|
if (ep && ep.host && Number(ep.port)) return new URL(`http://${ep.host}:${Number(ep.port)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config }) {
|
init({ config }) {
|
||||||
health = config.prodcomUrl
|
health = resolveTarget(config)
|
||||||
? { status: 'connecting', message: 'No traffic yet' }
|
? { status: 'connecting', message: 'No traffic yet' }
|
||||||
: { status: 'error', message: 'No ProdCom URL configured' };
|
: { status: 'error', message: 'No ProdCom server configured' };
|
||||||
return {
|
return {
|
||||||
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
||||||
health: () => health,
|
health: () => health,
|
||||||
@@ -83,8 +94,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
routes({ config, log }) {
|
routes({ config, log }) {
|
||||||
// An invalid URL throws here, which surfaces as a mount error in /admin.
|
// An invalid host/URL throws here, which surfaces as a mount error in /admin.
|
||||||
const target = new URL(config.prodcomUrl || 'http://10.3.11.152:24480');
|
const target = resolveTarget(config) || new URL('http://10.3.11.152:24480');
|
||||||
log(`proxying /prodcom/* → ${target.origin}`);
|
log(`proxying /prodcom/* → ${target.origin}`);
|
||||||
return {
|
return {
|
||||||
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
||||||
|
|||||||
@@ -82,15 +82,24 @@
|
|||||||
to { opacity: 1; transform: none; }
|
to { opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .channel-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* timestamp sits to the right of the channel name; the clock button only
|
||||||
|
toggles its visibility */
|
||||||
[data-module="prodcom-transcript"] .pt-entry .time {
|
[data-module="prodcom-transcript"] .pt-entry .time {
|
||||||
display: none;
|
display: none;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.72em;
|
font-size: 0.72em;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
flex: none;
|
flex: none;
|
||||||
padding-top: 3px;
|
|
||||||
}
|
}
|
||||||
[data-module="prodcom-transcript"] .show-times .pt-entry .time { display: block; }
|
[data-module="prodcom-transcript"] .show-times .pt-entry .time { display: inline; }
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-entry .avatar {
|
[data-module="prodcom-transcript"] .pt-entry .avatar {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -114,7 +123,9 @@
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-bottom: 4px;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-entry .text {
|
[data-module="prodcom-transcript"] .pt-entry .text {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"id": "propresenter-now-next",
|
"id": "propresenter-now-next",
|
||||||
"name": "ProPresenter",
|
"name": "ProPresenter",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "What is live in ProPresenter and what comes next",
|
"description": "What is live in ProPresenter and what comes next",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
@@ -9,15 +10,10 @@
|
|||||||
"minSize": { "w": 3, "h": 3 },
|
"minSize": { "w": 3, "h": 3 },
|
||||||
"defaultSize": { "w": 4, "h": 5 },
|
"defaultSize": { "w": 4, "h": 5 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"host": {
|
"propresenter": {
|
||||||
"type": "string",
|
"type": "endpoint",
|
||||||
"label": "ProPresenter host / IP",
|
"label": "ProPresenter",
|
||||||
"default": ""
|
"default": { "host": "", "port": 1025 }
|
||||||
},
|
|
||||||
"port": {
|
|
||||||
"type": "number",
|
|
||||||
"label": "ProPresenter network port",
|
|
||||||
"default": 1025
|
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ let current = null;
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log }) {
|
||||||
const streams = new Set();
|
const streams = new Set();
|
||||||
const enabled = Boolean(config.host && Number(config.port));
|
// Endpoint config. Legacy flat host/port keys (saved before the endpoint
|
||||||
|
// field existed) can only still be present until the admin form is
|
||||||
|
// re-saved, so when they exist they win over the endpoint's default.
|
||||||
|
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||||
|
const host = String(config.host ?? ep.host ?? '');
|
||||||
|
const port = Number(config.port ?? ep.port) || 1025;
|
||||||
|
const enabled = Boolean(host && port);
|
||||||
let latest = clearedViewModel();
|
let latest = clearedViewModel();
|
||||||
let lastFrame = '';
|
let lastFrame = '';
|
||||||
|
|
||||||
@@ -54,14 +60,14 @@ module.exports = {
|
|||||||
|
|
||||||
client.configure({
|
client.configure({
|
||||||
enabled,
|
enabled,
|
||||||
host: String(config.host || ''),
|
host,
|
||||||
port: Number(config.port) || 1025,
|
port,
|
||||||
password: String(config.password || ''),
|
password: String(config.password || ''),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
client.start();
|
client.start();
|
||||||
log(`following ProPresenter at ${config.host}:${config.port}`);
|
log(`following ProPresenter at ${host}:${port}`);
|
||||||
} else {
|
} else {
|
||||||
log('no ProPresenter host configured — set one in /admin');
|
log('no ProPresenter host configured — set one in /admin');
|
||||||
}
|
}
|
||||||
@@ -91,7 +97,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
||||||
if (latest.reachable) return { status: 'ok', message: `Following ${config.host}:${config.port}` };
|
if (latest.reachable) return { status: 'ok', message: `Following ${host}:${port}` };
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: latest.lastError
|
message: latest.lastError
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProPresenter timers and LTC timecode",
|
"description": "Live ProPresenter timers and LTC timecode",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
@@ -9,15 +10,10 @@
|
|||||||
"minSize": { "w": 2, "h": 1 },
|
"minSize": { "w": 2, "h": 1 },
|
||||||
"defaultSize": { "w": 4, "h": 2 },
|
"defaultSize": { "w": 4, "h": 2 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"host": {
|
"propresenter": {
|
||||||
"type": "string",
|
"type": "endpoint",
|
||||||
"label": "ProPresenter host / IP",
|
"label": "ProPresenter",
|
||||||
"default": ""
|
"default": { "host": "", "port": 1025 }
|
||||||
},
|
|
||||||
"port": {
|
|
||||||
"type": "number",
|
|
||||||
"label": "ProPresenter network port",
|
|
||||||
"default": 1025
|
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ function clearedState(enabled) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log }) {
|
||||||
const streams = new Set();
|
const streams = new Set();
|
||||||
const host = String(config.host || '');
|
// Endpoint config. Legacy flat host/port keys (saved before the endpoint
|
||||||
const port = Number(config.port) || 1025;
|
// field existed) can only still be present until the admin form is
|
||||||
|
// re-saved, so when they exist they win over the endpoint's default.
|
||||||
|
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||||
|
const host = String(config.host ?? ep.host ?? '');
|
||||||
|
const port = Number(config.port ?? ep.port) || 1025;
|
||||||
const password = String(config.password || '');
|
const password = String(config.password || '');
|
||||||
const enabled = Boolean(host && port);
|
const enabled = Boolean(host && port);
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -35,6 +35,18 @@ body { overflow: auto; }
|
|||||||
margin-bottom: 14px;
|
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 {
|
.admin-card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -93,6 +105,12 @@ body { overflow: auto; }
|
|||||||
|
|
||||||
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
.module-form .field { max-width: 420px; }
|
.module-form .field { max-width: 420px; }
|
||||||
|
|
||||||
|
/* endpoint fields: every module's upstream is entered the same way */
|
||||||
|
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.endpoint-row .ep-host { flex: 1; min-width: 0; }
|
||||||
|
.endpoint-row .ep-port { width: 92px; flex: none; }
|
||||||
|
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
|
||||||
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
||||||
|
|
||||||
.module-actions {
|
.module-actions {
|
||||||
@@ -152,3 +170,68 @@ body { overflow: auto; }
|
|||||||
.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; }
|
.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; }
|
||||||
.layout-row .meta { color: var(--muted); font-size: 11px; }
|
.layout-row .meta { color: var(--muted); font-size: 11px; }
|
||||||
.layouts-empty { color: var(--muted); font-size: 13px; }
|
.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; }
|
||||||
|
|||||||
+278
-4
@@ -12,6 +12,19 @@ const modulesList = document.getElementById('modules-list');
|
|||||||
const layoutsSection = document.getElementById('layouts-section');
|
const layoutsSection = document.getElementById('layouts-section');
|
||||||
const layoutsList = document.getElementById('layouts-list');
|
const layoutsList = document.getElementById('layouts-list');
|
||||||
const toastEl = document.getElementById('toast');
|
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;
|
let toastTimer = null;
|
||||||
function toast(msg, isError = false) {
|
function toast(msg, isError = false) {
|
||||||
@@ -42,7 +55,9 @@ async function api(path, opts = {}) {
|
|||||||
function showLogin() {
|
function showLogin() {
|
||||||
loginCard.hidden = false;
|
loginCard.hidden = false;
|
||||||
modulesSection.hidden = true;
|
modulesSection.hidden = true;
|
||||||
|
availableSection.hidden = true;
|
||||||
layoutsSection.hidden = true;
|
layoutsSection.hidden = true;
|
||||||
|
updateCard.hidden = true;
|
||||||
passcodeInput.focus();
|
passcodeInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +111,32 @@ function fieldFor(key, spec, value, passwordSet) {
|
|||||||
input.checked = Boolean(value);
|
input.checked = Boolean(value);
|
||||||
field.append(input, document.createTextNode(label));
|
field.append(input, document.createTextNode(label));
|
||||||
read = () => input.checked;
|
read = () => input.checked;
|
||||||
|
} else if (type === 'endpoint') {
|
||||||
|
// One consistent "Host / IP : Port" control for every module's upstream.
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const row = document.createElement('span');
|
||||||
|
row.className = 'endpoint-row';
|
||||||
|
const host = document.createElement('input');
|
||||||
|
host.type = 'text';
|
||||||
|
host.className = 'ep-host';
|
||||||
|
host.placeholder = 'Host / IP';
|
||||||
|
host.autocomplete = 'off';
|
||||||
|
host.spellcheck = false;
|
||||||
|
host.value = value?.host ? String(value.host) : '';
|
||||||
|
const sep = document.createElement('span');
|
||||||
|
sep.className = 'ep-sep';
|
||||||
|
sep.textContent = ':';
|
||||||
|
const port = document.createElement('input');
|
||||||
|
port.type = 'number';
|
||||||
|
port.className = 'ep-port';
|
||||||
|
port.placeholder = 'Port';
|
||||||
|
port.min = '1';
|
||||||
|
port.max = '65535';
|
||||||
|
port.value = value?.port ? String(value.port) : '';
|
||||||
|
row.append(host, sep, port);
|
||||||
|
field.append(span, row);
|
||||||
|
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
|
||||||
} else if (type === 'select' && Array.isArray(spec.options)) {
|
} else if (type === 'select' && Array.isArray(spec.options)) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
@@ -144,9 +185,43 @@ function buildModuleCard(mod) {
|
|||||||
version.className = 'module-version';
|
version.className = 'module-version';
|
||||||
version.textContent = mod.version ? 'v' + mod.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');
|
const spacer = document.createElement('span');
|
||||||
spacer.className = 'spacer';
|
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');
|
const toggleLabel = document.createElement('label');
|
||||||
toggleLabel.className = 'switch';
|
toggleLabel.className = 'switch';
|
||||||
const toggle = document.createElement('input');
|
const toggle = document.createElement('input');
|
||||||
@@ -158,7 +233,7 @@ function buildModuleCard(mod) {
|
|||||||
toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
||||||
toggleLabel.append(toggle, track, toggleText);
|
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);
|
card.appendChild(head);
|
||||||
|
|
||||||
const desc = document.createElement('div');
|
const desc = document.createElement('div');
|
||||||
@@ -173,11 +248,13 @@ function buildModuleCard(mod) {
|
|||||||
|
|
||||||
const mountError = document.createElement('div');
|
const mountError = document.createElement('div');
|
||||||
mountError.className = 'module-mount-error';
|
mountError.className = 'module-mount-error';
|
||||||
mountError.hidden = !mod.mountError;
|
mountError.hidden = !mod.mountError && !mod.incompatible;
|
||||||
mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
mountError.textContent = mod.incompatible
|
||||||
|
? `Not loaded — ${mod.incompatible}. Update ProdDash from the banner above.`
|
||||||
|
: (mod.mountError ? `Failed to start: ${mod.mountError}` : '');
|
||||||
card.appendChild(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 () => {
|
toggle.addEventListener('change', async () => {
|
||||||
try {
|
try {
|
||||||
@@ -263,8 +340,10 @@ async function refreshStatus() {
|
|||||||
els.dot.className = 'dot ' + healthClass(mod);
|
els.dot.className = 'dot ' + healthClass(mod);
|
||||||
els.healthEl.textContent = healthText(mod);
|
els.healthEl.textContent = healthText(mod);
|
||||||
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
||||||
|
if (!mod.incompatible) {
|
||||||
els.mountError.hidden = !mod.mountError;
|
els.mountError.hidden = !mod.mountError;
|
||||||
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
||||||
|
}
|
||||||
if (document.activeElement !== els.toggle) {
|
if (document.activeElement !== els.toggle) {
|
||||||
els.toggle.checked = mod.enabled;
|
els.toggle.checked = mod.enabled;
|
||||||
els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
||||||
@@ -356,14 +435,209 @@ async function loadState() {
|
|||||||
}
|
}
|
||||||
loginCard.hidden = true;
|
loginCard.hidden = true;
|
||||||
modulesSection.hidden = false;
|
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 = '';
|
modulesList.innerHTML = '';
|
||||||
liveEls.clear();
|
liveEls.clear();
|
||||||
for (const mod of state.modules || []) {
|
for (const mod of state.modules || []) {
|
||||||
modulesList.appendChild(buildModuleCard(mod));
|
modulesList.appendChild(buildModuleCard(mod));
|
||||||
}
|
}
|
||||||
loadLayouts();
|
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(() => {
|
setInterval(() => {
|
||||||
if (modulesSection.hidden) return;
|
if (modulesSection.hidden) return;
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
|
|||||||
+27
-1
@@ -11,7 +11,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<span id="brand">Prod<span class="accent">Dash</span> <span class="admin-tag">Admin</span></span>
|
<span id="brand">Prod<span class="accent">Dash</span> <span class="admin-tag">Admin</span> <span class="admin-version" id="shell-version"></span></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a class="tb-btn" href="/">← Dashboard</a>
|
<a class="tb-btn" href="/">← Dashboard</a>
|
||||||
</header>
|
</header>
|
||||||
@@ -27,14 +27,40 @@
|
|||||||
<div class="admin-status" id="login-status"></div>
|
<div class="admin-status" id="login-status"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-card update-card" id="update-card" hidden>
|
||||||
|
<div class="update-head">
|
||||||
|
<span class="dot connecting"></span>
|
||||||
|
<strong id="update-title">Update available</strong>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<a class="btn small" id="update-changes" href="#" target="_blank" rel="noopener">What changed</a>
|
||||||
|
<button class="btn small primary" id="update-btn">Update now</button>
|
||||||
|
</div>
|
||||||
|
<p class="admin-copy" id="update-copy"></p>
|
||||||
|
<div class="admin-status" id="update-status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="modules-section" hidden>
|
<section id="modules-section" hidden>
|
||||||
<h1 class="admin-h1">Modules</h1>
|
<h1 class="admin-h1">Modules</h1>
|
||||||
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
|
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
|
||||||
module immediately — open tiles reconnect on their own. Per-tile options (filters, text size)
|
module immediately — open tiles reconnect on their own. Per-tile options (filters, text size)
|
||||||
live in each tile's gear menu instead.</p>
|
live in each tile's gear menu instead.</p>
|
||||||
|
<p class="admin-copy admin-storage" id="storage-note" hidden>Saved on the server at
|
||||||
|
<code id="storage-path"></code> — outside the app folder, so updating ProdDash keeps them.</p>
|
||||||
<div id="modules-list"></div>
|
<div id="modules-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="available-section" hidden>
|
||||||
|
<h1 class="admin-h1">Available modules</h1>
|
||||||
|
<p class="admin-copy">Modules in the ProdDash repo that aren't installed here. Installing downloads the module
|
||||||
|
into the server's data directory — no restart, nothing written into the app folder. A module that needs a
|
||||||
|
newer ProdDash can't be installed until the shell is updated.</p>
|
||||||
|
<div class="catalog-toolbar">
|
||||||
|
<button class="btn small" id="catalog-refresh">Check again</button>
|
||||||
|
<span class="admin-status" id="catalog-status"></span>
|
||||||
|
</div>
|
||||||
|
<div id="available-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="layouts-section" hidden>
|
<section id="layouts-section" hidden>
|
||||||
<h1 class="admin-h1">Named layouts</h1>
|
<h1 class="admin-h1">Named layouts</h1>
|
||||||
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
|
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
|
||||||
|
|||||||
+12
-2
@@ -15,10 +15,18 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<span id="brand">Prod<span class="accent">Dash</span></span>
|
<!-- invisible twin of the logo: holds its place in the bar; the real logo is the handle below -->
|
||||||
|
<span id="brand" class="brand-spacer" aria-hidden="true">Prod<span class="accent">Dash</span></span>
|
||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
||||||
|
<button class="tb-btn icon" id="fs-btn" title="Fullscreen — auto-fits the layout to the screen" aria-label="Fullscreen">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
|
||||||
|
<path d="M4 15v4a1 1 0 0 0 1 1h4"/><path d="M20 15v4a1 1 0 0 1-1 1h-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="dropdown" id="add-dd">
|
<div class="dropdown" id="add-dd">
|
||||||
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
|
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
|
||||||
<div class="menu" id="add-menu" hidden></div>
|
<div class="menu" id="add-menu" hidden></div>
|
||||||
@@ -35,7 +43,7 @@
|
|||||||
<div class="menu" id="layout-menu" hidden></div>
|
<div class="menu" id="layout-menu" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="tb-btn" href="/admin/" title="Configure modules (server-wide)">
|
<a class="tb-btn" id="admin-link" href="/admin/" title="Configure modules (server-wide)">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
|
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -43,6 +51,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar">Prod<span class="accent">Dash</span></button>
|
||||||
|
|
||||||
<main id="gridwrap">
|
<main id="gridwrap">
|
||||||
<div id="grid">
|
<div id="grid">
|
||||||
<div id="empty-state">No tiles yet — use “+ Add tile” to build this dashboard.<br>
|
<div id="empty-state">No tiles yet — use “+ Add tile” to build this dashboard.<br>
|
||||||
|
|||||||
+281
-6
@@ -273,6 +273,15 @@ function buildTile(tile) {
|
|||||||
resize.className = 'tile-resize';
|
resize.className = 'tile-resize';
|
||||||
resize.title = 'Drag to resize';
|
resize.title = 'Drag to resize';
|
||||||
|
|
||||||
|
const resizeLeft = document.createElement('div');
|
||||||
|
resizeLeft.className = 'tile-resize-left';
|
||||||
|
resizeLeft.title = 'Drag to resize';
|
||||||
|
|
||||||
|
// pill on the bottom edge: moves that edge (trading rows with tiles below)
|
||||||
|
const resizeBottom = document.createElement('div');
|
||||||
|
resizeBottom.className = 'tile-resize-bottom';
|
||||||
|
resizeBottom.title = 'Drag to adjust height';
|
||||||
|
|
||||||
// notch on the title bar's bottom edge: hides/shows the bar
|
// notch on the title bar's bottom edge: hides/shows the bar
|
||||||
const notch = document.createElement('button');
|
const notch = document.createElement('button');
|
||||||
notch.className = 'tile-notch';
|
notch.className = 'tile-notch';
|
||||||
@@ -284,7 +293,7 @@ function buildTile(tile) {
|
|||||||
saveLayout();
|
saveLayout();
|
||||||
});
|
});
|
||||||
|
|
||||||
el.append(head, body, resize, notch);
|
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
|
||||||
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
|
|
||||||
@@ -293,7 +302,9 @@ function buildTile(tile) {
|
|||||||
applyRect(tile);
|
applyRect(tile);
|
||||||
|
|
||||||
wireDrag(tile, el, head);
|
wireDrag(tile, el, head);
|
||||||
wireResize(tile, el, resize);
|
wireResize(tile, el, resize, false);
|
||||||
|
wireResize(tile, el, resizeLeft, true);
|
||||||
|
wireBottomResize(tile, el, resizeBottom);
|
||||||
|
|
||||||
mountModule(tile);
|
mountModule(tile);
|
||||||
return entry;
|
return entry;
|
||||||
@@ -309,6 +320,7 @@ function wireDrag(tile, el, handle) {
|
|||||||
handle.addEventListener('pointerdown', (ev) => {
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return;
|
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return;
|
||||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
closeSettingsPopovers();
|
closeSettingsPopovers();
|
||||||
handle.setPointerCapture(ev.pointerId);
|
handle.setPointerCapture(ev.pointerId);
|
||||||
@@ -356,17 +368,19 @@ function wireDrag(tile, el, handle) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── drag to resize ─────────────────────────────────────────────────── */
|
/* ── drag to resize (bottom-right and bottom-left corners) ──────────── */
|
||||||
|
|
||||||
function wireResize(tile, el, handle) {
|
function wireResize(tile, el, handle, fromLeft) {
|
||||||
handle.addEventListener('pointerdown', (ev) => {
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
handle.setPointerCapture(ev.pointerId);
|
handle.setPointerCapture(ev.pointerId);
|
||||||
const m = cellMetrics();
|
const m = cellMetrics();
|
||||||
const sx = ev.clientX;
|
const sx = ev.clientX;
|
||||||
const sy = ev.clientY;
|
const sy = ev.clientY;
|
||||||
|
const startX = tile.x;
|
||||||
const startW = tile.w;
|
const startW = tile.w;
|
||||||
const startH = tile.h;
|
const startH = tile.h;
|
||||||
const min = minSizeOf(tile);
|
const min = minSizeOf(tile);
|
||||||
@@ -377,13 +391,26 @@ function wireResize(tile, el, handle) {
|
|||||||
const onMove = (e) => {
|
const onMove = (e) => {
|
||||||
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
|
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
|
||||||
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
|
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
|
||||||
const cand = {
|
let cand;
|
||||||
|
if (fromLeft) {
|
||||||
|
// the left edge moves; the right edge stays put
|
||||||
|
const newX = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
|
||||||
|
cand = {
|
||||||
|
x: newX,
|
||||||
|
y: tile.y,
|
||||||
|
w: startX + startW - newX,
|
||||||
|
h: Math.max(min.h, startH + dRows),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
cand = {
|
||||||
x: tile.x,
|
x: tile.x,
|
||||||
y: tile.y,
|
y: tile.y,
|
||||||
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
|
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
|
||||||
h: Math.max(min.h, startH + dRows),
|
h: Math.max(min.h, startH + dRows),
|
||||||
};
|
};
|
||||||
if ((cand.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) {
|
}
|
||||||
|
if ((cand.x !== tile.x || cand.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) {
|
||||||
|
tile.x = cand.x;
|
||||||
tile.w = cand.w;
|
tile.w = cand.w;
|
||||||
tile.h = cand.h;
|
tile.h = cand.h;
|
||||||
applyRect(tile);
|
applyRect(tile);
|
||||||
@@ -408,6 +435,79 @@ function wireResize(tile, el, handle) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── drag the bottom edge: height only ──────────────────────────────── */
|
||||||
|
|
||||||
|
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
|
||||||
|
directly below it (sharing the edge), the drag trades rows with them —
|
||||||
|
the column's total height stays the same, like the seam pill trades
|
||||||
|
columns. With nothing directly below, it simply changes this tile's
|
||||||
|
height. */
|
||||||
|
|
||||||
|
function tilesDirectlyBelow(tile) {
|
||||||
|
return tiles.filter((t) => t !== tile
|
||||||
|
&& t.y === tile.y + tile.h
|
||||||
|
&& t.x < tile.x + tile.w && tile.x < t.x + t.w);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireBottomResize(tile, el, handle) {
|
||||||
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
closeSettingsPopovers();
|
||||||
|
handle.setPointerCapture(ev.pointerId);
|
||||||
|
const m = cellMetrics();
|
||||||
|
const sy = ev.clientY;
|
||||||
|
const startH = tile.h;
|
||||||
|
const below = tilesDirectlyBelow(tile).map((t) => ({ t, bottom: t.y + t.h }));
|
||||||
|
const group = [tile.id, ...below.map((b) => b.t.id)];
|
||||||
|
const minH = minSizeOf(tile).h;
|
||||||
|
// the edge can rise until this tile hits its minimum, and drop until the
|
||||||
|
// shortest neighbour below hits its own
|
||||||
|
const maxH = below.length
|
||||||
|
? Math.min(...below.map((b) => b.bottom - minSizeOf(b.t).h)) - tile.y
|
||||||
|
: Infinity;
|
||||||
|
el.classList.add('resizing');
|
||||||
|
grid.classList.add('no-dividers');
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
|
||||||
|
const h = Math.max(minH, Math.min(maxH, startH + dRows));
|
||||||
|
if (h === tile.h || h > maxH) return;
|
||||||
|
const edge = tile.y + h;
|
||||||
|
const candSelf = { x: tile.x, y: tile.y, w: tile.w, h };
|
||||||
|
const candBelow = below.map((b) => ({ x: b.t.x, y: edge, w: b.t.w, h: b.bottom - edge }));
|
||||||
|
if (!isFreeExcept(candSelf, group) || candBelow.some((c) => !isFreeExcept(c, group))) return;
|
||||||
|
tile.h = h;
|
||||||
|
applyRect(tile);
|
||||||
|
notifyResize(tile);
|
||||||
|
below.forEach((b, i) => {
|
||||||
|
b.t.y = candBelow[i].y;
|
||||||
|
b.t.h = candBelow[i].h;
|
||||||
|
applyRect(b.t);
|
||||||
|
notifyResize(b.t);
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
handle.removeEventListener('pointermove', onMove);
|
||||||
|
handle.removeEventListener('pointerup', onUp);
|
||||||
|
handle.removeEventListener('pointercancel', onUp);
|
||||||
|
el.classList.remove('resizing');
|
||||||
|
grid.classList.remove('no-dividers');
|
||||||
|
if (changed) {
|
||||||
|
saveLayout();
|
||||||
|
rebuildDividers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', onMove);
|
||||||
|
handle.addEventListener('pointerup', onUp);
|
||||||
|
handle.addEventListener('pointercancel', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function notifyResize(tile) {
|
function notifyResize(tile) {
|
||||||
const entry = tileEls.get(tile.id);
|
const entry = tileEls.get(tile.id);
|
||||||
try {
|
try {
|
||||||
@@ -452,6 +552,7 @@ function buildDivider(L, R, top, bottom) {
|
|||||||
|
|
||||||
el.addEventListener('pointerdown', (ev) => {
|
el.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
closeSettingsPopovers();
|
closeSettingsPopovers();
|
||||||
el.setPointerCapture(ev.pointerId);
|
el.setPointerCapture(ev.pointerId);
|
||||||
@@ -915,6 +1016,7 @@ function renderLayout(list) {
|
|||||||
for (const tile of tiles) buildTile(tile);
|
for (const tile of tiles) buildTile(tile);
|
||||||
refreshEmptyState();
|
refreshEmptyState();
|
||||||
rebuildDividers();
|
rebuildDividers();
|
||||||
|
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── menus ──────────────────────────────────────────────────────────── */
|
/* ── menus ──────────────────────────────────────────────────────────── */
|
||||||
@@ -1026,6 +1128,179 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => {
|
|||||||
}, { danger: true, sub: 'Remove every tile from this browser' }));
|
}, { danger: true, sub: 'Remove every tile from this browser' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
|
||||||
|
|
||||||
|
const topbar = document.getElementById('topbar');
|
||||||
|
const topbarNotch = document.getElementById('topbar-notch');
|
||||||
|
const LS_MENU = 'proddash:menuHidden';
|
||||||
|
|
||||||
|
/** Keep the notch glued to the menubar's bottom edge, whatever its height. */
|
||||||
|
const topbarSizer = new ResizeObserver(() => {
|
||||||
|
document.documentElement.style.setProperty('--topbar-h', topbar.offsetHeight + 'px');
|
||||||
|
});
|
||||||
|
topbarSizer.observe(topbar);
|
||||||
|
|
||||||
|
function setMenuHidden(hidden, { persist = true } = {}) {
|
||||||
|
document.body.classList.toggle('menu-hidden', hidden);
|
||||||
|
if (persist) {
|
||||||
|
try { localStorage.setItem(LS_MENU, hidden ? '1' : '0'); } catch { /* fine */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topbarNotch.addEventListener('click', () => {
|
||||||
|
setMenuHidden(!document.body.classList.contains('menu-hidden'));
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
|
||||||
|
} catch { /* fine */ }
|
||||||
|
|
||||||
|
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
||||||
|
|
||||||
|
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
||||||
|
to the full width, and every column runs to the bottom of the screen: the
|
||||||
|
bottom-most tile of each column stretches to end exactly there. A small
|
||||||
|
tile (the clock) is never stretched into a ribbon — with a tile above it,
|
||||||
|
it drops to the bottom edge and the tile above grows into the gap; alone
|
||||||
|
in its columns, it stays exactly as it is. When nothing extends below the
|
||||||
|
viewport, scrolling is disabled. All of it is visual-only — the saved
|
||||||
|
layout is untouched and everything reverts on exit. */
|
||||||
|
|
||||||
|
const fsBtn = document.getElementById('fs-btn');
|
||||||
|
/** Tiles this short (the clock) are moved, never stretched, in fullscreen. */
|
||||||
|
const FS_SMALL_ROWS = 2;
|
||||||
|
let menuHiddenBeforeFs = false;
|
||||||
|
|
||||||
|
function colsOverlap(a, b) {
|
||||||
|
return a.x < b.x + b.w && b.x < a.x + a.w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fsSmall(tile) {
|
||||||
|
return tile.h <= FS_SMALL_ROWS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fsAloneInColumns(tile) {
|
||||||
|
return !tiles.some((t) => t !== tile && colsOverlap(t, tile));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fsBottomMost(tile) {
|
||||||
|
return !tiles.some((t) => t !== tile && colsOverlap(t, tile) && t.y >= tile.y + tile.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fullscreen row placement for every tile, given R rows on screen:
|
||||||
|
* { id -> { y, h } }. Only bottom-most tiles change (stretch, or drop when
|
||||||
|
* small), plus the tile directly above a dropped small one, which grows
|
||||||
|
* into the space it left — stopping at anything else below it.
|
||||||
|
*/
|
||||||
|
function fullscreenRows(R) {
|
||||||
|
const rows = new Map(tiles.map((t) => [t.id, { y: t.y, h: t.h }]));
|
||||||
|
const dropped = [];
|
||||||
|
for (const tile of tiles) {
|
||||||
|
if (!fsBottomMost(tile)) continue;
|
||||||
|
if (!fsSmall(tile)) {
|
||||||
|
rows.get(tile.id).h = R - tile.y;
|
||||||
|
} else if (!fsAloneInColumns(tile) && R - tile.h > tile.y) {
|
||||||
|
rows.get(tile.id).y = R - tile.h;
|
||||||
|
dropped.push(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const small of dropped) {
|
||||||
|
const gapTop = rows.get(small.id).y;
|
||||||
|
for (const above of tiles) {
|
||||||
|
if (above === small || fsSmall(above) || !colsOverlap(above, small)) continue;
|
||||||
|
if (above.y + above.h !== small.y) continue; // only a tile touching it
|
||||||
|
let limit = gapTop;
|
||||||
|
for (const other of tiles) {
|
||||||
|
if (other === above || other === small || !colsOverlap(above, other)) continue;
|
||||||
|
const oy = rows.get(other.id).y;
|
||||||
|
if (oy >= above.y + above.h) limit = Math.min(limit, oy);
|
||||||
|
}
|
||||||
|
rows.get(above.id).h = Math.max(above.h, limit - above.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFullscreenFit() {
|
||||||
|
if (!document.body.classList.contains('fullscreen') || !tiles.length) return;
|
||||||
|
const m = cellMetrics();
|
||||||
|
const baseCellH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-h')) || 72;
|
||||||
|
const wrapH = gridwrap.clientHeight;
|
||||||
|
const maxCol = Math.max(1, ...tiles.map((t) => t.x + t.w));
|
||||||
|
const maxRow = Math.max(1, ...tiles.map((t) => t.y + t.h));
|
||||||
|
|
||||||
|
// x: the occupied columns stretch proportionally across the full width
|
||||||
|
grid.style.gridTemplateColumns = `repeat(${maxCol}, minmax(0, 1fr))`;
|
||||||
|
|
||||||
|
const layoutH = maxRow * (baseCellH + m.gap) - m.gap;
|
||||||
|
if (layoutH > wrapH) {
|
||||||
|
// deeper than the screen: keep natural rows and scrolling
|
||||||
|
grid.style.gridAutoRows = '';
|
||||||
|
gridwrap.classList.remove('no-scroll');
|
||||||
|
for (const tile of tiles) applyRect(tile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing below the fold: lock scrolling and make R rows fill the height exactly
|
||||||
|
gridwrap.classList.add('no-scroll');
|
||||||
|
const R = Math.max(maxRow, Math.floor((wrapH + m.gap) / (baseCellH + m.gap)));
|
||||||
|
const cellH = (wrapH - (R - 1) * m.gap) / R;
|
||||||
|
grid.style.gridAutoRows = cellH + 'px';
|
||||||
|
|
||||||
|
const rows = fullscreenRows(R);
|
||||||
|
for (const tile of tiles) {
|
||||||
|
const entry = tileEls.get(tile.id);
|
||||||
|
if (!entry) continue;
|
||||||
|
const r = rows.get(tile.id);
|
||||||
|
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
|
||||||
|
entry.el.style.gridRow = `${r.y + 1} / span ${r.h}`;
|
||||||
|
try { entry.instance?.onResize?.(tile.w, r.h); } catch { /* module's problem, not the shell's */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFullscreenFit() {
|
||||||
|
grid.style.gridTemplateColumns = '';
|
||||||
|
grid.style.gridAutoRows = '';
|
||||||
|
gridwrap.classList.remove('no-scroll');
|
||||||
|
for (const tile of tiles) {
|
||||||
|
applyRect(tile);
|
||||||
|
notifyResize(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fsBtn.addEventListener('click', () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen?.().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
const active = Boolean(document.fullscreenElement);
|
||||||
|
document.body.classList.toggle('fullscreen', active);
|
||||||
|
if (active) {
|
||||||
|
closeSettingsPopovers();
|
||||||
|
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
|
||||||
|
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
|
||||||
|
// let the collapsed menubar's space settle before measuring
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
|
||||||
|
} else {
|
||||||
|
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
||||||
|
clearFullscreenFit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The grid area's size settles late: the browser animates into fullscreen,
|
||||||
|
// then the menubar collapse animates. Re-fit whenever its size changes.
|
||||||
|
let fsRefit = 0;
|
||||||
|
new ResizeObserver(() => {
|
||||||
|
if (!document.body.classList.contains('fullscreen')) return;
|
||||||
|
cancelAnimationFrame(fsRefit);
|
||||||
|
fsRefit = requestAnimationFrame(applyFullscreenFit);
|
||||||
|
}).observe(gridwrap);
|
||||||
|
|
||||||
/* ── live updates from the admin page ───────────────────────────────── */
|
/* ── live updates from the admin page ───────────────────────────────── */
|
||||||
|
|
||||||
/* The server broadcasts `modules-changed` on /api/events whenever admin
|
/* The server broadcasts `modules-changed` on /api/events whenever admin
|
||||||
|
|||||||
+136
-12
@@ -39,11 +39,71 @@ button { font-family: inherit; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 12px;
|
height: 46px;
|
||||||
|
padding: 0 12px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
|
transition: height 0.22s ease, border-bottom-width 0.22s ease;
|
||||||
|
}
|
||||||
|
/* collapsed by its notch (and automatically in fullscreen) */
|
||||||
|
body.menu-hidden #topbar {
|
||||||
|
height: 0;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The menubar's show/hide handle IS the logo: a translucent accent pill
|
||||||
|
wrapped around "ProdDash" at the left of the bar, "Dash" cut out in the
|
||||||
|
background colour. It sits at the left — never screen-centre, where a
|
||||||
|
tile's own (centred) tab lands once both bars are hidden. When the bar
|
||||||
|
hides, the pill shrinks to a slim lip at the very top edge, clear of a
|
||||||
|
top-row tile's status dot and title. */
|
||||||
|
.brand-spacer { visibility: hidden; }
|
||||||
|
|
||||||
|
#topbar-notch {
|
||||||
|
position: fixed;
|
||||||
|
/* a tab hung from the top of the bar: square top, rounded bottom, hanging
|
||||||
|
8px past the bar's bottom edge; the logo sits centred in the bar part */
|
||||||
|
top: 0;
|
||||||
|
left: 2px; /* the logo text starts at the bar's 12px padding; the tab adds 10px each side */
|
||||||
|
height: calc(var(--topbar-h, 46px) + 8px);
|
||||||
|
min-width: 60px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: var(--topbar-h, 46px);
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 60;
|
||||||
|
transition: height 0.22s ease, line-height 0.22s ease, font-size 0.22s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
#topbar-notch .accent { color: var(--bg); } /* the cutout */
|
||||||
|
#topbar-notch:hover { background: color-mix(in srgb, var(--accent) 68%, transparent); }
|
||||||
|
/* bar hidden: the text folds away and the tab becomes a lip at the top edge */
|
||||||
|
body.menu-hidden #topbar-notch {
|
||||||
|
height: 10px;
|
||||||
|
line-height: 10px;
|
||||||
|
font-size: 0;
|
||||||
|
background: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
}
|
||||||
|
body.menu-hidden #topbar-notch:hover { background: var(--accent); }
|
||||||
|
body.menu-hidden #topbar-notch::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 3px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
top: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#brand {
|
#brand {
|
||||||
@@ -207,9 +267,15 @@ button { font-family: inherit; }
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
transition: height 0.22s ease, border-bottom-width 0.22s ease;
|
||||||
}
|
}
|
||||||
.tile.dragging .tile-head { cursor: grabbing; }
|
.tile.dragging .tile-head { cursor: grabbing; }
|
||||||
.tile.head-hidden .tile-head { display: none; }
|
/* hidden = collapsed, not removed, so the notch toggle animates */
|
||||||
|
.tile.head-hidden .tile-head {
|
||||||
|
height: 0;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.tile-head .dot {
|
.tile-head .dot {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -305,7 +371,7 @@ button { font-family: inherit; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s ease, top 0.15s ease;
|
transition: opacity 0.15s ease, top 0.22s ease;
|
||||||
}
|
}
|
||||||
.tile:hover .tile-notch { opacity: 0.6; }
|
.tile:hover .tile-notch { opacity: 0.6; }
|
||||||
.tile .tile-notch:hover { opacity: 1; }
|
.tile .tile-notch:hover { opacity: 1; }
|
||||||
@@ -329,30 +395,76 @@ button { font-family: inherit; }
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-resize {
|
.tile-resize,
|
||||||
|
.tile-resize-left {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
cursor: nwse-resize;
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
opacity: 0.6;
|
opacity: 0; /* corner indicators only show while hovering the tile */
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
.tile-resize:hover { opacity: 1; color: var(--text); }
|
.tile-resize { right: 0; cursor: nwse-resize; }
|
||||||
.tile-resize::after {
|
.tile-resize-left { left: 0; cursor: nesw-resize; }
|
||||||
|
.tile:hover .tile-resize,
|
||||||
|
.tile:hover .tile-resize-left { opacity: 0.6; }
|
||||||
|
.tile .tile-resize:hover,
|
||||||
|
.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
|
||||||
|
.tile-resize::after,
|
||||||
|
.tile-resize-left::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 4px;
|
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
border-right: 2px solid currentColor;
|
|
||||||
border-bottom: 2px solid currentColor;
|
border-bottom: 2px solid currentColor;
|
||||||
|
}
|
||||||
|
.tile-resize::after {
|
||||||
|
right: 4px;
|
||||||
|
border-right: 2px solid currentColor;
|
||||||
border-radius: 0 0 3px 0;
|
border-radius: 0 0 3px 0;
|
||||||
}
|
}
|
||||||
|
.tile-resize-left::after {
|
||||||
|
left: 4px;
|
||||||
|
border-left: 2px solid currentColor;
|
||||||
|
border-radius: 0 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* bottom-edge pill: drag to move the tile's bottom edge (height only);
|
||||||
|
sits between the two corner handles */
|
||||||
|
.tile-resize-bottom {
|
||||||
|
position: absolute;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 10px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: ns-resize;
|
||||||
|
touch-action: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.tile-resize-bottom::after {
|
||||||
|
content: "";
|
||||||
|
width: 44px;
|
||||||
|
max-width: 60%;
|
||||||
|
height: 5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.tile:hover .tile-resize-bottom { opacity: 0.6; }
|
||||||
|
.tile .tile-resize-bottom:hover,
|
||||||
|
.tile.resizing .tile-resize-bottom { opacity: 1; }
|
||||||
|
.tile .tile-resize-bottom:hover::after,
|
||||||
|
.tile.resizing .tile-resize-bottom::after { background: var(--text); }
|
||||||
|
|
||||||
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
||||||
|
|
||||||
@@ -389,6 +501,18 @@ button { font-family: inherit; }
|
|||||||
they're rebuilt for the new geometry on release. */
|
they're rebuilt for the new geometry on release. */
|
||||||
#grid.no-dividers .tile-divider { display: none; }
|
#grid.no-dividers .tile-divider { display: none; }
|
||||||
|
|
||||||
|
/* ── fullscreen mode ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
|
||||||
|
editing affordances (corner handles, seam pills) are parked. */
|
||||||
|
body.fullscreen .tile-resize,
|
||||||
|
body.fullscreen .tile-resize-left,
|
||||||
|
body.fullscreen .tile-resize-bottom,
|
||||||
|
body.fullscreen .tile-divider,
|
||||||
|
body.fullscreen #admin-link { display: none; }
|
||||||
|
body.fullscreen #grid { padding-bottom: 0; }
|
||||||
|
#gridwrap.no-scroll { overflow-y: hidden; }
|
||||||
|
|
||||||
/* status message a module can show while degraded (also used by the shell
|
/* status message a module can show while degraded (also used by the shell
|
||||||
when a module fails to load) */
|
when a module fails to load) */
|
||||||
.tile-msg {
|
.tile-msg {
|
||||||
@@ -544,5 +668,5 @@ button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
#topbar { padding: 6px 8px; flex-wrap: wrap; }
|
#topbar { padding: 0 8px; gap: 6px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@
|
|||||||
*
|
*
|
||||||
* Shell server: serves the dashboard client from public/, discovers modules
|
* Shell server: serves the dashboard client from public/, discovers modules
|
||||||
* in modules/, mounts each enabled module's server routes under
|
* in modules/, mounts each enabled module's server routes under
|
||||||
* /api/modules/<id>/, and stores server-side config + named layouts under
|
* /api/modules/<id>/, and stores server-side module config + named layouts
|
||||||
* config/.
|
* in a per-machine data directory (see DATA_DIR below) so that updating the
|
||||||
|
* checkout never loses them.
|
||||||
*
|
*
|
||||||
* Zero dependencies — Node built-ins only (Node 18+ for global fetch).
|
* Zero dependencies — Node built-ins only (Node 18+ for global fetch).
|
||||||
* Configure via config/proddash.json or env vars:
|
* Configure via config/proddash.json (defaults, in the repo), an optional
|
||||||
|
* proddash.json in the data directory (this machine's overrides), or env vars:
|
||||||
* PORT (default: value in proddash.json, else 24500)
|
* PORT (default: value in proddash.json, else 24500)
|
||||||
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
||||||
|
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
@@ -19,12 +22,60 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
const { spawn, execFileSync } = require('child_process');
|
||||||
|
|
||||||
const ROOT = __dirname;
|
const ROOT = __dirname;
|
||||||
const PUBLIC_DIR = path.join(ROOT, 'public');
|
const PUBLIC_DIR = path.join(ROOT, 'public');
|
||||||
const CONFIG_DIR = path.join(ROOT, 'config');
|
const BUNDLED_MODULES_DIR = path.join(ROOT, 'modules'); // modules that ship with the checkout
|
||||||
const MODULES_DIR = path.join(ROOT, 'modules');
|
/** Checked-in defaults (proddash.json) — and where runtime state used to live. */
|
||||||
const MODULES_CONFIG_PATH = path.join(CONFIG_DIR, 'modules.json');
|
const REPO_CONFIG_DIR = path.join(ROOT, 'config');
|
||||||
|
|
||||||
|
/* ── data directory ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the app writes — module config (connection settings, API keys,
|
||||||
|
* enabled flags) and named layouts — lives in a per-machine data directory
|
||||||
|
* OUTSIDE the checkout. Pulling, re-cloning or resetting the app folder from
|
||||||
|
* main therefore never touches it, and nothing has to be re-entered in /admin.
|
||||||
|
*
|
||||||
|
* PRODDASH_DATA_DIR overrides the location. A relative path resolves
|
||||||
|
* against the app folder ("config" = the old in-repo spot).
|
||||||
|
* default Windows %APPDATA%\ProdDash
|
||||||
|
* macOS ~/Library/Application Support/ProdDash
|
||||||
|
* other $XDG_CONFIG_HOME/proddash, else ~/.config/proddash
|
||||||
|
*
|
||||||
|
* If the directory cannot be created or written, the server says so and
|
||||||
|
* falls back to the in-repo config/ folder rather than refusing to start.
|
||||||
|
*/
|
||||||
|
function defaultDataDir() {
|
||||||
|
const home = os.homedir();
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
|
||||||
|
}
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return path.join(home, 'Library', 'Application Support', 'ProdDash');
|
||||||
|
}
|
||||||
|
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDataDir() {
|
||||||
|
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||||
|
const dir = fromEnv ? path.resolve(ROOT, fromEnv) : defaultDataDir();
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.accessSync(dir, fs.constants.W_OK);
|
||||||
|
return dir;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[proddash] cannot use data directory ${dir} (${err.message}) — falling back to ${REPO_CONFIG_DIR}`);
|
||||||
|
return REPO_CONFIG_DIR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATA_DIR = resolveDataDir();
|
||||||
|
const MODULES_CONFIG_PATH = path.join(DATA_DIR, 'modules.json');
|
||||||
|
const LAYOUTS_DIR = path.join(DATA_DIR, 'layouts');
|
||||||
|
const INSTALLED_MODULES_DIR = path.join(DATA_DIR, 'modules'); // modules installed from the admin page
|
||||||
|
|
||||||
/* ── config files ───────────────────────────────────────────────────── */
|
/* ── config files ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -43,9 +94,107 @@ function writeJson(file, value) {
|
|||||||
fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config
|
fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config
|
||||||
}
|
}
|
||||||
|
|
||||||
const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {});
|
/**
|
||||||
|
* One-time move-in. Earlier versions kept modules.json and layouts/ inside the
|
||||||
|
* repo's config/ folder; if the data directory has none yet and those files
|
||||||
|
* exist, copy them across (the originals are left alone).
|
||||||
|
*/
|
||||||
|
function migrateLegacyState() {
|
||||||
|
if (DATA_DIR === REPO_CONFIG_DIR) return;
|
||||||
|
const moved = [];
|
||||||
|
const oldModules = path.join(REPO_CONFIG_DIR, 'modules.json');
|
||||||
|
if (!fs.existsSync(MODULES_CONFIG_PATH) && fs.existsSync(oldModules)) {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(oldModules, MODULES_CONFIG_PATH);
|
||||||
|
moved.push('modules.json');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[proddash] could not copy ${oldModules} into the data directory: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const oldLayouts = path.join(REPO_CONFIG_DIR, 'layouts');
|
||||||
|
let oldFiles = [];
|
||||||
|
try {
|
||||||
|
oldFiles = fs.readdirSync(oldLayouts).filter((f) => f.endsWith('.json'));
|
||||||
|
} catch { /* none to move */ }
|
||||||
|
let haveLayouts = false;
|
||||||
|
try {
|
||||||
|
haveLayouts = fs.readdirSync(LAYOUTS_DIR).some((f) => f.endsWith('.json'));
|
||||||
|
} catch { /* no layouts dir yet */ }
|
||||||
|
if (oldFiles.length && !haveLayouts) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(LAYOUTS_DIR, { recursive: true });
|
||||||
|
for (const f of oldFiles) fs.copyFileSync(path.join(oldLayouts, f), path.join(LAYOUTS_DIR, f));
|
||||||
|
moved.push(`${oldFiles.length} layout${oldFiles.length === 1 ? '' : 's'}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[proddash] could not copy layouts into the data directory: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moved.length) {
|
||||||
|
console.log(`[proddash] moved ${moved.join(' and ')} from ${REPO_CONFIG_DIR} into ${DATA_DIR}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
migrateLegacyState();
|
||||||
|
|
||||||
|
/** Shell settings: checked-in defaults, then this machine's overrides on top. */
|
||||||
|
const shellConfig = {
|
||||||
|
...readJson(path.join(REPO_CONFIG_DIR, 'proddash.json'), {}),
|
||||||
|
...readJson(path.join(DATA_DIR, 'proddash.json'), {}),
|
||||||
|
};
|
||||||
const PORT = Number(process.env.PORT || shellConfig.port || 24500);
|
const PORT = Number(process.env.PORT || shellConfig.port || 24500);
|
||||||
|
|
||||||
|
/* ── shell version & version ranges ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/** The shell's own version, from package.json. Modules declare the range
|
||||||
|
they need in their manifest ("proddash": ">=1.1.0"). */
|
||||||
|
const SHELL_VERSION = String(readJson(path.join(ROOT, 'package.json'), {}).version || '0.0.0');
|
||||||
|
|
||||||
|
function parseVersion(v) {
|
||||||
|
const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)(?:\.(\d+))?/);
|
||||||
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3] || 0)] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -1 / 0 / 1 like a comparator; unparseable versions sort lowest. */
|
||||||
|
function compareVersions(a, b) {
|
||||||
|
const pa = parseVersion(a) || [-1, 0, 0];
|
||||||
|
const pb = parseVersion(b) || [-1, 0, 0];
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does `version` satisfy `range`? Ranges are deliberately small: "" or "*"
|
||||||
|
* (anything), "1.2.0" (exact), ">=1.2.0", ">1.2.0", "<=…", "<…",
|
||||||
|
* "^1.2.0" (same major, at least that), "~1.2.0" (same minor, at least
|
||||||
|
* that), and space-separated combinations such as ">=1.1.0 <2.0.0".
|
||||||
|
*/
|
||||||
|
function satisfiesVersion(version, range) {
|
||||||
|
const r = String(range || '').trim();
|
||||||
|
if (!r || r === '*') return true;
|
||||||
|
const v = parseVersion(version);
|
||||||
|
if (!v) return false;
|
||||||
|
return r.split(/[\s,]+/).filter(Boolean).every((part) => {
|
||||||
|
const m = part.match(/^(>=|<=|>|<|=|\^|~)?v?(\d+)\.(\d+)(?:\.(\d+))?$/);
|
||||||
|
if (!m) return false;
|
||||||
|
const t = [Number(m[2]), Number(m[3]), Number(m[4] || 0)];
|
||||||
|
const c = compareVersions(v.join('.'), t.join('.'));
|
||||||
|
switch (m[1] || '=') {
|
||||||
|
case '>=': return c >= 0;
|
||||||
|
case '>': return c > 0;
|
||||||
|
case '<=': return c <= 0;
|
||||||
|
case '<': return c < 0;
|
||||||
|
case '^': return v[0] === t[0] && c >= 0;
|
||||||
|
case '~': return v[0] === t[0] && v[1] === t[1] && c >= 0;
|
||||||
|
default: return c === 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirementNote(range) {
|
||||||
|
return `Requires ProdDash ${range} — this is ${SHELL_VERSION}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */
|
/** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */
|
||||||
let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
||||||
|
|
||||||
@@ -54,18 +203,19 @@ let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
|||||||
/** @type {Map<string, object>} module id -> manifest (with .dir added) */
|
/** @type {Map<string, object>} module id -> manifest (with .dir added) */
|
||||||
const manifests = new Map();
|
const manifests = new Map();
|
||||||
|
|
||||||
function discoverModules() {
|
/** Read every valid manifest in one modules folder. */
|
||||||
manifests.clear();
|
function readManifests(baseDir, source) {
|
||||||
|
const out = [];
|
||||||
let dirs = [];
|
let dirs = [];
|
||||||
try {
|
try {
|
||||||
dirs = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
|
dirs = fs.readdirSync(baseDir, { withFileTypes: true })
|
||||||
.filter((d) => d.isDirectory())
|
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
||||||
.map((d) => d.name);
|
.map((d) => d.name);
|
||||||
} catch {
|
} catch {
|
||||||
return; // no modules/ folder yet — the shell still runs
|
return out; // folder missing — fine
|
||||||
}
|
}
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const manifestPath = path.join(MODULES_DIR, dir, 'module.json');
|
const manifestPath = path.join(baseDir, dir, 'module.json');
|
||||||
const man = readJson(manifestPath, null);
|
const man = readJson(manifestPath, null);
|
||||||
if (!man || typeof man !== 'object') {
|
if (!man || typeof man !== 'object') {
|
||||||
if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`);
|
if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`);
|
||||||
@@ -80,9 +230,48 @@ function discoverModules() {
|
|||||||
console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`);
|
console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
man.dir = path.join(MODULES_DIR, dir);
|
man.dir = path.join(baseDir, dir);
|
||||||
manifests.set(dir, man);
|
man.source = source;
|
||||||
|
man.requires = String(man.proddash || '').trim();
|
||||||
|
// A module built for a newer shell is listed (so admin can explain) but never loaded.
|
||||||
|
man.incompatible = satisfiesVersion(SHELL_VERSION, man.requires) ? '' : requirementNote(man.requires);
|
||||||
|
out.push(man);
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modules come from two places: the ones bundled with this checkout
|
||||||
|
* (modules/) and the ones installed from the admin page (the data
|
||||||
|
* directory's modules/). Same id in both → the newer version runs; a
|
||||||
|
* downloaded copy the bundled one has since overtaken is removed. A bundled
|
||||||
|
* module the admin page "uninstalled" stays on disk (it belongs to the app
|
||||||
|
* folder) but is left out here.
|
||||||
|
*/
|
||||||
|
function discoverModules() {
|
||||||
|
manifests.clear();
|
||||||
|
const bundled = new Map(readManifests(BUNDLED_MODULES_DIR, 'bundled').map((m) => [m.id, m]));
|
||||||
|
const installed = new Map(readManifests(INSTALLED_MODULES_DIR, 'installed').map((m) => [m.id, m]));
|
||||||
|
for (const [id, man] of bundled) {
|
||||||
|
if (isUninstalled(id)) continue;
|
||||||
|
const copy = installed.get(id);
|
||||||
|
if (copy && compareVersions(copy.version, man.version) >= 0) continue; // the copy wins, below
|
||||||
|
if (copy) {
|
||||||
|
console.log(`[proddash] ${id}: bundled v${man.version} supersedes the installed v${copy.version} — removing that copy`);
|
||||||
|
try { fs.rmSync(copy.dir, { recursive: true, force: true }); } catch { /* it just stays unused */ }
|
||||||
|
installed.delete(id);
|
||||||
|
}
|
||||||
|
manifests.set(id, man);
|
||||||
|
}
|
||||||
|
for (const [id, man] of installed) {
|
||||||
|
if (manifests.has(id)) continue;
|
||||||
|
man.bundledVersion = String(bundled.get(id)?.version || '');
|
||||||
|
manifests.set(id, man);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUninstalled(id) {
|
||||||
|
return moduleState(id).uninstalled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function moduleState(id) {
|
function moduleState(id) {
|
||||||
@@ -94,6 +283,12 @@ function isEnabled(id) {
|
|||||||
return moduleState(id).enabled !== false; // newly dropped modules default to enabled
|
return moduleState(id).enabled !== false; // newly dropped modules default to enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enabled and runnable on this shell version — what dashboards may load. */
|
||||||
|
function isActive(id) {
|
||||||
|
const man = manifests.get(id);
|
||||||
|
return Boolean(man) && isEnabled(id) && !man.incompatible;
|
||||||
|
}
|
||||||
|
|
||||||
/** Admin config for a module: schema defaults overlaid with stored values. */
|
/** Admin config for a module: schema defaults overlaid with stored values. */
|
||||||
function effectiveConfig(id) {
|
function effectiveConfig(id) {
|
||||||
const man = manifests.get(id);
|
const man = manifests.get(id);
|
||||||
@@ -167,6 +362,10 @@ function mountModule(id) {
|
|||||||
if (!man) return;
|
if (!man) return;
|
||||||
const entry = { routes: [], handle: null, error: '' };
|
const entry = { routes: [], handle: null, error: '' };
|
||||||
mounted.set(id, entry);
|
mounted.set(id, entry);
|
||||||
|
if (man.incompatible) {
|
||||||
|
entry.error = man.incompatible;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!man.server) return;
|
if (!man.server) return;
|
||||||
const log = makeLog(id);
|
const log = makeLog(id);
|
||||||
try {
|
try {
|
||||||
@@ -341,6 +540,385 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 25000).unref();
|
}, 25000).unref();
|
||||||
|
|
||||||
|
/* ── the repo: module catalog and shell updates ──────────────────────── */
|
||||||
|
|
||||||
|
/* The admin page installs modules from, and updates the shell against, the
|
||||||
|
ProdDash repository on GitHub — one small tar.gz of the branch, fetched at
|
||||||
|
most every few minutes, parsed here with no dependencies. Nothing is
|
||||||
|
contacted unless someone opens the admin page or the periodic update
|
||||||
|
check runs; every failure is recorded and shown, never thrown at a page. */
|
||||||
|
|
||||||
|
function parseRepoSetting(value, fallbackBranch) {
|
||||||
|
const m = String(value || '').trim().match(/^([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#([\w./-]+))?$/);
|
||||||
|
if (!m) return null;
|
||||||
|
return { owner: m[1], repo: m[2], branch: m[3] || fallbackBranch || 'main' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|
||||||
|
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main');
|
||||||
|
/** Where branch archives come from; tests point this at a local mock. */
|
||||||
|
const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || 'https://codeload.github.com').replace(/\/+$/, '');
|
||||||
|
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
|
||||||
|
|
||||||
|
function tarballUrl() {
|
||||||
|
return `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${encodeURIComponent(REPO.branch)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpError(status, message) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = status;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(buf) {
|
||||||
|
try { return buf ? JSON.parse(buf.toString('utf8')) : null; } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A relative path that can only land inside the folder it is joined to. */
|
||||||
|
function safeRelPath(p) {
|
||||||
|
if (typeof p !== 'string' || !p || p.startsWith('/') || p.includes('\\') || p.includes('\0')) return false;
|
||||||
|
return p.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — a minimal tar reader: regular files, GNU long names, pax headers — */
|
||||||
|
|
||||||
|
function cstr(buf, start, len) {
|
||||||
|
const s = buf.subarray(start, start + len);
|
||||||
|
const nul = s.indexOf(0);
|
||||||
|
return s.subarray(0, nul === -1 ? len : nul).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pax records look like "27 path=some/long/name\n" (length counts itself). */
|
||||||
|
function parsePaxRecords(buf) {
|
||||||
|
const out = {};
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < buf.length) {
|
||||||
|
const sp = buf.indexOf(0x20, pos);
|
||||||
|
if (sp === -1) break;
|
||||||
|
const len = parseInt(buf.subarray(pos, sp).toString(), 10);
|
||||||
|
if (!Number.isFinite(len) || len <= 0) break;
|
||||||
|
const rec = buf.subarray(sp + 1, pos + len - 1).toString('utf8');
|
||||||
|
const eq = rec.indexOf('=');
|
||||||
|
if (eq > 0) out[rec.slice(0, eq)] = rec.slice(eq + 1);
|
||||||
|
pos += len;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTar(buf) {
|
||||||
|
const files = new Map();
|
||||||
|
let comment = '';
|
||||||
|
let pendingName = null;
|
||||||
|
let off = 0;
|
||||||
|
while (off + 512 <= buf.length) {
|
||||||
|
const h = buf.subarray(off, off + 512);
|
||||||
|
if (h.every((b) => b === 0)) break; // end-of-archive blocks
|
||||||
|
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
|
||||||
|
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
|
||||||
|
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
|
||||||
|
const shortName = prefix ? `${prefix}/${cstr(h, 0, 100)}` : cstr(h, 0, 100);
|
||||||
|
const data = buf.subarray(off + 512, off + 512 + size);
|
||||||
|
off += 512 + Math.ceil(size / 512) * 512;
|
||||||
|
if (type === 'L') { pendingName = cstr(data, 0, size); continue; } // GNU long name
|
||||||
|
if (type === 'g' || type === 'x') { // pax headers
|
||||||
|
const rec = parsePaxRecords(data);
|
||||||
|
if (rec.comment && !comment) comment = String(rec.comment).trim(); // GitHub: the commit sha
|
||||||
|
if (type === 'x' && rec.path) pendingName = rec.path;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = pendingName || shortName;
|
||||||
|
pendingName = null;
|
||||||
|
if (type === '0') files.set(name, Buffer.from(data));
|
||||||
|
}
|
||||||
|
return { files, comment };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download the branch archive → { files: Map<relative path, Buffer>, sha }. */
|
||||||
|
async function fetchArchive() {
|
||||||
|
const res = await fetch(tarballUrl(), {
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${REPO.owner}/${REPO.repo}@${REPO.branch}`);
|
||||||
|
const gz = Buffer.from(await res.arrayBuffer());
|
||||||
|
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
|
||||||
|
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
||||||
|
const stripped = new Map(); // drop the archive's single top-level folder
|
||||||
|
for (const [p, data] of files) {
|
||||||
|
const i = p.indexOf('/');
|
||||||
|
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data);
|
||||||
|
}
|
||||||
|
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What we last learned about the repo. */
|
||||||
|
let remote = { checkedAt: 0, checking: null, error: '', sha: '', version: '', modules: new Map(), files: null };
|
||||||
|
|
||||||
|
async function refreshRemote(force = false) {
|
||||||
|
const fresh = remote.checkedAt && Date.now() - remote.checkedAt < REMOTE_TTL_MS;
|
||||||
|
if (!force && fresh && !remote.error) return remote;
|
||||||
|
if (remote.checking) return remote.checking;
|
||||||
|
const job = (async () => {
|
||||||
|
try {
|
||||||
|
const { files, sha } = await fetchArchive();
|
||||||
|
const pkg = safeJson(files.get('package.json'));
|
||||||
|
const modules = new Map();
|
||||||
|
for (const [p, data] of files) {
|
||||||
|
const m = p.match(/^modules\/([^/]+)\/(.+)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
|
||||||
|
entry.files.set(m[2], data);
|
||||||
|
modules.set(m[1], entry);
|
||||||
|
}
|
||||||
|
for (const [id, entry] of modules) {
|
||||||
|
const man = safeJson(entry.files.get('module.json'));
|
||||||
|
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
|
||||||
|
modules.delete(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.manifest = { ...man, id };
|
||||||
|
}
|
||||||
|
remote = { checkedAt: Date.now(), checking: null, error: '', sha, version: String(pkg?.version || ''), modules, files };
|
||||||
|
} catch (err) {
|
||||||
|
remote = { ...remote, checkedAt: Date.now(), checking: null, error: err?.message || String(err) };
|
||||||
|
}
|
||||||
|
return remote;
|
||||||
|
})();
|
||||||
|
remote.checking = job;
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — this checkout — */
|
||||||
|
|
||||||
|
function git(args, timeout = 15000) {
|
||||||
|
// fileMode=false: a chmod (e.g. making the launcher executable) is not a local change
|
||||||
|
return execFileSync('git', ['-c', 'core.fileMode=false', ...args], { cwd: ROOT, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function localGitInfo() {
|
||||||
|
if (!fs.existsSync(path.join(ROOT, '.git'))) return { isGit: false, sha: '', branch: '', dirty: false, error: '' };
|
||||||
|
try {
|
||||||
|
const porcelain = git(['status', '--porcelain']).split('\n').filter((l) => l && !l.startsWith('??'));
|
||||||
|
return { isGit: true, sha: git(['rev-parse', 'HEAD']), branch: git(['rev-parse', '--abbrev-ref', 'HEAD']), dirty: porcelain.length > 0, error: '' };
|
||||||
|
} catch (err) {
|
||||||
|
return { isGit: true, sha: '', branch: '', dirty: false, error: (err?.message || String(err)).split('\n')[0] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellStatus() {
|
||||||
|
const local = localGitInfo();
|
||||||
|
const versionCmp = remote.version ? compareVersions(remote.version, SHELL_VERSION) : 0;
|
||||||
|
const versionBehind = versionCmp > 0;
|
||||||
|
const sameCommit = !local.sha || !remote.sha || local.sha.startsWith(remote.sha) || remote.sha.startsWith(local.sha);
|
||||||
|
const commitsBehind = !sameCommit && versionCmp >= 0;
|
||||||
|
let blocker = '';
|
||||||
|
if (local.isGit) {
|
||||||
|
if (local.error) blocker = `git is not usable here (${local.error})`;
|
||||||
|
else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`;
|
||||||
|
else if (local.dirty) blocker = 'this checkout has local changes — commit or discard them first';
|
||||||
|
}
|
||||||
|
const gh = `https://github.com/${REPO.owner}/${REPO.repo}`;
|
||||||
|
return {
|
||||||
|
version: SHELL_VERSION,
|
||||||
|
repo: `${REPO.owner}/${REPO.repo}`,
|
||||||
|
branch: REPO.branch,
|
||||||
|
local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty },
|
||||||
|
remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error },
|
||||||
|
updateAvailable: versionBehind || commitsBehind,
|
||||||
|
versionBehind,
|
||||||
|
method: local.isGit ? 'git' : 'archive',
|
||||||
|
updateBlocker: blocker,
|
||||||
|
changesUrl: local.sha && remote.sha
|
||||||
|
? `${gh}/compare/${local.sha.slice(0, 12)}...${remote.sha.slice(0, 12)}`
|
||||||
|
: `${gh}/commits/${REPO.branch}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — the catalog: what could be installed or updated — */
|
||||||
|
|
||||||
|
function catalogView() {
|
||||||
|
const available = [];
|
||||||
|
const updates = [];
|
||||||
|
const hiddenBundled = readManifests(BUNDLED_MODULES_DIR, 'bundled').filter((m) => isUninstalled(m.id));
|
||||||
|
const seen = new Set();
|
||||||
|
for (const [id, entry] of remote.modules) {
|
||||||
|
const man = entry.manifest;
|
||||||
|
seen.add(id);
|
||||||
|
const requires = String(man.proddash || '').trim();
|
||||||
|
const compatible = satisfiesVersion(SHELL_VERSION, requires);
|
||||||
|
const base = {
|
||||||
|
id,
|
||||||
|
name: String(man.name || id),
|
||||||
|
version: String(man.version || ''),
|
||||||
|
description: String(man.description || ''),
|
||||||
|
requires,
|
||||||
|
compatible,
|
||||||
|
reason: compatible ? '' : requirementNote(requires),
|
||||||
|
};
|
||||||
|
const installed = manifests.get(id);
|
||||||
|
if (installed) {
|
||||||
|
if (compareVersions(man.version, installed.version) > 0) {
|
||||||
|
updates.push({ ...base, installedVersion: String(installed.version || ''), source: 'repo' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hidden = hiddenBundled.find((m) => m.id === id);
|
||||||
|
const useBundled = hidden && compareVersions(hidden.version, man.version) >= 0;
|
||||||
|
available.push({ ...base, source: useBundled ? 'bundled' : 'repo' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const m of hiddenBundled) {
|
||||||
|
if (seen.has(m.id)) continue;
|
||||||
|
available.push({
|
||||||
|
id: m.id, name: String(m.name || m.id), version: String(m.version || ''), description: String(m.description || ''),
|
||||||
|
requires: m.requires, compatible: !m.incompatible, reason: m.incompatible, source: 'bundled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||||
|
return { available: available.sort(byName), updates: updates.sort(byName) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install (or update) a module from the repo, or bring back a bundled one. */
|
||||||
|
async function installModule(id) {
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw httpError(400, 'Bad module id.');
|
||||||
|
await refreshRemote(false);
|
||||||
|
const remoteEntry = remote.modules.get(id) || null;
|
||||||
|
const bundledMan = readManifests(BUNDLED_MODULES_DIR, 'bundled').find((m) => m.id === id) || null;
|
||||||
|
const installedMan = readManifests(INSTALLED_MODULES_DIR, 'installed').find((m) => m.id === id) || null;
|
||||||
|
if (!remoteEntry && !bundledMan && !installedMan) {
|
||||||
|
throw httpError(404, remote.error
|
||||||
|
? `"${id}" is not here and the repo could not be reached: ${remote.error}`
|
||||||
|
: `"${id}" is not in ${REPO.owner}/${REPO.repo}.`);
|
||||||
|
}
|
||||||
|
// End up with the newest copy: what the repo offers vs what is already here.
|
||||||
|
const haveVersion = installedMan?.version || bundledMan?.version || '';
|
||||||
|
const download = Boolean(remoteEntry) && (!(bundledMan || installedMan) || compareVersions(remoteEntry.manifest.version, haveVersion) > 0);
|
||||||
|
const man = download ? remoteEntry.manifest : (installedMan || bundledMan);
|
||||||
|
const requires = String(man.proddash || '').trim();
|
||||||
|
if (!satisfiesVersion(SHELL_VERSION, requires)) {
|
||||||
|
throw httpError(409, `${man.name || id} ${requirementNote(requires).replace('Requires', 'requires')}. Update ProdDash first.`);
|
||||||
|
}
|
||||||
|
if (download) {
|
||||||
|
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
for (const [rel, data] of remoteEntry.files) {
|
||||||
|
if (!safeRelPath(rel)) continue;
|
||||||
|
const target = path.join(staging, rel);
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, data);
|
||||||
|
}
|
||||||
|
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
||||||
|
unmountModule(id);
|
||||||
|
fs.rmSync(dest, { recursive: true, force: true });
|
||||||
|
fs.renameSync(staging, dest);
|
||||||
|
}
|
||||||
|
const state = { ...moduleState(id) };
|
||||||
|
delete state.uninstalled;
|
||||||
|
modulesConfig[id] = state;
|
||||||
|
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
||||||
|
discoverModules();
|
||||||
|
remountModule(id);
|
||||||
|
broadcastShellEvent('modules-changed', { id });
|
||||||
|
const now = manifests.get(id);
|
||||||
|
console.log(`[proddash] ${download ? 'installed' : 'restored'} module "${id}" v${now?.version || '?'} (${now?.source || 'unknown'})`);
|
||||||
|
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a module: an installed copy is deleted; a bundled one is switched
|
||||||
|
off and hidden (its folder belongs to the app). Settings are kept so a
|
||||||
|
later reinstall picks them up again. */
|
||||||
|
function uninstallModule(id) {
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw httpError(400, 'Bad module id.');
|
||||||
|
const installedDir = path.join(INSTALLED_MODULES_DIR, id);
|
||||||
|
const hadInstalled = fs.existsSync(path.join(installedDir, 'module.json'));
|
||||||
|
const hasBundled = fs.existsSync(path.join(BUNDLED_MODULES_DIR, id, 'module.json'));
|
||||||
|
if (!manifests.has(id) && !hadInstalled && !hasBundled) throw httpError(404, `No module "${id}" is installed.`);
|
||||||
|
unmountModule(id);
|
||||||
|
if (hadInstalled) fs.rmSync(installedDir, { recursive: true, force: true });
|
||||||
|
modulesConfig[id] = { ...moduleState(id), uninstalled: true };
|
||||||
|
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
||||||
|
discoverModules();
|
||||||
|
broadcastShellEvent('modules-changed', { id });
|
||||||
|
console.log(`[proddash] uninstalled module "${id}"${hadInstalled ? ' (files removed)' : ''}${hasBundled ? ' (bundled copy hidden)' : ''}`);
|
||||||
|
return { id, removedFiles: hadInstalled, bundled: hasBundled };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — updating the shell itself — */
|
||||||
|
|
||||||
|
function copyTree(src, dst) {
|
||||||
|
fs.mkdirSync(dst, { recursive: true });
|
||||||
|
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
const s = path.join(src, e.name);
|
||||||
|
const d = path.join(dst, e.name);
|
||||||
|
if (e.isDirectory()) copyTree(s, d);
|
||||||
|
else fs.copyFileSync(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring the app folder up to the repo's branch. A git checkout gets
|
||||||
|
* `git pull --ff-only`; anything else has the archive unpacked over it
|
||||||
|
* (runtime state and .git are never touched). The caller restarts.
|
||||||
|
*/
|
||||||
|
async function updateShell() {
|
||||||
|
await refreshRemote(true);
|
||||||
|
if (remote.error) throw httpError(502, `Couldn't reach ${REPO.owner}/${REPO.repo}: ${remote.error}`);
|
||||||
|
const status = shellStatus();
|
||||||
|
if (status.updateBlocker) throw httpError(409, `Can't update from here: ${status.updateBlocker}.`);
|
||||||
|
if (!status.updateAvailable) throw httpError(409, 'Already up to date.');
|
||||||
|
if (status.local.isGit) {
|
||||||
|
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
|
||||||
|
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
|
||||||
|
} else {
|
||||||
|
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
|
||||||
|
const staging = path.join(ROOT, '.proddash-update');
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
for (const [rel, data] of remote.files) {
|
||||||
|
if (!safeRelPath(rel) || keep(rel)) continue;
|
||||||
|
const target = path.join(staging, rel);
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, data);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(path.join(staging, 'server.js'))) {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
|
||||||
|
}
|
||||||
|
copyTree(staging, ROOT);
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us
|
||||||
|
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
|
||||||
|
* own replacement, which waits for this port to free up.
|
||||||
|
*/
|
||||||
|
function restartServer(reason) {
|
||||||
|
console.log(`[proddash] ${reason} — restarting ProdDash`);
|
||||||
|
for (const id of [...mounted.keys()]) unmountModule(id);
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
if (!process.env.PRODDASH_LAUNCHER) {
|
||||||
|
try {
|
||||||
|
const child = spawn(process.execPath, process.argv.slice(1), {
|
||||||
|
cwd: process.cwd(), env: process.env, detached: true, stdio: 'inherit',
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[proddash] could not start the new ProdDash — start it by hand:', err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(75);
|
||||||
|
};
|
||||||
|
server.close(finish);
|
||||||
|
setTimeout(finish, 1500).unref();
|
||||||
|
}
|
||||||
|
|
||||||
/* ── admin API ──────────────────────────────────────────────────────── */
|
/* ── admin API ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
||||||
@@ -390,6 +968,9 @@ function adminModuleView(id) {
|
|||||||
version: String(man.version || ''),
|
version: String(man.version || ''),
|
||||||
description: String(man.description || ''),
|
description: String(man.description || ''),
|
||||||
hasServer: Boolean(man.server),
|
hasServer: Boolean(man.server),
|
||||||
|
source: man.source || 'bundled',
|
||||||
|
requires: man.requires || '',
|
||||||
|
incompatible: man.incompatible || '',
|
||||||
enabled: isEnabled(id),
|
enabled: isEnabled(id),
|
||||||
configSchema: man.configSchema || {},
|
configSchema: man.configSchema || {},
|
||||||
config: values,
|
config: values,
|
||||||
@@ -413,7 +994,13 @@ function applyConfigPatch(id, patch) {
|
|||||||
}
|
}
|
||||||
if (type === 'number') value = Number(value) || 0;
|
if (type === 'number') value = Number(value) || 0;
|
||||||
else if (type === 'boolean') value = Boolean(value);
|
else if (type === 'boolean') value = Boolean(value);
|
||||||
else value = value === undefined || value === null ? '' : String(value);
|
else if (type === 'endpoint') {
|
||||||
|
const src = value && typeof value === 'object' ? value : {};
|
||||||
|
value = {
|
||||||
|
host: String(src.host ?? '').trim(),
|
||||||
|
port: Math.max(0, Math.min(65535, Math.trunc(Number(src.port)) || 0)),
|
||||||
|
};
|
||||||
|
} else value = value === undefined || value === null ? '' : String(value);
|
||||||
next[key] = value;
|
next[key] = value;
|
||||||
}
|
}
|
||||||
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };
|
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };
|
||||||
@@ -447,10 +1034,51 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
authRequired: Boolean(ADMIN_PASSCODE),
|
authRequired: Boolean(ADMIN_PASSCODE),
|
||||||
authed: true,
|
authed: true,
|
||||||
|
version: SHELL_VERSION,
|
||||||
|
dataDir: DATA_DIR,
|
||||||
modules: [...manifests.keys()].sort().map(adminModuleView),
|
modules: [...manifests.keys()].sort().map(adminModuleView),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (urlPath === '/api/admin/catalog' && req.method === 'GET') {
|
||||||
|
if (!isAuthed(req)) return sendJson(res, 401, { authRequired: true, authed: false });
|
||||||
|
const refresh = new URL(req.url, 'http://proddash.invalid').searchParams.get('refresh') === '1';
|
||||||
|
await refreshRemote(refresh);
|
||||||
|
return sendJson(res, 200, { shell: shellStatus(), ...catalogView() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlPath === '/api/admin/update' && req.method === 'POST') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
try {
|
||||||
|
const result = await updateShell();
|
||||||
|
sendJson(res, 200, { ok: true, ...result, restarting: true });
|
||||||
|
setTimeout(() => restartServer(`updated ${result.from} → ${result.to || 'latest'} via ${result.method}`), 400);
|
||||||
|
} catch (err) {
|
||||||
|
sendJson(res, err.status || 500, { error: err.message || String(err) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
|
||||||
|
if (install && req.method === 'POST') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
try {
|
||||||
|
return sendJson(res, 200, { ok: true, ...(await installModule(decodeURIComponent(install[1]))) });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, err.status || 500, { error: err.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = urlPath.match(/^\/api\/admin\/modules\/([^/]+)$/);
|
||||||
|
if (remove && req.method === 'DELETE') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
try {
|
||||||
|
return sendJson(res, 200, { ok: true, ...uninstallModule(decodeURIComponent(remove[1])) });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, err.status || 500, { error: err.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/);
|
const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/);
|
||||||
if (m && req.method === 'PUT') {
|
if (m && req.method === 'PUT') {
|
||||||
const id = decodeURIComponent(m[1]);
|
const id = decodeURIComponent(m[1]);
|
||||||
@@ -462,15 +1090,19 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
} catch {
|
} catch {
|
||||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
if (m[2] === 'config') {
|
if (m[2] === 'config') {
|
||||||
applyConfigPatch(id, body.config);
|
applyConfigPatch(id, body.config);
|
||||||
remountModule(id); // config changes take effect without a manual restart
|
|
||||||
} else {
|
} else {
|
||||||
const enabled = Boolean(body.enabled);
|
modulesConfig[id] = { ...moduleState(id), enabled: Boolean(body.enabled) };
|
||||||
modulesConfig[id] = { ...moduleState(id), enabled };
|
|
||||||
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
||||||
remountModule(id); // mounts when enabled, unmounts (only) otherwise
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { error: `Could not save ${MODULES_CONFIG_PATH}: ${err?.message || err}` });
|
||||||
|
}
|
||||||
|
// Config changes take effect without a manual restart; a toggle mounts when
|
||||||
|
// enabled and unmounts (only) otherwise.
|
||||||
|
remountModule(id);
|
||||||
broadcastShellEvent('modules-changed', { id });
|
broadcastShellEvent('modules-changed', { id });
|
||||||
return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
|
return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
|
||||||
}
|
}
|
||||||
@@ -478,9 +1110,7 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
|
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── named layouts (JSON files under config/layouts/) ───────────────── */
|
/* ── named layouts (JSON files under <data dir>/layouts/) ───────────── */
|
||||||
|
|
||||||
const LAYOUTS_DIR = path.join(CONFIG_DIR, 'layouts');
|
|
||||||
|
|
||||||
/** Names double as file names — keep them boring on purpose. */
|
/** Names double as file names — keep them boring on purpose. */
|
||||||
function layoutNameOk(name) {
|
function layoutNameOk(name) {
|
||||||
@@ -558,7 +1188,11 @@ async function handleLayoutsApi(req, res, urlPath) {
|
|||||||
}
|
}
|
||||||
const layout = sanitizeLayout(body.layout);
|
const layout = sanitizeLayout(body.layout);
|
||||||
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
||||||
|
try {
|
||||||
writeJson(layoutPath(name), layout);
|
writeJson(layoutPath(name), layout);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` });
|
||||||
|
}
|
||||||
return sendJson(res, 200, { ok: true, name });
|
return sendJson(res, 200, { ok: true, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,6 +1238,7 @@ async function handleLayoutsApi(req, res, urlPath) {
|
|||||||
function dispatchModuleApi(req, res, id, subPath, search) {
|
function dispatchModuleApi(req, res, id, subPath, search) {
|
||||||
if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` });
|
if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` });
|
||||||
if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` });
|
if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` });
|
||||||
|
if (manifests.get(id).incompatible) return sendJson(res, 409, { error: manifests.get(id).incompatible });
|
||||||
const entry = mounted.get(id);
|
const entry = mounted.get(id);
|
||||||
if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` });
|
if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` });
|
||||||
if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` });
|
if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` });
|
||||||
@@ -706,7 +1341,7 @@ function handleRequest(req, res) {
|
|||||||
|
|
||||||
/* — shell API — */
|
/* — shell API — */
|
||||||
if (urlPath === '/api/modules' && req.method === 'GET') {
|
if (urlPath === '/api/modules' && req.method === 'GET') {
|
||||||
Promise.all([...manifests.keys()].filter(isEnabled).map(clientManifest))
|
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
|
||||||
.then((list) => sendJson(res, 200, { modules: list }))
|
.then((list) => sendJson(res, 200, { modules: list }))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[proddash] /api/modules failed:', err);
|
console.error('[proddash] /api/modules failed:', err);
|
||||||
@@ -773,7 +1408,7 @@ function handleRequest(req, res) {
|
|||||||
if (assetMatch) {
|
if (assetMatch) {
|
||||||
const id = decodeURIComponent(assetMatch[1]);
|
const id = decodeURIComponent(assetMatch[1]);
|
||||||
const man = manifests.get(id);
|
const man = manifests.get(id);
|
||||||
if (!man || !isEnabled(id)) {
|
if (!man || !isActive(id)) {
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
return res.end('Not found');
|
return res.end('Not found');
|
||||||
}
|
}
|
||||||
@@ -804,8 +1439,8 @@ server.requestTimeout = 0;
|
|||||||
discoverModules();
|
discoverModules();
|
||||||
mountAllModules();
|
mountAllModules();
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.on('listening', () => {
|
||||||
console.log('ProdDash running');
|
console.log('ProdDash ' + SHELL_VERSION);
|
||||||
console.log(' Local : http://localhost:' + PORT);
|
console.log(' Local : http://localhost:' + PORT);
|
||||||
for (const ifaces of Object.values(os.networkInterfaces())) {
|
for (const ifaces of Object.values(os.networkInterfaces())) {
|
||||||
for (const iface of ifaces || []) {
|
for (const iface of ifaces || []) {
|
||||||
@@ -816,10 +1451,34 @@ server.listen(PORT, '0.0.0.0', () => {
|
|||||||
}
|
}
|
||||||
const ids = [...manifests.keys()];
|
const ids = [...manifests.keys()];
|
||||||
console.log(ids.length
|
console.log(ids.length
|
||||||
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ')
|
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).join(', ')
|
||||||
: ' Modules : none installed');
|
: ' Modules : none installed');
|
||||||
|
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
|
||||||
|
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A restart hands the port over from the exiting ProdDash: wait for it.
|
||||||
|
let listenTries = 0;
|
||||||
|
server.on('error', (err) => {
|
||||||
|
if (err.code === 'EADDRINUSE' && listenTries < 30) {
|
||||||
|
listenTries += 1;
|
||||||
|
if (listenTries === 1) console.log(`[proddash] port ${PORT} is busy — waiting for it (a previous ProdDash may still be shutting down)`);
|
||||||
|
setTimeout(() => server.listen(PORT, '0.0.0.0'), 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('[proddash] cannot start:', err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '0.0.0.0');
|
||||||
|
|
||||||
|
// Update check: shortly after boot, then every few hours (the admin page
|
||||||
|
// shows the result; nothing is installed without someone clicking).
|
||||||
|
if (!process.env.PRODDASH_NO_REMOTE_CHECK) {
|
||||||
|
setTimeout(() => refreshRemote(false).catch(() => {}), 20000).unref();
|
||||||
|
setInterval(() => refreshRemote(true).catch(() => {}), REMOTE_CHECK_MS).unref();
|
||||||
|
}
|
||||||
|
|
||||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
process.on(signal, () => {
|
process.on(signal, () => {
|
||||||
for (const id of [...mounted.keys()]) unmountModule(id);
|
for (const id of [...mounted.keys()]) unmountModule(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user