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
@@ -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,
]);
}
}