From 4eeff4916a308091899067869f1be59274e19748 Mon Sep 17 00:00:00 2001 From: boban Date: Mon, 15 Jun 2026 03:05:35 +0200 Subject: [PATCH] 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) --- app/Livewire/Modals/Fail2banBans.php | 20 ++- .../livewire/modals/fail2ban-bans.blade.php | 19 ++- tests/Feature/Fail2banBansModalTest.php | 140 ++++++++++++++++++ 3 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/Fail2banBansModalTest.php diff --git a/app/Livewire/Modals/Fail2banBans.php b/app/Livewire/Modals/Fail2banBans.php index 686b487..496371a 100644 --- a/app/Livewire/Modals/Fail2banBans.php +++ b/app/Livewire/Modals/Fail2banBans.php @@ -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 { diff --git a/resources/views/livewire/modals/fail2ban-bans.blade.php b/resources/views/livewire/modals/fail2ban-bans.blade.php index 3fd892e..0b60625 100644 --- a/resources/views/livewire/modals/fail2ban-bans.blade.php +++ b/resources/views/livewire/modals/fail2ban-bans.blade.php @@ -1,4 +1,4 @@ -
+
@@ -17,6 +17,20 @@ @endif
+ @if (! $loaded) + @for ($i = 0; $i < 4; $i++) +
+
+ + +
+
+ + +
+
+ @endfor + @else @forelse ($jails as $jail)
@@ -44,9 +58,10 @@
@empty
-

{{ $loaded ? __('servers.fail2ban_no_banned') : __('servers.loading') }}

+

{{ __('servers.fail2ban_no_banned') }}

@endforelse + @endif
diff --git a/tests/Feature/Fail2banBansModalTest.php b/tests/Feature/Fail2banBansModalTest.php new file mode 100644 index 0000000..704a5cb --- /dev/null +++ b/tests/Feature/Fail2banBansModalTest.php @@ -0,0 +1,140 @@ +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()); + } +}