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; namespace App\Http\Controllers\Admin;
use App\Enums\PageStatusEnum;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\View\View; use Illuminate\View\View;
use Lvl0\FediDiscover\Models\Instance; use Lvl0\FediDiscover\Models\Instance;
@ -11,7 +12,10 @@ class InstancesController extends Controller
{ {
public function index(): View 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]); 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->boolean('enabled')->default(true);
$table->unsignedInteger('interval_seconds')->default(300); $table->unsignedInteger('interval_seconds')->default(300);
$table->json('extras')->default('{}'); $table->json('extras')->default('{}');
$table->unsignedInteger('consecutive_poll_failures')->default(0);
$table->timestampTz('last_polled_at')->nullable(); $table->timestampTz('last_polled_at')->nullable();
$table->string('last_seen_id')->nullable(); $table->string('last_seen_id')->nullable();
$table->timestamps(); $table->timestamps();

View file

@ -4,10 +4,12 @@
namespace Lvl0\FediDiscover\Models; namespace Lvl0\FediDiscover\Models;
use App\Models\Page;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Config\InstanceType;
use Lvl0\FediDiscover\Database\Factories\InstanceFactory; use Lvl0\FediDiscover\Database\Factories\InstanceFactory;
@ -20,6 +22,7 @@
* @property int $interval_seconds * @property int $interval_seconds
* @property array<string, mixed> $extras * @property array<string, mixed> $extras
* @property string|null $last_seen_id * @property string|null $last_seen_id
* @property int $consecutive_poll_failures
* @property Carbon|null $last_polled_at * @property Carbon|null $last_polled_at
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
@ -31,7 +34,7 @@ class Instance extends Model
protected $table = 'fedi_discover_instances'; 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 = [ protected $casts = [
'type' => InstanceType::class, 'type' => InstanceType::class,
@ -53,4 +56,10 @@ protected static function newFactory(): Factory
{ {
return InstanceFactory::new(); return InstanceFactory::new();
} }
public function pages(): HasMany
{
return $this->hasMany(Page::class);
}
} }

View file

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

View file

@ -4,6 +4,7 @@
namespace Tests\Feature\Admin; namespace Tests\Feature\Admin;
use App\Models\Page;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Config\InstanceType;
use Lvl0\FediDiscover\Models\Instance; use Lvl0\FediDiscover\Models\Instance;
@ -13,10 +14,6 @@ class InstancesAdminPageTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
// -------------------------------------------------------------------------
// Test 3 — admin instances page is accessible
// -------------------------------------------------------------------------
public function test_admin_instances_page_is_accessible(): void public function test_admin_instances_page_is_accessible(): void
{ {
$response = $this->get('/admin/instances'); $response = $this->get('/admin/instances');
@ -24,10 +21,6 @@ public function test_admin_instances_page_is_accessible(): void
$response->assertStatus(200); $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 public function test_admin_instances_page_shows_each_instance_url_and_last_polled_at(): void
{ {
$mastodon = Instance::factory() $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($mastodon->last_polled_at->toDateString());
$response->assertSee($lemmy->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,
]);
}
}