feat(fail2ban): lazy-load Gesperrte IPs modal behind a skeleton

The "Gesperrte IPs ansehen" modal did its SSH Fail2banService::status read
in mount(), so clicking the trigger blocked for seconds with no spinner
before the modal appeared.

Split mount() into an instant mount() (stores serverId only) plus a
load() method fired by wire:init, mirroring Services\Index. While
! $loaded the view renders a 4-row skeleton inside the max-h-96 scroll
container; once loaded it renders the real jail/IP list (or empty/error).
The unban flow still re-reads via loadJails() and dispatches
fail2banChanged, keeping #[Locked] $serverId and the audit intact.

Adds Fail2banBansModalTest covering the instant mount (status never
called), load() rendering the list, read-error surfacing, and both
unban success (audit + refresh + dispatch) and failure paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-15 03:05:35 +02:00
parent d384a7d918
commit 4eeff4916a
3 changed files with 171 additions and 8 deletions

View File

@ -13,10 +13,12 @@ use Throwable;
/**
* Scrollable list of fail2ban jails + their currently-banned IPs, lifted out of the
* servers/show panel so a long ban list never stretches the page. mount() reads the
* live jail status via Fail2banService (the SAME service the page uses); unban() calls
* Fail2banService::unban (the SAME path as Show::unbanIp), audits the success, refreshes
* this modal's list, and dispatches `fail2banChanged` so the page panel re-reads too.
* servers/show panel so a long ban list never stretches the page. mount() only stores
* the serverId so the modal opens INSTANTLY; load() (fired by wire:init, behind a
* skeleton) does the live jail-status read via Fail2banService (the SAME service the
* page uses). unban() calls Fail2banService::unban (the SAME path as Show::unbanIp),
* audits the success, refreshes this modal's list, and dispatches `fail2banChanged`
* so the page panel re-reads too.
*/
class Fail2banBans extends ModalComponent
{
@ -31,10 +33,10 @@ class Fail2banBans extends ModalComponent
public ?string $error = null;
public function mount(int $serverId, Fail2banService $fail2ban): void
public function mount(int $serverId): void
{
// Store the id ONLY so the modal opens instantly; the SSH read happens in load().
$this->serverId = $serverId;
$this->loadJails($fail2ban);
}
public static function modalMaxWidth(): string
@ -42,6 +44,12 @@ class Fail2banBans extends ModalComponent
return 'lg';
}
/** Lazy: the SSH read runs after the modal renders (wire:init), behind a skeleton. */
public function load(Fail2banService $fail2ban): void
{
$this->loadJails($fail2ban);
}
/** Read the live jail/banned-IP status (same Fail2banService::status the page calls). */
private function loadJails(Fail2banService $fail2ban): void
{

View File

@ -1,4 +1,4 @@
<div class="p-5 sm:p-6">
<div class="p-5 sm:p-6" wire:init="load">
<div class="flex items-start gap-3.5">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-offline/30 bg-offline/10 text-offline">
<x-icon name="shield" class="h-5 w-5" />
@ -17,6 +17,20 @@
@endif
<div class="mt-4 max-h-96 divide-y divide-line overflow-y-auto rounded-md border border-line">
@if (! $loaded)
@for ($i = 0; $i < 4; $i++)
<div class="px-3.5 py-3">
<div class="flex items-center justify-between gap-2">
<x-skeleton class="h-3.5 w-28" />
<x-skeleton class="h-2.5 w-36" />
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<x-skeleton class="h-2.5 w-44 max-w-full" />
<x-skeleton class="h-7 w-16 shrink-0" />
</div>
</div>
@endfor
@else
@forelse ($jails as $jail)
<div class="px-3.5 py-3">
<div class="flex flex-wrap items-center justify-between gap-2">
@ -44,9 +58,10 @@
</div>
@empty
<div class="px-3.5 py-6 text-center">
<p class="font-mono text-[11px] text-ink-3">{{ $loaded ? __('servers.fail2ban_no_banned') : __('servers.loading') }}</p>
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.fail2ban_no_banned') }}</p>
</div>
@endforelse
@endif
</div>
<div class="mt-6 flex items-center justify-end gap-2">

View File

@ -0,0 +1,140 @@
<?php
namespace Tests\Feature;
use App\Livewire\Modals\Fail2banBans;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Models\User;
use App\Services\Fail2banService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class Fail2banBansModalTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
/** The modal opens instantly: mount stores only the id, with no SSH read yet. */
public function test_mount_does_not_read_status_so_the_modal_opens_instantly(): void
{
$this->actingAs(User::factory()->create());
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
// status() must NOT be called during mount — it is deferred to load().
$svc = Mockery::mock(Fail2banService::class);
$svc->shouldReceive('status')->never();
app()->instance(Fail2banService::class, $svc);
Livewire::test(Fail2banBans::class, ['serverId' => $server->id])
->assertSet('loaded', false)
->assertSet('jails', []);
}
/** load() (fired by wire:init) reads the live status and renders the jail/IP list. */
public function test_load_reads_status_and_renders_the_jail_list(): void
{
$this->actingAs(User::factory()->create());
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
$svc = Mockery::mock(Fail2banService::class);
$svc->shouldReceive('status')->once()->with(Mockery::on(fn ($s) => $s->id === $server->id))
->andReturn([
'jails' => [
['name' => 'sshd', 'currentlyBanned' => 1, 'currentlyFailed' => 3, 'bannedIps' => ['203.0.113.7']],
],
]);
app()->instance(Fail2banService::class, $svc);
Livewire::test(Fail2banBans::class, ['serverId' => $server->id])
->call('load')
->assertSet('loaded', true)
->assertSee('sshd')
->assertSee('203.0.113.7');
}
/** A read error from status() surfaces the translated error message. */
public function test_load_surfaces_a_read_error(): void
{
$this->actingAs(User::factory()->create());
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
$svc = Mockery::mock(Fail2banService::class);
$svc->shouldReceive('status')->once()->andReturn(['jails' => [], 'readError' => true]);
app()->instance(Fail2banService::class, $svc);
Livewire::test(Fail2banBans::class, ['serverId' => $server->id])
->call('load')
->assertSet('loaded', true)
->assertSet('error', __('servers.fail2ban_read_error'));
}
/** unban() calls the service, audits the success, refreshes the list, and signals the page. */
public function test_unban_calls_service_audits_refreshes_and_dispatches_change(): void
{
$this->actingAs(User::factory()->create());
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
$svc = Mockery::mock(Fail2banService::class);
// Initial load: one banned IP.
$svc->shouldReceive('status')->once()->andReturn([
'jails' => [
['name' => 'sshd', 'currentlyBanned' => 1, 'currentlyFailed' => 3, 'bannedIps' => ['203.0.113.7']],
],
]);
$svc->shouldReceive('unban')->once()
->with(Mockery::on(fn ($s) => $s->id === $server->id), 'sshd', '203.0.113.7')
->andReturn(['ok' => true, 'output' => 'unbanned']);
// Refresh after unban: the IP is gone.
$svc->shouldReceive('status')->once()->andReturn([
'jails' => [
['name' => 'sshd', 'currentlyBanned' => 0, 'currentlyFailed' => 3, 'bannedIps' => []],
],
]);
app()->instance(Fail2banService::class, $svc);
Livewire::test(Fail2banBans::class, ['serverId' => $server->id])
->call('load')
->assertSee('203.0.113.7')
->call('unbanIp', 'sshd', '203.0.113.7')
->assertDispatched('fail2banChanged')
->assertDispatched('notify')
->assertDontSee('203.0.113.7');
$this->assertDatabaseHas('audit_events', [
'server_id' => $server->id,
'action' => 'fail2ban.unban',
]);
}
/** A failed unban surfaces a notice and does NOT audit or signal the page. */
public function test_unban_failure_notifies_without_auditing(): void
{
$this->actingAs(User::factory()->create());
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
$svc = Mockery::mock(Fail2banService::class);
$svc->shouldReceive('status')->once()->andReturn([
'jails' => [
['name' => 'sshd', 'currentlyBanned' => 1, 'currentlyFailed' => 3, 'bannedIps' => ['203.0.113.7']],
],
]);
$svc->shouldReceive('unban')->once()->andReturn(['ok' => false, 'output' => 'no permission']);
app()->instance(Fail2banService::class, $svc);
Livewire::test(Fail2banBans::class, ['serverId' => $server->id])
->call('load')
->call('unbanIp', 'sshd', '203.0.113.7')
->assertDispatched('notify')
->assertNotDispatched('fail2banChanged');
$this->assertSame(0, AuditEvent::where('action', 'fail2ban.unban')->count());
}
}