@datalabz_/dashboard-kit 5.0.1 → 6.0.0

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 (4) hide show
  1. package/package.json +2 -1
  2. package/v4.js +1 -1
  3. package/v5.js +1 -1
  4. package/v6.js +2000 -0
package/v6.js ADDED
@@ -0,0 +1,2000 @@
1
+ /* DashboardKit v6.js */
2
+ (function (global) {
3
+ 'use strict';
4
+
5
+ // Default gateway. The parent may name its own via `endpoint` in
6
+ // CUSTOM_DASHBOARDS_PARAMS (each BackOffice has its own); omitted → this one.
7
+ const DEFAULT_ENDPOINT = "https://aidqgateway.draftplaza.com/aidq";
8
+ // Retry is off: 1 = a single attempt. Set to 2 to re-enable one retry for
9
+ // whatever _isRetryable allows (no response, 5xx, 200 without rows).
10
+ const MAX_ATTEMPTS = 1;
11
+
12
+ // ── State ────────────────────────────────────────────────────────────────────
13
+ let _authToken = '';
14
+ let _endpoint = DEFAULT_ENDPOINT;
15
+ let _dateFromIso = '';
16
+ let _dateToIso = '';
17
+ let _timezone = '';
18
+ let _hierarchyId = null;
19
+ let _hasBooted = false;
20
+ let _activeTabId = '';
21
+ let _sections = []; // { id, label, load, loaded, errors }
22
+ let _title = '';
23
+ let _gitVer = '572aefd'; // replaced at deploy: sed -i "s/572aefd/$(git rev-parse --short HEAD)/g" v6.js
24
+ let _calls = []; // System Info call log
25
+ let _sectionErrors = {}; // sectionId → error count
26
+ let _generation = 0;
27
+ let _ttEl = null;
28
+
29
+ // ── Inject CSS ───────────────────────────────────────────────────────────────
30
+ // The kit carries its own CSS: Claude artifacts may load scripts from jsDelivr but not
31
+ // stylesheets, and a <link> there is blocked without any error. Appended to <head> as it
32
+ // stands while this script runs, not prepended: that puts it after whatever the page already
33
+ // holds — claude.ai's artifact wrapper sets the body's font and colours there, which the kit
34
+ // must override — and before anything parsed later, so a dashboard's own <style> still
35
+ // overrides the kit. The id keeps a page that loads the kit twice from adding it twice.
36
+ const DK_KIT_V6_CSS = `/* DashboardKit v6 — inner dashboard stylesheet */
37
+
38
+ /* ── Tokens ─────────────────────────────────────────────────────────────────── */
39
+ :root {
40
+ --bg: #0f1115;
41
+ --surface: #161922;
42
+ --raised: #1c2030;
43
+ --border: #2a2f3e;
44
+ --row_even: #14171f;
45
+ --row_odd: #1a1d27;
46
+ --text: #e6e8ee;
47
+ --muted: #8a90a3;
48
+ --brand: #0b8f84;
49
+ --info: #3498db;
50
+ --ok: #2ea043;
51
+ --warn: #f7ad2b;
52
+ --err: #ff4d4f;
53
+ }
54
+ :root[data-theme="light"] {
55
+ --bg: #ffffff;
56
+ --surface: #f7f8fa;
57
+ --raised: #eef1f5;
58
+ --border: #d9d9d9;
59
+ --row_even: #ffffff;
60
+ --row_odd: #f5f6f8;
61
+ --text: #000000;
62
+ --muted: #666666;
63
+ --brand: #0b8f84;
64
+ --info: #1677ff;
65
+ --ok: #1a7a3a;
66
+ --warn: #ad6800;
67
+ --err: #cf1322;
68
+ }
69
+
70
+ /* ── Scrollbar ───────────────────────────────────────────────────────────────── */
71
+ * {
72
+ scrollbar-width: thin;
73
+ scrollbar-color: var(--border) transparent;
74
+ }
75
+ ::-webkit-scrollbar {
76
+ width: 6px;
77
+ height: 6px;
78
+ }
79
+ ::-webkit-scrollbar-track {
80
+ background: transparent;
81
+ }
82
+ ::-webkit-scrollbar-thumb {
83
+ background: var(--border);
84
+ border-radius: 3px;
85
+ }
86
+ ::-webkit-scrollbar-thumb:hover {
87
+ background: var(--muted);
88
+ }
89
+
90
+ /* ── Reset + base ────────────────────────────────────────────────────────────── */
91
+ *,
92
+ *::before,
93
+ *::after {
94
+ box-sizing: border-box;
95
+ }
96
+ [hidden] {
97
+ display: none !important;
98
+ }
99
+ html,
100
+ body {
101
+ margin: 0;
102
+ padding: 0;
103
+ height: 100%;
104
+ background: var(--bg);
105
+ color: var(--text);
106
+ font:
107
+ 13px/1.45 "Roboto",
108
+ system-ui,
109
+ -apple-system,
110
+ "Segoe UI",
111
+ Helvetica,
112
+ Arial,
113
+ sans-serif;
114
+ }
115
+ body {
116
+ display: flex;
117
+ flex-direction: column;
118
+ min-height: 100vh;
119
+ }
120
+
121
+ /* ── Header ──────────────────────────────────────────────────────────────────── */
122
+ header {
123
+ background: var(--surface);
124
+ border-bottom: 1px solid var(--border);
125
+ padding: 7px 14px;
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 10px;
129
+ flex-wrap: wrap;
130
+ flex-shrink: 0;
131
+ }
132
+ .dk-title {
133
+ font-weight: 600;
134
+ font-size: 13px;
135
+ white-space: nowrap;
136
+ }
137
+ .dk-spacer {
138
+ flex: 1;
139
+ min-width: 8px;
140
+ }
141
+ header label {
142
+ display: inline-flex;
143
+ align-items: center;
144
+ gap: 5px;
145
+ color: var(--muted);
146
+ font-size: 12px;
147
+ }
148
+ header input,
149
+ header select {
150
+ background: var(--raised);
151
+ color: var(--text);
152
+ border: 1px solid var(--border);
153
+ border-radius: 4px;
154
+ padding: 0 7px;
155
+ height: 24px;
156
+ font: inherit;
157
+ }
158
+ header button {
159
+ background: var(--brand);
160
+ color: #fff;
161
+ border: 1px solid var(--brand);
162
+ border-radius: 4px;
163
+ padding: 0 10px;
164
+ height: 24px;
165
+ font: inherit;
166
+ font-size: 12px;
167
+ cursor: pointer;
168
+ }
169
+ header button:hover:not(:disabled) {
170
+ background: color-mix(in srgb, var(--brand) 85%, white);
171
+ }
172
+ header button:disabled,
173
+ header input:disabled,
174
+ header select:disabled {
175
+ opacity: 0.5;
176
+ cursor: default;
177
+ }
178
+
179
+ /* Hidden everywhere, not just the header: they invite the click-to-nudge the wheel
180
+ guard in v4.js prevents, and look like a fault inside a 24px control. */
181
+ input[type="number"] {
182
+ appearance: textfield;
183
+ -moz-appearance: textfield;
184
+ }
185
+ input[type="number"]::-webkit-outer-spin-button,
186
+ input[type="number"]::-webkit-inner-spin-button {
187
+ -webkit-appearance: none;
188
+ margin: 0;
189
+ }
190
+
191
+ /* ── Status pill ─────────────────────────────────────────────────────────────── */
192
+ .dk-pill {
193
+ padding: 3px 10px;
194
+ border-radius: 999px;
195
+ font-size: 11px;
196
+ white-space: nowrap;
197
+ background: color-mix(in srgb, var(--muted) 20%, transparent);
198
+ color: var(--muted);
199
+ }
200
+ .dk-pill.loading {
201
+ background: color-mix(in srgb, var(--info) 18%, transparent);
202
+ color: var(--info);
203
+ }
204
+ .dk-pill.ok {
205
+ background: color-mix(in srgb, var(--ok) 18%, transparent);
206
+ color: var(--ok);
207
+ }
208
+ .dk-pill.error {
209
+ background: color-mix(in srgb, var(--err) 18%, transparent);
210
+ color: var(--err);
211
+ }
212
+
213
+ /* ── Tabs ────────────────────────────────────────────────────────────────────── */
214
+ nav.dk-tabs {
215
+ background: var(--surface);
216
+ border-bottom: 1px solid var(--border);
217
+ display: flex;
218
+ padding: 0 14px;
219
+ flex-shrink: 0;
220
+ position: sticky;
221
+ top: 0;
222
+ z-index: 15;
223
+ overflow-x: auto;
224
+ }
225
+ nav.dk-tabs button {
226
+ background: transparent;
227
+ color: var(--muted);
228
+ border: none;
229
+ border-bottom: 2px solid transparent;
230
+ padding: 8px 12px;
231
+ font: inherit;
232
+ font-size: 12px;
233
+ cursor: pointer;
234
+ white-space: nowrap;
235
+ display: inline-flex;
236
+ align-items: center;
237
+ gap: 4px;
238
+ }
239
+ nav.dk-tabs button:hover {
240
+ color: var(--text);
241
+ }
242
+ nav.dk-tabs button.active {
243
+ color: var(--text);
244
+ border-bottom-color: var(--brand);
245
+ }
246
+ .dk-badge {
247
+ background: color-mix(in srgb, var(--err) 18%, transparent);
248
+ color: var(--err);
249
+ border-radius: 999px;
250
+ font-size: 10px;
251
+ padding: 0 5px;
252
+ min-width: 16px;
253
+ text-align: center;
254
+ }
255
+
256
+ /* ── Panels ──────────────────────────────────────────────────────────────────── */
257
+ .dk-content {
258
+ flex: 1;
259
+ overflow-y: auto;
260
+ padding: 14px;
261
+ }
262
+ .dk-panel {
263
+ display: none;
264
+ }
265
+ .dk-panel.active {
266
+ display: block;
267
+ }
268
+
269
+ /* ── Section states ──────────────────────────────────────────────────────────── */
270
+ .dk-loading {
271
+ padding: 32px;
272
+ text-align: center;
273
+ color: var(--muted);
274
+ display: flex;
275
+ align-items: center;
276
+ justify-content: center;
277
+ gap: 8px;
278
+ }
279
+ .dk-spinner {
280
+ display: inline-block;
281
+ width: 16px;
282
+ height: 16px;
283
+ flex-shrink: 0;
284
+ border: 2px solid var(--border);
285
+ border-top-color: var(--brand);
286
+ border-radius: 50%;
287
+ animation: dk-spin 0.7s linear infinite;
288
+ }
289
+ @keyframes dk-spin {
290
+ to {
291
+ transform: rotate(360deg);
292
+ }
293
+ }
294
+ .dk-empty {
295
+ padding: 32px;
296
+ text-align: center;
297
+ color: var(--muted);
298
+ font-size: 13px;
299
+ }
300
+ .dk-error {
301
+ display: flex;
302
+ align-items: center;
303
+ justify-content: flex-start;
304
+ align-items: center;
305
+ flex-wrap: wrap;
306
+ gap: 10px 16px;
307
+ background: color-mix(in srgb, var(--err) 12%, transparent);
308
+ color: var(--err);
309
+ border: 1px solid color-mix(in srgb, var(--err) 35%, transparent);
310
+ border-radius: 6px;
311
+ padding: 12px 16px;
312
+ margin-bottom: 12px;
313
+ }
314
+ .dk-error-report-btn {
315
+ display: none;
316
+ align-items: center;
317
+ gap: 6px;
318
+ background: var(--brand);
319
+ color: #fff;
320
+ border: 1px solid var(--brand);
321
+ border-radius: 6px;
322
+ padding: 0 14px;
323
+ height: 30px;
324
+ font: inherit;
325
+ font-size: 13px;
326
+ font-weight: 600;
327
+ white-space: nowrap;
328
+ cursor: pointer;
329
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
330
+ }
331
+ /* Only the chat artifact panel sends CHAT_MODE — hidden for standalone files
332
+ and the BackOffice wrapper, where there's no chat to send the error to. */
333
+ :root[data-chat-mode="true"] .dk-error-report-btn {
334
+ display: inline-flex;
335
+ }
336
+ .dk-error-report-btn:hover:not(:disabled) {
337
+ background: color-mix(in srgb, var(--brand) 85%, white);
338
+ }
339
+ .dk-error-report-btn:disabled {
340
+ opacity: 0.6;
341
+ cursor: default;
342
+ }
343
+
344
+ /* ── Table ───────────────────────────────────────────────────────────────────── */
345
+ .dk-table-wrap {
346
+ overflow-x: auto;
347
+ margin-bottom: 14px;
348
+ }
349
+ .dk-toolbar {
350
+ display: flex;
351
+ align-items: center;
352
+ height: 24px;
353
+ }
354
+ .dk-table-title {
355
+ font-size: 12px;
356
+ font-weight: 600;
357
+ }
358
+ table.dk-table {
359
+ width: 100%;
360
+ border-collapse: collapse;
361
+ font-size: 12px;
362
+ white-space: nowrap;
363
+ }
364
+ table.dk-table thead th {
365
+ background: var(--raised);
366
+ color: var(--muted);
367
+ font-weight: 500;
368
+ padding: 6px 10px;
369
+ text-align: left;
370
+ border: 1px solid var(--border);
371
+ position: sticky;
372
+ top: 0;
373
+ z-index: 2;
374
+ }
375
+ table.dk-table thead th .dk-th-inner {
376
+ display: inline-flex;
377
+ align-items: center;
378
+ gap: 4px;
379
+ }
380
+ table.dk-table thead th.num {
381
+ text-align: left;
382
+ }
383
+ table.dk-table tbody td {
384
+ padding: 5px 10px;
385
+ border: 1px solid var(--border);
386
+ }
387
+ table.dk-table tbody td.num {
388
+ text-align: left;
389
+ font-variant-numeric: tabular-nums;
390
+ font-family: monospace, monospace;
391
+ }
392
+ table.dk-table tbody tr:nth-child(even) td {
393
+ background: var(--row_even);
394
+ }
395
+ table.dk-table tbody tr:nth-child(odd) td {
396
+ background: var(--row_odd);
397
+ }
398
+ table.dk-table tbody tr:hover td {
399
+ background: color-mix(in srgb, var(--brand) 15%, transparent);
400
+ }
401
+ table.dk-table tbody tr.dk-totals-row td {
402
+ background: color-mix(in srgb, var(--brand) 25%, var(--surface));
403
+ font-weight: 600;
404
+ }
405
+ .dk-csv-btn {
406
+ background: transparent;
407
+ color: var(--muted);
408
+ border: 1px solid var(--border);
409
+ border-radius: 3px;
410
+ padding: 0 7px;
411
+ height: 20px;
412
+ font-size: 11px;
413
+ cursor: pointer;
414
+ margin-bottom: 2px;
415
+ margin-left: auto;
416
+ }
417
+ .dk-csv-btn:hover {
418
+ color: var(--text);
419
+ border-color: var(--brand);
420
+ }
421
+
422
+ /* ── Table column sorting ─────────────────────────────────────────────────────── */
423
+ table.dk-table thead th[data-dk-sort-key] {
424
+ cursor: pointer;
425
+ user-select: none;
426
+ position: relative;
427
+ padding-right: 20px;
428
+ }
429
+ table.dk-table thead th[data-dk-sort-key]:hover {
430
+ color: var(--text);
431
+ background: color-mix(in srgb, var(--brand) 10%, var(--raised));
432
+ }
433
+ table.dk-table thead th[data-dk-sort-key]::after {
434
+ content: "⇅";
435
+ font-size: 9px;
436
+ opacity: 0.3;
437
+ position: absolute;
438
+ right: 6px;
439
+ top: 50%;
440
+ transform: translateY(-50%);
441
+ }
442
+ table.dk-table thead th.dk-sort-asc::after {
443
+ content: "▲";
444
+ opacity: 1;
445
+ color: var(--info);
446
+ }
447
+ table.dk-table thead th.dk-sort-desc::after {
448
+ content: "▼";
449
+ opacity: 1;
450
+ color: var(--info);
451
+ }
452
+
453
+ /* ── KPI strip ───────────────────────────────────────────────────────────────── */
454
+ .dk-kpi-strip {
455
+ display: flex;
456
+ flex-wrap: wrap;
457
+ gap: 10px;
458
+ margin-bottom: 14px;
459
+ }
460
+ .dk-kpi {
461
+ background: var(--surface);
462
+ border: 1px solid var(--border);
463
+ border-radius: 6px;
464
+ padding: 10px 14px;
465
+ flex: 1;
466
+ position: relative;
467
+ }
468
+ .dk-kpi-label {
469
+ display: flex;
470
+ align-items: center;
471
+ gap: 4px;
472
+ color: var(--muted);
473
+ font-size: 11px;
474
+ margin-bottom: 4px;
475
+ white-space: nowrap;
476
+ }
477
+ .dk-kpi-value {
478
+ font-size: 15px;
479
+ font-weight: 600;
480
+ font-variant-numeric: tabular-nums;
481
+ white-space: nowrap;
482
+ }
483
+
484
+ /* A short visible line under an item's value (KPI card, info row, totals field). */
485
+ .dk-sub {
486
+ display: block;
487
+ color: var(--muted);
488
+ font-size: 11px;
489
+ font-weight: 400;
490
+ line-height: 1.35;
491
+ margin-top: 2px;
492
+ }
493
+
494
+ /* ── Tooltip icon ─────────────────────────────────────────────────────────────── */
495
+ .dk-tip {
496
+ display: inline-flex;
497
+ align-items: center;
498
+ justify-content: center;
499
+ width: 10px;
500
+ height: 10px;
501
+ border-radius: 50%;
502
+ border: 1px solid var(--info);
503
+ color: var(--info);
504
+ font-size: 8px;
505
+ font-style: italic;
506
+ font-family: serif;
507
+ line-height: 1;
508
+ cursor: default;
509
+ flex-shrink: 0;
510
+ vertical-align: middle;
511
+ transition:
512
+ color 0.12s,
513
+ border-color 0.12s;
514
+ }
515
+ .dk-tip:hover {
516
+ color: color-mix(in srgb, var(--info) 70%, white);
517
+ border-color: color-mix(in srgb, var(--info) 70%, white);
518
+ }
519
+ #dk-tt {
520
+ position: fixed;
521
+ z-index: 9999;
522
+ background: var(--text);
523
+ color: var(--bg);
524
+ padding: 4px 10px;
525
+ border-radius: 4px;
526
+ font-size: 11px;
527
+ font-weight: 400;
528
+ white-space: nowrap;
529
+ pointer-events: none;
530
+ opacity: 0;
531
+ transition: opacity 0.12s;
532
+ display: none;
533
+ }
534
+
535
+ /* ── Card ────────────────────────────────────────────────────────────────────── */
536
+ .dk-card {
537
+ background: var(--surface);
538
+ border: 1px solid var(--border);
539
+ border-radius: 6px;
540
+ padding: 12px;
541
+ margin-bottom: 12px;
542
+ }
543
+ .dk-card-header {
544
+ margin: -12px -12px 12px;
545
+ padding: 8px 12px;
546
+ border-bottom: 1px solid var(--border);
547
+ border-radius: 6px 6px 0 0;
548
+ font-size: 13px;
549
+ font-weight: 600;
550
+ }
551
+
552
+ /* ── Two-column layout ───────────────────────────────────────────────────────── */
553
+ .dk-two-col {
554
+ display: grid;
555
+ grid-template-columns: 1fr 1fr;
556
+ gap: 12px;
557
+ margin-bottom: 12px;
558
+ }
559
+ @media (max-width: 1000px) {
560
+ .dk-two-col {
561
+ grid-template-columns: 1fr;
562
+ }
563
+ }
564
+
565
+ /* ── Scrollable container ────────────────────────────────────────────────────── */
566
+ .dk-scroll {
567
+ max-height: 480px;
568
+ overflow: auto;
569
+ }
570
+
571
+ /* ── Totals strip ────────────────────────────────────────────────────────────── */
572
+ .dk-totals-strip {
573
+ display: flex;
574
+ flex-wrap: wrap;
575
+ gap: 18px;
576
+ justify-content: space-between;
577
+ background: var(--raised);
578
+ padding: 10px 12px;
579
+ border-radius: 6px;
580
+ border: 1px solid var(--border);
581
+ margin-bottom: 12px;
582
+ }
583
+ .dk-totals-strip .dk-field {
584
+ display: flex;
585
+ flex-direction: column;
586
+ }
587
+ .dk-totals-strip .dk-field-label {
588
+ display: flex;
589
+ align-items: center;
590
+ gap: 4px;
591
+ font-size: 11px;
592
+ color: var(--muted);
593
+ text-transform: uppercase;
594
+ letter-spacing: 0.5px;
595
+ }
596
+ .dk-totals-strip .dk-field-value {
597
+ font-size: 14px;
598
+ font-weight: 600;
599
+ font-variant-numeric: tabular-nums;
600
+ margin-top: 2px;
601
+ }
602
+
603
+ /* ── Info grid (key/value pairs) ─────────────────────────────────────────────── */
604
+ .dk-info-grid {
605
+ display: grid;
606
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
607
+ gap: 6px 14px;
608
+ margin-bottom: 8px;
609
+ }
610
+ .dk-info-row {
611
+ display: flex;
612
+ justify-content: space-between;
613
+ border-bottom: 1px solid var(--border);
614
+ padding: 4px 0;
615
+ font-size: 12px;
616
+ gap: 8px;
617
+ }
618
+ .dk-info-row .dk-lbl {
619
+ display: inline-flex;
620
+ align-items: center;
621
+ align-self: flex-start;
622
+ gap: 4px;
623
+ color: var(--muted);
624
+ white-space: nowrap;
625
+ }
626
+ .dk-info-row .dk-val {
627
+ font-family: monospace, monospace;
628
+ text-align: right;
629
+ word-break: break-word;
630
+ }
631
+
632
+ /* ── Semantic colors ─────────────────────────────────────────────────────────── */
633
+ .pos {
634
+ color: var(--ok);
635
+ }
636
+ .neg {
637
+ color: var(--err);
638
+ }
639
+
640
+ /* ── System Info ─────────────────────────────────────────────────────────────── */
641
+ .dk-sysinfo {
642
+ display: flex;
643
+ flex-direction: column;
644
+ gap: 14px;
645
+ }
646
+ .dk-sysinfo h3 {
647
+ font-size: 11px;
648
+ font-weight: 600;
649
+ color: var(--muted);
650
+ text-transform: uppercase;
651
+ letter-spacing: 0.05em;
652
+ margin: 0 0 8px;
653
+ }
654
+ .dk-sys-table {
655
+ width: 100%;
656
+ border-collapse: collapse;
657
+ font-size: 12px;
658
+ }
659
+ .dk-sys-table td {
660
+ padding: 4px 8px;
661
+ border-bottom: 1px solid var(--border);
662
+ vertical-align: top;
663
+ }
664
+ .dk-sys-table td:first-child {
665
+ color: var(--muted);
666
+ white-space: nowrap;
667
+ width: 190px;
668
+ }
669
+ .dk-cors-hint {
670
+ background: color-mix(in srgb, var(--warn) 12%, transparent);
671
+ border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent);
672
+ color: var(--warn);
673
+ border-radius: 4px;
674
+ padding: 10px 14px;
675
+ font-size: 12px;
676
+ }
677
+ details.dk-call {
678
+ background: var(--surface);
679
+ border: 1px solid var(--border);
680
+ border-radius: 4px;
681
+ margin-bottom: 4px;
682
+ }
683
+ details.dk-call summary {
684
+ padding: 7px 10px;
685
+ cursor: pointer;
686
+ font-size: 12px;
687
+ display: flex;
688
+ gap: 10px;
689
+ align-items: center;
690
+ flex-wrap: wrap;
691
+ list-style: none;
692
+ }
693
+ details.dk-call summary::-webkit-details-marker {
694
+ display: none;
695
+ }
696
+ .dk-call-body {
697
+ padding: 10px;
698
+ border-top: 1px solid var(--border);
699
+ }
700
+ .dk-call-body pre {
701
+ margin: 4px 0;
702
+ white-space: pre-wrap;
703
+ word-break: break-all;
704
+ font-size: 11px;
705
+ color: var(--muted);
706
+ background: var(--raised);
707
+ padding: 6px 8px;
708
+ border-radius: 3px;
709
+ }
710
+ .dk-resp-head {
711
+ font-size: 11px;
712
+ color: var(--muted);
713
+ margin: 8px 0 4px;
714
+ display: flex;
715
+ align-items: center;
716
+ justify-content: flex-start;
717
+ gap: 6px;
718
+ }
719
+ .dk-resp-toggle {
720
+ display: inline-flex;
721
+ align-items: center;
722
+ justify-content: center;
723
+ width: 14px;
724
+ height: 14px;
725
+ padding: 0;
726
+ border: 1px solid var(--border);
727
+ border-radius: 3px;
728
+ background: var(--surface);
729
+ font-size: 11px;
730
+ line-height: 1;
731
+ color: var(--muted);
732
+ cursor: pointer;
733
+ }
734
+ .dk-s-ok {
735
+ color: var(--ok);
736
+ font-weight: 600;
737
+ font-size: 11px;
738
+ }
739
+ .dk-s-err {
740
+ color: var(--err);
741
+ font-weight: 600;
742
+ font-size: 11px;
743
+ }
744
+ .dk-s-net {
745
+ color: var(--warn);
746
+ font-weight: 600;
747
+ font-size: 11px;
748
+ }
749
+
750
+ /* ── Wait message (before postMessage arrives) ───────────────────────────────── */
751
+ .dk-wait {
752
+ padding: 40px;
753
+ text-align: center;
754
+ color: var(--muted);
755
+ font-style: italic;
756
+ }
757
+ /*# sourceURL=dashboard-kit-v6.css */`;
758
+ if (!document.getElementById('dk-kit-css')) {
759
+ const style = document.createElement('style');
760
+ style.id = 'dk-kit-css';
761
+ style.textContent = DK_KIT_V6_CSS;
762
+ document.head.appendChild(style);
763
+ }
764
+
765
+ // ── Helpers ──────────────────────────────────────────────────────────────────
766
+ function esc(s) {
767
+ return String(s == null ? '' : s)
768
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
769
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;')
770
+ .replace(/'/g, '&#39;');
771
+ }
772
+
773
+ // Any DOM node, not only an Element: a Text node (createTextNode) is a natural thing to return,
774
+ // and would otherwise be printed as "[object Text]".
775
+ function _isElement(v) {
776
+ return !!v && typeof v === 'object' && typeof v.nodeType === 'number';
777
+ }
778
+
779
+ function fmtAmount(v) {
780
+ const n = parseFloat(v);
781
+ if (isNaN(n)) return '—';
782
+ return n.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,');
783
+ }
784
+
785
+ // No thousands grouping: this formats ids too.
786
+ function fmtInt(v) {
787
+ if (v == null) return '—';
788
+ return String(v);
789
+ }
790
+
791
+ // tz is type-checked: `format: formatDatetime` on a column puts the row object here.
792
+ function _fmtDate(iso, tz, withTime) {
793
+ if (!iso) return '—';
794
+ try {
795
+ const p = {};
796
+ new Intl.DateTimeFormat('en-CA', {
797
+ timeZone: (typeof tz === 'string' ? tz : '') || _timezone || 'UTC',
798
+ day: '2-digit', month: '2-digit', year: 'numeric',
799
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
800
+ }).formatToParts(new Date(iso)).forEach(function (x) { p[x.type] = x.value; });
801
+ const date = p.day + '.' + p.month + '.' + p.year;
802
+ return withTime ? date + ' ' + p.hour + ':' + p.minute + ':' + p.second : date;
803
+ } catch { return withTime ? iso : String(iso).slice(0, 10); }
804
+ }
805
+
806
+ const CALENDAR_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
807
+
808
+ // DD.MM.YYYY for a bare YYYY-MM-DD that names a real day; null otherwise, so anything else
809
+ // (2026-13-01, 2026-02-30) takes the existing path and formats exactly as it did before.
810
+ function _calendarDay(iso) {
811
+ const m = typeof iso === 'string' ? CALENDAR_DATE.exec(iso) : null;
812
+ if (!m) return null;
813
+ const t = new Date(Date.UTC(+m[1], m[2] - 1, +m[3]));
814
+ return t.getUTCMonth() === m[2] - 1 && t.getUTCDate() === +m[3] ? m[3] + '.' + m[2] + '.' + m[1] : null;
815
+ }
816
+
817
+ // A calendar date (a Postgres `date`, e.g. a birth date) names a day, not a moment:
818
+ // show that day, whatever the timezone. Instants still format in the report's timezone.
819
+ function fmtDate(iso, tz) {
820
+ return _calendarDay(iso) || _fmtDate(iso, tz, false);
821
+ }
822
+
823
+ // A calendar date has no time of day, so it is shown as the date alone; 00:00:00 would invent one.
824
+ function fmtDatetime(iso, tz) {
825
+ return _calendarDay(iso) || _fmtDate(iso, tz, true);
826
+ }
827
+
828
+ // Same type-check as _fmtDate.
829
+ function formatCurrency(amount, currency) {
830
+ const n = fmtAmount(amount);
831
+ return typeof currency === 'string' && currency ? `${currency} ${n}` : n;
832
+ }
833
+
834
+ // Same type-check as _fmtDate.
835
+ function formatPercent(v, decimals) {
836
+ const n = parseFloat(v);
837
+ if (isNaN(n)) return '—';
838
+ return n.toFixed(typeof decimals === 'number' ? decimals : 2) + '%';
839
+ }
840
+
841
+ function colorClass(v) {
842
+ const n = Number(v);
843
+ return n > 0 ? 'pos' : n < 0 ? 'neg' : '';
844
+ }
845
+
846
+ // ── Element slots ─────────────────────────────────────────────────────────────
847
+ // A format/value may return an element; it can't travel in the html string, so
848
+ // a placeholder goes in and is swapped for it after innerHTML.
849
+ let _slotSeq = 0;
850
+ function _slots() {
851
+ const pending = [];
852
+ return {
853
+ html: function (v, fallback) {
854
+ if (_isElement(v)) {
855
+ // Document-wide: a body and its totals row are two _slots() over one tbody.
856
+ const id = 'dk-s' + (_slotSeq++);
857
+ pending.push([id, v]);
858
+ return '<span data-dk-slot="' + id + '"></span>';
859
+ }
860
+ return esc(v == null ? (fallback == null ? '' : fallback) : v);
861
+ },
862
+ apply: function (scope) {
863
+ pending.forEach(function (p) {
864
+ const holder = scope.querySelector('[data-dk-slot="' + p[0] + '"]');
865
+ if (holder) holder.replaceWith(p[1]);
866
+ });
867
+ pending.length = 0;
868
+ },
869
+ };
870
+ }
871
+
872
+ // A format() or item value of '<button>…</button>' would show as literal text with nothing
873
+ // saying why. Only what the author supplies is checked, never a raw column value: data that
874
+ // happens to start with a tag must not take a section down.
875
+ const HTML_STRING = /^\s*<\/?[a-z][a-z0-9-]*(\s[^>]*)?\/?>/i;
876
+ function _refuseHtmlString(out, where) {
877
+ if (typeof out === 'string' && HTML_STRING.test(out)) {
878
+ throw new Error(where + ' returned an HTML string, which is shown as literal text — ' +
879
+ 'return an element (document.createElement) instead');
880
+ }
881
+ }
882
+
883
+ // ── Tooltip ──────────────────────────────────────────────────────────────────
884
+ function _initTooltip() {
885
+ if (_ttEl) return;
886
+ _ttEl = document.createElement('div');
887
+ _ttEl.id = 'dk-tt';
888
+ document.body.appendChild(_ttEl);
889
+ let _cur = null;
890
+ document.addEventListener('mouseover', function (e) {
891
+ const tip = e.target.closest('.dk-tip');
892
+ if (tip === _cur) return;
893
+ _cur = tip;
894
+ if (!tip) { _ttEl.style.opacity = '0'; return; }
895
+ const text = tip.getAttribute('data-tooltip');
896
+ if (!text) return;
897
+ _ttEl.textContent = text;
898
+ _ttEl.style.display = 'block';
899
+ _ttEl.style.opacity = '0';
900
+ requestAnimationFrame(function () {
901
+ const r = tip.getBoundingClientRect();
902
+ const tw = _ttEl.offsetWidth, th = _ttEl.offsetHeight;
903
+ _ttEl.style.left = Math.max(4, Math.min(r.left + r.width / 2 - tw / 2, window.innerWidth - tw - 4)) + 'px';
904
+ _ttEl.style.top = (r.top - th - 6) + 'px';
905
+ _ttEl.style.opacity = '1';
906
+ });
907
+ });
908
+ document.addEventListener('mouseout', function (e) {
909
+ if (_cur && !_cur.contains(e.relatedTarget)) {
910
+ _cur = null;
911
+ _ttEl.style.opacity = '0';
912
+ }
913
+ });
914
+ }
915
+
916
+ // ── Status pill ───────────────────────────────────────────────────────────────
917
+ function _setPill(state, label) {
918
+ const p = document.getElementById('dk-pill');
919
+ if (!p) return;
920
+ p.className = 'dk-pill ' + state;
921
+ p.textContent = label;
922
+ }
923
+
924
+ // ── postMessage ───────────────────────────────────────────────────────────────
925
+ function _isGatewayUrl(v) {
926
+ if (typeof v !== 'string' || !v) return false;
927
+ try {
928
+ const u = new URL(v);
929
+ return u.protocol === 'https:' ||
930
+ (u.protocol === 'http:' && (u.hostname === 'localhost' || u.hostname === '127.0.0.1'));
931
+ } catch { return false; }
932
+ }
933
+
934
+ function _onMessage(e) {
935
+ if (!e.data || typeof e.data !== 'object') return;
936
+ if (e.data.type === 'RK_SNAPSHOT') { _snapshot(); return; }
937
+ if (e.data.type === 'CHAT_MODE') { document.documentElement.dataset.chatMode = 'true'; return; }
938
+ if (e.data.type !== 'CUSTOM_DASHBOARDS_PARAMS') return;
939
+
940
+ if (typeof e.data.token === 'string') _authToken = e.data.token;
941
+
942
+ // The token rides on every call to the endpoint, so only the parent may set
943
+ // it, and only to an https URL (http for localhost dev). Anything else is
944
+ // ignored and the current endpoint stays.
945
+ if (e.data.endpoint !== undefined) {
946
+ if (e.source === window.parent && _isGatewayUrl(e.data.endpoint)) _endpoint = e.data.endpoint;
947
+ else console.warn('[DashboardKit] endpoint ignored:', e.data.endpoint);
948
+ }
949
+
950
+ if (e.data.theme === 'light' || e.data.theme === 'dark')
951
+ document.documentElement.dataset.theme = e.data.theme;
952
+
953
+ const f = e.data.filters;
954
+ if (f && typeof f === 'object') {
955
+ if (typeof f.dateFrom === 'string') _dateFromIso = f.dateFrom;
956
+ if (typeof f.dateTo === 'string') _dateToIso = f.dateTo;
957
+ if (typeof f.timezone === 'string') _timezone = f.timezone;
958
+ if ('hierarchyId' in f)
959
+ _hierarchyId = f.hierarchyId == null ? null : Number(f.hierarchyId);
960
+ }
961
+
962
+ if (!_hasBooted && _authToken && _dateFromIso && _dateToIso && _timezone) {
963
+ _hasBooted = true;
964
+ const wait = document.getElementById('dk-wait');
965
+ if (wait) wait.hidden = true;
966
+ _buildTabNav();
967
+ _activateTab(_sections[0].id);
968
+ } else if (_hasBooted) {
969
+ _invalidateAll();
970
+ _loadSection(_activeTabId);
971
+ }
972
+ }
973
+
974
+ // ── Tab state machine ─────────────────────────────────────────────────────────
975
+ function _buildTabNav() {
976
+ const nav = document.getElementById('dk-tabs');
977
+ if (!nav) return;
978
+ nav.innerHTML = '';
979
+ _sections.forEach(function (s) {
980
+ const btn = document.createElement('button');
981
+ btn.id = 'dk-tab-' + s.id;
982
+ btn.textContent = s.label;
983
+ if (s.tooltip) {
984
+ const icon = document.createElement('span');
985
+ icon.className = 'dk-tip';
986
+ icon.setAttribute('data-tooltip', s.tooltip);
987
+ icon.textContent = 'i';
988
+ btn.appendChild(icon);
989
+ }
990
+ btn.addEventListener('click', function () { _activateTab(s.id); });
991
+ nav.appendChild(btn);
992
+ });
993
+ _renderTabBtns();
994
+ }
995
+
996
+ function _renderTabBtns() {
997
+ _sections.forEach(function (s) {
998
+ const btn = document.getElementById('dk-tab-' + s.id);
999
+ if (!btn) return;
1000
+ btn.className = s.id === _activeTabId ? 'active' : '';
1001
+ const old = btn.querySelector('.dk-badge');
1002
+ if (old) old.remove();
1003
+ if (s.errors > 0) {
1004
+ const b = document.createElement('span');
1005
+ b.className = 'dk-badge';
1006
+ b.textContent = s.errors;
1007
+ btn.appendChild(b);
1008
+ }
1009
+ });
1010
+ }
1011
+
1012
+ function _activateTab(id) {
1013
+ _activeTabId = id;
1014
+ document.querySelectorAll('.dk-panel').forEach(function (p) {
1015
+ p.classList.remove('active');
1016
+ });
1017
+ const panel = document.getElementById('dk-panel-' + id);
1018
+ if (panel) panel.classList.add('active');
1019
+ _renderTabBtns();
1020
+ const sec = _sections.find(function (s) { return s.id === id; });
1021
+ if (sec && !sec.loaded) _loadSection(id);
1022
+ }
1023
+
1024
+ function _loadSection(id) {
1025
+ const sec = _sections.find(function (s) { return s.id === id; });
1026
+ const panel = document.getElementById('dk-panel-' + id);
1027
+ if (!sec || !panel) return;
1028
+ // Ignore a second activation while this run is in flight; Refresh/new params bump _generation.
1029
+ if (sec.pending && sec.runGen === _generation) return;
1030
+ sec.loaded = false;
1031
+ _runSectionLoad(sec, panel);
1032
+ }
1033
+
1034
+ function _invalidateAll() {
1035
+ _generation++;
1036
+ _sections.forEach(function (s) { s.loaded = false; s.errors = 0; });
1037
+ _sectionErrors = {};
1038
+ _calls = [];
1039
+ _setPill('', 'idle');
1040
+ _renderSystemInfo();
1041
+ }
1042
+
1043
+ async function _runSectionLoad(sec, panel) {
1044
+ if (sec.id === 'dk-sysinfo') { _renderSystemInfo(); sec.loaded = true; return; }
1045
+ const gen = _generation;
1046
+ sec.runGen = gen;
1047
+ sec.pending = true;
1048
+ // _sectionErrors is cumulative for the whole session, only ever cleared by
1049
+ // _invalidateAll() — a full-dashboard Refresh. Retry reloads one section on
1050
+ // its own, so without this reset a section that fails once, then succeeds
1051
+ // on retry, would keep showing its old error badge forever.
1052
+ _sectionErrors[sec.id] = 0;
1053
+ setSectionState(panel, 'loading');
1054
+ _setPill('loading', 'loading…');
1055
+ try {
1056
+ await sec.load({
1057
+ runQuery,
1058
+ formatCurrency,
1059
+ formatPercent,
1060
+ formatInteger: fmtInt,
1061
+ formatDate: fmtDate,
1062
+ formatDatetime: fmtDatetime,
1063
+ renderTable,
1064
+ renderKpiCard,
1065
+ renderKpiStrip,
1066
+ renderInfoGrid,
1067
+ renderTotalsStrip,
1068
+ escHtml: esc,
1069
+ colorClass,
1070
+ filters: DashboardKit.filters,
1071
+ });
1072
+ if (gen !== _generation) return;
1073
+ sec.loaded = true;
1074
+ sec.errors = _sectionErrors[sec.id] || 0;
1075
+ } catch (e) {
1076
+ // The real exception, stack and dkCall included — the panel and the report only get a phrase.
1077
+ console.error('[DashboardKit] section "' + sec.id + '" failed:', e);
1078
+ console.log('[DashboardKit] section "' + sec.id + '" failed:', e);
1079
+ if (gen !== _generation) return;
1080
+ setSectionState(panel, 'error', e.message || String(e), e && e.dkCall, e && e.stack);
1081
+ sec.errors = _sectionErrors[sec.id] || 0;
1082
+ sec.loaded = true;
1083
+ }
1084
+ // Only the current run clears the flag.
1085
+ if (sec.runGen === gen) sec.pending = false;
1086
+ _renderTabBtns();
1087
+ _refreshPill();
1088
+ _renderSystemInfo();
1089
+ }
1090
+
1091
+ function _refreshPill() {
1092
+ const anyErr = Object.keys(_sectionErrors).some(function (k) { return _sectionErrors[k] > 0; });
1093
+ _setPill(anyErr ? 'error' : 'ok', anyErr ? 'error' : 'ok');
1094
+ }
1095
+
1096
+ // ── Failure kind ─────────────────────────────────────────────────────────
1097
+ // One question: did a response arrive? 'transport' = no (network/CORS/DNS),
1098
+ // 'gateway' = yes, whatever the status or body. Who is to blame is read from
1099
+ // the status and the gateway's own fields by the host/server, not here.
1100
+ // Builds the exception a failed call throws, diagnosis attached as err.dkCall —
1101
+ // with parallel runQuery calls, searching _calls afterwards is ambiguous.
1102
+ function _failure(entry, kind) {
1103
+ const err = new Error(entry.error);
1104
+ const r = entry.response || {};
1105
+ const gw = kind === 'gateway';
1106
+ // '' is refused by the server; null is "not present".
1107
+ const s = function (v) { return typeof v === 'string' && v.length ? v : null; };
1108
+ let detail = gw
1109
+ ? s(r.error || r.message || (typeof r._raw === 'string' ? r._raw.slice(0, 200) : ''))
1110
+ : s(entry.browserMessage);
1111
+ // pg_position has no wire field of its own — the one permitted append.
1112
+ if (detail && gw && r.pg_position != null) detail += ' [pg_position ' + r.pg_position + ']';
1113
+ err.dkCall = {
1114
+ kind,
1115
+ label: entry.label, sql: entry.sql, args: entry.args,
1116
+ durationMs: entry.durationMs, clientMs: entry.clientMs,
1117
+ status: entry.status,
1118
+ error: entry.error,
1119
+ detail: detail,
1120
+ errorCode: gw ? s(r.error_code) : null,
1121
+ pgDetail: gw ? s(r.pg_detail) : null,
1122
+ pgHint: gw ? s(r.pg_hint) : null,
1123
+ trackId: gw ? s(r.track_id) : null,
1124
+ };
1125
+ return err;
1126
+ }
1127
+
1128
+ // By status, not kind: no response and 5xx may clear on their own; a 200
1129
+ // without { rows } is a proxy/captive portal; 4xx (408 included) is deterministic.
1130
+ function _isRetryable(entry) {
1131
+ if (entry.status === null) return true;
1132
+ if (entry.status >= 500) return true;
1133
+ if (entry.status === 200) return true;
1134
+ return false;
1135
+ }
1136
+
1137
+ // Randomized so every dashboard open against a recovering service doesn't
1138
+ // retry in the same instant and hammer it right as it's coming back —
1139
+ // the gateway team's own warning: retrying a struggling backend in lockstep
1140
+ // makes things worse, not better.
1141
+ function _retryDelay() {
1142
+ return new Promise(function (resolve) { setTimeout(resolve, 800 + Math.random() * 700); });
1143
+ }
1144
+
1145
+ // ── runQuery ──────────────────────────────────────────────────────────────────
1146
+ // Text a `$n` can sit in without being a placeholder: 'USD $10', "col$1", comments,
1147
+ // $tag$…$tag$ bodies. Blanked before counting, or a valid query is refused locally.
1148
+ const NOT_SQL_CODE =
1149
+ /'(?:[^']|'')*'|"(?:[^"]|"")*"|--[^\n]*|\/\*[\s\S]*?\*\/|\$([A-Za-z_]\w*)?\$[\s\S]*?\$\1\$/g;
1150
+
1151
+ function _maxPlaceholder(sql) {
1152
+ let m, max = 0;
1153
+ // A `$n` glued to a word is an identifier ("price$1"), so it needs a non-word char before it.
1154
+ const re = /(^|[^\w$])\$(\d+)/g;
1155
+ const code = String(sql).replace(NOT_SQL_CODE, ' ');
1156
+ while ((m = re.exec(code)) !== null) max = Math.max(max, +m[2]);
1157
+ return max;
1158
+ }
1159
+
1160
+ // One retry, only where _isRetryable says the same request may succeed.
1161
+ async function runQuery(sectionId, callLabel, sql, args, params) {
1162
+ const t0 = performance.now();
1163
+ const entry = {
1164
+ index: _calls.length + 1,
1165
+ sectionId, label: callLabel, sql,
1166
+ args: args || [],
1167
+ requestHeaders: { 'Content-Type': 'application/json', Authorization: 'Bearer ***' },
1168
+ status: null, clientMs: null, durationMs: null, serverMs: null,
1169
+ bytes: null, rowCount: null, response: null,
1170
+ error: null, state: 'running',
1171
+ ts: new Date().toISOString(),
1172
+ };
1173
+ _calls.push(entry);
1174
+
1175
+ // Preflight: $n count vs args, after the log so it shows in System Info with its SQL.
1176
+ // Never reaches the gateway, so no dkCall — it reports as kind 'script': the document
1177
+ // is wrong. Never retried. `local` keeps it out of the CORS hint and the NET label.
1178
+ const need = _maxPlaceholder(sql);
1179
+ const got = (args || []).length;
1180
+ if (need !== got) {
1181
+ entry.state = 'error';
1182
+ entry.local = true;
1183
+ entry.clientMs = Math.round(performance.now() - t0);
1184
+ entry.error =
1185
+ 'SQL uses ' + (need ? '$1..$' + need : 'no placeholders') +
1186
+ ' but received ' + got + ' arg' + (got === 1 ? '' : 's') +
1187
+ ' — each query takes exactly the args its own placeholders use, never a shared array.';
1188
+ _sectionErrors[sectionId] = (_sectionErrors[sectionId] || 0) + 1;
1189
+ throw new Error(entry.error);
1190
+ }
1191
+
1192
+ const body = { sql };
1193
+ if (args && args.length) body.args = args;
1194
+ if (params && params.hierarchy_id != null) body.hierarchy_id = params.hierarchy_id;
1195
+ else if (_hierarchyId != null) body.hierarchy_id = _hierarchyId;
1196
+
1197
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
1198
+ // Cleared each attempt so a stale status/text from a prior try can't
1199
+ // classify or describe this one.
1200
+ entry.status = null;
1201
+ entry.error = null;
1202
+ entry.browserMessage = null;
1203
+ const ta = performance.now(); // this attempt only — clientMs includes the retry pause
1204
+ try {
1205
+ const res = await fetch(_endpoint, {
1206
+ method: 'POST',
1207
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + _authToken },
1208
+ body: JSON.stringify(body),
1209
+ });
1210
+ entry.status = res.status;
1211
+ const text = await res.text();
1212
+ entry.bytes = text.length;
1213
+ let data;
1214
+ try { data = JSON.parse(text); } catch { data = { _raw: text }; }
1215
+ entry.response = data;
1216
+ entry.clientMs = Math.round(performance.now() - t0);
1217
+ entry.durationMs = Math.round(performance.now() - ta);
1218
+
1219
+ // A 200 whose body isn't { rows: [...] } (proxy HTML, truncated JSON) is
1220
+ // a gateway failure too, not an empty result.
1221
+ let failed = false;
1222
+ if (!res.ok) {
1223
+ entry.error = 'HTTP ' + res.status + ': ' + (data && (data.error || data.message) || text);
1224
+ failed = true;
1225
+ } else if (!Array.isArray(data.rows)) {
1226
+ entry.error = 'HTTP 200 but the body is not { rows: [...] }' +
1227
+ (data._raw !== undefined ? ' — not JSON: ' + text.slice(0, 200) : '');
1228
+ failed = true;
1229
+ }
1230
+ if (failed) {
1231
+ if (attempt < MAX_ATTEMPTS && _isRetryable(entry)) {
1232
+ entry.retried = true;
1233
+ await _retryDelay();
1234
+ continue;
1235
+ }
1236
+ entry.state = 'error';
1237
+ _sectionErrors[sectionId] = (_sectionErrors[sectionId] || 0) + 1;
1238
+ throw _failure(entry, 'gateway');
1239
+ }
1240
+ entry.serverMs = data.duration_ms ?? null;
1241
+ entry.rowCount = data.rows.length;
1242
+ entry.state = 'ok';
1243
+ return data.rows;
1244
+ } catch (err) {
1245
+ // The failed-response throw lands here too — already built.
1246
+ if (err && err.dkCall) throw err;
1247
+ entry.clientMs = Math.round(performance.now() - t0);
1248
+ entry.durationMs = Math.round(performance.now() - ta);
1249
+ if (entry.status === null) {
1250
+ // The browser's own message goes to the wire untouched; the suffix is
1251
+ // for System Info only.
1252
+ entry.browserMessage = err.message;
1253
+ entry.error = err.message + ' [no HTTP response — network/CORS error]';
1254
+ } else if (!entry.error) {
1255
+ entry.error = err.message;
1256
+ }
1257
+ if (attempt < MAX_ATTEMPTS && _isRetryable(entry)) {
1258
+ entry.retried = true;
1259
+ await _retryDelay();
1260
+ continue;
1261
+ }
1262
+ entry.state = 'error';
1263
+ _sectionErrors[sectionId] = (_sectionErrors[sectionId] || 0) + 1;
1264
+ throw _failure(entry, entry.status === null ? 'transport' : 'gateway');
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ // ── Container resolution ──────────────────────────────────────────────────────
1270
+ // Shared by setSectionState and every render primitive: accepts a CSS selector
1271
+ // string or a DOM element, throws (fail loud) instead of silently rendering
1272
+ // nothing when it resolves to nothing. Called from inside a section's load(),
1273
+ // the throw propagates through the kit's own try/catch into the section's
1274
+ // error state — same path a failed runQuery already uses.
1275
+ function resolveContainer(fnName, arg) {
1276
+ const el = typeof arg === 'string' ? document.querySelector(arg) : arg;
1277
+ if (!el) {
1278
+ const shown = typeof arg === 'string' ? `"${arg}"` : String(arg);
1279
+ throw new Error(`${fnName}: container ${shown} matched no element`);
1280
+ }
1281
+ return el;
1282
+ }
1283
+
1284
+ // ── Header filter values ─────────────────────────────────────────────────────
1285
+ // Generic — reads whatever dashboard-specific inputs/selects the author put in
1286
+ // <header data-dk-header>, keyed by element id. Dashboards define their own
1287
+ // filters beyond date/timezone/hierarchy, so the kit can't hardcode field names.
1288
+ function _headerFilterValues() {
1289
+ const header = document.querySelector('[data-dk-header]');
1290
+ const out = {};
1291
+ if (!header) return out;
1292
+ header.querySelectorAll('input, select').forEach(function (el) {
1293
+ if (el.id) out[el.id] = el.value;
1294
+ });
1295
+ return out;
1296
+ }
1297
+
1298
+ // ── Error report ── the turn:dashboard-error wire shape, nothing else. The host
1299
+ // adds chatId/dashboardFile and strips what the kit appends after it.
1300
+ function _wireReport(sectionId, sectionLabel, failedCall, msg, stack) {
1301
+ if (!failedCall) {
1302
+ return {
1303
+ kind: 'script', phase: 'section',
1304
+ sectionId: sectionId, sectionLabel: sectionLabel,
1305
+ error: { message: msg || '', stack: stack || null },
1306
+ };
1307
+ }
1308
+ const w = {
1309
+ kind: failedCall.kind, // 'transport' | 'gateway'
1310
+ sectionId: sectionId, sectionLabel: sectionLabel,
1311
+ callLabel: failedCall.label,
1312
+ sql: failedCall.sql, args: failedCall.args || [],
1313
+ durationMs: failedCall.durationMs,
1314
+ detail: failedCall.detail,
1315
+ };
1316
+ if (failedCall.kind === 'gateway') {
1317
+ w.status = failedCall.status;
1318
+ w.errorCode = failedCall.errorCode;
1319
+ w.pgDetail = failedCall.pgDetail;
1320
+ w.pgHint = failedCall.pgHint;
1321
+ w.trackId = failedCall.trackId;
1322
+ }
1323
+ return w;
1324
+ }
1325
+
1326
+ // ── Section state helpers ─────────────────────────────────────────────────────
1327
+ function setSectionState(container, state, msg, failedCall, stack) {
1328
+ container = resolveContainer('setSectionState', container);
1329
+ if (state === 'loading') {
1330
+ container.innerHTML =
1331
+ '<div class="dk-loading"><span class="dk-spinner"></span>Loading…</div>';
1332
+ } else if (state === 'error') {
1333
+ // A caller can be the section panel itself or, from a dashboard's own
1334
+ // load(), an arbitrary sub-container inside it — either way the nearest
1335
+ // .dk-panel ancestor is the section that actually failed. Falling back to
1336
+ // _activeTabId only covers the (shouldn't-happen) case of an element
1337
+ // outside any panel.
1338
+ const ownPanel = container.closest ? container.closest('.dk-panel') : null;
1339
+ const sectionId = ownPanel ? ownPanel.id.replace('dk-panel-', '') : _activeTabId;
1340
+ const sec = _sections.find(function (s) { return s.id === sectionId; });
1341
+ // failedCall is err.dkCall from runQuery; without one the rejection didn't
1342
+ // come from a gateway call at all — that's kind 'script'. The operator gets
1343
+ // a fixed phrase; the raw text goes to the parent via "Ask AI to fix" and to
1344
+ // System Info. No panel Retry: the header's Refresh covers it.
1345
+ container.innerHTML =
1346
+ '<div class="dk-error"><span>This view couldn\'t load</span>' +
1347
+ '<button class="dk-error-report-btn" type="button">Ask AI to fix</button></div>';
1348
+
1349
+ const reportBtn = container.querySelector('.dk-error-report-btn');
1350
+ if (reportBtn) {
1351
+ reportBtn.addEventListener('click', function () {
1352
+ const dashboardSections = _sections.filter(function (s) { return s.id !== 'dk-sysinfo'; });
1353
+ const failedSections = dashboardSections.filter(function (s) { return (s.errors || 0) > 0; }).length;
1354
+ window.parent.postMessage(Object.assign(
1355
+ { type: 'DASHBOARD_ERROR' },
1356
+ _wireReport(sectionId, sec ? sec.label : '', failedCall, msg, stack),
1357
+ {
1358
+ // Host-only, stripped before the socket.
1359
+ title: _title,
1360
+ message: msg || '',
1361
+ sectionErrorCount: sec ? sec.errors : 0,
1362
+ totalSections: dashboardSections.length,
1363
+ failedSections: failedSections,
1364
+ filters: Object.assign(
1365
+ {
1366
+ dateFromIso: _dateFromIso,
1367
+ dateToIso: _dateToIso,
1368
+ timezone: _timezone,
1369
+ hierarchyId: _hierarchyId,
1370
+ },
1371
+ _headerFilterValues(),
1372
+ ),
1373
+ call: failedCall,
1374
+ },
1375
+ ), '*');
1376
+ reportBtn.disabled = true;
1377
+ reportBtn.textContent = 'Sent ✓';
1378
+ setTimeout(function () {
1379
+ reportBtn.disabled = false;
1380
+ reportBtn.textContent = 'Ask AI to fix';
1381
+ }, 1500);
1382
+ });
1383
+ }
1384
+ } else if (state === 'empty') {
1385
+ container.innerHTML = EMPTY_HTML;
1386
+ }
1387
+ }
1388
+
1389
+ // Every primitive draws its own empty state, so a section never branches on emptiness.
1390
+ const EMPTY_HTML = '<div class="dk-empty">No data for the selected period.</div>';
1391
+
1392
+ function _titleHtml(title) {
1393
+ return title ? '<div class="dk-toolbar"><span class="dk-table-title">' + esc(title) + '</span></div>' : '';
1394
+ }
1395
+
1396
+ // ── renderTable ───────────────────────────────────────────────────────────────
1397
+ // columns: [{ key, label, format?, sortable?, tooltip? }]
1398
+ // format receives (value, row) — read row.<key> for per-row currency, etc.
1399
+ // totalsRow: [label|{key,agg}] matching columns length — optional
1400
+ // title: table heading; also the CSV filename — omit to hide title + CSV button
1401
+ function renderTable(containerOrSelector, rows, options) {
1402
+ const container = resolveContainer('renderTable', containerOrSelector);
1403
+
1404
+ const { columns = [], totalsRow, title } = options || {};
1405
+
1406
+ if (!rows || !rows.length) {
1407
+ container.innerHTML = _titleHtml(title) + EMPTY_HTML;
1408
+ return;
1409
+ }
1410
+
1411
+ let sortKey = null, sortDir = 'asc';
1412
+ let slots = null;
1413
+
1414
+ // format returns a string (escaped) or an element.
1415
+ function buildBodyRows(data) {
1416
+ slots = _slots();
1417
+ return data.map(function (row) {
1418
+ return '<tr>' + columns.map(function (col) {
1419
+ const raw = row[col.key];
1420
+ const out = col.format ? col.format(raw, row) : raw;
1421
+ if (col.format) _refuseHtmlString(out, 'renderTable: column "' + col.label + '" format');
1422
+ return '<td>' + slots.html(out, '—') + '</td>';
1423
+ }).join('') + '</tr>';
1424
+ }).join('');
1425
+ }
1426
+
1427
+ function sortedRows() {
1428
+ if (!sortKey) return rows;
1429
+ return rows.slice().sort(function (a, b) {
1430
+ let av = a[sortKey], bv = b[sortKey];
1431
+ if (av != null && bv != null && !isNaN(+av) && !isNaN(+bv)) { av = +av; bv = +bv; }
1432
+ if (av == null && bv == null) return 0;
1433
+ if (av == null) return 1;
1434
+ if (bv == null) return -1;
1435
+ const cmp = av < bv ? -1 : av > bv ? 1 : 0;
1436
+ return sortDir === 'asc' ? cmp : -cmp;
1437
+ });
1438
+ }
1439
+
1440
+ // Rebuilt per sort: an element from format lives in one place only.
1441
+ let totalsSlots = null;
1442
+ function buildTotalsHtml() {
1443
+ if (!totalsRow) return '';
1444
+ totalsSlots = _slots();
1445
+ let html = '<tr class="dk-totals-row">';
1446
+ totalsRow.forEach(function (cell, i) {
1447
+ const col = columns[i] || {};
1448
+ if (typeof cell === 'string' || cell == null) {
1449
+ html += '<td>' + esc(cell ?? '—') + '</td>';
1450
+ } else if (cell && cell.agg === 'sum') {
1451
+ const sum = rows.reduce(function (a, r) { return a + (Number(r[cell.key]) || 0); }, 0);
1452
+ const fmt = col.format || function (v) { return v; };
1453
+ // Totals have no single row; pass an empty row so a per-row-currency
1454
+ // formatter degrades gracefully instead of throwing. Don't sum a
1455
+ // mixed-currency column (see the all-currencies note in the docs).
1456
+ let out;
1457
+ try { out = fmt(sum, {}); } catch { out = '—'; }
1458
+ html += '<td>' + totalsSlots.html(out, '—') + '</td>';
1459
+ } else {
1460
+ html += '<td>—</td>';
1461
+ }
1462
+ });
1463
+ return html + '</tr>';
1464
+ }
1465
+
1466
+ const tblId = 'dk-tbl-' + Math.random().toString(36).slice(2, 7);
1467
+ let html = '<div class="dk-table-wrap">';
1468
+ html += '<div class="dk-toolbar">';
1469
+ if (title) html += '<span class="dk-table-title">' + esc(title) + '</span>';
1470
+ html += '<button class="dk-csv-btn" data-dk-csv="' + tblId + '">CSV ↓</button>';
1471
+ html += '</div>';
1472
+ html += '<table class="dk-table" id="' + tblId + '"><thead><tr>';
1473
+ columns.forEach(function (col) {
1474
+ const sortAttr = col.sortable === true ? ' data-dk-sort-key="' + esc(col.key) + '"' : '';
1475
+ const tipHtml = col.tooltip ? '<span class="dk-tip" data-tooltip="' + esc(col.tooltip) + '">i</span>' : '';
1476
+ html += '<th' + sortAttr + '><div class="dk-th-inner"><span>' + esc(col.label) + '</span>' + tipHtml + '</div></th>';
1477
+ });
1478
+ html += '</tr></thead><tbody>';
1479
+ const bodyHtml = buildBodyRows(rows);
1480
+ html += buildTotalsHtml();
1481
+ html += bodyHtml;
1482
+ html += '</tbody></table></div>';
1483
+
1484
+ container.innerHTML = html;
1485
+ slots.apply(container);
1486
+ if (totalsSlots) totalsSlots.apply(container);
1487
+
1488
+ // Sort handler
1489
+ const tblEl = container.querySelector('#' + tblId);
1490
+ tblEl.querySelector('thead').addEventListener('click', function (e) {
1491
+ const th = e.target.closest('th[data-dk-sort-key]');
1492
+ if (!th) return;
1493
+ const key = th.dataset.dkSortKey;
1494
+ sortDir = key === sortKey && sortDir === 'asc' ? 'desc' : 'asc';
1495
+ sortKey = key;
1496
+ const body = tblEl.querySelector('tbody');
1497
+ const rebuilt = buildBodyRows(sortedRows());
1498
+ body.innerHTML = buildTotalsHtml() + rebuilt;
1499
+ slots.apply(body);
1500
+ if (totalsSlots) totalsSlots.apply(body);
1501
+ tblEl.querySelectorAll('thead th[data-dk-sort-key]').forEach(function (h) {
1502
+ h.classList.remove('dk-sort-asc', 'dk-sort-desc');
1503
+ if (h.dataset.dkSortKey === sortKey) h.classList.add('dk-sort-' + sortDir);
1504
+ });
1505
+ });
1506
+
1507
+ // Wire CSV button
1508
+ const csvBtn = container.querySelector('[data-dk-csv]');
1509
+ if (csvBtn) {
1510
+ csvBtn.addEventListener('click', function () {
1511
+ const tbl = document.getElementById(csvBtn.dataset.dkCsv);
1512
+ if (!tbl) return;
1513
+ const name = [_title, title].filter(Boolean).concat(fmtDatetime(new Date().toISOString()).replace(/:/g, '-')).join(' - ') + '.csv';
1514
+ _downloadCsv(tbl, name);
1515
+ });
1516
+ }
1517
+ }
1518
+
1519
+ // ── Item shape ── { label, value, colorClass?, tooltip?, sub? } for every strip and grid;
1520
+ // value and sub are each a string or an element; sub is a short visible line under the value.
1521
+ function _tipHtml(tooltip) {
1522
+ return tooltip ? '<span class="dk-tip" data-tooltip="' + esc(tooltip) + '">i</span>' : '';
1523
+ }
1524
+
1525
+ function _subHtml(sub, slots) {
1526
+ return sub ? '<span class="dk-sub">' + slots.html(sub, '') + '</span>' : '';
1527
+ }
1528
+
1529
+ // A tuple here would read as undefined and render blank labels, with nothing to say why.
1530
+ function _checkItems(fnName, items) {
1531
+ items.forEach(function (item, i) {
1532
+ if (!item || typeof item !== 'object' || Array.isArray(item) || item.label === undefined) {
1533
+ throw new Error(fnName + ': items[' + i + '] must be { label, value, colorClass?, tooltip?, sub? }' +
1534
+ (Array.isArray(item) ? ' — got an array' : ''));
1535
+ }
1536
+ _refuseHtmlString(item.value, fnName + ': items[' + i + '] "' + item.label + '" value');
1537
+ _refuseHtmlString(item.sub, fnName + ': items[' + i + '] "' + item.label + '" sub');
1538
+ });
1539
+ }
1540
+
1541
+ // ── renderKpiCard ─────────────────────────────────────────────────────────────
1542
+ function _kpiCardHtml(item, slots) {
1543
+ const cls = 'dk-kpi-value' + (item.colorClass ? ' ' + item.colorClass : '');
1544
+ return '<div class="dk-kpi">' +
1545
+ '<div class="dk-kpi-label">' + esc(item.label) + _tipHtml(item.tooltip) + '</div>' +
1546
+ '<div class="' + cls + '">' + slots.html(item.value, '') + '</div>' +
1547
+ _subHtml(item.sub, slots) +
1548
+ '</div>';
1549
+ }
1550
+
1551
+ function renderKpiCard(containerOrSelector, item) {
1552
+ const container = resolveContainer('renderKpiCard', containerOrSelector);
1553
+ _checkItems('renderKpiCard', [item]);
1554
+ const slots = _slots();
1555
+ container.innerHTML = _kpiCardHtml(item, slots);
1556
+ slots.apply(container);
1557
+ }
1558
+
1559
+ // ── renderKpiStrip ────────────────────────────────────────────────────────────
1560
+ // options: { title? }
1561
+ function renderKpiStrip(containerOrSelector, items, options) {
1562
+ const container = resolveContainer('renderKpiStrip', containerOrSelector);
1563
+ const { title } = options || {};
1564
+ if (!items || !items.length) {
1565
+ container.innerHTML = _titleHtml(title) + EMPTY_HTML;
1566
+ return;
1567
+ }
1568
+ _checkItems('renderKpiStrip', items);
1569
+ let html = _titleHtml(title);
1570
+ const slots = _slots();
1571
+ html += '<div class="dk-kpi-strip">' +
1572
+ items.map(function (item) { return _kpiCardHtml(item, slots); }).join('') +
1573
+ '</div>';
1574
+ container.innerHTML = html;
1575
+ slots.apply(container);
1576
+ }
1577
+
1578
+ // ── renderInfoGrid ────────────────────────────────────────────────────────────
1579
+ // renders a responsive key/value grid
1580
+ // options: { title? }
1581
+ function renderInfoGrid(containerOrSelector, items, options) {
1582
+ const container = resolveContainer('renderInfoGrid', containerOrSelector);
1583
+ const { title } = options || {};
1584
+ if (!items || !items.length) {
1585
+ container.innerHTML = _titleHtml(title) + EMPTY_HTML;
1586
+ return;
1587
+ }
1588
+ _checkItems('renderInfoGrid', items);
1589
+ let html = _titleHtml(title);
1590
+ const slots = _slots();
1591
+ html += '<div class="dk-info-grid">' +
1592
+ items.map(function (item) {
1593
+ const cls = 'dk-val' + (item.colorClass ? ' ' + item.colorClass : '');
1594
+ return '<div class="dk-info-row">' +
1595
+ '<span class="dk-lbl">' + esc(item.label) + _tipHtml(item.tooltip) + '</span>' +
1596
+ '<span class="' + cls + '">' + slots.html(item.value, '—') + _subHtml(item.sub, slots) + '</span>' +
1597
+ '</div>';
1598
+ }).join('') +
1599
+ '</div>';
1600
+ container.innerHTML = html;
1601
+ slots.apply(container);
1602
+ }
1603
+
1604
+ // ── renderTotalsStrip ─────────────────────────────────────────────────────────
1605
+ // renders a horizontal totals bar
1606
+ // options: { title? }
1607
+ function renderTotalsStrip(containerOrSelector, items, options) {
1608
+ const container = resolveContainer('renderTotalsStrip', containerOrSelector);
1609
+ const { title } = options || {};
1610
+ if (!items || !items.length) {
1611
+ container.innerHTML = _titleHtml(title) + EMPTY_HTML;
1612
+ return;
1613
+ }
1614
+ _checkItems('renderTotalsStrip', items);
1615
+ let html = _titleHtml(title);
1616
+ const slots = _slots();
1617
+ html += '<div class="dk-totals-strip">' +
1618
+ items.map(function (item) {
1619
+ const cls = 'dk-field-value' + (item.colorClass ? ' ' + item.colorClass : '');
1620
+ return '<div class="dk-field">' +
1621
+ '<span class="dk-field-label">' + esc(item.label) + _tipHtml(item.tooltip) + '</span>' +
1622
+ '<span class="' + cls + '">' + slots.html(item.value, '—') + '</span>' +
1623
+ _subHtml(item.sub, slots) +
1624
+ '</div>';
1625
+ }).join('') +
1626
+ '</div>';
1627
+ container.innerHTML = html;
1628
+ slots.apply(container);
1629
+ }
1630
+
1631
+ // ── CSV download ──────────────────────────────────────────────────────────────
1632
+ function _downloadCsv(tableEl, filename) {
1633
+ const rows = [];
1634
+ tableEl.querySelectorAll('tr').forEach(function (tr) {
1635
+ const cells = Array.from(tr.querySelectorAll('th, td')).map(function (td) {
1636
+ const v = td.dataset.csvValue !== undefined ? td.dataset.csvValue : td.textContent.trim();
1637
+ const safe = /^[=+\-@]/.test(String(v)) && !/^-\d/.test(String(v)) ? "'" + String(v) : String(v);
1638
+ return '"' + safe.replace(/"/g, '""') + '"';
1639
+ });
1640
+ if (cells.length) rows.push(cells.join(','));
1641
+ });
1642
+ const blob = new Blob(['' + rows.join('\r\n')], { type: 'text/csv;charset=utf-8' });
1643
+ const a = document.createElement('a');
1644
+ a.href = URL.createObjectURL(blob);
1645
+ a.download = filename;
1646
+ a.click();
1647
+ URL.revokeObjectURL(a.href);
1648
+ }
1649
+
1650
+ // ── Snapshot ──────────────────────────────────────────────────────────────────
1651
+ function _snapshot() {
1652
+ // Freeze current filter values into attributes so cloneNode captures them.
1653
+ document.querySelectorAll('[data-dk-header] input, [data-dk-header-right] input').forEach(function (el) {
1654
+ el.setAttribute('value', el.value);
1655
+ });
1656
+ document.querySelectorAll('[data-dk-header] select, [data-dk-header-right] select').forEach(function (el) {
1657
+ el.querySelectorAll('option').forEach(function (opt) {
1658
+ opt.selected = opt.value === el.value;
1659
+ });
1660
+ });
1661
+
1662
+ const clone = document.documentElement.cloneNode(true);
1663
+
1664
+ // A canvas clones blank — its pixels aren't part of the DOM — so each chart
1665
+ // goes into the snapshot as an image of what it currently shows. The size
1666
+ // comes from the inline style Chart.js sets, not clientWidth: a chart on an
1667
+ // inactive tab is display:none and measures 0.
1668
+ const liveCanvases = document.querySelectorAll('canvas');
1669
+ clone.querySelectorAll('canvas').forEach(function (cv, i) {
1670
+ const src = liveCanvases[i];
1671
+ if (!src) return;
1672
+ const img = document.createElement('img');
1673
+ try { img.src = src.toDataURL('image/png'); } catch { return; }
1674
+ img.style.display = 'block';
1675
+ img.style.maxWidth = '100%';
1676
+ img.style.width = src.style.width || '100%';
1677
+ if (src.style.height) img.style.height = src.style.height;
1678
+ cv.replaceWith(img);
1679
+ });
1680
+
1681
+ // Strip auth token from System Info pre elements
1682
+ if (_authToken) {
1683
+ clone.querySelectorAll('pre').forEach(function (pre) {
1684
+ if (pre.textContent.includes(_authToken))
1685
+ pre.textContent = pre.textContent.replaceAll(_authToken, '***');
1686
+ });
1687
+ }
1688
+
1689
+ // Remove action buttons that make no sense in a static snapshot
1690
+ ['#dk-btn-refresh', '#dk-btn-snapshot'].forEach(function (sel) {
1691
+ const el = clone.querySelector(sel);
1692
+ if (el) el.remove();
1693
+ });
1694
+
1695
+ // Disable all inputs; keep tab buttons enabled
1696
+ clone.querySelectorAll('input, select, button').forEach(function (el) {
1697
+ el.setAttribute('disabled', '');
1698
+ });
1699
+
1700
+ // Remove live scripts
1701
+ clone.querySelectorAll('script').forEach(function (s) { s.remove(); });
1702
+
1703
+ // Re-attach tab-click handlers only
1704
+ const tabScript = document.createElement('script');
1705
+ tabScript.textContent = '(function(){' +
1706
+ 'document.querySelectorAll("nav.dk-tabs button").forEach(function(btn){' +
1707
+ 'btn.removeAttribute("disabled");' +
1708
+ 'btn.addEventListener("click",function(){' +
1709
+ 'var id=btn.id.replace("dk-tab-","");' +
1710
+ 'document.querySelectorAll(".dk-panel").forEach(function(p){p.classList.remove("active");});' +
1711
+ 'var panel=document.getElementById("dk-panel-"+id);' +
1712
+ 'if(panel)panel.classList.add("active");' +
1713
+ 'document.querySelectorAll("nav.dk-tabs button").forEach(function(b){b.classList.remove("active");});' +
1714
+ 'btn.classList.add("active");' +
1715
+ '});' +
1716
+ '});' +
1717
+ '})();';
1718
+ clone.querySelector('body').appendChild(tabScript);
1719
+
1720
+ const dateSlug = (_dateFromIso || '').slice(0, 10);
1721
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
1722
+ const filename = _title + '-snapshot-' + dateSlug + '-' + ts + '.html';
1723
+
1724
+ const blob = new Blob(['<!doctype html>\n' + clone.outerHTML], { type: 'text/html' });
1725
+ const a = document.createElement('a');
1726
+ a.href = URL.createObjectURL(blob);
1727
+ a.download = filename;
1728
+ a.click();
1729
+ URL.revokeObjectURL(a.href);
1730
+ }
1731
+
1732
+ // ── System Info ───────────────────────────────────────────────────────────────
1733
+ function _renderSystemInfo() {
1734
+ const container = document.getElementById('dk-panel-dk-sysinfo');
1735
+ if (!container) return;
1736
+ const calls = _calls;
1737
+ const okN = calls.filter(function (c) { return c.state === 'ok'; }).length;
1738
+ const errN = calls.filter(function (c) { return c.state === 'error'; }).length;
1739
+ const runN = calls.filter(function (c) { return c.state === 'running'; }).length;
1740
+ const totMs = calls.reduce(function (a, c) { return a + (c.clientMs || 0); }, 0);
1741
+ const totB = calls.reduce(function (a, c) { return a + (c.bytes || 0); }, 0);
1742
+ const hasCors = calls.some(function (c) { return c.state === 'error' && c.status === null && !c.local; });
1743
+
1744
+ const callsHtml = calls.length === 0
1745
+ ? '<p style="color:var(--muted);font-size:12px">No calls yet.</p>'
1746
+ : calls.map(function (c) {
1747
+ const statusLabel = c.local
1748
+ ? '<span class="dk-s-err">ARGS</span>'
1749
+ : c.status === null
1750
+ ? '<span class="dk-s-net">NET</span>'
1751
+ : c.state === 'ok'
1752
+ ? '<span class="dk-s-ok">' + c.status + '</span>'
1753
+ : '<span class="dk-s-err">' + c.status + '</span>';
1754
+ return '<details class="dk-call"><summary>' +
1755
+ '<span style="color:var(--muted);font-size:11px">#' + c.index + '</span>' +
1756
+ statusLabel +
1757
+ '<span style="flex:1">' + esc(c.label) + '</span>' +
1758
+ '<span style="color:var(--muted);font-size:11px">' + (c.clientMs != null ? c.clientMs + 'ms' : '—') + '</span>' +
1759
+ (c.serverMs != null ? '<span style="color:var(--muted);font-size:11px">srv:' + c.serverMs + 'ms</span>' : '') +
1760
+ (c.rowCount != null ? '<span style="color:var(--muted);font-size:11px">' + c.rowCount + ' rows</span>' : '') +
1761
+ '<span style="color:var(--muted);font-size:10px">' + c.ts.slice(11, 19) + '</span>' +
1762
+ '</summary><div class="dk-call-body">' +
1763
+ (c.error ? '<div class="dk-s-err" style="margin-bottom:6px">' + esc(c.error) + '</div>' : '') +
1764
+ '<p style="font-size:11px;color:var(--muted);margin:0 0 4px">Data Request:</p>' +
1765
+ '<pre>' + esc(c.sql) + '</pre>' +
1766
+ (c.args && c.args.length ? '<p style="font-size:11px;color:var(--muted);margin:8px 0 4px">Args:</p><pre>' + esc(JSON.stringify(c.args, null, 2)) + '</pre>' : '') +
1767
+ '<div class="dk-resp-head">' +
1768
+ '<span>Response:</span><button type="button" class="dk-resp-toggle" aria-label="Toggle response">−</button>' +
1769
+ '</div>' +
1770
+ '<pre class="dk-resp-body">' + esc(JSON.stringify(c.response, null, 2)) + '</pre>' +
1771
+ '</div></details>';
1772
+ }).join('');
1773
+
1774
+ container.innerHTML =
1775
+ '<div class="dk-sysinfo">' +
1776
+ (hasCors ? '<div class="dk-cors-hint">⚠ One or more calls received no HTTP response — likely a CORS or network error. Open DevTools → Network to inspect.</div>' : '') +
1777
+ '<div><h3>Context</h3><table class="dk-sys-table">' +
1778
+ // Unstamped build is called out, not thrown — a missed sed must not brick saved dashboards.
1779
+ // The marker is split so the deploy's sed cannot rewrite this comparison too.
1780
+ '<tr><td>Version</td><td>' +
1781
+ (_gitVer === '__' + 'BUILD__'
1782
+ ? '<span class="dk-s-err">unstamped — the deploy did not stamp a build</span>'
1783
+ : esc(_gitVer)) +
1784
+ '</td></tr>' +
1785
+ '<tr><td>Dashboard title</td><td>' + esc(_title) + '</td></tr>' +
1786
+ '<tr><td>Hierarchy ID</td><td>' + (_hierarchyId != null ? _hierarchyId : '—') + '</td></tr>' +
1787
+ '<tr><td>Timezone</td><td>' + esc(_timezone || '—') + '</td></tr>' +
1788
+ '<tr><td>Day window (UTC)</td><td>' + esc(_dateFromIso) + ' → ' + esc(_dateToIso) + '</td></tr>' +
1789
+ '</table></div>' +
1790
+ '<div><h3>Totals</h3><table class="dk-sys-table">' +
1791
+ '<tr><td>Calls</td><td>' + calls.length + ' (' + okN + ' ok · ' + errN + ' error' + (runN ? ' · ' + runN + ' running' : '') + ')</td></tr>' +
1792
+ '<tr><td>Client duration</td><td>' + totMs + ' ms</td></tr>' +
1793
+ '<tr><td>Bytes received</td><td>' + fmtInt(totB) + '</td></tr>' +
1794
+ '</table></div>' +
1795
+ '<div><h3>Calls <button id="dk-sysinfo-export" style="margin-left:8px;background:transparent;color:var(--brand);border:1px solid var(--brand);border-radius:3px;padding:0 8px;height:22px;font-size:11px;cursor:pointer">Export JSON ↓</button></h3>' +
1796
+ callsHtml + '</div>' +
1797
+ '</div>';
1798
+
1799
+ container.querySelectorAll('.dk-resp-toggle').forEach(function (btn) {
1800
+ btn.addEventListener('click', function () {
1801
+ const pre = btn.parentElement.nextElementSibling;
1802
+ pre.hidden = !pre.hidden;
1803
+ btn.textContent = pre.hidden ? '+' : '−';
1804
+ });
1805
+ });
1806
+
1807
+ const expBtn = document.getElementById('dk-sysinfo-export');
1808
+ if (expBtn) {
1809
+ expBtn.addEventListener('click', function () {
1810
+ const payload = {
1811
+ title: _title,
1812
+ hierarchyId: _hierarchyId, timezone: _timezone,
1813
+ dateFromIso: _dateFromIso, dateToIso: _dateToIso,
1814
+ calls: _calls.map(function (c) {
1815
+ return Object.assign({}, c, { requestHeaders: { 'Content-Type': 'application/json', Authorization: 'Bearer ***' } });
1816
+ }),
1817
+ };
1818
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
1819
+ const a = document.createElement('a');
1820
+ a.href = URL.createObjectURL(blob);
1821
+ a.download = 'system-info-' + (_dateFromIso || '').slice(0, 10) + '-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
1822
+ a.click();
1823
+ URL.revokeObjectURL(a.href);
1824
+ });
1825
+ }
1826
+ }
1827
+
1828
+ // ── Header wiring ─────────────────────────────────────────────────────────────
1829
+ function _buildHeader(title) {
1830
+ const header = document.querySelector('[data-dk-header]');
1831
+ if (!header) return;
1832
+
1833
+ // Collect dashboard-provided content before clearing
1834
+ const dashboardNodes = Array.from(header.childNodes);
1835
+
1836
+ const titleEl = document.createElement('span');
1837
+ titleEl.className = 'dk-title';
1838
+ titleEl.textContent = title;
1839
+
1840
+ const spacer = document.createElement('div');
1841
+ spacer.className = 'dk-spacer';
1842
+
1843
+ const btnRefresh = document.createElement('button');
1844
+ btnRefresh.id = 'dk-btn-refresh';
1845
+ btnRefresh.textContent = 'Refresh';
1846
+ btnRefresh.disabled = true;
1847
+ btnRefresh.addEventListener('click', function () {
1848
+ _invalidateAll();
1849
+ _loadSection(_activeTabId);
1850
+ });
1851
+
1852
+ const pill = document.createElement('div');
1853
+ pill.id = 'dk-pill';
1854
+ pill.className = 'dk-pill';
1855
+ pill.textContent = 'idle';
1856
+
1857
+ // Rebuild: title | spacer | dashboard filters | Refresh | Snapshot | pill
1858
+ header.innerHTML = '';
1859
+ header.appendChild(titleEl);
1860
+ header.appendChild(spacer);
1861
+ dashboardNodes.forEach(function (n) { header.appendChild(n); });
1862
+ header.appendChild(btnRefresh);
1863
+ header.appendChild(pill);
1864
+ }
1865
+
1866
+ // ── DashboardKit.init ────────────────────────────────────────────────────────────
1867
+ // A throw here leaves an empty document with no "Ask AI to fix" to click, so the
1868
+ // report is posted immediately. No sectionId: the server refuses init with one.
1869
+ function init(opts) {
1870
+ try {
1871
+ _init(opts);
1872
+ } catch (e) {
1873
+ console.error('[DashboardKit] init failed:', e);
1874
+ console.log('[DashboardKit] init failed:', e);
1875
+ window.parent.postMessage({
1876
+ type: 'DASHBOARD_ERROR',
1877
+ kind: 'script', phase: 'init',
1878
+ error: { message: e.message || String(e), stack: e.stack || null },
1879
+ title: (opts && opts.title) || '',
1880
+ }, '*');
1881
+ throw e;
1882
+ }
1883
+ }
1884
+
1885
+ function _init(opts) {
1886
+ _title = opts.title || '';
1887
+ if (opts.gitVersion) _gitVer = opts.gitVersion;
1888
+ _initTooltip();
1889
+
1890
+ // A snapshot copies each canvas as it is at that instant, so a chart still
1891
+ // animating goes into the file half-drawn. A chart that sets its own
1892
+ // `animation` overrides this default.
1893
+ if (global.Chart) global.Chart.defaults.animation = false;
1894
+
1895
+ // Build internal section list, append System Info last
1896
+ _sections = (opts.sections || []).map(function (s) {
1897
+ if (typeof s.load !== 'function') {
1898
+ throw new Error('section "' + s.id + '": load must be a function');
1899
+ }
1900
+ return { id: s.id, label: s.label, tooltip: s.tooltip, load: s.load, loaded: false, errors: 0 };
1901
+ });
1902
+ _sections.push({ id: 'dk-sysinfo', label: 'System Info', load: null, loaded: false, errors: 0 });
1903
+
1904
+ // Build header controls
1905
+ _buildHeader(_title);
1906
+
1907
+ // Build nav + panels for each section
1908
+ const nav = document.querySelector('[data-dk-tabs]') || _createNav();
1909
+ nav.id = 'dk-tabs';
1910
+ nav.classList.add('dk-tabs');
1911
+ const content = document.querySelector('.dk-content') || _createContent();
1912
+
1913
+ _sections.forEach(function (s) {
1914
+ if (!document.getElementById('dk-panel-' + s.id)) {
1915
+ const panel = document.createElement('section');
1916
+ panel.id = 'dk-panel-' + s.id;
1917
+ panel.className = 'dk-panel';
1918
+ content.appendChild(panel);
1919
+ }
1920
+ });
1921
+
1922
+ // Wait message
1923
+ const wait = document.createElement('div');
1924
+ wait.id = 'dk-wait';
1925
+ wait.className = 'dk-wait';
1926
+ wait.textContent = 'Waiting for dashboard parameters…';
1927
+ content.insertBefore(wait, content.firstChild);
1928
+
1929
+ // Enable refresh after first boot
1930
+ const origOnMessage = _onMessage;
1931
+ window.addEventListener('message', function (e) {
1932
+ origOnMessage(e);
1933
+ if (_hasBooted) {
1934
+ const btn = document.getElementById('dk-btn-refresh');
1935
+ if (btn) btn.disabled = false;
1936
+ }
1937
+ });
1938
+ }
1939
+
1940
+ function _createNav() {
1941
+ const nav = document.createElement('nav');
1942
+ nav.id = 'dk-tabs';
1943
+ nav.className = 'dk-tabs';
1944
+ document.body.appendChild(nav);
1945
+ return nav;
1946
+ }
1947
+
1948
+ function _createContent() {
1949
+ const div = document.createElement('div');
1950
+ div.className = 'dk-content';
1951
+ document.body.appendChild(div);
1952
+ return div;
1953
+ }
1954
+
1955
+ // ── Number-input guard ── a focused type=number takes the wheel. { passive: false }
1956
+ // is required for preventDefault; v4.css hides the spinners.
1957
+ document.addEventListener(
1958
+ 'wheel',
1959
+ function (e) {
1960
+ const el = e.target;
1961
+ if (el && el.type === 'number' && document.activeElement === el) {
1962
+ e.preventDefault();
1963
+ el.blur();
1964
+ }
1965
+ },
1966
+ { passive: false },
1967
+ );
1968
+
1969
+ // ── Public API ────────────────────────────────────────────────────────────────
1970
+ const DashboardKit = {
1971
+ init,
1972
+ get filters() {
1973
+ return {
1974
+ dateFromIso: _dateFromIso,
1975
+ dateToIso: _dateToIso,
1976
+ timezone: _timezone,
1977
+ hierarchyId: _hierarchyId,
1978
+ };
1979
+ },
1980
+ // Helpers are also passed into each load() function — that is the canonical
1981
+ // way to use them. They are mirrored here so DashboardKit.<helper>(…) works too.
1982
+ runQuery,
1983
+ setSectionState,
1984
+ renderTable,
1985
+ renderKpiCard,
1986
+ renderKpiStrip,
1987
+ renderInfoGrid,
1988
+ renderTotalsStrip,
1989
+ formatCurrency,
1990
+ formatInteger: fmtInt,
1991
+ formatPercent,
1992
+ formatDate: fmtDate,
1993
+ formatDatetime: fmtDatetime,
1994
+ colorClass,
1995
+ escHtml: esc,
1996
+ };
1997
+
1998
+ global.DashboardKit = DashboardKit;
1999
+
2000
+ }(window));