From 0066887ceeb8ecbd8b41a5b72c3df4f2249527de Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 14 Jun 2026 21:28:13 -0400 Subject: [PATCH] feat(security): reflow the node Security page for mobile (#1372) * feat(security): reflow the node Security page for mobile Below the md breakpoint the Security page now reads as a phone surface instead of a squeezed desktop, with no change to the desktop layout. - Masthead stat cluster moves into a full-width 3-cell strip (critical / high / last scan) below the tab strip, since the masthead hides its inline cluster on a phone. - The eight-section tab strip becomes a horizontally scrollable mono row with an edge mask-fade and a cyan underline on the active tab; every section stays reachable by scroll. - The six totals render as a 3x2 hairline-divided grid instead of the 640px-wide rail that forced a horizontal scroll. - The Images tab becomes a filterable, scrollable list (severity dot, truncated ref, freshness, critical/high count tags) with a chip row, in place of the desktop table. - A freshness footer band states scan recency and scanner version. All mobile treatment is gated by useIsMobile() or max-md: utilities, so the desktop view is byte-identical. Charts are reused full-width. * feat(security): make the mobile Security page a bespoke masthead-led screen On a phone the Security page now drops the global top bar and leads with its masthead (the notifications + more-menu cluster moves into the masthead's right slot), matching Home and Fleet so the mobile shell is continuous across pages. The view is reclassified bespoke and rendered through the masthead-led path; the desktop layout is unchanged. The mobile "more" menu now lists every destination instead of omitting the bottom-tab views, so the same menu opens the same set on every screen rather than changing contents from page to page. --- frontend/src/components/EditorLayout.tsx | 23 +- .../EditorLayout/mobile-treatments.test.ts | 12 +- .../EditorLayout/mobile-treatments.ts | 2 +- .../src/components/MobileMoreMenu.test.tsx | 49 ++++ frontend/src/components/MobileMoreMenu.tsx | 13 +- frontend/src/components/SecurityView.tsx | 195 +++++++++++----- .../src/components/security/ImagesTab.tsx | 58 ++++- .../src/components/security/OverviewTab.tsx | 41 +++- .../components/security/ScanNodeLauncher.tsx | 6 +- .../components/security/SecurityMobile.tsx | 216 ++++++++++++++++++ .../__tests__/SecurityMobile.test.tsx | 189 +++++++++++++++ frontend/src/lib/severityStyles.ts | 4 + 12 files changed, 712 insertions(+), 96 deletions(-) create mode 100644 frontend/src/components/MobileMoreMenu.test.tsx create mode 100644 frontend/src/components/security/SecurityMobile.tsx create mode 100644 frontend/src/components/security/__tests__/SecurityMobile.test.tsx diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 3340101e..c73d63de 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { Button } from './ui/button'; import { Plus, Loader2, ChevronLeft } from 'lucide-react'; import { UserProfileDropdown } from './UserProfileDropdown'; @@ -52,6 +52,11 @@ import { deriveMobileSurface, type MobileView } from './EditorLayout/mobile-surf import { BESPOKE_MOBILE_VIEWS } from './EditorLayout/mobile-treatments'; import type { SectionId } from './settings/types'; +// The Security page is heavy (charts, scan sheets) and already code-split on the +// desktop content path, so the bespoke phone screen reuses the same lazy chunk +// rather than pulling SecurityView into the main shell bundle. +const SecurityView = lazy(() => import('./SecurityView').then(m => ({ default: m.SecurityView }))); + export default function EditorLayout() { const { isAdmin, can } = useAuth(); const { status: trivy } = useTrivyStatus(); @@ -760,6 +765,22 @@ export default function EditorLayout() { return ; case 'settings': return ; + case 'security': + return ( + + + + )} + > + + + ); default: return workspaceEl; } diff --git a/frontend/src/components/EditorLayout/mobile-treatments.test.ts b/frontend/src/components/EditorLayout/mobile-treatments.test.ts index d40e0824..25d797a9 100644 --- a/frontend/src/components/EditorLayout/mobile-treatments.test.ts +++ b/frontend/src/components/EditorLayout/mobile-treatments.test.ts @@ -13,8 +13,8 @@ describe('mobile treatments', () => { } }); - it('treats the Security view as responsive (reflowed, not bespoke or desktop-only)', () => { - expect(MOBILE_TREATMENTS.security).toBe('responsive'); + it('treats the Security view as a bespoke masthead-led phone screen', () => { + expect(MOBILE_TREATMENTS.security).toBe('bespoke'); }); it('keeps BESPOKE_MOBILE_VIEWS in lockstep with the bespoke treatments', () => { @@ -27,10 +27,12 @@ describe('mobile treatments', () => { it('pins the set of bespoke phone screens (update deliberately when adding one)', () => { // A change here means a top-level view gained or lost a bespoke phone screen. - // Updating this list should go hand in hand with adding the screen under - // components/mobile/ and wiring its case in EditorLayout's renderMobileBespoke. + // Updating this list should go hand in hand with wiring the screen's case in + // EditorLayout's renderMobileBespoke (a dedicated component under + // components/mobile/, or a masthead-led mobile branch of the desktop view as + // Security does). expect([...BESPOKE_MOBILE_VIEWS].sort()).toEqual( - ['dashboard', 'fleet', 'scheduled-ops', 'settings'], + ['dashboard', 'fleet', 'scheduled-ops', 'security', 'settings'], ); }); }); diff --git a/frontend/src/components/EditorLayout/mobile-treatments.ts b/frontend/src/components/EditorLayout/mobile-treatments.ts index 70dcee31..960a9ed7 100644 --- a/frontend/src/components/EditorLayout/mobile-treatments.ts +++ b/frontend/src/components/EditorLayout/mobile-treatments.ts @@ -22,7 +22,7 @@ export const MOBILE_TREATMENTS: Record = { settings: 'bespoke', editor: 'detail', resources: 'responsive', - security: 'responsive', + security: 'bespoke', templates: 'responsive', 'global-observability': 'responsive', 'auto-updates': 'responsive', diff --git a/frontend/src/components/MobileMoreMenu.test.tsx b/frontend/src/components/MobileMoreMenu.test.tsx new file mode 100644 index 00000000..7879a431 --- /dev/null +++ b/frontend/src/components/MobileMoreMenu.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Home, Radar, Boxes, ShieldCheck } from 'lucide-react'; +import { MobileMoreMenu } from './MobileMoreMenu'; +import type { NavItem } from './EditorLayout/hooks/useViewNavigationState'; + +// Includes a bottom-tab primary (dashboard) and secondary destinations; the +// menu must list every one so navigation is identical on every screen. +const navItems: NavItem[] = [ + { value: 'dashboard', label: 'Home', icon: Home }, + { value: 'fleet', label: 'Fleet', icon: Radar }, + { value: 'resources', label: 'Resources', icon: Boxes }, + { value: 'security', label: 'Security', icon: ShieldCheck }, +]; + +function open(over: Partial> = {}) { + const props: React.ComponentProps = { + navItems, + activeView: 'security', + onNavigate: vi.fn(), + ...over, + }; + render(); + // The destination list lives inside a Sheet that is closed until the trigger + // is clicked, so open it before querying the nav buttons. + fireEvent.click(screen.getByRole('button', { name: 'More destinations' })); + return props; +} + +describe('MobileMoreMenu', () => { + it('lists every nav item, including the bottom-tab primaries', () => { + open(); + for (const item of navItems) { + expect(screen.getByRole('button', { name: item.label })).toBeInTheDocument(); + } + }); + + it('navigates to the chosen destination on click', () => { + const { onNavigate } = open(); + fireEvent.click(screen.getByRole('button', { name: 'Resources' })); + expect(onNavigate).toHaveBeenCalledWith('resources'); + }); + + it('marks the active destination', () => { + open({ activeView: 'security' }); + expect(screen.getByRole('button', { name: 'Security' }).className).toContain('bg-glass-highlight'); + expect(screen.getByRole('button', { name: 'Resources' }).className).not.toContain('font-medium'); + }); +}); diff --git a/frontend/src/components/MobileMoreMenu.tsx b/frontend/src/components/MobileMoreMenu.tsx index be70082b..2da98aa2 100644 --- a/frontend/src/components/MobileMoreMenu.tsx +++ b/frontend/src/components/MobileMoreMenu.tsx @@ -5,11 +5,6 @@ import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; import { cn } from '@/lib/utils'; import type { NavItem, ActiveView } from './EditorLayout/hooks/useViewNavigationState'; -// Destinations the bottom tab bar already exposes; the "more" menu omits these -// so it lists only the secondary destinations (auto-updates, app store, -// observability, etc.). Kept in sync with MobileTabBar's tab set. -const TAB_BAR_VIEWS = new Set(['dashboard', 'fleet', 'scheduled-ops', 'settings']); - interface MobileMoreMenuProps { /** The already-gated nav items (admin / remote / paid filtering applied). */ navItems: NavItem[]; @@ -22,12 +17,12 @@ interface MobileMoreMenuProps { /** * The "more destinations" affordance for the mobile content screens. On a phone * the global TopBar is dropped and each screen's masthead leads, so this hosts - * every secondary destination the bottom tab bar does not (auto-updates, app - * store, observability, etc.) plus the theme and account controls. + * the full destination list plus the theme and account controls. It lists every + * gated nav item (the bottom tab bar's primaries included) so the same menu + * opens the same set on every screen, rather than changing contents per page. */ export function MobileMoreMenu({ navItems, activeView, onNavigate, footer }: MobileMoreMenuProps) { const [open, setOpen] = useState(false); - const secondaryItems = navItems.filter(item => !TAB_BAR_VIEWS.has(item.value)); return ( @@ -46,7 +41,7 @@ export function MobileMoreMenu({ navItems, activeView, onNavigate, footer }: Mob

Navigate