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 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));
83 if (canvas.width !== width || canvas.height !== height) {
85 canvas.height = height;
88 return { width, height, ratio };
91 function drawSpark(canvas) {
92 const values = attr(canvas, "data-admin-spark", "")
94 .map(v => number(v, 0))
95 .filter(v => Number.isFinite(v));
97 if (!values.length) return;
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;
108 ctx.clearRect(0, 0, w, h);
109 ctx.lineWidth = 2 * size.ratio;
110 ctx.strokeStyle = colors.teal;
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);
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)");
126 ctx.lineTo(w - pad, h - pad);
127 ctx.lineTo(pad, h - pad);
129 ctx.fillStyle = gradient;
133 function drawRing(root) {
134 const holder = root.querySelector(".dbx-admin-dashboard-health");
137 const canvas = holder.querySelector("canvas");
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;
147 const radius = Math.min(w, h) * .38;
149 ctx.clearRect(0, 0, w, h);
150 ctx.lineWidth = 10 * size.ratio;
151 ctx.lineCap = "round";
153 ctx.strokeStyle = "rgba(42,59,84,.10)";
155 ctx.arc(cx, cy, radius, 0, Math.PI * 2);
158 ctx.strokeStyle = value >= 75 ? colors.green : (value >= 45 ? colors.amber : colors.red);
160 ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + (Math.PI * 2 * value / 100));
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;
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);
182 function arcFor(ms) {
183 return start + (end - start) * (1 - Math.max(0, Math.min(1, ms / max)));
186 function drawArc(fromMs, toMs, color, alpha) {
188 ctx.globalAlpha = alpha;
189 ctx.strokeStyle = color;
190 ctx.lineWidth = 13 * ratio;
191 ctx.lineCap = "butt";
193 ctx.arc(cx, cy, radius, arcFor(fromMs), arcFor(toMs));
198 ctx.clearRect(0, 0, w, h);
199 ctx.lineWidth = 13 * ratio;
200 ctx.lineCap = "round";
202 ctx.strokeStyle = "rgba(42,59,84,.12)";
204 ctx.arc(cx, cy, radius, start, end);
207 drawArc(6000, 4000, colors.red, .35);
208 drawArc(4000, 2000, colors.amber, .38);
209 drawArc(2000, 0, colors.green, .38);
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;
220 ctx.arc(cx, cy, radius, start, angle);
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;
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);
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);
240 ctx.strokeStyle = pointerColor;
241 ctx.lineWidth = 3 * ratio;
244 ctx.lineTo(cx + Math.cos(angle) * (radius - 8 * ratio), cy + Math.sin(angle) * (radius - 8 * ratio));
247 ctx.fillStyle = "#fff";
249 ctx.arc(cx, cy, 8 * ratio, 0, Math.PI * 2);
251 ctx.strokeStyle = pointerColor;
252 ctx.lineWidth = 2 * ratio;
256 function parseBars(canvas) {
258 const data = JSON.parse(attr(canvas, "data-admin-bars", "[]"));
259 return Array.isArray(data) ? data : [];
261 dbx.warn("[adminDashboard] bar data invalid", err);
266 function drawBars(canvas) {
267 const rows = parseBars(canvas);
268 if (!rows.length) return;
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;
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";
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;
293 ctx.lineTo(w - padX, y);
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;
304 const gradient = ctx.createLinearGradient(0, y, 0, y + barH);
305 gradient.addColorStop(0, tone);
306 gradient.addColorStop(1, "rgba(40,120,184,.50)");
308 ctx.fillStyle = gradient;
309 roundedRect(ctx, x, y, barW, barH, 8 * ratio);
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));
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);
323 function roundedRect(ctx, x, y, width, height, radius) {
324 const r = Math.min(radius, width / 2, height / 2);
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);
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);
343 function dashboardRoot(ctx) {
344 if (ctx && ctx.matches && ctx.matches("[data-admin-dashboard='1']")) {
347 if (ctx && ctx.querySelector) {
348 return ctx.querySelector("[data-admin-dashboard='1']");
350 return document.querySelector("[data-admin-dashboard='1']");
353 function dashboardWork(root) {
354 return root ? root.querySelector("#dbx_admin_dashboard_work") : null;
357 function navSectionFromLink(link) {
358 return attr(link, "data-admin-nav", "");
361 const UI_LIB = "adminDashboard";
362 const UI_ID = "admin-dashboard";
363 const UI_KEY_SECTION = "section";
364 const DEFAULT_SECTION = "hero";
366 function sectionFromUrl(url) {
369 const parsed = new URL(String(url), window.location.href);
370 return String(parsed.searchParams.get("dbx_run2") || "").trim();
376 function dashboardRootFromEvent(data) {
377 const direct = dashboardRoot(data && data.root);
378 if (direct) return direct;
380 const target = data && data.targetElement;
381 if (target && target.closest) {
382 return target.closest("[data-admin-dashboard='1']");
385 const source = data && data.source;
386 if (source && source.closest) {
387 return source.closest("[data-admin-dashboard='1']");
393 function getUiSection() {
394 if (!dbx.uiGet) return DEFAULT_SECTION;
396 const section = String(dbx.uiGet(UI_LIB, UI_ID, UI_KEY_SECTION, "") || "").trim();
397 return section || DEFAULT_SECTION;
400 function setUiSection(section) {
401 if (!section || !dbx.uiSet) return;
402 dbx.uiSet(UI_LIB, UI_ID, UI_KEY_SECTION, section);
405 function navLink(root, section) {
406 if (!root || !section) return null;
407 return root.querySelector(`[data-admin-nav="${section}"].list-group-item`);
410 function workSection(root) {
411 const work = dashboardWork(root);
412 return work ? String(work.getAttribute("data-admin-section") || DEFAULT_SECTION) : DEFAULT_SECTION;
415 function setWorkSection(root, section) {
416 const work = dashboardWork(root);
417 if (work && section) {
418 work.setAttribute("data-admin-section", section);
422 function setActiveNav(root, link) {
423 if (!root || !link) return;
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");
430 const section = navSectionFromLink(link);
432 setUiSection(section);
433 setWorkSection(root, section);
437 function setActiveNavBySection(root, section) {
438 const link = navLink(root, section);
439 if (link) setActiveNav(root, link);
442 function ajaxRootForLink(link) {
443 if (!link || !link.closest) return null;
444 return link.closest("[data-dbx-ajax-root='1']");
447 function syncDashboardSection(root) {
448 const section = getUiSection();
449 const link = navLink(root, section);
451 setUiSection(DEFAULT_SECTION);
452 const fallbackLink = navLink(root, DEFAULT_SECTION);
453 if (fallbackLink) setActiveNav(root, fallbackLink);
454 scheduleRedraw(root);
458 const currentSection = workSection(root);
459 setActiveNav(root, link);
461 if (currentSection !== section && dbx.ajax && typeof dbx.ajax.run === "function") {
462 const ajaxRoot = ajaxRootForLink(link);
464 dbx.ajax.run(ajaxRoot, link);
469 scheduleRedraw(root);
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);
479 const fromUrl = sectionFromUrl(data && data.url);
480 if (fromUrl) return fromUrl;
485 function handleDashboardAjaxBefore(data) {
486 const root = dashboardRootFromEvent(data);
489 const source = data && data.source;
490 if (source && source.getAttribute && source.hasAttribute("data-admin-nav") && root.contains(source)) {
491 setActiveNav(root, source);
495 function handleDashboardAjaxAfter(data) {
496 const root = dashboardRootFromEvent(data);
499 const section = resolveNavSection(data, root);
501 setActiveNavBySection(root, section);
504 scheduleRedraw(root);
507 function scheduleRedraw(root) {
510 window.requestAnimationFrame(() => {
511 refreshContent(root);
512 window.setTimeout(() => refreshContent(root), 60);
513 window.setTimeout(() => refreshContent(root), 220);
517 function refreshContent(root) {
520 const work = dashboardWork(root);
521 const scope = work || root;
523 scope.querySelectorAll("[data-admin-value]").forEach(animateValue);
527 dbx.adminDashboard = dbx.adminDashboard || {
531 syncDashboardSection(root);
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);
542 window.setTimeout(() => {
543 root.classList.add("is-ready");
544 scheduleRedraw(root);
547 if (!root.__dbxAdminDashboardResize) {
548 root.__dbxAdminDashboardResize = true;
549 window.addEventListener("resize", () => redraw(root), { passive: true });
554 const root = dashboardRoot(ctx);
555 if (root) scheduleRedraw(root);
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);
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);
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);
582 dbx.feature.register("adminDashboard", {
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"]
592 ["js", "lib", "ajax.js"]
594 init: function (el) {
595 dbx.adminDashboard.init(el);
597 rescan: function (ctx) {
598 dbx.adminDashboard.rescan(ctx || document);