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,
|
'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. */
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue