fix(ui): sidebar alert/threat badges update at once, not up to 60s late

The Alarme + Threats nav badges were cached for 60s and the cache was only
ever left to expire, so when an alert fired (or a fake-login was recorded)
the sidebar count lagged the page content by up to a minute — the content
showed the new state, the badge did not until a refresh.

Centralise each count on its model (AlertIncident::firingCount /
AuditEvent::threatLoginCount24h, still cached) and BUST that cache the
instant the state changes: AlertEvaluator on every fire/resolve,
HoneypotController on each recorded fake login. The badge now matches the
content on the very next render (navigate or refresh) while keeping the
per-render query off the hot path.

(The alert e-mail itself was already fixed by the earlier Queue::before
change; it just needed the long-lived queue worker restarted to pick up
the new code — verified the worker now sends via SMTP, not the log mailer.)

750 tests (new cache-bust coverage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-06 20:22:51 +02:00
parent b0c46f2b18
commit 6dbe2303a4
6 changed files with 70 additions and 15 deletions

View File

@ -125,6 +125,10 @@ class HoneypotController extends Controller
'ip' => $ip, 'ip' => $ip,
'meta' => $meta, 'meta' => $meta,
]); ]);
if ($isAttempt) {
AuditEvent::forgetThreatLoginCount(); // refresh the sidebar Threats badge immediately
}
} }
/** Pick a convincing fake response for the probed path. */ /** Pick a convincing fake response for the probed path. */

View File

@ -4,9 +4,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
class AlertIncident extends Model class AlertIncident extends Model
{ {
/** Sidebar firing-count badge cache key — busted by forgetFiringCount() on every fire/resolve. */
public const FIRING_COUNT_CACHE = 'alerts:firing_count';
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
@ -29,4 +33,19 @@ class AlertIncident extends Model
{ {
return $this->state === 'firing'; return $this->state === 'firing';
} }
/**
* Firing-incident count for the sidebar badge. Cached (the sidebar renders on every page), but
* the cache is busted the instant an incident fires or resolves (forgetFiringCount), so the badge
* matches the content on the next render instead of lagging up to the TTL.
*/
public static function firingCount(): int
{
return (int) Cache::remember(self::FIRING_COUNT_CACHE, 60, fn () => static::where('state', 'firing')->count());
}
public static function forgetFiringCount(): void
{
Cache::forget(self::FIRING_COUNT_CACHE);
}
} }

View File

@ -4,10 +4,14 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class AuditEvent extends Model class AuditEvent extends Model
{ {
/** Sidebar threats-count badge cache key — busted by forgetThreatLoginCount() on each fake login. */
public const THREAT_COUNT_CACHE = 'threats:login_attempts_24h';
protected $guarded = []; protected $guarded = [];
protected $casts = ['meta' => 'array']; protected $casts = ['meta' => 'array'];
@ -32,6 +36,25 @@ class AuditEvent extends Model
return $this->belongsTo(Server::class); return $this->belongsTo(Server::class);
} }
/**
* Fake-login (honeypot) attempts in the last 24h for the sidebar Threats badge. Cached (the
* sidebar renders on every page) but busted the instant one is recorded (forgetThreatLoginCount),
* so the badge stays in step with the Threats page instead of lagging up to the TTL.
*/
public static function threatLoginCount24h(): int
{
return (int) Cache::remember(
self::THREAT_COUNT_CACHE,
60,
fn () => static::where('action', 'security.honeypot_login')->where('created_at', '>', now()->subDay())->count(),
);
}
public static function forgetThreatLoginCount(): void
{
Cache::forget(self::THREAT_COUNT_CACHE);
}
/** Actions that record a failure or security event — surfaced with a warning style. */ /** Actions that record a failure or security event — surfaced with a warning style. */
private const ERROR_ACTIONS = [ private const ERROR_ACTIONS = [
'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned', 'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned',

View File

@ -61,6 +61,7 @@ class AlertEvaluator
} catch (QueryException) { } catch (QueryException) {
return; // another tick opened it first — dedup, no duplicate incident/notification return; // another tick opened it first — dedup, no duplicate incident/notification
} }
AlertIncident::forgetFiringCount(); // keep the sidebar badge in step with this new incident
$this->notifier->notify($incident, resolved: false); $this->notifier->notify($incident, resolved: false);
return; return;
@ -69,6 +70,7 @@ class AlertEvaluator
if (! $breached && $open) { if (! $breached && $open) {
// Clear firing_key on resolve so a later re-breach can claim the unique key again. // Clear firing_key on resolve so a later re-breach can claim the unique key again.
$open->update(['state' => 'resolved', 'firing_key' => null, 'resolved_at' => now()]); $open->update(['state' => 'resolved', 'firing_key' => null, 'resolved_at' => now()]);
AlertIncident::forgetFiringCount();
$this->notifier->notify($open, resolved: true); $this->notifier->notify($open, resolved: true);
} }

