jsgui3-server 0.0.150 → 0.0.151

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.
@@ -17,29 +17,29 @@ const Stat_Card = require('./stat_card');
17
17
  * Book reference: Chapter 6 — Implementation Patterns (Admin Shell)
18
18
  * Layers: B (View Composition) + D (Concrete Render)
19
19
  */
20
- class Admin_Shell extends Active_HTML_Document {
21
- constructor(spec = {}) {
22
- spec.__type_name = spec.__type_name || 'admin_shell';
23
- super(spec);
24
- const { context } = this;
25
-
26
- this._nav_items = [];
27
- this._tab_items = [];
28
- this._section_labels = Object.create(null);
29
- this._custom_nav_items = [];
30
- this._custom_nav_separator = null;
31
- this._custom_sections_list = [];
32
- this._client_bootstrapped = false;
33
-
34
- if (typeof this.body.add_class === 'function') {
35
- this.body.add_class('admin-shell');
36
- }
20
+ class Admin_Shell extends Active_HTML_Document {
21
+ constructor(spec = {}) {
22
+ spec.__type_name = spec.__type_name || 'admin_shell';
23
+ super(spec);
24
+ const { context } = this;
25
+
26
+ this._nav_items = [];
27
+ this._tab_items = [];
28
+ this._section_labels = Object.create(null);
29
+ this._custom_nav_items = [];
30
+ this._custom_nav_separator = null;
31
+ this._custom_sections_list = [];
32
+ this._client_bootstrapped = false;
33
+
34
+ if (typeof this.body.add_class === 'function') {
35
+ this.body.add_class('admin-shell');
36
+ }
37
37
 
38
38
  const compose = () => {
39
- // ─── Sidebar ─────────────────────────────────────
40
- const sidebar = new controls.div({ context, 'class': 'as-sidebar' });
41
- this.body.add(sidebar);
42
- this._sidebar = sidebar;
39
+ // ─── Sidebar ─────────────────────────────────────
40
+ const sidebar = new controls.div({ context, 'class': 'as-sidebar' });
41
+ this.body.add(sidebar);
42
+ this._sidebar = sidebar;
43
43
 
44
44
  const brand = new controls.div({ context, 'class': 'as-brand' });
45
45
  const brand_icon = new controls.span({ context, 'class': 'as-brand-icon' });
@@ -50,11 +50,11 @@ class Admin_Shell extends Active_HTML_Document {
50
50
  brand.add(brand_text);
51
51
  sidebar.add(brand);
52
52
 
53
- const nav = new controls.div({ context, 'class': 'as-nav' });
54
- sidebar.add(nav);
55
- this._nav = nav;
56
-
57
- this._add_nav_item(nav, 'Dashboard', 'dashboard', true);
53
+ const nav = new controls.div({ context, 'class': 'as-nav' });
54
+ sidebar.add(nav);
55
+ this._nav = nav;
56
+
57
+ this._add_nav_item(nav, 'Dashboard', 'dashboard', true);
58
58
  this._add_nav_item(nav, 'Resources', 'resources');
59
59
  this._add_nav_item(nav, 'Routes', 'routes');
60
60
  this._add_nav_item(nav, 'Settings', 'settings');
@@ -75,12 +75,12 @@ class Admin_Shell extends Active_HTML_Document {
75
75
  main.add(toolbar);
76
76
 
77
77
  // Hamburger button (hidden on desktop, visible on tablet/phone)
78
- const hamburger = new controls.button({ context, 'class': 'as-hamburger' });
79
- hamburger.dom.attributes.type = 'button';
80
- hamburger.dom.attributes['aria-label'] = 'Toggle navigation';
81
- hamburger.add('\u2630');
82
- toolbar.add(hamburger);
83
- this._hamburger = hamburger;
78
+ const hamburger = new controls.button({ context, 'class': 'as-hamburger' });
79
+ hamburger.dom.attributes.type = 'button';
80
+ hamburger.dom.attributes['aria-label'] = 'Toggle navigation';
81
+ hamburger.add('\u2630');
82
+ toolbar.add(hamburger);
83
+ this._hamburger = hamburger;
84
84
 
85
85
  const page_title = new controls.h2({ context, 'class': 'as-page-title' });
86
86
  page_title.add('Dashboard');
@@ -94,9 +94,9 @@ class Admin_Shell extends Active_HTML_Document {
94
94
  this._status_dot = status_dot;
95
95
 
96
96
  // Sidebar overlay backdrop (for tablet/phone)
97
- const overlay = new controls.div({ context, 'class': 'as-sidebar-overlay' });
98
- this.body.add(overlay);
99
- this._overlay = overlay;
97
+ const overlay = new controls.div({ context, 'class': 'as-sidebar-overlay' });
98
+ this.body.add(overlay);
99
+ this._overlay = overlay;
100
100
 
101
101
  // Content area
102
102
  const content = new controls.div({ context, 'class': 'as-content' });
@@ -130,82 +130,82 @@ class Admin_Shell extends Active_HTML_Document {
130
130
  const bottom_bar = new controls.div({ context, 'class': 'as-bottom-bar' });
131
131
  this.body.add(bottom_bar);
132
132
 
133
- const tab_items = [
134
- { label: '\u2302', section: 'dashboard', text: 'Home' },
135
- { label: '\u25A6', section: 'resources', text: 'Resources' },
136
- { label: '\u2194', section: 'routes', text: 'Routes' },
137
- { label: '\u2699', section: 'settings', text: 'Settings' }
138
- ];
139
- tab_items.forEach(item => {
140
- this._add_tab_item(bottom_bar, item, item.section === 'dashboard');
141
- });
142
-
143
- hamburger.on('click', () => this._toggle_sidebar());
144
- overlay.on('click', () => this._close_sidebar());
145
- };
146
-
147
- if (!spec.el) {
148
- compose();
149
- }
150
- }
151
-
152
- _add_nav_item(nav, label, id, active, options = {}) {
153
- const { context } = this;
154
- const item = new controls.div({
155
- context,
156
- 'class': 'as-nav-item' + (active ? ' active' : '')
157
- });
158
- item.dom.attributes['data-section'] = id;
159
- const icon_text = options.icon ? options.icon + ' ' : '';
160
- item.add(icon_text + label);
161
- nav.add(item);
162
-
163
- const nav_item_info = {
164
- id,
165
- label: options.title || label,
166
- control: item,
167
- is_custom: !!options.is_custom
168
- };
169
- this._section_labels[id] = nav_item_info.label;
170
- this._nav_items.push(nav_item_info);
171
- if (nav_item_info.is_custom) {
172
- this._custom_nav_items.push(nav_item_info);
173
- }
174
-
175
- item.on('click', () => {
176
- this._activate_section_from_nav(id, nav_item_info.label);
177
- });
178
-
179
- return item;
180
- }
181
-
182
- _add_tab_item(bottom_bar, item, active) {
183
- const { context } = this;
184
- const tab = new controls.div({ context, 'class': 'as-tab-item' + (active ? ' active' : '') });
185
- tab.dom.attributes['data-section'] = item.section;
186
-
187
- const icon = new controls.span({ context, 'class': 'as-tab-icon' });
188
- icon.add(item.label);
189
- tab.add(icon);
190
-
191
- const label = new controls.span({ context, 'class': 'as-tab-label' });
192
- label.add(item.text);
193
- tab.add(label);
194
- bottom_bar.add(tab);
195
-
196
- this._tab_items.push({
197
- id: item.section,
198
- label: item.text,
199
- control: tab
200
- });
201
-
202
- tab.on('click', () => {
203
- this._activate_section_from_tab(item.section, item.text);
204
- });
205
- }
206
-
207
- _compose_dashboard(container) {
208
- const { context } = this;
133
+ const tab_items = [
134
+ { label: '\u2302', section: 'dashboard', text: 'Home' },
135
+ { label: '\u25A6', section: 'resources', text: 'Resources' },
136
+ { label: '\u2194', section: 'routes', text: 'Routes' },
137
+ { label: '\u2699', section: 'settings', text: 'Settings' }
138
+ ];
139
+ tab_items.forEach(item => {
140
+ this._add_tab_item(bottom_bar, item, item.section === 'dashboard');
141
+ });
142
+
143
+ hamburger.on('click', () => this._toggle_sidebar());
144
+ overlay.on('click', () => this._close_sidebar());
145
+ };
146
+
147
+ if (!spec.el) {
148
+ compose();
149
+ }
150
+ }
151
+
152
+ _add_nav_item(nav, label, id, active, options = {}) {
153
+ const { context } = this;
154
+ const item = new controls.div({
155
+ context,
156
+ 'class': 'as-nav-item' + (active ? ' active' : '')
157
+ });
158
+ item.dom.attributes['data-section'] = id;
159
+ const icon_text = options.icon ? options.icon + ' ' : '';
160
+ item.add(icon_text + label);
161
+ nav.add(item);
162
+
163
+ const nav_item_info = {
164
+ id,
165
+ label: options.title || label,
166
+ control: item,
167
+ is_custom: !!options.is_custom
168
+ };
169
+ this._section_labels[id] = nav_item_info.label;
170
+ this._nav_items.push(nav_item_info);
171
+ if (nav_item_info.is_custom) {
172
+ this._custom_nav_items.push(nav_item_info);
173
+ }
174
+
175
+ item.on('click', () => {
176
+ this._activate_section_from_nav(id, nav_item_info.label);
177
+ });
178
+
179
+ return item;
180
+ }
181
+
182
+ _add_tab_item(bottom_bar, item, active) {
183
+ const { context } = this;
184
+ const tab = new controls.div({ context, 'class': 'as-tab-item' + (active ? ' active' : '') });
185
+ tab.dom.attributes['data-section'] = item.section;
186
+
187
+ const icon = new controls.span({ context, 'class': 'as-tab-icon' });
188
+ icon.add(item.label);
189
+ tab.add(icon);
190
+
191
+ const label = new controls.span({ context, 'class': 'as-tab-label' });
192
+ label.add(item.text);
193
+ tab.add(label);
194
+ bottom_bar.add(tab);
195
+
196
+ this._tab_items.push({
197
+ id: item.section,
198
+ label: item.text,
199
+ control: tab
200
+ });
201
+
202
+ tab.on('click', () => {
203
+ this._activate_section_from_tab(item.section, item.text);
204
+ });
205
+ }
206
+
207
+ _compose_dashboard(container) {
208
+ const { context } = this;
209
209
 
210
210
  // Server Info group
211
211
  const server_group = new Group_Box({ context, title: 'Server', 'class': 'group-box' });
@@ -258,437 +258,437 @@ class Admin_Shell extends Active_HTML_Document {
258
258
  const telemetry_group = new Group_Box({ context, title: 'Telemetry', 'class': 'group-box' });
259
259
  container.add(telemetry_group);
260
260
 
261
- this._card_requests = new Stat_Card({
262
- context, label: 'Requests', value: '0', detail: '0 req/min', accent: '#f093fb', 'class': 'stat_card'
263
- });
264
- telemetry_group.inner.add(this._card_requests);
265
- }
266
-
267
- // ─── Client-Side Activation ──────────────────────────────
268
-
269
- activate() {
270
- if (!this.__active) {
271
- super.activate();
272
- }
273
-
274
- if (!this._client_bootstrapped) {
275
- this._client_bootstrapped = true;
276
- this._update_layout_mode();
277
- if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
278
- if (!this._bound_resize_handler) {
279
- this._bound_resize_handler = () => this._update_layout_mode();
280
- }
281
- window.addEventListener('resize', this._bound_resize_handler);
282
- }
283
-
284
- this._fetch_status();
285
- this._connect_sse();
286
- this._load_custom_sections();
287
- }
288
- }
289
-
290
- _update_layout_mode() {
291
- const has_window = typeof window !== 'undefined';
292
- const w = has_window && typeof window.innerWidth === 'number'
293
- ? window.innerWidth
294
- : 1024;
295
- let mode = 'desktop';
296
- if (w <= 480) mode = 'phone';
297
- else if (w <= 768) mode = 'tablet';
298
- if (this.body && this.body.dom && this.body.dom.attributes) {
299
- this.body.dom.attributes['data-layout-mode'] = mode;
300
- }
301
- if (this.body && this.body.el && typeof this.body.el.setAttribute === 'function') {
302
- this.body.el.setAttribute('data-layout-mode', mode);
303
- }
304
- this._layout_mode = mode;
305
- }
306
-
307
- _toggle_sidebar() {
308
- const sidebar = this._sidebar;
309
- const overlay = this._overlay;
310
- if (!sidebar) return;
311
- const is_open = typeof sidebar.has_class === 'function' && sidebar.has_class('as-sidebar-open');
312
- if (is_open) {
313
- this._close_sidebar();
314
- } else {
315
- if (typeof sidebar.add_class === 'function') {
316
- sidebar.add_class('as-sidebar-open');
317
- }
318
- if (overlay && typeof overlay.add_class === 'function') {
319
- overlay.add_class('active');
320
- }
321
- }
322
- }
323
-
324
- _close_sidebar() {
325
- if (this._sidebar && typeof this._sidebar.remove_class === 'function') {
326
- this._sidebar.remove_class('as-sidebar-open');
327
- }
328
- if (this._overlay && typeof this._overlay.remove_class === 'function') {
329
- this._overlay.remove_class('active');
330
- }
331
- }
332
-
333
- _activate_section_from_nav(section_id, section_label) {
334
- this._set_active_nav(section_id);
335
- if (String(section_id).indexOf('custom:') === 0) {
336
- this._set_active_tab(null);
337
- } else {
338
- this._set_active_tab(section_id);
339
- }
340
- this._set_page_title(section_label || this._get_section_label(section_id));
341
- this._close_sidebar();
342
- this._select_section(section_id);
343
- }
344
-
345
- _activate_section_from_tab(section_id, section_label) {
346
- this._set_active_tab(section_id);
347
- this._set_active_nav(section_id);
348
- this._set_page_title(section_label || this._get_section_label(section_id));
349
- this._select_section(section_id);
350
- }
351
-
352
- _set_active_nav(section_id) {
353
- this._nav_items.forEach((nav_item) => {
354
- if (!nav_item.control) return;
355
- if (nav_item.id === section_id) {
356
- nav_item.control.add_class('active');
357
- } else {
358
- nav_item.control.remove_class('active');
359
- }
360
- });
361
- }
362
-
363
- _set_active_tab(section_id) {
364
- this._tab_items.forEach((tab_item) => {
365
- if (!tab_item.control) return;
366
- if (section_id != null && tab_item.id === section_id) {
367
- tab_item.control.add_class('active');
368
- } else {
369
- tab_item.control.remove_class('active');
370
- }
371
- });
372
- }
373
-
374
- _get_section_label(section_id) {
375
- return this._section_labels[section_id] || section_id;
376
- }
377
-
378
- _set_page_title(text) {
379
- this._set_control_text(this._page_title, text);
380
- }
381
-
382
- _select_section(section) {
383
- this._active_section = section;
384
-
385
- const dashboard_section = this._dashboard_section;
386
- const dynamic_section = this._dynamic_section;
387
- if (!dashboard_section || !dynamic_section) return Promise.resolve();
388
-
389
- if (section === 'dashboard') {
390
- dashboard_section.remove_class('hidden');
391
- dynamic_section.add_class('hidden');
392
- this._set_status_text('Dashboard view');
393
- return Promise.resolve();
394
- }
395
-
396
- dashboard_section.add_class('hidden');
397
- dynamic_section.remove_class('hidden');
398
-
399
- if (section === 'resources') {
400
- return this._render_resources_section();
401
- } else if (section === 'routes') {
402
- return this._render_routes_section();
403
- } else if (section === 'settings') {
404
- return this._render_settings_section();
405
- } else if (section.startsWith('custom:')) {
406
- const custom_id = section.substring(7);
407
- const custom = this._custom_sections_list && this._custom_sections_list.find(s => s.id === custom_id);
408
- if (custom) {
409
- return this._render_custom_section(custom);
410
- }
411
- }
412
- return Promise.resolve();
413
- }
414
-
415
- _set_control_text(control, text) {
416
- if (!control) return;
417
- if (typeof control.clear === 'function') {
418
- control.clear();
419
- }
420
- if (typeof control.add === 'function') {
421
- control.add(String(text == null ? '' : text));
422
- }
423
- }
424
-
425
- _clear_control(control) {
426
- if (control && typeof control.clear === 'function') {
427
- control.clear();
428
- }
429
- }
430
-
431
- _render_loading(message) {
432
- if (!this._dynamic_section) return;
433
- this._clear_control(this._dynamic_section);
434
- const panel = this._create_panel();
435
- const text = new controls.div({ context: this.context, 'class': 'as-muted' });
436
- text.add(String(message));
437
- panel.add(text);
438
- this._dynamic_section.add(panel);
439
- }
440
-
441
- _render_error(message, retry_handler) {
442
- if (!this._dynamic_section) return;
443
- this._clear_control(this._dynamic_section);
444
-
445
- const panel = this._create_panel();
446
- const text = new controls.div({ context: this.context, 'class': 'as-error' });
447
- text.add(String(message));
448
- panel.add(text);
449
-
450
- const retry_btn = new controls.button({ context: this.context, 'class': 'as-retry-btn' });
451
- retry_btn.dom.attributes.type = 'button';
452
- retry_btn.add('Retry');
453
- if (typeof retry_handler === 'function') {
454
- retry_btn.on('click', retry_handler);
455
- }
456
- panel.add(retry_btn);
457
-
458
- this._dynamic_section.add(panel);
459
- }
460
-
461
- _render_empty(message) {
462
- if (!this._dynamic_section) return;
463
- this._clear_control(this._dynamic_section);
464
-
465
- const panel = this._create_panel();
466
- const text = new controls.div({ context: this.context, 'class': 'as-muted' });
467
- text.add(String(message));
468
- panel.add(text);
469
- this._dynamic_section.add(panel);
470
- }
471
-
472
- _fetch_json(url) {
473
- return fetch(url).then((res) => {
474
- if (!res.ok) {
475
- if (res.status === 401) {
476
- this._redirect_to_login();
477
- throw new Error('Unauthorized');
478
- }
479
- throw new Error('HTTP ' + res.status + ' for ' + url);
480
- }
481
- return res.json();
482
- });
483
- }
484
-
485
- _create_tag_control(tag_name, class_name) {
486
- const spec = {
487
- context: this.context,
488
- tagName: tag_name
489
- };
490
- if (class_name) {
491
- spec.class = class_name;
492
- }
493
- return new Control(spec);
494
- }
495
-
496
- _create_panel(panel_title) {
497
- const panel = new controls.div({ context: this.context, 'class': 'as-panel' });
498
- if (panel_title) {
499
- const title = new controls.h3({ context: this.context, 'class': 'as-panel-title' });
500
- title.add(String(panel_title));
501
- panel.add(title);
502
- }
503
- return panel;
504
- }
505
-
506
- _stringify_value(value) {
507
- if (value == null) return '';
508
- if (typeof value === 'object') {
509
- try {
510
- return JSON.stringify(value);
511
- } catch (err) {
512
- return String(value);
513
- }
514
- }
515
- return String(value);
516
- }
517
-
518
- _create_table_panel(panel_title, column_names, rows_data) {
519
- const panel = this._create_panel(panel_title);
520
-
521
- const table = this._create_tag_control('table', 'as-table');
522
- const thead = this._create_tag_control('thead');
523
- const header_row = this._create_tag_control('tr');
524
-
525
- column_names.forEach((column_name) => {
526
- const th = this._create_tag_control('th');
527
- th.add(String(column_name));
528
- header_row.add(th);
529
- });
530
- thead.add(header_row);
531
- table.add(thead);
532
-
533
- const tbody = this._create_tag_control('tbody');
534
- rows_data.forEach((row_data) => {
535
- const row = this._create_tag_control('tr');
536
- row_data.forEach((cell_value) => {
537
- const td = this._create_tag_control('td');
538
- td.add(this._stringify_value(cell_value));
539
- row.add(td);
540
- });
541
- tbody.add(row);
542
- });
543
- table.add(tbody);
544
-
545
- panel.add(table);
546
- return panel;
547
- }
548
-
549
- _create_kv_row(key_text, value_text) {
550
- const row = new controls.div({ context: this.context, 'class': 'as-kv-row' });
551
- const key = new controls.span({ context: this.context, 'class': 'as-kv-key' });
552
- key.add(String(key_text));
553
- const value = new controls.span({ context: this.context, 'class': 'as-kv-value' });
554
- value.add(this._stringify_value(value_text));
555
- row.add(key);
556
- row.add(value);
557
- return row;
558
- }
559
-
560
- _render_resources_section() {
561
- this._render_loading('Loading resources...');
562
- this._set_status_text('Loading resources');
563
-
564
- return this._fetch_json('/api/admin/v1/resources')
565
- .then((data) => {
566
- if (!this._dynamic_section) return;
567
-
568
- const children = (data && data.children) || [];
569
- if (children.length === 0) {
570
- this._render_empty('No resources found.');
571
- return;
572
- }
573
-
574
- const rows_data = children.map((resource_item) => {
575
- return [
576
- resource_item.name || 'Unnamed',
577
- resource_item.type || 'Resource',
578
- resource_item.state || 'unknown'
579
- ];
580
- });
581
-
582
- this._clear_control(this._dynamic_section);
583
- this._dynamic_section.add(
584
- this._create_table_panel('Resources', ['Name', 'Type', 'State'], rows_data)
585
- );
586
-
587
- this._set_status_text('Loaded resources: ' + children.length);
588
- })
589
- .catch((err) => {
590
- console.error('[Admin] Failed to load resources:', err);
261
+ this._card_requests = new Stat_Card({
262
+ context, label: 'Requests', value: '0', detail: '0 req/min', accent: '#f093fb', 'class': 'stat_card'
263
+ });
264
+ telemetry_group.inner.add(this._card_requests);
265
+ }
266
+
267
+ // ─── Client-Side Activation ──────────────────────────────
268
+
269
+ activate() {
270
+ if (!this.__active) {
271
+ super.activate();
272
+ }
273
+
274
+ if (!this._client_bootstrapped) {
275
+ this._client_bootstrapped = true;
276
+ this._update_layout_mode();
277
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
278
+ if (!this._bound_resize_handler) {
279
+ this._bound_resize_handler = () => this._update_layout_mode();
280
+ }
281
+ window.addEventListener('resize', this._bound_resize_handler);
282
+ }
283
+
284
+ this._fetch_status();
285
+ this._connect_sse();
286
+ this._load_custom_sections();
287
+ }
288
+ }
289
+
290
+ _update_layout_mode() {
291
+ const has_window = typeof window !== 'undefined';
292
+ const w = has_window && typeof window.innerWidth === 'number'
293
+ ? window.innerWidth
294
+ : 1024;
295
+ let mode = 'desktop';
296
+ if (w <= 480) mode = 'phone';
297
+ else if (w <= 768) mode = 'tablet';
298
+ if (this.body && this.body.dom && this.body.dom.attributes) {
299
+ this.body.dom.attributes['data-layout-mode'] = mode;
300
+ }
301
+ if (this.body && this.body.el && typeof this.body.el.setAttribute === 'function') {
302
+ this.body.el.setAttribute('data-layout-mode', mode);
303
+ }
304
+ this._layout_mode = mode;
305
+ }
306
+
307
+ _toggle_sidebar() {
308
+ const sidebar = this._sidebar;
309
+ const overlay = this._overlay;
310
+ if (!sidebar) return;
311
+ const is_open = typeof sidebar.has_class === 'function' && sidebar.has_class('as-sidebar-open');
312
+ if (is_open) {
313
+ this._close_sidebar();
314
+ } else {
315
+ if (typeof sidebar.add_class === 'function') {
316
+ sidebar.add_class('as-sidebar-open');
317
+ }
318
+ if (overlay && typeof overlay.add_class === 'function') {
319
+ overlay.add_class('active');
320
+ }
321
+ }
322
+ }
323
+
324
+ _close_sidebar() {
325
+ if (this._sidebar && typeof this._sidebar.remove_class === 'function') {
326
+ this._sidebar.remove_class('as-sidebar-open');
327
+ }
328
+ if (this._overlay && typeof this._overlay.remove_class === 'function') {
329
+ this._overlay.remove_class('active');
330
+ }
331
+ }
332
+
333
+ _activate_section_from_nav(section_id, section_label) {
334
+ this._set_active_nav(section_id);
335
+ if (String(section_id).indexOf('custom:') === 0) {
336
+ this._set_active_tab(null);
337
+ } else {
338
+ this._set_active_tab(section_id);
339
+ }
340
+ this._set_page_title(section_label || this._get_section_label(section_id));
341
+ this._close_sidebar();
342
+ this._select_section(section_id);
343
+ }
344
+
345
+ _activate_section_from_tab(section_id, section_label) {
346
+ this._set_active_tab(section_id);
347
+ this._set_active_nav(section_id);
348
+ this._set_page_title(section_label || this._get_section_label(section_id));
349
+ this._select_section(section_id);
350
+ }
351
+
352
+ _set_active_nav(section_id) {
353
+ this._nav_items.forEach((nav_item) => {
354
+ if (!nav_item.control) return;
355
+ if (nav_item.id === section_id) {
356
+ nav_item.control.add_class('active');
357
+ } else {
358
+ nav_item.control.remove_class('active');
359
+ }
360
+ });
361
+ }
362
+
363
+ _set_active_tab(section_id) {
364
+ this._tab_items.forEach((tab_item) => {
365
+ if (!tab_item.control) return;
366
+ if (section_id != null && tab_item.id === section_id) {
367
+ tab_item.control.add_class('active');
368
+ } else {
369
+ tab_item.control.remove_class('active');
370
+ }
371
+ });
372
+ }
373
+
374
+ _get_section_label(section_id) {
375
+ return this._section_labels[section_id] || section_id;
376
+ }
377
+
378
+ _set_page_title(text) {
379
+ this._set_control_text(this._page_title, text);
380
+ }
381
+
382
+ _select_section(section) {
383
+ this._active_section = section;
384
+
385
+ const dashboard_section = this._dashboard_section;
386
+ const dynamic_section = this._dynamic_section;
387
+ if (!dashboard_section || !dynamic_section) return Promise.resolve();
388
+
389
+ if (section === 'dashboard') {
390
+ dashboard_section.remove_class('hidden');
391
+ dynamic_section.add_class('hidden');
392
+ this._set_status_text('Dashboard view');
393
+ return Promise.resolve();
394
+ }
395
+
396
+ dashboard_section.add_class('hidden');
397
+ dynamic_section.remove_class('hidden');
398
+
399
+ if (section === 'resources') {
400
+ return this._render_resources_section();
401
+ } else if (section === 'routes') {
402
+ return this._render_routes_section();
403
+ } else if (section === 'settings') {
404
+ return this._render_settings_section();
405
+ } else if (section.startsWith('custom:')) {
406
+ const custom_id = section.substring(7);
407
+ const custom = this._custom_sections_list && this._custom_sections_list.find(s => s.id === custom_id);
408
+ if (custom) {
409
+ return this._render_custom_section(custom);
410
+ }
411
+ }
412
+ return Promise.resolve();
413
+ }
414
+
415
+ _set_control_text(control, text) {
416
+ if (!control) return;
417
+ if (typeof control.clear === 'function') {
418
+ control.clear();
419
+ }
420
+ if (typeof control.add === 'function') {
421
+ control.add(String(text == null ? '' : text));
422
+ }
423
+ }
424
+
425
+ _clear_control(control) {
426
+ if (control && typeof control.clear === 'function') {
427
+ control.clear();
428
+ }
429
+ }
430
+
431
+ _render_loading(message) {
432
+ if (!this._dynamic_section) return;
433
+ this._clear_control(this._dynamic_section);
434
+ const panel = this._create_panel();
435
+ const text = new controls.div({ context: this.context, 'class': 'as-muted' });
436
+ text.add(String(message));
437
+ panel.add(text);
438
+ this._dynamic_section.add(panel);
439
+ }
440
+
441
+ _render_error(message, retry_handler) {
442
+ if (!this._dynamic_section) return;
443
+ this._clear_control(this._dynamic_section);
444
+
445
+ const panel = this._create_panel();
446
+ const text = new controls.div({ context: this.context, 'class': 'as-error' });
447
+ text.add(String(message));
448
+ panel.add(text);
449
+
450
+ const retry_btn = new controls.button({ context: this.context, 'class': 'as-retry-btn' });
451
+ retry_btn.dom.attributes.type = 'button';
452
+ retry_btn.add('Retry');
453
+ if (typeof retry_handler === 'function') {
454
+ retry_btn.on('click', retry_handler);
455
+ }
456
+ panel.add(retry_btn);
457
+
458
+ this._dynamic_section.add(panel);
459
+ }
460
+
461
+ _render_empty(message) {
462
+ if (!this._dynamic_section) return;
463
+ this._clear_control(this._dynamic_section);
464
+
465
+ const panel = this._create_panel();
466
+ const text = new controls.div({ context: this.context, 'class': 'as-muted' });
467
+ text.add(String(message));
468
+ panel.add(text);
469
+ this._dynamic_section.add(panel);
470
+ }
471
+
472
+ _fetch_json(url) {
473
+ return fetch(url).then((res) => {
474
+ if (!res.ok) {
475
+ if (res.status === 401) {
476
+ this._redirect_to_login();
477
+ throw new Error('Unauthorized');
478
+ }
479
+ throw new Error('HTTP ' + res.status + ' for ' + url);
480
+ }
481
+ return res.json();
482
+ });
483
+ }
484
+
485
+ _create_tag_control(tag_name, class_name) {
486
+ const spec = {
487
+ context: this.context,
488
+ tagName: tag_name
489
+ };
490
+ if (class_name) {
491
+ spec.class = class_name;
492
+ }
493
+ return new Control(spec);
494
+ }
495
+
496
+ _create_panel(panel_title) {
497
+ const panel = new controls.div({ context: this.context, 'class': 'as-panel' });
498
+ if (panel_title) {
499
+ const title = new controls.h3({ context: this.context, 'class': 'as-panel-title' });
500
+ title.add(String(panel_title));
501
+ panel.add(title);
502
+ }
503
+ return panel;
504
+ }
505
+
506
+ _stringify_value(value) {
507
+ if (value == null) return '';
508
+ if (typeof value === 'object') {
509
+ try {
510
+ return JSON.stringify(value);
511
+ } catch (err) {
512
+ return String(value);
513
+ }
514
+ }
515
+ return String(value);
516
+ }
517
+
518
+ _create_table_panel(panel_title, column_names, rows_data) {
519
+ const panel = this._create_panel(panel_title);
520
+
521
+ const table = this._create_tag_control('table', 'as-table');
522
+ const thead = this._create_tag_control('thead');
523
+ const header_row = this._create_tag_control('tr');
524
+
525
+ column_names.forEach((column_name) => {
526
+ const th = this._create_tag_control('th');
527
+ th.add(String(column_name));
528
+ header_row.add(th);
529
+ });
530
+ thead.add(header_row);
531
+ table.add(thead);
532
+
533
+ const tbody = this._create_tag_control('tbody');
534
+ rows_data.forEach((row_data) => {
535
+ const row = this._create_tag_control('tr');
536
+ row_data.forEach((cell_value) => {
537
+ const td = this._create_tag_control('td');
538
+ td.add(this._stringify_value(cell_value));
539
+ row.add(td);
540
+ });
541
+ tbody.add(row);
542
+ });
543
+ table.add(tbody);
544
+
545
+ panel.add(table);
546
+ return panel;
547
+ }
548
+
549
+ _create_kv_row(key_text, value_text) {
550
+ const row = new controls.div({ context: this.context, 'class': 'as-kv-row' });
551
+ const key = new controls.span({ context: this.context, 'class': 'as-kv-key' });
552
+ key.add(String(key_text));
553
+ const value = new controls.span({ context: this.context, 'class': 'as-kv-value' });
554
+ value.add(this._stringify_value(value_text));
555
+ row.add(key);
556
+ row.add(value);
557
+ return row;
558
+ }
559
+
560
+ _render_resources_section() {
561
+ this._render_loading('Loading resources...');
562
+ this._set_status_text('Loading resources');
563
+
564
+ return this._fetch_json('/api/admin/v1/resources')
565
+ .then((data) => {
566
+ if (!this._dynamic_section) return;
567
+
568
+ const children = (data && data.children) || [];
569
+ if (children.length === 0) {
570
+ this._render_empty('No resources found.');
571
+ return;
572
+ }
573
+
574
+ const rows_data = children.map((resource_item) => {
575
+ return [
576
+ resource_item.name || 'Unnamed',
577
+ resource_item.type || 'Resource',
578
+ resource_item.state || 'unknown'
579
+ ];
580
+ });
581
+
582
+ this._clear_control(this._dynamic_section);
583
+ this._dynamic_section.add(
584
+ this._create_table_panel('Resources', ['Name', 'Type', 'State'], rows_data)
585
+ );
586
+
587
+ this._set_status_text('Loaded resources: ' + children.length);
588
+ })
589
+ .catch((err) => {
590
+ console.error('[Admin] Failed to load resources:', err);
591
591
  this._render_error('Failed to load resources.', () => this._render_resources_section());
592
592
  this._set_status_text('Error loading resources');
593
593
  });
594
594
  }
595
595
 
596
- _render_routes_section() {
597
- this._render_loading('Loading routes...');
598
- this._set_status_text('Loading routes');
599
-
600
- return this._fetch_json('/api/admin/v1/routes')
601
- .then((routes) => {
602
- if (!this._dynamic_section) return;
603
-
604
- if (!Array.isArray(routes) || routes.length === 0) {
605
- this._render_empty('No routes found.');
606
- return;
607
- }
608
-
609
- const rows_data = routes.map((route) => {
610
- return [
611
- route.path || '',
612
- route.method || 'GET',
613
- route.type || 'route',
614
- route.handler || 'anonymous'
615
- ];
616
- });
617
-
618
- this._clear_control(this._dynamic_section);
619
- this._dynamic_section.add(
620
- this._create_table_panel('Routes', ['Path', 'Method', 'Type', 'Handler'], rows_data)
621
- );
622
-
623
- this._set_status_text('Loaded routes: ' + routes.length);
624
- })
625
- .catch((err) => {
626
- console.error('[Admin] Failed to load routes:', err);
596
+ _render_routes_section() {
597
+ this._render_loading('Loading routes...');
598
+ this._set_status_text('Loading routes');
599
+
600
+ return this._fetch_json('/api/admin/v1/routes')
601
+ .then((routes) => {
602
+ if (!this._dynamic_section) return;
603
+
604
+ if (!Array.isArray(routes) || routes.length === 0) {
605
+ this._render_empty('No routes found.');
606
+ return;
607
+ }
608
+
609
+ const rows_data = routes.map((route) => {
610
+ return [
611
+ route.path || '',
612
+ route.method || 'GET',
613
+ route.type || 'route',
614
+ route.handler || 'anonymous'
615
+ ];
616
+ });
617
+
618
+ this._clear_control(this._dynamic_section);
619
+ this._dynamic_section.add(
620
+ this._create_table_panel('Routes', ['Path', 'Method', 'Type', 'Handler'], rows_data)
621
+ );
622
+
623
+ this._set_status_text('Loaded routes: ' + routes.length);
624
+ })
625
+ .catch((err) => {
626
+ console.error('[Admin] Failed to load routes:', err);
627
627
  this._render_error('Failed to load routes.', () => this._render_routes_section());
628
628
  this._set_status_text('Error loading routes');
629
629
  });
630
630
  }
631
631
 
632
- _render_settings_section() {
633
- this._render_loading('Loading settings snapshot...');
634
- this._set_status_text('Loading settings snapshot');
635
-
636
- return this._fetch_json('/api/admin/v1/status')
637
- .then((status) => {
638
- if (!this._dynamic_section) return;
639
-
640
- const server_name = (status && status.server && status.server.name) || 'jsgui3-server';
641
- const node_version = (status && status.process && status.process.node_version) || 'unknown';
642
- const platform = (status && status.process && status.process.platform) || 'unknown';
643
- const arch = (status && status.process && status.process.arch) || 'unknown';
644
-
645
- const panel = this._create_panel('Settings (Read-only)');
646
- panel.add(this._create_kv_row('Server Name', server_name));
647
- panel.add(this._create_kv_row('Node Version', node_version));
648
- panel.add(this._create_kv_row('Platform', platform + ' / ' + arch));
649
-
650
- const logout_btn = new controls.button({ context: this.context, 'class': 'as-logout-btn' });
651
- logout_btn.dom.attributes.type = 'button';
652
- logout_btn.add('Log Out');
653
- logout_btn.on('click', () => {
654
- fetch('/api/admin/v1/auth/logout', {
655
- method: 'POST',
656
- credentials: 'same-origin'
657
- }).finally(() => {
658
- this._redirect_to_login();
659
- });
660
- });
661
- panel.add(logout_btn);
662
-
663
- const muted = new controls.div({ context: this.context, 'class': 'as-muted' });
664
- muted.add('Authentication and write-actions are intentionally not enabled yet.');
665
- panel.add(muted);
666
-
667
- this._clear_control(this._dynamic_section);
668
- this._dynamic_section.add(panel);
669
-
670
- this._set_status_text('Loaded settings snapshot');
671
- })
672
- .catch((err) => {
673
- console.error('[Admin] Failed to load settings snapshot:', err);
632
+ _render_settings_section() {
633
+ this._render_loading('Loading settings snapshot...');
634
+ this._set_status_text('Loading settings snapshot');
635
+
636
+ return this._fetch_json('/api/admin/v1/status')
637
+ .then((status) => {
638
+ if (!this._dynamic_section) return;
639
+
640
+ const server_name = (status && status.server && status.server.name) || 'jsgui3-server';
641
+ const node_version = (status && status.process && status.process.node_version) || 'unknown';
642
+ const platform = (status && status.process && status.process.platform) || 'unknown';
643
+ const arch = (status && status.process && status.process.arch) || 'unknown';
644
+
645
+ const panel = this._create_panel('Settings (Read-only)');
646
+ panel.add(this._create_kv_row('Server Name', server_name));
647
+ panel.add(this._create_kv_row('Node Version', node_version));
648
+ panel.add(this._create_kv_row('Platform', platform + ' / ' + arch));
649
+
650
+ const logout_btn = new controls.button({ context: this.context, 'class': 'as-logout-btn' });
651
+ logout_btn.dom.attributes.type = 'button';
652
+ logout_btn.add('Log Out');
653
+ logout_btn.on('click', () => {
654
+ fetch('/api/admin/v1/auth/logout', {
655
+ method: 'POST',
656
+ credentials: 'same-origin'
657
+ }).finally(() => {
658
+ this._redirect_to_login();
659
+ });
660
+ });
661
+ panel.add(logout_btn);
662
+
663
+ const muted = new controls.div({ context: this.context, 'class': 'as-muted' });
664
+ muted.add('Authentication and write-actions are intentionally not enabled yet.');
665
+ panel.add(muted);
666
+
667
+ this._clear_control(this._dynamic_section);
668
+ this._dynamic_section.add(panel);
669
+
670
+ this._set_status_text('Loaded settings snapshot');
671
+ })
672
+ .catch((err) => {
673
+ console.error('[Admin] Failed to load settings snapshot:', err);
674
674
  this._render_error('Failed to load settings snapshot.', () => this._render_settings_section());
675
675
  this._set_status_text('Error loading settings snapshot');
676
- });
677
- }
678
-
679
- _redirect_to_login() {
680
- if (typeof window !== 'undefined' && window.location) {
681
- window.location.href = '/admin/v1/login';
682
- }
683
- }
684
-
685
- _fetch_status() {
686
- return this._fetch_json('/api/admin/v1/status')
687
- .then(data => {
688
- this._apply_status(data);
689
- this._set_status_text('Connected — data loaded');
690
- })
691
- .catch(err => {
676
+ });
677
+ }
678
+
679
+ _redirect_to_login() {
680
+ if (typeof window !== 'undefined' && window.location) {
681
+ window.location.href = '/admin/v1/login';
682
+ }
683
+ }
684
+
685
+ _fetch_status() {
686
+ return this._fetch_json('/api/admin/v1/status')
687
+ .then(data => {
688
+ this._apply_status(data);
689
+ this._set_status_text('Connected — data loaded');
690
+ })
691
+ .catch(err => {
692
692
  console.error('[Admin] Failed to fetch status:', err);
693
693
  this._set_status_text('Error loading status');
694
694
  });
@@ -798,22 +798,22 @@ class Admin_Shell extends Active_HTML_Document {
798
798
  }
799
799
  });
800
800
 
801
- es.addEventListener('open', () => {
802
- // Reset backoff on successful connection
803
- this._sse_backoff = 1000;
804
- this._set_status_text('Live — SSE connected');
805
- if (this._status_dot) {
806
- this._status_dot.add_class('online');
807
- this._status_dot.remove_class('offline');
808
- }
809
- });
810
-
811
- es.addEventListener('error', () => {
812
- this._set_status_text('SSE disconnected — reconnecting...');
813
- if (this._status_dot) {
814
- this._status_dot.add_class('offline');
815
- this._status_dot.remove_class('online');
816
- }
801
+ es.addEventListener('open', () => {
802
+ // Reset backoff on successful connection
803
+ this._sse_backoff = 1000;
804
+ this._set_status_text('Live — SSE connected');
805
+ if (this._status_dot) {
806
+ this._status_dot.add_class('online');
807
+ this._status_dot.remove_class('offline');
808
+ }
809
+ });
810
+
811
+ es.addEventListener('error', () => {
812
+ this._set_status_text('SSE disconnected — reconnecting...');
813
+ if (this._status_dot) {
814
+ this._status_dot.add_class('offline');
815
+ this._status_dot.remove_class('online');
816
+ }
817
817
 
818
818
  // Close and reconnect with backoff
819
819
  es.close();
@@ -892,9 +892,9 @@ class Admin_Shell extends Active_HTML_Document {
892
892
  this._set_status_text('Live — last update ' + hh + ':' + mm + ':' + ss);
893
893
  }
894
894
 
895
- _set_status_text(text) {
896
- this._set_control_text(this._status_text, text);
897
- }
895
+ _set_status_text(text) {
896
+ this._set_control_text(this._status_text, text);
897
+ }
898
898
 
899
899
  // ─── Custom Sections (Extensibility) ─────────────────
900
900
 
@@ -904,91 +904,91 @@ class Admin_Shell extends Active_HTML_Document {
904
904
  * click handler that fetches data from its api_path and renders
905
905
  * it using automatic table/key-value rendering.
906
906
  */
907
- _load_custom_sections() {
908
- this._custom_sections_list = [];
909
-
910
- return this._fetch_json('/api/admin/v1/custom-sections')
911
- .then(sections => {
912
- this._remove_custom_nav_items();
913
- if (!Array.isArray(sections) || sections.length === 0) return;
914
-
915
- this._custom_sections_list = sections;
916
- if (!this._nav) return;
917
-
918
- this._ensure_custom_nav_separator();
919
-
920
- sections.forEach(section => {
921
- const section_id = 'custom:' + section.id;
922
- const item = this._add_nav_item(
923
- this._nav,
924
- section.label || section.id,
925
- section_id,
926
- false,
927
- {
928
- icon: section.icon || '',
929
- title: section.label || section.id,
930
- is_custom: true
931
- }
932
- );
933
- if (this.__active && typeof item.activate === 'function') {
934
- item.activate();
935
- }
936
- });
937
- })
938
- .catch(err => {
939
- console.warn('[Admin] Failed to load custom sections:', err);
940
- });
941
- }
942
-
943
- _remove_custom_nav_items() {
944
- const custom_ids = this._custom_nav_items.map((custom_item) => custom_item.id);
945
- this._custom_nav_items.forEach((custom_item) => {
946
- if (custom_item.control && typeof custom_item.control.remove === 'function') {
947
- custom_item.control.remove();
948
- }
949
- });
950
- this._custom_nav_items = [];
951
-
952
- this._nav_items = this._nav_items.filter((nav_item) => !nav_item.is_custom);
953
- custom_ids.forEach((custom_id) => {
954
- delete this._section_labels[custom_id];
955
- });
956
-
957
- if (this._custom_nav_separator && typeof this._custom_nav_separator.remove === 'function') {
958
- this._custom_nav_separator.remove();
959
- this._custom_nav_separator = null;
960
- }
961
- }
962
-
963
- _ensure_custom_nav_separator() {
964
- if (this._custom_nav_separator || !this._nav) return;
965
- const separator = new controls.div({ context: this.context, 'class': 'as-nav-separator' });
966
- this._nav.add(separator);
967
- this._custom_nav_separator = separator;
968
- if (this.__active && typeof separator.activate === 'function') {
969
- separator.activate();
970
- }
971
- }
907
+ _load_custom_sections() {
908
+ this._custom_sections_list = [];
909
+
910
+ return this._fetch_json('/api/admin/v1/custom-sections')
911
+ .then(sections => {
912
+ this._remove_custom_nav_items();
913
+ if (!Array.isArray(sections) || sections.length === 0) return;
914
+
915
+ this._custom_sections_list = sections;
916
+ if (!this._nav) return;
917
+
918
+ this._ensure_custom_nav_separator();
919
+
920
+ sections.forEach(section => {
921
+ const section_id = 'custom:' + section.id;
922
+ const item = this._add_nav_item(
923
+ this._nav,
924
+ section.label || section.id,
925
+ section_id,
926
+ false,
927
+ {
928
+ icon: section.icon || '',
929
+ title: section.label || section.id,
930
+ is_custom: true
931
+ }
932
+ );
933
+ if (this.__active && typeof item.activate === 'function') {
934
+ item.activate();
935
+ }
936
+ });
937
+ })
938
+ .catch(err => {
939
+ console.warn('[Admin] Failed to load custom sections:', err);
940
+ });
941
+ }
942
+
943
+ _remove_custom_nav_items() {
944
+ const custom_ids = this._custom_nav_items.map((custom_item) => custom_item.id);
945
+ this._custom_nav_items.forEach((custom_item) => {
946
+ if (custom_item.control && typeof custom_item.control.remove === 'function') {
947
+ custom_item.control.remove();
948
+ }
949
+ });
950
+ this._custom_nav_items = [];
951
+
952
+ this._nav_items = this._nav_items.filter((nav_item) => !nav_item.is_custom);
953
+ custom_ids.forEach((custom_id) => {
954
+ delete this._section_labels[custom_id];
955
+ });
956
+
957
+ if (this._custom_nav_separator && typeof this._custom_nav_separator.remove === 'function') {
958
+ this._custom_nav_separator.remove();
959
+ this._custom_nav_separator = null;
960
+ }
961
+ }
962
+
963
+ _ensure_custom_nav_separator() {
964
+ if (this._custom_nav_separator || !this._nav) return;
965
+ const separator = new controls.div({ context: this.context, 'class': 'as-nav-separator' });
966
+ this._nav.add(separator);
967
+ this._custom_nav_separator = separator;
968
+ if (this.__active && typeof separator.activate === 'function') {
969
+ separator.activate();
970
+ }
971
+ }
972
972
 
973
973
  /**
974
974
  * Render a custom section by fetching its data endpoint.
975
975
  * Arrays are rendered as tables, objects as key-value panels,
976
976
  * scalars as plain text.
977
977
  */
978
- _render_custom_section(section) {
979
- if (!this._dashboard_section || !this._dynamic_section) return Promise.resolve();
980
-
981
- this._dashboard_section.add_class('hidden');
982
- this._dynamic_section.remove_class('hidden');
983
-
984
- this._render_loading('Loading ' + section.label + '...');
985
- this._set_status_text('Loading ' + section.label);
986
-
987
- return this._fetch_json(section.api_path)
988
- .then(data => {
989
- this._render_custom_section_data(section, data);
990
- this._set_status_text('Loaded ' + section.label);
991
- })
978
+ _render_custom_section(section) {
979
+ if (!this._dashboard_section || !this._dynamic_section) return Promise.resolve();
980
+
981
+ this._dashboard_section.add_class('hidden');
982
+ this._dynamic_section.remove_class('hidden');
983
+
984
+ this._render_loading('Loading ' + section.label + '...');
985
+ this._set_status_text('Loading ' + section.label);
986
+
987
+ return this._fetch_json(section.api_path)
988
+ .then(data => {
989
+ this._render_custom_section_data(section, data);
990
+ this._set_status_text('Loaded ' + section.label);
991
+ })
992
992
  .catch(err => {
993
993
  console.error('[Admin] Failed to load custom section:', err);
994
994
  this._render_error('Failed to load ' + section.label + '.', () => this._render_custom_section(section));
@@ -999,47 +999,47 @@ class Admin_Shell extends Active_HTML_Document {
999
999
  /**
1000
1000
  * Auto-render data based on shape:
1001
1001
  * - Array of objects → table
1002
- * - Object → key-value panel
1003
- * - Scalar → plain text
1004
- */
1005
- _render_custom_section_data(section, data) {
1006
- if (!this._dynamic_section) return;
1007
-
1008
- if (Array.isArray(data)) {
1009
- if (data.length === 0) {
1010
- this._render_empty('No data for ' + section.label + '.');
1011
- return;
1012
- }
1013
- const normalized_first_row = (data[0] && typeof data[0] === 'object')
1014
- ? data[0]
1015
- : { value: data[0] };
1016
- const keys = Object.keys(normalized_first_row);
1017
- const rows_data = data.map((row) => {
1018
- const normalized_row = (row && typeof row === 'object') ? row : { value: row };
1019
- return keys.map((key) => normalized_row[key]);
1020
- });
1021
-
1022
- this._clear_control(this._dynamic_section);
1023
- this._dynamic_section.add(
1024
- this._create_table_panel(section.label, keys, rows_data)
1025
- );
1026
- } else if (data && typeof data === 'object') {
1027
- const panel = this._create_panel(section.label);
1028
- Object.entries(data).forEach((entry) => {
1029
- panel.add(this._create_kv_row(entry[0], entry[1]));
1030
- });
1031
- this._clear_control(this._dynamic_section);
1032
- this._dynamic_section.add(panel);
1033
- } else {
1034
- const panel = this._create_panel();
1035
- const text = new controls.div({ context: this.context, 'class': 'as-muted' });
1036
- text.add(this._stringify_value(data));
1037
- panel.add(text);
1038
- this._clear_control(this._dynamic_section);
1039
- this._dynamic_section.add(panel);
1040
- }
1041
- }
1042
- }
1002
+ * - Object → key-value panel
1003
+ * - Scalar → plain text
1004
+ */
1005
+ _render_custom_section_data(section, data) {
1006
+ if (!this._dynamic_section) return;
1007
+
1008
+ if (Array.isArray(data)) {
1009
+ if (data.length === 0) {
1010
+ this._render_empty('No data for ' + section.label + '.');
1011
+ return;
1012
+ }
1013
+ const normalized_first_row = (data[0] && typeof data[0] === 'object')
1014
+ ? data[0]
1015
+ : { value: data[0] };
1016
+ const keys = Object.keys(normalized_first_row);
1017
+ const rows_data = data.map((row) => {
1018
+ const normalized_row = (row && typeof row === 'object') ? row : { value: row };
1019
+ return keys.map((key) => normalized_row[key]);
1020
+ });
1021
+
1022
+ this._clear_control(this._dynamic_section);
1023
+ this._dynamic_section.add(
1024
+ this._create_table_panel(section.label, keys, rows_data)
1025
+ );
1026
+ } else if (data && typeof data === 'object') {
1027
+ const panel = this._create_panel(section.label);
1028
+ Object.entries(data).forEach((entry) => {
1029
+ panel.add(this._create_kv_row(entry[0], entry[1]));
1030
+ });
1031
+ this._clear_control(this._dynamic_section);
1032
+ this._dynamic_section.add(panel);
1033
+ } else {
1034
+ const panel = this._create_panel();
1035
+ const text = new controls.div({ context: this.context, 'class': 'as-muted' });
1036
+ text.add(this._stringify_value(data));
1037
+ panel.add(text);
1038
+ this._clear_control(this._dynamic_section);
1039
+ this._dynamic_section.add(panel);
1040
+ }
1041
+ }
1042
+ }
1043
1043
 
1044
1044
  Admin_Shell.css = `
1045
1045
  /* ─── Reset & Root ───────────────────────────────────────── */