6 - Add admin instances page and PollAlertService failure tracking

This commit is contained in:
myrmidex 2026-04-28 23:33:32 +02:00
parent 257dbfcf5f
commit f9cebe5bae
7 changed files with 150 additions and 10 deletions

View file

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

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Lvl0\FediDiscover\Models\Instance;
class PollAlertService
{
public function recordFailure(Instance $instance): void
{
$instance->increment('consecutive_poll_failures');
}
}

View file

@ -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();

View file

@ -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<string, mixed> $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);
}
}

View file

@ -7,6 +7,8 @@
<tr>
<th>Instance</th>
<th>Last polled at</th>
<th>URLs</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
@ -14,6 +16,8 @@
<tr>
<td>{{ $instance->url }}</td>
<td>{{ $instance->last_polled_at }}</td>
<td>{{ $instance->pages_count }} URLs</td>
<td>{{ $instance->failed_pages_count }} errors</td>
</tr>
@endforeach
</tbody>

View file

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

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Services;
use App\Services\PollAlertService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Lvl0\FediDiscover\Config\InstanceType;
use Lvl0\FediDiscover\Models\Instance;
use Tests\TestCase;
class PollAlertServiceTest extends TestCase
{
use RefreshDatabase;
public function test_recordFailure_increments_consecutive_poll_failures_on_the_instance(): void
{
$instance = Instance::factory()
->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,
]);
}
}