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