diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock index 86f2fba..602d889 100644 --- a/Overlord-Server/bun.lock +++ b/Overlord-Server/bun.lock @@ -10,6 +10,7 @@ "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", "geoip-lite": "latest", + "gridstack": "^12.6.0", "jose": "^6.1.3", "msgpackr": "1.11.8", "qrcode": "^1.5.4", @@ -316,6 +317,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gridstack": ["gridstack@12.6.0", "", {}, "sha512-dUrqsormSybFn/2P4Dz8AgprftKD5e/IiV7UmC0XLQU+G+/WtkAeFiCSNLoAGhPDXoJ/O61Xtj3gljY/Ds83yQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "highlight.js": ["highlight.js@11.9.0", "", {}, "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw=="], diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index cb0d92b..8ce510b 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -21,6 +21,7 @@ "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", "geoip-lite": "latest", + "gridstack": "^12.6.0", "jose": "^6.1.3", "msgpackr": "1.11.8", "qrcode": "^1.5.4", diff --git a/Overlord-Server/public/assets/dashboard-stats.js b/Overlord-Server/public/assets/dashboard-stats.js index 61ff587..37439b0 100644 --- a/Overlord-Server/public/assets/dashboard-stats.js +++ b/Overlord-Server/public/assets/dashboard-stats.js @@ -5,12 +5,22 @@ let osChart = null; const STATS_COLLAPSED_KEY = "overlord_dashboard_stats_collapsed"; const STATS_HEIGHT_KEY = "overlord_dashboard_stats_height"; const STATS_ORDER_KEY = "overlord_dashboard_stats_order"; +const STATS_LAYOUT_KEY = "overlord_dashboard_stats_gridstack_layout"; +const STATS_HIDDEN_KEY = "overlord_dashboard_stats_hidden"; const PULSE_ORDER_KEY = "overlord_dashboard_pulse_order"; const DEFAULT_STATS_HEIGHT = 142; const MIN_STATS_HEIGHT = 112; const MAX_STATS_HEIGHT = 280; const DEFAULT_CARD_ORDER = ["online", "sessions", "trend", "fleet", "pulse"]; const DEFAULT_PULSE_ORDER = ["api", "memory", "ping"]; +const DEFAULT_GRID_LAYOUT = { + online: { x: 0, y: 0, w: 2, h: 2 }, + sessions: { x: 2, y: 0, w: 2, h: 2 }, + trend: { x: 4, y: 0, w: 3, h: 2 }, + fleet: { x: 7, y: 0, w: 3, h: 2 }, + pulse: { x: 10, y: 0, w: 2, h: 2 }, +}; +let statsGrid = null; const palette = { text: "#cbd5e1", @@ -291,6 +301,239 @@ function applyHeightMode(height) { } } +function getStatsCellHeight() { + return Math.max(48, Math.round(getSavedHeight() / 2)); +} + +function resizeCharts() { + requestAnimationFrame(() => { + onlineChart?.resize(); + osChart?.resize(); + }); +} + +function syncStatsWidgetHeights() { + if (!statsGrid?.engine?.nodes) return; + const cellHeight = Number(statsGrid.opts?.cellHeight) || getStatsCellHeight(); + for (const node of statsGrid.engine.nodes) { + const content = node.el?.querySelector?.(".grid-stack-item-content"); + if (!content) continue; + const estimated = Math.max(64, (Number(node.h) || 2) * cellHeight - 12); + content.style.setProperty("--dashboard-stats-height", `${estimated}px`); + } +} + +function readSavedGridLayout() { + try { + const parsed = JSON.parse(localStorage.getItem(STATS_LAYOUT_KEY) || "[]"); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((item) => DEFAULT_CARD_ORDER.includes(item?.id)) + .map((item) => ({ + id: item.id, + x: Math.max(0, Number(item.x) || 0), + y: Math.max(0, Number(item.y) || 0), + w: Math.max(1, Number(item.w) || DEFAULT_GRID_LAYOUT[item.id]?.w || 2), + h: Math.max(1, Number(item.h) || DEFAULT_GRID_LAYOUT[item.id]?.h || 2), + })); + } catch { + return []; + } +} + +function readHiddenStatsCards() { + try { + const parsed = JSON.parse(localStorage.getItem(STATS_HIDDEN_KEY) || "[]"); + if (!Array.isArray(parsed)) return []; + return parsed.filter((id) => DEFAULT_CARD_ORDER.includes(id)); + } catch { + return []; + } +} + +function saveHiddenStatsCards(hidden) { + const cleaned = Array.from(hidden).filter((id) => DEFAULT_CARD_ORDER.includes(id)); + localStorage.setItem(STATS_HIDDEN_KEY, JSON.stringify(cleaned)); +} + +function isStatsGridManaged(el) { + return !!statsGrid?.engine?.nodes?.some((node) => node.el === el); +} + +function saveStatsGridLayout() { + if (!statsGrid?.engine?.nodes) return; + const layoutMap = new Map( + DEFAULT_CARD_ORDER.map((id) => [id, { id, ...DEFAULT_GRID_LAYOUT[id] }]), + ); + + for (const item of readSavedGridLayout()) { + layoutMap.set(item.id, item); + } + + for (const node of statsGrid.engine.nodes) { + const id = node.el?.dataset?.dashboardCard || node.id; + if (!DEFAULT_CARD_ORDER.includes(id)) continue; + layoutMap.set(id, { id, x: node.x, y: node.y, w: node.w, h: node.h }); + } + + const layout = DEFAULT_CARD_ORDER.map((id) => layoutMap.get(id)); + localStorage.setItem(STATS_LAYOUT_KEY, JSON.stringify(layout)); +} + +function applyGridLayout(layout) { + if (!statsGrid) return; + statsGrid.batchUpdate(); + for (const item of layout) { + const el = document.querySelector(`[data-dashboard-card="${item.id}"]`); + if (el) statsGrid.update(el, { x: item.x, y: item.y, w: item.w, h: item.h }); + } + statsGrid.batchUpdate(false); + syncStatsWidgetHeights(); + resizeCharts(); +} + +function setStatsCardHidden(id, hidden, persist = true) { + if (!DEFAULT_CARD_ORDER.includes(id)) return; + const el = document.querySelector(`[data-dashboard-card="${id}"]`); + if (!el) return; + + if (hidden) { + if (statsGrid && isStatsGridManaged(el)) statsGrid.removeWidget(el, false); + el.classList.add("dashboard-card-hidden"); + } else { + el.classList.remove("dashboard-card-hidden"); + if (statsGrid && !isStatsGridManaged(el)) { + statsGrid.makeWidget(el); + const layout = readSavedGridLayout().find((item) => item.id === id); + statsGrid.update(el, { ...(layout || DEFAULT_GRID_LAYOUT[id]) }); + } + } + + if (persist) { + const nextHidden = new Set(readHiddenStatsCards()); + if (hidden) nextHidden.add(id); + else nextHidden.delete(id); + saveHiddenStatsCards(nextHidden); + saveStatsGridLayout(); + } + + syncStatsWidgetHeights(); + resizeCharts(); +} + +function applyHiddenStatsCards() { + const hidden = new Set(readHiddenStatsCards()); + for (const id of DEFAULT_CARD_ORDER) { + setStatsCardHidden(id, hidden.has(id), false); + } +} + +function syncStatsCardMenu() { + const panel = $("dashboard-card-menu-panel"); + if (!panel) return; + const hidden = new Set(readHiddenStatsCards()); + panel.querySelectorAll("[data-dashboard-card-toggle]").forEach((input) => { + const id = input.dataset.dashboardCardToggle; + input.checked = !hidden.has(id); + }); +} + +function initStatsCardMenu() { + const menu = $("dashboard-card-menu"); + const panel = $("dashboard-card-menu-panel"); + if (!panel || panel.dataset.bound === "true") return; + + panel.dataset.bound = "true"; + syncStatsCardMenu(); + panel.querySelectorAll("[data-dashboard-card-toggle]").forEach((input) => { + input.addEventListener("change", () => { + setStatsCardHidden(input.dataset.dashboardCardToggle, !input.checked); + }); + }); + + menu?.addEventListener("toggle", () => { + if (menu.open) syncStatsCardMenu(); + }); +} + +function applySavedGridLayout() { + const saved = readSavedGridLayout(); + if (saved.length) { + applyGridLayout(saved); + return; + } + + // Preserve older drag-only ordering by converting it into default-size widgets. + const order = readSavedOrder(); + if (!order.length || order.join("|") === DEFAULT_CARD_ORDER.join("|")) return; + const migrated = order.map((id, index) => ({ + id, + ...(DEFAULT_GRID_LAYOUT[id] || { w: 2, h: 2 }), + x: index === 0 ? 0 : undefined, + y: index === 0 ? 0 : undefined, + })); + let x = 0; + for (const item of migrated) { + const width = item.w || 2; + if (x + width > 12) x = 0; + item.x = x; + item.y = 0; + x += width; + } + applyGridLayout(migrated); +} + +function resetStatsGridLayout() { + localStorage.removeItem(STATS_LAYOUT_KEY); + localStorage.removeItem(STATS_HIDDEN_KEY); + for (const id of DEFAULT_CARD_ORDER) { + setStatsCardHidden(id, false, false); + } + syncStatsCardMenu(); + if (!statsGrid) { + const content = $("dashboard-stats-content"); + if (content) applySavedOrder(content); + return; + } + applyGridLayout(DEFAULT_CARD_ORDER.map((id) => ({ id, ...DEFAULT_GRID_LAYOUT[id] }))); +} + +function initStatsGrid(content) { + if (!content || content.dataset.gridstackBound === "true") return; + const api = window.GridStack?.GridStack || window.GridStack; + if (typeof api?.init !== "function") { + applySavedOrder(content); + applyHiddenStatsCards(); + return; + } + + content.dataset.gridstackBound = "true"; + statsGrid = api.init({ + column: 12, + cellHeight: getStatsCellHeight(), + margin: 6, + animate: true, + float: false, + handle: ".grid-stack-item-content", + draggable: { + handle: ".grid-stack-item-content", + appendTo: "body", + scroll: true, + }, + resizable: { handles: "se" }, + }, content); + + applySavedGridLayout(); + applyHiddenStatsCards(); + statsGrid.on("change dragstop resizestop", () => { + syncStatsWidgetHeights(); + saveStatsGridLayout(); + resizeCharts(); + }); + syncStatsWidgetHeights(); + resizeCharts(); +} + function updateOnlineChart(history, snapshot) { if (!onlineChart) return; const rows = aggregateOnlineHistory(history, snapshot); @@ -403,18 +646,15 @@ function initStatsToggle() { const resizer = $("dashboard-stats-resizer"); if (!shell || !button) return; - const resizeCharts = () => { - requestAnimationFrame(() => { - onlineChart?.resize(); - osChart?.resize(); - }); - }; - const setHeight = (value, persist = true) => { const height = normalizeHeight(value); shell.style.setProperty("--dashboard-stats-height", `${height}px`); resizer?.setAttribute("aria-valuenow", String(height)); if (persist) localStorage.setItem(STATS_HEIGHT_KEY, String(height)); + if (statsGrid) { + statsGrid.cellHeight(getStatsCellHeight()); + syncStatsWidgetHeights(); + } applyHeightMode(height); resizeCharts(); }; @@ -429,7 +669,8 @@ function initStatsToggle() { localStorage.setItem(STATS_COLLAPSED_KEY, collapsed ? "true" : "false"); }; - initCardReorder(); + initStatsGrid($("dashboard-stats-content")); + initStatsCardMenu(); initPulseReorder(); setHeight(getSavedHeight(), false); setCollapsed(localStorage.getItem(STATS_COLLAPSED_KEY) === "true"); @@ -483,10 +724,9 @@ function initStatsToggle() { resetButton?.addEventListener("click", () => { setHeight(DEFAULT_STATS_HEIGHT); setCollapsed(false); + resetStatsGridLayout(); localStorage.removeItem(STATS_ORDER_KEY); localStorage.removeItem(PULSE_ORDER_KEY); - const content = $("dashboard-stats-content"); - if (content) applySavedOrder(content); const pulseList = document.querySelector(".dashboard-health-list"); if (pulseList) applyPulseOrder(pulseList); }); diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index 18bd091..a53c724 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -4466,11 +4466,56 @@ body.sb-ready #top-nav { border-color: rgba(71, 85, 105, 0.8); } -.dashboard-stats-content { +.dashboard-card-menu { + position: relative; +} + +.dashboard-card-menu > summary { + list-style: none; +} + +.dashboard-card-menu > summary::-webkit-details-marker { + display: none; +} + +.dashboard-card-menu-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 45; display: grid; - grid-template-columns: minmax(150px, 0.85fr) minmax(150px, 0.85fr) minmax(260px, 1.45fr) minmax(260px, 1.35fr) minmax(220px, 1fr); - gap: 12px; - align-items: stretch; + gap: 6px; + width: 196px; + padding: 10px; + border: 1px solid rgba(51, 65, 85, 0.82); + border-radius: 8px; + background: rgba(15, 23, 42, 0.98); + box-shadow: 0 18px 38px rgba(2, 6, 23, 0.42); +} + +.dashboard-card-menu-panel label { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px 7px; + border-radius: 6px; + color: #cbd5e1; + font-size: var(--text-xs); + font-weight: 700; + cursor: pointer; +} + +.dashboard-card-menu-panel label:hover { + background: rgba(51, 65, 85, 0.62); +} + +.dashboard-card-menu-panel input { + accent-color: #38bdf8; +} + +.dashboard-stats-content { + min-height: calc(var(--dashboard-stats-height) + 12px); } .dashboard-stats-shell.is-collapsed .dashboard-stats-content, @@ -4513,6 +4558,7 @@ body.sb-ready #top-nav { .dashboard-health-card { position: relative; min-width: 0; + height: 100%; border: 1px solid rgba(51, 65, 85, 0.68); border-radius: 10px; background: rgba(15, 23, 42, 0.72); @@ -4540,11 +4586,44 @@ body.sb-ready #top-nav { border-color: rgba(56, 189, 248, 0.62); } +.dashboard-stats-content .grid-stack-item-content { + overflow: hidden !important; +} + +.dashboard-stats-content .grid-stack-item.dashboard-card-hidden { + display: none; +} + +.dashboard-stats-content .grid-stack-item.ui-draggable-dragging .grid-stack-item-content, +.dashboard-stats-content .grid-stack-item.ui-resizable-resizing .grid-stack-item-content { + border-color: rgba(56, 189, 248, 0.62); + box-shadow: 0 18px 42px rgba(2, 6, 23, 0.42); +} + +.dashboard-stats-content .grid-stack-placeholder > .placeholder-content, +.dashboard-stats-content .grid-stack-placeholder > .grid-stack-item-content { + border: 1px dashed rgba(56, 189, 248, 0.58); + border-radius: 10px; + background: rgba(8, 47, 73, 0.22); +} + +.dashboard-stats-content .ui-resizable-se { + right: 8px !important; + bottom: 8px !important; + width: 14px; + height: 14px; + opacity: 0; + transition: opacity 140ms ease; +} + +.dashboard-stats-content .grid-stack-item:hover .ui-resizable-se { + opacity: 0.72; +} + .dashboard-stat-card { display: flex; flex-direction: column; justify-content: space-between; - height: var(--dashboard-stats-height); padding: 14px; } @@ -4603,7 +4682,6 @@ body.sb-ready #top-nav { .dashboard-chart-card, .dashboard-health-card { - height: var(--dashboard-stats-height); padding: 12px 14px 10px; } @@ -4653,6 +4731,12 @@ body.sb-ready #top-nav { padding-bottom: 8px; } +.dashboard-chart-wrap canvas { + display: block; + max-width: 100%; + max-height: 100%; +} + .dashboard-health-list { position: relative; z-index: 1; @@ -4741,37 +4825,7 @@ body.sb-ready #top-nav { box-shadow: 0 0 0 4px rgba(100, 116, 139, 0.1); } -@media (max-width: 1380px) { - .dashboard-stats-content { - grid-template-columns: repeat(2, minmax(150px, 1fr)) repeat(2, minmax(260px, 1.35fr)); - } - - .dashboard-health-card { - grid-column: span 2; - } -} - -@media (max-width: 940px) { - .dashboard-stats-content { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .dashboard-chart-card, - .dashboard-health-card { - grid-column: span 2; - } -} - @media (max-width: 620px) { - .dashboard-stats-content { - grid-template-columns: 1fr; - } - - .dashboard-chart-card, - .dashboard-health-card { - grid-column: auto; - } - .dashboard-stat-value { font-size: clamp(1.65rem, calc(var(--dashboard-stats-height) / 3.4), 3rem); } diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html index cf9071b..33901cc 100644 --- a/Overlord-Server/public/index.html +++ b/Overlord-Server/public/index.html @@ -12,11 +12,13 @@ + + @@ -34,6 +36,18 @@ Server version: loading...