mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update dashboard widgets
This commit is contained in:
+1
-1
@@ -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();
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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")';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user