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
parent
d348e77ad7
commit
40383261bd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue