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
parent
d384a7d918
commit
4eeff4916a
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue