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.
This commit is contained in:
Anso
2026-07-11 20:57:06 -04:00
committed by GitHub
parent 3fed67a3f1
commit 2da87c8c5d
4 changed files with 421 additions and 8 deletions
@@ -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 <div className="py-12 text-center text-sm text-muted-foreground">Loading scan history...</div>;
}
if (error) {
return <div className="py-12 text-center text-sm text-muted-foreground">Couldn't load scan history. Try again.</div>;
}
if (isEmpty) {
return (
<div className="py-12 text-center text-sm text-muted-foreground">
{search ? 'No scans match your search.' : 'No completed scans yet. Scan an image from the Images tab.'}
</div>
);
}
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<SortKey>('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) {
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
{isMobile ? (
<div className="px-4">
{!loading && !error && sorted.map((scan) => (
<HistoryScanRow
key={scan.id}
scan={scan}
selected={selected.includes(scan.id)}
onToggle={() => toggleSelect(scan.id)}
onInspect={() => onInspect(scan.id, 'vulns')}
/>
))}
<HistoryStateMessage loading={loading} error={error} search={search} isEmpty={sorted.length === 0} />
</div>
) : (
<ScrollArea block className="max-h-[60vh]">
<Table className="max-md:min-w-[720px]">
<TableHeader>
@@ -239,14 +279,9 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
})}
</TableBody>
</Table>
{loading && <div className="py-12 text-center text-sm text-muted-foreground">Loading scan history...</div>}
{!loading && error && <div className="py-12 text-center text-sm text-muted-foreground">Couldn't load scan history. Try again.</div>}
{!loading && !error && sorted.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
{search ? 'No scans match your search.' : 'No completed scans yet. Scan an image from the Images tab.'}
</div>
)}
<HistoryStateMessage loading={loading} error={error} search={search} isEmpty={sorted.length === 0} />
</ScrollArea>
)}
</div>
{total > PAGE_SIZE && (
@@ -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 (
<div className="flex min-h-11 items-center gap-[11px] border-b border-hairline py-[11px] last:border-b-0">
<Checkbox
checked={selected}
onCheckedChange={onToggle}
aria-label="Select scan to compare"
className="h-11 w-11"
/>
<button
type="button"
onClick={onInspect}
className="flex min-h-11 flex-1 items-center gap-[11px] text-left min-w-0"
>
<span
className={cn('h-[7px] w-[7px] shrink-0 rounded-full', SEVERITY_DOT_CLASSES[severityKey])}
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-[13px] text-stat-value">
{scan.image_ref}
</span>
<span className="mt-px block font-mono text-[10px] text-stat-icon">
{scan.triggered_by} · scanned {formatTimeAgo(scan.scanned_at)}
</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{scan.total_vulnerabilities > 0 && (
<CountTag tone="destructive">{scan.total_vulnerabilities}</CountTag>
)}
{scan.fixable_count > 0 && (
<CountTag tone="success">{scan.fixable_count} fixable</CountTag>
)}
{severityKey === 'CLEAN' && <CountTag tone="success">clean</CountTag>}
</span>
<ChevronRight className="h-3 w-3 shrink-0 text-stat-icon" strokeWidth={1.5} aria-hidden />
</button>
</div>
);
}
export interface ImageFilterChip {
value: ImageFilterValue;
label: string;
@@ -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(<HistoryTab onInspect={vi.fn()} />);
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(<HistoryTab onInspect={onInspect} />);
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(<HistoryTab onInspect={vi.fn()} />);
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(<HistoryTab onInspect={vi.fn()} />);
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(<HistoryTab onInspect={vi.fn()} />);
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(<HistoryTab onInspect={vi.fn()} />);
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');
});
});