View File

@ -1,21 +1,11 @@
@php @php
// Global "update available" cue for the Version nav item — a cache read only (no network). // Global "update available" cue for the Version nav item — a cache read only (no network).
$updateAvailable = app(\App\Services\ReleaseChecker::class)->updateAvailable(); $updateAvailable = app(\App\Services\ReleaseChecker::class)->updateAvailable();
// Honeypot fake-login attempts in the last 24h → Threats nav badge. The sidebar renders on // Nav badges — cached (the sidebar renders on every page) but the cache is BUSTED at the moment
// every page, so cache the count for 60s to avoid a per-render COUNT query. // a threat/alert changes (see AuditEvent::forgetThreatLoginCount / AlertIncident::forgetFiringCount),
$threatCount = \Illuminate\Support\Facades\Cache::remember( // so a badge never lags the page content by up to the TTL.
'threats:login_attempts_24h', $threatCount = \App\Models\AuditEvent::threatLoginCount24h();
60, $alertCount = \App\Models\AlertIncident::firingCount();
fn () => \App\Models\AuditEvent::where('action', 'security.honeypot_login')
->where('created_at', '>', now()->subDay())
->count(),
);
// Active (firing) alert incidents → the Alarme nav badge. Cached like the threats count.
$alertCount = \Illuminate\Support\Facades\Cache::remember(
'alerts:firing_count',
60,
fn () => \App\Models\AlertIncident::where('state', 'firing')->count(),
);
@endphp @endphp
{{-- Fixed on desktop, off-canvas drawer on mobile/tablet (toggled by `nav` in the layout). --}} {{-- Fixed on desktop, off-canvas drawer on mobile/tablet (toggled by `nav` in the layout). --}}
<aside class="fixed inset-y-0 left-0 z-40 flex w-[272px] -translate-x-full flex-col border-r border-line bg-surface transition-transform duration-200 lg:translate-x-0" <aside class="fixed inset-y-0 left-0 z-40 flex w-[272px] -translate-x-full flex-col border-r border-line bg-surface transition-transform duration-200 lg:translate-x-0"

View File

@ -48,6 +48,23 @@ class AlertEvaluatorTest extends TestCase
$this->assertDatabaseHas('alert_incidents', ['server_id' => $server->id, 'state' => 'firing', 'value' => 95]); $this->assertDatabaseHas('alert_incidents', ['server_id' => $server->id, 'state' => 'firing', 'value' => 95]);
} }
public function test_firing_a_new_incident_busts_the_sidebar_firing_count_cache(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify');
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
$server = $this->server();
// Warm the badge-count cache while nothing is firing.
$this->assertSame(0, AlertIncident::firingCount());
// A breach opens an incident — the sidebar badge must reflect it at once (cache busted),
// not stay at the cached 0 until the TTL expires.
$eval->evaluate($server, ['cpu' => 95, 'mem' => 10, 'disk' => 10, 'load' => 1], 'online');
$this->assertSame(1, AlertIncident::firingCount());
}
public function test_a_sustained_breach_does_not_open_a_second_incident(): void public function test_a_sustained_breach_does_not_open_a_second_incident(): void
{ {
[$eval, $notifier] = $this->make(); [$eval, $notifier] = $this->make();