From f9cebe5bae149b98878257223222ae7ed69b3967 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Tue, 28 Apr 2026 23:33:32 +0200 Subject: [PATCH] 6 - Add admin instances page and PollAlertService failure tracking --- .../Controllers/Admin/InstancesController.php | 6 +- app/Services/PollAlertService.php | 14 +++ ...7_create_fedi_discover_instances_table.php | 1 + .../Lvl0/FediDiscover/src/Models/Instance.php | 11 ++- resources/views/admin/index.blade.php | 4 + .../Feature/Admin/InstancesAdminPageTest.php | 92 +++++++++++++++++-- .../Feature/Services/PollAlertServiceTest.php | 32 +++++++ 7 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 app/Services/PollAlertService.php create mode 100644 tests/Feature/Services/PollAlertServiceTest.php diff --git a/app/Http/Controllers/Admin/InstancesController.php b/app/Http/Controllers/Admin/InstancesController.php index 4cbdf88..d47702d 100644 --- a/app/Http/Controllers/Admin/InstancesController.php +++ b/app/Http/Controllers/Admin/InstancesController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Admin; +use App\Enums\PageStatusEnum; use App\Http\Controllers\Controller; use Illuminate\View\View; use Lvl0\FediDiscover\Models\Instance; @@ -11,7 +12,10 @@ class InstancesController extends Controller { public function index(): View { - $instances = Instance::orderBy('url', 'asc')->get(); + $instances = Instance::withCount([ + 'pages', + 'pages as failed_pages_count' => fn ($q) => $q->where('status', PageStatusEnum::Failed), + ])->orderBy('url', 'asc')->get(); return view('admin.index', ['instances' => $instances]); } diff --git a/app/Services/PollAlertService.php b/app/Services/PollAlertService.php new file mode 100644 index 0000000..44b470b --- /dev/null +++ b/app/Services/PollAlertService.php @@ -0,0 +1,14 @@ +increment('consecutive_poll_failures'); + } +} diff --git a/packages/Lvl0/FediDiscover/database/migrations/2026_04_23_205027_create_fedi_discover_instances_table.php b/packages/Lvl0/FediDiscover/database/migrations/2026_04_23_205027_create_fedi_discover_instances_table.php index 5a9fb60..209c3f7 100644 --- a/packages/Lvl0/FediDiscover/database/migrations/2026_04_23_205027_create_fedi_discover_instances_table.php +++ b/packages/Lvl0/FediDiscover/database/migrations/2026_04_23_205027_create_fedi_discover_instances_table.php @@ -18,6 +18,7 @@ public function up(): void $table->boolean('enabled')->default(true); $table->unsignedInteger('interval_seconds')->default(300); $table->json('extras')->default('{}'); + $table->unsignedInteger('consecutive_poll_failures')->default(0); $table->timestampTz('last_polled_at')->nullable(); $table->string('last_seen_id')->nullable(); $table->timestamps(); diff --git a/packages/Lvl0/FediDiscover/src/Models/Instance.php b/packages/Lvl0/FediDiscover/src/Models/Instance.php index a7211e5..f2c6b87 100644 --- a/packages/Lvl0/FediDiscover/src/Models/Instance.php +++ b/packages/Lvl0/FediDiscover/src/Models/Instance.php @@ -4,10 +4,12 @@ namespace Lvl0\FediDiscover\Models; +use App\Models\Page; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Carbon; use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Database\Factories\InstanceFactory; @@ -20,6 +22,7 @@ * @property int $interval_seconds * @property array $extras * @property string|null $last_seen_id + * @property int $consecutive_poll_failures * @property Carbon|null $last_polled_at * @property Carbon $created_at * @property Carbon $updated_at @@ -31,7 +34,7 @@ class Instance extends Model protected $table = 'fedi_discover_instances'; - protected $fillable = ['type', 'url', 'enabled', 'interval_seconds', 'extras', 'last_seen_id', 'last_polled_at']; + protected $fillable = ['type', 'url', 'enabled', 'interval_seconds', 'extras', 'last_seen_id', 'last_polled_at', 'consecutive_poll_failures']; protected $casts = [ 'type' => InstanceType::class, @@ -53,4 +56,10 @@ protected static function newFactory(): Factory { return InstanceFactory::new(); } + + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + } diff --git a/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php index 05d00bf..721bca7 100644 --- a/resources/views/admin/index.blade.php +++ b/resources/views/admin/index.blade.php @@ -7,6 +7,8 @@ Instance Last polled at + URLs + Errors @@ -14,6 +16,8 @@ {{ $instance->url }} {{ $instance->last_polled_at }} + {{ $instance->pages_count }} URLs + {{ $instance->failed_pages_count }} errors @endforeach diff --git a/tests/Feature/Admin/InstancesAdminPageTest.php b/tests/Feature/Admin/InstancesAdminPageTest.php index 88c7670..46a0070 100644 --- a/tests/Feature/Admin/InstancesAdminPageTest.php +++ b/tests/Feature/Admin/InstancesAdminPageTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Admin; +use App\Models\Page; use Illuminate\Foundation\Testing\RefreshDatabase; use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Models\Instance; @@ -13,10 +14,6 @@ class InstancesAdminPageTest extends TestCase { use RefreshDatabase; - // ------------------------------------------------------------------------- - // Test 3 — admin instances page is accessible - // ------------------------------------------------------------------------- - public function test_admin_instances_page_is_accessible(): void { $response = $this->get('/admin/instances'); @@ -24,10 +21,6 @@ public function test_admin_instances_page_is_accessible(): void $response->assertStatus(200); } - // ------------------------------------------------------------------------- - // Test 4 — admin instances page lists each instance's URL and last_polled_at - // ------------------------------------------------------------------------- - public function test_admin_instances_page_shows_each_instance_url_and_last_polled_at(): void { $mastodon = Instance::factory() @@ -53,4 +46,87 @@ public function test_admin_instances_page_shows_each_instance_url_and_last_polle $response->assertSee($mastodon->last_polled_at->toDateString()); $response->assertSee($lemmy->last_polled_at->toDateString()); } + + public function test_admin_instances_page_shows_error_count_per_instance(): void + { + $first = Instance::factory() + ->type(InstanceType::Mastodon) + ->enabled() + ->create(['url' => 'https://aardvark.example']); + + $second = Instance::factory() + ->type(InstanceType::Lemmy) + ->enabled() + ->create(['url' => 'https://zebra.example']); + + // First instance: 3 failed + 2 non-failed pages + Page::factory() + ->count(3) + ->sequence(fn ($s) => ['url' => "https://aardvark.example/fail-{$s->index}"]) + ->createQuietly(['instance_id' => $first->id, 'status' => \App\Enums\PageStatusEnum::Failed]); + + Page::factory() + ->count(2) + ->sequence(fn ($s) => ['url' => "https://aardvark.example/ok-{$s->index}"]) + ->createQuietly(['instance_id' => $first->id, 'status' => \App\Enums\PageStatusEnum::Fetched]); + + // Second instance: 1 failed + 4 non-failed pages + Page::factory() + ->count(1) + ->sequence(fn ($s) => ['url' => "https://zebra.example/fail-{$s->index}"]) + ->createQuietly(['instance_id' => $second->id, 'status' => \App\Enums\PageStatusEnum::Failed]); + + Page::factory() + ->count(4) + ->sequence(fn ($s) => ['url' => "https://zebra.example/ok-{$s->index}"]) + ->createQuietly(['instance_id' => $second->id, 'status' => \App\Enums\PageStatusEnum::Fetched]); + + $response = $this->get('/admin/instances'); + + // Each error-count cell must render as "{n} errors" — this string cannot + // collide with dates, IDs, or the "URLs" column. The counts (3 and 1) + // are distinct and non-equal so the assertion proves per-row mapping, + // not a leaked total. + $response->assertSeeInOrder([ + $first->url, + '3 errors', + $second->url, + '1 errors', + ]); + } + + public function test_admin_instances_page_shows_url_count_per_instance(): void + { + $first = Instance::factory() + ->type(InstanceType::Mastodon) + ->enabled() + ->create(['url' => 'https://aardvark.example']); + + $second = Instance::factory() + ->type(InstanceType::Lemmy) + ->enabled() + ->create(['url' => 'https://zebra.example']); + + Page::factory() + ->count(7) + ->sequence(fn ($s) => ['url' => "https://aardvark.example/page-{$s->index}"]) + ->createQuietly(['instance_id' => $first->id]); + + Page::factory() + ->count(2) + ->sequence(fn ($s) => ['url' => "https://zebra.example/page-{$s->index}"]) + ->createQuietly(['instance_id' => $second->id]); + + $response = $this->get('/admin/instances'); + + // Each count cell must render as "{n} URLs" — this string cannot + // collide with dates, IDs, or any other incidental numeric content, + // so the assertion only passes when a real count column is wired in. + $response->assertSeeInOrder([ + $first->url, + '7 URLs', + $second->url, + '2 URLs', + ]); + } } diff --git a/tests/Feature/Services/PollAlertServiceTest.php b/tests/Feature/Services/PollAlertServiceTest.php new file mode 100644 index 0000000..a1e7a5e --- /dev/null +++ b/tests/Feature/Services/PollAlertServiceTest.php @@ -0,0 +1,32 @@ +type(InstanceType::Mastodon) + ->enabled() + ->create(['consecutive_poll_failures' => 0]); + + $service = new PollAlertService(); + $service->recordFailure($instance); + + $this->assertDatabaseHas('fedi_discover_instances', [ + 'id' => $instance->id, + 'consecutive_poll_failures' => 1, + ]); + } +}