From 6dbe2303a4c2a4227e2901ec2ab70745cb436992 Mon Sep 17 00:00:00 2001 From: boban Date: Mon, 6 Jul 2026 20:22:51 +0200 Subject: [PATCH] fix(ui): sidebar alert/threat badges update at once, not up to 60s late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Http/Controllers/HoneypotController.php | 4 ++++ app/Models/AlertIncident.php | 19 ++++++++++++++++ app/Models/AuditEvent.php | 23 ++++++++++++++++++++ app/Services/AlertEvaluator.php | 2 ++ resources/views/components/sidebar.blade.php | 20 +++++------------ tests/Feature/AlertEvaluatorTest.php | 17 +++++++++++++++ 6 files changed, 70 insertions(+), 15 deletions(-) diff --git a/app/Http/Controllers/HoneypotController.php b/app/Http/Controllers/HoneypotController.php index 0be9440..a4056d4 100644 --- a/app/Http/Controllers/HoneypotController.php +++ b/app/Http/Controllers/HoneypotController.php @@ -125,6 +125,10 @@ class HoneypotController extends Controller 'ip' => $ip, 'meta' => $meta, ]); + + if ($isAttempt) { + AuditEvent::forgetThreatLoginCount(); // refresh the sidebar Threats badge immediately + } } /** Pick a convincing fake response for the probed path. */ diff --git a/app/Models/AlertIncident.php b/app/Models/AlertIncident.php index 8e0f58e..953f18b 100644 --- a/app/Models/AlertIncident.php +++ b/app/Models/AlertIncident.php @@ -4,9 +4,13 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\Cache; 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 $casts = [ @@ -29,4 +33,19 @@ class AlertIncident extends Model { 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); + } } diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index 184f766..b7f3b15 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -4,10 +4,14 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; 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 $casts = ['meta' => 'array']; @@ -32,6 +36,25 @@ class AuditEvent extends Model 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. */ private const ERROR_ACTIONS = [ 'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned', diff --git a/app/Services/AlertEvaluator.php b/app/Services/AlertEvaluator.php index b2a96c4..54049e7 100644 --- a/app/Services/AlertEvaluator.php +++ b/app/Services/AlertEvaluator.php @@ -61,6 +61,7 @@ class AlertEvaluator } catch (QueryException) { 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); return; @@ -69,6 +70,7 @@ class AlertEvaluator if (! $breached && $open) { // 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()]); + AlertIncident::forgetFiringCount(); $this->notifier->notify($open, resolved: true); } diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index fbd9a96..a0fe7f4 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -1,21 +1,11 @@ @php // Global "update available" cue for the Version nav item — a cache read only (no network). $updateAvailable = app(\App\Services\ReleaseChecker::class)->updateAvailable(); - // Honeypot fake-login attempts in the last 24h → Threats nav badge. The sidebar renders on - // every page, so cache the count for 60s to avoid a per-render COUNT query. - $threatCount = \Illuminate\Support\Facades\Cache::remember( - 'threats:login_attempts_24h', - 60, - 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(), - ); + // Nav badges — cached (the sidebar renders on every page) but the cache is BUSTED at the moment + // a threat/alert changes (see AuditEvent::forgetThreatLoginCount / AlertIncident::forgetFiringCount), + // so a badge never lags the page content by up to the TTL. + $threatCount = \App\Models\AuditEvent::threatLoginCount24h(); + $alertCount = \App\Models\AlertIncident::firingCount(); @endphp {{-- Fixed on desktop, off-canvas drawer on mobile/tablet (toggled by `nav` in the layout). --}}