From 7a6033999465a30ec2ecb5187415080074dbefc5 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 10 Mar 2026 11:06:52 +0100 Subject: [PATCH] Add dashboard auto refresh --- .../js/alpine/components/admin/dashboard.js | 247 +++++++++++++----- resources/js/alpine/lazy-loader.js | 1 - resources/views/admin/dashboard.blade.php | 33 ++- .../Feature/AdminDashboardAutoRefreshTest.php | 53 ++++ 4 files changed, 265 insertions(+), 69 deletions(-) create mode 100644 tests/Feature/AdminDashboardAutoRefreshTest.php 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) + '

'; - this.$el.appendChild(div); - }); - const ts = document.getElementById('activity-last-updated'); - if (ts) ts.innerHTML = ' Last updated: ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - }) - .catch(() => {}); + _initPercentChart(canvasId, label, backgroundColor, borderColor) { + const canvas = document.getElementById(canvasId); + + if (!canvas) { + return; + } + + const data = this._parseChartData(canvas.dataset.history); + + if (data.length === 0) { + return; + } + + const chart = new Chart(canvas, { + type: 'line', + data: { + labels: data.map(item => item.time), + datasets: [{ + label, + data: data.map(item => item.value), + backgroundColor, + borderColor, + borderWidth: 2, + fill: true, + tension: 0.4, + }], + }, + options: { + responsive: true, + maintainAspectRatio: true, + scales: { + y: { + beginAtZero: true, + max: 100, + ticks: { + callback: value => value + '%', + }, + }, + }, + plugins: { + legend: { + display: false, + }, + }, + }, + }); + + this._charts.push(chart); + }, + + _destroyCharts() { + this._charts.forEach(chart => chart.destroy()); + this._charts = []; }, destroy() { - if (this._interval) clearInterval(this._interval); - } + if (this._refreshInterval) { + window.clearInterval(this._refreshInterval); + } + + if (this._visibilityHandler) { + document.removeEventListener('visibilitychange', this._visibilityHandler); + } + + this._destroyCharts(); + }, })); diff --git a/resources/js/alpine/lazy-loader.js b/resources/js/alpine/lazy-loader.js index 67351633a..261601337 100644 --- a/resources/js/alpine/lazy-loader.js +++ b/resources/js/alpine/lazy-loader.js @@ -42,7 +42,6 @@ const lazyComponentMap = { // --- Admin components (includes Chart.js — heavy) --- 'adminDashboard': () => import('./components/admin/dashboard.js'), - 'recentActivity': () => import('./components/admin/dashboard.js'), // same file 'adminGroups': () => import('./components/admin/groups.js'), 'adminFeatures': () => import('./components/admin/features.js'), 'adminUserEdit': () => import('./components/admin/features.js'), // same file diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index f33f969e7..0c333bfc0 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -1,11 +1,29 @@ @extends('layouts.admin') @section('content') -
+@php + $dashboardLastRefreshedAt = now()->format('H:i:s'); +@endphp + +
+
-

Admin Dashboard

-

Welcome to the administration panel

+
+
+

Admin Dashboard

+

Welcome to the administration panel

+
+
+

+ Last dashboard refresh: {{ $dashboardLastRefreshedAt }} +

+

Auto-refreshes every 15 minutes

+
+
@@ -373,8 +391,8 @@
- Last updated: {{ now()->format('H:i:s') }} - (Auto-updates every minute) + Last dashboard refresh: {{ $dashboardLastRefreshedAt }} + (Auto-refreshes every 15 minutes)
@@ -492,10 +510,10 @@ Recent User Activity - Auto-refreshes every 20 minutes + Last dashboard refresh: {{ $dashboardLastRefreshedAt }} -
+
@if(isset($recent_activity) && count($recent_activity) > 0) @foreach($recent_activity as $activity)
@@ -545,6 +563,7 @@
+
@endsection {{-- Chart.js is bundled via Vite (imported in resources/js/alpine/components/admin/dashboard.js) --}} diff --git a/tests/Feature/AdminDashboardAutoRefreshTest.php b/tests/Feature/AdminDashboardAutoRefreshTest.php new file mode 100644 index 000000000..22472bc40 --- /dev/null +++ b/tests/Feature/AdminDashboardAutoRefreshTest.php @@ -0,0 +1,53 @@ +assertFileExists($bladePath); + + $content = file_get_contents($bladePath); + + $this->assertStringContainsString('x-data="adminDashboard"', $content); + $this->assertStringContainsString("data-refresh-url=\"{{ route('admin.index') }}\"", $content); + $this->assertStringContainsString('data-refresh-interval="{{ 15 * 60 * 1000 }}"', $content); + $this->assertStringContainsString('data-dashboard-content', $content); + } + + public function test_dashboard_blade_labels_match_fifteen_minute_refresh(): void + { + $bladePath = resource_path('views/admin/dashboard.blade.php'); + + $this->assertFileExists($bladePath); + + $content = file_get_contents($bladePath); + + $this->assertStringContainsString('Auto-refreshes every 15 minutes', $content); + $this->assertStringContainsString('Last dashboard refresh:', $content); + $this->assertStringContainsString('$dashboardLastRefreshedAt', $content); + $this->assertStringNotContainsString("@php(\$dashboardLastRefreshedAt", $content); + $this->assertStringNotContainsString('Auto-refreshes every 20 minutes', $content); + $this->assertStringNotContainsString('Auto-updates every minute', $content); + } + + public function test_dashboard_component_refreshes_widgets_without_page_reload(): void + { + $scriptPath = resource_path('js/alpine/components/admin/dashboard.js'); + + $this->assertFileExists($scriptPath); + + $content = file_get_contents($scriptPath); + + $this->assertStringContainsString('_refreshDashboard(url)', $content); + $this->assertStringContainsString('DOMParser().parseFromString', $content); + $this->assertStringContainsString('#adminDashboard [data-dashboard-content]', $content); + $this->assertStringContainsString('currentContent.innerHTML = nextContent.innerHTML', $content); + $this->assertStringContainsString('15 * 60 * 1000', $content); + } +}