diff --git a/frontend/src/components/security/HistoryTab.tsx b/frontend/src/components/security/HistoryTab.tsx
index c1aef800..d192c741 100644
--- a/frontend/src/components/security/HistoryTab.tsx
+++ b/frontend/src/components/security/HistoryTab.tsx
@@ -9,10 +9,12 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import { useNodes } from '@/context/NodeContext';
+import { useIsMobile } from '@/hooks/use-is-mobile';
import { formatShortDigest } from '@/lib/formatDigest';
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
import { SeverityChip } from '../VulnerabilityScanSheet';
import { ScanComparisonSheet } from '../ScanComparisonSheet';
+import { HistoryScanRow } from './SecurityMobile';
import type { VulnerabilityScan, ScanDetailTab, VulnSeverity } from '@/types/security';
const PAGE_SIZE = 100;
@@ -59,6 +61,29 @@ interface HistoryTabProps {
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
}
+/** Shared loading/error/empty state messages for the mobile and desktop branches. */
+function HistoryStateMessage({ loading, error, search, isEmpty }: {
+ loading: boolean;
+ error: boolean;
+ search: string;
+ isEmpty: boolean;
+}) {
+ if (loading) {
+ return
Loading scan history...
;
+ }
+ if (error) {
+ return Couldn't load scan history. Try again.
;
+ }
+ if (isEmpty) {
+ return (
+
+ {search ? 'No scans match your search.' : 'No completed scans yet. Scan an image from the Images tab.'}
+
+ );
+ }
+ return null;
+}
+
/** Inline scan-history table: search, sortable columns, two-scan compare, and
* server-paginated completed scans. Replaces the former history sheet. */
export function HistoryTab({ onInspect }: HistoryTabProps) {
@@ -76,6 +101,7 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
const [compareIds, setCompareIds] = useState<[number, number] | null>(null);
const [sortKey, setSortKey] = useState('scanned_at');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
+ const isMobile = useIsMobile();
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
@@ -191,6 +217,20 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
+ {isMobile ? (
+
+ {!loading && !error && sorted.map((scan) => (
+ toggleSelect(scan.id)}
+ onInspect={() => onInspect(scan.id, 'vulns')}
+ />
+ ))}
+
+
+ ) : (
@@ -239,14 +279,9 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
})}
- {loading && Loading scan history...
}
- {!loading && error && Couldn't load scan history. Try again.
}
- {!loading && !error && sorted.length === 0 && (
-
- {search ? 'No scans match your search.' : 'No completed scans yet. Scan an image from the Images tab.'}
-
- )}
+
+ )}
{total > PAGE_SIZE && (
diff --git a/frontend/src/components/security/SecurityMobile.tsx b/frontend/src/components/security/SecurityMobile.tsx
index 4e926642..87f69f47 100644
--- a/frontend/src/components/security/SecurityMobile.tsx
+++ b/frontend/src/components/security/SecurityMobile.tsx
@@ -6,10 +6,11 @@ import type { ReactNode } from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Kicker, MobileSubTabs, MobileChipRow } from '@/components/mobile/mobile-ui';
+import { Checkbox } from '@/components/ui/checkbox';
import { getSeverityKey, SEVERITY_DOT_CLASSES, type ImageFilterValue } from '@/lib/severityStyles';
import { formatTimeAgo } from '@/lib/relativeTime';
import type { SecurityTab } from '@/lib/events';
-import type { ScanSummary, SecurityOverview, ScanDetailTab } from '@/types/security';
+import type { ScanSummary, SecurityOverview, ScanDetailTab, VulnerabilityScan } from '@/types/security';
export interface SecurityMobileTab {
value: SecurityTab;
@@ -150,6 +151,61 @@ export function ImageScanRow({ summary, onInspect }: {
);
}
+/** One scan-history row in the mobile History list: checkbox (44px touch target
+ * for compare selection), severity dot, truncated mono ref over a freshness meta
+ * line, trailing count tags (total vulns + fixable, max 2), and a chevron. */
+export function HistoryScanRow({ scan, selected, onToggle, onInspect }: {
+ scan: VulnerabilityScan;
+ selected: boolean;
+ onToggle: () => void;
+ onInspect: () => void;
+}) {
+ // VulnerabilityScan has the same highest_severity / secret_count /
+ // misconfig_count fields as ScanSummary but a different shape, so the
+ // classification is inlined rather than cast through getSeverityKey.
+ const hasNonVuln = scan.secret_count > 0 || scan.misconfig_count > 0;
+ const severityKey = scan.highest_severity ?? (hasNonVuln ? 'FINDINGS' : 'CLEAN');
+
+ return (
+
+
+
+
+ );
+}
+
export interface ImageFilterChip {
value: ImageFilterValue;
label: string;
diff --git a/frontend/src/components/security/__tests__/HistoryTab.test.tsx b/frontend/src/components/security/__tests__/HistoryTab.test.tsx
index e6395f66..13dc7fed 100644
--- a/frontend/src/components/security/__tests__/HistoryTab.test.tsx
+++ b/frontend/src/components/security/__tests__/HistoryTab.test.tsx
@@ -66,6 +66,19 @@ function listResponse(items: VulnerabilityScan[], total?: number): Response {
return { ok: true, status: 200, json: async () => ({ items, total: total ?? items.length }) } as unknown as Response;
}
+function installMatchMedia(matches: boolean) {
+ window.matchMedia = vi.fn().mockReturnValue({
+ matches,
+ media: '',
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }) as unknown as typeof window.matchMedia;
+}
+
beforeEach(() => {
mockedFetch.mockReset();
compareProps.length = 0;
@@ -162,3 +175,85 @@ describe('HistoryTab', () => {
expect(screen.queryByText(/No completed scans yet/)).not.toBeInTheDocument();
});
});
+
+describe('HistoryTab (mobile)', () => {
+ const original = window.matchMedia;
+ afterEach(() => { window.matchMedia = original; vi.clearAllMocks(); });
+
+ it('renders mobile rows instead of a table', async () => {
+ installMatchMedia(true);
+ mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
+ render();
+ await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
+ // No table element, no column headers.
+ expect(document.querySelector('table')).toBeNull();
+ expect(screen.queryByText('Trigger')).toBeNull();
+ });
+
+ it('opens the scan sheet from a mobile row tap', async () => {
+ installMatchMedia(true);
+ const onInspect = vi.fn();
+ mockedFetch.mockResolvedValue(listResponse([scan({ id: 42, image_ref: 'nginx:1' })]));
+ render();
+ await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument());
+ // Click the row's content button (the inspect target), not the checkbox.
+ const rowBtn = screen.getByRole('button', { name: /nginx:1/ });
+ await userEvent.click(rowBtn);
+ expect(onInspect).toHaveBeenCalledWith(42, 'vulns');
+ });
+
+ it('selects two scans via mobile checkboxes and opens compare', async () => {
+ installMatchMedia(true);
+ const older = scan({ id: 10, image_ref: 'a:1', scanned_at: 1000 });
+ const newer = scan({ id: 20, image_ref: 'b:1', scanned_at: 2000 });
+ mockedFetch.mockResolvedValue(listResponse([newer, older]));
+ render();
+ await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument());
+ const checks = screen.getAllByLabelText('Select scan to compare');
+ await userEvent.click(checks[0]);
+ await userEvent.click(checks[1]);
+ await userEvent.click(screen.getByRole('button', { name: /Compare/ }));
+ const last = compareProps[compareProps.length - 1];
+ expect(last.baselineScanId).toBe(10);
+ expect(last.currentScanId).toBe(20);
+ });
+
+ it('does not show clean for a secret-only scan', async () => {
+ installMatchMedia(true);
+ mockedFetch.mockResolvedValue(listResponse([scan({
+ image_ref: 'secret-img:1',
+ total_vulnerabilities: 0,
+ secret_count: 2,
+ misconfig_count: 0,
+ highest_severity: null,
+ })]));
+ render();
+ await waitFor(() => expect(screen.getByText('secret-img:1')).toBeInTheDocument());
+ expect(screen.queryByText('clean')).not.toBeInTheDocument();
+ });
+
+ it('shows clean tag for a truly clean scan', async () => {
+ installMatchMedia(true);
+ mockedFetch.mockResolvedValue(listResponse([scan({
+ image_ref: 'clean-img:1',
+ total_vulnerabilities: 0,
+ fixable_count: 0,
+ secret_count: 0,
+ misconfig_count: 0,
+ highest_severity: null,
+ })]));
+ render();
+ await waitFor(() => expect(screen.getByText('clean-img:1')).toBeInTheDocument());
+ expect(screen.getByText('clean')).toBeInTheDocument();
+ });
+
+ it('renders checkbox with 44px touch target', async () => {
+ installMatchMedia(true);
+ mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'nginx:1' })]));
+ render();
+ await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument());
+ const checkbox = screen.getByLabelText('Select scan to compare');
+ expect(checkbox.className).toContain('h-11');
+ expect(checkbox.className).toContain('w-11');
+ });
+});
diff --git a/images-tab.md b/images-tab.md
new file mode 100644
index 00000000..04be17ff
--- /dev/null
+++ b/images-tab.md
@@ -0,0 +1,227 @@
+- generic [ref=f1e3]:
+ - generic [ref=f1e4]:
+ - generic [ref=f1e7]:
+ - generic [ref=f1e8]: Sencho
+ - generic [ref=f1e9]: v0.94.1
+ - button "Switch node" [ref=f1e11]:
+ - generic [ref=f1e14] [cursor=pointer]:
+ - generic [ref=f1e15]: Node · LOCAL
+ - generic [ref=f1e16]: Local
+ - generic [ref=f1e20]:
+ - button "Create Stack" [ref=f1e22]
+ - button [ref=f1e23]
+ - button [ref=f1e24]
+ - generic [ref=f1e25]:
+ - combobox [expanded] [ref=f1e32]
+ - generic [ref=f1e33]:
+ - generic [ref=f1e34]:
+ - button "All 1" [pressed] [ref=f1e35]:
+ - text: All
+ - generic [ref=f1e36]: "1"
+ - button "Up 1" [ref=f1e37]:
+ - text: Up
+ - generic [ref=f1e38]: "1"
+ - button "Down 0" [ref=f1e39]:
+ - text: Down
+ - generic [ref=f1e40]: "0"
+ - button "Updates 0" [ref=f1e41]:
+ - text: Updates
+ - generic [ref=f1e42]: "0"
+ - button "Hide filters" [ref=f1e43]
+ - listbox "Suggestions" [ref=f1e49]:
+ - generic [ref=f1e51]:
+ - button "UNLABELED 1" [expanded] [ref=f1e52]:
+ - generic [ref=f1e53]: UNLABELED
+ - generic [ref=f1e54]: "1"
+ - option [selected] [ref=f1e59]:
+ - button "UP bookstack" [ref=f1e60] [cursor=pointer]:
+ - generic [ref=f1e61]: UP
+ - generic [ref=f1e62]: bookstack
+ - button [ref=f1e65]
+ - button "Live · no stack changes in 1h LIVE · OPEN ACTIVITY →" [ref=f1e66] [cursor=pointer]:
+ - generic [ref=f1e67]: Live · no stack changes in 1h
+ - generic [ref=f1e70]: LIVE · OPEN ACTIVITY →
+ - generic [ref=f1e71]:
+ - generic [ref=f1e72]:
+ - navigation "Primary" [ref=f1e73]:
+ - button "Home" [ref=f1e74]
+ - button "Fleet" [ref=f1e79]
+ - button "Resources" [ref=f1e88]
+ - button "Security" [ref=f1e92]
+ - button "App Store" [ref=f1e97]
+ - button "Logs" [ref=f1e103]
+ - button "Update" [ref=f1e107]
+ - button "Schedules" [ref=f1e114]
+ - generic [ref=f1e119]:
+ - button "Open search (Ctrl+K)" [ref=f1e120]
+ - button "Theme" [ref=f1e124]
+ - button "Notifications" [ref=f1e125]
+ - button "Profile" [ref=f1e126]: BO
+ - generic [ref=f1e128]:
+ - generic [ref=f1e132]:
+ - generic [ref=f1e133]:
+ - generic [ref=f1e134]:
+ - generic [ref=f1e135]: Action needed
+ - generic [ref=f1e137]:
+ - generic [ref=f1e138]: "2 actions: fixable findings, publicly exposed affected images ·"
+ - generic [ref=f1e139]: 10 images scanned · scanner ready
+ - button "Update affected images →" [ref=f1e144]
+ - generic [ref=f1e145]:
+ - generic [ref=f1e146]:
+ - generic [ref=f1e147]: CRITICAL
+ - generic [ref=f1e148]: "0"
+ - generic [ref=f1e149]:
+ - generic [ref=f1e150]: HIGH
+ - generic [ref=f1e151]: "31"
+ - generic [ref=f1e152]:
+ - generic [ref=f1e153]: LAST SCAN
+ - generic [ref=f1e154]: 23h ago
+ - generic [ref=f1e155]:
+ - tablist [ref=f1e157]:
+ - tab "Overview" [ref=f1e159]
+ - tab "Images" [active] [selected] [ref=f1e166]
+ - tab "Compose risks" [ref=f1e178]
+ - tab "Secrets" [ref=f1e182]
+ - tab "Policies" [ref=f1e188]
+ - tab "Suppressions" [ref=f1e193]
+ - tab "History" [ref=f1e200]
+ - tab "Scanner setup" [ref=f1e206]
+ - tabpanel "Images" [ref=f1e314]:
+ - generic [ref=f1e315]:
+ - generic [ref=f1e316]:
+ - textbox "Search images..." [ref=f1e321]
+ - combobox [ref=f1e323]:
+ - generic [ref=f1e324]: All severities
+ - table [ref=f1e333]:
+ - rowgroup [ref=f1e334]:
+ - row [ref=f1e335]:
+ - columnheader [ref=f1e336] [cursor=pointer]:
+ - button "Image" [ref=f1e337]
+ - columnheader [ref=f1e338] [cursor=pointer]:
+ - button "Findings" [ref=f1e339]
+ - columnheader [ref=f1e340] [cursor=pointer]:
+ - button "Last scan" [ref=f1e341]
+ - columnheader [ref=f1e344] [cursor=pointer]:
+ - button "Severity" [ref=f1e345]
+ - columnheader "Actions" [ref=f1e346]
+ - rowgroup [ref=f1e347]:
+ - row [ref=f1e348]:
+ - cell [ref=f1e349]:
+ - button "lscr.io/linuxserver/bookstack:latest" [ref=f1e350]
+ - cell [ref=f1e351]:
+ - button "6H14 fixable" [ref=f1e352]:
+ - generic [ref=f1e353]: 6H
+ - generic [ref=f1e354]: 14 fixable
+ - cell "23h ago" [ref=f1e355]
+ - cell [ref=f1e356]:
+ - button "HIGH" [ref=f1e357] [cursor=pointer]
+ - cell [ref=f1e359]:
+ - button "Scan lscr.io/linuxserver/bookstack:latest" [ref=f1e360]
+ - row [ref=f1e361]:
+ - cell [ref=f1e362]:
+ - button "lscr.io/linuxserver/bookstack@sha256:13dc508fb46aa7d2b726b6c872e4903892bda68391ac948a5603d3ca60f0937e" [ref=f1e363]
+ - cell [ref=f1e364]:
+ - button "6H14 fixable" [ref=f1e365]:
+ - generic [ref=f1e366]: 6H
+ - generic [ref=f1e367]: 14 fixable
+ - cell "23h ago" [ref=f1e368]
+ - cell [ref=f1e369]:
+ - button "HIGH" [ref=f1e370] [cursor=pointer]
+ - cell [ref=f1e372]:
+ - button "Scan lscr.io/linuxserver/bookstack@sha256:13dc508fb46aa7d2b726b6c872e4903892bda68391ac948a5603d3ca60f0937e" [ref=f1e373]
+ - row [ref=f1e374]:
+ - cell [ref=f1e375]:
+ - button "lscr.io/linuxserver/bazarr:latest" [ref=f1e376]
+ - cell [ref=f1e377]:
+ - button "2H1 fixable" [ref=f1e378]:
+ - generic [ref=f1e379]: 2H
+ - generic [ref=f1e380]: 1 fixable
+ - cell "23h ago" [ref=f1e381]
+ - cell [ref=f1e382]:
+ - button "HIGH" [ref=f1e383] [cursor=pointer]
+ - cell [ref=f1e385]:
+ - button "Scan lscr.io/linuxserver/bazarr:latest" [ref=f1e386]
+ - row [ref=f1e387]:
+ - cell [ref=f1e388]:
+ - button "lscr.io/linuxserver/jackett:latest" [ref=f1e389]
+ - cell [ref=f1e390]:
+ - button "1H1 fixable" [ref=f1e391]:
+ - generic [ref=f1e392]: 1H
+ - generic [ref=f1e393]: 1 fixable
+ - cell "23h ago" [ref=f1e394]
+ - cell [ref=f1e395]:
+ - button "HIGH" [ref=f1e396] [cursor=pointer]
+ - cell [ref=f1e398]:
+ - button "Scan lscr.io/linuxserver/jackett:latest" [ref=f1e399]
+ - row [ref=f1e400]:
+ - cell [ref=f1e401]:
+ - button "saelix/sencho:pr-1603" [ref=f1e402]
+ - cell [ref=f1e403]:
+ - button "3H4 fixable" [ref=f1e404]:
+ - generic [ref=f1e405]: 3H
+ - generic [ref=f1e406]: 4 fixable
+ - cell "23h ago" [ref=f1e407]
+ - cell [ref=f1e408]:
+ - button "HIGH" [ref=f1e409] [cursor=pointer]
+ - cell [ref=f1e411]:
+ - button "Scan saelix/sencho:pr-1603" [ref=f1e412]
+ - row [ref=f1e413]:
+ - cell [ref=f1e414]:
+ - button "lscr.io/linuxserver/prowlarr:latest" [ref=f1e415]
+ - cell [ref=f1e416]:
+ - button "1H1 fixable" [ref=f1e417]:
+ - generic [ref=f1e418]: 1H
+ - generic [ref=f1e419]: 1 fixable
+ - cell "23h ago" [ref=f1e420]
+ - cell [ref=f1e421]:
+ - button "HIGH" [ref=f1e422] [cursor=pointer]
+ - cell [ref=f1e424]:
+ - button "Scan lscr.io/linuxserver/prowlarr:latest" [ref=f1e425]
+ - row [ref=f1e426]:
+ - cell [ref=f1e427]:
+ - button "saelix/sencho:pr-1600" [ref=f1e428]
+ - cell [ref=f1e429]:
+ - button "3H4 fixable" [ref=f1e430]:
+ - generic [ref=f1e431]: 3H
+ - generic [ref=f1e432]: 4 fixable
+ - cell "23h ago" [ref=f1e433]
+ - cell [ref=f1e434]:
+ - button "HIGH" [ref=f1e435] [cursor=pointer]
+ - cell [ref=f1e437]:
+ - button "Scan saelix/sencho:pr-1600" [ref=f1e438]
+ - row [ref=f1e439]:
+ - cell [ref=f1e440]:
+ - button "saelix/sencho:pr-1609" [ref=f1e441]
+ - cell [ref=f1e442]:
+ - button "3H4 fixable" [ref=f1e443]:
+ - generic [ref=f1e444]: 3H
+ - generic [ref=f1e445]: 4 fixable
+ - cell "23h ago" [ref=f1e446]
+ - cell [ref=f1e447]:
+ - button "HIGH" [ref=f1e448] [cursor=pointer]
+ - cell [ref=f1e450]:
+ - button "Scan saelix/sencho:pr-1609" [ref=f1e451]
+ - row [ref=f1e452]:
+ - cell [ref=f1e453]:
+ - button "saelix/sencho:pr-1608" [ref=f1e454]
+ - cell [ref=f1e455]:
+ - button "3H4 fixable" [ref=f1e456]:
+ - generic [ref=f1e457]: 3H
+ - generic [ref=f1e458]: 4 fixable
+ - cell "23h ago" [ref=f1e459]
+ - cell [ref=f1e460]:
+ - button "HIGH" [ref=f1e461] [cursor=pointer]
+ - cell [ref=f1e463]:
+ - button "Scan saelix/sencho:pr-1608" [ref=f1e464]
+ - row [ref=f1e465]:
+ - cell [ref=f1e466]:
+ - button "saelix/sencho:pr-1610" [ref=f1e467]
+ - cell [ref=f1e468]:
+ - button "3H4 fixable" [ref=f1e469]:
+ - generic [ref=f1e470]: 3H
+ - generic [ref=f1e471]: 4 fixable
+ - cell "23h ago" [ref=f1e472]
+ - cell [ref=f1e473]:
+ - button "HIGH" [ref=f1e474] [cursor=pointer]
+ - cell [ref=f1e476]:
+ - button "Scan saelix/sencho:pr-1610" [ref=f1e477]
\ No newline at end of file