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}