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
parent
b0c46f2b18
commit
6dbe2303a4
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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). --}}
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -48,6 +48,23 @@ class AlertEvaluatorTest extends TestCase
|
|||
$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
|
||||
{
|
||||
[$eval, $notifier] = $this->make();
|
||||
|
|
|
|||
Loading…
Reference in New Issue