From 166ba21ff195315e80352a3d199808c6a6071d77 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 6 May 2026 08:43:46 -0400 Subject: [PATCH] feat(sidebar): filter toggle + action button padding fix (#933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: open security basics, manual fleet ops, and basic fleet management to Community Realign tier guards to the user-stated philosophy: Community covers deploy/monitor at scale plus security basics, Skipper adds automation and advanced fleet management, Admiral keeps enterprise control. Community now includes: - Trivy install / uninstall / update from the Settings Hub (admin role) - CVE suppressions CRUD (admin role; replicates fleet-wide) - Manual image scan with vuln, secret, and misconfig results - Stack-config scan, scan comparison - Manual fleet snapshots: create, list, view, restore, delete - Per-node Sencho self-update (Check Updates + per-node Update) - Fleet Overview search, sort, filters, node-card expand, auto-refresh Stays paid: - Scan policies with block_on_deploy enforcement (Skipper+) - SBOM (SPDX, CycloneDX), SARIF export (Skipper+) - Bulk Update All across the fleet (Skipper+) - Scheduled snapshot create (now Skipper, was Admiral) - Trivy auto-update toggle, fleet-wide policy push (Admiral) The Settings -> Security tab is unhidden by setting the registry tier to null. The SecuritySection no longer early-returns a PaidGate; the policy list, Add Policy button, and policy dialogs are wrapped in {isPaid && }. The Fleet view drops isPaid gates on the Snapshots tab, Check Updates button, per-node update handlers, OverviewToolbar grid controls, the NodeCard expand affordance, and the auto-refresh notice. The NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid guards so polling runs for Community; useFleetOverview drops the isPaid wrap on the filter and sort path. Backend route guards are flipped per the matrix above. The scheduler tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch. Backend test assertions are inverted for the now-Community endpoints and a positive Skipper-snapshot-task test is added. Documentation across features/, api-reference/, and operations/ is updated to reflect the new tier mapping. * feat: add node last-contact tracking, fleet latency, and stack-restart summary - DatabaseService: add last_successful_contact column to nodes table via idempotent migration; expose updateNodeLastContact() and getStackRestartSummary() methods; include the column in NODE_COLUMNS so getNodes/getNode return it - fleet.ts: record latency_ms and last_successful_contact on each remote node overview fetch; pilot-agent nodes surface pilot_last_seen instead; pass db singleton into fetchRemoteNodeOverview to avoid redundant getInstance calls - dashboard.ts: replace /recent-activity with /stack-restarts endpoint that groups notification_history events by stack and category (crash/autoheal/manual) over a configurable window (default 7 days, max 30) * refactor(dashboard): remove redundant per-route authMiddleware All routes under /api/ are covered by the global auth gate in app.ts. The inline authMiddleware arguments on /configuration and /stack-restarts were redundant with that gate and inconsistent with every other route in the file. Remove them and drop the now-unused import. * refactor(backend): consolidate Date.now(), move SQL aggregation, normalize node row mapping - Capture a single completedAt timestamp in fetchRemoteNodeOverview to eliminate two separate Date.now() calls and ensure latency_ms and last_successful_contact are derived from the same instant - Inline the redundant contactedAt variable; use completedAt directly - Move stack-restart aggregation from JS into SQL (GROUP BY stack_name with CASE/SUM counts), replacing the Map loop in the route handler - Export StackRestartSummary interface from DatabaseService and remove the duplicate local definition in dashboard.ts; handler now returns the query result directly - Add last_successful_contact normalization in decryptNodeRow, mirroring the existing pilot_last_seen pattern - Add authGate reliance comment above dashboardRouter route handlers * feat(dashboard): replace Recent Activity card with context-aware Fleet Heartbeat / Stack Restart Map - Multi-node installs (≥1 remote node): shows Fleet Heartbeat — real-time reachability, latency, and container count per registered node - Local-only installs: shows Stack Restart Map — 7-day restart frequency per stack grouped by crash / auto-heal / manual category - Conditional wrapper (DashboardActivityCard) switches states automatically when the node list changes, with no page reload required - Deletes RecentActivity card and hook (duplicated data already in Recent Alerts) - Extracts formatRelativeTime to frontend/src/lib/utils.ts for reuse * fix(dashboard): add pilot_last_seen to FleetNodeOverview and use it in getLastSeenLabel * fix(fleet): expose mode and pilot_last_seen in overview, consolidate formatRelativeTime, drop em dash - Add `mode` and `pilot_last_seen` (in seconds) to the FleetNodeOverview interface and to both the pilot-agent and HTTP-proxy return paths in fetchRemoteNodeOverview so the frontend getLastSeenLabel pilot branch can fire correctly - Remove the private formatRelativeTime from RecentAlerts.tsx and use the shared implementation from lib/utils, converting the millisecond timestamp at the call site - Replace the em dash in getLatencyLabel with 'n/a' per project rules * feat(sidebar): add filter toggle and fix action button padding - Add collapsible filter chip row in SidebarFilterChips with Plus/Minus toggle button pinned to the far right of the row - Persist expanded/collapsed state across reloads via localStorage key sencho:sidebar:filters-visible (default expanded) - Active filter chip stays applied while the row is hidden; hiding is purely a visual noise reduction and does not reset the filter - Fix flex overflow in SidebarActions by adding min-w-0 to the flex-1 wrapper around the Create Stack slot, restoring the correct 16px right padding on the scan button - Cap displayed counts at 99+ to bound chip render width; chips use min-w-0 overflow-hidden instead of shrink-0 so they flex-shrink proportionally rather than hard-clipping the last chip * test: resolve failing CI tests and act warnings (#933) - Fix SidebarActivityTicker to properly filter out stale activities - Update StackGroup test to handle pinned star prefixes - Refactor useNotifications tests to use waitFor and suppress un-awaitable act() warnings * fix: add missing beforeAll/afterAll imports to useNotifications test The tsc build step failed because beforeAll and afterAll were used but not imported from vitest, unlike the other vitest functions already in the import statement. * fix: resolve ESLint errors in test and sidebar files Replace any[] with unknown[] in useNotifications.test.ts console.error mock, and add a comment to the empty catch block in StackSidebar.tsx. --- .../hooks/useNotifications.test.ts | 37 +++++--- .../src/components/sidebar/SidebarActions.tsx | 2 +- .../sidebar/SidebarActivityTicker.tsx | 7 +- .../components/sidebar/SidebarFilterChips.tsx | 88 ++++++++++++------- .../src/components/sidebar/StackSidebar.tsx | 19 +++- .../sidebar/__tests__/StackGroup.test.tsx | 2 +- 6 files changed, 105 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts b/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts index 786d783a..10834c5c 100644 --- a/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts @@ -1,5 +1,5 @@ -import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import { useNotifications } from './useNotifications'; import type { Node } from '@/context/NodeContext'; import type { NotificationItem } from '../../dashboard/types'; @@ -34,11 +34,30 @@ class MockWS { beforeEach(() => { MockWS.reset(); vi.stubGlobal('WebSocket', MockWS); - (apiFetch as ReturnType).mockResolvedValue({ ok: false }); + (apiFetch as ReturnType).mockResolvedValue({ ok: false, json: async () => [] }); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); }); -afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); describe('useNotifications', () => { + let originalError: typeof console.error; + + beforeAll(() => { + originalError = console.error; + console.error = (...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].includes('was not wrapped in act')) { + return; + } + originalError.call(console, ...args); + }; + }); + + afterAll(() => { + console.error = originalError; + }); + it('starts with empty notifications and disconnected state', () => { const { result } = renderHook(() => useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }), @@ -78,7 +97,6 @@ describe('useNotifications', () => { }); it('clearAllNotifications empties the local state', async () => { - (apiFetch as ReturnType).mockResolvedValue({ ok: true }); const { result } = renderHook(() => useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }), ); @@ -89,12 +107,11 @@ describe('useNotifications', () => { }); }); expect(result.current.notifications).toHaveLength(1); - await act(async () => { await result.current.clearAllNotifications(); }); - expect(result.current.notifications).toHaveLength(0); + act(() => { result.current.clearAllNotifications(); }); + await waitFor(() => expect(result.current.notifications).toHaveLength(0)); }); it('deleteNotification removes the matching item', async () => { - (apiFetch as ReturnType).mockResolvedValue({ ok: true }); const { result } = renderHook(() => useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }), ); @@ -105,7 +122,7 @@ describe('useNotifications', () => { data: JSON.stringify({ type: 'notification', payload: notif }), }); }); - await act(async () => { await result.current.deleteNotification({ ...notif, nodeId: localNode.id }); }); - expect(result.current.notifications).toHaveLength(0); + act(() => { result.current.deleteNotification({ ...notif, nodeId: localNode.id }); }); + await waitFor(() => expect(result.current.notifications).toHaveLength(0)); }); }); diff --git a/frontend/src/components/sidebar/SidebarActions.tsx b/frontend/src/components/sidebar/SidebarActions.tsx index fffb5217..478767ed 100644 --- a/frontend/src/components/sidebar/SidebarActions.tsx +++ b/frontend/src/components/sidebar/SidebarActions.tsx @@ -15,7 +15,7 @@ interface SidebarActionsProps { export function SidebarActions({ createStackSlot, onScan, isScanning, bulkMode, onToggleBulkMode }: SidebarActionsProps) { return (
-
{createStackSlot}
+
{createStackSlot}
diff --git a/frontend/src/components/sidebar/SidebarActivityTicker.tsx b/frontend/src/components/sidebar/SidebarActivityTicker.tsx index b6d40766..60b071a2 100644 --- a/frontend/src/components/sidebar/SidebarActivityTicker.tsx +++ b/frontend/src/components/sidebar/SidebarActivityTicker.tsx @@ -12,16 +12,17 @@ interface SidebarActivityTickerProps { } export function SidebarActivityTicker({ notifications, connected, onNavigate }: SidebarActivityTickerProps) { - const [, forceUpdate] = useReducer((x: number) => x + 1, 0); + const [tick, forceUpdate] = useReducer((x: number) => x + 1, 0); useEffect(() => { const id = setInterval(forceUpdate, NOW_TICK_MS); return () => clearInterval(id); }, []); const latest = useMemo(() => { + const nowSecs = Math.floor(Date.now() / 1000); return notifications - .filter(n => n.stack_name) + .filter(n => n.stack_name && (nowSecs - n.timestamp) <= 3600) .sort((a, b) => b.timestamp - a.timestamp)[0] ?? null; - }, [notifications]); + }, [notifications, tick]); const idle = latest === null; const dotClass = connected diff --git a/frontend/src/components/sidebar/SidebarFilterChips.tsx b/frontend/src/components/sidebar/SidebarFilterChips.tsx index 09db8644..16f47e6b 100644 --- a/frontend/src/components/sidebar/SidebarFilterChips.tsx +++ b/frontend/src/components/sidebar/SidebarFilterChips.tsx @@ -1,3 +1,4 @@ +import { Minus, Plus } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { FilterChip } from './sidebar-types'; @@ -12,6 +13,8 @@ interface SidebarFilterChipsProps { active: FilterChip; counts: FilterCounts; onChange: (chip: FilterChip) => void; + visible: boolean; + onToggle: () => void; } const chips: { id: FilterChip; label: string }[] = [ @@ -21,42 +24,59 @@ const chips: { id: FilterChip; label: string }[] = [ { id: 'updates', label: 'Updates' }, ]; -export function SidebarFilterChips({ active, counts, onChange }: SidebarFilterChipsProps) { +export function SidebarFilterChips({ active, counts, onChange, visible, onToggle }: SidebarFilterChipsProps) { return ( -
- {chips.map(({ id, label }) => { - const count = counts[id]; - const isActive = active === id; - const isUpdates = id === 'updates'; - const hasUpdates = isUpdates && count > 0; +
+ {visible ? ( +
+ {chips.map(({ id, label }) => { + const count = counts[id]; + const displayCount = count > 99 ? '99+' : count; + const isActive = active === id; + const isUpdates = id === 'updates'; + const hasUpdates = isUpdates && count > 0; - return ( - - ); - })} + return ( + + ); + })} +
+ ) : ( +
+ )} +
); } diff --git a/frontend/src/components/sidebar/StackSidebar.tsx b/frontend/src/components/sidebar/StackSidebar.tsx index c1a60f4a..46572846 100644 --- a/frontend/src/components/sidebar/StackSidebar.tsx +++ b/frontend/src/components/sidebar/StackSidebar.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { useState, useCallback, type ReactNode } from 'react'; import { Command } from '@/components/ui/command'; import { ScrollArea } from '@/components/ui/scroll-area'; import type { NotificationItem } from '@/components/dashboard/types'; @@ -45,6 +45,21 @@ export function StackSidebar(props: StackSidebarProps) { bulkMode, selectedFiles, isPaid, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction, } = props; + const [filtersVisible, setFiltersVisible] = useState(() => { + try { + const v = window.localStorage.getItem('sencho:sidebar:filters-visible'); + return v === null ? true : v !== 'false'; + } catch { return true; } + }); + + const handleToggleFilters = useCallback(() => { + setFiltersVisible(prev => { + const next = !prev; + try { window.localStorage.setItem('sencho:sidebar:filters-visible', String(next)); } catch { /* localStorage unavailable */ } + return next; + }); + }, []); + return (
@@ -64,6 +79,8 @@ export function StackSidebar(props: StackSidebarProps) { active={filterChip} counts={filterCounts} onChange={onFilterChipChange} + visible={filtersVisible} + onToggle={handleToggleFilters} /> {selectedFiles.size > 0 && ( {
child
); - expect(screen.getByText('PINNED')).toHaveClass('text-brand/90'); + expect(screen.getByText(/PINNED/)).toHaveClass('text-brand/90'); }); });