jsgui3-server 0.0.149 → 0.0.150
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/Mobile Developer.agent.md +89 -0
- package/AGENTS.md +4 -0
- package/README.md +130 -0
- package/admin-ui/client.js +73 -43
- package/admin-ui/v1/admin_auth_service.js +197 -0
- package/admin-ui/v1/admin_user_store.js +71 -0
- package/admin-ui/v1/client.js +17 -0
- package/admin-ui/v1/controls/admin_shell.js +1399 -0
- package/admin-ui/v1/controls/group_box.js +84 -0
- package/admin-ui/v1/controls/stat_card.js +125 -0
- package/admin-ui/v1/server.js +658 -0
- package/admin-ui/v1/utils/formatters.js +68 -0
- package/docs/admin-extension-guide.md +345 -0
- package/docs/books/adaptive-control-improvements/01-control-candidate-matrix.md +122 -0
- package/docs/books/adaptive-control-improvements/02-tier-1-layout-playbooks.md +207 -0
- package/docs/books/adaptive-control-improvements/03-tier-2-navigation-form-overlay.md +140 -0
- package/docs/books/adaptive-control-improvements/04-cross-cutting-platform-functionality.md +141 -0
- package/docs/books/adaptive-control-improvements/05-styling-theming-density-upgrades.md +114 -0
- package/docs/books/adaptive-control-improvements/06-testing-quality-gates.md +97 -0
- package/docs/books/adaptive-control-improvements/07-delivery-roadmap-and-ownership.md +137 -0
- package/docs/books/adaptive-control-improvements/08-appendix-tier1-acceptance-and-pr-templates.md +261 -0
- package/docs/books/adaptive-control-improvements/README.md +66 -0
- package/docs/books/admin-ui-authentication/01-threat-model-and-goals.md +124 -0
- package/docs/books/admin-ui-authentication/02-session-model-and-token-model.md +75 -0
- package/docs/books/admin-ui-authentication/03-auth-middleware-patterns.md +77 -0
- package/docs/books/admin-ui-authentication/README.md +25 -0
- package/docs/books/creating-a-new-admin-ui/01-introduction-and-vision.md +130 -0
- package/docs/books/creating-a-new-admin-ui/02-architecture-and-data-flow.md +298 -0
- package/docs/books/creating-a-new-admin-ui/03-server-introspection.md +381 -0
- package/docs/books/creating-a-new-admin-ui/04-admin-module-adapter-layer.md +592 -0
- package/docs/books/creating-a-new-admin-ui/05-domain-controls-stat-cards-and-gauges.md +513 -0
- package/docs/books/creating-a-new-admin-ui/06-domain-controls-process-manager.md +544 -0
- package/docs/books/creating-a-new-admin-ui/07-domain-controls-resource-pool-inspector.md +493 -0
- package/docs/books/creating-a-new-admin-ui/08-domain-controls-route-table-and-api-explorer.md +586 -0
- package/docs/books/creating-a-new-admin-ui/09-domain-controls-log-viewer-and-activity-feed.md +490 -0
- package/docs/books/creating-a-new-admin-ui/10-domain-controls-build-status-and-bundle-inspector.md +526 -0
- package/docs/books/creating-a-new-admin-ui/11-domain-controls-configuration-panel.md +808 -0
- package/docs/books/creating-a-new-admin-ui/12-admin-shell-layout-sidebar-navigation.md +210 -0
- package/docs/books/creating-a-new-admin-ui/13-telemetry-integration.md +556 -0
- package/docs/books/creating-a-new-admin-ui/14-realtime-sse-observable-integration.md +485 -0
- package/docs/books/creating-a-new-admin-ui/15-styling-theming-aero-design-system.md +521 -0
- package/docs/books/creating-a-new-admin-ui/16-testing-and-quality-assurance.md +147 -0
- package/docs/books/creating-a-new-admin-ui/17-next-steps-process-resource-roadmap.md +356 -0
- package/docs/books/creating-a-new-admin-ui/README.md +68 -0
- package/docs/books/device-adaptive-composition/01-platform-feature-audit.md +177 -0
- package/docs/books/device-adaptive-composition/02-responsive-composition-model.md +187 -0
- package/docs/books/device-adaptive-composition/03-data-model-vs-view-model.md +231 -0
- package/docs/books/device-adaptive-composition/04-styling-theme-breakpoints.md +234 -0
- package/docs/books/device-adaptive-composition/05-showcase-app-multi-device-assessment.md +193 -0
- package/docs/books/device-adaptive-composition/06-implementation-patterns-and-apis.md +346 -0
- package/docs/books/device-adaptive-composition/07-testing-harness-and-quality-gates.md +265 -0
- package/docs/books/device-adaptive-composition/08-roadmap-and-adoption-plan.md +250 -0
- package/docs/books/device-adaptive-composition/README.md +47 -0
- package/docs/comparison-report-express-plex-cpanel.md +549 -0
- package/docs/designs/server-admin-interface-aero.svg +611 -0
- package/docs/troubleshooting.md +84 -53
- package/module.js +16 -11
- package/package.json +1 -1
- package/serve-factory.js +1 -0
- package/server.js +199 -0
- package/tests/README.md +5 -0
- package/tests/admin-ui-jsgui-controls.test.js +581 -0
- package/tests/test-runner.js +1 -0
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-071799b982906680f5fd699d.js +0 -40
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-07352945ad5c92654fcb8b65.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-138a601fadb6191ea314c6fd.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-171f6c381c2cadf2e9fa7087.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-1d973388156b84a04373fac9.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-20e117bc8a10d2cd16234bbe.js +0 -40
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-2b028a82b0e5efddba42425f.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-4518556cd5c7e059e82b22b8.js +0 -40
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-5bac1aa0f213902f718ed74f.js +0 -40
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-5f9996ac7822caf777d92f56.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-60a92c702e65fd9cf748e3ec.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-6164c1f8f738995c541895d2.js +0 -44
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-6718a85eb9e5aa782dd47a05.js +0 -45
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-69e280f14e37aee76a1d4675.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-7570d1b030d44b111ed59c4c.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-7798c9bbd55e510d5039f936.js +0 -42
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-78cd511ea1ef18ecb03d1be5.js +0 -40
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-7d482e0b95bcb5e3c543118b.js +0 -43
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-80e9476d1127c55b40fdb36f.js +0 -40
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-810ced55d5320a3088a05b13.js +0 -40
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-8423565f1a40e329afc8c6cf.js +0 -40
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-900bef783b8cee36506ec282.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-a1a37aff6416fdad74040ddf.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-ad48d5e8eda40f175b4df090.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-aec5a2d963015528c9099462.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-af9d34e0f1722fab9e28c269.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-b818e4015e2f1fe86280b5ab.js +0 -41
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-bcb2541adc70b7aba61768c5.js +0 -44
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-bfe89d2c78ed44f95ed7dd73.js +0 -40
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-c06f04806a1e688e1187110c.js +0 -40
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-c3f3adf904f585afc544b96a.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-d45acb873e1d8e32d5e60f2e.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-db06f132533706f4a0163b8c.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-f660f40d78b135fc8560a862.js +0 -39
- package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-f9dee4ec18a96e09bee06bae.js +0 -39
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const HTTP_SSE_Publisher = require('../../publishers/http-sse-publisher');
|
|
4
|
+
const Admin_User_Store = require('./admin_user_store');
|
|
5
|
+
const Admin_Auth_Service = require('./admin_auth_service');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Admin Module V1 — adapter layer that instruments the server
|
|
9
|
+
* and exposes telemetry data via JSON endpoints and SSE.
|
|
10
|
+
*/
|
|
11
|
+
class Admin_Module_V1 {
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} [config] - Optional configuration
|
|
14
|
+
* @param {boolean} [config.enabled] - Whether admin UI is active (default: true)
|
|
15
|
+
* @param {Array} [config.sections] - Custom sidebar sections to register
|
|
16
|
+
* @param {Array} [config.endpoints] - Custom protected API endpoints
|
|
17
|
+
*/
|
|
18
|
+
constructor(config = {}) {
|
|
19
|
+
this._config = config;
|
|
20
|
+
this._request_count = 0;
|
|
21
|
+
this._request_window = [];
|
|
22
|
+
this._status_counts = {};
|
|
23
|
+
this._routes = [];
|
|
24
|
+
this._build_info = null;
|
|
25
|
+
this._sse_publisher = null;
|
|
26
|
+
this._heartbeat_interval = null;
|
|
27
|
+
this._routes_instrumented = false;
|
|
28
|
+
this._process_instrumented = false;
|
|
29
|
+
|
|
30
|
+
// Extensibility registries
|
|
31
|
+
this._custom_sections = [];
|
|
32
|
+
this._custom_endpoints = [];
|
|
33
|
+
|
|
34
|
+
this.user_store = this._create_user_store();
|
|
35
|
+
this.auth = new Admin_Auth_Service({
|
|
36
|
+
user_store: this.user_store,
|
|
37
|
+
cookie_name: 'jsgui_admin_v1_sid'
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_create_user_store() {
|
|
42
|
+
const store = new Admin_User_Store();
|
|
43
|
+
const env_user = process.env.ADMIN_V1_USER || 'admin';
|
|
44
|
+
const env_password = process.env.ADMIN_V1_PASSWORD || null;
|
|
45
|
+
|
|
46
|
+
if (env_password) {
|
|
47
|
+
store.add_user({
|
|
48
|
+
username: env_user,
|
|
49
|
+
password: env_password,
|
|
50
|
+
roles: ['admin_read', 'admin_write']
|
|
51
|
+
});
|
|
52
|
+
return store;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (process.env.NODE_ENV === 'production') {
|
|
56
|
+
console.warn('[Admin_Module_V1] No ADMIN_V1_PASSWORD set; login is disabled in production.');
|
|
57
|
+
return store;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
store.add_user({
|
|
61
|
+
username: 'admin',
|
|
62
|
+
password: 'admin',
|
|
63
|
+
roles: ['admin_read', 'admin_write']
|
|
64
|
+
});
|
|
65
|
+
console.warn('[Admin_Module_V1] Development default credentials active: admin/admin');
|
|
66
|
+
return store;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Initialise the adapter and attach to the server.
|
|
71
|
+
* Must be called after the server's core systems are up
|
|
72
|
+
* but before it starts accepting requests.
|
|
73
|
+
* @param {object} server - JSGUI_Single_Process_Server instance
|
|
74
|
+
*/
|
|
75
|
+
init(server) {
|
|
76
|
+
this._server = server;
|
|
77
|
+
const router = server.server_router || server.router;
|
|
78
|
+
if (!router) {
|
|
79
|
+
console.warn('[Admin_Module_V1] No router found; skipping.');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this._router = router;
|
|
83
|
+
|
|
84
|
+
// 1. Track route registrations (must be first)
|
|
85
|
+
this._track_route_registration(router);
|
|
86
|
+
|
|
87
|
+
// 2. Set up SSE channel
|
|
88
|
+
this._init_sse_channel(router);
|
|
89
|
+
|
|
90
|
+
// 3. Register API endpoints
|
|
91
|
+
this._register_endpoints(router);
|
|
92
|
+
|
|
93
|
+
// 4. Instrument request handler
|
|
94
|
+
this._instrument_request_handler(router);
|
|
95
|
+
|
|
96
|
+
// 5. Subscribe to resource pool events
|
|
97
|
+
this._subscribe_resource_events(server);
|
|
98
|
+
|
|
99
|
+
// 6. Start heartbeat
|
|
100
|
+
this._start_heartbeat(server);
|
|
101
|
+
|
|
102
|
+
// 7. Apply config-driven sections and endpoints
|
|
103
|
+
if (this._config.sections && Array.isArray(this._config.sections)) {
|
|
104
|
+
this._config.sections.forEach(s => this.add_section(s));
|
|
105
|
+
}
|
|
106
|
+
if (this._config.endpoints && Array.isArray(this._config.endpoints)) {
|
|
107
|
+
this._config.endpoints.forEach(e => this.add_endpoint(e));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── API Endpoints ───────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
_write_unauthorized_json(res) {
|
|
114
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
115
|
+
res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_write_forbidden_json(res) {
|
|
119
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
120
|
+
res.end(JSON.stringify({ ok: false, error: 'Forbidden' }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_require_auth(req, res, handler) {
|
|
124
|
+
if (!this.auth.is_authenticated(req)) {
|
|
125
|
+
this._write_unauthorized_json(res);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
handler();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_require_role(req, res, role_name, handler) {
|
|
132
|
+
if (!this.auth.is_authenticated(req)) {
|
|
133
|
+
this._write_unauthorized_json(res);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!this.auth.has_role(req, role_name)) {
|
|
137
|
+
this._write_forbidden_json(res);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
handler();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_require_admin_read(req, res, handler) {
|
|
144
|
+
this._require_role(req, res, 'admin_read', handler);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_require_admin_write(req, res, handler) {
|
|
148
|
+
this._require_role(req, res, 'admin_write', handler);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_register_endpoints(router) {
|
|
152
|
+
// Auth endpoints (public)
|
|
153
|
+
router.set_route('/api/admin/v1/auth/login', (req, res) => {
|
|
154
|
+
this.auth.handle_login(req, res);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
router.set_route('/api/admin/v1/auth/logout', (req, res) => {
|
|
158
|
+
this.auth.handle_logout(req, res);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
router.set_route('/api/admin/v1/auth/session', (req, res) => {
|
|
162
|
+
this.auth.handle_session(req, res);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// GET /api/admin/v1/status
|
|
166
|
+
router.set_route('/api/admin/v1/status', (req, res) => {
|
|
167
|
+
this._require_admin_read(req, res, () => {
|
|
168
|
+
const data = this.get_status();
|
|
169
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
170
|
+
res.end(JSON.stringify(data));
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// GET /api/admin/v1/resources
|
|
175
|
+
router.set_route('/api/admin/v1/resources', (req, res) => {
|
|
176
|
+
this._require_admin_read(req, res, () => {
|
|
177
|
+
const data = this.get_resources_tree();
|
|
178
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
179
|
+
res.end(JSON.stringify(data));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// GET /api/admin/v1/routes
|
|
184
|
+
router.set_route('/api/admin/v1/routes', (req, res) => {
|
|
185
|
+
this._require_admin_read(req, res, () => {
|
|
186
|
+
const data = this.get_routes_list();
|
|
187
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
188
|
+
res.end(JSON.stringify(data));
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// GET /api/admin/v1/custom-sections — returns metadata for client discovery
|
|
193
|
+
router.set_route('/api/admin/v1/custom-sections', (req, res) => {
|
|
194
|
+
this._require_admin_read(req, res, () => {
|
|
195
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
196
|
+
res.end(JSON.stringify(this.get_custom_sections()));
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Status Snapshot ─────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
get_status() {
|
|
204
|
+
const server = this._server;
|
|
205
|
+
const mem = process.memoryUsage();
|
|
206
|
+
const pool_summary = this._safe_pool_summary(server);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
process: {
|
|
210
|
+
pid: process.pid,
|
|
211
|
+
title: process.title,
|
|
212
|
+
uptime: Math.floor(process.uptime()),
|
|
213
|
+
memory: {
|
|
214
|
+
rss: mem.rss,
|
|
215
|
+
heap_used: mem.heapUsed,
|
|
216
|
+
heap_total: mem.heapTotal,
|
|
217
|
+
external: mem.external
|
|
218
|
+
},
|
|
219
|
+
node_version: process.version,
|
|
220
|
+
platform: process.platform,
|
|
221
|
+
arch: process.arch
|
|
222
|
+
},
|
|
223
|
+
server: {
|
|
224
|
+
port: (server && server.port) || null,
|
|
225
|
+
name: (server && server.name) || 'jsgui3-server'
|
|
226
|
+
},
|
|
227
|
+
telemetry: {
|
|
228
|
+
request_count: this._request_count,
|
|
229
|
+
requests_per_minute: this._get_requests_per_minute(),
|
|
230
|
+
status_counts: this._status_counts
|
|
231
|
+
},
|
|
232
|
+
pool: pool_summary,
|
|
233
|
+
routes: {
|
|
234
|
+
total: this._routes.length
|
|
235
|
+
},
|
|
236
|
+
build: this._build_info
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Resources ───────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
get_resources_tree() {
|
|
243
|
+
const tree = { name: 'Root', type: 'pool', children: [] };
|
|
244
|
+
try {
|
|
245
|
+
const pool = this._server ? this._server.resource_pool : null;
|
|
246
|
+
if (!pool || !pool.resources) return tree;
|
|
247
|
+
|
|
248
|
+
const resources = pool.resources._arr || pool.resources || [];
|
|
249
|
+
resources.forEach(res => {
|
|
250
|
+
if (!res) return;
|
|
251
|
+
tree.children.push({
|
|
252
|
+
name: res.name || 'Unnamed',
|
|
253
|
+
type: (res.constructor && res.constructor.name) || 'Resource',
|
|
254
|
+
state: res.state || 'unknown'
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
} catch (e) {
|
|
258
|
+
// Defensive — pool access may fail
|
|
259
|
+
}
|
|
260
|
+
return tree;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─── Routes ──────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
get_routes_list() {
|
|
266
|
+
return this._routes.map(r => ({
|
|
267
|
+
path: r.path,
|
|
268
|
+
type: r.type,
|
|
269
|
+
handler: r.handler_name,
|
|
270
|
+
method: this._infer_method(r.type)
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_track_route_registration(router) {
|
|
275
|
+
if (!router || !router.set_route) return;
|
|
276
|
+
if (this._routes_instrumented || router.__admin_v1_wrapped_set_route) return;
|
|
277
|
+
|
|
278
|
+
const original_set_route = router.set_route.bind(router);
|
|
279
|
+
const self = this;
|
|
280
|
+
|
|
281
|
+
router.set_route = function(path, responder_or_handler, handler) {
|
|
282
|
+
const route_info = {
|
|
283
|
+
path: path,
|
|
284
|
+
type: self._categorize_route(path, responder_or_handler),
|
|
285
|
+
handler_name: self._get_handler_name(responder_or_handler, handler),
|
|
286
|
+
registered_at: Date.now()
|
|
287
|
+
};
|
|
288
|
+
self._routes.push(route_info);
|
|
289
|
+
return original_set_route(path, responder_or_handler, handler);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
router.__admin_v1_wrapped_set_route = true;
|
|
293
|
+
this._routes_instrumented = true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_categorize_route(path, handler) {
|
|
297
|
+
if (typeof path !== 'string') return 'route';
|
|
298
|
+
if (path.startsWith('/api/admin')) return 'admin';
|
|
299
|
+
if (path.startsWith('/api/')) return 'api';
|
|
300
|
+
if (path === '/admin') return 'admin';
|
|
301
|
+
|
|
302
|
+
const name = handler && handler.constructor ? handler.constructor.name : '';
|
|
303
|
+
if (name.includes('Webpage')) return 'webpage';
|
|
304
|
+
if (name.includes('Function')) return 'api';
|
|
305
|
+
if (name.includes('Observable')) return 'observable';
|
|
306
|
+
if (name.includes('SSE')) return 'sse';
|
|
307
|
+
if (name.includes('CSS') || name.includes('JS') || name.includes('Static')) return 'static';
|
|
308
|
+
|
|
309
|
+
return 'route';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
_get_handler_name(responder, handler) {
|
|
313
|
+
if (handler && typeof handler === 'function' && handler.name) return handler.name;
|
|
314
|
+
if (responder && responder.constructor) return responder.constructor.name;
|
|
315
|
+
if (typeof responder === 'function' && responder.name) return responder.name;
|
|
316
|
+
return 'anonymous';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
_infer_method(type) {
|
|
320
|
+
switch (type) {
|
|
321
|
+
case 'api': return 'GET';
|
|
322
|
+
case 'observable': return 'GET';
|
|
323
|
+
case 'sse': return 'GET';
|
|
324
|
+
case 'static': return 'GET';
|
|
325
|
+
case 'webpage': return 'GET';
|
|
326
|
+
case 'admin': return 'GET';
|
|
327
|
+
default: return 'ANY';
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── Request Telemetry ───────────────────────────────────
|
|
332
|
+
|
|
333
|
+
_instrument_request_handler(router) {
|
|
334
|
+
if (!router || !router.process) return;
|
|
335
|
+
if (this._process_instrumented || router.__admin_v1_wrapped_process) return;
|
|
336
|
+
|
|
337
|
+
const original_process = router.process.bind(router);
|
|
338
|
+
const self = this;
|
|
339
|
+
|
|
340
|
+
router.process = function(req, res) {
|
|
341
|
+
// Skip admin routes from telemetry
|
|
342
|
+
if (req.url && (req.url.startsWith('/api/admin/') || req.url === '/admin' || req.url.startsWith('/admin/v1'))) {
|
|
343
|
+
return original_process(req, res);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const start = Date.now();
|
|
347
|
+
self._request_count++;
|
|
348
|
+
|
|
349
|
+
// Track in rolling window (last 60 seconds)
|
|
350
|
+
self._request_window.push(start);
|
|
351
|
+
self._trim_request_window(start);
|
|
352
|
+
|
|
353
|
+
// Wrap res.end to capture timing
|
|
354
|
+
const original_end = res.end.bind(res);
|
|
355
|
+
let end_called = false;
|
|
356
|
+
res.end = function(...args) {
|
|
357
|
+
if (end_called) return original_end(...args);
|
|
358
|
+
end_called = true;
|
|
359
|
+
|
|
360
|
+
const duration_ms = Date.now() - start;
|
|
361
|
+
const status = res.statusCode || 200;
|
|
362
|
+
self._status_counts[status] = (self._status_counts[status] || 0) + 1;
|
|
363
|
+
|
|
364
|
+
// Broadcast to SSE (throttled)
|
|
365
|
+
self._broadcast_request({
|
|
366
|
+
method: req.method,
|
|
367
|
+
url: req.url,
|
|
368
|
+
status: status,
|
|
369
|
+
duration_ms: duration_ms,
|
|
370
|
+
timestamp: start
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
return original_end(...args);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
return original_process(req, res);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
router.__admin_v1_wrapped_process = true;
|
|
380
|
+
this._process_instrumented = true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
_trim_request_window(now) {
|
|
384
|
+
const cutoff = now - 60000;
|
|
385
|
+
while (this._request_window.length > 0 && this._request_window[0] < cutoff) {
|
|
386
|
+
this._request_window.shift();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
_get_requests_per_minute() {
|
|
391
|
+
this._trim_request_window(Date.now());
|
|
392
|
+
return this._request_window.length;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ─── Request Broadcast Throttle ──────────────────────────
|
|
396
|
+
|
|
397
|
+
_broadcast_request(data) {
|
|
398
|
+
const now = Date.now();
|
|
399
|
+
if (!this._last_request_broadcast || now - this._last_request_broadcast > 1000) {
|
|
400
|
+
this._last_request_broadcast = now;
|
|
401
|
+
this._request_broadcast_count = 0;
|
|
402
|
+
}
|
|
403
|
+
this._request_broadcast_count = (this._request_broadcast_count || 0) + 1;
|
|
404
|
+
if (this._request_broadcast_count <= 10) {
|
|
405
|
+
this._broadcast('request', data);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ─── Resource Pool Events ────────────────────────────────
|
|
410
|
+
|
|
411
|
+
_subscribe_resource_events(server) {
|
|
412
|
+
const pool = server.resource_pool;
|
|
413
|
+
if (!pool || typeof pool.on !== 'function') return;
|
|
414
|
+
|
|
415
|
+
const events = ['resource_state_change', 'crashed', 'unhealthy', 'unreachable', 'recovered', 'removed'];
|
|
416
|
+
events.forEach(event_name => {
|
|
417
|
+
pool.on(event_name, (data) => {
|
|
418
|
+
this._broadcast(event_name, {
|
|
419
|
+
event: event_name,
|
|
420
|
+
resourceName: (data && data.resourceName) || 'unknown',
|
|
421
|
+
timestamp: Date.now(),
|
|
422
|
+
details: data
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ─── SSE Channel ─────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
_init_sse_channel(router) {
|
|
431
|
+
this._sse_publisher = new HTTP_SSE_Publisher({
|
|
432
|
+
name: 'admin_v1_events',
|
|
433
|
+
eventHistorySize: 100
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
router.set_route('/api/admin/v1/events', (req, res) => {
|
|
437
|
+
this._require_admin_read(req, res, () => {
|
|
438
|
+
this._sse_publisher.handle_http(req, res);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
is_authenticated_request(req) {
|
|
444
|
+
return this.auth.is_authenticated(req);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
is_admin_read_request(req) {
|
|
448
|
+
return this.auth.has_role(req, 'admin_read');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
is_admin_write_request(req) {
|
|
452
|
+
return this.auth.has_role(req, 'admin_write');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
_broadcast(event_name, data) {
|
|
456
|
+
if (this._sse_publisher) {
|
|
457
|
+
this._sse_publisher.broadcast(event_name, data);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ─── Heartbeat ───────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Build a pool summary without touching resource.status (which
|
|
465
|
+
* calls jsgui.http on the client and crashes on the server).
|
|
466
|
+
*/
|
|
467
|
+
_safe_pool_summary(server) {
|
|
468
|
+
const summary = { total: 0, running: 0, stopped: 0, byType: {} };
|
|
469
|
+
try {
|
|
470
|
+
const pool = server ? server.resource_pool : null;
|
|
471
|
+
if (!pool || !pool.resources) return summary;
|
|
472
|
+
const arr = pool.resources._arr || [];
|
|
473
|
+
arr.forEach(res => {
|
|
474
|
+
if (!res) return;
|
|
475
|
+
summary.total++;
|
|
476
|
+
const type_name = (res.constructor && res.constructor.name) || 'Unknown';
|
|
477
|
+
summary.byType[type_name] = (summary.byType[type_name] || 0) + 1;
|
|
478
|
+
});
|
|
479
|
+
} catch (e) { /* defensive */ }
|
|
480
|
+
return summary;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
_start_heartbeat(server) {
|
|
484
|
+
this._heartbeat_interval = setInterval(() => {
|
|
485
|
+
const pool_summary = this._safe_pool_summary(server);
|
|
486
|
+
const mem = process.memoryUsage();
|
|
487
|
+
|
|
488
|
+
this._broadcast('heartbeat', {
|
|
489
|
+
uptime: Math.floor(process.uptime()),
|
|
490
|
+
pid: process.pid,
|
|
491
|
+
memory: {
|
|
492
|
+
rss: mem.rss,
|
|
493
|
+
heap_used: mem.heapUsed,
|
|
494
|
+
heap_total: mem.heapTotal
|
|
495
|
+
},
|
|
496
|
+
request_count: this._request_count,
|
|
497
|
+
requests_per_minute: this._get_requests_per_minute(),
|
|
498
|
+
pool_summary: pool_summary,
|
|
499
|
+
route_count: this._routes.length,
|
|
500
|
+
timestamp: Date.now()
|
|
501
|
+
});
|
|
502
|
+
}, 5000);
|
|
503
|
+
|
|
504
|
+
// Don't prevent process exit
|
|
505
|
+
if (this._heartbeat_interval.unref) {
|
|
506
|
+
this._heartbeat_interval.unref();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// ─── Extensibility API ────────────────────────────────
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Register a custom sidebar section.
|
|
513
|
+
*
|
|
514
|
+
* The section appears in the admin sidebar. When the user clicks it,
|
|
515
|
+
* the admin shell fetches `api_path` and auto-renders the result as
|
|
516
|
+
* a table (arrays) or key-value panel (objects).
|
|
517
|
+
*
|
|
518
|
+
* @param {object} opts
|
|
519
|
+
* @param {string} opts.id - Unique section identifier (snake_case)
|
|
520
|
+
* @param {string} opts.label - Human-readable label for the sidebar
|
|
521
|
+
* @param {string} [opts.icon] - Optional emoji or text icon
|
|
522
|
+
* @param {string} opts.api_path - API endpoint path (e.g. '/api/admin/v1/crawlers')
|
|
523
|
+
* @param {string} [opts.role] - Required role (default: 'admin_read')
|
|
524
|
+
* @param {Function} [opts.handler] - Request handler for the api_path endpoint
|
|
525
|
+
* @returns {Admin_Module_V1} this (for chaining)
|
|
526
|
+
*
|
|
527
|
+
* @example
|
|
528
|
+
* server.admin_v1.add_section({
|
|
529
|
+
* id: 'crawlers',
|
|
530
|
+
* label: 'Crawlers',
|
|
531
|
+
* icon: '\uD83D\uDD77\uFE0F',
|
|
532
|
+
* api_path: '/api/admin/v1/crawlers',
|
|
533
|
+
* handler: (req, res) => {
|
|
534
|
+
* res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
535
|
+
* res.end(JSON.stringify([
|
|
536
|
+
* { name: 'Crawler A', status: 'running', pages: 1234 }
|
|
537
|
+
* ]));
|
|
538
|
+
* }
|
|
539
|
+
* });
|
|
540
|
+
*/
|
|
541
|
+
add_section(opts) {
|
|
542
|
+
if (!opts || !opts.id || !opts.label || !opts.api_path) {
|
|
543
|
+
throw new Error('add_section requires { id, label, api_path }');
|
|
544
|
+
}
|
|
545
|
+
const section = {
|
|
546
|
+
id: opts.id,
|
|
547
|
+
label: opts.label,
|
|
548
|
+
icon: opts.icon || null,
|
|
549
|
+
api_path: opts.api_path,
|
|
550
|
+
role: opts.role || 'admin_read'
|
|
551
|
+
};
|
|
552
|
+
this._custom_sections.push(section);
|
|
553
|
+
|
|
554
|
+
// If a handler was supplied and the router is already available, register it
|
|
555
|
+
if (typeof opts.handler === 'function') {
|
|
556
|
+
this.add_endpoint({
|
|
557
|
+
path: opts.api_path,
|
|
558
|
+
role: section.role,
|
|
559
|
+
handler: opts.handler
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
return this;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Register a custom protected admin API endpoint.
|
|
567
|
+
*
|
|
568
|
+
* The endpoint is automatically protected by the specified role.
|
|
569
|
+
*
|
|
570
|
+
* @param {object} opts
|
|
571
|
+
* @param {string} opts.path - Route path (e.g. '/api/admin/v1/my-data')
|
|
572
|
+
* @param {string} [opts.role] - Required role (default: 'admin_read')
|
|
573
|
+
* @param {Function} opts.handler - (req, res) handler
|
|
574
|
+
* @returns {Admin_Module_V1} this (for chaining)
|
|
575
|
+
*
|
|
576
|
+
* @example
|
|
577
|
+
* server.admin_v1.add_endpoint({
|
|
578
|
+
* path: '/api/admin/v1/crawlers/start',
|
|
579
|
+
* role: 'admin_write',
|
|
580
|
+
* handler: (req, res) => {
|
|
581
|
+
* // start a crawler …
|
|
582
|
+
* res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
583
|
+
* res.end(JSON.stringify({ ok: true }));
|
|
584
|
+
* }
|
|
585
|
+
* });
|
|
586
|
+
*/
|
|
587
|
+
add_endpoint(opts) {
|
|
588
|
+
if (!opts || !opts.path || typeof opts.handler !== 'function') {
|
|
589
|
+
throw new Error('add_endpoint requires { path, handler }');
|
|
590
|
+
}
|
|
591
|
+
const role = opts.role || 'admin_read';
|
|
592
|
+
this._custom_endpoints.push({ path: opts.path, role });
|
|
593
|
+
|
|
594
|
+
const router = this._router;
|
|
595
|
+
if (router) {
|
|
596
|
+
router.set_route(opts.path, (req, res) => {
|
|
597
|
+
this._require_role(req, res, role, () => {
|
|
598
|
+
opts.handler(req, res);
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
} else {
|
|
602
|
+
// Router not yet available — queue for deferred registration.
|
|
603
|
+
// This can happen if add_endpoint is called before init().
|
|
604
|
+
this._deferred_endpoints = this._deferred_endpoints || [];
|
|
605
|
+
this._deferred_endpoints.push(opts);
|
|
606
|
+
}
|
|
607
|
+
return this;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Plugin-style extension point.
|
|
612
|
+
*
|
|
613
|
+
* The provided function receives the admin module instance so it can
|
|
614
|
+
* call `add_section`, `add_endpoint`, access `auth`, etc.
|
|
615
|
+
*
|
|
616
|
+
* @param {Function} plugin_fn - (admin_v1) => void
|
|
617
|
+
* @returns {Admin_Module_V1} this (for chaining)
|
|
618
|
+
*
|
|
619
|
+
* @example
|
|
620
|
+
* server.admin_v1.use((admin) => {
|
|
621
|
+
* admin.add_section({ id: 'logs', label: 'Logs', api_path: '/api/admin/v1/logs' });
|
|
622
|
+
* });
|
|
623
|
+
*/
|
|
624
|
+
use(plugin_fn) {
|
|
625
|
+
if (typeof plugin_fn !== 'function') {
|
|
626
|
+
throw new Error('use() requires a function');
|
|
627
|
+
}
|
|
628
|
+
plugin_fn(this);
|
|
629
|
+
return this;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Returns metadata for all registered custom sections.
|
|
634
|
+
* Used by the admin shell client to discover and render them.
|
|
635
|
+
* @returns {Array<{id, label, icon, api_path}>}
|
|
636
|
+
*/
|
|
637
|
+
get_custom_sections() {
|
|
638
|
+
return this._custom_sections.map(s => ({
|
|
639
|
+
id: s.id,
|
|
640
|
+
label: s.label,
|
|
641
|
+
icon: s.icon,
|
|
642
|
+
api_path: s.api_path
|
|
643
|
+
}));
|
|
644
|
+
}
|
|
645
|
+
// ─── Cleanup ─────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
destroy() {
|
|
648
|
+
if (this._heartbeat_interval) {
|
|
649
|
+
clearInterval(this._heartbeat_interval);
|
|
650
|
+
this._heartbeat_interval = null;
|
|
651
|
+
}
|
|
652
|
+
if (this._sse_publisher && typeof this._sse_publisher.stop === 'function') {
|
|
653
|
+
this._sse_publisher.stop();
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
module.exports = Admin_Module_V1;
|