From 2da87c8c5d2df408e81d2d199b2db49b24b379f5 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 11 Jul 2026 20:57:06 -0400 Subject: [PATCH] feat: add mobile row layout to Security History tab (#1608) Replace the desktop 8-column table with card-like mobile rows on phones, following the ImagesTab pattern. Each row shows a 44px checkbox for compare selection, a severity dot, truncated image ref with trigger and timestamp meta, count tags (total vulns + fixable, at most 2), and a chevron. Secret/config-only scans use the FINDINGS dot and suppress the clean tag, matching the getSeverityKey classification from severityStyles. Extract a shared HistoryStateMessage component to deduplicate the loading/error/empty state blocks between the mobile and desktop branches. --- .../src/components/security/HistoryTab.tsx | 49 +++- .../components/security/SecurityMobile.tsx | 58 ++++- .../security/__tests__/HistoryTab.test.tsx | 95 ++++++++ images-tab.md | 227 ++++++++++++++++++ 4 files changed, 421 insertions(+), 8 deletions(-) create mode 100644 images-tab.md 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