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.
Files changed (98) hide show
  1. package/.github/agents/Mobile Developer.agent.md +89 -0
  2. package/AGENTS.md +4 -0
  3. package/README.md +130 -0
  4. package/admin-ui/client.js +73 -43
  5. package/admin-ui/v1/admin_auth_service.js +197 -0
  6. package/admin-ui/v1/admin_user_store.js +71 -0
  7. package/admin-ui/v1/client.js +17 -0
  8. package/admin-ui/v1/controls/admin_shell.js +1399 -0
  9. package/admin-ui/v1/controls/group_box.js +84 -0
  10. package/admin-ui/v1/controls/stat_card.js +125 -0
  11. package/admin-ui/v1/server.js +658 -0
  12. package/admin-ui/v1/utils/formatters.js +68 -0
  13. package/docs/admin-extension-guide.md +345 -0
  14. package/docs/books/adaptive-control-improvements/01-control-candidate-matrix.md +122 -0
  15. package/docs/books/adaptive-control-improvements/02-tier-1-layout-playbooks.md +207 -0
  16. package/docs/books/adaptive-control-improvements/03-tier-2-navigation-form-overlay.md +140 -0
  17. package/docs/books/adaptive-control-improvements/04-cross-cutting-platform-functionality.md +141 -0
  18. package/docs/books/adaptive-control-improvements/05-styling-theming-density-upgrades.md +114 -0
  19. package/docs/books/adaptive-control-improvements/06-testing-quality-gates.md +97 -0
  20. package/docs/books/adaptive-control-improvements/07-delivery-roadmap-and-ownership.md +137 -0
  21. package/docs/books/adaptive-control-improvements/08-appendix-tier1-acceptance-and-pr-templates.md +261 -0
  22. package/docs/books/adaptive-control-improvements/README.md +66 -0
  23. package/docs/books/admin-ui-authentication/01-threat-model-and-goals.md +124 -0
  24. package/docs/books/admin-ui-authentication/02-session-model-and-token-model.md +75 -0
  25. package/docs/books/admin-ui-authentication/03-auth-middleware-patterns.md +77 -0
  26. package/docs/books/admin-ui-authentication/README.md +25 -0
  27. package/docs/books/creating-a-new-admin-ui/01-introduction-and-vision.md +130 -0
  28. package/docs/books/creating-a-new-admin-ui/02-architecture-and-data-flow.md +298 -0
  29. package/docs/books/creating-a-new-admin-ui/03-server-introspection.md +381 -0
  30. package/docs/books/creating-a-new-admin-ui/04-admin-module-adapter-layer.md +592 -0
  31. package/docs/books/creating-a-new-admin-ui/05-domain-controls-stat-cards-and-gauges.md +513 -0
  32. package/docs/books/creating-a-new-admin-ui/06-domain-controls-process-manager.md +544 -0
  33. package/docs/books/creating-a-new-admin-ui/07-domain-controls-resource-pool-inspector.md +493 -0
  34. package/docs/books/creating-a-new-admin-ui/08-domain-controls-route-table-and-api-explorer.md +586 -0
  35. package/docs/books/creating-a-new-admin-ui/09-domain-controls-log-viewer-and-activity-feed.md +490 -0
  36. package/docs/books/creating-a-new-admin-ui/10-domain-controls-build-status-and-bundle-inspector.md +526 -0
  37. package/docs/books/creating-a-new-admin-ui/11-domain-controls-configuration-panel.md +808 -0
  38. package/docs/books/creating-a-new-admin-ui/12-admin-shell-layout-sidebar-navigation.md +210 -0
  39. package/docs/books/creating-a-new-admin-ui/13-telemetry-integration.md +556 -0
  40. package/docs/books/creating-a-new-admin-ui/14-realtime-sse-observable-integration.md +485 -0
  41. package/docs/books/creating-a-new-admin-ui/15-styling-theming-aero-design-system.md +521 -0
  42. package/docs/books/creating-a-new-admin-ui/16-testing-and-quality-assurance.md +147 -0
  43. package/docs/books/creating-a-new-admin-ui/17-next-steps-process-resource-roadmap.md +356 -0
  44. package/docs/books/creating-a-new-admin-ui/README.md +68 -0
  45. package/docs/books/device-adaptive-composition/01-platform-feature-audit.md +177 -0
  46. package/docs/books/device-adaptive-composition/02-responsive-composition-model.md +187 -0
  47. package/docs/books/device-adaptive-composition/03-data-model-vs-view-model.md +231 -0
  48. package/docs/books/device-adaptive-composition/04-styling-theme-breakpoints.md +234 -0
  49. package/docs/books/device-adaptive-composition/05-showcase-app-multi-device-assessment.md +193 -0
  50. package/docs/books/device-adaptive-composition/06-implementation-patterns-and-apis.md +346 -0
  51. package/docs/books/device-adaptive-composition/07-testing-harness-and-quality-gates.md +265 -0
  52. package/docs/books/device-adaptive-composition/08-roadmap-and-adoption-plan.md +250 -0
  53. package/docs/books/device-adaptive-composition/README.md +47 -0
  54. package/docs/comparison-report-express-plex-cpanel.md +549 -0
  55. package/docs/designs/server-admin-interface-aero.svg +611 -0
  56. package/docs/troubleshooting.md +84 -53
  57. package/module.js +16 -11
  58. package/package.json +1 -1
  59. package/serve-factory.js +1 -0
  60. package/server.js +199 -0
  61. package/tests/README.md +5 -0
  62. package/tests/admin-ui-jsgui-controls.test.js +581 -0
  63. package/tests/test-runner.js +1 -0
  64. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-071799b982906680f5fd699d.js +0 -40
  65. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-07352945ad5c92654fcb8b65.js +0 -39
  66. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-138a601fadb6191ea314c6fd.js +0 -39
  67. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-171f6c381c2cadf2e9fa7087.js +0 -39
  68. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-1d973388156b84a04373fac9.js +0 -39
  69. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-20e117bc8a10d2cd16234bbe.js +0 -40
  70. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-2b028a82b0e5efddba42425f.js +0 -39
  71. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-4518556cd5c7e059e82b22b8.js +0 -40
  72. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-5bac1aa0f213902f718ed74f.js +0 -40
  73. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-5f9996ac7822caf777d92f56.js +0 -39
  74. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-60a92c702e65fd9cf748e3ec.js +0 -39
  75. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-6164c1f8f738995c541895d2.js +0 -44
  76. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-6718a85eb9e5aa782dd47a05.js +0 -45
  77. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-69e280f14e37aee76a1d4675.js +0 -39
  78. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-7570d1b030d44b111ed59c4c.js +0 -39
  79. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-7798c9bbd55e510d5039f936.js +0 -42
  80. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-78cd511ea1ef18ecb03d1be5.js +0 -40
  81. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-7d482e0b95bcb5e3c543118b.js +0 -43
  82. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-80e9476d1127c55b40fdb36f.js +0 -40
  83. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-810ced55d5320a3088a05b13.js +0 -40
  84. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-8423565f1a40e329afc8c6cf.js +0 -40
  85. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-900bef783b8cee36506ec282.js +0 -39
  86. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-a1a37aff6416fdad74040ddf.js +0 -39
  87. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-ad48d5e8eda40f175b4df090.js +0 -39
  88. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-aec5a2d963015528c9099462.js +0 -39
  89. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-af9d34e0f1722fab9e28c269.js +0 -39
  90. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-b818e4015e2f1fe86280b5ab.js +0 -41
  91. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-bcb2541adc70b7aba61768c5.js +0 -44
  92. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-bfe89d2c78ed44f95ed7dd73.js +0 -40
  93. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-c06f04806a1e688e1187110c.js +0 -40
  94. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-c3f3adf904f585afc544b96a.js +0 -39
  95. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-d45acb873e1d8e32d5e60f2e.js +0 -39
  96. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-db06f132533706f4a0163b8c.js +0 -39
  97. package/.jsgui3-server-cache/jsgui3-html-shims/jsgui3-html-controls-shim-f660f40d78b135fc8560a862.js +0 -39
  98. 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;