diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index 1569ce9a..304e3a85 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -171,7 +171,11 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) { state={state} tone={tone} pulsing={pulsing} + size="hero" className="rounded-lg mb-4" + subtitle={overview + ? `${overview.scannedImages} ${overview.scannedImages === 1 ? 'image' : 'images'} scanned ยท scanner ${overview.scanner.available ? 'ready' : 'not installed'}` + : undefined} metadata={overview ? [ { label: 'CRITICAL', value: String(overview.critical), tone: overview.critical > 0 ? 'error' : 'value' }, { label: 'HIGH', value: String(overview.high), tone: overview.high > 0 ? 'warn' : 'value' }, diff --git a/frontend/src/components/security/HistoryTab.tsx b/frontend/src/components/security/HistoryTab.tsx index 4355c156..a5dfd81b 100644 --- a/frontend/src/components/security/HistoryTab.tsx +++ b/frontend/src/components/security/HistoryTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; @@ -98,6 +98,18 @@ export function HistoryTab({ onInspect }: HistoryTabProps) { useEffect(() => { void load(safePage, search); }, [load, safePage, search, nodeId]); + // Realtime search: apply the draft automatically a beat after typing stops (no + // Enter), then refetch from page 0. Debounced rather than per-keystroke because + // the list is server-paginated, so the query searches every completed scan, not + // just the loaded page. Skip the initial mount so it does not reset pagination. + const prevSearchDraftRef = useRef(searchDraft); + useEffect(() => { + if (prevSearchDraftRef.current === searchDraft) return; + prevSearchDraftRef.current = searchDraft; + const t = setTimeout(() => { setSearch(searchDraft); setPage(0); }, 250); + return () => clearTimeout(t); + }, [searchDraft]); + const toggleSelect = (scanId: number) => { setSelected((prev) => { if (prev.includes(scanId)) return prev.filter((x) => x !== scanId); @@ -157,7 +169,6 @@ export function HistoryTab({ onInspect }: HistoryTabProps) { placeholder="Search by image..." value={searchDraft} onChange={(e) => setSearchDraft(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { setPage(0); setSearch(searchDraft); } }} className="pl-8" /> diff --git a/frontend/src/components/security/__tests__/HistoryTab.test.tsx b/frontend/src/components/security/__tests__/HistoryTab.test.tsx index e49439a1..eb2c61de 100644 --- a/frontend/src/components/security/__tests__/HistoryTab.test.tsx +++ b/frontend/src/components/security/__tests__/HistoryTab.test.tsx @@ -125,11 +125,11 @@ describe('HistoryTab', () => { expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeInTheDocument(); }); - it('searches by image on Enter, adding imageRefLike to the request', async () => { + it('searches by image as you type (no Enter), adding imageRefLike to the request', async () => { mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })])); render(); await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument()); - await userEvent.type(screen.getByPlaceholderText('Search by image...'), 'redis{Enter}'); + await userEvent.type(screen.getByPlaceholderText('Search by image...'), 'redis'); await waitFor(() => { const calls = mockedFetch.mock.calls.map((c) => c[0] as string); expect(calls.some((u) => u.includes('imageRefLike=redis'))).toBe(true); diff --git a/frontend/src/components/ui/PageMasthead.tsx b/frontend/src/components/ui/PageMasthead.tsx index e65645ce..d36e5149 100644 --- a/frontend/src/components/ui/PageMasthead.tsx +++ b/frontend/src/components/ui/PageMasthead.tsx @@ -15,8 +15,16 @@ export interface PageMastheadProps { tone: MastheadTone; pulsing?: boolean; metadata?: MastheadMetadataItem[]; + /** Optional meta line under the state word (e.g. a one-line posture summary). */ + subtitle?: ReactNode; children?: ReactNode; className?: string; + /** + * `hero` matches the larger title of the primary nav pages (Home, Fleet); + * `default` is the compact title used by the secondary tool pages (Settings, + * Console, Logs). + */ + size?: 'default' | 'hero'; } const toneConfig: Record {kicker} - + {state} + {subtitle ? ( + + {subtitle} + + ) : null} {children ?
{children}
: null}