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
This commit was merged in pull request #35.
This commit is contained in:
2026-09-08 22:23:24 -04:00
41 changed files with 13966 additions and 3421 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
public/web_template/
+72 -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,39 @@ 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`
## 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 +357,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
+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 };
+353 -219
View File
@@ -11,8 +11,10 @@
"dependencies": {
"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"
},
@@ -22,12 +24,13 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -36,29 +39,31 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -75,13 +80,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
"integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.8",
"@babel/types": "^7.29.8",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -91,13 +97,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -106,46 +113,40 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -164,52 +165,57 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -441,31 +447,33 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
"integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.8",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.8",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.8",
"debug": "^4.3.1"
},
"engines": {
@@ -473,13 +481,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -624,10 +633,11 @@
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"version": "3.15.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
@@ -1153,7 +1163,6 @@
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"dependencies": {
"undici-types": "~7.19.0"
}
@@ -1305,6 +1314,12 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -1427,14 +1442,14 @@
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz",
"integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==",
"version": "2.11.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
"integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
@@ -1443,20 +1458,20 @@
}
},
"node_modules/body-parser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -1466,11 +1481,25 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -1489,9 +1518,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"version": "4.28.9",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
"integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
"dev": true,
"funding": [
{
@@ -1507,12 +1536,13 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
"baseline-browser-mapping": "^2.11.20",
"caniuse-lite": "^1.0.30001810",
"electron-to-chromium": "^1.5.420",
"node-releases": "^2.0.54",
"update-browserslist-db": "^1.3.2"
},
"bin": {
"browserslist": "cli.js"
@@ -1593,9 +1623,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001788",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
"integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true,
"funding": [
{
@@ -1610,7 +1640,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "4.1.2",
@@ -1850,15 +1881,6 @@
"node": ">=0.10.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1930,11 +1952,27 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.336",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz",
"integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==",
"dev": true
"version": "1.5.425",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.425.tgz",
"integrity": "sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g==",
"dev": true,
"license": "ISC"
},
"node_modules/emittery": {
"version": "0.13.1",
@@ -1991,9 +2029,9 @@
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -2285,11 +2323,13 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
"debug": "^4.4.3",
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
@@ -2349,6 +2389,36 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2651,6 +2721,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -2687,9 +2766,9 @@
}
},
"node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -2773,9 +2852,10 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
@@ -2975,6 +3055,23 @@
"node": ">=8"
}
},
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
@@ -3549,10 +3646,21 @@
"dev": true
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
@@ -3682,18 +3790,19 @@
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/lru.min": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz",
"integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
@@ -3851,35 +3960,36 @@
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",
"aws-ssl-profiles": "^1.1.2",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.0",
"long": "^5.2.1",
"lru.min": "^1.0.0",
"named-placeholders": "^1.1.3",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
"iconv-lite": "^0.7.3",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.5.1"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
"integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru-cache": "^7.14.1"
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=12.0.0"
"node": ">=8.0.0"
}
},
"node_modules/natural-compare": {
@@ -3904,10 +4014,14 @@
"dev": true
},
"node_modules/node-releases": {
"version": "2.0.37",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
"integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
"dev": true
"version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
@@ -4118,8 +4232,7 @@
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "2.3.2",
@@ -4293,11 +4406,13 @@
]
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -4527,11 +4642,6 @@
"node": ">= 18"
}
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
@@ -4575,14 +4685,14 @@
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -4594,13 +4704,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -4692,13 +4802,19 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"node_modules/sql-escaper": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
"integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/stack-utils": {
@@ -4905,24 +5021,40 @@
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="
},
"node_modules/unpipe": {
"version": "1.0.0",
@@ -4934,9 +5066,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"dev": true,
"funding": [
{
@@ -4952,6 +5084,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
@@ -5065,9 +5198,9 @@
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -5098,7 +5231,8 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "17.7.2",
+4 -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": [],
@@ -12,8 +13,10 @@
"dependencies": {
"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
+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")"
+149 -34
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);
@@ -1717,6 +1808,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 +1836,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>