2 * @file adminDashboard.js
3 * Dashboard-Visualisierung fuer das dbxAdmin-Modul.
5 * Die Lib rendert bewusst ohne Fremdchart-Library:
6 * - Counter-Animationen
9 * - Speedometer fuer Request-/DB-Timer
13 * <section data-dbx="lib=adminDashboard">
14 * <canvas data-admin-gauge="0.110"></canvas>
18(function (window, document) {
21 if (!window.dbx || !window.dbx.feature) {
22 console.error("[dbx][adminDashboard] dbx core missing");
26 const dbx = window.dbx;
36 grid: "rgba(42,59,84,.12)",
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();
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;
52 function format(num) {
53 return Math.round(num).toLocaleString("de-DE");
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();
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);
70 window.requestAnimationFrame(step);
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]")
80 if (!select || !root.contains(select)) return;
82 if (attr(select, "data-sysmsg-level-select", "") === "form") {
83 const form = select.closest("form");
85 if (typeof form.requestSubmit === "function") form.requestSubmit();
91 const control = select.closest(".dbx-admin-dashboard-sysmsg-level-control");
92 const save = control ? control.querySelector("a[data-sysmsg-level-save]") : null;
94 save.href = attr(save, "data-save-base", "") + "&sys_msg_level=" + encodeURIComponent(select.value);
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));
109 if (canvas.width !== width || canvas.height !== height) {
110 canvas.width = width;
111 canvas.height = height;
114 return { width, height, ratio };
117 function drawSpark(canvas) {
118 const values = attr(canvas, "data-admin-spark", "")
120 .map(v => number(v, 0))
121 .filter(v => Number.isFinite(v));
123 if (!values.length) return;
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;
134 ctx.clearRect(0, 0, w, h);
135 ctx.lineWidth = 2 * size.ratio;
136 ctx.strokeStyle = colors.teal;
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);
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)");
152 ctx.lineTo(w - pad, h - pad);
153 ctx.lineTo(pad, h - pad);
155 ctx.fillStyle = gradient;
159 function drawRing(root) {
160 const holder = root.querySelector(".dbx-admin-dashboard-health");
163 const canvas = holder.querySelector("canvas");
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;
173 const radius = Math.min(w, h) * .38;
175 ctx.clearRect(0, 0, w, h);
176 ctx.lineWidth = 10 * size.ratio;
177 ctx.lineCap = "round";
179 ctx.strokeStyle = "rgba(42,59,84,.10)";
181 ctx.arc(cx, cy, radius, 0, Math.PI * 2);
184 ctx.strokeStyle = value >= 75 ? colors.green : (value >= 45 ? colors.amber : colors.red);
186 ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + (Math.PI * 2 * value / 100));
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;
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);
208 function arcFor(ms) {
209 return start + (end - start) * (1 - Math.max(0, Math.min(1, ms / max)));
212 function drawArc(fromMs, toMs, color, alpha) {
214 ctx.globalAlpha = alpha;
215 ctx.strokeStyle = color;
216 ctx.lineWidth = 13 * ratio;
217 ctx.lineCap = "butt";
219 ctx.arc(cx, cy, radius, arcFor(fromMs), arcFor(toMs));
224 ctx.clearRect(0, 0, w, h);
225 ctx.lineWidth = 13 * ratio;
226 ctx.lineCap = "round";
228 ctx.strokeStyle = "rgba(42,59,84,.12)";
230 ctx.arc(cx, cy, radius, start, end);
233 drawArc(6000, 4000, colors.red, .35);
234 drawArc(4000, 2000, colors.amber, .38);
235 drawArc(2000, 0, colors.green, .38);
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;
246 ctx.arc(cx, cy, radius, start, angle);
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;
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);
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);
266 ctx.strokeStyle = pointerColor;
267 ctx.lineWidth = 3 * ratio;
270 ctx.lineTo(cx + Math.cos(angle) * (radius - 8 * ratio), cy + Math.sin(angle) * (radius - 8 * ratio));
273 ctx.fillStyle = "#fff";
275 ctx.arc(cx, cy, 8 * ratio, 0, Math.PI * 2);
277 ctx.strokeStyle = pointerColor;
278 ctx.lineWidth = 2 * ratio;
282 function parseBars(canvas) {
284 const data = JSON.parse(attr(canvas, "data-admin-bars", "[]"));
285 return Array.isArray(data) ? data : [];
287 dbx.warn("[adminDashboard] bar data invalid", err);
292 function drawBars(canvas) {
293 const rows = parseBars(canvas);
294 if (!rows.length) return;
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;
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";
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;
319 ctx.lineTo(w - padX, y);
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;
330 const gradient = ctx.createLinearGradient(0, y, 0, y + barH);
331 gradient.addColorStop(0, tone);
332 gradient.addColorStop(1, "rgba(40,120,184,.50)");
334 ctx.fillStyle = gradient;
335 roundedRect(ctx, x, y, barW, barH, 8 * ratio);
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));
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);
349 function roundedRect(ctx, x, y, width, height, radius) {
350 const r = Math.min(radius, width / 2, height / 2);
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);
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);
369 function dashboardRoot(ctx) {
370 if (ctx && ctx.matches && ctx.matches("[data-admin-dashboard='1']")) {
373 if (ctx && ctx.querySelector) {
374 return ctx.querySelector("[data-admin-dashboard='1']");
376 return document.querySelector("[data-admin-dashboard='1']");
379 function dashboardWork(root) {
380 return root ? root.querySelector("#dbx_admin_dashboard_work") : null;
383 function navSectionFromLink(link) {
384 return attr(link, "data-admin-nav", "");
387 const UI_LIB = "adminDashboard";
388 const UI_ID = "admin-dashboard";
389 const UI_KEY_SECTION = "section";
390 const DEFAULT_SECTION = "hero";
392 function sectionFromUrl(url) {
395 const parsed = new URL(String(url), window.location.href);
396 return String(parsed.searchParams.get("dbx_run2") || "").trim();
402 function dashboardRootFromEvent(data) {
403 const direct = dashboardRoot(data && data.root);
404 if (direct) return direct;
406 const target = data && data.targetElement;
407 if (target && target.closest) {
408 return target.closest("[data-admin-dashboard='1']");
411 const source = data && data.source;
412 if (source && source.closest) {
413 return source.closest("[data-admin-dashboard='1']");
419 function getUiSection() {
420 if (!dbx.uiGet) return DEFAULT_SECTION;
422 const section = String(dbx.uiGet(UI_LIB, UI_ID, UI_KEY_SECTION, "") || "").trim();
423 return section || DEFAULT_SECTION;
426 function setUiSection(section) {
427 if (!section || !dbx.uiSet) return;
428 dbx.uiSet(UI_LIB, UI_ID, UI_KEY_SECTION, section);
431 function navLink(root, section) {
432 if (!root || !section) return null;
433 return root.querySelector(`[data-admin-nav="${section}"].list-group-item`);
436 function workSection(root) {
437 const work = dashboardWork(root);
438 return work ? String(work.getAttribute("data-admin-section") || DEFAULT_SECTION) : DEFAULT_SECTION;
441 function setWorkSection(root, section) {
442 const work = dashboardWork(root);
443 if (work && section) {
444 work.setAttribute("data-admin-section", section);
448 function setActiveNav(root, link) {
449 if (!root || !link) return;
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");
456 const section = navSectionFromLink(link);
458 setUiSection(section);
459 setWorkSection(root, section);
463 function setActiveNavBySection(root, section) {
464 const link = navLink(root, section);
465 if (link) setActiveNav(root, link);
468 function ajaxRootForLink(link) {
469 if (!link || !link.closest) return null;
470 return link.closest("[data-dbx-ajax-root='1']");
473 function syncDashboardSection(root) {
474 const section = getUiSection();
475 const link = navLink(root, section);
477 setUiSection(DEFAULT_SECTION);
478 const fallbackLink = navLink(root, DEFAULT_SECTION);
479 if (fallbackLink) setActiveNav(root, fallbackLink);
480 scheduleRedraw(root);
484 const currentSection = workSection(root);
485 setActiveNav(root, link);
487 if (currentSection !== section && dbx.ajax && typeof dbx.ajax.run === "function") {
488 const ajaxRoot = ajaxRootForLink(link);
490 dbx.ajax.run(ajaxRoot, link);
495 scheduleRedraw(root);
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);
505 const fromUrl = sectionFromUrl(data && data.url);
506 if (fromUrl) return fromUrl;
511 function handleDashboardAjaxBefore(data) {
512 const root = dashboardRootFromEvent(data);
515 const source = data && data.source;
516 if (source && source.getAttribute && source.hasAttribute("data-admin-nav") && root.contains(source)) {
517 setActiveNav(root, source);
521 function handleDashboardAjaxAfter(data) {
522 const root = dashboardRootFromEvent(data);
525 const section = resolveNavSection(data, root);
527 setActiveNavBySection(root, section);
530 scheduleRedraw(root);
533 function scheduleRedraw(root) {
536 window.requestAnimationFrame(() => {
537 refreshContent(root);
538 window.setTimeout(() => refreshContent(root), 60);
539 window.setTimeout(() => refreshContent(root), 220);
543 function refreshContent(root) {
546 const work = dashboardWork(root);
547 const scope = work || root;
549 scope.querySelectorAll("[data-admin-value]").forEach(animateValue);
553 dbx.adminDashboard = dbx.adminDashboard || {
557 bindSysmsgControls(root);
558 syncDashboardSection(root);
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);
569 window.setTimeout(() => {
570 root.classList.add("is-ready");
571 scheduleRedraw(root);
574 if (!root.__dbxAdminDashboardResize) {
575 root.__dbxAdminDashboardResize = true;
576 window.addEventListener("resize", () => redraw(root), { passive: true });
581 const root = dashboardRoot(ctx);
582 if (root) scheduleRedraw(root);
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);
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);
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);
609 dbx.feature.register("adminDashboard", {
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"]
619 ["js", "lib", "ajax.js"]
621 init: function (el) {
622 dbx.adminDashboard.init(el);
624 rescan: function (ctx) {
625 dbx.adminDashboard.rescan(ctx || document);