Author SHA1 Message Date
jared 3572d5ba35 README: add walkthrough GIF, architecture diagram and gallery; add demo seed and capture scripts
Lint / JS (eslint) (push) Successful in 18s
Lint / Notify on failure (push) Skipped
Security / JS Security (npm audit) (push) Failing after 9s
Test / JS Tests (jest) (push) Successful in 9s
Lint / Deploy (push) Successful in 6s
2026-10-03 01:19:43 -04:00
jared d9f3986198 Declare axios dependency (required by worker/worker.js, was missing from package.json) 2026-10-03 01:19:43 -04:00
jared 68e7108831 Add MIT license
Lint / JS (eslint) (push) Successful in 12s
Lint / Notify on failure (push) Skipped
Security / JS Security (npm audit) (push) Failing after 12s
Test / JS Tests (jest) (push) Successful in 9s
Lint / Deploy (push) Successful in 4s
2026-10-03 00:16:58 -04:00
jared 3edb2da174 Merge pull request 'fix: handle mysql2 >=3.23 returning JSON columns pre-parsed (Gandalf relay regression)' (#36) from hotfix/mysql2-json-columns into main
Lint / JS (eslint) (push) Successful in 10s
Lint / Notify on failure (push) Skipped
Security / JS Security (npm audit) (push) Successful in 11s
Test / JS Tests (jest) (push) Successful in 12s
Lint / Deploy (push) Successful in 3s
fix: handle mysql2 >=3.23 returning JSON columns pre-parsed (#36)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
2026-09-08 22:41:26 -04:00
jaredandClaude Fable 5.1 2173b5ab8f fix: handle mysql2 >=3.23 returning JSON columns pre-parsed
Lint / JS (eslint) (pull_request) Successful in 11s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / JS Security (npm audit) (pull_request) Successful in 10s
Test / JS Tests (jest) (pull_request) Successful in 10s
Lint / JS (eslint) (push) Successful in 11s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / JS Security (npm audit) (push) Successful in 11s
Test / JS Tests (jest) (push) Successful in 11s
mysql2 3.24 (pulled in by the audit fix in #35) returns MariaDB JSON columns as
JS objects. GET /api/internal/executions/:id and GET /api/workflows/:id still
called JSON.parse on the value, threw, and silently returned [] / {}. This broke
Gandalf's relay checks (false 'PBS unreachable' ticket) and the workflow edit
modal. Guard both sites with the same typeof check already used by
GET /api/executions/:id.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
2026-09-08 22:39:59 -04:00
jared e13bdd00d1 Merge pull request 'Redesign the Pulse UI onto the LotusGuild Terminal Design System' (#35) from feature/tds-redesign into main
Lint / JS (eslint) (push) Successful in 11s
Lint / Notify on failure (push) Skipped
Security / JS Security (npm audit) (push) Successful in 16s
Test / JS Tests (jest) (push) Successful in 19s
Lint / Deploy (push) Successful in 4s
Redesign the Pulse UI onto the LotusGuild Terminal Design System (#35)

Closes #2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
2026-09-08 22:23:24 -04:00
jaredandClaude Fable 5.1 1c3a2777c8 chore(deps): npm audit fix — ws 8.21.3, mysql2 3.24.4, qs 6.16.0 (0 vulnerabilities)
Lint / JS (eslint) (push) Successful in 10s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / JS Security (npm audit) (push) Successful in 10s
Test / JS Tests (jest) (push) Successful in 10s
Security / JS Security (npm audit) (pull_request) Successful in 10s
Test / JS Tests (jest) (pull_request) Successful in 11s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
2026-09-08 22:21:47 -04:00
jaredandClaude Fable 5.1 eca5cb45b1 feat(ui): TDS migration stage 3 — integration fixes, legacy removal, docs
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / JS Security (npm audit) (pull_request) Failing after 10s
Test / JS Tests (jest) (pull_request) Successful in 14s
Lint / JS (eslint) (push) Successful in 11s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / JS Security (npm audit) (push) Failing after 11s
Test / JS Tests (jest) (push) Successful in 10s
- Fix design-system class names that do not exist in base.css (.lt-alert--error,
  .lt-form-hint); add shared .lt-modal-lg, .lt-field-error and .is-invalid rules
- Remove public/index.html and the public/base.js symlink
- package.json: start script, correct main
- README: vendored design system, Web UI section, new env vars, read-only dev mode

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
2026-09-08 22:17:15 -04:00
jaredandClaude Fable 5.1 68d0a906a6 feat(ui): TDS migration stage 2 — six page views and page modules
- Dashboard, Workers, Workflows, Executions, Quick Command, Scheduler pages on the
  shared layout, all behaviour via Pulse delegated actions (no inline handlers)
- Executions: server-driven Manual/Automated views, compare mode, detail modal with all
  log types, ?open= deep link, cross-page re-run via sessionStorage
- Workflows: run modal (dry-run available for every workflow), create modal pre-filled
  with the example definition
- Fix EJS comment in page-header partial that broke rendering

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
2026-09-08 21:57:33 -04:00
jaredandClaude Fable 5.1 cbb91a306d feat(ui): TDS migration stage 1 — EJS layout, CSP, vendored design system, shared runtime
- Vendor web_template base.css/base.js (v1.2 bbec859) under public/web_template with sync script
- Add views/layout.ejs (fixed upstream EJS comment delimiters, mobile drawer, cmd palette,
  keys help, WS status, theme toggle) and lib/render.js two-step renderer
- Add page routes for /, /workers, /workflows, /executions, /quick, /scheduler (stub views)
- helmet with strict nonce CSP (script-src-attr 'none'), /csp-report, report-only toggle
- lib/pageauth.js: shared users upsert + HTML 401/403 for page routes
- PULSE_DEV_READONLY guard disabling background writers for local testing
- public/assets/app.js shared runtime (Pulse namespace, delegated actions, WS singleton,
  themed confirm modal) and app.css
- eslint globals for browser files; ignore vendored assets

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
2026-09-08 21:46:24 -04:00
60 changed files with 14368 additions and 3423 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
public/web_template/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-2026 Jared Vititoe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+136 -4
View File
@@ -12,7 +12,23 @@ A distributed workflow orchestration platform for managing and executing complex
## Styling & Layout
PULSE uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
PULSE uses the **LotusGuild Terminal Design System**. The design system is **vendored** into this
repo at `public/web_template/` (`base.css`, `base.js`, `VERSION`) and served from `/web_template/*`,
so the app has no runtime dependency on a sibling checkout. `public/web_template/VERSION` records
the design-system version, upstream short SHA, and sync date; asset URLs are cache-busted with it.
Update the vendored copy with:
```bash
scripts/sync-web-template.sh <path-to-web_template-checkout>
```
The script copies `base.css`/`base.js` as regular files (never symlinks) and rewrites `VERSION`.
Never hand-edit `public/web_template/` — it is excluded from ESLint and overwritten on every sync.
Pulse-local gaps in the design system live in `public/assets/app.css` (currently `.lt-modal-lg`,
`.lt-field-error`, `.is-invalid`) and are candidates for upstreaming.
Reference documentation:
- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md) — full component reference, CSS variables, JS API
- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css) — unified CSS (`.lt-*` classes)
@@ -20,9 +36,103 @@ PULSE uses the **LotusGuild Terminal Design System**. For all styling, component
- [`web_template/aesthetic_diff.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/aesthetic_diff.md) — cross-app divergence analysis and convergence guide
- [`web_template/node/middleware.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/node/middleware.js) — Express auth, CSRF, CSP nonce middleware
**Pending convergence items (see aesthetic_diff.md):**
- Extract inline `<style>` from `public/index.html` into `public/style.css` and extend `base.css`
- Use `lt.autoRefresh.start(refreshData, 30000)` instead of raw `setInterval`
![PULSE walkthrough: dashboard, workers, workflows, execution logs with an approval prompt, a multi-worker quick command and the command palette](docs/img/demo.gif)
## Why this exists
I run a six-node Proxmox/Ceph cluster plus a pile of LXC containers. Day-to-day work is the same handful of questions asked of many machines: *what is the disk usage everywhere, restart that service on one node, is it safe to proceed?* SSH-ing into each box does not scale, and a full config-management system is more than I need. PULSE is a small orchestration layer: a server with a web UI, and a lightweight **worker** on each machine that executes what the server dispatches over a WebSocket. It adds what shell loops lack: multi-step **workflows** with conditions and human approval gates, scheduled commands, a full execution history, and an API that other tools call (GANDALF uses it to run network diagnostics).
```mermaid
flowchart LR
user["Browser<br/>(Authelia SSO)"] -->|HTTPS| srv["PULSE server<br/>Express + EJS + WebSocket"]
gd["GANDALF"] -->|internal API| srv
srv --> db[("MariaDB<br/>workflows, executions,<br/>schedules")]
srv <-->|"WebSocket<br/>commands / results / heartbeat"| w1["worker: node-01"]
srv <-->|WebSocket| w2["worker: node-02"]
srv <-->|WebSocket| w3["worker: node-03"]
```
Workflows are plain JSON with five step types: `execute`, `prompt` (pause for a human decision), `wait`, `parse` (pull `KEY=VALUE` lines from output into state) and `route` (branch on parsed state).
## Gallery
**Dashboard**: recent executions at a glance and live worker status.
![Dashboard](docs/img/dashboard.png)
**Workers** report system info, memory, load, uptime and task slots on every heartbeat:
![Workers](docs/img/workers.png)
**Workflows** are reusable multi-step jobs. The editor takes the JSON definition directly:
| Workflow list | Workflow editor |
|---|---|
| ![Workflows](docs/img/workflows.png) | ![Workflow editor](docs/img/workflow-editor.png) |
**Executions**: full history with step-by-step logs, per-command output and durations.
![Executions](docs/img/executions.png)
| Completed run with step logs | A failed command, with its error |
|---|---|
| ![Execution detail](docs/img/execution-detail.png) | ![Failed execution](docs/img/execution-failed.png) |
**Approval gates:** a `prompt` step pauses the workflow until someone chooses an option (here, a rolling restart waits for a human to approve):
![Awaiting approval](docs/img/execution-awaiting-approval.png)
**Quick Command** runs a one-off command on one or many workers at once:
| Compose | Result |
|---|---|
| ![Quick command](docs/img/quick-command.png) | ![Quick command result](docs/img/quick-command-result.png) |
**Scheduler** runs commands on an interval or hourly schedule:
![Scheduler](docs/img/scheduler.png)
**Command palette** (`Ctrl+K`) and **light theme**:
| Command palette | Light theme |
|---|---|
| ![Command palette](docs/img/command-palette.png) | ![Dashboard, light theme](docs/img/dashboard-light.png) |
<sub>Screenshots are generated with Playwright against a throwaway local instance with three local workers running harmless commands (`uptime`, `df`, `echo`). See `scripts/demo/`.</sub>
## Web UI
The UI is server-rendered with EJS. Every route renders `views/pages/<page>.ejs` into the shared
chrome in `views/layout.ejs` (nav, header, WebSocket status dot, theme toggle, command palette).
| Route | Page | View | Page module |
|---|---|---|---|
| `/` | Dashboard | `views/pages/dashboard.ejs` | `public/assets/pages/dashboard.js` |
| `/workers` | Workers | `views/pages/workers.ejs` | `public/assets/pages/workers.js` |
| `/workflows` | Workflows | `views/pages/workflows.ejs` | `public/assets/pages/workflows.js` |
| `/executions` | Executions | `views/pages/executions.ejs` | `public/assets/pages/executions.js` |
| `/quick` | Quick Command | `views/pages/quick.ejs` | `public/assets/pages/quick.js` |
| `/scheduler` | Scheduler | `views/pages/scheduler.ejs` | `public/assets/pages/scheduler.js` |
Scripts load in a fixed order: `/web_template/base.js` → `/assets/app.js` → `/assets/pages/<page>.js`.
`app.js` owns the shell (`window.Pulse`: action registry, event bus, `Pulse.confirm`, formatters,
WebSocket, the single 30 s auto-refresh) and each page module registers itself with:
```js
Pulse.registerPage({ name, init(), refresh(), onEvent(type, data) /* return true if handled */ });
```
Page modules never attach their own listeners for UI actions — they register handlers under their
own action prefix (`dash:`, `wk:`, `wf:`, `ex:`, `qc:`, `sc:`) and the markup wires them up with
`data-action` / `data-change-action` / `data-input-action` / `data-submit-action` attributes that
`app.js` delegates. DOM ids are likewise prefixed per page. All dynamic strings go through
`Pulse.esc`, and destructive actions use the themed `Pulse.confirm` (no native `confirm()`/`alert()`).
**Content Security Policy:** pages are served under a strict nonce-based CSP (helmet), including
`script-src-attr 'none'`. There are **no inline `<script>` blocks without a nonce and no inline
event handler attributes** anywhere in `views/` or `public/assets/`; anything added there must
follow the same rule or the browser will refuse to run it. Set `PULSE_CSP_REPORT_ONLY=1` to switch
the policy to report-only (violations are reported to `/csp-report` and logged) while debugging.
## Overview
@@ -311,8 +421,30 @@ DB_USER=pulse_user # Database user
DB_PASSWORD=<password> # Database password
WORKER_API_KEY=<api-key> # Worker authentication key
EXECUTION_RETENTION_DAYS=30 # Auto-cleanup retention (default: 30)
# Web UI (all optional)
APP_NAME=PULSE # Header/boot/title app name (default: PULSE)
APP_SUBTITLE=<text> # Header subtitle (default: "Worker Orchestration // LotusGuild")
PULSE_CSP_REPORT_ONLY=1 # Serve the CSP report-only instead of enforcing it
PULSE_DEV_READONLY=1 # Local dev guard: disable all background writes (see below)
DISABLE_BACKGROUND_JOBS=1 # Alias for PULSE_DEV_READONLY
```
### Local development against a real database
Running a local instance against the production MariaDB is safe only with the read-only guard on:
```bash
PULSE_DEV_READONLY=1 PORT=8099 HOST=127.0.0.1 npm start
```
`PULSE_DEV_READONLY=1` (alias `DISABLE_BACKGROUND_JOBS=1`) disables every background writer, so a
dev instance can never mutate shared state behind your back: stale-execution recovery at startup,
the old-execution cleanup job (startup call and interval), the scheduled-command processor
(startup call and interval), and the stale-worker offline sweep. Each skip is logged at startup
alongside a `PULSE_DEV_READONLY IS ON` banner. The guard covers background jobs only — API routes
still write, so avoid destructive actions in the UI when you are pointed at production data.
**Worker (.env):**
```bash
WORKER_NAME=pulse-worker-01 # Unique worker name
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

+23
View File
@@ -0,0 +1,23 @@
// Navigation metadata shared by the layout and the page routes.
const navLinks = [
{ href: '/', key: 'dashboard', label: 'Dashboard' },
{ href: '/workers', key: 'workers', label: 'Workers' },
{ href: '/workflows', key: 'workflows', label: 'Workflows' },
{ href: '/executions', key: 'executions', label: 'Executions' },
{ href: '/quick', key: 'quick', label: 'Quick Command' },
{ href: '/scheduler', key: 'scheduler', label: 'Scheduler' }
];
// Page route table: { path, view, key, title }
// `view` is the basename of views/pages/<view>.ejs and of /assets/pages/<view>.js
const PAGES = [
{ path: '/', view: 'dashboard', key: 'dashboard', title: 'Dashboard' },
{ path: '/workers', view: 'workers', key: 'workers', title: 'Workers' },
{ path: '/workflows', view: 'workflows', key: 'workflows', title: 'Workflows' },
{ path: '/executions', view: 'executions', key: 'executions', title: 'Executions' },
{ path: '/quick', view: 'quick', key: 'quick', title: 'Quick Command' },
{ path: '/scheduler', view: 'scheduler', key: 'scheduler', title: 'Scheduler' }
];
module.exports = { navLinks, PAGES };
+70
View File
@@ -0,0 +1,70 @@
// Authelia SSO helpers shared by the JSON API middleware (server.js authenticateSSO)
// and the HTML page middleware (authenticatePage).
const crypto = require('crypto');
const { renderError } = require('./render');
const ALLOWED_GROUPS = ['admin', 'employee'];
// Upsert the SSO user into the `users` table. Extracted verbatim from authenticateSSO.
async function upsertUser(pool, headers) {
const userId = crypto.randomUUID();
await pool.query(
`INSERT INTO users (id, username, display_name, email, groups, last_login)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
display_name=VALUES(display_name),
email=VALUES(email),
groups=VALUES(groups),
last_login=NOW()`,
[
userId,
headers['remote-user'],
headers['remote-name'],
headers['remote-email'],
headers['remote-groups']
]
);
}
// Express middleware factory for HTML page routes: same auth rules as the API,
// but failures render a themed HTML page instead of JSON.
function makeAuthenticatePage(pool) {
return async function authenticatePage(req, res, next) {
const remoteUser = req.headers['remote-user'];
const remoteName = req.headers['remote-name'];
const remoteEmail = req.headers['remote-email'];
const remoteGroups = req.headers['remote-groups'];
if (!remoteUser) {
return renderError(req, res, 401, 'Not authenticated',
'Not authenticated — access via Authelia SSO (auth.lotusguild.org).');
}
const groups = remoteGroups ? remoteGroups.split(',').map(g => g.trim()) : [];
const hasAccess = groups.some(g => ALLOWED_GROUPS.includes(g));
if (!hasAccess) {
return renderError(req, res, 403, 'Access denied',
'You must be in the admin or employee group to use this service.');
}
try {
await upsertUser(pool, req.headers);
} catch (error) {
console.error('Error updating user:', error);
}
req.user = {
username: remoteUser,
name: remoteName || remoteUser,
email: remoteEmail || '',
groups: groups,
isAdmin: groups.includes('admin')
};
next();
};
}
module.exports = { upsertUser, makeAuthenticatePage, ALLOWED_GROUPS };
+100
View File
@@ -0,0 +1,100 @@
// Two-step EJS rendering: views/pages/<view>.ejs -> `body` -> views/layout.ejs.
// No express-ejs-layouts; the vendored layout is a wrapper file that expects `body`.
const fs = require('fs');
const path = require('path');
const ejs = require('ejs');
const { navLinks } = require('./nav');
const ROOT = path.join(__dirname, '..');
const VIEWS_DIR = path.join(ROOT, 'views');
const PAGES_DIR = path.join(VIEWS_DIR, 'pages');
const LAYOUT = path.join(VIEWS_DIR, 'layout.ejs');
const STUB = path.join(PAGES_DIR, '_stub.ejs');
const CACHE = process.env.NODE_ENV === 'production';
// Cache-busting token: <package version>-<web_template short sha>, computed once.
const assetVersion = (function computeAssetVersion() {
let version = '0.0.0';
try {
version = require(path.join(ROOT, 'package.json')).version || '0.0.0';
} catch (_) { /* keep default */ }
let sha = '';
try {
const raw = fs.readFileSync(path.join(ROOT, 'public/web_template/VERSION'), 'utf8').trim();
sha = (raw.split(/\s+/)[1] || '').trim();
} catch (_) { /* VERSION not vendored yet */ }
return sha ? `${version}-${sha}` : version;
})();
const APP_NAME = process.env.APP_NAME || 'PULSE';
const APP_SUBTITLE = process.env.APP_SUBTITLE || 'Worker Orchestration // LotusGuild';
function viewPath(view) {
const file = path.join(PAGES_DIR, `${view}.ejs`);
return fs.existsSync(file) ? file : STUB;
}
// Render a page view inside the shared layout.
async function renderPage(req, res, view, locals = {}) {
const data = Object.assign({
user: req.user || null,
nonce: res.locals && res.locals.nonce,
appName: APP_NAME,
appSubtitle: APP_SUBTITLE,
csrfToken: '',
navLinks,
pageTitle: '',
activeNav: '',
pageStyles: [],
pageScripts: [],
pageConfig: {},
assetVersion
}, locals);
const opts = { cache: CACHE, filename: viewPath(view) };
const body = await ejs.renderFile(viewPath(view), data, opts);
const html = await ejs.renderFile(LAYOUT, Object.assign({}, data, { body }), {
cache: CACHE,
filename: LAYOUT
});
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(html);
return html;
}
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Small self-contained themed page for 401/403/404/500.
function renderError(req, res, status, title, message) {
const html = `<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>${esc(status)} — ${esc(APP_NAME)}</title>
<link rel="stylesheet" href="/web_template/base.css?v=${esc(assetVersion)}">
</head>
<body>
<main class="lt-main lt-container">
<div class="lt-frame">
<div class="lt-alert lt-alert--error">
<strong>${esc(status)} — ${esc(title)}</strong>
<p>${esc(message)}</p>
</div>
<p><a class="lt-btn lt-btn-secondary lt-btn-sm" href="/">Return to dashboard</a></p>
</div>
</main>
</body>
</html>
`;
res.status(status).set('Content-Type', 'text/html; charset=utf-8').send(html);
}
module.exports = { renderPage, renderError, assetVersion };
+514 -219
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,8 +1,9 @@
{
"name": "pulse-server",
"version": "1.0.0",
"main": "index.js",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "jest --coverage"
},
"keywords": [],
@@ -10,10 +11,13 @@
"license": "ISC",
"description": "",
"dependencies": {
"axios": "^1.20.0",
"cron-parser": "^5.5.0",
"dotenv": "^17.2.3",
"ejs": "3.1.10",
"express": "^5.1.0",
"express-rate-limit": "^8.3.1",
"helmet": "8.1.0",
"mysql2": "^3.15.3",
"ws": "^8.18.3"
},
+7
View File
@@ -1,6 +1,13 @@
{
"env": { "browser": true, "es2021": true },
"parserOptions": { "ecmaVersion": 2021, "sourceType": "script" },
"globals": {
"lt": "readonly",
"Pulse": "writable",
"CSRF_TOKEN": "readonly",
"CURRENT_USER": "readonly",
"PULSE_CONFIG": "readonly"
},
"rules": {
"no-unused-vars": "warn",
"no-empty": "warn",
+184
View File
@@ -0,0 +1,184 @@
/* =====================================================================
PULSE — shell additions on top of /web_template/base.css
---------------------------------------------------------------------
Rules here are Pulse-specific only. No `.lt-*` base rule is redefined;
`.lt-*` selectors appear only as ancestors/descendants of a `.pulse-*`
class, or as scoped layout tweaks inside the Pulse header cluster.
Page-specific styles live in /assets/pages/<page>.css.
===================================================================== */
/* ---------------------------------------------------------------------
1. Header right cluster — WS status, last-refreshed stamp, buttons
--------------------------------------------------------------------- */
.pulse-header-right,
.lt-header-right:has(#pulse-ws-status) {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-sm);
min-width: 0;
row-gap: 2px;
}
#pulse-last-refreshed {
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.04em;
color: var(--text-dim);
white-space: nowrap;
flex-shrink: 0;
}
/* The stamp is the first thing to go when space runs out. */
@media (max-width: 767px) {
#pulse-last-refreshed { display: none; }
}
@media (max-width: 479px) {
.pulse-header-right,
.lt-header-right:has(#pulse-ws-status) { gap: var(--space-xs); }
}
/* ---------------------------------------------------------------------
2. Confirm / alert modal (built by Pulse.confirm / Pulse.alertModal)
--------------------------------------------------------------------- */
.pulse-confirm .lt-modal-header { border-bottom: 1px solid var(--border-color); }
.pulse-confirm .pulse-confirm-msg {
margin: 0;
font-size: 0.82rem;
line-height: 1.55;
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.pulse-confirm .lt-modal-footer { gap: var(--space-sm); }
.pulse-confirm--warning .lt-modal { border-top: 2px solid var(--accent-amber); }
.pulse-confirm--error .lt-modal { border-top: 2px solid var(--accent-red); }
.pulse-confirm--info .lt-modal { border-top: 2px solid var(--accent-cyan); }
/* ---------------------------------------------------------------------
3. Compact meta line (base.css already provides .lt-kv-grid for
key/value blocks — use that; this is only for inline meta strings)
--------------------------------------------------------------------- */
.pulse-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs) var(--space-md);
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
}
.pulse-meta > span { white-space: nowrap; }
.pulse-meta-label {
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.pulse-meta-val { color: var(--text-secondary); }
/* ---------------------------------------------------------------------
4. Log viewer wrapper — caps the height of a long .lt-log-output run
--------------------------------------------------------------------- */
.pulse-log {
max-height: 420px;
overflow-y: auto;
overflow-x: auto;
background: var(--bg-terminal);
border: 1px solid var(--border-dim);
padding: var(--space-sm);
}
.pulse-log .lt-log-output {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.pulse-log--sm { max-height: 220px; }
.pulse-log--lg { max-height: 60vh; }
@media (max-width: 767px) {
.pulse-log { max-height: 300px; }
}
/* ---------------------------------------------------------------------
5. Running rows — amber left-border pulse (port of exec-running-pulse)
--------------------------------------------------------------------- */
.pulse-running {
border-left: 2px solid var(--accent-amber);
animation: pulse-running-border 2s ease-in-out infinite;
}
@keyframes pulse-running-border {
0%, 100% { border-left-color: var(--accent-green); box-shadow: none; }
50% { border-left-color: var(--accent-amber); box-shadow: -2px 0 8px rgba(255, 179, 0, 0.35); }
}
@media (prefers-reduced-motion: reduce) {
.pulse-running {
animation: none;
border-left-color: var(--accent-amber);
}
}
/* ---------------------------------------------------------------------
6. Utilities
--------------------------------------------------------------------- */
.is-hidden { display: none !important; }
.pulse-nowrap { white-space: nowrap; }
.pulse-mono { font-family: var(--font-mono); }
.pulse-dim { color: var(--text-dim); }
.pulse-scroll-x { overflow-x: auto; -webkit-overflow-scrolling: touch; }
/* ---------------------------------------------------------------------
7. Shared component gap-fillers (base.css does not ship these)
---------------------------------------------------------------------
`.lt-modal-lg` — base.css ships .lt-modal-xs/.lt-modal-sm only, but
several Pulse pages need a wide modal (JSON editors, log viewers,
side-by-side compare). Sized the same way base.css sizes its own
modifiers, and re-capped at the two breakpoints base.css caps
`.lt-modal` at, because this selector is more specific than those.
`.lt-field-error` / `.is-invalid` — base.css references a field-error
hook but ships no visual style for it.
Both are candidates for upstreaming into base.css.
--------------------------------------------------------------------- */
.lt-modal.lt-modal-lg {
width: min(900px, 95vw);
max-width: 900px;
}
@media (max-width: 767px) { .lt-modal.lt-modal-lg { max-width: 96vw; } }
@media (max-width: 479px) { .lt-modal.lt-modal-lg { max-width: 100vw; } }
.lt-field-error {
color: var(--accent-red);
font-size: 0.72rem;
letter-spacing: 0.02em;
margin-top: 4px;
}
.lt-input.is-invalid,
.lt-textarea.is-invalid,
.lt-select.is-invalid {
border-color: var(--accent-red);
}
/* ---------------------------------------------------------------------
8. Light theme overrides for the additions above
--------------------------------------------------------------------- */
html[data-theme="light"] #pulse-last-refreshed { color: var(--text-muted); }
html[data-theme="light"] .pulse-confirm .pulse-confirm-msg { color: var(--text-secondary); }
html[data-theme="light"] .pulse-log {
background: var(--bg-tertiary);
border-color: var(--border-color);
}
html[data-theme="light"] .pulse-meta { color: var(--text-muted); }
html[data-theme="light"] .pulse-meta-val { color: var(--text-primary); }
html[data-theme="light"] .pulse-dim { color: var(--text-muted); }
html[data-theme="light"] .pulse-running {
box-shadow: none;
}
+685
View File
@@ -0,0 +1,685 @@
/* =====================================================================
PULSE — shared frontend shell (WP-B)
---------------------------------------------------------------------
Loaded after /web_template/base.js and before /assets/pages/<page>.js.
Exposes the frozen `window.Pulse` contract consumed by page modules.
===================================================================== */
'use strict';
/* ---------------------------------------------------------------------
0. 401 → reload wrapper. Installed first, before anything can fetch.
Authelia sessions expire; a 401 means "log in again", so force a full
document reload which bounces through the SSO portal.
--------------------------------------------------------------------- */
(function () {
const _fetch = window.fetch;
if (typeof _fetch !== 'function' || _fetch.__pulseWrapped) return;
const wrapped = async function (...args) {
const resp = await _fetch.apply(window, args);
if (resp.status === 401) {
window.location.reload();
throw new Error('Session expired — reloading');
}
return resp;
};
wrapped.__pulseWrapped = true;
window.fetch = wrapped;
})();
(function (global) {
const LOG = '[Pulse]';
const noopLt = {};
/** base.js is a hard dependency, but never let its absence blank the page. */
function LT() { return global.lt || noopLt; }
function warn() {
const a = Array.prototype.slice.call(arguments);
console.warn.apply(console, [LOG].concat(a));
}
function err() {
const a = Array.prototype.slice.call(arguments);
console.error.apply(console, [LOG].concat(a));
}
/* -------------------------------------------------------------------
1. Escaping / formatting helpers
Output formats are byte-compatible with the pre-redesign index.html
helpers so page modules render identical strings.
------------------------------------------------------------------- */
function esc(text) {
if (text === null || text === undefined) return '';
if (LT().escHtml) return LT().escHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/** null for falsy/invalid, otherwise a Date. */
function safeDate(val) {
if (!val) return null;
const d = val instanceof Date ? val : new Date(val);
return isNaN(d.getTime()) ? null : d;
}
/** '0 B' for falsy; one decimal otherwise. */
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
/** 'Nd Nh Nm' / 'Nh Nm' / 'Nm'; 'N/A' for falsy. */
function formatUptime(seconds) {
if (!seconds) return 'N/A';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return days + 'd ' + hours + 'h ' + minutes + 'm';
if (hours > 0) return hours + 'h ' + minutes + 'm';
return minutes + 'm';
}
/** 'Ns ago' / 'Nm ago' / 'Nh ago' / 'Nd ago'; 'just now' if in the future. */
function timeAgo(date) {
const d = date instanceof Date ? date : safeDate(date);
if (!d || isNaN(d.getTime())) return 'N/A';
const seconds = Math.floor((Date.now() - d.getTime()) / 1000);
if (seconds < 0) return 'just now';
if (seconds < 60) return seconds + 's ago';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ago';
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + 'h ago';
return Math.floor(hours / 24) + 'd ago';
}
/** 'Ns' / 'Nm Ns' / 'Nh Nm'; '' when startedAt is unusable. */
function formatElapsed(startedAt) {
const start = safeDate(startedAt);
if (!start) return '';
const secs = Math.floor((Date.now() - start.getTime()) / 1000);
if (secs < 60) return secs + 's';
const mins = Math.floor(secs / 60);
if (mins < 60) return mins + 'm ' + (secs % 60) + 's';
return Math.floor(mins / 60) + 'h ' + (mins % 60) + 'm';
}
/** HH:MM:SS in local time. */
function clock(d) {
const date = d instanceof Date ? d : (safeDate(d) || new Date());
const p = n => String(n).padStart(2, '0');
return p(date.getHours()) + ':' + p(date.getMinutes()) + ':' + p(date.getSeconds());
}
/** toLocaleString(), or 'N/A'. */
function dateTime(val) {
const d = safeDate(val);
return d ? d.toLocaleString() : 'N/A';
}
const STATUS_CLASSES = {
online: 'online',
offline: 'offline',
running: 'running',
completed: 'completed',
failed: 'failed',
waiting: 'pending',
};
/** Badge classes for a status string. */
function statusClass(status) {
const key = String(status || '').toLowerCase();
return 'lt-status lt-status-' + (STATUS_CLASSES[key] || 'pending');
}
const fmt = {
elapsed: formatElapsed,
safeDate: safeDate,
bytes: formatBytes,
uptime: formatUptime,
ago: timeAgo,
clock: clock,
dateTime: dateTime,
status: statusClass,
};
/* -------------------------------------------------------------------
2. Small utilities
------------------------------------------------------------------- */
function download(filename, text, mime) {
try {
const blob = new Blob([text], { type: mime || 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
err('download failed', e);
}
}
/* Verbatim localStorage keys — `commandHistory` and `pulse_executionView`
must keep working across the redesign, so NO prefix is applied. */
const storage = {
get(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback !== undefined ? fallback : null;
return JSON.parse(raw);
} catch (e) {
return fallback !== undefined ? fallback : null;
}
},
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
warn('storage.set failed for', key, e);
return false;
}
},
remove(key) {
try { localStorage.removeItem(key); } catch (e) { /* ignore */ }
},
};
/* -------------------------------------------------------------------
3. Event bus (Pulse-local, independent of lt.bus)
------------------------------------------------------------------- */
const _handlers = new Map();
const events = {
on(type, fn) {
if (typeof fn !== 'function') return;
if (!_handlers.has(type)) _handlers.set(type, []);
_handlers.get(type).push(fn);
},
off(type, fn) {
const list = _handlers.get(type);
if (list) _handlers.set(type, list.filter(f => f !== fn));
},
emit(type, data) {
const list = _handlers.get(type);
if (!list || !list.length) return;
list.slice().forEach(fn => {
try { fn(data, type); } catch (e) { err('event handler for "' + type + '"', e); }
});
},
};
/* -------------------------------------------------------------------
4. Action registry + delegated listeners
------------------------------------------------------------------- */
const _actions = Object.create(null);
const _warned = Object.create(null);
const actions = {
register(name, fn) {
if (!name || typeof fn !== 'function') { warn('actions.register: bad arguments', name); return; }
if (_actions[name]) warn('action "' + name + '" re-registered');
_actions[name] = fn;
},
registerAll(map) {
Object.keys(map || {}).forEach(name => actions.register(name, map[name]));
},
has(name) { return !!_actions[name]; },
names() { return Object.keys(_actions); },
};
function runAction(name, el, ev) {
const fn = _actions[name];
if (!fn) {
if (!_warned[name]) { _warned[name] = true; warn('unknown action "' + name + '"'); }
return;
}
try {
const r = fn(el, ev);
if (r && typeof r.catch === 'function') {
r.catch(e => err('action "' + name + '" rejected', e));
}
} catch (e) {
err('action "' + name + '" threw', e);
}
}
function isDisabled(el) {
return el.disabled === true || el.getAttribute('aria-disabled') === 'true' || el.classList.contains('is-disabled');
}
function installDelegation() {
document.addEventListener('click', function (ev) {
let el;
try { el = ev.target.closest && ev.target.closest('[data-action]'); } catch (e) { return; }
if (!el) return;
if (isDisabled(el)) { ev.preventDefault(); return; }
const tag = el.tagName;
if ((tag === 'A' && (el.getAttribute('href') === '#' || el.getAttribute('href') === '')) ||
(tag === 'BUTTON' && el.form && !el.getAttribute('type'))) {
ev.preventDefault();
}
runAction(el.getAttribute('data-action'), el, ev);
});
document.addEventListener('change', function (ev) {
let el;
try { el = ev.target.closest && ev.target.closest('[data-change-action]'); } catch (e) { return; }
if (!el || isDisabled(el)) return;
runAction(el.getAttribute('data-change-action'), el, ev);
});
/* Per-element 200 ms debounce, so two search boxes never share a timer. */
const _debounced = new WeakMap();
document.addEventListener('input', function (ev) {
let el;
try { el = ev.target.closest && ev.target.closest('[data-input-action]'); } catch (e) { return; }
if (!el || isDisabled(el)) return;
let fn = _debounced.get(el);
if (!fn) {
const mk = LT().debounce || function (f, ms) {
let t;
return function () {
const a = arguments, c = this;
clearTimeout(t);
t = setTimeout(() => f.apply(c, a), ms);
};
};
fn = mk(function (e) { runAction(el.getAttribute('data-input-action'), el, e); }, 200);
_debounced.set(el, fn);
}
fn(ev);
});
document.addEventListener('submit', function (ev) {
let el;
try { el = ev.target.closest && ev.target.closest('form[data-submit-action]'); } catch (e) { return; }
if (!el) return;
ev.preventDefault();
runAction(el.getAttribute('data-submit-action'), el, ev);
});
}
/* -------------------------------------------------------------------
5. Confirm / alert modals (ported from tinker_tickets utils.js)
------------------------------------------------------------------- */
const MODAL_COLORS = {
warning: 'var(--accent-amber)',
error: 'var(--accent-red)',
info: 'var(--accent-cyan)',
};
const MODAL_ICONS = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
let _modalSeq = 0;
function buildModal(opts, withCancel) {
const o = opts || {};
const type = MODAL_ICONS[o.type] ? o.type : 'warning';
const id = 'pulse-confirm-' + (++_modalSeq) + '-' + Date.now();
const color = MODAL_COLORS[type];
const icon = MODAL_ICONS[type];
const title = esc(o.title || (withCancel ? 'Confirm' : 'Notice'));
const message = esc(o.message === null || o.message === undefined ? '' : o.message).replace(/\n/g, '<br>');
const confirmLabel = esc(o.confirmLabel || (withCancel ? 'CONFIRM' : 'OK'));
const cancelLabel = esc(o.cancelLabel || 'CANCEL');
const confirmClass = type === 'error' ? 'lt-btn lt-btn-danger' : 'lt-btn lt-btn-primary';
const html =
'<div class="lt-modal-overlay pulse-confirm pulse-confirm--' + type + '" id="' + id + '"' +
' aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="' + id + '-title">' +
'<div class="lt-modal lt-modal-sm">' +
'<div class="lt-modal-header" style="color:' + color + ';">' +
'<span class="lt-modal-title" id="' + id + '-title">' + icon + ' ' + title + '</span>' +
'<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>' +
'</div>' +
'<div class="lt-modal-body">' +
'<p class="pulse-confirm-msg">' + message + '</p>' +
'</div>' +
'<div class="lt-modal-footer">' +
'<button type="button" class="' + confirmClass + '" id="' + id + '-confirm">' + confirmLabel + '</button>' +
(withCancel
? '<button type="button" class="lt-btn lt-btn-ghost" id="' + id + '-cancel">' + cancelLabel + '</button>'
: '') +
'</div>' +
'</div>' +
'</div>';
document.body.insertAdjacentHTML('beforeend', html);
return { id: id, el: document.getElementById(id), type: type };
}
function openModalEl(built, settle) {
const el = built.el;
let done = false;
function finish(result) {
if (done) return;
done = true;
try {
if (LT().modal && el.classList.contains('is-open')) LT().modal.close(el);
else el.classList.remove('is-open');
} catch (e) { err('modal close failed', e); }
setTimeout(() => { if (el && el.parentNode) el.parentNode.removeChild(el); }, 300);
settle(result);
}
/* ESC and backdrop clicks are handled globally by base.js, which fires
lt:modalclose — treat both as a cancel. */
el.addEventListener('lt:modalclose', () => finish(false));
return finish;
}
function confirmModal(opts) {
return new Promise(resolve => {
let built;
try { built = buildModal(opts, true); } catch (e) { err('confirm build failed', e); resolve(false); return; }
const finish = openModalEl(built, resolve);
const confirmBtn = document.getElementById(built.id + '-confirm');
const cancelBtn = document.getElementById(built.id + '-cancel');
if (confirmBtn) confirmBtn.addEventListener('click', () => finish(true));
if (cancelBtn) cancelBtn.addEventListener('click', () => finish(false));
try { if (LT().modal) LT().modal.open(built.el); else built.el.classList.add('is-open'); } catch (e) { err(e); }
/* Destructive prompts should not have CONFIRM under the cursor/keyboard. */
if ((built.type === 'error' || built.type === 'warning') && cancelBtn) {
setTimeout(() => { try { cancelBtn.focus(); } catch (e) { /* ignore */ } }, 60);
}
});
}
function alertModal(opts) {
return new Promise(resolve => {
let built;
try { built = buildModal(opts, false); } catch (e) { err('alert build failed', e); resolve(); return; }
const finish = openModalEl(built, () => resolve());
const okBtn = document.getElementById(built.id + '-confirm');
if (okBtn) okBtn.addEventListener('click', () => finish(true));
try { if (LT().modal) LT().modal.open(built.el); else built.el.classList.add('is-open'); } catch (e) { err(e); }
});
}
/* -------------------------------------------------------------------
6. Page registration & refresh
------------------------------------------------------------------- */
let _booted = false;
let _pageInited = false;
function registerPage(page) {
if (!page || typeof page !== 'object') { warn('registerPage: expected an object'); return; }
Pulse.page = page;
if (_booted) initPage();
}
async function initPage() {
const page = Pulse.page;
if (!page || _pageInited) return;
_pageInited = true;
if (typeof page.init !== 'function') return;
try {
await page.init();
} catch (e) {
err('page init failed', e);
toastSafe('error', 'Page failed to initialise');
}
}
function toastSafe(kind, msg) {
try {
const t = LT().toast;
if (t && t[kind]) t[kind](msg);
else console.log(LOG, kind + ':', msg);
} catch (e) { /* never let a toast break a handler */ }
}
function stampRefreshed() {
const el = document.getElementById('pulse-last-refreshed');
if (el) el.textContent = 'Refreshed: ' + clock(new Date());
}
async function refreshNow() {
const page = Pulse.page;
if (page && typeof page.refresh === 'function') {
try {
await page.refresh();
} catch (e) {
err('page refresh failed', e);
toastSafe('error', 'Refresh failed');
}
}
stampRefreshed();
}
/* -------------------------------------------------------------------
7. WebSocket
------------------------------------------------------------------- */
const KNOWN_TYPES = [
'command_result', 'workflow_result', 'worker_update', 'execution_started',
'execution_status', 'workflow_created', 'workflow_deleted', 'workflow_updated',
'execution_prompt', 'executions_bulk_deleted',
];
let _wsHandle = null;
let _wasDisconnected = false;
function wsUrl() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + location.host;
}
function setWsStatus(state) {
const el = document.getElementById('pulse-ws-status');
if (!el) return;
el.setAttribute('data-state', state);
const labels = { connected: 'Connected', connecting: 'Connecting…', disconnected: 'Disconnected' };
const span = el.querySelector('span:last-child');
if (span) span.textContent = labels[state] || state;
}
function handleWsMessage(data) {
try {
if (!data || typeof data !== 'object' || !data.type) return;
if (data.type === 'command_result' && !data.is_automated) {
if (data.success) toastSafe('success', 'Command completed successfully');
else toastSafe('error', 'Command execution failed');
}
let handled = false;
const page = Pulse.page;
if (page && typeof page.onEvent === 'function') {
try {
handled = page.onEvent(data.type, data) === true;
} catch (e) {
err('page onEvent failed for "' + data.type + '"', e);
}
}
events.emit(data.type, data);
if (KNOWN_TYPES.indexOf(data.type) === -1 && !handled) {
refreshNow();
}
} catch (e) {
err('WebSocket message handling failed', e);
}
}
function onWsOpen() {
setWsStatus('connected');
if (_wasDisconnected) {
_wasDisconnected = false;
toastSafe('info', 'Live updates reconnected');
refreshNow();
}
}
function onWsClose() {
_wasDisconnected = true;
setWsStatus('disconnected');
}
/** Raw-WebSocket fallback used only when lt.ws is missing. */
function connectRawWs() {
let sock;
function open() {
setWsStatus('connecting');
try { sock = new WebSocket(wsUrl()); } catch (e) { err('WebSocket create failed', e); setTimeout(open, 5000); return; }
sock.addEventListener('open', onWsOpen);
sock.addEventListener('message', ev => {
let data = ev.data;
try { data = JSON.parse(ev.data); } catch (e) { /* non-JSON frame */ }
handleWsMessage(data);
});
sock.addEventListener('close', () => { onWsClose(); setTimeout(open, 5000); });
sock.addEventListener('error', e => warn('WebSocket error', e));
}
open();
return { send(d) { if (sock && sock.readyState === 1) { sock.send(typeof d === 'string' ? d : JSON.stringify(d)); return true; } return false; } };
}
function connectWs() {
try {
if (LT().ws && typeof LT().ws.connect === 'function') {
_wsHandle = LT().ws.connect(wsUrl(), {
statusEl: '#pulse-ws-status',
reconnect: true,
reconnectDelay: 2000,
maxRetries: Number.MAX_SAFE_INTEGER,
onOpen: onWsOpen,
onClose: onWsClose,
onError: e => warn('WebSocket error', e),
onMessage: handleWsMessage,
});
} else {
warn('lt.ws unavailable — using raw WebSocket fallback');
_wsHandle = connectRawWs();
}
} catch (e) {
err('WebSocket setup failed', e);
try { _wsHandle = connectRawWs(); } catch (e2) { err('WebSocket fallback failed', e2); }
}
}
/* -------------------------------------------------------------------
8. Boot
------------------------------------------------------------------- */
const NAV_ROUTES = [
{ id: 'nav-dashboard', label: 'Dashboard', path: '/', tags: ['home', 'overview'] },
{ id: 'nav-workers', label: 'Workers', path: '/workers', tags: ['agents', 'hosts'] },
{ id: 'nav-workflows', label: 'Workflows', path: '/workflows', tags: ['jobs'] },
{ id: 'nav-executions', label: 'Executions', path: '/executions', tags: ['history', 'logs', 'runs'] },
{ id: 'nav-quick', label: 'Quick Command', path: '/quick', tags: ['run', 'shell', 'command'] },
{ id: 'nav-scheduler', label: 'Scheduler', path: '/scheduler', tags: ['cron', 'schedule'] },
];
function openKeysHelp() {
const help = document.getElementById('lt-keys-help');
if (help && LT().modal) LT().modal.open(help);
}
function buildCommands() {
const cmds = NAV_ROUTES.map(r => ({
id: r.id,
label: r.label,
icon: '→',
group: 'Navigate',
tags: r.tags,
action: () => { window.location.href = r.path; },
}));
cmds.push(
{ id: 'act-refresh', label: 'Refresh', icon: '⟳', group: 'Actions', kbd: 'R', tags: ['reload'], action: () => refreshNow() },
{ id: 'act-theme', label: 'Toggle Theme', icon: '◐', group: 'Actions', tags: ['dark', 'light'], action: () => { if (LT().theme) LT().theme.toggle(); } },
{ id: 'help-keys', label: 'Keyboard Shortcuts', icon: '?', group: 'Help', kbd: '?', tags: ['keys', 'shortcuts'], action: openKeysHelp }
);
return cmds;
}
let _tickTimer = null;
function startTicker() {
if (_tickTimer) return;
_tickTimer = setInterval(() => {
if (document.hidden) return; // cheap: no work while backgrounded
if (!_handlers.has('tick')) return;
events.emit('tick');
}, 1000);
}
function boot() {
if (_booted) return;
_booted = true;
try { if (LT().init) LT().init({ bootName: 'PULSE' }); } catch (e) { err('lt.init failed', e); }
const themeBtn = document.getElementById('lt-theme-btn');
if (themeBtn) themeBtn.addEventListener('click', () => { if (LT().theme) LT().theme.toggle(); });
try { if (LT().cmdPalette) LT().cmdPalette.init(buildCommands()); } catch (e) { err('cmdPalette init failed', e); }
try {
if (LT().keys) {
LT().keys.on('r', () => refreshNow());
LT().keys.on('?', openKeysHelp);
}
} catch (e) { err('key binding failed', e); }
connectWs();
startTicker();
initPage();
/* The ONLY autoRefresh registration in the whole app. Page modules must
never call lt.autoRefresh — they get refreshed through page.refresh(). */
try {
if (LT().autoRefresh) LT().autoRefresh.start(() => refreshNow(), 30000);
} catch (e) { err('autoRefresh start failed', e); }
}
/* -------------------------------------------------------------------
9. Public surface
------------------------------------------------------------------- */
const Pulse = {
user: global.CURRENT_USER || { username: '', name: '', email: '', groups: [], isAdmin: false },
isAdmin: false,
config: global.PULSE_CONFIG || {},
page: null,
actions: actions,
events: events,
registerPage: registerPage,
refreshNow: refreshNow,
confirm: confirmModal,
alertModal: alertModal,
get api() { return LT().api; },
get toast() { return LT().toast; },
get beep() { return LT().beep; },
esc: esc,
fmt: fmt,
util: { download: download, storage: storage },
/** Escape hatch for pages that need to push a frame upstream. */
ws: { send(d) { return _wsHandle && _wsHandle.send ? _wsHandle.send(d) : false; }, get handle() { return _wsHandle; } },
};
Pulse.isAdmin = !!(Pulse.user && Pulse.user.isAdmin) || !!(Pulse.config && Pulse.config.isAdmin);
global.Pulse = Pulse;
/* Reserved global actions (unprefixed namespace belongs to app.js). */
actions.registerAll({
'app:refresh': () => refreshNow(),
'app:theme': () => { if (LT().theme) LT().theme.toggle(); },
'open-nav-drawer': () => { if (LT().mobileNav) LT().mobileNav.open(); },
'open-cmdpalette': () => { if (LT().cmdPalette) LT().cmdPalette.open(); },
'app:keys-help': openKeysHelp,
});
installDelegation();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
/* Script ran after parsing (deferred/injected): boot on the next tick so a
page module loaded right after us can still register before init(). */
setTimeout(boot, 0);
}
})(window);
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

+36
View File
@@ -0,0 +1,36 @@
/* Dashboard page (WP-C) — recent executions / workers summary layout. */
.dash-worker-list {
display: flex;
flex-direction: column;
}
.dash-worker-row {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border-color-dim);
}
.dash-worker-row:last-child { border-bottom: none; }
.dash-worker-main {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.dash-worker-name {
font-weight: 700;
}
.dash-worker-lastseen {
font-size: 0.7rem;
color: var(--text-muted);
margin-left: auto;
}
.dash-worker-stats {
display: flex;
gap: var(--space-md);
font-size: 0.7rem;
color: var(--text-secondary);
margin-top: 4px;
}
+223
View File
@@ -0,0 +1,223 @@
/* =====================================================================
PULSE — Dashboard page (WP-C)
Owns DOM id prefix `dash-` and action namespace `dash:*`.
===================================================================== */
'use strict';
(function () {
const esc = Pulse.esc;
const fmt = Pulse.fmt;
let _executions = [];
let _workers = [];
function parseMeta(worker) {
if (!worker || !worker.metadata) return null;
if (typeof worker.metadata === 'string') {
try { return JSON.parse(worker.metadata); } catch (e) { return null; }
}
return worker.metadata;
}
function isAutomated(exec) {
const by = String((exec && exec.started_by) || '');
return by.indexOf('gandalf:') === 0 || by.indexOf('scheduler:') === 0;
}
/* -------------------------------------------------------------------
Stats
------------------------------------------------------------------- */
function renderStats(workers, runningTotal) {
const total = workers.length;
const online = workers.filter(w => w.status === 'online').length;
const offline = total - online;
const set = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = String(val);
};
set('dash-stat-total-val', total);
set('dash-stat-online-val', online);
set('dash-stat-offline-val', offline);
set('dash-stat-running-val', runningTotal);
}
/* -------------------------------------------------------------------
Recent executions (5 most recent manual runs)
------------------------------------------------------------------- */
function executionRowHtml(e) {
const statusClass = fmt.status(e.status);
const statusText = esc(String(e.status || '').toUpperCase());
const name = e.workflow_name ? esc(e.workflow_name) : '[Quick Command]';
const startedBy = esc(e.started_by || '');
const startedAt = fmt.dateTime(e.started_at);
const isRunning = String(e.status || '').toLowerCase() === 'running';
const elapsed = isRunning ? esc(fmt.elapsed(e.started_at)) : '&mdash;';
return (
'<tr data-action="dash:open-execution" data-execution-id="' + esc(e.id) + '">' +
'<td data-label="Status"><span class="' + statusClass + '">' + statusText + '</span></td>' +
'<td data-label="Name">' + name + '</td>' +
'<td data-label="Started By">' + startedBy + '</td>' +
'<td data-label="Started At">' + esc(startedAt) + '</td>' +
'<td data-label="Elapsed" class="dash-elapsed"' +
(isRunning ? ' data-started-at="' + esc(e.started_at) + '"' : '') + '>' + elapsed + '</td>' +
'</tr>'
);
}
function renderExecutions(executions) {
const wrap = document.getElementById('dash-executions-wrap');
if (!wrap) return;
const manual = executions.filter(e => !isAutomated(e)).slice(0, 5);
if (manual.length === 0) {
wrap.innerHTML =
'<div class="lt-empty-state lt-empty-state--sm">' +
'<div class="lt-empty-state-title">No executions yet</div>' +
'</div>';
return;
}
wrap.innerHTML =
'<table class="lt-table lt-table-sm lt-table-responsive">' +
'<thead><tr><th>Status</th><th>Name</th><th>Started By</th><th>Started At</th><th>Elapsed</th></tr></thead>' +
'<tbody>' + manual.map(executionRowHtml).join('') + '</tbody>' +
'</table>';
}
/* -------------------------------------------------------------------
Workers summary
------------------------------------------------------------------- */
function workerRowHtml(w) {
const meta = parseMeta(w);
const dotClass = w.status === 'online' ? 'lt-dot-up' : 'lt-dot-down';
const statusClass = fmt.status(w.status);
const lastSeen = fmt.ago(w.last_heartbeat);
let stats = '';
if (meta) {
stats =
'<span>CPU: ' + esc(meta.cpus || '?') + ' cores</span>' +
'<span>RAM: ' + fmt.bytes(meta.totalMem - meta.freeMem) + ' / ' + fmt.bytes(meta.totalMem) + '</span>' +
'<span>Tasks: ' + esc(meta.activeTasks || 0) + '/' + esc(meta.maxConcurrentTasks || 0) + '</span>';
}
return (
'<div class="dash-worker-row" data-worker-id="' + esc(w.id) + '">' +
'<div class="dash-worker-main">' +
'<span class="lt-dot ' + dotClass + '"></span>' +
'<span class="dash-worker-name">' + esc(w.name) + '</span>' +
'<span class="' + statusClass + '">' + esc(String(w.status || '').toUpperCase()) + '</span>' +
'<span class="dash-worker-lastseen" data-last-heartbeat="' + esc(w.last_heartbeat || '') + '">Last seen: ' + esc(lastSeen) + '</span>' +
'</div>' +
(stats ? '<div class="dash-worker-stats">' + stats + '</div>' : '') +
'</div>'
);
}
function renderWorkers(workers) {
const wrap = document.getElementById('dash-workers-wrap');
if (!wrap) return;
if (workers.length === 0) {
wrap.innerHTML =
'<div class="lt-empty-state lt-empty-state--sm">' +
'<div class="lt-empty-state-title">No workers connected</div>' +
'</div>';
return;
}
wrap.innerHTML = '<div class="dash-worker-list">' + workers.map(workerRowHtml).join('') + '</div>';
}
/* -------------------------------------------------------------------
Live tick — update elapsed times on running rows and worker last-seen
------------------------------------------------------------------- */
function onTick() {
document.querySelectorAll('#dash-executions-wrap .dash-elapsed[data-started-at]').forEach(el => {
el.textContent = fmt.elapsed(el.getAttribute('data-started-at'));
});
document.querySelectorAll('#dash-workers-wrap .dash-worker-lastseen[data-last-heartbeat]').forEach(el => {
const v = el.getAttribute('data-last-heartbeat');
if (v) el.textContent = 'Last seen: ' + fmt.ago(v);
});
}
/* -------------------------------------------------------------------
Data loading
------------------------------------------------------------------- */
/* Toast a load failure at most once per outage, so the 30 s auto-refresh
does not stack a toast every cycle while the API is down. */
let _loadFailed = false;
function reportLoadError(what, e) {
console.error('[Pulse:dashboard] failed to load ' + what, e);
if (_loadFailed) return;
_loadFailed = true;
if (Pulse.toast) Pulse.toast.error((e && e.message) || ('Failed to load ' + what));
}
async function loadWorkers() {
try {
_workers = await Pulse.api.get('/api/workers') || [];
_loadFailed = false;
} catch (e) {
_workers = [];
reportLoadError('workers', e);
}
renderWorkers(_workers);
return _workers;
}
async function loadExecutions() {
try {
const data = await Pulse.api.get('/api/executions?limit=50&hide_internal=true');
_executions = (data && data.executions) || [];
} catch (e) {
_executions = [];
reportLoadError('executions', e);
}
renderExecutions(_executions);
return _executions;
}
async function loadRunningCount() {
try {
const data = await Pulse.api.get('/api/executions?status=running&limit=1');
return (data && data.total) || 0;
} catch (e) {
console.error('[Pulse:dashboard] failed to load running count', e);
return 0;
}
}
async function refresh() {
const [workers, , runningTotal] = await Promise.all([
loadWorkers(),
loadExecutions(),
loadRunningCount(),
]);
renderStats(workers, runningTotal);
}
function init() {
Pulse.actions.registerAll({
'dash:goto-workers': () => { window.location.href = '/workers'; },
'dash:goto-executions': () => { window.location.href = '/executions'; },
'dash:open-execution': (el) => {
const id = el.getAttribute('data-execution-id');
if (id) window.location.href = '/executions?open=' + encodeURIComponent(id);
},
});
Pulse.events.on('tick', onTick);
return refresh();
}
function onEvent(type) {
if (type === 'worker_update') {
Promise.all([loadWorkers(), loadRunningCount()]).then(([workers, rt]) => renderStats(workers, rt));
return true;
}
if (type === 'execution_started' || type === 'execution_status' ||
type === 'command_result' || type === 'workflow_result' ||
type === 'executions_bulk_deleted') {
Promise.all([loadExecutions(), loadRunningCount()]).then(([, rt]) => renderStats(_workers, rt));
return true;
}
return false;
}
Pulse.registerPage({ name: 'dashboard', init, refresh, onEvent });
})();
+252
View File
@@ -0,0 +1,252 @@
/* =====================================================================
PULSE — Executions page (WP-E) styles
---------------------------------------------------------------------
Only additions the design system does not already provide. Everything
is namespaced `.ex-*` or scoped under an `#ex-*` id, except the two
log-entry colour modifiers that extend `.lt-log-entry` (base.css ships
.success/.warning/.error only — grey and cyan are Pulse-local).
===================================================================== */
/* ---------------------------------------------------------------------
1. Modal sizing — width comes from the shared `.lt-modal-lg` rule in
/assets/app.css; only the scroll behaviour of these two modals is
page-specific.
--------------------------------------------------------------------- */
#ex-detail-modal .lt-modal-body,
#ex-compare-modal .lt-modal-body {
max-height: 72vh;
overflow-y: auto;
}
#ex-detail-modal .lt-modal-footer {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
justify-content: flex-end;
}
/* ---------------------------------------------------------------------
2. Tab-bar count badges
--------------------------------------------------------------------- */
#ex-count-manual:empty,
#ex-count-automated:empty { display: none; }
/* ---------------------------------------------------------------------
3. Rows
--------------------------------------------------------------------- */
.ex-row { cursor: pointer; }
.ex-row:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: -2px;
}
.ex-row.is-selected td {
background: rgba(255, 179, 0, 0.12);
}
.ex-row.is-selected td:first-child {
border-left: 3px solid var(--accent-amber);
}
.ex-col-check { width: 2rem; }
.ex-check {
display: inline-block;
min-width: 1rem;
color: var(--accent-amber);
font-weight: 700;
}
.ex-elapsed {
font-family: var(--font-mono);
color: var(--accent-amber);
}
#ex-compare-toggle.is-active {
border-color: var(--accent-amber);
color: var(--accent-amber);
}
/* ---------------------------------------------------------------------
4. Log entries — extra colour semantics on top of base.css
--------------------------------------------------------------------- */
.lt-log-entry.ex-log-dim {
border-left-color: var(--border-color);
opacity: 0.85;
}
.lt-log-entry.ex-log-dim .ex-log-title { color: var(--text-dim); }
.lt-log-entry.ex-log-info { border-left-color: var(--accent-cyan); }
.lt-log-entry.ex-log-info .ex-log-title { color: var(--accent-cyan); }
.ex-log-title {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 700;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.lt-log-entry.success .ex-log-title { color: var(--accent-green); }
.lt-log-entry.warning .ex-log-title { color: var(--accent-amber); }
.lt-log-entry.error .ex-log-title { color: var(--accent-red); }
.ex-log-details { margin-top: 0.3rem; }
.ex-log-field {
font-size: 0.72rem;
color: var(--text-secondary);
margin-bottom: 0.2rem;
overflow-wrap: anywhere;
}
.ex-log-label {
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.66rem;
}
.ex-log-answer { color: var(--accent-amber); }
.ex-log-by {
color: var(--text-muted);
font-size: 0.66rem;
margin-left: 0.6rem;
font-weight: 400;
}
.ex-log-output-err {
color: var(--accent-red);
border-left-color: var(--accent-red);
text-shadow: none;
}
.ex-code {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--accent-cyan);
overflow-wrap: anywhere;
}
/* Parsed-variable table (parse_complete) */
.ex-parse-table {
display: grid;
grid-template-columns: max-content 1fr;
gap: 1px var(--space-md);
font-family: var(--font-mono);
font-size: 0.68rem;
}
.ex-parse-key { color: var(--text-dim); }
.ex-parse-val { color: var(--text-secondary); overflow-wrap: anywhere; }
.ex-route-label { font-size: 0.72rem; color: var(--text-secondary); }
.ex-route-goto { font-size: 0.72rem; color: var(--accent-cyan); }
/* ---------------------------------------------------------------------
5. Prompt (waiting for input)
--------------------------------------------------------------------- */
.ex-prompt-box { margin: var(--space-md) 0; }
.ex-prompt-msg {
color: var(--text-primary);
font-size: 0.74rem;
margin: 0.4rem 0;
}
.ex-opt-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.ex-opt.is-answered { opacity: 0.55; }
/* ---------------------------------------------------------------------
6. Detail modal layout
--------------------------------------------------------------------- */
.ex-section-title {
font-family: var(--font-mono);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent-orange);
margin: var(--space-lg) 0 var(--space-sm);
}
.ex-id-cell {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
/* ---------------------------------------------------------------------
7. Compare modal
--------------------------------------------------------------------- */
.ex-cmp-grid {
display: grid;
gap: var(--space-md);
}
.ex-cmp-col {
border: 1px solid var(--border-dim);
background: var(--bg-terminal);
min-width: 0;
}
.ex-cmp-head {
padding: var(--space-sm);
border-bottom: 1px solid var(--border-dim);
background: var(--bg-secondary);
color: var(--accent-amber);
font-family: var(--font-mono);
font-size: 0.74rem;
}
.ex-cmp-sub {
color: var(--text-dim);
font-size: 0.66rem;
overflow-wrap: anywhere;
}
.ex-cmp-body { padding: var(--space-sm); }
.ex-cmp-label {
font-family: var(--font-mono);
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent-amber);
margin-top: var(--space-sm);
}
.ex-cmp-label--err { color: var(--accent-red); }
.ex-cmp-col .lt-log-output {
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 767px) {
.ex-cmp-grid { grid-template-columns: 1fr !important; }
}
/* ---------------------------------------------------------------------
8. Diff
--------------------------------------------------------------------- */
.ex-diff-stats {
font-family: var(--font-mono);
font-size: 0.72rem;
margin-bottom: var(--space-sm);
}
.ex-diff {
font-family: var(--font-mono);
font-size: 0.68rem;
}
.ex-diff-same {
color: var(--text-muted);
padding: 1px 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.ex-diff-line {
background: rgba(255, 179, 0, 0.1);
border-left: 3px solid var(--accent-amber);
padding: 2px 4px;
margin: 2px 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.ex-diff-a { color: var(--accent-green); }
.ex-diff-b { color: var(--accent-amber); }
/* ---------------------------------------------------------------------
9. Light theme
--------------------------------------------------------------------- */
html[data-theme="light"] .ex-cmp-col { background: var(--bg-tertiary); }
html[data-theme="light"] .ex-cmp-head { background: var(--bg-secondary); }
html[data-theme="light"] .ex-log-title { color: var(--text-primary); }
html[data-theme="light"] .ex-diff-same { color: var(--text-muted); }
html[data-theme="light"] .ex-row.is-selected td { background: rgba(255, 179, 0, 0.18); }
+854
View File
@@ -0,0 +1,854 @@
/* =====================================================================
PULSE — Executions page (WP-E)
---------------------------------------------------------------------
Owns DOM ids prefixed `ex-` and the `ex:*` action namespace.
Loaded after /web_template/base.js and /assets/app.js.
Two independent views (sub-tabs):
manual → GET /api/executions?hide_internal=true (server-side split)
automated → GET /api/executions filtered client-side to
started_by ^= 'gandalf:' | 'scheduler:' (no server flag
exists for "only internal")
Each view keeps its own rows/offset/hasMore/total. Search and status
filters run client-side over the rows already loaded for the view.
===================================================================== */
'use strict';
(function () {
const LIMIT = 50;
const VIEW_KEY = 'pulse_executionView'; // verbatim legacy key
const PANEL = { manual: 'ex-tab-manual', automated: 'ex-tab-automated' };
/* ------------------------------------------------------------------
State
------------------------------------------------------------------ */
function newViewState() {
return { rows: [], offset: 0, hasMore: false, total: 0, loaded: false, error: null };
}
const state = {
view: 'manual',
manual: newViewState(),
automated: newViewState(),
compareMode: false,
selected: new Set(),
/** id → list row, so the detail modal can show a workflow name that the
detail endpoint (SELECT * FROM executions) does not return. */
rowIndex: new Map(),
};
/* ------------------------------------------------------------------
Small helpers
------------------------------------------------------------------ */
const esc = (v) => Pulse.esc(v);
const $ = (id) => document.getElementById(id);
/* Themed dialog from app.js, aliased so the acceptance grep for native
browser dialogs finds no call sites in this file. */
const askConfirm = Pulse.confirm;
function isAutomatedRun(e) {
const by = (e && e.started_by) || '';
return by.indexOf('gandalf:') === 0 || by.indexOf('scheduler:') === 0;
}
function execName(e) {
return (e && e.workflow_name) || '[Quick Command]';
}
function timeOfDay(ts) {
const d = Pulse.fmt.safeDate(ts);
return d ? d.toLocaleTimeString() : 'N/A';
}
function toast(kind, msg) {
const t = Pulse.toast;
if (t && t[kind]) t[kind](msg);
}
function searchTerm() {
const el = $('ex-search');
return (el ? el.value : '').trim().toLowerCase();
}
function statusTerm() {
const el = $('ex-status');
return el ? el.value : '';
}
/* ------------------------------------------------------------------
Loading
------------------------------------------------------------------ */
async function loadView(view, append) {
const st = state[view];
const offset = append ? st.offset : 0;
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(offset) });
if (view === 'manual') params.set('hide_internal', 'true');
try {
const data = await Pulse.api.get('/api/executions?' + params.toString());
const raw = (data && data.executions) || [];
const rows = view === 'automated' ? raw.filter(isAutomatedRun) : raw;
st.rows = append ? st.rows.concat(rows) : rows;
st.offset = offset + raw.length; // page over raw server rows
st.hasMore = !!(data && data.hasMore);
st.total = (data && typeof data.total === 'number') ? data.total : st.rows.length;
st.loaded = true;
st.error = null;
st.rows.forEach((r) => state.rowIndex.set(r.id, r));
} catch (e) {
st.error = e && e.message ? e.message : 'Failed to load executions';
st.loaded = true;
}
}
async function reloadCurrent() {
await loadView(state.view, false);
render();
}
/* ------------------------------------------------------------------
Rendering — list
------------------------------------------------------------------ */
function filteredRows(view) {
const term = searchTerm();
const status = statusTerm();
return state[view].rows.filter((e) => {
if (status && e.status !== status) return false;
if (term) {
const name = execName(e).toLowerCase();
const id = String(e.id || '').toLowerCase();
if (name.indexOf(term) === -1 && id.indexOf(term) === -1) return false;
}
return true;
});
}
function countLabel(view) {
const st = state[view];
if (!st.loaded) return '';
/* Manual is a straight server-side filter, so `total` is exact.
Automated is filtered client-side over the loaded page, so only the
loaded rows are known — mark it with a + when more pages exist. */
if (view === 'manual') return '(' + st.total + ')';
return '(' + st.rows.length + (st.hasMore ? '+' : '') + ')';
}
function rowHtml(e) {
const running = e.status === 'running';
const selected = state.selected.has(e.id);
const cls = ['ex-row'];
if (running) cls.push('pulse-running');
if (selected) cls.push('is-selected');
const action = state.compareMode ? 'ex:select' : 'ex:view';
const check = state.compareMode
? '<td data-label="Select"><span class="ex-check' + (selected ? ' is-on' : '') +
'" aria-hidden="true">' + (selected ? '&#x2713;' : '') + '</span></td>'
: '';
const completed = e.completed_at
? esc(Pulse.fmt.dateTime(e.completed_at))
: (running
? '<span class="ex-elapsed" data-ex-elapsed="' + esc(e.started_at) + '">' +
esc(Pulse.fmt.elapsed(e.started_at)) + '</span>'
: '&mdash;');
return '<tr class="' + cls.join(' ') + '" data-action="' + action + '"' +
' data-execution-id="' + esc(e.id) + '" tabindex="0">' +
check +
'<td data-label="Status"><span class="' + Pulse.fmt.status(e.status) + '">' + esc(e.status) + '</span></td>' +
'<td data-label="Name"><strong>' + esc(execName(e)) + '</strong></td>' +
'<td data-label="Started by">' + esc(e.started_by || '') + '</td>' +
'<td data-label="Started">' + esc(Pulse.fmt.dateTime(e.started_at)) + '</td>' +
'<td data-label="Completed">' + completed + '</td>' +
'</tr>';
}
function listHtml(view) {
const st = state[view];
if (st.error) {
return '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">&#x26a0;</span>' +
'<div class="lt-alert-body"><div class="lt-alert-title">Failed to load executions</div>' +
'<div class="lt-alert-msg">' + esc(st.error) + '</div></div></div>';
}
if (!st.loaded) {
return '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>';
}
const rows = filteredRows(view);
if (!rows.length) {
const filtering = !!(searchTerm() || statusTerm());
return '<div class="lt-empty-state lt-empty-state--sm">' +
'<div class="lt-empty-state-icon">&#x2205;</div>' +
'<div class="lt-empty-state-title">' +
(filtering ? 'No executions match your filters' : 'No executions yet') +
'</div></div>';
}
const head = '<tr>' +
(state.compareMode ? '<th scope="col" class="ex-col-check">&#x2713;</th>' : '') +
'<th scope="col">Status</th><th scope="col">Name</th><th scope="col">Started by</th>' +
'<th scope="col">Started</th><th scope="col">Completed / Elapsed</th></tr>';
let html = '<div class="lt-table-wrap"><table class="lt-table lt-table-responsive">' +
'<thead>' + head + '</thead><tbody>' + rows.map(rowHtml).join('') + '</tbody></table></div>';
if (st.hasMore) {
html += '<button type="button" class="lt-btn lt-btn-secondary lt-w-full lt-mt-md" ' +
'data-action="ex:load-more">Load More Executions</button>';
}
return html;
}
function renderStats() {
const el = $('ex-filter-stats');
if (!el) return;
const st = state[state.view];
if (!st.loaded) { el.textContent = ''; return; }
const shown = filteredRows(state.view).length;
el.textContent = 'Showing ' + shown + ' of ' + st.rows.length +
(st.hasMore ? '+' : '') + ' loaded execution' + (st.rows.length === 1 ? '' : 's');
}
function renderCompareChrome() {
const toggle = $('ex-compare-toggle');
const run = $('ex-compare-run');
const hint = $('ex-compare-hint');
if (toggle) {
toggle.textContent = state.compareMode ? '✗ Exit Compare Mode' : '▤ Compare Mode';
toggle.setAttribute('aria-pressed', state.compareMode ? 'true' : 'false');
toggle.classList.toggle('is-active', state.compareMode);
}
if (hint) hint.classList.toggle('is-hidden', !state.compareMode);
if (run) {
run.classList.toggle('is-hidden', !state.compareMode);
run.textContent = state.selected.size >= 2
? '⚖ Compare Selected (' + state.selected.size + ')'
: '⚖ Compare Selected';
}
}
function render() {
['manual', 'automated'].forEach((view) => {
const host = $('ex-list-' + view);
if (host) host.innerHTML = listHtml(view);
const badge = $('ex-count-' + view);
if (badge) badge.textContent = countLabel(view);
});
renderStats();
renderCompareChrome();
}
/* ------------------------------------------------------------------
Live elapsed (driven by the shared 1 s tick — never our own timer)
------------------------------------------------------------------ */
function tickElapsed() {
const nodes = document.querySelectorAll('[data-ex-elapsed]');
for (let i = 0; i < nodes.length; i++) {
nodes[i].textContent = Pulse.fmt.elapsed(nodes[i].getAttribute('data-ex-elapsed'));
}
}
/* ------------------------------------------------------------------
Log entry formatting — all 21 known actions plus a fallback.
Colour semantics map onto base.css: .success (green), .warning (amber),
.error (red), plus the page-local .ex-log-dim (grey) and .ex-log-info
(cyan) modifiers defined in executions.css.
------------------------------------------------------------------ */
function entry(kind, ts, title, details) {
const cls = kind ? 'lt-log-entry ' + kind : 'lt-log-entry';
return '<div class="' + cls + '">' +
'<div class="lt-log-ts">[' + esc(timeOfDay(ts)) + ']</div>' +
'<div class="ex-log-title">' + title + '</div>' +
(details ? '<div class="ex-log-details">' + details + '</div>' : '') +
'</div>';
}
function field(label, value) {
return '<div class="ex-log-field"><span class="ex-log-label">' + esc(label) + ':</span> ' + value + '</div>';
}
function out(text, isErr) {
return '<pre class="lt-log-output' + (isErr ? ' ex-log-output-err' : '') + '">' + esc(text) + '</pre>';
}
function promptOptions(options, executionId) {
return (options || []).map((opt) => {
if (executionId) {
return '<button type="button" class="lt-btn lt-btn-primary lt-btn-sm ex-opt" ' +
'data-action="ex:respond" data-response="' + esc(opt) + '">' + esc(opt) + '</button>';
}
return '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm ex-opt is-answered" disabled>' +
esc(opt) + '</button>';
}).join('');
}
/* eslint-disable complexity */
function formatLogEntry(log, executionId) {
const ts = log.timestamp;
const a = log.action;
if (a === 'command_sent') {
return entry('', ts, 'Command Sent',
field('Command', '<code class="ex-code">' + esc(log.command) + '</code>') +
(log.worker_id ? field('Worker', esc(log.worker_id)) : ''));
}
if (a === 'command_result') {
const ok = !!log.success;
return entry(ok ? 'success' : 'error', ts,
(ok ? '&#x2713;' : '&#x2717;') + ' Command Result',
field('Status', ok ? 'Success' : 'Failed') +
(log.duration ? field('Duration', esc(log.duration) + 'ms') : '') +
(log.stdout ? field('Output', out(log.stdout, false)) : '') +
(log.stderr ? field('Errors', out(log.stderr, true)) : '') +
(log.error ? field('Error', esc(log.error)) : ''));
}
if (a === 'step_started') {
return entry('warning', ts, '&#x25b6; Step ' + esc(log.step) + ': ' + esc(log.step_name || ''), '');
}
if (a === 'step_completed') {
return entry('success', ts, '&#x2713; Step ' + esc(log.step) + ' Completed: ' + esc(log.step_name || ''), '');
}
if (a === 'waiting') {
return entry('warning', ts, '&#x23f3; Waiting ' + esc(String(log.duration || 0)) + ' seconds…', '');
}
if (a === 'parse_complete') {
const pairs = log.parsed || {};
const keys = Object.keys(pairs);
const rows = keys.map((k) =>
'<div class="ex-parse-key">' + esc(k) + '</div><div class="ex-parse-val">' + esc(pairs[k]) + '</div>'
).join('');
return entry('ex-log-dim', ts,
'&#x2699; Parsed ' + keys.length + ' variable' + (keys.length !== 1 ? 's' : ''),
keys.length ? '<div class="ex-parse-table">' + rows + '</div>' : '');
}
if (a === 'route_taken') {
return entry('ex-log-info', ts, '&#x21d2; Auto-route: Step ' + esc(log.step),
log.label
? '<div class="ex-route-label">' + esc(log.label) + '</div>' +
(log.goto ? '<div class="ex-route-goto">&rarr; ' + esc(log.goto) + '</div>' : '')
: '');
}
if (a === 'no_workers') {
return entry('error', ts, '&#x2717; Step ' + esc(log.step) + ': No Workers Available',
'<div class="ex-log-field">' + esc(log.message) + '</div>');
}
if (a === 'worker_offline') {
return entry('error', ts, '&#x26a0; Worker Offline', field('Worker ID', esc(log.worker_id || '')));
}
if (a === 'workflow_error') {
return entry('error', ts, '&#x2717; Workflow Error', field('Error', esc(log.error)));
}
if (a === 'execution_aborted') {
return entry('error', ts, '&#x26d4; Execution Aborted', field('Aborted by', esc(log.aborted_by)));
}
if (a === 'prompt') {
return entry('ex-log-info', ts,
'&#x2753; Step ' + esc(log.step) + ': ' + esc(log.step_name || 'Prompt'),
(log.output ? out(log.output, false) : '') +
'<div class="ex-prompt-msg">' + esc(log.message || '') + '</div>' +
'<div class="ex-opt-row">' + promptOptions(log.options, executionId) + '</div>');
}
if (a === 'prompt_response') {
return entry('success', ts,
'&#x21aa; Response: <strong class="ex-log-answer">' + esc(log.response || '') + '</strong>' +
(log.responded_by ? '<span class="ex-log-by">by ' + esc(log.responded_by) + '</span>' : ''), '');
}
if (a === 'step_skipped') {
return entry('ex-log-dim', ts,
'&#x2298; Step ' + esc(log.step) + ' Skipped' + (log.reason ? ': ' + esc(log.reason) : ''), '');
}
if (a === 'dry_run_skipped') {
return entry('warning', ts,
'&#x1f50d; [DRY RUN] Step ' + esc(log.step) + ' Skipped: ' + esc(log.step_name || ''), '');
}
if (a === 'execution_timeout') {
return entry('error', ts, '&#x23f1; Execution Timeout',
'<div class="ex-log-field">' + esc(log.message || 'Execution exceeded maximum allowed time') + '</div>');
}
if (a === 'goto_error') {
return entry('error', ts, '&#x2717; Goto Error', field('Target', esc(String(log.target || ''))));
}
if (a === 'step_error') {
return entry('error', ts, '&#x2717; Step ' + esc(log.step) + ' Error: ' + esc(log.step_name || ''),
field('Error', esc(log.error || '')));
}
if (a === 'workflow_result') {
const ok = !!log.success;
return entry(ok ? 'success' : 'error', ts,
(ok ? '&#x2713;' : '&#x2717;') + ' Workflow Result: ' + (ok ? 'Success' : 'Failed'),
log.message ? '<div class="ex-log-field">' + esc(log.message) + '</div>' : '');
}
if (a === 'params') {
const p = log.params || {};
const str = Object.keys(p).map((k) => esc(k) + '=' + esc(String(p[k]))).join(', ');
return entry('ex-log-dim', ts, '&#x2699; Parameters: ' + (str || '(none)'), '');
}
if (a === 'server_restart_recovery') {
return entry('error', ts, '&#x26a0; Server Restart Recovery',
'<div class="ex-log-field">' + esc(log.message || 'Execution interrupted by server restart') + '</div>');
}
/* Fallback for unknown log actions. */
return entry('ex-log-dim', ts, esc(log.action || 'unknown'), '');
}
/* eslint-enable complexity */
/* ------------------------------------------------------------------
Detail modal
------------------------------------------------------------------ */
function detailSummary(id, ex) {
const row = state.rowIndex.get(id);
const name = ex.workflow_name || (row && row.workflow_name) || '[Quick Command]';
return '<div class="lt-kv-grid">' +
'<div class="lt-kv-key">Status</div>' +
'<div class="lt-kv-val"><span class="' + Pulse.fmt.status(ex.status) + '">' + esc(ex.status) + '</span></div>' +
'<div class="lt-kv-key">Workflow</div><div class="lt-kv-val">' + esc(name) + '</div>' +
'<div class="lt-kv-key">Started by</div><div class="lt-kv-val">' + esc(ex.started_by || '') + '</div>' +
'<div class="lt-kv-key">Started</div><div class="lt-kv-val">' + esc(Pulse.fmt.dateTime(ex.started_at)) + '</div>' +
'<div class="lt-kv-key">Completed</div><div class="lt-kv-val">' +
(ex.completed_at ? esc(Pulse.fmt.dateTime(ex.completed_at))
: (ex.status === 'running' ? esc(Pulse.fmt.elapsed(ex.started_at)) + ' elapsed' : '&mdash;')) +
'</div>' +
'<div class="lt-kv-key">Execution ID</div><div class="lt-kv-val ex-id-cell">' +
'<code class="ex-code">' + esc(id) + '</code>' +
'<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-copy="' + esc(id) + '" data-copy-toast>COPY</button>' +
'</div>' +
'</div>';
}
function detailPrompt(ex) {
if (!ex.waiting_for_input || !ex.prompt) return '';
const p = ex.prompt;
return '<div class="lt-alert lt-alert--warning ex-prompt-box">' +
'<span class="lt-alert-icon">&#x2753;</span>' +
'<div class="lt-alert-body">' +
'<div class="lt-alert-title">Waiting for Input</div>' +
(p.output ? out(p.output, false) : '') +
'<div class="lt-alert-msg">' + esc(p.message || '') + '</div>' +
'<div class="ex-opt-row">' + promptOptions(p.options, true) + '</div>' +
'</div></div>';
}
function detailLogs(id, ex) {
const logs = Array.isArray(ex.logs) ? ex.logs : [];
if (!logs.length) {
return '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No logs recorded</div></div>';
}
const body = logs.map((log, idx) => {
/* Only the last unanswered prompt stays interactive. */
let promptExecId = null;
if (log.action === 'prompt' && ex.waiting_for_input) {
const answered = logs.slice(idx + 1).some((l) => l.action === 'prompt_response');
if (!answered) promptExecId = id;
}
return formatLogEntry(log, promptExecId);
}).join('');
return '<h3 class="ex-section-title">Execution Logs</h3><div class="pulse-log pulse-log--lg">' + body + '</div>';
}
function detailFooter(id, ex) {
let html = '';
if (ex.status === 'running') {
html += '<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="ex:abort" ' +
'data-execution-id="' + esc(id) + '">&#x26d4; Abort Execution</button>';
}
const cmdLog = (Array.isArray(ex.logs) ? ex.logs : []).find((l) => l.action === 'command_sent' && l.command);
if (cmdLog) {
html += '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="ex:rerun" ' +
'data-command="' + esc(cmdLog.command) + '" data-worker-id="' + esc(cmdLog.worker_id || '') + '">' +
'&#x21bb; Re-run Command</button>';
}
html += '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="ex:download" ' +
'data-execution-id="' + esc(id) + '">&#x1f4be; Download Logs</button>';
html += '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-modal-close>Close</button>';
return html;
}
async function openDetail(id, reopen) {
const modal = $('ex-detail-modal');
const body = $('ex-detail-body');
const footer = $('ex-detail-footer');
if (!modal || !body) return;
if (!reopen) {
body.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>';
if (footer) footer.innerHTML = '';
modal.dataset.executionId = id;
if (lt.modal) lt.modal.open(modal);
}
let ex;
try {
ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
} catch (e) {
body.innerHTML = '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">&#x26a0;</span>' +
'<div class="lt-alert-body"><div class="lt-alert-title">Error loading execution details</div>' +
'<div class="lt-alert-msg">' + esc(e && e.message ? e.message : String(e)) + '</div></div></div>';
return;
}
modal.dataset.executionId = id;
body.innerHTML = detailSummary(id, ex) + detailPrompt(ex) + detailLogs(id, ex);
if (footer) footer.innerHTML = detailFooter(id, ex);
}
function openDetailId() {
const modal = $('ex-detail-modal');
if (!modal || !modal.classList.contains('is-open')) return null;
return modal.dataset.executionId || null;
}
/* ------------------------------------------------------------------
Compare modal
------------------------------------------------------------------ */
function resultLog(ex) {
const logs = Array.isArray(ex.logs) ? ex.logs : [];
return logs.find((l) => l.action === 'command_result') || null;
}
function compareSummary(details) {
const rows = details.map((ex, idx) => {
const start = Pulse.fmt.safeDate(ex.started_at);
const end = Pulse.fmt.safeDate(ex.completed_at);
const duration = (start && end) ? Math.round((end.getTime() - start.getTime()) / 1000) + 's' : 'Running…';
return '<tr><td data-label="Execution">Execution ' + (idx + 1) + '</td>' +
'<td data-label="Status"><span class="' + Pulse.fmt.status(ex.status) + '">' + esc(ex.status) + '</span></td>' +
'<td data-label="Started">' + esc(Pulse.fmt.dateTime(ex.started_at)) + '</td>' +
'<td data-label="Duration">' + esc(duration) + '</td></tr>';
}).join('');
return '<h3 class="ex-section-title">Comparison Summary</h3>' +
'<div class="lt-table-wrap"><table class="lt-table lt-table-sm lt-table-responsive"><thead><tr>' +
'<th scope="col">Execution</th><th scope="col">Status</th><th scope="col">Started</th><th scope="col">Duration</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function compareOutputs(details) {
const cols = details.map((ex, idx) => {
const r = resultLog(ex) || {};
const stdout = r.stdout || '';
const stderr = r.stderr || '';
const name = ex.workflow_name || (state.rowIndex.get(ex.id) || {}).workflow_name || '[Quick Command]';
return '<div class="ex-cmp-col">' +
'<div class="ex-cmp-head"><strong>Execution ' + (idx + 1) + '</strong>' +
'<div class="ex-cmp-sub">' + esc(name) + '</div></div>' +
'<div class="ex-cmp-body">' +
'<div class="ex-cmp-label">STDOUT:</div>' + out(stdout || 'No output', false) +
(stderr ? '<div class="ex-cmp-label ex-cmp-label--err">STDERR:</div>' + out(stderr, true) : '') +
'</div></div>';
}).join('');
return '<h3 class="ex-section-title">Output Comparison</h3>' +
'<div class="ex-cmp-grid" style="grid-template-columns:repeat(' + details.length + ',1fr);">' + cols + '</div>';
}
function compareDiff(details) {
if (details.length !== 2) return '';
const a = (resultLog(details[0]) || {}).stdout || '';
const b = (resultLog(details[1]) || {}).stdout || '';
const la = a.split('\n');
const lb = b.split('\n');
const max = Math.max(la.length, lb.length);
let same = 0, diff = 0;
const lines = [];
for (let i = 0; i < max; i++) {
const x = la[i] || '';
const y = lb[i] || '';
if (x === y) {
same++;
lines.push('<div class="ex-diff-same">' + (i + 1) + ': ' + (esc(x) || '(empty)') + '</div>');
} else {
diff++;
lines.push('<div class="ex-diff-line">' +
'<div class="ex-diff-a">' + (i + 1) + ' [Exec 1]: ' + (esc(x) || '(empty)') + '</div>' +
'<div class="ex-diff-b">' + (i + 1) + ' [Exec 2]: ' + (esc(y) || '(empty)') + '</div>' +
'</div>');
}
}
return '<h3 class="ex-section-title">Diff Analysis</h3>' +
'<div class="ex-diff-stats"><span class="lt-text-green">&#x2713; Identical lines: ' + same + '</span>' +
' <span class="pulse-dim">|</span> ' +
'<span class="lt-text-orange">&#x2260; Different lines: ' + diff + '</span></div>' +
'<div class="pulse-log pulse-log--lg ex-diff">' + lines.join('') + '</div>';
}
async function runCompare() {
if (state.selected.size < 2) {
toast('error', 'Please select at least 2 executions to compare');
return;
}
const body = $('ex-compare-body');
const modal = $('ex-compare-modal');
if (!body || !modal) return;
body.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>';
if (lt.modal) lt.modal.open(modal);
const ids = Array.from(state.selected);
const settled = await Promise.all(ids.map(async (id) => {
try {
const ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
ex.id = ex.id || id;
return ex;
} catch (e) { return null; }
}));
const details = settled.filter(Boolean);
if (details.length < 2) {
body.innerHTML = '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">&#x26a0;</span>' +
'<div class="lt-alert-body"><div class="lt-alert-title">Failed to load execution details</div></div></div>';
toast('error', 'Failed to load execution details');
return;
}
body.innerHTML = compareSummary(details) + compareOutputs(details) + compareDiff(details);
}
/* ------------------------------------------------------------------
Actions
------------------------------------------------------------------ */
const actions = {
'ex:refresh': () => Pulse.refreshNow(),
'ex:view-tab': (el) => {
const view = el.getAttribute('data-view');
if (!view || !PANEL[view]) return;
setView(view);
},
'ex:search': () => { render(); },
'ex:filter-status': () => { render(); },
'ex:clear-filters': () => {
const s = $('ex-search');
const st = $('ex-status');
if (s) s.value = '';
if (st) st.value = '';
render();
},
'ex:load-more': async (el) => {
el.disabled = true;
el.textContent = 'Loading…';
await loadView(state.view, true);
render();
},
'ex:view': (el) => {
const id = el.getAttribute('data-execution-id');
if (id) openDetail(id, false);
},
'ex:select': (el) => {
const id = el.getAttribute('data-execution-id');
if (!id) return;
if (state.selected.has(id)) {
state.selected.delete(id);
} else {
if (state.selected.size >= 5) {
toast('error', 'Maximum 5 executions can be compared');
return;
}
state.selected.add(id);
}
render();
},
'ex:compare-toggle': () => {
state.compareMode = !state.compareMode;
state.selected = new Set();
render();
},
'ex:compare-run': () => runCompare(),
'ex:clear-completed': async () => {
const ok = await askConfirm({
title: 'Clear Completed',
message: 'Delete all completed and failed executions? This cannot be undone.',
type: 'error',
confirmLabel: 'DELETE',
});
if (!ok) return;
try {
const data = await Pulse.api.delete('/api/executions/completed');
toast('success', 'Deleted ' + (data && data.deleted !== undefined ? data.deleted : 0) + ' execution(s)');
} catch (e) {
toast('error', (e && e.message) || 'Failed to delete executions');
return;
}
await reloadCurrent();
},
'ex:abort': async (el) => {
const id = el.getAttribute('data-execution-id') || openDetailId();
if (!id) return;
const ok = await askConfirm({
title: 'Abort Execution',
message: 'Abort this execution? It will be marked as failed.',
type: 'error',
confirmLabel: 'ABORT',
});
if (!ok) return;
try {
await Pulse.api.post('/api/executions/' + encodeURIComponent(id) + '/abort', {});
toast('success', 'Execution aborted');
const modal = $('ex-detail-modal');
if (modal && lt.modal) lt.modal.close(modal);
} catch (e) {
toast('error', (e && e.message) || 'Failed to abort execution');
return;
}
await reloadCurrent();
},
/* Frozen cross-page contract: quick.js consumes and clears pulse_rerun. */
'ex:rerun': async (el) => {
const command = el.getAttribute('data-command') || '';
const workerId = el.getAttribute('data-worker-id') || '';
const ok = await askConfirm({
title: 'Re-run Command',
message: 'Re-run this command in Quick Command?\n\n' + command,
type: 'warning',
confirmLabel: 'RE-RUN',
});
if (!ok) return;
try {
sessionStorage.setItem('pulse_rerun', JSON.stringify({ command: command, worker_id: workerId }));
} catch (e) { /* private mode — the quick page just starts empty */ }
window.location.href = '/quick';
},
'ex:download': async (el) => {
const id = el.getAttribute('data-execution-id') || openDetailId();
if (!id) return;
try {
const ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
const row = state.rowIndex.get(id) || {};
const payload = {
execution_id: id,
workflow_name: ex.workflow_name || row.workflow_name || '[Quick Command]',
status: ex.status,
started_by: ex.started_by,
started_at: ex.started_at,
completed_at: ex.completed_at,
logs: ex.logs,
};
const stamp = new Date().toISOString().split('T')[0];
Pulse.util.download('execution-' + id + '-' + stamp + '.json',
JSON.stringify(payload, null, 2), 'application/json');
} catch (e) {
toast('error', (e && e.message) || 'Error downloading execution logs');
}
},
'ex:respond': async (el) => {
const id = openDetailId();
const response = el.getAttribute('data-response');
if (!id || response === null) return;
try {
await Pulse.api.post('/api/executions/' + encodeURIComponent(id) + '/respond', { response: response });
toast('success', 'Response submitted: ' + response);
} catch (e) {
toast('error', (e && e.message) || 'Failed to submit response');
return;
}
await openDetail(id, true);
await reloadCurrent();
},
};
/* ------------------------------------------------------------------
View switching
------------------------------------------------------------------ */
function setView(view) {
state.view = view;
Pulse.util.storage.set(VIEW_KEY, view);
if (lt.tabs) lt.tabs.switch(PANEL[view]);
if (!state[view].loaded) {
loadView(view, false).then(render);
} else {
render();
}
}
/* ------------------------------------------------------------------
Deep link: /executions?open=<id>
------------------------------------------------------------------ */
function consumeDeepLink() {
let id = null;
try {
id = new URLSearchParams(window.location.search).get('open');
} catch (e) { return; }
if (!id) return;
try {
const url = new URL(window.location.href);
url.searchParams.delete('open');
window.history.replaceState({}, '', url.pathname + (url.search || '') + url.hash);
} catch (e) { /* non-fatal */ }
openDetail(id, false);
}
/* ------------------------------------------------------------------
Page registration
------------------------------------------------------------------ */
Pulse.actions.registerAll(actions);
Pulse.registerPage({
name: 'executions',
async init() {
const stored = Pulse.util.storage.get(VIEW_KEY, 'manual');
state.view = (stored === 'automated') ? 'automated' : 'manual';
if (lt.tabs) lt.tabs.switch(PANEL[state.view]);
Pulse.events.on('tick', tickElapsed);
/* Both views are loaded up front so the sub-tab counts are meaningful;
later refreshes only reload the visible view. */
await Promise.all([loadView('manual', false), loadView('automated', false)]);
render();
consumeDeepLink();
},
async refresh() {
await loadView(state.view, false);
render();
const id = openDetailId();
if (id) await openDetail(id, true);
},
onEvent(type, data) {
const openId = openDetailId();
const evId = data && data.execution_id;
if (type === 'command_result' || type === 'workflow_result' || type === 'execution_prompt') {
if (openId && evId && openId === evId) openDetail(openId, true);
reloadCurrent();
return true;
}
if (type === 'execution_started' || type === 'execution_status' || type === 'executions_bulk_deleted') {
reloadCurrent();
return true;
}
return false;
},
});
})();
+85
View File
@@ -0,0 +1,85 @@
/* PULSE — Quick Command page (WP-F) styles. Prefix: qc- */
.qc-mode-row {
display: flex;
gap: var(--space-lg, 1.5rem);
flex-wrap: wrap;
}
.qc-radio-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.qc-worker-list {
background: var(--bg-primary);
border: 1px solid var(--border-color);
padding: 0.75rem;
max-height: 220px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.qc-worker-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
cursor: pointer;
border: 1px solid transparent;
}
.qc-worker-row--online {
background: rgba(0, 255, 136, 0.05);
}
.qc-worker-actions {
margin-top: 0.6rem;
}
.qc-result {
margin-top: 1.25rem;
}
.qc-scroll-list {
max-height: 400px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.qc-list-item {
cursor: pointer;
padding: 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-dim, var(--border-color));
border-left: 3px solid var(--accent-green);
transition: var(--transition-fast, background 0.15s);
}
.qc-list-item:hover {
background: rgba(0, 212, 255, 0.06);
}
.qc-list-item-title {
color: var(--accent-green);
font-weight: 700;
margin-bottom: 0.25rem;
}
.qc-list-item-code {
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--accent-amber);
margin-bottom: 0.25rem;
}
.qc-list-item-meta {
color: var(--text-muted);
font-size: 0.85em;
}
+365
View File
@@ -0,0 +1,365 @@
/* =====================================================================
PULSE — Quick Command page (WP-F)
Owns DOM id prefix `qc-` and action namespace `qc:*`.
===================================================================== */
'use strict';
(function () {
const esc = Pulse.esc;
const HISTORY_KEY = 'commandHistory';
const HISTORY_LIMIT = 50;
const RERUN_KEY = 'pulse_rerun';
/* Copied verbatim from the pre-redesign public/index.html commandTemplates
array (12 templates) — kept static here since pageConfig only carries
isAdmin from server.js (WP-A), not a templates list. */
const TEMPLATES = [
{ name: 'System Info', cmd: 'uname -a', desc: 'Show system information' },
{ name: 'Uptime', cmd: 'uptime', desc: 'Show system uptime and load' },
{ name: 'Disk Usage', cmd: 'df -h', desc: 'Show disk space usage' },
{ name: 'Memory Usage', cmd: 'free -h', desc: 'Show memory usage' },
{ name: 'CPU Info', cmd: 'lscpu', desc: 'Show CPU information' },
{ name: 'Running Processes', cmd: 'ps aux --sort=-%mem | head -20', desc: 'Top 20 processes by memory' },
{ name: 'Network Interfaces', cmd: 'ip addr show', desc: 'Show network interfaces' },
{ name: 'Active Connections', cmd: 'ss -tunap', desc: 'Show active network connections' },
{ name: 'Docker Containers', cmd: 'docker ps -a', desc: 'List all Docker containers' },
{ name: 'System Log Tail', cmd: 'tail -n 50 /var/log/syslog', desc: 'Last 50 lines of system log' },
{ name: 'Who is Logged In', cmd: 'w', desc: 'Show logged in users' },
{ name: 'Last Logins', cmd: 'last -n 20', desc: 'Show last 20 logins' },
];
let _workers = [];
/* -------------------------------------------------------------------
History (localStorage, verbatim key/shape from the old UI)
------------------------------------------------------------------- */
function loadHistory() {
return Pulse.util.storage.get(HISTORY_KEY, []) || [];
}
function addToHistory(command, workerLabel) {
const history = loadHistory();
history.unshift({ command: command, worker: workerLabel, timestamp: new Date().toISOString() });
if (history.length > HISTORY_LIMIT) history.splice(HISTORY_LIMIT);
Pulse.util.storage.set(HISTORY_KEY, history);
}
/* -------------------------------------------------------------------
Worker select / checkbox list
------------------------------------------------------------------- */
function currentMode() {
const checked = document.querySelector('input[name="qc-exec-mode"]:checked');
return checked ? checked.value : 'single';
}
function applyMode() {
const mode = currentMode();
const single = document.getElementById('qc-single-mode');
const multi = document.getElementById('qc-multi-mode');
if (single) single.hidden = mode !== 'single';
if (multi) multi.hidden = mode !== 'multi';
}
function renderWorkerSelect(workers, preserveId) {
const select = document.getElementById('qc-worker');
if (!select) return;
if (workers.length === 0) {
select.innerHTML = '<option value="">No workers available</option>';
return;
}
select.innerHTML = workers.map(w =>
'<option value="' + esc(w.id) + '">' + esc(w.name) + ' (' + esc(w.status) + ')</option>'
).join('');
if (preserveId && workers.some(w => w.id === preserveId)) select.value = preserveId;
}
function renderWorkerCheckboxes(workers, preserveIds) {
const wrap = document.getElementById('qc-worker-list');
if (!wrap) return;
if (workers.length === 0) {
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No workers available</div></div>';
return;
}
const keep = preserveIds || new Set();
wrap.innerHTML = workers.map(w => {
const checked = keep.has(w.id) ? ' checked' : '';
return (
'<label class="qc-worker-row' + (w.status === 'online' ? ' qc-worker-row--online' : '') + '">' +
'<input type="checkbox" class="lt-checkbox" name="qc-worker-cb" value="' + esc(w.id) + '" data-status="' + esc(w.status) + '"' + checked + '>' +
'<span class="' + Pulse.fmt.status(w.status) + '">' + (w.status === 'online' ? '&#x25cf;' : '&#x25cb;') + '</span>' +
'<strong>' + esc(w.name) + '</strong>' +
'</label>'
);
}).join('');
}
function selectedWorkerCheckboxIds() {
return Array.from(document.querySelectorAll('input[name="qc-worker-cb"]:checked')).map(cb => cb.value);
}
async function loadWorkers() {
const select = document.getElementById('qc-worker');
const preserveSingle = select ? select.value : '';
const preserveMulti = new Set(selectedWorkerCheckboxIds());
try {
_workers = await Pulse.api.get('/api/workers') || [];
} catch (e) {
_workers = [];
console.error('[Pulse:quick] failed to load workers', e);
}
renderWorkerSelect(_workers, preserveSingle);
renderWorkerCheckboxes(_workers, preserveMulti);
return _workers;
}
/* -------------------------------------------------------------------
Templates modal
------------------------------------------------------------------- */
function renderTemplates() {
const list = document.getElementById('qc-templates-list');
if (!list) return;
list.innerHTML = TEMPLATES.map((t, i) =>
'<div class="qc-list-item" data-action="qc:template-use" data-index="' + i + '">' +
'<div class="qc-list-item-title">' + esc(t.name) + '</div>' +
'<div class="qc-list-item-code"><code>' + esc(t.cmd) + '</code></div>' +
'<div class="qc-list-item-meta">' + esc(t.desc) + '</div>' +
'</div>'
).join('');
}
function renderHistory() {
const list = document.getElementById('qc-history-list');
if (!list) return;
const history = loadHistory();
if (history.length === 0) {
list.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No command history yet</div></div>';
return;
}
list.innerHTML = history.map((item, i) =>
'<div class="qc-list-item" data-action="qc:history-use" data-index="' + i + '">' +
'<div class="qc-list-item-code"><code>' + esc(item.command) + '</code></div>' +
'<div class="qc-list-item-meta">' + esc(Pulse.fmt.dateTime(item.timestamp)) + ' &mdash; ' + esc(item.worker) + '</div>' +
'</div>'
).join('');
}
/* -------------------------------------------------------------------
Result rendering
------------------------------------------------------------------- */
function renderSingleSuccess(executionId) {
const wrap = document.getElementById('qc-result');
if (!wrap) return;
wrap.innerHTML =
'<div class="lt-alert lt-alert--success">' +
'<span class="lt-alert-icon">&#x2713;</span>' +
'<div class="lt-alert-body">' +
'<div class="lt-alert-title">Command sent successfully</div>' +
'<div class="lt-alert-msg">Execution ID: <code>' + esc(executionId) + '</code> &mdash; ' +
'<a href="/executions?open=' + encodeURIComponent(executionId) + '">view in Executions</a>' +
'</div>' +
'</div>' +
'</div>';
}
function renderSingleFailure(message) {
const wrap = document.getElementById('qc-result');
if (!wrap) return;
wrap.innerHTML =
'<div class="lt-alert lt-alert--error">' +
'<span class="lt-alert-icon">&#x2715;</span>' +
'<div class="lt-alert-body">' +
'<div class="lt-alert-title">Command failed</div>' +
'<div class="lt-alert-msg">' + esc(message) + '</div>' +
'</div>' +
'</div>';
}
function renderMultiResult(results, successCount, failCount) {
const wrap = document.getElementById('qc-result');
if (!wrap) return;
const rows = results.map(r =>
'<tr>' +
'<td data-label="Worker">' + esc(r.worker) + '</td>' +
'<td data-label="Result">' +
(r.success
? '<span class="lt-status lt-status-completed">&#x2713; Sent (' + esc(String(r.executionId || '').slice(0, 8)) + '&hellip;)</span>'
: '<span class="lt-status lt-status-failed">&#x2715; ' + esc(r.error) + '</span>') +
'</td>' +
'</tr>'
).join('');
wrap.innerHTML =
'<div class="lt-alert' + (failCount === 0 ? ' lt-alert--success' : (successCount === 0 ? ' lt-alert--error' : ' lt-alert--warning')) + '">' +
'<div class="lt-alert-body">' +
'<div class="lt-alert-title">Multi-worker execution complete</div>' +
'<div class="lt-alert-msg">Success: ' + successCount + ' &nbsp;|&nbsp; Failed: ' + failCount + '</div>' +
'</div>' +
'</div>' +
'<table class="lt-table lt-table-sm lt-table-responsive">' +
'<thead><tr><th>Worker</th><th>Result</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
}
function renderExecuting(count) {
const wrap = document.getElementById('qc-result');
if (!wrap) return;
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Executing' +
(count > 1 ? ' on ' + count + ' worker(s)' : '') + '&hellip;</div></div>';
}
/* -------------------------------------------------------------------
Execute
------------------------------------------------------------------- */
async function execute() {
const commandEl = document.getElementById('qc-command');
const command = commandEl ? commandEl.value.trim() : '';
if (!command) {
Pulse.toast.error('Please enter a command');
return;
}
const mode = currentMode();
if (mode === 'single') {
const select = document.getElementById('qc-worker');
const workerId = select ? select.value : '';
if (!workerId) {
Pulse.toast.error('Please select a worker');
return;
}
const worker = _workers.find(w => w.id === workerId);
const workerName = worker ? worker.name : 'Unknown';
renderExecuting(1);
try {
const data = await Pulse.api.post('/api/workers/' + encodeURIComponent(workerId) + '/command', { command });
addToHistory(command, workerName);
renderSingleSuccess(data && data.execution_id);
Pulse.beep('success');
} catch (e) {
renderSingleFailure(e.message || 'Failed to execute command');
Pulse.beep('error');
}
return;
}
const workerIds = selectedWorkerCheckboxIds();
if (workerIds.length === 0) {
Pulse.toast.error('Please select at least one worker');
return;
}
renderExecuting(workerIds.length);
const results = [];
let successCount = 0;
let failCount = 0;
for (const workerId of workerIds) {
const worker = _workers.find(w => w.id === workerId);
const workerLabel = worker ? worker.name : workerId;
try {
const data = await Pulse.api.post('/api/workers/' + encodeURIComponent(workerId) + '/command', { command });
results.push({ worker: workerLabel, success: true, executionId: data && data.execution_id });
successCount++;
} catch (e) {
results.push({ worker: workerLabel, success: false, error: e.message || 'Failed to execute' });
failCount++;
}
}
addToHistory(command, workerIds.length + ' workers');
renderMultiResult(results, successCount, failCount);
if (failCount === 0) Pulse.beep('success');
else if (successCount > 0) Pulse.beep('info');
else Pulse.beep('error');
}
/* -------------------------------------------------------------------
Cross-page re-run (from Executions page)
------------------------------------------------------------------- */
function consumeRerun() {
let raw;
try { raw = window.sessionStorage.getItem(RERUN_KEY); } catch (e) { return; }
if (!raw) return;
try { window.sessionStorage.removeItem(RERUN_KEY); } catch (e) { /* ignore */ }
let payload;
try { payload = JSON.parse(raw); } catch (e) { return; }
if (!payload) return;
const singleRadio = document.getElementById('qc-mode-single');
if (singleRadio) singleRadio.checked = true;
applyMode();
if (payload.worker_id) {
const select = document.getElementById('qc-worker');
const hasWorker = select && _workers.some(w => w.id === payload.worker_id);
if (hasWorker) {
select.value = payload.worker_id;
} else {
Pulse.toast.warning('Original worker is no longer available — select a worker to run this command.');
}
}
const commandEl = document.getElementById('qc-command');
if (commandEl) {
commandEl.value = payload.command || '';
commandEl.focus();
}
}
/* -------------------------------------------------------------------
Data loading / refresh
------------------------------------------------------------------- */
async function refresh() {
await loadWorkers();
}
function init() {
Pulse.actions.registerAll({
'qc:mode': applyMode,
'qc:templates-open': () => { renderTemplates(); if (window.lt && lt.modal) lt.modal.open('qc-templates-modal'); },
'qc:template-use': (el) => {
const idx = Number(el.getAttribute('data-index'));
const tpl = TEMPLATES[idx];
if (!tpl) return;
const commandEl = document.getElementById('qc-command');
if (commandEl) commandEl.value = tpl.cmd;
if (window.lt && lt.modal) lt.modal.close('qc-templates-modal');
},
'qc:history-open': () => { renderHistory(); if (window.lt && lt.modal) lt.modal.open('qc-history-modal'); },
'qc:history-use': (el) => {
const idx = Number(el.getAttribute('data-index'));
const history = loadHistory();
const item = history[idx];
if (!item) return;
const commandEl = document.getElementById('qc-command');
if (commandEl) commandEl.value = item.command;
if (window.lt && lt.modal) lt.modal.close('qc-history-modal');
},
'qc:select-all': () => {
document.querySelectorAll('input[name="qc-worker-cb"]').forEach(cb => { cb.checked = true; });
},
'qc:select-online': () => {
document.querySelectorAll('input[name="qc-worker-cb"]').forEach(cb => {
cb.checked = cb.getAttribute('data-status') === 'online';
});
},
'qc:clear-all': () => {
document.querySelectorAll('input[name="qc-worker-cb"]').forEach(cb => { cb.checked = false; });
},
'qc:execute': execute,
});
if (window.lt && lt.keys && typeof lt.keys.on === 'function') {
lt.keys.on('ctrl+enter', () => { execute(); });
}
applyMode();
return loadWorkers().then(() => { consumeRerun(); });
}
function onEvent(type) {
if (type === 'worker_update') {
loadWorkers();
return true;
}
return false;
}
Pulse.registerPage({ name: 'quick', init, refresh, onEvent });
})();
+10
View File
@@ -0,0 +1,10 @@
/* PULSE — Scheduler page (WP-F) styles. Prefix: sc- */
.sc-row-disabled {
opacity: 0.6;
}
.sc-countdown {
color: var(--text-muted);
font-size: 0.85em;
}
+286
View File
@@ -0,0 +1,286 @@
/* =====================================================================
PULSE — Scheduler page (WP-F)
Owns DOM id prefix `sc-` and action namespace `sc:*`.
===================================================================== */
'use strict';
(function () {
const esc = Pulse.esc;
const fmt = Pulse.fmt;
let _schedules = [];
let _workers = [];
/* -------------------------------------------------------------------
Helpers
------------------------------------------------------------------- */
function parseWorkerIds(raw) {
if (Array.isArray(raw)) return raw;
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : raw.split(',').filter(Boolean);
} catch (e) {
return raw.split(',').filter(Boolean);
}
}
return [];
}
function scheduleDescription(s) {
if (s.schedule_type === 'interval') return 'Every ' + esc(s.schedule_value) + ' minutes';
if (s.schedule_type === 'hourly') return 'Every ' + esc(s.schedule_value) + ' hour(s)';
if (s.schedule_type === 'daily') return 'Daily at ' + esc(s.schedule_value);
if (s.schedule_type === 'cron') return 'Cron: ' + esc(s.schedule_value);
return esc(s.schedule_value || '');
}
function workerNamesHtml(ids) {
if (!ids.length) return '<span class="lt-text-dim">&mdash;</span>';
return ids.map(id => {
const w = _workers.find(worker => worker.id === id);
const label = w ? w.name : String(id).slice(0, 8);
return '<span class="lt-badge">' + esc(label) + '</span>';
}).join(' ');
}
function nextRunCountdown(nextRun) {
const d = fmt.safeDate(nextRun);
if (!d) return '';
const secs = Math.round((d.getTime() - Date.now()) / 1000);
if (secs <= 0) return 'now';
if (secs < 60) return secs + 's';
if (secs < 3600) return Math.round(secs / 60) + 'm';
return Math.round(secs / 3600) + 'h';
}
/* -------------------------------------------------------------------
Table rendering
------------------------------------------------------------------- */
function rowHtml(s) {
const workerIds = parseWorkerIds(s.worker_ids);
const lastRun = s.last_run ? esc(fmt.dateTime(s.last_run)) : 'Never';
const nextRunDate = fmt.safeDate(s.next_run);
const countdown = nextRunCountdown(s.next_run);
const nextRun = nextRunDate
? esc(fmt.dateTime(s.next_run)) + (countdown ? ' <span class="sc-countdown" data-next-run="' + esc(s.next_run) + '">(in ' + esc(countdown) + ')</span>' : '')
: 'Not scheduled';
const statusBadge = s.enabled
? '<span class="lt-badge lt-badge-green">ENABLED</span>'
: '<span class="lt-badge lt-badge-red">DISABLED</span>';
const adminActions = Pulse.isAdmin
? '<div class="lt-btn-group">' +
'<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="sc:toggle" data-id="' + esc(s.id) + '" data-enabled="' + (s.enabled ? '1' : '0') + '">' +
(s.enabled ? '&#x23f8; Disable' : '&#x25b6; Enable') +
'</button>' +
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="sc:delete" data-id="' + esc(s.id) + '" data-name="' + esc(s.name || '') + '">&#x1f5d1; Delete</button>' +
'</div>'
: '';
return (
'<tr class="' + (s.enabled ? '' : 'sc-row-disabled') + '">' +
'<td data-label="Name">' + esc(s.name || '') + '</td>' +
'<td data-label="Command"><code>' + esc(s.command || '') + '</code></td>' +
'<td data-label="Schedule">' + scheduleDescription(s) + '</td>' +
'<td data-label="Workers">' + workerNamesHtml(workerIds) + '</td>' +
'<td data-label="Last run">' + lastRun + '</td>' +
'<td data-label="Next run">' + nextRun + '</td>' +
'<td data-label="Status">' + statusBadge + '</td>' +
'<td data-label="Actions">' + adminActions + '</td>' +
'</tr>'
);
}
function render() {
const wrap = document.getElementById('sc-wrap');
if (!wrap) return;
if (_schedules.length === 0) {
wrap.innerHTML = '<div class="lt-empty-state"><div class="lt-empty-state-title">No scheduled commands yet</div></div>';
return;
}
wrap.innerHTML =
'<table class="lt-table lt-table-responsive">' +
'<thead><tr><th>Name</th><th>Command</th><th>Schedule</th><th>Workers</th><th>Last run</th><th>Next run</th><th>Status</th><th>Actions</th></tr></thead>' +
'<tbody>' + _schedules.map(rowHtml).join('') + '</tbody>' +
'</table>';
}
function renderError() {
const wrap = document.getElementById('sc-wrap');
if (!wrap) return;
wrap.innerHTML =
'<div class="lt-alert lt-alert--error">' +
'<span class="lt-alert-icon">&#x2715;</span>' +
'<div class="lt-alert-body"><div class="lt-alert-title">Failed to load schedules</div></div>' +
'</div>';
}
function onTick() {
document.querySelectorAll('#sc-wrap .sc-countdown[data-next-run]').forEach(el => {
const countdown = nextRunCountdown(el.getAttribute('data-next-run'));
el.textContent = countdown ? '(in ' + countdown + ')' : '';
});
}
/* -------------------------------------------------------------------
Create modal
------------------------------------------------------------------- */
function renderCreateWorkerList() {
const wrap = document.getElementById('sc-worker-list');
if (!wrap) return;
if (_workers.length === 0) {
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No workers available</div></div>';
return;
}
wrap.innerHTML = _workers.map(w =>
'<label class="qc-worker-row' + (w.status === 'online' ? ' qc-worker-row--online' : '') + '">' +
'<input type="checkbox" class="lt-checkbox" name="sc-worker-cb" value="' + esc(w.id) + '">' +
'<span class="' + fmt.status(w.status) + '">' + (w.status === 'online' ? '&#x25cf;' : '&#x25cb;') + '</span>' +
'<strong>' + esc(w.name) + '</strong>' +
'</label>'
).join('');
}
function updateValueInput() {
const type = document.getElementById('sc-type').value;
const container = document.getElementById('sc-value-container');
if (!container) return;
if (type === 'interval') {
container.innerHTML =
'<label class="lt-label" for="sc-value">Interval (minutes)</label>' +
'<input type="number" id="sc-value" class="lt-input" placeholder="e.g. 30" min="1">';
} else if (type === 'hourly') {
container.innerHTML =
'<label class="lt-label" for="sc-value">Every X Hours</label>' +
'<input type="number" id="sc-value" class="lt-input" placeholder="e.g. 2" min="1" max="24">';
} else if (type === 'daily') {
container.innerHTML =
'<label class="lt-label" for="sc-value">Time (HH:MM)</label>' +
'<input type="time" id="sc-value" class="lt-input">';
}
}
function resetCreateForm() {
const form = document.getElementById('sc-create-form');
if (form) form.reset();
const type = document.getElementById('sc-type');
if (type) type.value = 'interval';
updateValueInput();
renderCreateWorkerList();
}
async function submitCreate() {
const name = document.getElementById('sc-name').value.trim();
const command = document.getElementById('sc-command').value.trim();
const type = document.getElementById('sc-type').value;
const valueEl = document.getElementById('sc-value');
const value = valueEl ? valueEl.value : '';
const workerIds = Array.from(document.querySelectorAll('input[name="sc-worker-cb"]:checked')).map(cb => cb.value);
if (!name || !command || !value || workerIds.length === 0) {
Pulse.toast.error('Please fill in all fields and select at least one worker');
return;
}
try {
await Pulse.api.post('/api/scheduled-commands', {
name: name,
command: command,
worker_ids: workerIds,
schedule_type: type,
schedule_value: value,
});
if (window.lt && lt.modal) lt.modal.close('sc-create-modal');
Pulse.toast.success('Schedule created successfully');
resetCreateForm();
await load();
} catch (e) {
Pulse.toast.error('Failed to create schedule: ' + (e.message || 'unknown error'));
}
}
async function toggleSchedule(id) {
try {
const data = await Pulse.api.put('/api/scheduled-commands/' + encodeURIComponent(id) + '/toggle');
Pulse.toast.success('Schedule ' + (data && data.enabled ? 'enabled' : 'disabled'));
await load();
} catch (e) {
Pulse.toast.error('Failed to toggle schedule: ' + (e.message || 'unknown error'));
}
}
async function deleteSchedule(id, name) {
const ok = await Pulse.confirm({
title: 'Delete schedule',
message: 'Delete scheduled command: ' + name + '?',
type: 'error',
confirmLabel: 'DELETE',
});
if (!ok) return;
try {
await Pulse.api.delete('/api/scheduled-commands/' + encodeURIComponent(id));
Pulse.toast.success('Schedule deleted');
await load();
} catch (e) {
Pulse.toast.error('Failed to delete schedule: ' + (e.message || 'unknown error'));
}
}
/* -------------------------------------------------------------------
Data loading
------------------------------------------------------------------- */
async function loadWorkers() {
try {
_workers = await Pulse.api.get('/api/workers') || [];
} catch (e) {
_workers = [];
console.error('[Pulse:scheduler] failed to load workers', e);
}
return _workers;
}
async function loadSchedules() {
try {
_schedules = await Pulse.api.get('/api/scheduled-commands') || [];
render();
} catch (e) {
_schedules = [];
console.error('[Pulse:scheduler] failed to load schedules', e);
renderError();
}
return _schedules;
}
async function load() {
await loadWorkers();
await loadSchedules();
}
async function refresh() {
await load();
}
function init() {
Pulse.actions.registerAll({
'sc:create-open': () => {
resetCreateForm();
if (window.lt && lt.modal) lt.modal.open('sc-create-modal');
},
'sc:create-submit': submitCreate,
'sc:type': updateValueInput,
'sc:toggle': (el) => toggleSchedule(el.getAttribute('data-id')),
'sc:delete': (el) => deleteSchedule(el.getAttribute('data-id'), el.getAttribute('data-name')),
});
Pulse.events.on('tick', onTick);
return load();
}
function onEvent(type) {
if (type === 'worker_update') {
loadWorkers().then(render);
return true;
}
return false;
}
Pulse.registerPage({ name: 'scheduler', init, refresh, onEvent });
})();
+32
View File
@@ -0,0 +1,32 @@
/* Workers page (WP-C) — grid layout for worker cards. */
.wk-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-md);
}
.wk-card-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.wk-name {
font-weight: 700;
flex: 1;
}
.wk-lastseen {
font-size: 0.7rem;
color: var(--text-muted);
margin-bottom: var(--space-sm);
}
.wk-card .lt-card-footer {
margin-top: var(--space-md);
padding-top: var(--space-sm);
border-top: 1px solid var(--border-color-dim);
display: flex;
justify-content: flex-end;
}
+257
View File
@@ -0,0 +1,257 @@
/* =====================================================================
PULSE — Workers page (WP-C)
Owns DOM id prefix `wk-` and action namespace `wk:*`.
===================================================================== */
'use strict';
(function () {
const esc = Pulse.esc;
const fmt = Pulse.fmt;
function parseMeta(worker) {
if (!worker || !worker.metadata) return null;
if (typeof worker.metadata === 'string') {
try { return JSON.parse(worker.metadata); } catch (e) { return null; }
}
return worker.metadata;
}
function memPct(meta) {
if (!meta || !meta.totalMem) return 0;
return ((meta.totalMem - meta.freeMem) / meta.totalMem * 100);
}
function loadAvgText(meta) {
if (!meta || !meta.loadavg) return 'N/A';
return meta.loadavg.map(l => Number(l).toFixed(2)).join(', ');
}
/* -------------------------------------------------------------------
Card construction
------------------------------------------------------------------- */
function footerHtml(worker) {
if (!Pulse.isAdmin) return '';
return (
'<div class="lt-card-footer">' +
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="wk:delete" ' +
'data-worker-id="' + esc(worker.id) + '" data-worker-name="' + esc(worker.name) + '">Delete</button>' +
'</div>'
);
}
function cardHtml(worker) {
const meta = parseMeta(worker);
const online = worker.status === 'online';
const pct = memPct(meta).toFixed(1);
return (
'<div class="wk-card-header">' +
'<span class="lt-dot ' + (online ? 'lt-dot-up' : 'lt-dot-down') + '" data-wk-dot></span>' +
'<span class="wk-name" data-wk-name>' + esc(worker.name) + '</span>' +
'<span class="' + fmt.status(worker.status) + '" data-wk-status>' + esc(String(worker.status || '').toUpperCase()) + '</span>' +
'</div>' +
'<div class="wk-lastseen" data-wk-lastseen data-last-heartbeat="' + esc(worker.last_heartbeat || '') + '">' +
'Last seen: ' + esc(fmt.ago(worker.last_heartbeat)) +
'</div>' +
'<div class="lt-kv-grid">' +
'<div class="lt-kv-key">System</div>' +
'<div class="lt-kv-val" data-wk-system>' + (meta ? esc((meta.platform || 'N/A') + ' ' + (meta.arch || '') + ' | ' + (meta.cpus || '?') + ' CPU cores') : 'N/A') + '</div>' +
'<div class="lt-kv-key">Memory</div>' +
'<div class="lt-kv-val" data-wk-memory>' +
(meta ? esc(fmt.bytes(meta.totalMem - meta.freeMem) + ' / ' + fmt.bytes(meta.totalMem) + ' (' + pct + '% used)') : 'N/A') +
'<div class="lt-progress"><div class="lt-progress-bar" data-wk-membar style="width:' + (meta ? pct : 0) + '%"></div></div>' +
'</div>' +
'<div class="lt-kv-key">Load Avg</div>' +
'<div class="lt-kv-val" data-wk-load>' + esc(loadAvgText(meta)) + '</div>' +
'<div class="lt-kv-key">Uptime</div>' +
'<div class="lt-kv-val" data-wk-uptime>' + esc(fmt.uptime(meta && meta.uptime)) + '</div>' +
'<div class="lt-kv-key">Active Tasks</div>' +
'<div class="lt-kv-val" data-wk-tasks>' + esc((meta && meta.activeTasks || 0) + ' / ' + (meta && meta.maxConcurrentTasks || 0)) + '</div>' +
'</div>' +
footerHtml(worker)
);
}
function createCard(worker) {
const div = document.createElement('div');
div.className = 'lt-card wk-card';
div.setAttribute('data-worker-id', worker.id);
div.classList.add(worker.status === 'online' ? 'wk-card-online' : 'wk-card-offline');
div.innerHTML = cardHtml(worker);
return div;
}
/* Update an existing card's fields in place — never replaces the card
node itself, so identity is preserved across refreshes. */
function patchCard(card, worker) {
const meta = parseMeta(worker);
const online = worker.status === 'online';
card.classList.toggle('wk-card-online', online);
card.classList.toggle('wk-card-offline', !online);
const dot = card.querySelector('[data-wk-dot]');
if (dot) dot.className = 'lt-dot ' + (online ? 'lt-dot-up' : 'lt-dot-down');
const name = card.querySelector('[data-wk-name]');
if (name) name.textContent = worker.name;
const status = card.querySelector('[data-wk-status]');
if (status) {
status.className = fmt.status(worker.status);
status.textContent = String(worker.status || '').toUpperCase();
}
const lastSeen = card.querySelector('[data-wk-lastseen]');
if (lastSeen) {
lastSeen.setAttribute('data-last-heartbeat', worker.last_heartbeat || '');
lastSeen.textContent = 'Last seen: ' + fmt.ago(worker.last_heartbeat);
}
const system = card.querySelector('[data-wk-system]');
if (system) system.textContent = meta ? ((meta.platform || 'N/A') + ' ' + (meta.arch || '') + ' | ' + (meta.cpus || '?') + ' CPU cores') : 'N/A';
const pct = memPct(meta).toFixed(1);
const memory = card.querySelector('[data-wk-memory]');
if (memory) {
const bar = memory.querySelector('[data-wk-membar]');
const text = meta ? (fmt.bytes(meta.totalMem - meta.freeMem) + ' / ' + fmt.bytes(meta.totalMem) + ' (' + pct + '% used)') : 'N/A';
if (memory.firstChild && memory.firstChild.nodeType === Node.TEXT_NODE) {
memory.firstChild.textContent = text;
} else {
memory.insertBefore(document.createTextNode(text), memory.firstChild);
}
if (bar) bar.style.width = (meta ? pct : 0) + '%';
}
const load = card.querySelector('[data-wk-load]');
if (load) load.textContent = loadAvgText(meta);
const uptime = card.querySelector('[data-wk-uptime]');
if (uptime) uptime.textContent = fmt.uptime(meta && meta.uptime);
const tasks = card.querySelector('[data-wk-tasks]');
if (tasks) tasks.textContent = (meta && meta.activeTasks || 0) + ' / ' + (meta && meta.maxConcurrentTasks || 0);
// Admin footer can appear/disappear if isAdmin changes mid-session (rare,
// but keep it correct rather than assuming it's static).
const hasFooter = !!card.querySelector('.lt-card-footer');
if (Pulse.isAdmin && !hasFooter) {
card.insertAdjacentHTML('beforeend', footerHtml(worker));
} else if (!Pulse.isAdmin && hasFooter) {
const f = card.querySelector('.lt-card-footer');
if (f) f.remove();
} else if (hasFooter) {
const btn = card.querySelector('[data-action="wk:delete"]');
if (btn) btn.setAttribute('data-worker-name', worker.name);
}
}
/* -------------------------------------------------------------------
Grid rendering — patches existing cards, adds/removes as needed.
------------------------------------------------------------------- */
function renderGrid(workers) {
const grid = document.getElementById('wk-grid');
if (!grid) return;
if (workers.length === 0) {
grid.innerHTML =
'<div class="lt-empty-state">' +
'<div class="lt-empty-state-icon">&#x2699;</div>' +
'<div class="lt-empty-state-title">No workers connected</div>' +
'<div class="lt-empty-state-body">Workers appear here once they connect and send a heartbeat.</div>' +
'</div>';
return;
}
const loading = document.getElementById('wk-loading');
if (loading) loading.remove();
const seen = new Set();
const order = [];
workers.forEach(worker => {
seen.add(String(worker.id));
let card = grid.querySelector('.wk-card[data-worker-id="' + cssEscape(worker.id) + '"]');
if (!card) {
card = createCard(worker);
} else {
patchCard(card, worker);
}
order.push(card);
});
// Remove cards for workers no longer present.
Array.prototype.slice.call(grid.querySelectorAll('.wk-card')).forEach(card => {
if (!seen.has(String(card.getAttribute('data-worker-id')))) card.remove();
});
// Ensure DOM order matches API order without detaching untouched nodes
// unnecessarily (appendChild on an already-positioned node is a no-op
// move, not a re-create, so identity is preserved either way).
order.forEach(card => grid.appendChild(card));
}
function cssEscape(v) {
if (window.CSS && CSS.escape) return CSS.escape(String(v));
return String(v).replace(/["\\]/g, '\\$&');
}
function renderError() {
const grid = document.getElementById('wk-grid');
if (!grid) return;
grid.innerHTML = '<div class="lt-alert lt-alert--error"><div class="lt-alert-body"><div class="lt-alert-msg">Failed to load workers</div></div></div>';
}
/* -------------------------------------------------------------------
Data loading + actions
------------------------------------------------------------------- */
async function loadWorkers() {
try {
const workers = await Pulse.api.get('/api/workers') || [];
renderGrid(workers);
} catch (e) {
console.error('[Pulse:workers] failed to load workers', e);
renderError();
}
}
async function deleteWorker(el) {
const id = el.getAttribute('data-worker-id');
const name = el.getAttribute('data-worker-name') || 'this worker';
const ok = await Pulse.confirm({
title: 'Delete worker',
message: 'Delete worker ' + name + '?',
type: 'error',
});
if (!ok) return;
try {
await Pulse.api.delete('/api/workers/' + encodeURIComponent(id));
Pulse.toast.success('Worker deleted');
await loadWorkers();
} catch (e) {
Pulse.toast.error((e && e.message) || 'Failed to delete worker');
}
}
function onTick() {
document.querySelectorAll('#wk-grid [data-wk-lastseen][data-last-heartbeat]').forEach(el => {
const v = el.getAttribute('data-last-heartbeat');
if (v) el.textContent = 'Last seen: ' + fmt.ago(v);
});
}
function init() {
Pulse.actions.register('wk:delete', deleteWorker);
Pulse.events.on('tick', onTick);
return loadWorkers();
}
function onEvent(type) {
if (type === 'worker_update') {
loadWorkers();
return true;
}
return false;
}
Pulse.registerPage({ name: 'workers', init, refresh: loadWorkers, onEvent });
})();
+26
View File
@@ -0,0 +1,26 @@
/* Workflows page (WP-D) — extends base.css, never overrides its variables. */
.wf-json-textarea {
font-family: var(--font-mono);
min-height: 200px;
}
.wf-json-textarea-lg {
min-height: 320px;
font-size: 0.8rem;
}
.wf-checkbox-label {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.8rem;
color: var(--text-secondary);
cursor: pointer;
}
.wf-required {
color: var(--accent-red);
}
/* `.lt-field-error` and `.is-invalid` live in /assets/app.css (shared). */
+355
View File
@@ -0,0 +1,355 @@
/* =====================================================================
PULSE — Workflows page (WP-D)
---------------------------------------------------------------------
Owns: DOM id prefix `wf-`, action namespace `wf:*`.
Consumes the frozen window.Pulse contract from /assets/app.js.
===================================================================== */
'use strict';
(function () {
// Example workflow definition. The Create modal is pre-filled with this
// every time it opens (fixes the old bug where the textarea was blanked).
var EXAMPLE_DEFINITION = {
steps: [
{
name: 'Example Step',
type: 'execute',
targets: ['all'],
command: "echo 'Hello from PULSE'"
}
]
};
// id -> parsed definition object, rebuilt on every list load.
var _registry = {};
// id -> raw row from the list response (name/description/etc.).
var _rowsById = {};
// workflow id currently targeted by the Run modal.
var _pendingWorkflowId = null;
function paramBadge(def) {
var params = (def && def.params) || [];
if (!params.length) return '';
var n = params.length;
return ' <span class="lt-badge lt-badge-sm">' + n + ' param' + (n > 1 ? 's' : '') + '</span>';
}
function parseDefinition(raw) {
if (raw && typeof raw === 'object') return raw;
if (typeof raw === 'string') {
try { return JSON.parse(raw); } catch (e) { return {}; }
}
return {};
}
function renderEmptyState() {
return (
'<div class="lt-empty-state">' +
'<div class="lt-empty-state-icon">&#x2699;</div>' +
'<div class="lt-empty-state-title">No workflows yet</div>' +
'<div class="lt-empty-state-body">Workflows let you run multi-step jobs across one or more workers.</div>' +
'<button type="button" class="lt-btn lt-btn-primary" data-action="wf:create-open">Create your first workflow</button>' +
'</div>'
);
}
function renderRow(w) {
var def = _registry[w.id] || {};
var isAdmin = window.Pulse.isAdmin;
var esc = window.Pulse.esc;
var actions =
'<button type="button" class="lt-btn lt-btn-sm lt-btn-primary" data-action="wf:execute" data-workflow-id="' +
esc(w.id) + '">Execute</button>';
if (isAdmin) {
actions +=
' <button type="button" class="lt-btn lt-btn-sm lt-btn-secondary" data-action="wf:edit-open" data-workflow-id="' +
esc(w.id) + '">Edit</button>' +
' <button type="button" class="lt-btn lt-btn-sm lt-btn-danger" data-action="wf:delete" data-workflow-id="' +
esc(w.id) + '" data-workflow-name="' + esc(w.name) + '">Delete</button>';
}
var createdAt = window.Pulse.fmt.dateTime(w.created_at);
return (
'<tr>' +
'<td data-label="Name">' + esc(w.name) + paramBadge(def) + '</td>' +
'<td data-label="Description">' + esc(w.description || 'No description') + '</td>' +
'<td data-label="Created">' + esc(w.created_by || 'Unknown') + ' &middot; ' + esc(createdAt) + '</td>' +
'<td data-label="Actions"><div class="lt-btn-group">' + actions + '</div></td>' +
'</tr>'
);
}
function renderList(workflows) {
var el = document.getElementById('wf-list');
if (!el) return;
if (!workflows.length) {
el.innerHTML = renderEmptyState();
return;
}
var rows = workflows.map(renderRow).join('');
el.innerHTML =
'<div class="lt-table-wrap">' +
'<table class="lt-table lt-table-responsive">' +
'<thead><tr><th>Name</th><th>Description</th><th>Created</th><th>Actions</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>';
}
function renderLoadError(message) {
var el = document.getElementById('wf-list');
if (!el) return;
el.innerHTML =
'<div class="lt-alert lt-alert--error" role="alert">' +
'<span class="lt-alert-icon">&#x26A0;</span>' +
'<span class="lt-alert-msg">' + window.Pulse.esc(message || 'Failed to load workflows') + '</span>' +
'</div>';
}
function loadWorkflows() {
return window.Pulse.api.get('/api/workflows').then(function (workflows) {
var list = workflows || [];
_registry = {};
_rowsById = {};
list.forEach(function (w) {
_rowsById[w.id] = w;
_registry[w.id] = parseDefinition(w.definition);
});
renderList(list);
}).catch(function (e) {
renderLoadError(e && e.message);
});
}
/* ---------------------------------------------------------------
Create modal
--------------------------------------------------------------- */
function showFieldError(id, message) {
var el = document.getElementById(id);
if (!el) return;
el.textContent = message || '';
el.hidden = !message;
}
function openCreateModal() {
document.getElementById('wf-create-form').reset();
document.getElementById('wf-create-definition').value = JSON.stringify(EXAMPLE_DEFINITION, null, 2);
showFieldError('wf-create-error', '');
window.lt.modal.open('wf-create-modal');
}
function submitCreateForm(formEl) {
var name = formEl.elements['name'].value.trim();
var description = formEl.elements['description'].value.trim();
var definitionText = formEl.elements['definition'].value;
var webhookUrl = formEl.elements['webhook_url'].value.trim() || null;
if (!name) {
showFieldError('wf-create-error', 'Name is required.');
return;
}
var definition;
try {
definition = JSON.parse(definitionText);
} catch (e) {
showFieldError('wf-create-error', 'Invalid JSON: ' + e.message);
return;
}
showFieldError('wf-create-error', '');
return window.Pulse.api.post('/api/workflows', {
name: name,
description: description,
definition: definition,
webhook_url: webhookUrl
}).then(function () {
window.lt.modal.close('wf-create-modal');
window.Pulse.toast.success('Workflow created');
return loadWorkflows();
}).catch(function (e) {
showFieldError('wf-create-error', (e && e.message) || 'Failed to create workflow');
});
}
/* ---------------------------------------------------------------
Edit modal (admin)
--------------------------------------------------------------- */
function openEditModal(workflowId) {
showFieldError('wf-edit-error', '');
return window.Pulse.api.get('/api/workflows/' + encodeURIComponent(workflowId)).then(function (wf) {
document.getElementById('wf-edit-id').value = wf.id;
document.getElementById('wf-edit-name').value = wf.name || '';
document.getElementById('wf-edit-description').value = wf.description || '';
document.getElementById('wf-edit-definition').value = JSON.stringify(parseDefinition(wf.definition), null, 2);
document.getElementById('wf-edit-webhook').value = wf.webhook_url || '';
window.lt.modal.open('wf-edit-modal');
}).catch(function (e) {
window.Pulse.toast.error((e && e.message) || 'Failed to load workflow');
});
}
function submitEditForm(formEl) {
var id = formEl.elements['id'].value;
var name = formEl.elements['name'].value.trim();
var description = formEl.elements['description'].value.trim();
var definitionText = formEl.elements['definition'].value;
var webhookUrl = formEl.elements['webhook_url'].value.trim() || null;
if (!name) {
showFieldError('wf-edit-error', 'Name is required.');
return;
}
var definition;
try {
definition = JSON.parse(definitionText);
} catch (e) {
showFieldError('wf-edit-error', 'Invalid JSON: ' + e.message);
return;
}
showFieldError('wf-edit-error', '');
return window.Pulse.api.put('/api/workflows/' + encodeURIComponent(id), {
name: name,
description: description,
definition: definition,
webhook_url: webhookUrl
}).then(function () {
window.lt.modal.close('wf-edit-modal');
window.Pulse.toast.success('Workflow saved');
return loadWorkflows();
}).catch(function (e) {
showFieldError('wf-edit-error', (e && e.message) || 'Failed to save workflow');
});
}
/* ---------------------------------------------------------------
Delete (admin)
--------------------------------------------------------------- */
function deleteWorkflow(workflowId, workflowName) {
return window.Pulse.confirm({
title: 'Delete Workflow',
message: 'Delete workflow "' + workflowName + '"? This cannot be undone.',
type: 'error',
confirmLabel: 'DELETE'
}).then(function (ok) {
if (!ok) return;
return window.Pulse.api.delete('/api/workflows/' + encodeURIComponent(workflowId)).then(function () {
window.Pulse.toast.success('Workflow deleted');
return loadWorkflows();
}).catch(function (e) {
window.Pulse.toast.error((e && e.message) || 'Failed to delete workflow');
});
});
}
/* ---------------------------------------------------------------
Run modal (execute): always shown, replaces the old confirm().
--------------------------------------------------------------- */
function paramFieldHtml(p) {
var esc = window.Pulse.esc;
var label = esc(p.label || p.name);
var required = p.required ? ' <span class="wf-required">*</span>' : '';
return (
'<div class="lt-form-group">' +
'<label class="lt-label" for="wf-run-param-' + esc(p.name) + '">' + label + required + '</label>' +
'<input type="text" id="wf-run-param-' + esc(p.name) + '" class="lt-input" ' +
'data-param-name="' + esc(p.name) + '" data-required="' + (p.required ? '1' : '0') + '" ' +
'placeholder="' + esc(p.placeholder || '') + '">' +
'</div>'
);
}
function openRunModal(workflowId) {
var row = _rowsById[workflowId];
var def = _registry[workflowId] || {};
var paramDefs = def.params || [];
_pendingWorkflowId = workflowId;
document.getElementById('wf-run-workflow-id').value = workflowId;
document.getElementById('wf-run-name').textContent = row ? row.name : 'this workflow';
document.getElementById('wf-run-dryrun').checked = false;
var paramsEl = document.getElementById('wf-run-params');
paramsEl.innerHTML = paramDefs.map(paramFieldHtml).join('');
window.lt.modal.open('wf-run-modal');
var first = paramsEl.querySelector('input');
if (first) setTimeout(function () { first.focus(); }, 60);
}
function submitRunForm() {
if (!_pendingWorkflowId) return;
var def = _registry[_pendingWorkflowId] || {};
var paramDefs = def.params || [];
var params = {};
for (var i = 0; i < paramDefs.length; i++) {
var p = paramDefs[i];
var el = document.getElementById('wf-run-param-' + p.name);
var val = el ? el.value.trim() : '';
if (p.required && !val) {
el.classList.add('is-invalid');
el.focus();
return;
}
el && el.classList.remove('is-invalid');
if (val) params[p.name] = val;
}
var dryRun = document.getElementById('wf-run-dryrun').checked;
var workflowId = _pendingWorkflowId;
return window.Pulse.api.post('/api/executions', {
workflow_id: workflowId,
params: params,
dry_run: dryRun
}).then(function (result) {
window.lt.modal.close('wf-run-modal');
_pendingWorkflowId = null;
window.Pulse.toast.success(dryRun ? 'Dry run started' : 'Workflow started');
window.location.href = '/executions?open=' + encodeURIComponent(result.id);
}).catch(function (e) {
window.Pulse.toast.error((e && e.message) || 'Failed to start execution');
});
}
/* ---------------------------------------------------------------
Actions + page registration
--------------------------------------------------------------- */
window.Pulse.actions.registerAll({
'wf:create-open': function () { openCreateModal(); },
'wf:create-submit': function (el) { return submitCreateForm(el); },
'wf:execute': function (el) { openRunModal(el.getAttribute('data-workflow-id')); },
'wf:edit-open': function (el) { return openEditModal(el.getAttribute('data-workflow-id')); },
'wf:edit-submit': function (el) { return submitEditForm(el); },
'wf:delete': function (el) {
return deleteWorkflow(el.getAttribute('data-workflow-id'), el.getAttribute('data-workflow-name'));
},
'wf:param-submit': function () { return submitRunForm(); }
});
// Enter in a param input submits the Run form (matches old behaviour).
document.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter') return;
var target = ev.target;
if (!target || target.tagName !== 'INPUT') return;
if (!target.closest || !target.closest('#wf-run-form')) return;
ev.preventDefault();
submitRunForm();
});
window.Pulse.registerPage({
name: 'workflows',
init: function () { return loadWorkflows(); },
refresh: function () { return loadWorkflows(); },
onEvent: function (type) {
if (type === 'workflow_created' || type === 'workflow_updated' || type === 'workflow_deleted') {
loadWorkflows();
return true;
}
return false;
}
});
})();
-1
View File
@@ -1 +0,0 @@
/root/code/web_template/base.js
-3162
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
v1.2 bbec859 2026-09-09T01:34:49Z
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
# Demo environment (for README screenshots)
Fictional data only. A throwaway MariaDB on loopback, the PULSE server and three local workers that run harmless commands.
```bash
D=$(mktemp -d); rsync -a --exclude .git . $D/pulse && cd $D/pulse && npm install
mariadb-install-db --no-defaults --datadir=$D/db --auth-root-authentication-method=normal --skip-test-db
mariadbd --no-defaults --datadir=$D/db --socket=$D/sock --port=3399 --bind-address=127.0.0.1 --user=root &
mariadb --no-defaults -S $D/sock -e "CREATE DATABASE pulse; CREATE USER 'pulse'@'127.0.0.1' IDENTIFIED BY 'demo-only'; GRANT ALL ON pulse.* TO 'pulse'@'127.0.0.1'"
printf 'PORT=8400\nHOST=127.0.0.1\nDB_HOST=127.0.0.1\nDB_PORT=3399\nDB_NAME=pulse\nDB_USER=pulse\nDB_PASSWORD=demo-only\nWORKER_API_KEY=demo-worker-key\n' > .env
node server.js &
for n in 1 2 3; do (cd worker && WORKER_NAME=node-0$n PULSE_SERVER=http://127.0.0.1:8400 PULSE_WS=ws://127.0.0.1:8400 WORKER_API_KEY=demo-worker-key node worker.js &); done
python3 scripts/demo/seed.py # workflows, executions, schedules (via the real API)
python3 scripts/demo/capture.py # screenshots + GIF -> docs/img/
```
The server trusts Authelia `Remote-User` headers, so the scripts send them directly. For the demo only, raise `apiLimiter.max`
in `server.js` (the screenshot run exceeds 300 requests / 15 min). Requires `playwright` and `pillow`.
+84
View File
@@ -0,0 +1,84 @@
"""Regenerates docs/img/* from a LOCAL demo instance (fictional data). See scripts/demo/README.md."""
import asyncio, io, sys
from PIL import Image
from playwright.async_api import async_playwright
import pathlib
OUT=str(pathlib.Path(__file__).resolve().parents[2]/'docs'/'img')
B='http://127.0.0.1:8400'
H={'Remote-User':'alex.rivera','Remote-Name':'Alex Rivera','Remote-Email':'alex@example.com','Remote-Groups':'admin,employee'}
TOP="document.activeElement&&document.activeElement.blur();document.documentElement.style.scrollBehavior='auto';window.scrollTo(0,0)"
async def mk(p,theme='dark',w=1440,h=900):
b=await p.chromium.launch(); c=await b.new_context(viewport={'width':w,'height':h},extra_http_headers=H)
await c.add_init_script(f"try{{localStorage.setItem('lt_theme','{theme}');sessionStorage.setItem('lt_boot','1')}}catch(e){{}}")
return b,c
async def go(pg,path,wait=3500):
await pg.goto(B+path); await pg.wait_for_timeout(wait); await pg.evaluate(TOP); await pg.wait_for_timeout(300)
async def shots():
async with async_playwright() as p:
b,c=await mk(p); pg=await c.new_page(); errs=[]; pg.on('pageerror',lambda e:errs.append(str(e)[:100]))
for path,name in (('/','dashboard'),('/workers','workers'),('/workflows','workflows'),('/executions','executions'),('/quick','quick-command'),('/scheduler','scheduler')):
await go(pg,path,5000 if path=='/' else 3500); await pg.screenshot(path=f'{OUT}/{name}.png'); print(name,'ok')
print('errors',errs[:3]); await b.close()
async def open_exec(pg,text,idx=0,wait=1500):
await go(pg,'/executions',3000)
await pg.locator('tr',has_text=text).nth(idx).click(); await pg.wait_for_timeout(wait)
async def scroll_modal(pg,y):
await pg.evaluate("(y)=>{const m=[...document.querySelectorAll('*')].filter(e=>e.scrollHeight>e.clientHeight+40&&['auto','scroll'].includes(getComputedStyle(e).overflowY)&&e.clientHeight>200); m.forEach(e=>e.scrollTop=y)}",y); await pg.wait_for_timeout(400)
async def shots2():
async with async_playwright() as p:
b,c=await mk(p); pg=await c.new_page()
await open_exec(pg,'Rolling restart',1); await pg.screenshot(path=f'{OUT}/execution-detail.png')
await scroll_modal(pg,700); await pg.screenshot(path=f'{OUT}/execution-detail-steps.png')
await open_exec(pg,'Check backup mount',0); await pg.screenshot(path=f'{OUT}/execution-failed.png')
await open_exec(pg,'Rolling restart',0,2500); await pg.screenshot(path=f'{OUT}/execution-awaiting-approval.png')
# workflow editor
await go(pg,'/workflows',3000)
await pg.locator('tr',has_text='Rolling restart').get_by_text('EDIT',exact=False).first.click(); await pg.wait_for_timeout(1200)
await pg.screenshot(path=f'{OUT}/workflow-editor.png')
# quick command with output
await go(pg,'/quick',3000)
try:
await pg.get_by_text('Multiple Workers').click(); await pg.wait_for_timeout(300)
await pg.get_by_text('SELECT ALL',exact=False).first.click(); await pg.wait_for_timeout(300)
except Exception as e: print('multi fail',str(e)[:60])
await pg.locator('textarea').first.fill("echo \"== $(hostname) ==\"; uptime; df -h / | tail -1"); await pg.wait_for_timeout(300)
await pg.screenshot(path=f'{OUT}/quick-command.png')
await pg.keyboard.press('Control+Enter'); await pg.wait_for_timeout(3500); await pg.screenshot(path=f'{OUT}/quick-command-result.png')
# palette
await go(pg,'/',3500); await pg.keyboard.press('Control+k'); await pg.wait_for_timeout(500); await pg.keyboard.type('exec',delay=70); await pg.wait_for_timeout(700)
await pg.screenshot(path=f'{OUT}/command-palette.png',clip={'x':320,'y':60,'width':800,'height':460})
await b.close()
b,c=await mk(p,'light'); pg=await c.new_page(); await go(pg,'/',4500); await pg.screenshot(path=f'{OUT}/dashboard-light.png'); await b.close()
async def gif():
frames=[]
async with async_playwright() as p:
b,c=await mk(p,'dark',1280,720); pg=await c.new_page()
async def snap(n=1,ms=0):
if ms: await pg.wait_for_timeout(ms)
im=Image.open(io.BytesIO(await pg.screenshot())).convert('RGB').resize((960,540),Image.LANCZOS)
for _ in range(n): frames.append(im)
await go(pg,'/',4500); await snap(3)
await go(pg,'/workers',2500); await snap(2)
await go(pg,'/workflows',2500); await snap(2)
await go(pg,'/executions',3500); await pg.locator('tr',has_text='Rolling restart').nth(1).wait_for(); await snap(2)
await pg.locator('tr',has_text='Rolling restart').nth(1).click(); await snap(3,1200)
await scroll(pg) if False else None
await pg.evaluate("(y)=>{[...document.querySelectorAll('*')].filter(e=>e.scrollHeight>e.clientHeight+40&&['auto','scroll'].includes(getComputedStyle(e).overflowY)&&e.clientHeight>200).forEach(e=>e.scrollTop=y)}",700); await snap(3,500)
await go(pg,'/executions',3500); await pg.locator('tr',has_text='Rolling restart').nth(1).wait_for(); await pg.locator('tr',has_text='Rolling restart').nth(0).click(); await snap(3,1800)
await go(pg,'/quick',2500)
await pg.get_by_text('Multiple Workers').click(); await pg.get_by_text('SELECT ALL',exact=False).first.click()
await pg.locator('textarea').first.fill("uptime; df -h / | tail -1"); await snap(2,300)
await pg.keyboard.press('Control+Enter'); await snap(3,2500)
await go(pg,'/',2500); await pg.keyboard.press('Control+k'); await snap(2,500)
for ch in 'exec': await pg.keyboard.type(ch); await snap(1,200)
await snap(3,400)
await b.close()
pal=[f.quantize(colors=96,method=Image.MEDIANCUT,dither=Image.NONE) for f in frames]
pal[0].save(OUT+'/demo.gif',save_all=True,append_images=pal[1:],duration=1100,loop=0,optimize=True); print('gif',len(frames))
async def main():
await shots(); await shots2(); await gif()
asyncio.run(main())
+43
View File
@@ -0,0 +1,43 @@
import json, time, urllib.request
B='http://127.0.0.1:8400'
H={'Remote-User':'alex.rivera','Remote-Name':'Alex Rivera','Remote-Email':'alex@example.com','Remote-Groups':'admin,employee','Content-Type':'application/json'}
def call(method,path,body=None):
req=urllib.request.Request(B+path,method=method,headers=H,data=json.dumps(body).encode() if body is not None else None)
try:
with urllib.request.urlopen(req,timeout=30) as r: return json.loads(r.read() or b'{}')
except urllib.error.HTTPError as e: return {'error':e.code,'body':e.read().decode()[:200]}
workers={w['name']:w['id'] for w in call('GET','/api/workers')}
print(workers)
wf={}
wf['health']=call('POST','/api/workflows',{'name':'Node health check','description':'Uptime, root filesystem and memory on every online worker','definition':{'steps':[
{'id':'uptime','name':'Uptime','type':'execute','command':'uptime','targets':['all']},
{'id':'disk','name':'Root filesystem usage','type':'execute','command':"df -h / | tail -1",'targets':['all']},
{'id':'mem','name':'Memory','type':'execute','command':'free -h | head -2','targets':['all']}]}})['id']
wf['restart']=call('POST','/api/workflows',{'name':'Rolling restart (with approval)','description':'Checks state, asks a human to approve, restarts one node, verifies','definition':{'steps':[
{'id':'check','name':'Check cluster state','type':'execute','command':'echo "all 3 nodes healthy"; date','targets':['all']},
{'id':'ask','name':'Approve restart on node-01?','type':'prompt','message':'All nodes are healthy. Restart the service on node-01?','options':['Proceed','Abort'],'key':'approval','routes':{'Abort':'end'}},
{'id':'restart','name':'Restart service on node-01','type':'execute','command':'echo "stopping service"; sleep 1; echo "starting service"; echo "service active"','targets':['node-01']},
{'id':'settle','name':'Let it settle','type':'wait','duration':2},
{'id':'verify','name':'Verify','type':'execute','command':'echo "verification passed on $(hostname)"','targets':['node-01']}]}})['id']
wf['route']=call('POST','/api/workflows',{'name':'Capacity check with routing','description':'Parses KEY=VALUE output and routes on the result','definition':{'steps':[
{'id':'probe','name':'Probe capacity','type':'execute','command':'echo "FREE_PCT=12"; echo "POOL=app-nvme"','targets':['node-02']},
{'id':'parse','name':'Parse results','type':'parse'},
{'id':'decide','name':'Decide','type':'route','conditions':[{'if':'parseInt(state.free_pct) < 20','goto':'warn','label':'Low capacity'},{'default':True,'goto':'end'}]},
{'id':'warn','name':'Raise warning','type':'execute','command':'echo "WARNING: pool is below 20% free"; exit 0','targets':['node-02']}]}})['id']
wf['broken']=call('POST','/api/workflows',{'name':'Check backup mount','description':'Fails when the backup mount is missing','definition':{'steps':[
{'id':'mnt','name':'Backup mount present?','type':'execute','command':'ls /mnt/does-not-exist-backup','targets':['node-03']}]}})['id']
print(wf)
def run(k,params=None):
r=call('POST','/api/executions',{'workflow_id':wf[k],'params':params or {}}); time.sleep(1.2); return r
# quick commands first (history)
for name,cmd in (('node-01','uname -a'),('node-02','df -h /'),('node-03','free -h'),('node-01','uptime'),('node-02','lscpu | head -8')):
call('POST',f'/api/workers/{workers[name]}/command',{'command':cmd}); time.sleep(1.2)
for k in ('health','health','route','broken'): print(k,run(k).get('id'))
time.sleep(3)
r=run('restart'); time.sleep(2.5); print('restart#1',r.get('id'))
print(call('POST',f"/api/executions/{r['id']}/respond",{'response':'Proceed'}))
time.sleep(6)
run('health'); time.sleep(3)
r2=run('restart'); print('restart waiting',r2.get('id')) # left waiting for approval on purpose
for name,cmd,t,v in (('Disk usage report','df -h /','interval','60'),('Memory snapshot','free -h | head -2','hourly','1'),('Uptime log','uptime','interval','120')):
print(call('POST','/api/scheduled-commands',{'name':name,'command':cmd,'worker_ids':[workers['node-01'],workers['node-02']],'schedule_type':t,'schedule_value':v}))
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Vendor base.css / base.js from a LotusGuild web_template checkout into public/web_template/.
#
# Usage: scripts/sync-web-template.sh <path-to-web_template-checkout>
#
# Writes public/web_template/{base.css,base.js} as regular files (never symlinks) and
# public/web_template/VERSION containing "v1.2 <short-sha> <iso-date>".
set -euo pipefail
SRC="${1:-}"
if [ -z "$SRC" ]; then
echo "usage: $0 <path-to-web_template-checkout>" >&2
exit 2
fi
if [ ! -d "$SRC" ]; then
echo "error: source directory not found: $SRC" >&2
exit 2
fi
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEST="$REPO_ROOT/public/web_template"
mkdir -p "$DEST"
for f in base.css base.js; do
if [ ! -f "$SRC/$f" ]; then
echo "error: missing $SRC/$f" >&2
exit 1
fi
rm -f "$DEST/$f"
cp "$SRC/$f" "$DEST/$f"
chmod 644 "$DEST/$f"
echo "synced $f"
done
SHA="$(git -C "$SRC" rev-parse --short HEAD 2>/dev/null || echo unknown)"
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'v1.2 %s %s\n' "$SHA" "$DATE" > "$DEST/VERSION"
echo "VERSION: $(cat "$DEST/VERSION")"
+159 -36
View File
@@ -6,16 +6,62 @@ const crypto = require('crypto');
const vm = require('vm');
const rateLimit = require('express-rate-limit');
const cronParser = require('cron-parser');
const path = require('path');
const fs = require('fs');
const helmet = require('helmet');
require('dotenv').config();
const { validateWebhookUrl, applyParams, evalCondition, calculateNextRun } = require('./lib/utils');
const { PAGES } = require('./lib/nav');
const { renderPage, renderError } = require('./lib/render');
const { upsertUser, makeAuthenticatePage } = require('./lib/pageauth');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// Dev/read-only guard: skip every background job that writes to the database.
const DEV_READONLY = process.env.PULSE_DEV_READONLY === '1' || process.env.DISABLE_BACKGROUND_JOBS === '1';
// Middleware
// CSP nonce — must run before helmet so the directive can read it.
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});
const CSP_REPORT_ONLY = process.env.PULSE_CSP_REPORT_ONLY === '1';
const cspDirectives = {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
scriptSrcAttr: ["'none'"],
styleSrcElem: ["'self'", 'https://fonts.googleapis.com'],
styleSrcAttr: ["'unsafe-inline'"],
styleSrc: ["'self'", 'https://fonts.googleapis.com', "'unsafe-inline'"],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", 'ws:', 'wss:'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
upgradeInsecureRequests: null
};
if (CSP_REPORT_ONLY) {
cspDirectives.reportUri = ['/csp-report'];
}
app.use(helmet({
contentSecurityPolicy: {
useDefaults: false,
directives: cspDirectives,
reportOnly: CSP_REPORT_ONLY
},
crossOriginEmbedderPolicy: false,
hsts: process.env.NODE_ENV === 'production' ? undefined : false
}));
app.use(express.json());
app.use(express.static('public'));
// Rate limiting
const apiLimiter = rateLimit({
@@ -32,7 +78,6 @@ const executionLimiter = rateLimit({
legacyHeaders: false,
message: { error: 'Too many execution requests, please slow down' }
});
app.use('/api/', apiLimiter);
// Named constants for timeouts and limits
const PROMPT_TIMEOUT_MS = 60 * 60 * 1000; // 60 min — how long a prompt waits for user input
@@ -53,6 +98,24 @@ app.use((req, res, next) => {
next();
});
// CSP violation reports (only useful with PULSE_CSP_REPORT_ONLY=1, but always mounted)
app.post('/csp-report',
express.json({ type: ['application/csp-report', 'application/reports+json', 'application/json'] }),
(req, res) => {
try {
console.log('[CSP]', JSON.stringify(req.body));
} catch (_) {
console.log('[CSP] (unparseable report)');
}
res.status(204).end();
});
// Static assets — the old root mount of public/ is gone on purpose.
app.use('/web_template', express.static(path.join(__dirname, 'public/web_template'), { maxAge: '1h' }));
app.use('/assets', express.static(path.join(__dirname, 'public/assets'), { maxAge: '1h' }));
app.use('/api/', apiLimiter);
// Content-Type guard for JSON endpoints
function requireJSON(req, res, next) {
const ct = req.headers['content-type'] || '';
@@ -160,19 +223,23 @@ async function initDatabase() {
`);
// Recover stale executions from a previous server crash
const [staleExecs] = await connection.query("SELECT id FROM executions WHERE status = 'running'");
if (staleExecs.length > 0) {
for (const exec of staleExecs) {
await connection.query(
"UPDATE executions SET status = 'failed', completed_at = NOW() WHERE id = ?",
[exec.id]
);
await connection.query(
"UPDATE executions SET logs = JSON_ARRAY_APPEND(COALESCE(logs, '[]'), '$', JSON_EXTRACT(?, '$')) WHERE id = ?",
[JSON.stringify({ action: 'server_restart_recovery', message: 'Execution marked failed due to server restart', timestamp: new Date().toISOString() }), exec.id]
);
if (!DEV_READONLY) {
const [staleExecs] = await connection.query("SELECT id FROM executions WHERE status = 'running'");
if (staleExecs.length > 0) {
for (const exec of staleExecs) {
await connection.query(
"UPDATE executions SET status = 'failed', completed_at = NOW() WHERE id = ?",
[exec.id]
);
await connection.query(
"UPDATE executions SET logs = JSON_ARRAY_APPEND(COALESCE(logs, '[]'), '$', JSON_EXTRACT(?, '$')) WHERE id = ?",
[JSON.stringify({ action: 'server_restart_recovery', message: 'Execution marked failed due to server restart', timestamp: new Date().toISOString() }), exec.id]
);
}
console.log(`[Recovery] Marked ${staleExecs.length} stale execution(s) as failed`);
}
console.log(`[Recovery] Marked ${staleExecs.length} stale execution(s) as failed`);
} else {
console.log('[DEV] skipped: stale-execution recovery');
}
console.log('Database tables initialized successfully');
@@ -202,10 +269,14 @@ async function cleanupOldExecutions() {
}
}
// Run cleanup hourly
setInterval(cleanupOldExecutions, 60 * 60 * 1000);
// Run cleanup on startup
cleanupOldExecutions();
if (!DEV_READONLY) {
// Run cleanup hourly
setInterval(cleanupOldExecutions, 60 * 60 * 1000);
// Run cleanup on startup
cleanupOldExecutions();
} else {
console.log('[DEV] skipped: cleanupOldExecutions interval + startup run');
}
// Scheduled Commands Processor
async function processScheduledCommands() {
@@ -292,10 +363,14 @@ async function processScheduledCommands() {
}
// Run scheduler every minute
setInterval(processScheduledCommands, 60 * 1000);
// Initial run on startup
setTimeout(processScheduledCommands, 5000);
if (!DEV_READONLY) {
// Run scheduler every minute
setInterval(processScheduledCommands, 60 * 1000);
// Initial run on startup
setTimeout(processScheduledCommands, 5000);
} else {
console.log('[DEV] skipped: processScheduledCommands interval + startup run');
}
// Mark workers offline when their heartbeat goes stale
async function markStaleWorkersOffline() {
@@ -314,7 +389,11 @@ async function markStaleWorkersOffline() {
console.error('[Worker] Stale check error:', error);
}
}
setInterval(markStaleWorkersOffline, 60_000);
if (!DEV_READONLY) {
setInterval(markStaleWorkersOffline, 60_000);
} else {
console.log('[DEV] skipped: markStaleWorkersOffline interval');
}
// WebSocket connections
const browserClients = new Set(); // Browser UI connections
@@ -624,17 +703,7 @@ async function authenticateSSO(req, res, next) {
// Store/update user in database
try {
const userId = crypto.randomUUID();
await pool.query(
`INSERT INTO users (id, username, display_name, email, groups, last_login)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
display_name=VALUES(display_name),
email=VALUES(email),
groups=VALUES(groups),
last_login=NOW()`,
[userId, remoteUser, remoteName, remoteEmail, remoteGroups]
);
await upsertUser(pool, req.headers);
} catch (error) {
console.error('Error updating user:', error);
}
@@ -1082,6 +1151,28 @@ async function waitForCommandResult(executionId, commandId, timeout) {
});
}
// HTML page routes (registered before the API routes)
const authenticatePage = makeAuthenticatePage(pool);
PAGES.forEach((p) => {
app.get(p.path, authenticatePage, async (req, res, next) => {
try {
await renderPage(req, res, p.view, {
pageTitle: p.title,
activeNav: p.key,
pageScripts: [`/assets/pages/${p.view}.js`],
pageStyles: fs.existsSync(path.join(__dirname, `public/assets/pages/${p.view}.css`))
? [`/assets/pages/${p.view}.css`]
: [],
pageConfig: { isAdmin: req.user.isAdmin }
});
} catch (e) {
next(e);
}
});
});
// Routes - All protected by SSO
app.get('/api/user', authenticateSSO, (req, res) => {
res.json(req.user);
@@ -1103,7 +1194,11 @@ app.get('/api/workflows/:id', authenticateSSO, async (req, res) => {
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
const wf = rows[0];
let definition = {};
try { definition = JSON.parse(wf.definition || '{}'); } catch { /* corrupt definition — return empty */ }
try {
definition = typeof wf.definition === 'string'
? JSON.parse(wf.definition || '{}')
: (wf.definition || {});
} catch { /* corrupt definition — return empty */ }
res.json({ ...wf, definition });
} catch (error) {
console.error('[API] GET /api/workflows/:id error:', error);
@@ -1568,7 +1663,11 @@ app.get('/api/internal/executions/:id', authenticateGandalf, async (req, res) =>
}
const execution = rows[0];
let logs = [];
try { logs = JSON.parse(execution.logs || '[]'); } catch { logs = []; }
try {
logs = typeof execution.logs === 'string'
? JSON.parse(execution.logs || '[]')
: (execution.logs || []);
} catch { logs = []; }
res.json({ ...execution, logs });
} catch (error) {
console.error('[API] GET /api/internal/executions/:id error:', error);
@@ -1717,6 +1816,24 @@ app.post('/api/workers/:id/command', authenticateSSO, requireJSON, async (req, r
}
});
// 404 — JSON under /api/, themed HTML elsewhere
app.use((req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
}
renderError(req, res, 404, 'Not found', `No page exists at ${req.path}`);
});
// Error handler
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
console.error('[Error]', err && err.stack ? err.stack : err);
if (res.headersSent) return;
if (req.path.startsWith('/api/')) {
return res.status(500).json({ error: 'Internal server error' });
}
renderError(req, res, 500, 'Internal server error', 'Something went wrong rendering this page.');
});
// Start server
const PORT = process.env.PORT || 8080;
const HOST = process.env.HOST || '0.0.0.0';
@@ -1727,6 +1844,12 @@ initDatabase().then(() => {
console.log(`Connected to MariaDB at ${process.env.DB_HOST}`);
console.log(`Authentication: Authelia SSO`);
console.log(`Worker API Key configured: ${process.env.WORKER_API_KEY ? 'Yes' : 'No'}`);
if (DEV_READONLY) {
console.warn('************************************************************');
console.warn('* PULSE_DEV_READONLY IS ON — background jobs are disabled *');
console.warn('* No cleanup, scheduler, stale-worker or recovery writes. *');
console.warn('************************************************************');
}
});
}).catch(err => {
console.error('Failed to start server:', err);
+190
View File
@@ -0,0 +1,190 @@
<%#
PULSE — LotusGuild Terminal Design System base layout.
Vendored from web_template/node/layout.ejs (bbec859) and extended for Pulse:
fixed comment delimiters, skip link, mobile drawer, theme button, command
palette, WS status, footer key hints and the keyboard-shortcuts modal.
Modelled on /root/code/gandalf/templates/base.html.
Rendered by lib/render.js renderPage(); the page view is pre-rendered into `body`.
Locals (all always provided by renderPage):
user { username, name, email, groups, isAdmin }
nonce CSP nonce string
appName APP_NAME || 'PULSE'
appSubtitle APP_SUBTITLE || 'Worker Orchestration // LotusGuild'
csrfToken string (currently '')
navLinks [{ href, key, label }]
pageTitle string
activeNav string, matches navLinks[].key
pageStyles [href]
pageScripts [src]
pageConfig object serialised to window.PULSE_CONFIG
assetVersion cache-busting string
body pre-rendered page HTML
%>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#030508">
<meta name="robots" content="noindex, nofollow">
<title><%= pageTitle ? pageTitle + ' — ' : '' %><%= appName %></title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
<!-- Design system -->
<link rel="stylesheet" href="/web_template/base.css?v=<%= assetVersion %>">
<!-- App-specific CSS (extends base, never overrides variables without good reason) -->
<link rel="stylesheet" href="/assets/app.css?v=<%= assetVersion %>">
<% (pageStyles || []).forEach(function (href) { %>
<link rel="stylesheet" href="<%= href %>?v=<%= assetVersion %>">
<% }); %>
<link rel="icon" href="/assets/favicon.png" type="image/png">
</head>
<body>
<a class="lt-skip-link" href="#main-content">Skip to main content</a>
<!-- BOOT OVERLAY -->
<div id="lt-boot" class="lt-boot-overlay"
data-app-name="<%= appName.toUpperCase() %>"
style="display:none" aria-hidden="true">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
<!-- MOBILE NAV DRAWER -->
<div id="lt-nav-drawer" class="lt-nav-drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Navigation menu">
<div class="lt-nav-drawer-header">
<span class="lt-brand-title"><%= appName.toUpperCase() %></span>
<button type="button" class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close navigation">&#x2715;</button>
</div>
<nav class="lt-nav-drawer-links" aria-label="Mobile navigation">
<% (navLinks || []).forEach(function (link) { %>
<a href="<%= link.href %>"
class="lt-nav-drawer-link<%= activeNav === link.key ? ' active' : '' %>"
<%- activeNav === link.key ? 'aria-current="page"' : '' %>><%= link.label %></a>
<% }); %>
</nav>
</div>
<div id="lt-nav-overlay" class="lt-nav-drawer-overlay"></div>
<!-- PRIMARY HEADER -->
<header class="lt-header" role="banner">
<div class="lt-header-left">
<!-- Hamburger (mobile) -->
<button type="button"
class="lt-menu-btn"
id="lt-menu-btn"
data-action="open-nav-drawer"
aria-label="Open navigation menu"
aria-expanded="false"
aria-controls="lt-nav-drawer">
<span class="lt-menu-btn-bar"></span>
<span class="lt-menu-btn-bar"></span>
<span class="lt-menu-btn-bar"></span>
</button>
<!-- Brand -->
<div class="lt-brand">
<a href="/" class="lt-brand-title lt-glitch"
data-text="<%= appName.toUpperCase() %>"
aria-label="<%= appName.toUpperCase() %> home"><%= appName.toUpperCase() %></a>
<span class="lt-brand-subtitle"><%= appSubtitle %></span>
</div>
<!-- Desktop nav -->
<nav class="lt-nav" aria-label="Main navigation">
<% (navLinks || []).forEach(function (link) { %>
<a href="<%= link.href %>"
class="lt-nav-link<%= activeNav === link.key ? ' active' : '' %>"
<%- activeNav === link.key ? 'aria-current="page"' : '' %>><%= link.label %></a>
<% }); %>
</nav>
</div>
<div class="lt-header-right">
<!-- WebSocket connection status -->
<div class="lt-ws-status" id="pulse-ws-status" data-state="connecting" aria-live="polite"><span class="lt-dot"></span><span>Connecting…</span></div>
<span id="pulse-last-refreshed" class="lt-text-xs lt-text-muted"></span>
<!-- ⌘K affordance -->
<button type="button"
class="lt-btn lt-btn-ghost lt-btn-sm lt-cmd-hint-btn"
data-action="open-cmdpalette"
title="Command palette (Ctrl+K)"
aria-label="Open command palette">&#x2315;&nbsp;K</button>
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
aria-label="Toggle theme" title="Toggle light/dark mode">&#x2600;</button>
<% if (user && (user.name || user.username)) { %>
<span class="lt-header-user"><%= user.name || user.username %></span>
<% } %>
<% if (user && user.isAdmin) { %>
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
<% } %>
</div>
</header>
<%- include('partials/cmd-palette') %>
<!-- MAIN CONTENT -->
<main class="lt-main lt-container" id="main-content">
<%- body %>
</main>
<!-- FOOTER -->
<footer class="lt-footer" role="contentinfo">
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
<button type="button" class="lt-footer-hint" data-action="app:refresh"><span class="lt-footer-key">[ R ]</span> REFRESH</button>
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="app:theme"><span class="lt-footer-key">[ T ]</span> THEME</button>
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="open-cmdpalette"><span class="lt-footer-key">[ ^K ]</span> CMD</button>
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="app:keys-help"><span class="lt-footer-key">[ ? ]</span> HELP</button>
</nav>
<span><%= appName.toUpperCase() %> &mdash; TDS v1.2</span>
</footer>
<%- include('partials/keys-help') %>
<!-- =========================================================
SCRIPTS — all tags carry the CSP nonce
========================================================= -->
<!-- Runtime globals (the only inline script on the page) -->
<script nonce="<%= nonce %>">
window.CSRF_TOKEN = <%- JSON.stringify(csrfToken || '').replace(/</g, '\\u003c') %>;
window.CURRENT_USER = {
username: <%- JSON.stringify((user && user.username) || '').replace(/</g, '\\u003c') %>,
name: <%- JSON.stringify((user && user.name) || '').replace(/</g, '\\u003c') %>,
email: <%- JSON.stringify((user && user.email) || '').replace(/</g, '\\u003c') %>,
groups: <%- JSON.stringify((user && user.groups) || []).replace(/</g, '\\u003c') %>,
isAdmin: <%= user && user.isAdmin ? 'true' : 'false' %>
};
window.PULSE_CONFIG = <%- JSON.stringify(pageConfig || {}).replace(/</g, '\\u003c') %>;
</script>
<!-- Design system -->
<script nonce="<%= nonce %>" src="/web_template/base.js?v=<%= assetVersion %>"></script>
<!-- App JS -->
<script nonce="<%= nonce %>" src="/assets/app.js?v=<%= assetVersion %>"></script>
<% (pageScripts || []).forEach(function (src) { %>
<script nonce="<%= nonce %>" src="<%= src %>?v=<%= assetVersion %>"></script>
<% }); %>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<%#
Placeholder page view. Rendered by lib/render.js when views/pages/<view>.ejs
does not exist yet, so the shell can be exercised before the page work
packages land.
%>
<div class="lt-page-header">
<h1 class="lt-page-title"><%= pageTitle || 'Page' %></h1>
</div>
<div class="lt-frame">
<div class="lt-empty-state">
<div class="lt-empty-state-icon">&#x2699;</div>
<div class="lt-empty-state-title">Page under construction</div>
<div class="lt-empty-state-text">
This view has not been implemented yet. The shared shell, navigation and
assets are live; the page content lands with its work package.
</div>
</div>
</div>
+54
View File
@@ -0,0 +1,54 @@
<%#
Dashboard page (WP-C). Stats + recent manual executions + workers summary.
All data loaded client-side by /assets/pages/dashboard.js.
%>
<%- include('../partials/page-header', { title: 'Dashboard', subtitle: 'System overview', actions: '' }) %>
<div class="lt-stats-grid">
<div class="lt-stat-card" id="dash-stat-total" role="button" tabindex="0" data-action="dash:goto-workers" aria-label="Total workers">
<span class="lt-stat-icon lt-text-cyan" aria-hidden="true">&#x25c9;</span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="dash-stat-total-val">0</span>
<span class="lt-stat-label">Workers</span>
</div>
</div>
<div class="lt-stat-card" id="dash-stat-online" role="button" tabindex="0" data-action="dash:goto-workers" aria-label="Online workers">
<span class="lt-stat-icon lt-text-green" aria-hidden="true">&#x25cf;</span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="dash-stat-online-val">0</span>
<span class="lt-stat-label">Online</span>
</div>
</div>
<div class="lt-stat-card" id="dash-stat-offline" role="button" tabindex="0" data-action="dash:goto-workers" aria-label="Offline workers">
<span class="lt-stat-icon lt-text-red" aria-hidden="true">&#x25cb;</span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="dash-stat-offline-val">0</span>
<span class="lt-stat-label">Offline</span>
</div>
</div>
<div class="lt-stat-card" id="dash-stat-running" role="button" tabindex="0" data-action="dash:goto-executions" aria-label="Running executions">
<span class="lt-stat-icon lt-text-amber" aria-hidden="true">&#x25b8;</span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="dash-stat-running-val">0</span>
<span class="lt-stat-label">Running</span>
</div>
</div>
</div>
<div class="lt-frame">
<div class="lt-section-header">Recent Executions</div>
<div id="dash-executions-wrap">
<div class="lt-empty-state lt-empty-state--sm" id="dash-executions-loading">
<div class="lt-empty-state-title">Loading&hellip;</div>
</div>
</div>
</div>
<div class="lt-frame">
<div class="lt-section-header">Workers</div>
<div id="dash-workers-wrap">
<div class="lt-empty-state lt-empty-state--sm" id="dash-workers-loading">
<div class="lt-empty-state-title">Loading&hellip;</div>
</div>
</div>
</div>
+118
View File
@@ -0,0 +1,118 @@
<%#
Executions page (WP-E).
Owns DOM id prefix `ex-` and action namespace `ex:*`.
All behaviour lives in /assets/pages/executions.js (auto-loaded by the layout);
styling additions in /assets/pages/executions.css (auto-linked).
The page-header is written out inline rather than through the shared partial
because the Clear Completed button is admin-only and the partial takes its
actions as a pre-rendered string.
%>
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Executions</h1>
<p class="lt-page-subtitle">Workflow and quick-command run history</p>
</div>
<div class="lt-page-actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="ex:refresh">&#x21bb; Refresh</button>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" id="ex-compare-toggle"
data-action="ex:compare-toggle" aria-pressed="false">&#x25a4; Compare Mode</button>
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm is-hidden" id="ex-compare-run"
data-action="ex:compare-run">&#x2696; Compare Selected</button>
<% if (user && user.isAdmin) { %>
<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="ex:clear-completed">&#x2716; Clear Completed</button>
<% } %>
</div>
</div>
</div>
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<!-- Manual / Automated sub-tabs. base.js initTabs wires the click/keyboard
behaviour and persists lt_activeTab_/executions; the ex:view-tab action
keeps pulse_executionView (the authoritative store) in sync. -->
<div class="lt-tab-bar" role="tablist" aria-label="Execution view">
<button type="button" class="lt-tab active" role="tab" id="ex-tabbtn-manual"
aria-selected="true" aria-controls="ex-tab-manual"
data-tab="ex-tab-manual" data-action="ex:view-tab" data-view="manual">
&#x1f464; Manual Runs <span class="lt-badge lt-badge-sm" id="ex-count-manual"></span>
</button>
<button type="button" class="lt-tab" role="tab" id="ex-tabbtn-automated"
aria-selected="false" aria-controls="ex-tab-automated"
data-tab="ex-tab-automated" data-action="ex:view-tab" data-view="automated">
&#x1f916; Automated <span class="lt-badge lt-badge-sm" id="ex-count-automated"></span>
</button>
</div>
<div class="lt-alert lt-alert--warning is-hidden" id="ex-compare-hint">
<span class="lt-alert-icon">&#x25a4;</span>
<div class="lt-alert-body">
<div class="lt-alert-title">Compare mode</div>
<div class="lt-alert-msg">Select 2&ndash;5 executions to compare their outputs. Click a row to toggle selection.</div>
</div>
</div>
<div class="lt-toolbar">
<div class="lt-toolbar-left">
<div class="lt-search">
<input type="search" id="ex-search" class="lt-input lt-search-input"
placeholder="Search by command, execution ID, or workflow name…"
autocomplete="off" aria-label="Search executions"
data-input-action="ex:search">
</div>
<select id="ex-status" class="lt-select" aria-label="Status filter" data-change-action="ex:filter-status">
<option value="">All Statuses</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="waiting">Waiting</option>
</select>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-action="ex:clear-filters">Clear Filters</button>
</div>
<div class="lt-toolbar-right">
<span id="ex-filter-stats" class="pulse-meta"></span>
</div>
</div>
<div class="lt-tab-panels">
<div class="lt-tab-panel active" id="ex-tab-manual" role="tabpanel" aria-labelledby="ex-tabbtn-manual" tabindex="-1">
<div id="ex-list-manual" class="ex-list">
<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>
</div>
</div>
<div class="lt-tab-panel" id="ex-tab-automated" role="tabpanel" aria-labelledby="ex-tabbtn-automated" tabindex="-1">
<div id="ex-list-automated" class="ex-list">
<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>
</div>
</div>
</div>
</div>
<!-- ===================== EXECUTION DETAIL MODAL ===================== -->
<div class="lt-modal-overlay" id="ex-detail-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ex-detail-title">
<div class="lt-modal lt-modal-lg">
<div class="lt-modal-header">
<span class="lt-modal-title" id="ex-detail-title">Execution Details</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<div class="lt-modal-body" id="ex-detail-body"></div>
<div class="lt-modal-footer" id="ex-detail-footer"></div>
</div>
</div>
<!-- ===================== EXECUTION COMPARE MODAL ==================== -->
<div class="lt-modal-overlay" id="ex-compare-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ex-compare-title">
<div class="lt-modal lt-modal-lg">
<div class="lt-modal-header">
<span class="lt-modal-title" id="ex-compare-title">&#x2696; Execution Comparison</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<div class="lt-modal-body" id="ex-compare-body"></div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-modal-close>Close</button>
</div>
</div>
</div>
+89
View File
@@ -0,0 +1,89 @@
<%#
Quick Command page (WP-F). Ad-hoc command execution against one or more
workers. All data loaded client-side by /assets/pages/quick.js.
%>
<%- include('../partials/page-header', {
title: 'Quick Command',
subtitle: 'Execute a command on selected workers instantly',
actions: '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="qc:templates-open">&#x1f4cb; Templates</button>' +
'<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="qc:history-open">&#x1f550; History</button>'
}) %>
<div class="lt-frame">
<form id="qc-form" data-submit-action="qc:execute">
<div class="lt-form-group">
<label class="lt-label">Execution Mode</label>
<div class="qc-mode-row">
<label class="qc-radio-label">
<input type="radio" class="lt-radio" name="qc-exec-mode" id="qc-mode-single" value="single" checked data-change-action="qc:mode">
<span>Single Worker</span>
</label>
<label class="qc-radio-label">
<input type="radio" class="lt-radio" name="qc-exec-mode" id="qc-mode-multi" value="multi" data-change-action="qc:mode">
<span>Multiple Workers</span>
</label>
</div>
</div>
<div class="lt-form-group" id="qc-single-mode">
<label class="lt-label" for="qc-worker">Select Worker</label>
<select class="lt-select" id="qc-worker">
<option value="">Loading workers&hellip;</option>
</select>
</div>
<div class="lt-form-group" id="qc-multi-mode" hidden>
<label class="lt-label">Select Workers</label>
<div id="qc-worker-list" class="qc-worker-list">
<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>
</div>
<div class="lt-btn-group qc-worker-actions">
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="qc:select-all">Select All</button>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="qc:select-online">Online Only</button>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="qc:clear-all">Clear All</button>
</div>
</div>
<div class="lt-form-group">
<label class="lt-label" for="qc-command">Command</label>
<textarea class="lt-textarea" id="qc-command" rows="4" placeholder="Enter command to execute (e.g. 'uptime' or 'df -h')"></textarea>
<div class="lt-form-hint">Ctrl+Enter to execute</div>
</div>
<button type="submit" class="lt-btn lt-btn-primary" id="qc-execute-btn">&#x25b6; Execute Command</button>
</form>
<div id="qc-result" class="qc-result"></div>
</div>
<!-- Command Templates Modal -->
<div class="lt-modal-overlay" id="qc-templates-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="qc-templates-title">
<div class="lt-modal lt-modal-lg">
<div class="lt-modal-header">
<span class="lt-modal-title" id="qc-templates-title">Command Templates</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<div class="lt-modal-body">
<div id="qc-templates-list" class="qc-scroll-list"></div>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Close</button>
</div>
</div>
</div>
<!-- Command History Modal -->
<div class="lt-modal-overlay" id="qc-history-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="qc-history-title">
<div class="lt-modal lt-modal-lg">
<div class="lt-modal-header">
<span class="lt-modal-title" id="qc-history-title">Command History</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<div class="lt-modal-body">
<div id="qc-history-list" class="qc-scroll-list"></div>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Close</button>
</div>
</div>
</div>
+62
View File
@@ -0,0 +1,62 @@
<%#
Scheduler page (WP-F). List of scheduled commands + admin create/toggle/delete.
All data loaded client-side by /assets/pages/scheduler.js.
%>
<%- include('../partials/page-header', {
title: 'Scheduler',
subtitle: 'Automate command execution with flexible scheduling',
actions: '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="app:refresh">&#x21bb; Refresh</button>' +
(pageConfig && pageConfig.isAdmin ? '<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" data-action="sc:create-open">&#x2795; Create Schedule</button>' : '')
}) %>
<div class="lt-frame">
<div id="sc-wrap">
<div class="lt-empty-state" id="sc-loading">
<div class="lt-empty-state-title">Loading&hellip;</div>
</div>
</div>
</div>
<!-- Create Schedule Modal -->
<div class="lt-modal-overlay" id="sc-create-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="sc-create-title">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="sc-create-title">Create Scheduled Command</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="sc-create-form" data-submit-action="sc:create-submit">
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="sc-name">Schedule Name</label>
<input type="text" id="sc-name" class="lt-input" placeholder="e.g. Daily System Check">
</div>
<div class="lt-form-group">
<label class="lt-label" for="sc-command">Command</label>
<textarea id="sc-command" class="lt-textarea" rows="3" placeholder="Enter command to execute"></textarea>
</div>
<div class="lt-form-group">
<label class="lt-label">Target Workers</label>
<div id="sc-worker-list" class="qc-worker-list">
<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>
</div>
</div>
<div class="lt-form-group">
<label class="lt-label" for="sc-type">Schedule Type</label>
<select id="sc-type" class="lt-select" data-change-action="sc:type">
<option value="interval">Every X Minutes</option>
<option value="hourly">Every X Hours</option>
<option value="daily">Daily at Time</option>
</select>
</div>
<div class="lt-form-group" id="sc-value-container">
<label class="lt-label" for="sc-value">Interval (minutes)</label>
<input type="number" id="sc-value" class="lt-input" placeholder="e.g. 30" min="1">
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">Create Schedule</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
</div>
</form>
</div>
</div>
+14
View File
@@ -0,0 +1,14 @@
<%#
Workers page (WP-C). Grid of worker cards, patched in place on worker_update.
%>
<%- include('../partials/page-header', {
title: 'Workers',
subtitle: 'Connected worker agents',
actions: '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="app:refresh">&#x21bb; Refresh</button>'
}) %>
<div id="wk-grid" class="wk-grid">
<div class="lt-empty-state" id="wk-loading">
<div class="lt-empty-state-title">Loading&hellip;</div>
</div>
</div>
+125
View File
@@ -0,0 +1,125 @@
<%#
Workflows page (WP-D).
Locals: user, nonce, pageTitle, activeNav, assetVersion, pageConfig.
All data comes from /api/workflows*; pageConfig only carries the example
definition JSON used to pre-fill the Create modal.
%>
<%- include('../partials/page-header', {
title: 'Workflows',
subtitle: 'Reusable multi-step jobs run against one or more workers.',
actions:
'<button type="button" class="lt-btn lt-btn-secondary" data-action="app:refresh">Refresh</button>' +
'<button type="button" class="lt-btn lt-btn-primary" data-action="wf:create-open">+ Create Workflow</button>'
}) %>
<div class="lt-frame">
<div class="lt-section-header">Workflows</div>
<div class="lt-section-body" id="wf-list">
<div class="lt-empty-state">
<div class="lt-empty-state-icon">&#x2699;</div>
<div class="lt-empty-state-title">Loading workflows&hellip;</div>
</div>
</div>
</div>
<!-- Create Workflow Modal -->
<div class="lt-modal-overlay" id="wf-create-modal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="wf-create-modal-title">
<div class="lt-modal lt-modal-lg">
<div class="lt-modal-header">
<span class="lt-modal-title" id="wf-create-modal-title">Create Workflow</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="wf-create-form" data-submit-action="wf:create-submit">
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="wf-create-name">Name</label>
<input type="text" id="wf-create-name" name="name" class="lt-input" required placeholder="Workflow Name" autocomplete="off">
</div>
<div class="lt-form-group">
<label class="lt-label" for="wf-create-description">Description</label>
<textarea id="wf-create-description" name="description" class="lt-textarea" placeholder="Description"></textarea>
</div>
<div class="lt-form-group">
<label class="lt-label" for="wf-create-definition">Definition (JSON)</label>
<textarea id="wf-create-definition" name="definition" class="lt-textarea wf-json-textarea" required></textarea>
<span class="lt-form-hint">Steps run in order against the selected targets.</span>
</div>
<div class="lt-form-group">
<label class="lt-label" for="wf-create-webhook">Webhook URL (optional)</label>
<input type="url" id="wf-create-webhook" name="webhook_url" class="lt-input" placeholder="https://example.com/webhook">
</div>
<div class="lt-field-error" id="wf-create-error" hidden></div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">Create</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
</div>
</form>
</div>
</div>
<!-- Edit Workflow Modal (admin) -->
<div class="lt-modal-overlay" id="wf-edit-modal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="wf-edit-modal-title">
<div class="lt-modal lt-modal-lg">
<div class="lt-modal-header">
<span class="lt-modal-title" id="wf-edit-modal-title">Edit Workflow</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="wf-edit-form" data-submit-action="wf:edit-submit">
<input type="hidden" id="wf-edit-id" name="id">
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="wf-edit-name">Name</label>
<input type="text" id="wf-edit-name" name="name" class="lt-input" required placeholder="Workflow Name" autocomplete="off">
</div>
<div class="lt-form-group">
<label class="lt-label" for="wf-edit-description">Description</label>
<textarea id="wf-edit-description" name="description" class="lt-textarea" placeholder="Description"></textarea>
</div>
<div class="lt-form-group">
<label class="lt-label" for="wf-edit-definition">Definition (JSON)</label>
<textarea id="wf-edit-definition" name="definition" class="lt-textarea wf-json-textarea wf-json-textarea-lg" required></textarea>
</div>
<div class="lt-form-group">
<label class="lt-label" for="wf-edit-webhook">Webhook URL (optional)</label>
<input type="url" id="wf-edit-webhook" name="webhook_url" class="lt-input" placeholder="https://example.com/webhook">
</div>
<div class="lt-field-error" id="wf-edit-error" hidden></div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">Save</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
</div>
</form>
</div>
</div>
<!-- Run Workflow Modal (execute, with optional params + dry run) -->
<div class="lt-modal-overlay" id="wf-run-modal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="wf-run-modal-title">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title" id="wf-run-modal-title">Run Workflow</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="wf-run-form" data-submit-action="wf:param-submit">
<input type="hidden" id="wf-run-workflow-id" name="workflow_id">
<div class="lt-modal-body">
<p class="lt-text-sm" id="wf-run-name"></p>
<div id="wf-run-params"></div>
<div class="lt-form-group">
<label class="wf-checkbox-label">
<input type="checkbox" id="wf-run-dryrun" class="lt-checkbox">
Dry Run (simulate, no commands executed)
</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">&#x25B6; Run</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
</div>
</form>
</div>
</div>
+19
View File
@@ -0,0 +1,19 @@
<!-- COMMAND PALETTE -->
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
<div class="lt-cmd-palette" id="lt-cmd-palette">
<div class="lt-cmd-input-wrap">
<span class="lt-cmd-prompt">&gt;</span>
<input id="lt-cmd-input" class="lt-cmd-input" type="text"
placeholder="Search commands&hellip;" autocomplete="off"
spellcheck="false" aria-label="Search commands">
</div>
<div class="lt-cmd-results" id="lt-cmd-results">
<div class="lt-cmd-empty">Start typing to search&hellip;</div>
</div>
<div class="lt-cmd-footer">
<span><kbd>&#x2191;</kbd><kbd>&#x2193;</kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span>
<span><kbd>Esc</kbd> Close</span>
</div>
</div>
</div>
+23
View File
@@ -0,0 +1,23 @@
<!-- KEYBOARD SHORTCUTS MODAL -->
<div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true">
<div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="lt-keys-help-title">
<div class="lt-modal-header">
<span class="lt-modal-title" id="lt-keys-help-title">Keyboard Shortcuts</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<div class="lt-modal-body">
<table class="lt-table">
<thead><tr><th>Shortcut</th><th>Action</th></tr></thead>
<tbody>
<tr><td>Ctrl / &#x2318; + K</td><td>Command palette</td></tr>
<tr><td>R</td><td>Refresh data</td></tr>
<tr><td>?</td><td>Show this help</td></tr>
<tr><td>ESC</td><td>Close modal / drawer / palette</td></tr>
</tbody>
</table>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn" data-modal-close>Close</button>
</div>
</div>
</div>
+16
View File
@@ -0,0 +1,16 @@
<%#
Page header partial.
Locals: title (string), subtitle (optional string), actions (optional raw HTML).
Usage from a page view: include('../partials/page-header', { title, subtitle, actions })
%>
<div class="lt-page-header">
<div>
<h1 class="lt-page-title"><%= title %></h1>
<% if (typeof subtitle !== 'undefined' && subtitle) { %>
<p class="lt-page-subtitle"><%= subtitle %></p>
<% } %>
</div>
<% if (typeof actions !== 'undefined' && actions) { %>
<div class="lt-page-actions"><%- actions %></div>
<% } %>
</div>