Add dashboard auto refresh

This commit is contained in:
DariusIII
2026-03-10 11:06:52 +01:00
parent 9bb6534dd4
commit 7a60339994
4 changed files with 265 additions and 69 deletions
+186 -61
View File
@@ -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 = '<p class="text-gray-500 dark:text-gray-400 text-center py-4">No recent activity</p>';
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 = '<div class="w-8 h-8 ' + a.icon_bg + ' rounded-full flex items-center justify-center mr-3 shrink-0"><i class="fas fa-' + a.icon + ' ' + a.icon_color + ' text-sm"></i></div><div class="flex-1"><p class="text-sm text-gray-800 dark:text-gray-200">' + escapeHtml(a.message) + '</p><p class="text-xs text-gray-500 dark:text-gray-400">' + escapeHtml(a.created_at) + '</p></div>';
this.$el.appendChild(div);
});
const ts = document.getElementById('activity-last-updated');
if (ts) ts.innerHTML = '<i class="fas fa-sync-alt"></i> 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();
},
}));
-1
View File
@@ -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
+26 -7
View File
@@ -1,11 +1,29 @@
@extends('layouts.admin')
@section('content')
<div x-data="adminDashboard" class="space-y-6" id="adminDashboard">
@php
$dashboardLastRefreshedAt = now()->format('H:i:s');
@endphp
<div x-data="adminDashboard"
id="adminDashboard"
data-refresh-url="{{ route('admin.index') }}"
data-refresh-interval="{{ 15 * 60 * 1000 }}">
<div class="space-y-6" data-dashboard-content>
<!-- Welcome Section -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6">
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-2">Admin Dashboard</h2>
<p class="text-gray-600">Welcome to the administration panel</p>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-2">Admin Dashboard</h2>
<p class="text-gray-600">Welcome to the administration panel</p>
</div>
<div class="rounded-lg bg-gray-50 dark:bg-gray-900 px-4 py-3 text-sm text-gray-600 dark:text-gray-300 lg:text-right">
<p class="font-medium text-gray-700 dark:text-gray-200">
<i class="fas fa-sync-alt mr-1"></i> Last dashboard refresh: {{ $dashboardLastRefreshedAt }}
</p>
<p class="mt-1 text-xs text-green-600 dark:text-green-400">Auto-refreshes every 15 minutes</p>
</div>
</div>
</div>
<!-- Stats Grid -->
@@ -373,8 +391,8 @@
<!-- Last Updated Indicator -->
<div class="mb-4 text-right">
<span class="text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-sync-alt mr-1"></i>Last updated: <span data-metric="last-updated">{{ now()->format('H:i:s') }}</span>
<span class="ml-2 text-green-600 dark:text-green-400">(Auto-updates every minute)</span>
<i class="fas fa-sync-alt mr-1"></i>Last dashboard refresh: <span data-metric="last-updated">{{ $dashboardLastRefreshedAt }}</span>
<span class="ml-2 text-green-600 dark:text-green-400">(Auto-refreshes every 15 minutes)</span>
</span>
</div>
@@ -492,10 +510,10 @@
Recent User Activity
</span>
<span class="text-xs text-gray-500 dark:text-gray-400" id="activity-last-updated">
<i class="fas fa-sync-alt"></i> Auto-refreshes every 20 minutes
<i class="fas fa-sync-alt"></i> Last dashboard refresh: {{ $dashboardLastRefreshedAt }}
</span>
</h3>
<div x-data="recentActivity" class="space-y-3" id="recent-activity-container" data-refresh-url="{{ route('admin.api.user-activity.recent') }}">
<div class="space-y-3" id="recent-activity-container">
@if(isset($recent_activity) && count($recent_activity) > 0)
@foreach($recent_activity as $activity)
<div class="flex items-start activity-item rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors">
@@ -545,6 +563,7 @@
</div>
</div>
</div>
</div>
@endsection
{{-- Chart.js is bundled via Vite (imported in resources/js/alpine/components/admin/dashboard.js) --}}
@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class AdminDashboardAutoRefreshTest extends TestCase
{
public function test_dashboard_blade_has_auto_refresh_container(): void
{
$bladePath = resource_path('views/admin/dashboard.blade.php');
$this->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);
}
}