mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
Add dashboard auto refresh
This commit is contained in:
@@ -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();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user