Update dashboard widgets

This commit is contained in:
DariusIII
2026-07-20 13:07:56 +02:00
parent c2789fb333
commit 7906148f7f
16 changed files with 375 additions and 185 deletions
+1 -1
View File
@@ -323,7 +323,7 @@ FORUM_FRONTEND_ENABLED=false
STREAM_FORK_OUTPUT=false
# API hot-path caching, asynchronous audit persistence, and instrumentation.
# API hot-path caching, asynchronous access metadata, and instrumentation.
API_RELEASE_CACHE_TTL=600
API_RELEASE_CACHE_JITTER=60
API_RELEASE_CACHE_STALE_TTL=900
@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\UserActivityStat;
use App\Models\UserDownload;
use App\Models\UserRequest;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@@ -29,7 +27,7 @@ class BackfillUserActivityStats extends Command
*
* @var string
*/
protected $description = 'Backfill user activity stats (daily or hourly) from existing user_downloads and user_requests data';
protected $description = 'Backfill hourly activity from raw logs or daily activity from retained hourly aggregates';
/**
* Execute the console command.
@@ -77,24 +75,7 @@ class BackfillUserActivityStats extends Command
continue;
}
// Count downloads for the date
$downloadsCount = UserDownload::query()
->whereRaw('DATE(timestamp) = ?', [$date])
->count();
// Count API hits for the date
$apiHitsCount = UserRequest::query()
->whereRaw('DATE(timestamp) = ?', [$date])
->count();
// Store or update the stats
UserActivityStat::updateOrCreate(
['stat_date' => $date],
[
'downloads_count' => $downloadsCount,
'api_hits_count' => $apiHitsCount,
]
);
UserActivityStat::collectDailyStats($date);
$statsCollected++;
$progressBar->advance();
@@ -137,28 +118,7 @@ class BackfillUserActivityStats extends Command
continue;
}
// Count downloads for the hour
$downloadsCount = UserDownload::query()
->where('timestamp', '>=', $hour)
->where('timestamp', '<', Carbon::parse($hour)->addHour()->format('Y-m-d H:00:00'))
->count();
// Count API hits for the hour
$apiHitsCount = UserRequest::query()
->where('timestamp', '>=', $hour)
->where('timestamp', '<', Carbon::parse($hour)->addHour()->format('Y-m-d H:00:00'))
->count();
// Store or update the stats
DB::table('user_activity_stats_hourly')->updateOrInsert(
['stat_hour' => $hour],
[
'downloads_count' => $downloadsCount,
'api_hits_count' => $apiHitsCount,
'updated_at' => now(),
'created_at' => DB::raw('COALESCE(created_at, NOW())'),
]
);
UserActivityStat::collectHourlyStats($hour);
$statsCollected++;
$progressBar->advance();
+2 -2
View File
@@ -44,10 +44,10 @@ class CollectStats extends Command
$this->info('New users by month collected.');
RoleStat::insertUsersByRole();
$this->info('Users by role collected.');
UserActivityStat::collectDailyStats();
$this->info('User activity daily stats collected.');
UserActivityStat::collectHourlyStats();
$this->info('User activity hourly stats collected.');
UserActivityStat::collectDailyStats();
$this->info('User activity daily stats collected.');
$this->info('Site stats collected.');
}
}
+2 -5
View File
@@ -37,11 +37,8 @@ final class RecordApiUsage implements ShouldQueue
public function handle(): void
{
UserRequest::query()->insert([
'users_id' => $this->userId,
'request' => $this->requestUri,
'timestamp' => $this->occurredAt,
]);
// Legacy job retained while pre-deployment audit jobs drain.
UserRequest::recordApiRequest($this->userId, $this->requestUri, $this->occurredAt);
$lockKey = 'api_access_update:'.$this->userId;
$interval = max(1, (int) config('nntmux.api.access_update_interval', 60));
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\FailOnTimeout;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Throwable;
#[Backoff([1, 5, 30])]
#[FailOnTimeout]
final class UpdateUserApiAccess implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 4;
public int $timeout = 15;
public function __construct(
public readonly int $userId,
public readonly ?string $ip,
public readonly string $occurredAt,
) {
$this->onQueue((string) config('nntmux.api.audit_queue', 'api-audit'));
}
public function handle(): void
{
$lockKey = 'api_access_update:'.$this->userId;
$interval = max(1, (int) config('nntmux.api.access_update_interval', 60));
try {
if (! Cache::add($lockKey, true, $interval)) {
return;
}
} catch (Throwable) {
// Cache degradation must not prevent the metadata update.
}
$update = ['apiaccess' => $this->occurredAt];
if ($this->ip !== null) {
$update['host'] = $this->ip;
}
DB::table('users')->where('id', $this->userId)->update($update);
}
}
+26 -29
View File
@@ -22,29 +22,34 @@ class UserActivityStat extends Model
/**
* Collect and store user activity stats for a specific date
* This aggregates data from user_downloads and user_requests tables
* This rolls up the retained hourly aggregates so the result cannot shrink
* after the raw user_downloads and user_requests rows are pruned.
*/
public static function collectDailyStats(?string $date = null): void
{
$statDate = $date ? Carbon::parse($date)->format('Y-m-d') : Carbon::yesterday()->format('Y-m-d');
$dayStart = Carbon::parse($statDate)->startOfDay();
$dayEnd = $dayStart->copy()->addDay();
// Count downloads for the date
$downloadsCount = UserDownload::query()
->whereRaw('DATE(timestamp) = ?', [$statDate])
->count();
$totals = DB::table('user_activity_stats_hourly')
->where('stat_hour', '>=', $dayStart->format('Y-m-d H:00:00'))
->where('stat_hour', '<', $dayEnd->format('Y-m-d H:00:00'))
->selectRaw('COALESCE(SUM(downloads_count), 0) as downloads, COALESCE(SUM(api_hits_count), 0) as api_hits')
->first();
// Count API hits for the date
$apiHitsCount = UserRequest::query()
->whereRaw('DATE(timestamp) = ?', [$statDate])
->count();
$values = [
'downloads_count' => (int) ($totals->downloads ?? 0),
'api_hits_count' => (int) ($totals->api_hits ?? 0),
'updated_at' => now(),
];
// Store or update the stats
self::updateOrCreate(
if (! DB::table('user_activity_stats')->where('stat_date', $statDate)->exists()) {
$values['created_at'] = now();
}
DB::table('user_activity_stats')->updateOrInsert(
['stat_date' => $statDate],
[
'downloads_count' => $downloadsCount,
'api_hits_count' => $apiHitsCount,
]
$values,
);
}
@@ -111,9 +116,8 @@ class UserActivityStat extends Model
/**
* Get total downloads across the last N days inclusive of today.
*
* Sums closed days from `user_activity_stats` and today's closed hours
* from `user_activity_stats_hourly`. The current in-progress hour is not
* included here — callers needing live "today" totals should use
* Sums closed hours from `user_activity_stats_hourly`. The current
* in-progress hour is not included here — callers needing live totals use
* {@see UserStatsService::getSummaryStats()} which layers
* the live tables on top.
*/
@@ -134,20 +138,13 @@ class UserActivityStat extends Model
private static function sumLastNDays(int $days, string $column): int
{
$today = Carbon::now()->startOfDay();
$weekStart = Carbon::now()->subDays($days - 1)->startOfDay();
$currentHour = Carbon::now()->startOfHour();
$closedDays = (int) self::query()
->where('stat_date', '>=', $weekStart->format('Y-m-d'))
->where('stat_date', '<', $today->format('Y-m-d'))
return (int) DB::table('user_activity_stats_hourly')
->where('stat_hour', '>=', $weekStart->format('Y-m-d H:00:00'))
->where('stat_hour', '<', $currentHour->format('Y-m-d H:00:00'))
->sum($column);
$todayClosedHours = (int) DB::table('user_activity_stats_hourly')
->where('stat_hour', '>=', $today->format('Y-m-d H:00:00'))
->where('stat_hour', '<', Carbon::now()->startOfHour()->format('Y-m-d H:00:00'))
->sum($column);
return $closedDays + $todayClosedHours;
}
/**
+10 -1
View File
@@ -132,7 +132,16 @@ class UserRequest extends Model
} else {
$userID = User::query()->select(['id'])->where('api_token', $tokenOrUserId)->value('id');
}
self::query()->insert(['users_id' => $userID, 'request' => $request, 'timestamp' => now()]);
self::recordApiRequest((int) $userID, $request, now()->toDateTimeString());
}
public static function recordApiRequest(int $userId, string $request, string $occurredAt): void
{
self::query()->insert([
'users_id' => $userId,
'request' => $request,
'timestamp' => $occurredAt,
]);
}
/**
+14 -4
View File
@@ -4,8 +4,9 @@ declare(strict_types=1);
namespace App\Services\Api;
use App\Jobs\RecordApiUsage;
use App\Jobs\UpdateUserApiAccess;
use App\Models\User;
use App\Models\UserRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@@ -30,18 +31,27 @@ final class ApiUsageService
public function record(User $user, Request $request): void
{
$job = new RecordApiUsage(
$occurredAt = now()->toDateTimeString();
// Request accounting drives quotas and admin statistics, so it must be
// durable before the response returns even when no queue worker runs.
UserRequest::recordApiRequest(
$user->id,
$request->getRequestUri(),
$occurredAt,
);
$job = new UpdateUserApiAccess(
$user->id,
$request->ip(),
now()->toDateTimeString(),
$occurredAt,
);
if ((bool) config('nntmux.api.async_audit', true)) {
try {
dispatch($job);
} catch (Throwable) {
// Preserve audit and quota correctness when the queue backend is unavailable.
// Keep access metadata current when the queue backend is unavailable.
$job->handle();
}
+63 -64
View File
@@ -38,11 +38,8 @@ class UserStatsService
/**
* Get downloads per day for the last N days (inclusive of today).
*
* Source layering — designed to avoid the 1-day rolling purge of
* `user_downloads` (see routes/console.php cleanup-api-request-logs):
* - Closed days (today-(N-1) … yesterday) come from `user_activity_stats`.
* - Today is composed of closed hours from `user_activity_stats_hourly`
* + the in-progress hour from the live `user_downloads` table.
* Closed hours come from `user_activity_stats_hourly`; the current hour
* comes from the live `user_downloads` table.
*
* @return list<array<string, int|string>>
*/
@@ -65,8 +62,15 @@ class UserStatsService
*/
public function getDownloadsPerHour(int $hours = 168): array
{
// Use the aggregated hourly stats from UserActivityStat model
return UserActivityStat::getDownloadsPerHour($hours);
$result = UserActivityStat::getDownloadsPerHour($hours);
if ($result !== []) {
$result[count($result) - 1]['count'] = UserDownload::query()
->where('timestamp', '>=', Carbon::now()->startOfHour())
->count();
}
return $result;
}
/**
@@ -77,14 +81,12 @@ class UserStatsService
public function getDownloadsPerMinute(int $minutes = 60): array
{
$startTime = Carbon::now()->subMinutes($minutes);
$minuteExpression = $this->minuteBucketExpression();
$downloads = UserDownload::query()
->select(
DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00") as minute'),
DB::raw('COUNT(*) as count')
)
->selectRaw($minuteExpression.' as minute, COUNT(*) as count')
->where('timestamp', '>=', $startTime)
->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")'))
->groupByRaw($minuteExpression)
->orderBy('minute', 'asc')
->get()
->keyBy('minute');
@@ -107,9 +109,8 @@ class UserStatsService
/**
* Get API hits per day for the last N days (inclusive of today).
*
* Mirrors {@see self::getDownloadsPerDay()} — closed days from
* `user_activity_stats`, today composed of `user_activity_stats_hourly`
* + the in-progress hour from `user_requests`.
* Mirrors {@see self::getDownloadsPerDay()} using combined authenticated
* API v1/v2 and RSS requests from `user_requests`.
*
* @return list<array<string, int|string>>
*/
@@ -132,8 +133,15 @@ class UserStatsService
*/
public function getApiHitsPerHour(int $hours = 168): array
{
// Use the aggregated hourly stats from UserActivityStat model
return UserActivityStat::getApiHitsPerHour($hours);
$result = UserActivityStat::getApiHitsPerHour($hours);
if ($result !== []) {
$result[count($result) - 1]['count'] = UserRequest::query()
->where('timestamp', '>=', Carbon::now()->startOfHour())
->count();
}
return $result;
}
/**
@@ -144,15 +152,13 @@ class UserStatsService
public function getApiHitsPerMinute(int $minutes = 60): array
{
$startTime = Carbon::now()->subMinutes($minutes);
$minuteExpression = $this->minuteBucketExpression();
// Track actual API requests from user_requests table
$apiHits = UserRequest::query()
->select(
DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00") as minute'),
DB::raw('COUNT(*) as count')
)
->selectRaw($minuteExpression.' as minute, COUNT(*) as count')
->where('timestamp', '>=', $startTime)
->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")'))
->groupByRaw($minuteExpression)
->orderBy('minute', 'asc')
->get()
->keyBy('minute');
@@ -177,12 +183,8 @@ class UserStatsService
*
* Window: "today" and "(7d) = last 7 calendar days inclusive of today".
*
* The live `user_downloads` / `user_requests` tables are pruned hourly to
* the last ~24h (see routes/console.php), so we layer sources to keep the
* totals consistent regardless of when the purge ran:
* - Closed days (today-6 … yesterday) → `user_activity_stats`.
* - Today's closed hours → `user_activity_stats_hourly`.
* - Current in-progress hour → live `user_downloads` / `user_requests`.
* Closed hours come from `user_activity_stats_hourly`; the current
* in-progress hour comes from live `user_downloads` / `user_requests`.
*
* @return array{total_users: int, downloads_today: int, downloads_week: int, api_hits_today: int, api_hits_week: int}
*/
@@ -192,10 +194,9 @@ class UserStatsService
$weekStart = Carbon::now()->subDays(6)->startOfDay();
$currentHourStart = Carbon::now()->startOfHour();
// Closed days: [today-6 .. yesterday]
$historical = UserActivityStat::query()
->where('stat_date', '>=', $weekStart->format('Y-m-d'))
->where('stat_date', '<', $today->format('Y-m-d'))
$weekClosed = DB::table('user_activity_stats_hourly')
->where('stat_hour', '>=', $weekStart->format('Y-m-d H:00:00'))
->where('stat_hour', '<', $currentHourStart->format('Y-m-d H:00:00'))
->selectRaw('COALESCE(SUM(downloads_count), 0) as downloads, COALESCE(SUM(api_hits_count), 0) as api_hits')
->first();
@@ -220,9 +221,9 @@ class UserStatsService
return [
'total_users' => User::whereNull('deleted_at')->count(),
'downloads_today' => $downloadsToday,
'downloads_week' => (int) ($historical->downloads ?? 0) + $downloadsToday,
'downloads_week' => (int) ($weekClosed->downloads ?? 0) + $downloadsCurrentHour,
'api_hits_today' => $apiHitsToday,
'api_hits_week' => (int) ($historical->api_hits ?? 0) + $apiHitsToday,
'api_hits_week' => (int) ($weekClosed->api_hits ?? 0) + $apiHitsCurrentHour,
];
}
@@ -247,10 +248,8 @@ class UserStatsService
}
/**
* Build a "last N days inclusive of today" series, layering closed days
* (from `user_activity_stats`), today's closed hours (from
* `user_activity_stats_hourly`) and the current in-progress hour from a
* caller-supplied live counter.
* Build a "last N days inclusive of today" series from hourly aggregates
* plus the current in-progress hour from a caller-supplied live counter.
*
* @param callable(Carbon): int $liveCurrentHourCounter
* @return list<array{date: string, count: int}>
@@ -260,42 +259,42 @@ class UserStatsService
$today = Carbon::now()->startOfDay();
$startDate = $today->copy()->subDays($days - 1);
$historical = UserActivityStat::query()
->select('stat_date', $statColumn)
->where('stat_date', '>=', $startDate->format('Y-m-d'))
->where('stat_date', '<', $today->format('Y-m-d'))
->orderBy('stat_date', 'asc')
->get()
->keyBy(static fn (UserActivityStat $s): string => $s->stat_date instanceof Carbon
? $s->stat_date->format('Y-m-d')
: (string) $s->stat_date);
$currentHourStart = Carbon::now()->startOfHour();
$hourly = DB::table('user_activity_stats_hourly')
->select('stat_hour', $statColumn)
->where('stat_hour', '>=', $startDate->format('Y-m-d H:00:00'))
->where('stat_hour', '<', $currentHourStart->format('Y-m-d H:00:00'))
->orderBy('stat_hour')
->get();
$dailyCounts = [];
foreach ($hourly as $stat) {
$date = Carbon::parse((string) $stat->stat_hour)->format('Y-m-d');
$dailyCounts[$date] = ($dailyCounts[$date] ?? 0) + (int) $stat->{$statColumn};
}
$result = [];
$cursor = $startDate->copy();
while ($cursor->lt($today)) {
$stat = $historical->get($cursor->format('Y-m-d'));
while ($cursor->lte($today)) {
$date = $cursor->format('Y-m-d');
$result[] = [
'date' => $cursor->format('M d'),
'count' => $stat ? (int) $stat->{$statColumn} : 0,
'count' => $dailyCounts[$date] ?? 0,
];
$cursor->addDay();
}
// Today: closed hours from hourly aggregate + the current hour from live.
$currentHourStart = Carbon::now()->startOfHour();
$hourlyColumn = $statColumn === 'api_hits_count' ? 'api_hits_count' : 'downloads_count';
$todayClosed = (int) DB::table('user_activity_stats_hourly')
->where('stat_hour', '>=', $today->format('Y-m-d H:00:00'))
->where('stat_hour', '<', $currentHourStart->format('Y-m-d H:00:00'))
->sum($hourlyColumn);
$todayLive = (int) $liveCurrentHourCounter($currentHourStart);
$result[] = [
'date' => $today->format('M d'),
'count' => $todayClosed + $todayLive,
];
if ($result !== []) {
$result[count($result) - 1]['count'] += (int) $liveCurrentHourCounter($currentHourStart);
}
return $result;
}
private function minuteBucketExpression(): string
{
return DB::connection()->getDriverName() === 'sqlite'
? "strftime('%Y-%m-%d %H:%M:00', timestamp)"
: 'DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")';
}
}
-6
View File
@@ -1003,12 +1003,6 @@ parameters:
count: 1
path: app/Services/UserStatsService.php
-
message: '#^Method App\\Services\\UserStatsService\:\:getDownloadsPerHour\(\) should return list\<array\<string, int\|string\|null\>\> but returns array\<string, mixed\>\.$#'
identifier: return.type
count: 1
path: app/Services/UserStatsService.php
-
message: '#^Method App\\Services\\UserStatsService\:\:getDownloadsPerMinute\(\) should return array\<string, mixed\> but returns list\<array\<string, int\|string\>\>\.$#'
identifier: return.type
@@ -52,7 +52,7 @@ Alpine.data('adminDashboard', () => ({
this._scheduleRefresh();
},
_scheduleRefresh() {
const interval = Number.parseInt(this.$el.dataset.refreshInterval ?? '', 10) || (15 * 60 * 1000);
const interval = Number.parseInt(this.$el.dataset.refreshInterval ?? '', 10) || (60 * 1000);
this._refreshInterval = window.setInterval(() => this._loadDashboardData(), interval);
this._visibilityHandler = () => {
if (!document.hidden && Date.now() - this._lastRefreshAt >= interval) {
@@ -159,8 +159,8 @@ Alpine.data('adminDashboard', () => ({
{ label: 'Total Users', value: summary.total_users, theme: 'blue' },
{ label: 'Downloads Today', value: summary.downloads_today, theme: 'green' },
{ label: 'Downloads (last 7d incl. today)', value: summary.downloads_week, theme: 'purple' },
{ label: 'API Hits Today', value: summary.api_hits_today, theme: 'orange' },
{ label: 'API Hits (last 7d incl. today)', value: summary.api_hits_week, theme: 'pink' },
{ label: 'API/RSS Hits Today', value: summary.api_hits_today, theme: 'orange' },
{ label: 'API/RSS Hits (last 7d incl. today)', value: summary.api_hits_week, theme: 'pink' },
];
const summaryGrid = widget.querySelector('[data-summary-grid]');
if (summaryGrid) {
@@ -209,11 +209,11 @@ Alpine.data('adminDashboard', () => ({
}
this._renderChart('downloadsChart', 'bar', 'Downloads', stats.downloads_per_hour ?? [],
'rgba(34,197,94,0.7)', 'rgba(34,197,94,1)', false);
this._renderChart('apiHitsChart', 'line', 'API Hits', stats.api_hits_per_hour ?? [],
this._renderChart('apiHitsChart', 'line', 'API/RSS Hits', stats.api_hits_per_hour ?? [],
'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true);
this._renderChart('downloadsMinuteChart', 'line', 'Downloads', stats.downloads_per_minute ?? [],
'rgba(34,197,94,0.2)', 'rgba(34,197,94,1)', true);
this._renderChart('apiHitsMinuteChart', 'line', 'API Hits', stats.api_hits_per_minute ?? [],
this._renderChart('apiHitsMinuteChart', 'line', 'API/RSS Hits', stats.api_hits_per_minute ?? [],
'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true);
},
_renderSystemMetrics(metrics) {
+4 -4
View File
@@ -15,7 +15,7 @@
<div x-data="adminDashboard"
id="adminDashboard"
data-data-url="{{ route('admin.api.dashboard-data') }}"
data-refresh-interval="{{ 15 * 60 * 1000 }}">
data-refresh-interval="{{ 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">
@@ -28,7 +28,7 @@
<p class="font-medium text-gray-700 dark:text-gray-200">
<i class="fas fa-sync-alt mr-1"></i> Last dashboard refresh: <span data-stat="last-refresh">{{ $dashboardLastRefreshedAt }}</span>
</p>
<p class="mt-1 text-xs text-green-600 dark:text-green-400">Auto-refreshes every 15 minutes</p>
<p class="mt-1 text-xs text-green-600 dark:text-green-400">Auto-refreshes every minute</p>
</div>
</div>
</div>
@@ -322,7 +322,7 @@
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
<i class="fas fa-chart-area mr-2 text-purple-600 dark:text-purple-400"></i>
API Hits (Last 7 Days - Hourly)
API/RSS Hits (Last 7 Days - Hourly)
</h4>
<div class="chart-container"><canvas id="apiHitsChart"></canvas></div>
</div>
@@ -336,7 +336,7 @@
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
<i class="fas fa-chart-line mr-2 text-purple-600 dark:text-purple-400"></i>
API Hits (Last 60 Minutes)
API/RSS Hits (Last 60 Minutes)
</h4>
<div class="chart-container"><canvas id="apiHitsMinuteChart"></canvas></div>
</div>
@@ -16,14 +16,14 @@ class AdminDashboardAutoRefreshTest extends TestCase
$this->assertStringContainsString('x-data="adminDashboard"', $content);
$this->assertStringContainsString('data-data-url="{{ route(\'admin.api.dashboard-data\') }}"', $content);
$this->assertStringContainsString('data-refresh-interval="{{ 15 * 60 * 1000 }}"', $content);
$this->assertStringContainsString('data-refresh-interval="{{ 60 * 1000 }}"', $content);
$this->assertStringContainsString('data-dashboard-content', $content);
// The previous full-page-reload approach has been removed in favour of
// updating headline tiles + widgets directly from the JSON payload.
$this->assertStringNotContainsString('data-refresh-url=', $content);
}
public function test_dashboard_blade_labels_match_fifteen_minute_refresh(): void
public function test_dashboard_blade_labels_match_one_minute_refresh(): void
{
$bladePath = resource_path('views/admin/dashboard.blade.php');
@@ -31,7 +31,7 @@ class AdminDashboardAutoRefreshTest extends TestCase
$content = file_get_contents($bladePath);
$this->assertStringContainsString('Auto-refreshes every 15 minutes', $content);
$this->assertStringContainsString('Auto-refreshes every minute', $content);
$this->assertStringContainsString('Last dashboard refresh:', $content);
$this->assertStringContainsString('$dashboardLastRefreshedAt', $content);
$this->assertStringContainsString('data-stat="last-refresh"', $content);
@@ -47,8 +47,7 @@ class AdminDashboardAutoRefreshTest extends TestCase
$content = file_get_contents($scriptPath);
// Auto-refresh interval is preserved.
$this->assertStringContainsString('15 * 60 * 1000', $content);
$this->assertStringContainsString('60 * 1000', $content);
// The fetch hits the cached JSON endpoint.
$this->assertStringContainsString('this.$el.dataset.dataUrl', $content);
// Each tick re-renders headline tiles + registration status + the
@@ -10,8 +10,7 @@ use Tests\TestCase;
* Locks in the "(7d) = last 7 calendar days inclusive of today" semantics
* and the source layering used by the headline summary tiles:
*
* - Closed days come from `user_activity_stats`.
* - Today's closed hours come from `user_activity_stats_hourly`.
* - Every completed hour comes from `user_activity_stats_hourly`.
* - The current in-progress hour comes from the live `user_downloads` /
* `user_requests` tables (which are pruned hourly to ~24h).
*
@@ -21,7 +20,7 @@ use Tests\TestCase;
*/
class AdminDashboardSummaryWindowTest extends TestCase
{
public function test_summary_window_layers_daily_hourly_and_live_sources(): void
public function test_summary_window_layers_hourly_and_live_sources(): void
{
$servicePath = app_path('Services/UserStatsService.php');
@@ -36,18 +35,16 @@ class AdminDashboardSummaryWindowTest extends TestCase
'Summary window must be the last 7 calendar days inclusive of today.'
);
// Closed days from the daily aggregate, strictly before today.
$this->assertStringContainsString('UserActivityStat::query()', $content);
// Every completed hour in the seven-day window comes from the retained
// hourly aggregate, including completed hours from today.
$this->assertStringContainsString(
"->where('stat_date', '>=', \$weekStart->format('Y-m-d'))",
"->where('stat_hour', '>=', \$weekStart->format('Y-m-d H:00:00'))",
$content
);
$this->assertStringContainsString(
"->where('stat_date', '<', \$today->format('Y-m-d'))",
"->where('stat_hour', '<', \$currentHourStart->format('Y-m-d H:00:00'))",
$content
);
// Today's closed hours from the hourly aggregate.
$this->assertStringContainsString(
"DB::table('user_activity_stats_hourly')",
$content
@@ -67,13 +64,13 @@ class AdminDashboardSummaryWindowTest extends TestCase
$content
);
// Today and week totals must combine all three sources.
// Today and week totals combine closed hourly data with live data.
$this->assertStringContainsString(
"'downloads_today' => \$downloadsToday,",
$content
);
$this->assertStringContainsString(
"'downloads_week' => (int) (\$historical->downloads ?? 0) + \$downloadsToday,",
"'downloads_week' => (int) (\$weekClosed->downloads ?? 0) + \$downloadsCurrentHour,",
$content
);
$this->assertStringContainsString(
@@ -81,7 +78,7 @@ class AdminDashboardSummaryWindowTest extends TestCase
$content
);
$this->assertStringContainsString(
"'api_hits_week' => (int) (\$historical->api_hits ?? 0) + \$apiHitsToday,",
"'api_hits_week' => (int) (\$weekClosed->api_hits ?? 0) + \$apiHitsCurrentHour,",
$content
);
+9 -4
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit;
use App\Jobs\RecordApiUsage;
use App\Jobs\UpdateUserApiAccess;
use App\Models\User;
use App\Services\Api\ApiUsageService;
use Illuminate\Database\Schema\Blueprint;
@@ -44,7 +45,7 @@ final class ApiUsageServiceTest extends TestCase
DB::table('users')->insert(['id' => 1]);
}
public function test_async_record_dispatches_id_only_audit_job(): void
public function test_async_record_persists_usage_before_dispatching_metadata_update(): void
{
Queue::fake();
config(['nntmux.api.async_audit' => true]);
@@ -54,12 +55,16 @@ final class ApiUsageServiceTest extends TestCase
(new ApiUsageService)->record($user, $request);
Queue::assertPushed(RecordApiUsage::class, static fn (RecordApiUsage $job): bool => $job->userId === 1 && $job->requestUri === '/api/v2/search?api_token=secret&id=test'
Queue::assertPushed(UpdateUserApiAccess::class, static fn (UpdateUserApiAccess $job): bool => $job->userId === 1
&& $job->ip === '127.0.0.1'
);
$this->assertSame(0, DB::table('user_requests')->count());
$this->assertDatabaseHas('user_requests', [
'users_id' => 1,
'request' => '/api/v2/search?api_token=secret&id=test',
]);
}
public function test_audit_job_persists_each_request_and_coalesces_user_updates(): void
public function test_legacy_audit_job_persists_queued_requests_and_coalesces_user_updates(): void
{
config(['nntmux.api.access_update_interval' => 60]);
+167
View File
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Models\UserActivityStat;
use App\Services\UserStatsService;
use Carbon\Carbon;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
final class UserActivityStatsTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => ':memory:',
'cache.default' => 'array',
]);
DB::purge();
DB::reconnect();
Cache::flush();
Carbon::setTestNow('2026-07-20 14:37:00');
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->dateTime('deleted_at')->nullable();
});
Schema::create('settings', function (Blueprint $table): void {
$table->string('name')->primary();
$table->text('value')->nullable();
});
Schema::create('user_requests', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('users_id');
$table->text('request');
$table->dateTime('timestamp');
});
Schema::create('user_downloads', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('users_id');
$table->dateTime('timestamp');
});
Schema::create('user_activity_stats', function (Blueprint $table): void {
$table->id();
$table->date('stat_date')->unique();
$table->integer('downloads_count')->default(0);
$table->integer('api_hits_count')->default(0);
$table->timestamps();
});
Schema::create('user_activity_stats_hourly', function (Blueprint $table): void {
$table->id();
$table->dateTime('stat_hour')->unique();
$table->integer('downloads_count')->default(0);
$table->integer('api_hits_count')->default(0);
$table->timestamps();
});
DB::table('users')->insert(['id' => 1]);
DB::table('settings')->insert([
['name' => 'categorizeforeign', 'value' => '0'],
['name' => 'catwebdl', 'value' => '0'],
['name' => 'delaytime', 'value' => '0'],
['name' => 'innerfileblacklist', 'value' => ''],
]);
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_summary_and_charts_combine_hourly_api_and_rss_hits_with_the_live_hour(): void
{
$this->insertHourly('2026-07-14 09:00:00', 3, 7);
$this->insertHourly('2026-07-20 13:00:00', 2, 4);
DB::table('user_activity_stats')->insert([
'stat_date' => '2026-07-19',
'downloads_count' => 999,
'api_hits_count' => 999,
]);
DB::table('user_requests')->insert([
['users_id' => 1, 'request' => '/api/v2/search?id=one', 'timestamp' => '2026-07-20 14:37:00'],
['users_id' => 1, 'request' => '/rss/full-feed?api_token=test', 'timestamp' => '2026-07-20 14:37:30'],
]);
DB::table('user_downloads')->insert([
'users_id' => 1,
'timestamp' => '2026-07-20 14:36:00',
]);
$service = new UserStatsService;
$summary = $service->getSummaryStats();
$this->assertSame(6, $summary['api_hits_today']);
$this->assertSame(13, $summary['api_hits_week']);
$this->assertSame(3, $summary['downloads_today']);
$this->assertSame(6, $summary['downloads_week']);
$hourly = $service->getApiHitsPerHour(2);
$this->assertSame([4, 2], array_column($hourly, 'count'));
$daily = $service->getApiHitsPerDay(7);
$this->assertSame(7, $daily[0]['count']);
$this->assertSame(6, $daily[6]['count']);
$perMinute = $service->getApiHitsPerMinute(60);
$this->assertSame(2, $perMinute[59]['count']);
}
public function test_daily_rollup_remains_stable_after_raw_rows_are_pruned(): void
{
$this->insertHourly('2026-07-19 01:00:00', 2, 3);
$this->insertHourly('2026-07-19 23:00:00', 4, 5);
DB::table('user_requests')->insert([
'users_id' => 1,
'request' => '/api/v1/api?t=search',
'timestamp' => '2026-07-19 23:30:00',
]);
UserActivityStat::collectDailyStats('2026-07-19');
DB::table('user_requests')->delete();
UserActivityStat::collectDailyStats('2026-07-19');
$daily = DB::table('user_activity_stats')->where('stat_date', '2026-07-19')->first();
$this->assertSame(6, $daily->downloads_count);
$this->assertSame(8, $daily->api_hits_count);
}
public function test_forced_daily_backfill_rebuilds_retained_history_from_hourly_totals(): void
{
$this->insertHourly('2026-07-19 10:00:00', 11, 17);
DB::table('user_activity_stats')->insert([
'stat_date' => '2026-07-19',
'downloads_count' => 1,
'api_hits_count' => 1,
]);
$this->artisan('nntmux:backfill-user-activity-stats', [
'--type' => 'daily',
'--days' => 2,
'--force' => true,
])->assertSuccessful();
$daily = DB::table('user_activity_stats')->where('stat_date', '2026-07-19')->first();
$this->assertSame(11, $daily->downloads_count);
$this->assertSame(17, $daily->api_hits_count);
}
private function insertHourly(string $hour, int $downloads, int $apiHits): void
{
DB::table('user_activity_stats_hourly')->insert([
'stat_hour' => $hour,
'downloads_count' => $downloads,
'api_hits_count' => $apiHits,
]);
}
}