@datalabz_/dashboard-kit 4.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.
- package/README.md +156 -0
- package/package.json +15 -0
- package/v4.css +694 -0
- package/v4.js +1093 -0
- package/wrapper.css +326 -0
- package/wrapper.js +310 -0
package/v4.js
ADDED
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
/* DashboardKit v4.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 = '5f9741d'; // replaced at deploy: sed -i "s/5f9741d/$(git rev-parse --short HEAD)/g" v4.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
|
+
// v4.css is expected to be loaded via <link> alongside this script.
|
|
31
|
+
// No runtime injection — keeps CSP simple and avoids FOUC.
|
|
32
|
+
|
|
33
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
34
|
+
function esc(s) {
|
|
35
|
+
return String(s == null ? '' : s)
|
|
36
|
+
.replace(/&/g, '&').replace(/</g, '<')
|
|
37
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fmtAmount(v) {
|
|
41
|
+
const n = parseFloat(v);
|
|
42
|
+
if (isNaN(n)) return '—';
|
|
43
|
+
return n.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fmtInt(v) {
|
|
47
|
+
if (v == null) return '—';
|
|
48
|
+
return String(v);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _fmtDate(iso, tz, withTime) {
|
|
52
|
+
if (!iso) return '—';
|
|
53
|
+
try {
|
|
54
|
+
const p = {};
|
|
55
|
+
new Intl.DateTimeFormat('en-CA', {
|
|
56
|
+
timeZone: tz || _timezone || 'UTC',
|
|
57
|
+
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
58
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
59
|
+
}).formatToParts(new Date(iso)).forEach(function (x) { p[x.type] = x.value; });
|
|
60
|
+
const date = p.day + '.' + p.month + '.' + p.year;
|
|
61
|
+
return withTime ? date + ' ' + p.hour + ':' + p.minute + ':' + p.second : date;
|
|
62
|
+
} catch { return withTime ? iso : String(iso).slice(0, 10); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const CALENDAR_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
66
|
+
|
|
67
|
+
// DD.MM.YYYY for a bare YYYY-MM-DD that names a real day; null otherwise, so anything else
|
|
68
|
+
// (2026-13-01, 2026-02-30) takes the existing path and formats exactly as it did before.
|
|
69
|
+
function _calendarDay(iso) {
|
|
70
|
+
const m = typeof iso === 'string' ? CALENDAR_DATE.exec(iso) : null;
|
|
71
|
+
if (!m) return null;
|
|
72
|
+
const t = new Date(Date.UTC(+m[1], m[2] - 1, +m[3]));
|
|
73
|
+
return t.getUTCMonth() === m[2] - 1 && t.getUTCDate() === +m[3] ? m[3] + '.' + m[2] + '.' + m[1] : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A calendar date (a Postgres `date`, e.g. a birth date) names a day, not a moment:
|
|
77
|
+
// show that day, whatever the timezone. Instants still format in the report's timezone.
|
|
78
|
+
function fmtDate(iso, tz) {
|
|
79
|
+
return _calendarDay(iso) || _fmtDate(iso, tz, false);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A calendar date has no time of day, so it is shown as the date alone; 00:00:00 would invent one.
|
|
83
|
+
function fmtDatetime(iso, tz) {
|
|
84
|
+
return _calendarDay(iso) || _fmtDate(iso, tz, true);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function formatCurrency(amount, currency) {
|
|
88
|
+
const n = fmtAmount(amount);
|
|
89
|
+
return currency ? `${currency} ${n}` : n;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatPercent(v, decimals) {
|
|
93
|
+
const n = parseFloat(v);
|
|
94
|
+
if (isNaN(n)) return '—';
|
|
95
|
+
return n.toFixed(decimals ?? 2) + '%';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function colorClass(v) {
|
|
99
|
+
const n = Number(v);
|
|
100
|
+
return n > 0 ? 'pos' : n < 0 ? 'neg' : '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Tooltip ──────────────────────────────────────────────────────────────────
|
|
104
|
+
function _initTooltip() {
|
|
105
|
+
if (_ttEl) return;
|
|
106
|
+
_ttEl = document.createElement('div');
|
|
107
|
+
_ttEl.id = 'dk-tt';
|
|
108
|
+
document.body.appendChild(_ttEl);
|
|
109
|
+
let _cur = null;
|
|
110
|
+
document.addEventListener('mouseover', function (e) {
|
|
111
|
+
const tip = e.target.closest('.dk-tip');
|
|
112
|
+
if (tip === _cur) return;
|
|
113
|
+
_cur = tip;
|
|
114
|
+
if (!tip) { _ttEl.style.opacity = '0'; return; }
|
|
115
|
+
const text = tip.getAttribute('data-tooltip');
|
|
116
|
+
if (!text) return;
|
|
117
|
+
_ttEl.textContent = text;
|
|
118
|
+
_ttEl.style.display = 'block';
|
|
119
|
+
_ttEl.style.opacity = '0';
|
|
120
|
+
requestAnimationFrame(function () {
|
|
121
|
+
const r = tip.getBoundingClientRect();
|
|
122
|
+
const tw = _ttEl.offsetWidth, th = _ttEl.offsetHeight;
|
|
123
|
+
_ttEl.style.left = Math.max(4, Math.min(r.left + r.width / 2 - tw / 2, window.innerWidth - tw - 4)) + 'px';
|
|
124
|
+
_ttEl.style.top = (r.top - th - 6) + 'px';
|
|
125
|
+
_ttEl.style.opacity = '1';
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
document.addEventListener('mouseout', function (e) {
|
|
129
|
+
if (_cur && !_cur.contains(e.relatedTarget)) {
|
|
130
|
+
_cur = null;
|
|
131
|
+
_ttEl.style.opacity = '0';
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Status pill ───────────────────────────────────────────────────────────────
|
|
137
|
+
function _setPill(state, label) {
|
|
138
|
+
const p = document.getElementById('dk-pill');
|
|
139
|
+
if (!p) return;
|
|
140
|
+
p.className = 'dk-pill ' + state;
|
|
141
|
+
p.textContent = label;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── postMessage ───────────────────────────────────────────────────────────────
|
|
145
|
+
function _isGatewayUrl(v) {
|
|
146
|
+
if (typeof v !== 'string' || !v) return false;
|
|
147
|
+
try {
|
|
148
|
+
const u = new URL(v);
|
|
149
|
+
return u.protocol === 'https:' ||
|
|
150
|
+
(u.protocol === 'http:' && (u.hostname === 'localhost' || u.hostname === '127.0.0.1'));
|
|
151
|
+
} catch { return false; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _onMessage(e) {
|
|
155
|
+
if (!e.data || typeof e.data !== 'object') return;
|
|
156
|
+
if (e.data.type === 'RK_SNAPSHOT') { _snapshot(); return; }
|
|
157
|
+
if (e.data.type === 'CHAT_MODE') { document.documentElement.dataset.chatMode = 'true'; return; }
|
|
158
|
+
if (e.data.type !== 'CUSTOM_DASHBOARDS_PARAMS') return;
|
|
159
|
+
|
|
160
|
+
if (typeof e.data.token === 'string') _authToken = e.data.token;
|
|
161
|
+
|
|
162
|
+
// The token rides on every call to the endpoint, so only the parent may set
|
|
163
|
+
// it, and only to an https URL (http for localhost dev). Anything else is
|
|
164
|
+
// ignored and the current endpoint stays.
|
|
165
|
+
if (e.data.endpoint !== undefined) {
|
|
166
|
+
if (e.source === window.parent && _isGatewayUrl(e.data.endpoint)) _endpoint = e.data.endpoint;
|
|
167
|
+
else console.warn('[DashboardKit] endpoint ignored:', e.data.endpoint);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (e.data.theme === 'light' || e.data.theme === 'dark')
|
|
171
|
+
document.documentElement.dataset.theme = e.data.theme;
|
|
172
|
+
|
|
173
|
+
const f = e.data.filters;
|
|
174
|
+
if (f && typeof f === 'object') {
|
|
175
|
+
if (typeof f.dateFrom === 'string') _dateFromIso = f.dateFrom;
|
|
176
|
+
if (typeof f.dateTo === 'string') _dateToIso = f.dateTo;
|
|
177
|
+
if (typeof f.timezone === 'string') _timezone = f.timezone;
|
|
178
|
+
if ('hierarchyId' in f)
|
|
179
|
+
_hierarchyId = f.hierarchyId == null ? null : Number(f.hierarchyId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!_hasBooted && _authToken && _dateFromIso && _dateToIso && _timezone) {
|
|
183
|
+
_hasBooted = true;
|
|
184
|
+
const wait = document.getElementById('dk-wait');
|
|
185
|
+
if (wait) wait.hidden = true;
|
|
186
|
+
_buildTabNav();
|
|
187
|
+
_activateTab(_sections[0].id);
|
|
188
|
+
} else if (_hasBooted) {
|
|
189
|
+
_invalidateAll();
|
|
190
|
+
_loadSection(_activeTabId);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Tab state machine ─────────────────────────────────────────────────────────
|
|
195
|
+
function _buildTabNav() {
|
|
196
|
+
const nav = document.getElementById('dk-tabs');
|
|
197
|
+
if (!nav) return;
|
|
198
|
+
nav.innerHTML = '';
|
|
199
|
+
_sections.forEach(function (s) {
|
|
200
|
+
const btn = document.createElement('button');
|
|
201
|
+
btn.id = 'dk-tab-' + s.id;
|
|
202
|
+
btn.textContent = s.label;
|
|
203
|
+
if (s.tooltip) {
|
|
204
|
+
const icon = document.createElement('span');
|
|
205
|
+
icon.className = 'dk-tip';
|
|
206
|
+
icon.setAttribute('data-tooltip', s.tooltip);
|
|
207
|
+
icon.textContent = 'i';
|
|
208
|
+
btn.appendChild(icon);
|
|
209
|
+
}
|
|
210
|
+
btn.addEventListener('click', function () { _activateTab(s.id); });
|
|
211
|
+
nav.appendChild(btn);
|
|
212
|
+
});
|
|
213
|
+
_renderTabBtns();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function _renderTabBtns() {
|
|
217
|
+
_sections.forEach(function (s) {
|
|
218
|
+
const btn = document.getElementById('dk-tab-' + s.id);
|
|
219
|
+
if (!btn) return;
|
|
220
|
+
btn.className = s.id === _activeTabId ? 'active' : '';
|
|
221
|
+
const old = btn.querySelector('.dk-badge');
|
|
222
|
+
if (old) old.remove();
|
|
223
|
+
if (s.errors > 0) {
|
|
224
|
+
const b = document.createElement('span');
|
|
225
|
+
b.className = 'dk-badge';
|
|
226
|
+
b.textContent = s.errors;
|
|
227
|
+
btn.appendChild(b);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function _activateTab(id) {
|
|
233
|
+
_activeTabId = id;
|
|
234
|
+
document.querySelectorAll('.dk-panel').forEach(function (p) {
|
|
235
|
+
p.classList.remove('active');
|
|
236
|
+
});
|
|
237
|
+
const panel = document.getElementById('dk-panel-' + id);
|
|
238
|
+
if (panel) panel.classList.add('active');
|
|
239
|
+
_renderTabBtns();
|
|
240
|
+
const sec = _sections.find(function (s) { return s.id === id; });
|
|
241
|
+
if (sec && !sec.loaded) _loadSection(id);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function _loadSection(id) {
|
|
245
|
+
const sec = _sections.find(function (s) { return s.id === id; });
|
|
246
|
+
const panel = document.getElementById('dk-panel-' + id);
|
|
247
|
+
if (!sec || !panel) return;
|
|
248
|
+
sec.loaded = false;
|
|
249
|
+
_runSectionLoad(sec, panel);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function _invalidateAll() {
|
|
253
|
+
_generation++;
|
|
254
|
+
_sections.forEach(function (s) { s.loaded = false; s.errors = 0; });
|
|
255
|
+
_sectionErrors = {};
|
|
256
|
+
_calls = [];
|
|
257
|
+
_setPill('', 'idle');
|
|
258
|
+
_renderSystemInfo();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function _runSectionLoad(sec, panel) {
|
|
262
|
+
if (sec.id === 'dk-sysinfo') { _renderSystemInfo(); sec.loaded = true; return; }
|
|
263
|
+
const gen = _generation;
|
|
264
|
+
// _sectionErrors is cumulative for the whole session, only ever cleared by
|
|
265
|
+
// _invalidateAll() — a full-dashboard Refresh. Retry reloads one section on
|
|
266
|
+
// its own, so without this reset a section that fails once, then succeeds
|
|
267
|
+
// on retry, would keep showing its old error badge forever.
|
|
268
|
+
_sectionErrors[sec.id] = 0;
|
|
269
|
+
setSectionState(panel, 'loading');
|
|
270
|
+
_setPill('loading', 'loading…');
|
|
271
|
+
try {
|
|
272
|
+
await sec.load({
|
|
273
|
+
runQuery,
|
|
274
|
+
formatCurrency,
|
|
275
|
+
formatPercent,
|
|
276
|
+
formatInteger: fmtInt,
|
|
277
|
+
formatDate: fmtDate,
|
|
278
|
+
formatDatetime: fmtDatetime,
|
|
279
|
+
renderTable,
|
|
280
|
+
renderKpiCard,
|
|
281
|
+
renderKpiStrip,
|
|
282
|
+
renderInfoGrid,
|
|
283
|
+
renderTotalsStrip,
|
|
284
|
+
escHtml: esc,
|
|
285
|
+
colorClass,
|
|
286
|
+
filters: DashboardKit.filters,
|
|
287
|
+
});
|
|
288
|
+
if (gen !== _generation) return;
|
|
289
|
+
sec.loaded = true;
|
|
290
|
+
sec.errors = _sectionErrors[sec.id] || 0;
|
|
291
|
+
} catch (e) {
|
|
292
|
+
// The real exception, stack and dkCall included — the panel and the report only get a phrase.
|
|
293
|
+
console.error('[DashboardKit] section "' + sec.id + '" failed:', e);
|
|
294
|
+
console.log('[DashboardKit] section "' + sec.id + '" failed:', e);
|
|
295
|
+
if (gen !== _generation) return;
|
|
296
|
+
setSectionState(panel, 'error', e.message || String(e), e && e.dkCall, e && e.stack);
|
|
297
|
+
sec.errors = _sectionErrors[sec.id] || 0;
|
|
298
|
+
sec.loaded = true;
|
|
299
|
+
}
|
|
300
|
+
_renderTabBtns();
|
|
301
|
+
_refreshPill();
|
|
302
|
+
_renderSystemInfo();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function _refreshPill() {
|
|
306
|
+
const anyErr = Object.keys(_sectionErrors).some(function (k) { return _sectionErrors[k] > 0; });
|
|
307
|
+
_setPill(anyErr ? 'error' : 'ok', anyErr ? 'error' : 'ok');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── Failure kind ─────────────────────────────────────────────────────────
|
|
311
|
+
// One question: did a response arrive? 'transport' = no (network/CORS/DNS),
|
|
312
|
+
// 'gateway' = yes, whatever the status or body. Who is to blame is read from
|
|
313
|
+
// the status and the gateway's own fields by the host/server, not here.
|
|
314
|
+
// Builds the exception a failed call throws, diagnosis attached as err.dkCall —
|
|
315
|
+
// with parallel runQuery calls, searching _calls afterwards is ambiguous.
|
|
316
|
+
function _failure(entry, kind) {
|
|
317
|
+
const err = new Error(entry.error);
|
|
318
|
+
const r = entry.response || {};
|
|
319
|
+
const gw = kind === 'gateway';
|
|
320
|
+
// '' is refused by the server; null is "not present".
|
|
321
|
+
const s = function (v) { return typeof v === 'string' && v.length ? v : null; };
|
|
322
|
+
let detail = gw
|
|
323
|
+
? s(r.error || r.message || (typeof r._raw === 'string' ? r._raw.slice(0, 200) : ''))
|
|
324
|
+
: s(entry.browserMessage);
|
|
325
|
+
// pg_position has no wire field of its own — the one permitted append.
|
|
326
|
+
if (detail && gw && r.pg_position != null) detail += ' [pg_position ' + r.pg_position + ']';
|
|
327
|
+
err.dkCall = {
|
|
328
|
+
kind,
|
|
329
|
+
label: entry.label, sql: entry.sql, args: entry.args,
|
|
330
|
+
durationMs: entry.durationMs, clientMs: entry.clientMs,
|
|
331
|
+
status: entry.status,
|
|
332
|
+
error: entry.error,
|
|
333
|
+
detail: detail,
|
|
334
|
+
errorCode: gw ? s(r.error_code) : null,
|
|
335
|
+
pgDetail: gw ? s(r.pg_detail) : null,
|
|
336
|
+
pgHint: gw ? s(r.pg_hint) : null,
|
|
337
|
+
trackId: gw ? s(r.track_id) : null,
|
|
338
|
+
};
|
|
339
|
+
return err;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// By status, not kind: no response and 5xx may clear on their own; a 200
|
|
343
|
+
// without { rows } is a proxy/captive portal; 4xx (408 included) is deterministic.
|
|
344
|
+
function _isRetryable(entry) {
|
|
345
|
+
if (entry.status === null) return true;
|
|
346
|
+
if (entry.status >= 500) return true;
|
|
347
|
+
if (entry.status === 200) return true;
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Randomized so every dashboard open against a recovering service doesn't
|
|
352
|
+
// retry in the same instant and hammer it right as it's coming back —
|
|
353
|
+
// the gateway team's own warning: retrying a struggling backend in lockstep
|
|
354
|
+
// makes things worse, not better.
|
|
355
|
+
function _retryDelay() {
|
|
356
|
+
return new Promise(function (resolve) { setTimeout(resolve, 800 + Math.random() * 700); });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── runQuery ──────────────────────────────────────────────────────────────────
|
|
360
|
+
// One retry, only where _isRetryable says the same request may succeed.
|
|
361
|
+
async function runQuery(sectionId, callLabel, sql, args, params) {
|
|
362
|
+
const t0 = performance.now();
|
|
363
|
+
const entry = {
|
|
364
|
+
index: _calls.length + 1,
|
|
365
|
+
sectionId, label: callLabel, sql,
|
|
366
|
+
args: args || [],
|
|
367
|
+
requestHeaders: { 'Content-Type': 'application/json', Authorization: 'Bearer ***' },
|
|
368
|
+
status: null, clientMs: null, durationMs: null, serverMs: null,
|
|
369
|
+
bytes: null, rowCount: null, response: null,
|
|
370
|
+
error: null, state: 'running',
|
|
371
|
+
ts: new Date().toISOString(),
|
|
372
|
+
};
|
|
373
|
+
_calls.push(entry);
|
|
374
|
+
|
|
375
|
+
const body = { sql };
|
|
376
|
+
if (args && args.length) body.args = args;
|
|
377
|
+
if (params && params.hierarchy_id != null) body.hierarchy_id = params.hierarchy_id;
|
|
378
|
+
else if (_hierarchyId != null) body.hierarchy_id = _hierarchyId;
|
|
379
|
+
|
|
380
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
381
|
+
// Cleared each attempt so a stale status/text from a prior try can't
|
|
382
|
+
// classify or describe this one.
|
|
383
|
+
entry.status = null;
|
|
384
|
+
entry.error = null;
|
|
385
|
+
entry.browserMessage = null;
|
|
386
|
+
const ta = performance.now(); // this attempt only — clientMs includes the retry pause
|
|
387
|
+
try {
|
|
388
|
+
const res = await fetch(_endpoint, {
|
|
389
|
+
method: 'POST',
|
|
390
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + _authToken },
|
|
391
|
+
body: JSON.stringify(body),
|
|
392
|
+
});
|
|
393
|
+
entry.status = res.status;
|
|
394
|
+
const text = await res.text();
|
|
395
|
+
entry.bytes = text.length;
|
|
396
|
+
let data;
|
|
397
|
+
try { data = JSON.parse(text); } catch { data = { _raw: text }; }
|
|
398
|
+
entry.response = data;
|
|
399
|
+
entry.clientMs = Math.round(performance.now() - t0);
|
|
400
|
+
entry.durationMs = Math.round(performance.now() - ta);
|
|
401
|
+
|
|
402
|
+
// A 200 whose body isn't { rows: [...] } (proxy HTML, truncated JSON) is
|
|
403
|
+
// a gateway failure too, not an empty result.
|
|
404
|
+
let failed = false;
|
|
405
|
+
if (!res.ok) {
|
|
406
|
+
entry.error = 'HTTP ' + res.status + ': ' + (data && (data.error || data.message) || text);
|
|
407
|
+
failed = true;
|
|
408
|
+
} else if (!Array.isArray(data.rows)) {
|
|
409
|
+
entry.error = 'HTTP 200 but the body is not { rows: [...] }' +
|
|
410
|
+
(data._raw !== undefined ? ' — not JSON: ' + text.slice(0, 200) : '');
|
|
411
|
+
failed = true;
|
|
412
|
+
}
|
|
413
|
+
if (failed) {
|
|
414
|
+
if (attempt < MAX_ATTEMPTS && _isRetryable(entry)) {
|
|
415
|
+
entry.retried = true;
|
|
416
|
+
await _retryDelay();
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
entry.state = 'error';
|
|
420
|
+
_sectionErrors[sectionId] = (_sectionErrors[sectionId] || 0) + 1;
|
|
421
|
+
throw _failure(entry, 'gateway');
|
|
422
|
+
}
|
|
423
|
+
entry.serverMs = data.duration_ms ?? null;
|
|
424
|
+
entry.rowCount = data.rows.length;
|
|
425
|
+
entry.state = 'ok';
|
|
426
|
+
return data.rows;
|
|
427
|
+
} catch (err) {
|
|
428
|
+
// The failed-response throw lands here too — already built.
|
|
429
|
+
if (err && err.dkCall) throw err;
|
|
430
|
+
entry.clientMs = Math.round(performance.now() - t0);
|
|
431
|
+
entry.durationMs = Math.round(performance.now() - ta);
|
|
432
|
+
if (entry.status === null) {
|
|
433
|
+
// The browser's own message goes to the wire untouched; the suffix is
|
|
434
|
+
// for System Info only.
|
|
435
|
+
entry.browserMessage = err.message;
|
|
436
|
+
entry.error = err.message + ' [no HTTP response — network/CORS error]';
|
|
437
|
+
} else if (!entry.error) {
|
|
438
|
+
entry.error = err.message;
|
|
439
|
+
}
|
|
440
|
+
if (attempt < MAX_ATTEMPTS && _isRetryable(entry)) {
|
|
441
|
+
entry.retried = true;
|
|
442
|
+
await _retryDelay();
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
entry.state = 'error';
|
|
446
|
+
_sectionErrors[sectionId] = (_sectionErrors[sectionId] || 0) + 1;
|
|
447
|
+
throw _failure(entry, entry.status === null ? 'transport' : 'gateway');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── Container resolution ──────────────────────────────────────────────────────
|
|
453
|
+
// Shared by setSectionState and every render primitive: accepts a CSS selector
|
|
454
|
+
// string or a DOM element, throws (fail loud) instead of silently rendering
|
|
455
|
+
// nothing when it resolves to nothing. Called from inside a section's load(),
|
|
456
|
+
// the throw propagates through the kit's own try/catch into the section's
|
|
457
|
+
// error state — same path a failed runQuery already uses.
|
|
458
|
+
function resolveContainer(fnName, arg) {
|
|
459
|
+
const el = typeof arg === 'string' ? document.querySelector(arg) : arg;
|
|
460
|
+
if (!el) {
|
|
461
|
+
const shown = typeof arg === 'string' ? `"${arg}"` : String(arg);
|
|
462
|
+
throw new Error(`${fnName}: container ${shown} matched no element`);
|
|
463
|
+
}
|
|
464
|
+
return el;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── Header filter values ─────────────────────────────────────────────────────
|
|
468
|
+
// Generic — reads whatever dashboard-specific inputs/selects the author put in
|
|
469
|
+
// <header data-dk-header>, keyed by element id. Dashboards define their own
|
|
470
|
+
// filters beyond date/timezone/hierarchy, so the kit can't hardcode field names.
|
|
471
|
+
function _headerFilterValues() {
|
|
472
|
+
const header = document.querySelector('[data-dk-header]');
|
|
473
|
+
const out = {};
|
|
474
|
+
if (!header) return out;
|
|
475
|
+
header.querySelectorAll('input, select').forEach(function (el) {
|
|
476
|
+
if (el.id) out[el.id] = el.value;
|
|
477
|
+
});
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── Error report ── the turn:dashboard-error wire shape, nothing else. The host
|
|
482
|
+
// adds chatId/dashboardFile and strips what the kit appends after it.
|
|
483
|
+
function _wireReport(sectionId, sectionLabel, failedCall, msg, stack) {
|
|
484
|
+
if (!failedCall) {
|
|
485
|
+
return {
|
|
486
|
+
kind: 'script', phase: 'section',
|
|
487
|
+
sectionId: sectionId, sectionLabel: sectionLabel,
|
|
488
|
+
error: { message: msg || '', stack: stack || null },
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const w = {
|
|
492
|
+
kind: failedCall.kind, // 'transport' | 'gateway'
|
|
493
|
+
sectionId: sectionId, sectionLabel: sectionLabel,
|
|
494
|
+
callLabel: failedCall.label,
|
|
495
|
+
sql: failedCall.sql, args: failedCall.args || [],
|
|
496
|
+
durationMs: failedCall.durationMs,
|
|
497
|
+
detail: failedCall.detail,
|
|
498
|
+
};
|
|
499
|
+
if (failedCall.kind === 'gateway') {
|
|
500
|
+
w.status = failedCall.status;
|
|
501
|
+
w.errorCode = failedCall.errorCode;
|
|
502
|
+
w.pgDetail = failedCall.pgDetail;
|
|
503
|
+
w.pgHint = failedCall.pgHint;
|
|
504
|
+
w.trackId = failedCall.trackId;
|
|
505
|
+
}
|
|
506
|
+
return w;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Section state helpers ─────────────────────────────────────────────────────
|
|
510
|
+
function setSectionState(container, state, msg, failedCall, stack) {
|
|
511
|
+
container = resolveContainer('setSectionState', container);
|
|
512
|
+
if (state === 'loading') {
|
|
513
|
+
container.innerHTML =
|
|
514
|
+
'<div class="dk-loading"><span class="dk-spinner"></span>Loading…</div>';
|
|
515
|
+
} else if (state === 'error') {
|
|
516
|
+
// A caller can be the section panel itself or, from a dashboard's own
|
|
517
|
+
// load(), an arbitrary sub-container inside it — either way the nearest
|
|
518
|
+
// .dk-panel ancestor is the section that actually failed. Falling back to
|
|
519
|
+
// _activeTabId only covers the (shouldn't-happen) case of an element
|
|
520
|
+
// outside any panel.
|
|
521
|
+
const ownPanel = container.closest ? container.closest('.dk-panel') : null;
|
|
522
|
+
const sectionId = ownPanel ? ownPanel.id.replace('dk-panel-', '') : _activeTabId;
|
|
523
|
+
const sec = _sections.find(function (s) { return s.id === sectionId; });
|
|
524
|
+
// failedCall is err.dkCall from runQuery; without one the rejection didn't
|
|
525
|
+
// come from a gateway call at all — that's kind 'script'. The operator gets
|
|
526
|
+
// a fixed phrase; the raw text goes to the parent via "Ask AI to fix" and to
|
|
527
|
+
// System Info. No panel Retry: the header's Refresh covers it.
|
|
528
|
+
container.innerHTML =
|
|
529
|
+
'<div class="dk-error"><span>This view couldn\'t load — try Refresh.</span>' +
|
|
530
|
+
'<button class="dk-error-report-btn" type="button">Ask AI to fix</button></div>';
|
|
531
|
+
|
|
532
|
+
const reportBtn = container.querySelector('.dk-error-report-btn');
|
|
533
|
+
if (reportBtn) {
|
|
534
|
+
reportBtn.addEventListener('click', function () {
|
|
535
|
+
const dashboardSections = _sections.filter(function (s) { return s.id !== 'dk-sysinfo'; });
|
|
536
|
+
const failedSections = dashboardSections.filter(function (s) { return (s.errors || 0) > 0; }).length;
|
|
537
|
+
window.parent.postMessage(Object.assign(
|
|
538
|
+
{ type: 'DASHBOARD_ERROR' },
|
|
539
|
+
_wireReport(sectionId, sec ? sec.label : '', failedCall, msg, stack),
|
|
540
|
+
{
|
|
541
|
+
// Host-only, stripped before the socket.
|
|
542
|
+
title: _title,
|
|
543
|
+
message: msg || '',
|
|
544
|
+
sectionErrorCount: sec ? sec.errors : 0,
|
|
545
|
+
totalSections: dashboardSections.length,
|
|
546
|
+
failedSections: failedSections,
|
|
547
|
+
filters: Object.assign(
|
|
548
|
+
{
|
|
549
|
+
dateFromIso: _dateFromIso,
|
|
550
|
+
dateToIso: _dateToIso,
|
|
551
|
+
timezone: _timezone,
|
|
552
|
+
hierarchyId: _hierarchyId,
|
|
553
|
+
},
|
|
554
|
+
_headerFilterValues(),
|
|
555
|
+
),
|
|
556
|
+
call: failedCall,
|
|
557
|
+
},
|
|
558
|
+
), '*');
|
|
559
|
+
reportBtn.disabled = true;
|
|
560
|
+
reportBtn.textContent = 'Sent ✓';
|
|
561
|
+
setTimeout(function () {
|
|
562
|
+
reportBtn.disabled = false;
|
|
563
|
+
reportBtn.textContent = 'Ask AI to fix';
|
|
564
|
+
}, 1500);
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
} else if (state === 'empty') {
|
|
568
|
+
container.innerHTML =
|
|
569
|
+
'<div class="dk-empty">No data for the selected period.</div>';
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── renderTable ───────────────────────────────────────────────────────────────
|
|
574
|
+
// columns: [{ key, label, format?, sortable?, tooltip? }]
|
|
575
|
+
// format receives (value, row) — read row.<key> for per-row currency, etc.
|
|
576
|
+
// totalsRow: [label|{key,agg}] matching columns length — optional
|
|
577
|
+
// tableTitle: table heading; also the CSV filename — omit to hide title + CSV button
|
|
578
|
+
function renderTable(containerOrSelector, rows, schema) {
|
|
579
|
+
const container = resolveContainer('renderTable', containerOrSelector);
|
|
580
|
+
|
|
581
|
+
const { columns = [], totalsRow, tableTitle } = schema || {};
|
|
582
|
+
|
|
583
|
+
if (!rows || !rows.length) {
|
|
584
|
+
container.innerHTML = '<div class="dk-empty">No data for the selected period.</div>';
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
let sortKey = null, sortDir = 'asc';
|
|
589
|
+
|
|
590
|
+
function buildBodyRows(data) {
|
|
591
|
+
return data.map(function (row) {
|
|
592
|
+
return '<tr>' + columns.map(function (col) {
|
|
593
|
+
const raw = row[col.key];
|
|
594
|
+
const displayed = col.format ? col.format(raw, row) : (raw == null ? '—' : raw);
|
|
595
|
+
return '<td>' + esc(displayed) + '</td>';
|
|
596
|
+
}).join('') + '</tr>';
|
|
597
|
+
}).join('');
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function sortedRows() {
|
|
601
|
+
if (!sortKey) return rows;
|
|
602
|
+
return rows.slice().sort(function (a, b) {
|
|
603
|
+
let av = a[sortKey], bv = b[sortKey];
|
|
604
|
+
if (av != null && bv != null && !isNaN(+av) && !isNaN(+bv)) { av = +av; bv = +bv; }
|
|
605
|
+
if (av == null && bv == null) return 0;
|
|
606
|
+
if (av == null) return 1;
|
|
607
|
+
if (bv == null) return -1;
|
|
608
|
+
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
|
609
|
+
return sortDir === 'asc' ? cmp : -cmp;
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Totals row
|
|
614
|
+
let totalsHtml = '';
|
|
615
|
+
if (totalsRow) {
|
|
616
|
+
totalsHtml = '<tr class="dk-totals-row">';
|
|
617
|
+
totalsRow.forEach(function (cell, i) {
|
|
618
|
+
const col = columns[i] || {};
|
|
619
|
+
if (typeof cell === 'string' || cell == null) {
|
|
620
|
+
totalsHtml += '<td>' + esc(cell ?? '—') + '</td>';
|
|
621
|
+
} else if (cell && cell.agg === 'sum') {
|
|
622
|
+
const sum = rows.reduce(function (a, r) { return a + (Number(r[cell.key]) || 0); }, 0);
|
|
623
|
+
const fmt = col.format || function (v) { return v; };
|
|
624
|
+
// Totals have no single row; pass an empty row so a per-row-currency
|
|
625
|
+
// formatter degrades gracefully instead of throwing. Don't sum a
|
|
626
|
+
// mixed-currency column (see the all-currencies note in the docs).
|
|
627
|
+
let out;
|
|
628
|
+
try { out = fmt(sum, {}); } catch { out = '—'; }
|
|
629
|
+
totalsHtml += '<td>' + esc(out) + '</td>';
|
|
630
|
+
} else {
|
|
631
|
+
totalsHtml += '<td>—</td>';
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
totalsHtml += '</tr>';
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const tblId = 'dk-tbl-' + Math.random().toString(36).slice(2, 7);
|
|
638
|
+
let html = '<div class="dk-table-wrap">';
|
|
639
|
+
html += '<div class="dk-toolbar">';
|
|
640
|
+
if (tableTitle) html += '<span class="dk-table-title">' + esc(tableTitle) + '</span>';
|
|
641
|
+
html += '<button class="dk-csv-btn" data-dk-csv="' + tblId + '">CSV ↓</button>';
|
|
642
|
+
html += '</div>';
|
|
643
|
+
html += '<table class="dk-table" id="' + tblId + '"><thead><tr>';
|
|
644
|
+
columns.forEach(function (col) {
|
|
645
|
+
const sortAttr = col.sortable === true ? ' data-dk-sort-key="' + esc(col.key) + '"' : '';
|
|
646
|
+
const tipHtml = col.tooltip ? '<span class="dk-tip" data-tooltip="' + esc(col.tooltip) + '">i</span>' : '';
|
|
647
|
+
html += '<th' + sortAttr + '><div class="dk-th-inner"><span>' + esc(col.label) + '</span>' + tipHtml + '</div></th>';
|
|
648
|
+
});
|
|
649
|
+
html += '</tr></thead><tbody>';
|
|
650
|
+
if (totalsHtml) html += totalsHtml;
|
|
651
|
+
html += buildBodyRows(rows);
|
|
652
|
+
html += '</tbody></table></div>';
|
|
653
|
+
|
|
654
|
+
container.innerHTML = html;
|
|
655
|
+
|
|
656
|
+
// Sort handler
|
|
657
|
+
const tblEl = container.querySelector('#' + tblId);
|
|
658
|
+
tblEl.querySelector('thead').addEventListener('click', function (e) {
|
|
659
|
+
const th = e.target.closest('th[data-dk-sort-key]');
|
|
660
|
+
if (!th) return;
|
|
661
|
+
const key = th.dataset.dkSortKey;
|
|
662
|
+
sortDir = key === sortKey && sortDir === 'asc' ? 'desc' : 'asc';
|
|
663
|
+
sortKey = key;
|
|
664
|
+
tblEl.querySelector('tbody').innerHTML = (totalsHtml || '') + buildBodyRows(sortedRows());
|
|
665
|
+
tblEl.querySelectorAll('thead th[data-dk-sort-key]').forEach(function (h) {
|
|
666
|
+
h.classList.remove('dk-sort-asc', 'dk-sort-desc');
|
|
667
|
+
if (h.dataset.dkSortKey === sortKey) h.classList.add('dk-sort-' + sortDir);
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Wire CSV button
|
|
672
|
+
const csvBtn = container.querySelector('[data-dk-csv]');
|
|
673
|
+
if (csvBtn) {
|
|
674
|
+
csvBtn.addEventListener('click', function () {
|
|
675
|
+
const tbl = document.getElementById(csvBtn.dataset.dkCsv);
|
|
676
|
+
if (!tbl) return;
|
|
677
|
+
const name = [_title, tableTitle].filter(Boolean).concat(fmtDatetime(new Date().toISOString()).replace(/:/g, '-')).join(' - ') + '.csv';
|
|
678
|
+
_downloadCsv(tbl, name);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ── renderKpiCard ─────────────────────────────────────────────────────────────
|
|
684
|
+
// item: { label, value, colorClass?, tooltip? }
|
|
685
|
+
function _kpiCardHtml(item) {
|
|
686
|
+
const cls = 'dk-kpi-value' + (item.colorClass ? ' ' + item.colorClass : '');
|
|
687
|
+
const tipIcon = item.tooltip ? '<span class="dk-tip" data-tooltip="' + esc(item.tooltip) + '">i</span>' : '';
|
|
688
|
+
return '<div class="dk-kpi">' +
|
|
689
|
+
tipIcon +
|
|
690
|
+
'<div class="dk-kpi-label">' + esc(item.label) + '</div>' +
|
|
691
|
+
'<div class="' + cls + '">' + esc(item.value) + '</div>' +
|
|
692
|
+
'</div>';
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function renderKpiCard(containerOrSelector, item) {
|
|
696
|
+
const container = resolveContainer('renderKpiCard', containerOrSelector);
|
|
697
|
+
container.innerHTML = _kpiCardHtml(item);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ── renderKpiStrip ────────────────────────────────────────────────────────────
|
|
701
|
+
// items: [{ label, value, colorClass? }]
|
|
702
|
+
// options: { stripTitle? }
|
|
703
|
+
function renderKpiStrip(containerOrSelector, items, options) {
|
|
704
|
+
const container = resolveContainer('renderKpiStrip', containerOrSelector);
|
|
705
|
+
const { stripTitle } = options || {};
|
|
706
|
+
let html = '';
|
|
707
|
+
if (stripTitle) html += '<div class="dk-toolbar"><span class="dk-table-title">' + esc(stripTitle) + '</span></div>';
|
|
708
|
+
html += '<div class="dk-kpi-strip">' +
|
|
709
|
+
(items || []).map(_kpiCardHtml).join('') +
|
|
710
|
+
'</div>';
|
|
711
|
+
container.innerHTML = html;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ── renderInfoGrid ────────────────────────────────────────────────────────────
|
|
715
|
+
// items: [[label, value, colorClass?], ...] — renders a responsive key/value grid
|
|
716
|
+
// options: { gridTitle? }
|
|
717
|
+
function renderInfoGrid(containerOrSelector, items, options) {
|
|
718
|
+
const container = resolveContainer('renderInfoGrid', containerOrSelector);
|
|
719
|
+
const { gridTitle } = options || {};
|
|
720
|
+
let html = '';
|
|
721
|
+
if (gridTitle) html += '<div class="dk-toolbar"><span class="dk-table-title">' + esc(gridTitle) + '</span></div>';
|
|
722
|
+
html += '<div class="dk-info-grid">' +
|
|
723
|
+
(items || []).map(function (item) {
|
|
724
|
+
const cls = 'dk-val' + (item[2] ? ' ' + item[2] : '');
|
|
725
|
+
return '<div class="dk-info-row">' +
|
|
726
|
+
'<span class="dk-lbl">' + esc(item[0]) + '</span>' +
|
|
727
|
+
'<span class="' + cls + '">' + esc(item[1] != null ? item[1] : '—') + '</span>' +
|
|
728
|
+
'</div>';
|
|
729
|
+
}).join('') +
|
|
730
|
+
'</div>';
|
|
731
|
+
container.innerHTML = html;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ── renderTotalsStrip ─────────────────────────────────────────────────────────
|
|
735
|
+
// items: [[label, value, colorClass?], ...] — renders a horizontal totals bar
|
|
736
|
+
// options: { stripTitle? }
|
|
737
|
+
function renderTotalsStrip(containerOrSelector, items, options) {
|
|
738
|
+
const container = resolveContainer('renderTotalsStrip', containerOrSelector);
|
|
739
|
+
const { stripTitle } = options || {};
|
|
740
|
+
let html = '';
|
|
741
|
+
if (stripTitle) html += '<div class="dk-toolbar"><span class="dk-table-title">' + esc(stripTitle) + '</span></div>';
|
|
742
|
+
html += '<div class="dk-totals-strip">' +
|
|
743
|
+
(items || []).map(function (item) {
|
|
744
|
+
const cls = 'dk-field-value' + (item[2] ? ' ' + item[2] : '');
|
|
745
|
+
return '<div class="dk-field">' +
|
|
746
|
+
'<span class="dk-field-label">' + esc(item[0]) + '</span>' +
|
|
747
|
+
'<span class="' + cls + '">' + esc(item[1] != null ? item[1] : '—') + '</span>' +
|
|
748
|
+
'</div>';
|
|
749
|
+
}).join('') +
|
|
750
|
+
'</div>';
|
|
751
|
+
container.innerHTML = html;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ── CSV download ──────────────────────────────────────────────────────────────
|
|
755
|
+
function _downloadCsv(tableEl, filename) {
|
|
756
|
+
const rows = [];
|
|
757
|
+
tableEl.querySelectorAll('tr').forEach(function (tr) {
|
|
758
|
+
const cells = Array.from(tr.querySelectorAll('th, td')).map(function (td) {
|
|
759
|
+
const v = td.dataset.csvValue !== undefined ? td.dataset.csvValue : td.textContent.trim();
|
|
760
|
+
const safe = /^[=+\-@]/.test(String(v)) && !/^-\d/.test(String(v)) ? "'" + String(v) : String(v);
|
|
761
|
+
return '"' + safe.replace(/"/g, '""') + '"';
|
|
762
|
+
});
|
|
763
|
+
if (cells.length) rows.push(cells.join(','));
|
|
764
|
+
});
|
|
765
|
+
const blob = new Blob(['' + rows.join('\r\n')], { type: 'text/csv;charset=utf-8' });
|
|
766
|
+
const a = document.createElement('a');
|
|
767
|
+
a.href = URL.createObjectURL(blob);
|
|
768
|
+
a.download = filename;
|
|
769
|
+
a.click();
|
|
770
|
+
URL.revokeObjectURL(a.href);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ── Snapshot ──────────────────────────────────────────────────────────────────
|
|
774
|
+
function _snapshot() {
|
|
775
|
+
// Freeze current filter values into attributes so cloneNode captures them.
|
|
776
|
+
document.querySelectorAll('[data-dk-header] input, [data-dk-header-right] input').forEach(function (el) {
|
|
777
|
+
el.setAttribute('value', el.value);
|
|
778
|
+
});
|
|
779
|
+
document.querySelectorAll('[data-dk-header] select, [data-dk-header-right] select').forEach(function (el) {
|
|
780
|
+
el.querySelectorAll('option').forEach(function (opt) {
|
|
781
|
+
opt.selected = opt.value === el.value;
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
const clone = document.documentElement.cloneNode(true);
|
|
786
|
+
|
|
787
|
+
// Strip auth token from System Info pre elements
|
|
788
|
+
if (_authToken) {
|
|
789
|
+
clone.querySelectorAll('pre').forEach(function (pre) {
|
|
790
|
+
if (pre.textContent.includes(_authToken))
|
|
791
|
+
pre.textContent = pre.textContent.replaceAll(_authToken, '***');
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Remove action buttons that make no sense in a static snapshot
|
|
796
|
+
['#dk-btn-refresh', '#dk-btn-snapshot'].forEach(function (sel) {
|
|
797
|
+
const el = clone.querySelector(sel);
|
|
798
|
+
if (el) el.remove();
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// Disable all inputs; keep tab buttons enabled
|
|
802
|
+
clone.querySelectorAll('input, select, button').forEach(function (el) {
|
|
803
|
+
el.setAttribute('disabled', '');
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
// Remove live scripts
|
|
807
|
+
clone.querySelectorAll('script').forEach(function (s) { s.remove(); });
|
|
808
|
+
|
|
809
|
+
// Re-attach tab-click handlers only
|
|
810
|
+
const tabScript = document.createElement('script');
|
|
811
|
+
tabScript.textContent = '(function(){' +
|
|
812
|
+
'document.querySelectorAll("nav.dk-tabs button").forEach(function(btn){' +
|
|
813
|
+
'btn.removeAttribute("disabled");' +
|
|
814
|
+
'btn.addEventListener("click",function(){' +
|
|
815
|
+
'var id=btn.id.replace("dk-tab-","");' +
|
|
816
|
+
'document.querySelectorAll(".dk-panel").forEach(function(p){p.classList.remove("active");});' +
|
|
817
|
+
'var panel=document.getElementById("dk-panel-"+id);' +
|
|
818
|
+
'if(panel)panel.classList.add("active");' +
|
|
819
|
+
'document.querySelectorAll("nav.dk-tabs button").forEach(function(b){b.classList.remove("active");});' +
|
|
820
|
+
'btn.classList.add("active");' +
|
|
821
|
+
'});' +
|
|
822
|
+
'});' +
|
|
823
|
+
'})();';
|
|
824
|
+
clone.querySelector('body').appendChild(tabScript);
|
|
825
|
+
|
|
826
|
+
const dateSlug = (_dateFromIso || '').slice(0, 10);
|
|
827
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
828
|
+
const filename = _title + '-snapshot-' + dateSlug + '-' + ts + '.html';
|
|
829
|
+
|
|
830
|
+
const blob = new Blob(['<!doctype html>\n' + clone.outerHTML], { type: 'text/html' });
|
|
831
|
+
const a = document.createElement('a');
|
|
832
|
+
a.href = URL.createObjectURL(blob);
|
|
833
|
+
a.download = filename;
|
|
834
|
+
a.click();
|
|
835
|
+
URL.revokeObjectURL(a.href);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ── System Info ───────────────────────────────────────────────────────────────
|
|
839
|
+
function _renderSystemInfo() {
|
|
840
|
+
const container = document.getElementById('dk-panel-dk-sysinfo');
|
|
841
|
+
if (!container) return;
|
|
842
|
+
const calls = _calls;
|
|
843
|
+
const okN = calls.filter(function (c) { return c.state === 'ok'; }).length;
|
|
844
|
+
const errN = calls.filter(function (c) { return c.state === 'error'; }).length;
|
|
845
|
+
const runN = calls.filter(function (c) { return c.state === 'running'; }).length;
|
|
846
|
+
const totMs = calls.reduce(function (a, c) { return a + (c.clientMs || 0); }, 0);
|
|
847
|
+
const totB = calls.reduce(function (a, c) { return a + (c.bytes || 0); }, 0);
|
|
848
|
+
const hasCors = calls.some(function (c) { return c.state === 'error' && c.status === null; });
|
|
849
|
+
|
|
850
|
+
const callsHtml = calls.length === 0
|
|
851
|
+
? '<p style="color:var(--muted);font-size:12px">No calls yet.</p>'
|
|
852
|
+
: calls.map(function (c) {
|
|
853
|
+
const statusLabel = c.status === null
|
|
854
|
+
? '<span class="dk-s-net">NET</span>'
|
|
855
|
+
: c.state === 'ok'
|
|
856
|
+
? '<span class="dk-s-ok">' + c.status + '</span>'
|
|
857
|
+
: '<span class="dk-s-err">' + c.status + '</span>';
|
|
858
|
+
return '<details class="dk-call"><summary>' +
|
|
859
|
+
'<span style="color:var(--muted);font-size:11px">#' + c.index + '</span>' +
|
|
860
|
+
statusLabel +
|
|
861
|
+
'<span style="flex:1">' + esc(c.label) + '</span>' +
|
|
862
|
+
'<span style="color:var(--muted);font-size:11px">' + (c.clientMs != null ? c.clientMs + 'ms' : '—') + '</span>' +
|
|
863
|
+
(c.serverMs != null ? '<span style="color:var(--muted);font-size:11px">srv:' + c.serverMs + 'ms</span>' : '') +
|
|
864
|
+
(c.rowCount != null ? '<span style="color:var(--muted);font-size:11px">' + c.rowCount + ' rows</span>' : '') +
|
|
865
|
+
'<span style="color:var(--muted);font-size:10px">' + c.ts.slice(11, 19) + '</span>' +
|
|
866
|
+
'</summary><div class="dk-call-body">' +
|
|
867
|
+
(c.error ? '<div class="dk-s-err" style="margin-bottom:6px">' + esc(c.error) + '</div>' : '') +
|
|
868
|
+
'<p style="font-size:11px;color:var(--muted);margin:0 0 4px">Data Request:</p>' +
|
|
869
|
+
'<pre>' + esc(c.sql) + '</pre>' +
|
|
870
|
+
(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>' : '') +
|
|
871
|
+
'<div class="dk-resp-head">' +
|
|
872
|
+
'<span>Response:</span><button type="button" class="dk-resp-toggle" aria-label="Toggle response">−</button>' +
|
|
873
|
+
'</div>' +
|
|
874
|
+
'<pre class="dk-resp-body">' + esc(JSON.stringify(c.response, null, 2)) + '</pre>' +
|
|
875
|
+
'</div></details>';
|
|
876
|
+
}).join('');
|
|
877
|
+
|
|
878
|
+
container.innerHTML =
|
|
879
|
+
'<div class="dk-sysinfo">' +
|
|
880
|
+
(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>' : '') +
|
|
881
|
+
'<div><h3>Context</h3><table class="dk-sys-table">' +
|
|
882
|
+
'<tr><td>Version</td><td>' + esc(_gitVer) + '</td></tr>' +
|
|
883
|
+
'<tr><td>Dashboard title</td><td>' + esc(_title) + '</td></tr>' +
|
|
884
|
+
'<tr><td>Hierarchy ID</td><td>' + (_hierarchyId != null ? _hierarchyId : '—') + '</td></tr>' +
|
|
885
|
+
'<tr><td>Timezone</td><td>' + esc(_timezone || '—') + '</td></tr>' +
|
|
886
|
+
'<tr><td>Day window (UTC)</td><td>' + esc(_dateFromIso) + ' → ' + esc(_dateToIso) + '</td></tr>' +
|
|
887
|
+
'</table></div>' +
|
|
888
|
+
'<div><h3>Totals</h3><table class="dk-sys-table">' +
|
|
889
|
+
'<tr><td>Calls</td><td>' + calls.length + ' (' + okN + ' ok · ' + errN + ' error' + (runN ? ' · ' + runN + ' running' : '') + ')</td></tr>' +
|
|
890
|
+
'<tr><td>Client duration</td><td>' + totMs + ' ms</td></tr>' +
|
|
891
|
+
'<tr><td>Bytes received</td><td>' + fmtInt(totB) + '</td></tr>' +
|
|
892
|
+
'</table></div>' +
|
|
893
|
+
'<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>' +
|
|
894
|
+
callsHtml + '</div>' +
|
|
895
|
+
'</div>';
|
|
896
|
+
|
|
897
|
+
container.querySelectorAll('.dk-resp-toggle').forEach(function (btn) {
|
|
898
|
+
btn.addEventListener('click', function () {
|
|
899
|
+
const pre = btn.parentElement.nextElementSibling;
|
|
900
|
+
pre.hidden = !pre.hidden;
|
|
901
|
+
btn.textContent = pre.hidden ? '+' : '−';
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
const expBtn = document.getElementById('dk-sysinfo-export');
|
|
906
|
+
if (expBtn) {
|
|
907
|
+
expBtn.addEventListener('click', function () {
|
|
908
|
+
const payload = {
|
|
909
|
+
title: _title,
|
|
910
|
+
hierarchyId: _hierarchyId, timezone: _timezone,
|
|
911
|
+
dateFromIso: _dateFromIso, dateToIso: _dateToIso,
|
|
912
|
+
calls: _calls.map(function (c) {
|
|
913
|
+
return Object.assign({}, c, { requestHeaders: { 'Content-Type': 'application/json', Authorization: 'Bearer ***' } });
|
|
914
|
+
}),
|
|
915
|
+
};
|
|
916
|
+
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
|
917
|
+
const a = document.createElement('a');
|
|
918
|
+
a.href = URL.createObjectURL(blob);
|
|
919
|
+
a.download = 'system-info-' + (_dateFromIso || '').slice(0, 10) + '-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
|
|
920
|
+
a.click();
|
|
921
|
+
URL.revokeObjectURL(a.href);
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ── Header wiring ─────────────────────────────────────────────────────────────
|
|
927
|
+
function _buildHeader(title) {
|
|
928
|
+
const header = document.querySelector('[data-dk-header]');
|
|
929
|
+
if (!header) return;
|
|
930
|
+
|
|
931
|
+
// Collect dashboard-provided content before clearing
|
|
932
|
+
const dashboardNodes = Array.from(header.childNodes);
|
|
933
|
+
|
|
934
|
+
const titleEl = document.createElement('span');
|
|
935
|
+
titleEl.className = 'dk-title';
|
|
936
|
+
titleEl.textContent = title;
|
|
937
|
+
|
|
938
|
+
const spacer = document.createElement('div');
|
|
939
|
+
spacer.className = 'dk-spacer';
|
|
940
|
+
|
|
941
|
+
const btnRefresh = document.createElement('button');
|
|
942
|
+
btnRefresh.id = 'dk-btn-refresh';
|
|
943
|
+
btnRefresh.textContent = 'Refresh';
|
|
944
|
+
btnRefresh.disabled = true;
|
|
945
|
+
btnRefresh.addEventListener('click', function () {
|
|
946
|
+
_invalidateAll();
|
|
947
|
+
_loadSection(_activeTabId);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
const pill = document.createElement('div');
|
|
951
|
+
pill.id = 'dk-pill';
|
|
952
|
+
pill.className = 'dk-pill';
|
|
953
|
+
pill.textContent = 'idle';
|
|
954
|
+
|
|
955
|
+
// Rebuild: title | spacer | dashboard filters | Refresh | Snapshot | pill
|
|
956
|
+
header.innerHTML = '';
|
|
957
|
+
header.appendChild(titleEl);
|
|
958
|
+
header.appendChild(spacer);
|
|
959
|
+
dashboardNodes.forEach(function (n) { header.appendChild(n); });
|
|
960
|
+
header.appendChild(btnRefresh);
|
|
961
|
+
header.appendChild(pill);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ── DashboardKit.init ────────────────────────────────────────────────────────────
|
|
965
|
+
// A throw here leaves an empty document with no "Ask AI to fix" to click, so the
|
|
966
|
+
// report is posted immediately. No sectionId: the server refuses init with one.
|
|
967
|
+
function init(opts) {
|
|
968
|
+
try {
|
|
969
|
+
_init(opts);
|
|
970
|
+
} catch (e) {
|
|
971
|
+
console.error('[DashboardKit] init failed:', e);
|
|
972
|
+
console.log('[DashboardKit] init failed:', e);
|
|
973
|
+
window.parent.postMessage({
|
|
974
|
+
type: 'DASHBOARD_ERROR',
|
|
975
|
+
kind: 'script', phase: 'init',
|
|
976
|
+
error: { message: e.message || String(e), stack: e.stack || null },
|
|
977
|
+
title: (opts && opts.title) || '',
|
|
978
|
+
}, '*');
|
|
979
|
+
throw e;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function _init(opts) {
|
|
984
|
+
_title = opts.title || '';
|
|
985
|
+
if (opts.gitVersion) _gitVer = opts.gitVersion;
|
|
986
|
+
_initTooltip();
|
|
987
|
+
|
|
988
|
+
// Build internal section list, append System Info last
|
|
989
|
+
_sections = (opts.sections || []).map(function (s) {
|
|
990
|
+
return { id: s.id, label: s.label, tooltip: s.tooltip, load: s.load, loaded: false, errors: 0 };
|
|
991
|
+
});
|
|
992
|
+
_sections.push({ id: 'dk-sysinfo', label: 'System Info', load: null, loaded: false, errors: 0 });
|
|
993
|
+
|
|
994
|
+
// Build header controls
|
|
995
|
+
_buildHeader(_title);
|
|
996
|
+
|
|
997
|
+
// Build nav + panels for each section
|
|
998
|
+
const nav = document.querySelector('[data-dk-tabs]') || _createNav();
|
|
999
|
+
nav.id = 'dk-tabs';
|
|
1000
|
+
nav.classList.add('dk-tabs');
|
|
1001
|
+
const content = document.querySelector('.dk-content') || _createContent();
|
|
1002
|
+
|
|
1003
|
+
_sections.forEach(function (s) {
|
|
1004
|
+
if (!document.getElementById('dk-panel-' + s.id)) {
|
|
1005
|
+
const panel = document.createElement('section');
|
|
1006
|
+
panel.id = 'dk-panel-' + s.id;
|
|
1007
|
+
panel.className = 'dk-panel';
|
|
1008
|
+
content.appendChild(panel);
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// Wait message
|
|
1013
|
+
const wait = document.createElement('div');
|
|
1014
|
+
wait.id = 'dk-wait';
|
|
1015
|
+
wait.className = 'dk-wait';
|
|
1016
|
+
wait.textContent = 'Waiting for dashboard parameters…';
|
|
1017
|
+
content.insertBefore(wait, content.firstChild);
|
|
1018
|
+
|
|
1019
|
+
// Enable refresh after first boot
|
|
1020
|
+
const origOnMessage = _onMessage;
|
|
1021
|
+
window.addEventListener('message', function (e) {
|
|
1022
|
+
origOnMessage(e);
|
|
1023
|
+
if (_hasBooted) {
|
|
1024
|
+
const btn = document.getElementById('dk-btn-refresh');
|
|
1025
|
+
if (btn) btn.disabled = false;
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function _createNav() {
|
|
1031
|
+
const nav = document.createElement('nav');
|
|
1032
|
+
nav.id = 'dk-tabs';
|
|
1033
|
+
nav.className = 'dk-tabs';
|
|
1034
|
+
document.body.appendChild(nav);
|
|
1035
|
+
return nav;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function _createContent() {
|
|
1039
|
+
const div = document.createElement('div');
|
|
1040
|
+
div.className = 'dk-content';
|
|
1041
|
+
document.body.appendChild(div);
|
|
1042
|
+
return div;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
1046
|
+
const DashboardKit = {
|
|
1047
|
+
init,
|
|
1048
|
+
get filters() {
|
|
1049
|
+
return {
|
|
1050
|
+
dateFromIso: _dateFromIso,
|
|
1051
|
+
dateToIso: _dateToIso,
|
|
1052
|
+
timezone: _timezone,
|
|
1053
|
+
hierarchyId: _hierarchyId,
|
|
1054
|
+
};
|
|
1055
|
+
},
|
|
1056
|
+
// Helpers are also passed into each load() function — that is the canonical
|
|
1057
|
+
// way to use them. They are mirrored here so DashboardKit.<helper>(…) works too.
|
|
1058
|
+
runQuery,
|
|
1059
|
+
setSectionState,
|
|
1060
|
+
renderTable,
|
|
1061
|
+
renderKpiCard,
|
|
1062
|
+
renderKpiStrip,
|
|
1063
|
+
renderInfoGrid,
|
|
1064
|
+
renderTotalsStrip,
|
|
1065
|
+
formatCurrency,
|
|
1066
|
+
formatInteger: fmtInt,
|
|
1067
|
+
formatPercent,
|
|
1068
|
+
formatDate: fmtDate,
|
|
1069
|
+
formatDatetime: fmtDatetime,
|
|
1070
|
+
colorClass,
|
|
1071
|
+
escHtml: esc,
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
// Expose primitives directly (called inside load functions via destructuring).
|
|
1075
|
+
// Deprecated: prefer the load() arguments or DashboardKit.<helper>; these bare
|
|
1076
|
+
// globals remain for backwards compatibility and may be removed later.
|
|
1077
|
+
global.DashboardKit = DashboardKit;
|
|
1078
|
+
global.runQuery = runQuery;
|
|
1079
|
+
global.setSectionState = setSectionState;
|
|
1080
|
+
global.renderTable = renderTable;
|
|
1081
|
+
global.renderKpiCard = renderKpiCard;
|
|
1082
|
+
global.renderKpiStrip = renderKpiStrip;
|
|
1083
|
+
global.renderInfoGrid = renderInfoGrid;
|
|
1084
|
+
global.renderTotalsStrip = renderTotalsStrip;
|
|
1085
|
+
global.formatCurrency = formatCurrency;
|
|
1086
|
+
global.formatInteger = fmtInt;
|
|
1087
|
+
global.formatPercent = formatPercent;
|
|
1088
|
+
global.formatDate = fmtDate;
|
|
1089
|
+
global.formatDatetime = fmtDatetime;
|
|
1090
|
+
global.colorClass = colorClass;
|
|
1091
|
+
global.escHtml = esc;
|
|
1092
|
+
|
|
1093
|
+
}(window));
|