dbXapp 2.0
RAD, CMS, Module und Runtime-IDE fuer dbXapp
Loading...
Searching...
No Matches
adminDashboard.js
Go to the documentation of this file.
1/*!
2 * @file adminDashboard.js
3 * Dashboard-Visualisierung fuer das dbxAdmin-Modul.
4 *
5 * Die Lib rendert bewusst ohne Fremdchart-Library:
6 * - Counter-Animationen
7 * - Health-Ring
8 * - Sparklines
9 * - Speedometer fuer Request-/DB-Timer
10 *
11 * Beispiel:
12 * ```html
13 * <section data-dbx="lib=adminDashboard">
14 * <canvas data-admin-gauge="0.110"></canvas>
15 * </section>
16 * ```
17 */
18(function (window, document) {
19 "use strict";
20
21 if (!window.dbx || !window.dbx.feature) {
22 console.error("[dbx][adminDashboard] dbx core missing");
23 return;
24 }
25
26 const dbx = window.dbx;
27 const colors = {
28 teal: "#0f9f9a",
29 cyan: "#2878b8",
30 green: "#2d9b65",
31 amber: "#c9841b",
32 navy: "#14213d",
33 red: "#c94f4f",
34 purple: "#7b5bb7",
35 slate: "#58677a",
36 grid: "rgba(42,59,84,.12)",
37 muted: "#637083"
38 };
39
40 function attr(el, name, def = "") {
41 if (!el || !el.getAttribute) return def;
42 const value = el.getAttribute(name);
43 return value == null ? def : String(value).trim();
44 }
45
46 function number(value, def = 0) {
47 const raw = String(value == null ? "" : value).replace(/\./g, "").replace(",", ".");
48 const num = parseFloat(raw);
49 return Number.isFinite(num) ? num : def;
50 }
51
52 function format(num) {
53 return Math.round(num).toLocaleString("de-DE");
54 }
55
56 function animateValue(el) {
57 const target = number(attr(el, "data-admin-value", el.textContent), 0);
58 const suffix = String(el.textContent || "").includes("%") ? "%" : "";
59 const start = performance.now();
60 const duration = 760;
61
62 function step(now) {
63 const t = Math.min(1, (now - start) / duration);
64 const eased = 1 - Math.pow(1 - t, 3);
65 const value = target * eased;
66 el.textContent = format(value) + suffix;
67 if (t < 1) window.requestAnimationFrame(step);
68 }
69
70 window.requestAnimationFrame(step);
71 }
72
73 function canvasSize(canvas) {
74 const rect = canvas.getBoundingClientRect();
75 const ratio = window.devicePixelRatio || 1;
76 const baseWidth = Math.max(1, number(canvas.getAttribute("width"), 1));
77 const baseHeight = Math.max(1, number(canvas.getAttribute("height"), 1));
78 const cssWidth = rect.width > 2 ? rect.width : baseWidth;
79 const cssHeight = rect.height > 2 ? rect.height : Math.max(1, cssWidth * (baseHeight / baseWidth));
80 const width = Math.max(1, Math.round(cssWidth * ratio));
81 const height = Math.max(1, Math.round(cssHeight * ratio));
82
83 if (canvas.width !== width || canvas.height !== height) {
84 canvas.width = width;
85 canvas.height = height;
86 }
87
88 return { width, height, ratio };
89 }
90
91 function drawSpark(canvas) {
92 const values = attr(canvas, "data-admin-spark", "")
93 .split(",")
94 .map(v => number(v, 0))
95 .filter(v => Number.isFinite(v));
96
97 if (!values.length) return;
98
99 const ctx = canvas.getContext("2d");
100 const size = canvasSize(canvas);
101 const w = size.width;
102 const h = size.height;
103 const min = Math.min(...values);
104 const max = Math.max(...values);
105 const range = Math.max(1, max - min);
106 const pad = 8 * size.ratio;
107
108 ctx.clearRect(0, 0, w, h);
109 ctx.lineWidth = 2 * size.ratio;
110 ctx.strokeStyle = colors.teal;
111 ctx.beginPath();
112
113 values.forEach((value, index) => {
114 const x = pad + ((w - pad * 2) / Math.max(1, values.length - 1)) * index;
115 const y = h - pad - ((value - min) / range) * (h - pad * 2);
116 if (index === 0) ctx.moveTo(x, y);
117 else ctx.lineTo(x, y);
118 });
119
120 ctx.stroke();
121
122 const gradient = ctx.createLinearGradient(0, 0, 0, h);
123 gradient.addColorStop(0, "rgba(15,159,154,.22)");
124 gradient.addColorStop(1, "rgba(15,159,154,0)");
125
126 ctx.lineTo(w - pad, h - pad);
127 ctx.lineTo(pad, h - pad);
128 ctx.closePath();
129 ctx.fillStyle = gradient;
130 ctx.fill();
131 }
132
133 function drawRing(root) {
134 const holder = root.querySelector(".dbx-admin-dashboard-health");
135 if (!holder) return;
136
137 const canvas = holder.querySelector("canvas");
138 if (!canvas) return;
139
140 const value = Math.max(0, Math.min(100, number(attr(holder, "data-admin-ring", "0"), 0)));
141 const ctx = canvas.getContext("2d");
142 const size = canvasSize(canvas);
143 const w = size.width;
144 const h = size.height;
145 const cx = w / 2;
146 const cy = h / 2;
147 const radius = Math.min(w, h) * .38;
148
149 ctx.clearRect(0, 0, w, h);
150 ctx.lineWidth = 10 * size.ratio;
151 ctx.lineCap = "round";
152
153 ctx.strokeStyle = "rgba(42,59,84,.10)";
154 ctx.beginPath();
155 ctx.arc(cx, cy, radius, 0, Math.PI * 2);
156 ctx.stroke();
157
158 ctx.strokeStyle = value >= 75 ? colors.green : (value >= 45 ? colors.amber : colors.red);
159 ctx.beginPath();
160 ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + (Math.PI * 2 * value / 100));
161 ctx.stroke();
162 }
163
164 function drawGauge(canvas) {
165 const value = Math.max(0, number(attr(canvas, "data-admin-gauge-value", "0"), 0));
166 const max = Math.max(1, number(attr(canvas, "data-admin-gauge-max", "1"), 1));
167 const ctx = canvas.getContext("2d");
168 const size = canvasSize(canvas);
169 const w = size.width;
170 const h = size.height;
171 const ratio = size.ratio;
172 const cx = w / 2;
173 const cy = h * .86;
174 const radius = Math.min(w * .42, h * .64);
175 const start = Math.PI;
176 const end = Math.PI * 2;
177 const pct = Math.max(0, Math.min(1, value / max));
178 const displayPct = 1 - pct;
179 const angle = start + (end - start) * displayPct;
180 const pointerColor = displayPct < .34 ? colors.red : (displayPct < .68 ? colors.amber : colors.green);
181
182 function arcFor(ms) {
183 return start + (end - start) * (1 - Math.max(0, Math.min(1, ms / max)));
184 }
185
186 function drawArc(fromMs, toMs, color, alpha) {
187 ctx.save();
188 ctx.globalAlpha = alpha;
189 ctx.strokeStyle = color;
190 ctx.lineWidth = 13 * ratio;
191 ctx.lineCap = "butt";
192 ctx.beginPath();
193 ctx.arc(cx, cy, radius, arcFor(fromMs), arcFor(toMs));
194 ctx.stroke();
195 ctx.restore();
196 }
197
198 ctx.clearRect(0, 0, w, h);
199 ctx.lineWidth = 13 * ratio;
200 ctx.lineCap = "round";
201
202 ctx.strokeStyle = "rgba(42,59,84,.12)";
203 ctx.beginPath();
204 ctx.arc(cx, cy, radius, start, end);
205 ctx.stroke();
206
207 drawArc(6000, 4000, colors.red, .35);
208 drawArc(4000, 2000, colors.amber, .38);
209 drawArc(2000, 0, colors.green, .38);
210
211 const gradient = ctx.createLinearGradient(cx - radius, 0, cx + radius, 0);
212 gradient.addColorStop(0, colors.red);
213 gradient.addColorStop(.34, colors.red);
214 gradient.addColorStop(.35, colors.amber);
215 gradient.addColorStop(.67, colors.amber);
216 gradient.addColorStop(.68, colors.green);
217 gradient.addColorStop(1, colors.green);
218 ctx.strokeStyle = gradient;
219 ctx.beginPath();
220 ctx.arc(cx, cy, radius, start, angle);
221 ctx.stroke();
222
223 [6000, 4000, 2000, 0].forEach(ms => {
224 const tick = arcFor(ms);
225 const inner = radius - 11 * ratio;
226 const outer = radius + 4 * ratio;
227 ctx.strokeStyle = "rgba(20,33,61,.38)";
228 ctx.lineWidth = 1.5 * ratio;
229 ctx.beginPath();
230 ctx.moveTo(cx + Math.cos(tick) * inner, cy + Math.sin(tick) * inner);
231 ctx.lineTo(cx + Math.cos(tick) * outer, cy + Math.sin(tick) * outer);
232 ctx.stroke();
233
234 ctx.fillStyle = colors.muted;
235 ctx.font = `${9.5 * ratio}px system-ui, -apple-system, Segoe UI, sans-serif`;
236 ctx.textAlign = ms === 6000 ? "left" : (ms === 0 ? "right" : "center");
237 ctx.fillText(ms === 0 ? "0" : `${Math.round(ms / 1000)}s`, cx + Math.cos(tick) * (radius + 14 * ratio), cy + Math.sin(tick) * (radius + 14 * ratio) + 4 * ratio);
238 });
239
240 ctx.strokeStyle = pointerColor;
241 ctx.lineWidth = 3 * ratio;
242 ctx.beginPath();
243 ctx.moveTo(cx, cy);
244 ctx.lineTo(cx + Math.cos(angle) * (radius - 8 * ratio), cy + Math.sin(angle) * (radius - 8 * ratio));
245 ctx.stroke();
246
247 ctx.fillStyle = "#fff";
248 ctx.beginPath();
249 ctx.arc(cx, cy, 8 * ratio, 0, Math.PI * 2);
250 ctx.fill();
251 ctx.strokeStyle = pointerColor;
252 ctx.lineWidth = 2 * ratio;
253 ctx.stroke();
254 }
255
256 function parseBars(canvas) {
257 try {
258 const data = JSON.parse(attr(canvas, "data-admin-bars", "[]"));
259 return Array.isArray(data) ? data : [];
260 } catch (err) {
261 dbx.warn("[adminDashboard] bar data invalid", err);
262 return [];
263 }
264 }
265
266 function drawBars(canvas) {
267 const rows = parseBars(canvas);
268 if (!rows.length) return;
269
270 const ctx = canvas.getContext("2d");
271 const size = canvasSize(canvas);
272 const w = size.width;
273 const h = size.height;
274 const ratio = size.ratio;
275 const padX = 42 * ratio;
276 const padY = 30 * ratio;
277 const chartW = w - padX * 2;
278 const chartH = h - padY * 2;
279 const max = Math.max(1, ...rows.map(row => number(row.value, 0)));
280 const gap = 16 * ratio;
281 const barW = (chartW - gap * (rows.length - 1)) / rows.length;
282
283 ctx.clearRect(0, 0, w, h);
284 ctx.font = `${12 * ratio}px system-ui, -apple-system, Segoe UI, sans-serif`;
285 ctx.textBaseline = "middle";
286
287 for (let i = 0; i <= 4; i++) {
288 const y = padY + chartH - (chartH / 4) * i;
289 ctx.strokeStyle = colors.grid;
290 ctx.lineWidth = 1 * ratio;
291 ctx.beginPath();
292 ctx.moveTo(padX, y);
293 ctx.lineTo(w - padX, y);
294 ctx.stroke();
295 }
296
297 rows.forEach((row, index) => {
298 const value = number(row.value, 0);
299 const barH = Math.max(3 * ratio, (value / max) * chartH);
300 const x = padX + index * (barW + gap);
301 const y = padY + chartH - barH;
302 const tone = colors[row.tone] || colors.teal;
303
304 const gradient = ctx.createLinearGradient(0, y, 0, y + barH);
305 gradient.addColorStop(0, tone);
306 gradient.addColorStop(1, "rgba(40,120,184,.50)");
307
308 ctx.fillStyle = gradient;
309 roundedRect(ctx, x, y, barW, barH, 8 * ratio);
310 ctx.fill();
311
312 ctx.fillStyle = colors.navy;
313 ctx.textAlign = "center";
314 ctx.font = `${13 * ratio}px system-ui, -apple-system, Segoe UI, sans-serif`;
315 ctx.fillText(format(value), x + barW / 2, Math.max(16 * ratio, y - 12 * ratio));
316
317 ctx.fillStyle = colors.muted;
318 ctx.font = `${12 * ratio}px system-ui, -apple-system, Segoe UI, sans-serif`;
319 ctx.fillText(String(row.label || ""), x + barW / 2, h - 14 * ratio);
320 });
321 }
322
323 function roundedRect(ctx, x, y, width, height, radius) {
324 const r = Math.min(radius, width / 2, height / 2);
325 ctx.beginPath();
326 ctx.moveTo(x + r, y);
327 ctx.lineTo(x + width - r, y);
328 ctx.quadraticCurveTo(x + width, y, x + width, y + r);
329 ctx.lineTo(x + width, y + height);
330 ctx.lineTo(x, y + height);
331 ctx.lineTo(x, y + r);
332 ctx.quadraticCurveTo(x, y, x + r, y);
333 ctx.closePath();
334 }
335
336 function redraw(root) {
337 root.querySelectorAll("canvas[data-admin-spark]").forEach(drawSpark);
338 root.querySelectorAll("canvas[data-admin-gauge]").forEach(drawGauge);
339 root.querySelectorAll("canvas[data-admin-bars]").forEach(drawBars);
340 drawRing(root);
341 }
342
343 function dashboardRoot(ctx) {
344 if (ctx && ctx.matches && ctx.matches("[data-admin-dashboard='1']")) {
345 return ctx;
346 }
347 if (ctx && ctx.querySelector) {
348 return ctx.querySelector("[data-admin-dashboard='1']");
349 }
350 return document.querySelector("[data-admin-dashboard='1']");
351 }
352
353 function dashboardWork(root) {
354 return root ? root.querySelector("#dbx_admin_dashboard_work") : null;
355 }
356
357 function navSectionFromLink(link) {
358 return attr(link, "data-admin-nav", "");
359 }
360
361 const UI_LIB = "adminDashboard";
362 const UI_ID = "admin-dashboard";
363 const UI_KEY_SECTION = "section";
364 const DEFAULT_SECTION = "hero";
365
366 function sectionFromUrl(url) {
367 if (!url) return "";
368 try {
369 const parsed = new URL(String(url), window.location.href);
370 return String(parsed.searchParams.get("dbx_run2") || "").trim();
371 } catch (err) {
372 return "";
373 }
374 }
375
376 function dashboardRootFromEvent(data) {
377 const direct = dashboardRoot(data && data.root);
378 if (direct) return direct;
379
380 const target = data && data.targetElement;
381 if (target && target.closest) {
382 return target.closest("[data-admin-dashboard='1']");
383 }
384
385 const source = data && data.source;
386 if (source && source.closest) {
387 return source.closest("[data-admin-dashboard='1']");
388 }
389
390 return null;
391 }
392
393 function getUiSection() {
394 if (!dbx.uiGet) return DEFAULT_SECTION;
395
396 const section = String(dbx.uiGet(UI_LIB, UI_ID, UI_KEY_SECTION, "") || "").trim();
397 return section || DEFAULT_SECTION;
398 }
399
400 function setUiSection(section) {
401 if (!section || !dbx.uiSet) return;
402 dbx.uiSet(UI_LIB, UI_ID, UI_KEY_SECTION, section);
403 }
404
405 function navLink(root, section) {
406 if (!root || !section) return null;
407 return root.querySelector(`[data-admin-nav="${section}"].list-group-item`);
408 }
409
410 function workSection(root) {
411 const work = dashboardWork(root);
412 return work ? String(work.getAttribute("data-admin-section") || DEFAULT_SECTION) : DEFAULT_SECTION;
413 }
414
415 function setWorkSection(root, section) {
416 const work = dashboardWork(root);
417 if (work && section) {
418 work.setAttribute("data-admin-section", section);
419 }
420 }
421
422 function setActiveNav(root, link) {
423 if (!root || !link) return;
424
425 root.querySelectorAll("[data-admin-nav].list-group-item").forEach(row => {
426 row.classList.toggle("active", row === link);
427 row.setAttribute("aria-current", row === link ? "page" : "false");
428 });
429
430 const section = navSectionFromLink(link);
431 if (section) {
432 setUiSection(section);
433 setWorkSection(root, section);
434 }
435 }
436
437 function setActiveNavBySection(root, section) {
438 const link = navLink(root, section);
439 if (link) setActiveNav(root, link);
440 }
441
442 function ajaxRootForLink(link) {
443 if (!link || !link.closest) return null;
444 return link.closest("[data-dbx-ajax-root='1']");
445 }
446
447 function syncDashboardSection(root) {
448 const section = getUiSection();
449 const link = navLink(root, section);
450 if (!link) {
451 setUiSection(DEFAULT_SECTION);
452 const fallbackLink = navLink(root, DEFAULT_SECTION);
453 if (fallbackLink) setActiveNav(root, fallbackLink);
454 scheduleRedraw(root);
455 return;
456 }
457
458 const currentSection = workSection(root);
459 setActiveNav(root, link);
460
461 if (currentSection !== section && dbx.ajax && typeof dbx.ajax.run === "function") {
462 const ajaxRoot = ajaxRootForLink(link);
463 if (ajaxRoot) {
464 dbx.ajax.run(ajaxRoot, link);
465 } else {
466 link.click();
467 }
468 } else {
469 scheduleRedraw(root);
470 }
471 }
472
473 function resolveNavSection(data, root) {
474 const source = data && data.source;
475 if (source && source.getAttribute && source.hasAttribute("data-admin-nav") && root.contains(source)) {
476 return navSectionFromLink(source);
477 }
478
479 const fromUrl = sectionFromUrl(data && data.url);
480 if (fromUrl) return fromUrl;
481
482 return "";
483 }
484
485 function handleDashboardAjaxBefore(data) {
486 const root = dashboardRootFromEvent(data);
487 if (!root) return;
488
489 const source = data && data.source;
490 if (source && source.getAttribute && source.hasAttribute("data-admin-nav") && root.contains(source)) {
491 setActiveNav(root, source);
492 }
493 }
494
495 function handleDashboardAjaxAfter(data) {
496 const root = dashboardRootFromEvent(data);
497 if (!root) return;
498
499 const section = resolveNavSection(data, root);
500 if (section) {
501 setActiveNavBySection(root, section);
502 }
503
504 scheduleRedraw(root);
505 }
506
507 function scheduleRedraw(root) {
508 if (!root) return;
509
510 window.requestAnimationFrame(() => {
511 refreshContent(root);
512 window.setTimeout(() => refreshContent(root), 60);
513 window.setTimeout(() => refreshContent(root), 220);
514 });
515 }
516
517 function refreshContent(root) {
518 if (!root) return;
519
520 const work = dashboardWork(root);
521 const scope = work || root;
522
523 scope.querySelectorAll("[data-admin-value]").forEach(animateValue);
524 redraw(root);
525 }
526
527 dbx.adminDashboard = dbx.adminDashboard || {
528 init(root) {
529 if (!root) return;
530
531 syncDashboardSection(root);
532
533 if (!root.__dbxAdminDashboardNavBound) {
534 root.__dbxAdminDashboardNavBound = true;
535 root.addEventListener("click", event => {
536 const link = event.target.closest("[data-admin-nav].list-group-item.dbxAjax");
537 if (!link || !root.contains(link)) return;
538 setActiveNav(root, link);
539 });
540 }
541
542 window.setTimeout(() => {
543 root.classList.add("is-ready");
544 scheduleRedraw(root);
545 }, 30);
546
547 if (!root.__dbxAdminDashboardResize) {
548 root.__dbxAdminDashboardResize = true;
549 window.addEventListener("resize", () => redraw(root), { passive: true });
550 }
551 },
552
553 rescan(ctx) {
554 const root = dashboardRoot(ctx);
555 if (root) scheduleRedraw(root);
556 }
557 };
558
559 if (!dbx.adminDashboard.__ajaxBeforeBound && dbx.event && typeof dbx.event.on === "function") {
560 dbx.adminDashboard.__ajaxBeforeBound = true;
561 dbx.event.on("ajax:before", data => {
562 handleDashboardAjaxBefore(data);
563 });
564 }
565
566 if (!dbx.adminDashboard.__ajaxAfterBound && dbx.event && typeof dbx.event.on === "function") {
567 dbx.adminDashboard.__ajaxAfterBound = true;
568 dbx.event.on("ajax:after", data => {
569 handleDashboardAjaxAfter(data);
570 });
571 }
572
573 if (!dbx.adminDashboard.__uiCollapseBound && dbx.event && typeof dbx.event.on === "function") {
574 dbx.adminDashboard.__uiCollapseBound = true;
575 dbx.event.on("ui:collapse", data => {
576 const panel = data && data.panel;
577 const root = panel && panel.closest ? panel.closest("[data-admin-dashboard='1']") : null;
578 if (root) window.setTimeout(() => redraw(root), 20);
579 });
580 }
581
582 dbx.feature.register("adminDashboard", {
583 scope: "element",
584 priority: "last",
585 css: [
586 ["css", "root", "vendor/twbs/bootstrap-icons/font/bootstrap-icons.css"],
587 ["css", "design", "c-form.css"],
588 ["css", "design", "c-admin.css"],
589 ["css", "root", "modules/dbxAdmin/tpl/css/admin-dashboard.css"]
590 ],
591 js: [
592 ["js", "lib", "ajax.js"]
593 ],
594 init: function (el) {
595 dbx.adminDashboard.init(el);
596 },
597 rescan: function (ctx) {
598 dbx.adminDashboard.rescan(ctx || document);
599 }
600 });
601
602})(window, document);