feat(ui): live sidebar badges (alerts / threats / update) without a refresh

The topbar online count + server-switcher poll, but the sidebar nav
badges (firing-alert "1", update chip) were static Blade — they only
updated on a page navigate/refresh, out of step with the live shell.

The sidebar can't itself be a Livewire poll (that would break its
request()->is() active-nav states). Instead: a tiny `navBadges` Alpine
island on the <nav> polls GET /shell/badges.json every 15s and updates
the [data-nav-badge] chips in place. The badge chips are now always in
the DOM (hidden at zero) so the poller can reveal/hide them without a
re-render. Counts come from the same cached+busted model helpers the
render uses (AlertIncident::firingCount / AuditEvent::threatLoginCount24h
/ ReleaseChecker::updateAvailable — all cache-only, no per-poll query
storm); the endpoint zeroes alerts/threats for non-manage-panel roles.

Verified live in a browser: with an incident fired mid-session, the alert
chip flips to "1" within one poll cycle with NO page refresh, zero
console errors. New ShellBadgesTest (auth + gating + count); the release
badge test updated for the always-present-hidden chip. 753 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-06 21:41:37 +02:00
parent d348e77ad7
commit 40383261bd
6 changed files with 112 additions and 11 deletions

View File

@ -409,6 +409,35 @@ document.addEventListener('alpine:init', () => {
},
}));
// Live sidebar badge chips (alerts / threats / update). The sidebar is static Blade (a Livewire
// poll would break its request()->is() active states), so this tiny island polls the cached
// badges endpoint and updates the [data-nav-badge] chips in place. Re-initialised by Alpine on
// every wire:navigate (body swap), so the interval never piles up across navigations.
window.Alpine.data('navBadges', () => ({
timer: null,
init() {
this.refresh();
this.timer = setInterval(() => this.refresh(), 15000);
},
destroy() {
clearInterval(this.timer);
},
async refresh() {
try {
const res = await fetch('/shell/badges.json', { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const counts = await res.json();
for (const el of this.$el.querySelectorAll('[data-nav-badge]')) {
const n = Number(counts[el.dataset.navBadge] ?? 0);
el.hidden = n <= 0;
el.textContent = n > 0 ? String(n) : '';
}
} catch {
// transient network error — keep the current chips, next tick retries
}
},
}));
// Interactive terminal (xterm.js). xterm + its CSS are dynamically imported so they only load on
// the terminal page (code-split chunk). On a 'terminal-open' event (from the Livewire rail) it
// opens a same-origin WebSocket to /terminal/ws?token=… — the sidecar holds the PTY. A real PTY

View File

@ -1,4 +1,4 @@
@props(['icon', 'href' => '#', 'active' => false, 'badge' => null, 'badgeTitle' => null])
@props(['icon', 'href' => '#', 'active' => false, 'badge' => null, 'badgeTitle' => null, 'badgeKey' => null])
<a {{ $attributes->class([
'group flex min-h-11 items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors',
'border-accent/25 bg-accent/10 text-ink shadow-[inset_2px_0_0_var(--color-accent)]' => $active,
@ -7,7 +7,14 @@
@if ($active) aria-current="page" @endif>
<x-icon :name="$icon" @class(['h-[18px] w-[18px] shrink-0', 'text-accent' => $active]) />
<span class="truncate">{{ $slot }}</span>
@if ($badge !== null && $badge !== '')
@if ($badgeKey !== null)
{{-- Live badge: always in the DOM (hidden at zero) so the navBadges poller can update it
in place without a page re-render. --}}
<span data-nav-badge="{{ $badgeKey }}"
class="ml-auto inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-accent px-1 font-mono text-[10px] font-semibold tabular-nums text-void"
@if ($badge === null || $badge === '' || $badge === 0) hidden @endif
@if ($badgeTitle) title="{{ $badgeTitle }}" @endif>{{ $badge }}</span>
@elseif ($badge !== null && $badge !== '')
<span class="ml-auto inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-accent px-1 font-mono text-[10px] font-semibold tabular-nums text-void"
@if ($badgeTitle) title="{{ $badgeTitle }}" @endif>{{ $badge }}</span>
@endif

View File

@ -30,8 +30,8 @@
<livewire:server-switcher />
</div>
{{-- Nav --}}
<nav class="flex-1 space-y-1 overflow-y-auto p-3">
{{-- Nav (navBadges polls /shell/badges.json and updates the alert/threat/update chips live) --}}
<nav class="flex-1 space-y-1 overflow-y-auto p-3" x-data="navBadges">
{{-- Flotte: per-server / fleet operations --}}
<p class="px-3 pb-1 pt-2 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_fleet') }}</p>
<x-nav-item icon="dashboard" href="/" :active="request()->is('/')" data-tour="dashboard">{{ __('shell.nav_dashboard') }}</x-nav-item>
@ -64,10 +64,10 @@
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')" data-tour="settings">{{ __('shell.nav_settings') }}</x-nav-item>
@can('manage-panel')
<x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item>
<x-nav-item icon="shield-alert" href="/threats" :active="request()->is('threats*')" :badge="$threatCount > 0 ? $threatCount : null" :badge-title="$threatCount > 0 ? __('shell.threats_badge', ['count' => $threatCount]) : null">{{ __('shell.nav_threats') }}</x-nav-item>
<x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" badge-key="alerts" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="__('alerts.incidents_title')">{{ __('shell.nav_alerts') }}</x-nav-item>
<x-nav-item icon="shield-alert" href="/threats" :active="request()->is('threats*')" badge-key="threats" :badge="$threatCount > 0 ? $threatCount : null" :badge-title="__('shell.nav_threats')">{{ __('shell.nav_threats') }}</x-nav-item>
@endcan
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')" :badge="$updateAvailable ? '1' : null" :badge-title="$updateAvailable ? __('shell.update_available') : null">{{ __('shell.nav_versions') }}</x-nav-item>
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')" badge-key="update" :badge="$updateAvailable ? '1' : null" :badge-title="__('shell.update_available')">{{ __('shell.nav_versions') }}</x-nav-item>
@if (config('clusev.release_controls'))
<x-nav-item icon="tag" href="/release" :active="request()->is('release*')">
{{ __('release.nav') }}

View File

@ -26,11 +26,14 @@ use App\Livewire\Terminal;
use App\Livewire\Threats;
use App\Livewire\Versions;
use App\Livewire\Wireguard;
use App\Models\AlertIncident;
use App\Models\AuditEvent;
use App\Models\HostCredential;
use App\Models\Server;
use App\Models\TerminalSession;
use App\Services\DeploymentService;
use App\Services\MetricHistory;
use App\Services\ReleaseChecker;
use App\Services\WgStatus;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth as AuthFacade;
@ -214,6 +217,19 @@ Route::middleware('auth')->group(function () {
Route::middleware(EnsureSecurityOnboarded::class)->group(function () {
Route::get('/', Dashboard::class)->name('dashboard');
// Live sidebar badge counts (alerts firing / threats 24h / update available). Polled by the
// navBadges Alpine island so the chips update without a page re-render; counts are the same
// cached+busted model helpers the sidebar render uses.
Route::get('/shell/badges.json', function () {
$panel = auth()->user()?->can('manage-panel') ?? false;
return response()->json([
'alerts' => $panel ? AlertIncident::firingCount() : 0,
'threats' => $panel ? AuditEvent::threatLoginCount24h() : 0,
'update' => app(ReleaseChecker::class)->updateAvailable() ? 1 : 0,
]);
})->name('shell.badges');
Route::get('/servers', Servers\Index::class)->name('servers.index');
// MUST be registered before /servers/{server}, or "groups" is captured as a server uuid.
Route::get('/servers/groups', Servers\Groups::class)->middleware('can:manage-fleet')->name('servers.groups');

View File

@ -92,12 +92,15 @@ class ReleaseCheckerTest extends TestCase
config()->set('clusev.version', '0.9.10');
$this->actingAs(User::factory()->create(['must_change_password' => false]));
// No cached latest → no badge.
$this->get('/audit')->assertOk()->assertDontSee(__('shell.update_available'));
// The badge chip is ALWAYS in the DOM (so the navBadges poller can reveal it live without a
// page reload); no update → it carries the `hidden` attribute.
$html = $this->get('/audit')->assertOk()->getContent();
$this->assertMatchesRegularExpression('/data-nav-badge="update"[^>]*\shidden/', $html);
// Cached newer release → the sidebar Version item carries the badge.
// Cached newer release → the same chip renders WITHOUT hidden (visible).
Cache::put($this->tagKey(), '0.9.11', now()->addMinutes(30));
$this->get('/audit')->assertOk()->assertSee(__('shell.update_available'));
$html = $this->get('/audit')->assertOk()->getContent();
$this->assertDoesNotMatchRegularExpression('/data-nav-badge="update"[^>]*\shidden/', $html);
}
public function test_no_update_check_when_no_repository_is_configured(): void

View File

@ -0,0 +1,46 @@
<?php
namespace Tests\Feature;
use App\Models\AlertIncident;
use App\Models\AlertRule;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/** The polled sidebar badge endpoint (live alert/threat/update chips). */
class ShellBadgesTest extends TestCase
{
use RefreshDatabase;
public function test_guests_cannot_read_the_badge_counts(): void
{
// The panel's auth layer redirects guests to the login page (no data leaves).
$this->get('/shell/badges.json')->assertRedirect();
}
public function test_admin_gets_the_firing_alert_count(): void
{
$this->actingAs(User::factory()->create(['must_change_password' => false]));
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
$rule = AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
AlertIncident::create(['alert_rule_id' => $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 95, 'firing_key' => $rule->id.':'.$server->id, 'started_at' => now()]);
$this->getJson('/shell/badges.json')
->assertOk()
->assertJson(['alerts' => 1, 'threats' => 0]);
}
public function test_non_panel_roles_get_zeroed_alert_and_threat_counts(): void
{
// Operators/viewers don't see the Alerts/Threats nav items, so the endpoint must not leak
// those counts to them either.
$this->actingAs(User::factory()->operator()->create(['must_change_password' => false]));
$this->getJson('/shell/badges.json')
->assertOk()
->assertJson(['alerts' => 0, 'threats' => 0]);
}
}