feat(ui): TDS migration stage 1 — EJS layout, CSP, vendored design system, shared runtime

- Vendor web_template base.css/base.js (v1.2 bbec859) under public/web_template with sync script
- Add views/layout.ejs (fixed upstream EJS comment delimiters, mobile drawer, cmd palette,
  keys help, WS status, theme toggle) and lib/render.js two-step renderer
- Add page routes for /, /workers, /workflows, /executions, /quick, /scheduler (stub views)
- helmet with strict nonce CSP (script-src-attr 'none'), /csp-report, report-only toggle
- lib/pageauth.js: shared users upsert + HTML 401/403 for page routes
- PULSE_DEV_READONLY guard disabling background writers for local testing
- public/assets/app.js shared runtime (Pulse namespace, delegated actions, WS singleton,
  themed confirm modal) and app.css
- eslint globals for browser files; ignore vendored assets

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
This commit is contained in:
2026-09-08 21:46:24 -04:00
co-authored by Claude Fable 5.1
parent 53b61249b0
commit cbb91a306d
24 changed files with 10750 additions and 38 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
public/web_template/
+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-danger">
<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 };
+81 -4
View File
@@ -11,8 +11,10 @@
"dependencies": { "dependencies": {
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"ejs": "3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^8.3.1", "express-rate-limit": "^8.3.1",
"helmet": "8.1.0",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
"ws": "^8.18.3" "ws": "^8.18.3"
}, },
@@ -1305,6 +1307,12 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true "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": { "node_modules/aws-ssl-profiles": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -1427,8 +1435,7 @@
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
"dev": true
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.19", "version": "2.10.19",
@@ -1930,6 +1937,21 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.336", "version": "1.5.336",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz",
@@ -2349,6 +2371,36 @@
"node": "^10.12.0 || >=12.0.0" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2651,6 +2703,15 @@
"node": ">= 0.4" "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": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -2975,6 +3036,23 @@
"node": ">=8" "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": { "node_modules/jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
@@ -4118,8 +4196,7 @@
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
"dev": true
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.2",
+2
View File
@@ -12,8 +12,10 @@
"dependencies": { "dependencies": {
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"ejs": "3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^8.3.1", "express-rate-limit": "^8.3.1",
"helmet": "8.1.0",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
"ws": "^8.18.3" "ws": "^8.18.3"
}, },
+7
View File
@@ -1,6 +1,13 @@
{ {
"env": { "browser": true, "es2021": true }, "env": { "browser": true, "es2021": true },
"parserOptions": { "ecmaVersion": 2021, "sourceType": "script" }, "parserOptions": { "ecmaVersion": 2021, "sourceType": "script" },
"globals": {
"lt": "readonly",
"Pulse": "writable",
"CSRF_TOKEN": "readonly",
"CURRENT_USER": "readonly",
"PULSE_CONFIG": "readonly"
},
"rules": { "rules": {
"no-unused-vars": "warn", "no-unused-vars": "warn",
"no-empty": "warn", "no-empty": "warn",
+151
View File
@@ -0,0 +1,151 @@
/* =====================================================================
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. 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

+212
View File
@@ -0,0 +1,212 @@
/* =====================================================================
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
------------------------------------------------------------------- */
async function loadWorkers() {
try {
_workers = await Pulse.api.get('/api/workers') || [];
} catch (e) {
_workers = [];
console.error('[Pulse:dashboard] failed to load 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 = [];
console.error('[Pulse:dashboard] failed to load 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 });
})();
+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 vm = require('vm');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const cronParser = require('cron-parser'); const cronParser = require('cron-parser');
const path = require('path');
const fs = require('fs');
const helmet = require('helmet');
require('dotenv').config(); require('dotenv').config();
const { validateWebhookUrl, applyParams, evalCondition, calculateNextRun } = require('./lib/utils'); 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 app = express();
const server = http.createServer(app); const server = http.createServer(app);
const wss = new WebSocket.Server({ server }); 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 // 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.json());
app.use(express.static('public'));
// Rate limiting // Rate limiting
const apiLimiter = rateLimit({ const apiLimiter = rateLimit({
@@ -32,7 +78,6 @@ const executionLimiter = rateLimit({
legacyHeaders: false, legacyHeaders: false,
message: { error: 'Too many execution requests, please slow down' } message: { error: 'Too many execution requests, please slow down' }
}); });
app.use('/api/', apiLimiter);
// Named constants for timeouts and limits // Named constants for timeouts and limits
const PROMPT_TIMEOUT_MS = 60 * 60 * 1000; // 60 min — how long a prompt waits for user input 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(); 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 // Content-Type guard for JSON endpoints
function requireJSON(req, res, next) { function requireJSON(req, res, next) {
const ct = req.headers['content-type'] || ''; const ct = req.headers['content-type'] || '';
@@ -160,19 +223,23 @@ async function initDatabase() {
`); `);
// Recover stale executions from a previous server crash // Recover stale executions from a previous server crash
const [staleExecs] = await connection.query("SELECT id FROM executions WHERE status = 'running'"); if (!DEV_READONLY) {
if (staleExecs.length > 0) { const [staleExecs] = await connection.query("SELECT id FROM executions WHERE status = 'running'");
for (const exec of staleExecs) { if (staleExecs.length > 0) {
await connection.query( for (const exec of staleExecs) {
"UPDATE executions SET status = 'failed', completed_at = NOW() WHERE id = ?", await connection.query(
[exec.id] "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 = ?", await connection.query(
[JSON.stringify({ action: 'server_restart_recovery', message: 'Execution marked failed due to server restart', timestamp: new Date().toISOString() }), exec.id] "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'); console.log('Database tables initialized successfully');
@@ -202,10 +269,14 @@ async function cleanupOldExecutions() {
} }
} }
// Run cleanup hourly if (!DEV_READONLY) {
setInterval(cleanupOldExecutions, 60 * 60 * 1000); // Run cleanup hourly
// Run cleanup on startup setInterval(cleanupOldExecutions, 60 * 60 * 1000);
cleanupOldExecutions(); // Run cleanup on startup
cleanupOldExecutions();
} else {
console.log('[DEV] skipped: cleanupOldExecutions interval + startup run');
}
// Scheduled Commands Processor // Scheduled Commands Processor
async function processScheduledCommands() { async function processScheduledCommands() {
@@ -292,10 +363,14 @@ async function processScheduledCommands() {
} }
// Run scheduler every minute if (!DEV_READONLY) {
setInterval(processScheduledCommands, 60 * 1000); // Run scheduler every minute
// Initial run on startup setInterval(processScheduledCommands, 60 * 1000);
setTimeout(processScheduledCommands, 5000); // Initial run on startup
setTimeout(processScheduledCommands, 5000);
} else {
console.log('[DEV] skipped: processScheduledCommands interval + startup run');
}
// Mark workers offline when their heartbeat goes stale // Mark workers offline when their heartbeat goes stale
async function markStaleWorkersOffline() { async function markStaleWorkersOffline() {
@@ -314,7 +389,11 @@ async function markStaleWorkersOffline() {
console.error('[Worker] Stale check error:', error); 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 // WebSocket connections
const browserClients = new Set(); // Browser UI connections const browserClients = new Set(); // Browser UI connections
@@ -624,17 +703,7 @@ async function authenticateSSO(req, res, next) {
// Store/update user in database // Store/update user in database
try { try {
const userId = crypto.randomUUID(); await upsertUser(pool, req.headers);
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]
);
} catch (error) { } catch (error) {
console.error('Error updating user:', 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 // Routes - All protected by SSO
app.get('/api/user', authenticateSSO, (req, res) => { app.get('/api/user', authenticateSSO, (req, res) => {
res.json(req.user); 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 // Start server
const PORT = process.env.PORT || 8080; const PORT = process.env.PORT || 8080;
const HOST = process.env.HOST || '0.0.0.0'; 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(`Connected to MariaDB at ${process.env.DB_HOST}`);
console.log(`Authentication: Authelia SSO`); console.log(`Authentication: Authelia SSO`);
console.log(`Worker API Key configured: ${process.env.WORKER_API_KEY ? 'Yes' : 'No'}`); 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 => { }).catch(err => {
console.error('Failed to start server:', 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>
+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-field-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>
+17
View File
@@ -0,0 +1,17 @@
<%#
Page header partial.
Locals: title (string), subtitle (optional string), actions (optional raw HTML).
Usage from a page view:
<%- include('../partials/page-header', { title: 'Workers', 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>