diff --git a/resources/js/alpine/components/admin/dashboard.js b/resources/js/alpine/components/admin/dashboard.js index 7a655ccd8..b6f483f68 100644 --- a/resources/js/alpine/components/admin/dashboard.js +++ b/resources/js/alpine/components/admin/dashboard.js @@ -1,22 +1,88 @@ /** - * Alpine.data('adminDashboard') - Admin dashboard charts (Chart.js) - * Alpine.data('recentActivity') - Auto-refreshing recent activity + * Alpine.data('adminDashboard') - Admin dashboard charts with widget auto-refresh. */ import Alpine from '@alpinejs/csp'; import Chart from 'chart.js/auto'; -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - Alpine.data('adminDashboard', () => ({ + _charts: [], + _refreshInterval: null, + _visibilityHandler: null, + _lastRefreshAt: Date.now(), + _isRefreshing: false, + init() { this._initCharts(); + this._scheduleRefresh(); + }, + + _scheduleRefresh() { + const url = this.$el.dataset.refreshUrl; + const interval = Number.parseInt(this.$el.dataset.refreshInterval ?? '', 10) || (15 * 60 * 1000); + + if (!url) { + return; + } + + this._refreshInterval = window.setInterval(() => this._refreshDashboard(url), interval); + this._visibilityHandler = () => { + if (!document.hidden && Date.now() - this._lastRefreshAt >= interval) { + this._refreshDashboard(url); + } + }; + + document.addEventListener('visibilitychange', this._visibilityHandler); + }, + + _refreshDashboard(url) { + if (this._isRefreshing) { + return; + } + + const currentContent = this.$el.querySelector('[data-dashboard-content]'); + + if (!currentContent) { + return; + } + + this._isRefreshing = true; + + fetch(url, { + method: 'GET', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'text/html', + }, + credentials: 'same-origin', + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to refresh dashboard.'); + } + + return response.text(); + }) + .then(html => { + const parsedDocument = new DOMParser().parseFromString(html, 'text/html'); + const nextContent = parsedDocument.querySelector('#adminDashboard [data-dashboard-content]'); + + if (!nextContent) { + return; + } + + this._destroyCharts(); + currentContent.innerHTML = nextContent.innerHTML; + this._initCharts(); + this._lastRefreshAt = Date.now(); + }) + .catch(() => {}) + .finally(() => { + this._isRefreshing = false; + }); }, _initCharts() { + this._destroyCharts(); this._initChart('downloadsChart', 'bar', 'Downloads', 'rgba(34,197,94,0.7)', 'rgba(34,197,94,1)', false); this._initChart('apiHitsChart', 'line', 'API Hits', 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true); this._initChart('downloadsMinuteChart', 'line', 'Downloads', 'rgba(34,197,94,0.2)', 'rgba(34,197,94,1)', true); @@ -27,68 +93,127 @@ Alpine.data('adminDashboard', () => ({ this._initPercentChart('ramHistory30dChart', 'RAM Usage %', 'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)'); }, - _initChart(canvasId, type, label, bg, border, fill) { - const canvas = document.getElementById(canvasId); - if (!canvas) return; - const data = JSON.parse(canvas.dataset.chartData || canvas.dataset.history || '[]'); - if (data.length === 0) return; - new Chart(canvas, { - type, data: { - labels: data.map(i => i.time), - datasets: [{ label, data: data.map(i => i.count), backgroundColor: bg, borderColor: border, borderWidth: type === 'bar' ? 1 : 2, fill, tension: 0.4 }] - }, - options: { responsive: true, maintainAspectRatio: true, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }, plugins: { legend: { display: false } } } - }); + _parseChartData(rawData) { + try { + return JSON.parse(rawData || '[]'); + } catch { + return []; + } }, - _initPercentChart(canvasId, label, bg, border) { + _initChart(canvasId, type, label, backgroundColor, borderColor, fill) { const canvas = document.getElementById(canvasId); - if (!canvas) return; - const data = JSON.parse(canvas.dataset.history || '[]'); - if (data.length === 0) return; - new Chart(canvas, { - type: 'line', data: { - labels: data.map(i => i.time), - datasets: [{ label, data: data.map(i => i.value), backgroundColor: bg, borderColor: border, borderWidth: 2, fill: true, tension: 0.4 }] + + if (!canvas) { + return; + } + + const data = this._parseChartData(canvas.dataset.chartData || canvas.dataset.history); + + if (data.length === 0) { + return; + } + + const chart = new Chart(canvas, { + type, + data: { + labels: data.map(item => item.time), + datasets: [{ + label, + data: data.map(item => item.count), + backgroundColor, + borderColor, + borderWidth: type === 'bar' ? 1 : 2, + fill, + tension: 0.4, + }], + }, + options: { + responsive: true, + maintainAspectRatio: true, + scales: { + y: { + beginAtZero: true, + ticks: { + precision: 0, + }, + }, + }, + plugins: { + legend: { + display: false, + }, + }, }, - options: { responsive: true, maintainAspectRatio: true, scales: { y: { beginAtZero: true, max: 100, ticks: { callback: v => v + '%' } } }, plugins: { legend: { display: false } } } }); - } -})); -Alpine.data('recentActivity', () => ({ - _interval: null, - - init() { - const url = this.$el.dataset.refreshUrl; - if (!url) return; - this._interval = setInterval(() => this._refresh(url), 20 * 60 * 1000); - document.addEventListener('visibilitychange', () => { if (!document.hidden) this._refresh(url); }); + this._charts.push(chart); }, - _refresh(url) { - fetch(url, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, credentials: 'same-origin' }) - .then(r => { if (!r.ok) throw new Error('fail'); return r.json(); }) - .then(data => { - if (!data.success || !data.activities) return; - this.$el.innerHTML = ''; - if (data.activities.length === 0) { - this.$el.innerHTML = '
No recent activity
'; - return; - } - data.activities.forEach(a => { - const div = document.createElement('div'); - div.className = 'flex items-start activity-item rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors'; - div.innerHTML = '' + escapeHtml(a.message) + '
' + escapeHtml(a.created_at) + '
Welcome to the administration panel
+Welcome to the administration panel
++ Last dashboard refresh: {{ $dashboardLastRefreshedAt }} +
+Auto-refreshes every 15 minutes
+