mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
0066887cee
* feat(security): reflow the node Security page for mobile Below the md breakpoint the Security page now reads as a phone surface instead of a squeezed desktop, with no change to the desktop layout. - Masthead stat cluster moves into a full-width 3-cell strip (critical / high / last scan) below the tab strip, since the masthead hides its inline cluster on a phone. - The eight-section tab strip becomes a horizontally scrollable mono row with an edge mask-fade and a cyan underline on the active tab; every section stays reachable by scroll. - The six totals render as a 3x2 hairline-divided grid instead of the 640px-wide rail that forced a horizontal scroll. - The Images tab becomes a filterable, scrollable list (severity dot, truncated ref, freshness, critical/high count tags) with a chip row, in place of the desktop table. - A freshness footer band states scan recency and scanner version. All mobile treatment is gated by useIsMobile() or max-md: utilities, so the desktop view is byte-identical. Charts are reused full-width. * feat(security): make the mobile Security page a bespoke masthead-led screen On a phone the Security page now drops the global top bar and leads with its masthead (the notifications + more-menu cluster moves into the masthead's right slot), matching Home and Fleet so the mobile shell is continuous across pages. The view is reclassified bespoke and rendered through the masthead-led path; the desktop layout is unchanged. The mobile "more" menu now lists every destination instead of omitting the bottom-tab views, so the same menu opens the same set on every screen rather than changing contents from page to page.
301 lines
14 KiB
TypeScript
301 lines
14 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { Boxes, AlertTriangle, Search, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ShieldCheck, Loader2 } from 'lucide-react';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Combobox } from '@/components/ui/combobox';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
|
import { SeverityBadge } from '@/components/ui/SeverityBadge';
|
|
import { getSeverityKey, type SeverityKey, type ImageFilterValue } from '@/lib/severityStyles';
|
|
import { formatTimeAgo } from '@/lib/relativeTime';
|
|
import { cn } from '@/lib/utils';
|
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
|
import { ImageScanRow, ImageFilterChips, type ImageFilterChip } from './SecurityMobile';
|
|
import type { ScanSummary, ScanDetailTab, ScannerKind } from '@/types/security';
|
|
|
|
// Mobile severity chips. 'FIXABLE' is a phone-only pseudo-filter (the desktop
|
|
// Combobox never emits it), so the shared filter logic treats it specially.
|
|
const MOBILE_FILTER_CHIPS: ImageFilterChip[] = [
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'CRITICAL', label: 'Critical' },
|
|
{ value: 'HIGH', label: 'High' },
|
|
{ value: 'FIXABLE', label: 'Fixable' },
|
|
{ value: 'CLEAN', label: 'Clean' },
|
|
];
|
|
|
|
const PAGE_SIZE = 12;
|
|
|
|
type SortKey = 'image_ref' | 'scanned_at' | 'severity' | 'findings';
|
|
|
|
const SEVERITY_RANK: Record<SeverityKey, number> = {
|
|
CRITICAL: 6, HIGH: 5, MEDIUM: 4, LOW: 3, UNKNOWN: 2, FINDINGS: 1, CLEAN: 0,
|
|
};
|
|
|
|
/** Sortable column header. Module-scoped so it is a stable component. */
|
|
function SortHead({ label, k, sortKey, sortDir, onSort, className }: {
|
|
label: string;
|
|
k: SortKey;
|
|
sortKey: SortKey;
|
|
sortDir: 'asc' | 'desc';
|
|
onSort: (k: SortKey) => void;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<TableHead className={cn('text-[10px] uppercase tracking-[0.18em] cursor-pointer select-none', className)}>
|
|
<button type="button" onClick={() => onSort(k)} className="inline-flex items-center gap-1 hover:text-stat-value">
|
|
{label}
|
|
{sortKey === k && (sortDir === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />)}
|
|
</button>
|
|
</TableHead>
|
|
);
|
|
}
|
|
|
|
const FILTER_OPTIONS: Array<{ value: 'all' | SeverityKey; label: string }> = [
|
|
{ value: 'all', label: 'All severities' },
|
|
{ value: 'CRITICAL', label: 'Critical' },
|
|
{ value: 'HIGH', label: 'High' },
|
|
{ value: 'MEDIUM', label: 'Medium' },
|
|
{ value: 'LOW', label: 'Low' },
|
|
{ value: 'FINDINGS', label: 'Secrets / misconfigs' },
|
|
{ value: 'CLEAN', label: 'Clean' },
|
|
];
|
|
|
|
const findingsCount = (s: ScanSummary) => s.total + (s.secret_count ?? 0) + (s.misconfig_count ?? 0);
|
|
|
|
interface ImagesTabProps {
|
|
summaries: Record<string, ScanSummary>;
|
|
loading: boolean;
|
|
/** True when the summaries fetch failed; render an error state, never a false "clean". */
|
|
error?: boolean;
|
|
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
|
|
/** Admin on a node with a ready scanner; gates the scan Actions column. */
|
|
canScan: boolean;
|
|
/** image_ref of the scan currently in flight, for the per-row spinner. */
|
|
scanningRef: string | null;
|
|
onScan: (imageRef: string, scanners: ScannerKind[]) => void;
|
|
}
|
|
|
|
/** Latest-scan index for real images (stack/config scans live in Compose risks). */
|
|
export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan }: ImagesTabProps) {
|
|
const isMobile = useIsMobile();
|
|
const [search, setSearch] = useState('');
|
|
const [severity, setSeverity] = useState<ImageFilterValue>('all');
|
|
const [sortKey, setSortKey] = useState<SortKey>('scanned_at');
|
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
|
const [page, setPage] = useState(0);
|
|
|
|
const filtered = useMemo(() => {
|
|
const term = search.trim().toLowerCase();
|
|
return Object.values(summaries)
|
|
.filter((s) => !s.image_ref.startsWith('stack:'))
|
|
.filter((s) => (term ? s.image_ref.toLowerCase().includes(term) : true))
|
|
.filter((s) => {
|
|
if (severity === 'all') return true;
|
|
if (severity === 'FIXABLE') return s.fixable > 0;
|
|
return getSeverityKey(s) === severity;
|
|
});
|
|
}, [summaries, search, severity]);
|
|
|
|
const sorted = useMemo(() => {
|
|
const dir = sortDir === 'asc' ? 1 : -1;
|
|
return [...filtered].sort((a, b) => {
|
|
switch (sortKey) {
|
|
case 'image_ref': return a.image_ref.localeCompare(b.image_ref) * dir;
|
|
case 'severity': return (SEVERITY_RANK[getSeverityKey(a)] - SEVERITY_RANK[getSeverityKey(b)]) * dir;
|
|
case 'findings': return (findingsCount(a) - findingsCount(b)) * dir;
|
|
default: return (a.scanned_at - b.scanned_at) * dir;
|
|
}
|
|
});
|
|
}, [filtered, sortKey, sortDir]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
|
|
const safePage = Math.min(page, totalPages - 1);
|
|
const pageItems = sorted.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
|
|
|
const toggleSort = (key: SortKey) => {
|
|
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
else { setSortKey(key); setSortDir(key === 'image_ref' ? 'asc' : 'desc'); }
|
|
setPage(0);
|
|
};
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
<AlertTriangle className="w-12 h-12 text-warning/60 mb-4" strokeWidth={1.5} />
|
|
<h3 className="text-lg font-medium mb-1">Couldn't load scan results</h3>
|
|
<p className="text-sm text-muted-foreground">Scan results failed to load for this node. Try again shortly.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-2" aria-busy="true">
|
|
<Skeleton className="h-12 w-full rounded-lg" />
|
|
<Skeleton className="h-12 w-full rounded-lg" />
|
|
<Skeleton className="h-12 w-full rounded-lg" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const noImagesAtAll = Object.values(summaries).every((s) => s.image_ref.startsWith('stack:'));
|
|
if (noImagesAtAll) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
<Boxes className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
|
|
<h3 className="text-lg font-medium mb-1">No scanned images</h3>
|
|
<p className="text-sm text-muted-foreground">Scan an image from Resources to see its findings here.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{isMobile ? (
|
|
<>
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
|
<Input
|
|
placeholder="Filter images…"
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
<ImageFilterChips
|
|
chips={MOBILE_FILTER_CHIPS}
|
|
active={severity}
|
|
onSelect={(v) => { setSeverity(v); setPage(0); }}
|
|
/>
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
|
<div className="px-4">
|
|
{pageItems.map((s) => (
|
|
<ImageScanRow key={s.image_ref} summary={s} onInspect={onInspect} />
|
|
))}
|
|
</div>
|
|
{pageItems.length === 0 && (
|
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
|
No images match your search or filter.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
|
<Input
|
|
placeholder="Search images..."
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
<Combobox
|
|
options={FILTER_OPTIONS}
|
|
value={severity}
|
|
onValueChange={(v) => { setSeverity((v || 'all') as ImageFilterValue); setPage(0); }}
|
|
className="w-[180px]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
|
<ScrollArea className="max-h-[62vh] bg-background">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="hover:bg-transparent">
|
|
<SortHead label="Image" k="image_ref" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
|
<SortHead label="Findings" k="findings" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} className="max-md:hidden" />
|
|
<SortHead label="Last scan" k="scanned_at" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} className="max-md:hidden" />
|
|
<SortHead label="Severity" k="severity" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
|
{canScan && <TableHead className="text-right text-[10px] uppercase tracking-[0.18em]">Actions</TableHead>}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{pageItems.map((s) => (
|
|
<TableRow key={s.image_ref} className="hover:bg-muted/30 transition-colors">
|
|
<TableCell className="font-mono text-xs truncate max-w-[280px]">
|
|
<button type="button" className="hover:text-brand truncate block w-full text-left" onClick={() => onInspect(s.scan_id, 'vulns')}>
|
|
{s.image_ref}
|
|
</button>
|
|
</TableCell>
|
|
<TableCell className="max-md:hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => onInspect(s.scan_id, 'vulns')}
|
|
className="font-mono tabular-nums text-xs text-stat-subtitle text-left hover:text-stat-value transition-colors"
|
|
>
|
|
{s.critical > 0 && <span className="text-destructive mr-2">{s.critical}C</span>}
|
|
{s.high > 0 && <span className="text-warning mr-2">{s.high}H</span>}
|
|
{s.secret_count > 0 && <span className="text-warning mr-2">{s.secret_count} secret</span>}
|
|
{s.misconfig_count > 0 && <span className="text-warning mr-2">{s.misconfig_count} misconfig</span>}
|
|
{s.fixable > 0 && <span className="text-stat-subtitle">{s.fixable} fixable</span>}
|
|
{findingsCount(s) === 0 && <span className="text-success">clean</span>}
|
|
</button>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs text-stat-subtitle whitespace-nowrap max-md:hidden">
|
|
{formatTimeAgo(s.scanned_at)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<SeverityBadge summary={s} tooltip={false} onClick={() => onInspect(s.scan_id, 'vulns')} />
|
|
</TableCell>
|
|
{canScan && (
|
|
<TableCell className="text-right">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
|
disabled={scanningRef === s.image_ref}
|
|
title="Scan image"
|
|
aria-label={`Scan ${s.image_ref}`}
|
|
>
|
|
{scanningRef === s.image_ref
|
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
|
: <ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => onScan(s.image_ref, ['vuln'])}>
|
|
Scan (vulnerabilities)
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onScan(s.image_ref, ['vuln', 'secret'])}>
|
|
Full scan (vulnerabilities + secrets)
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
{pageItems.length === 0 && (
|
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
|
No images match your search or filter.
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{sorted.length > PAGE_SIZE && (
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setPage(Math.max(0, safePage - 1))} disabled={safePage === 0} aria-label="Previous page">
|
|
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
|
</Button>
|
|
<span className="text-xs text-stat-subtitle tabular-nums px-1">{safePage + 1} / {totalPages}</span>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))} disabled={safePage >= totalPages - 1} aria-label="Next page">
|
|
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|