mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
1f8ce773ff
* feat(ui): hide paid features from community-tier dashboard Community installs render only the features they can use. Tier-locked sections, lock badges, upsell cards, and "Upgrade" buttons no longer appear anywhere except the License page in Settings, which is the single discoverable upgrade path. Concretely: - PaidGate and AdmiralGate now render null for non-qualifying tiers instead of upsell cards. - SectionGate (settings) hides tier-locked sections entirely. - Settings sidebar and command palette filter out items the operator cannot reach. - Configuration Status widget on the dashboard drops the Automation section for community and hides any locked rows in remaining sections. - Fleet > Status node cards drop locked summary rows. - Stack action menu, sidebar bulk bar, file upload / download, scan comparison, network topology toggle, node label picker all hide for community instead of showing disabled affordances or "Upgrade" literal text. - Removes tierUpsell, TierLockChip, and useDismissalState (no longer referenced). Backend tier guards remain authoritative; this changes UI discovery only. * test(e2e): assert upload control is absent in community tier The community-clean-ui change removes the "Upgrade to unlock upload" pill from the file explorer. Update the matching e2e assertion to verify the upload control is not rendered, instead of waiting for a pill that no longer exists.
351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Input } from '@/components/ui/input';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@/components/ui/sheet';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
GitCompare,
|
|
RefreshCw,
|
|
Search,
|
|
ShieldCheck,
|
|
} from 'lucide-react';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { cn } from '@/lib/utils';
|
|
import { ScanComparisonSheet } from './ScanComparisonSheet';
|
|
import { SeverityChip } from './VulnerabilityScanSheet';
|
|
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
|
import { useLicense } from '@/context/LicenseContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import type { VulnerabilityScan } from '@/types/security';
|
|
|
|
const PAGE_SIZE = 100;
|
|
|
|
interface GroupedScans {
|
|
image_ref: string;
|
|
scans: VulnerabilityScan[];
|
|
}
|
|
|
|
function groupByImage(scans: VulnerabilityScan[]): GroupedScans[] {
|
|
const map = new Map<string, VulnerabilityScan[]>();
|
|
for (const s of scans) {
|
|
const list = map.get(s.image_ref) ?? [];
|
|
list.push(s);
|
|
map.set(s.image_ref, list);
|
|
}
|
|
const groups: GroupedScans[] = [];
|
|
for (const [image_ref, list] of map.entries()) {
|
|
list.sort((a, b) => b.scanned_at - a.scanned_at);
|
|
groups.push({ image_ref, scans: list });
|
|
}
|
|
groups.sort((a, b) => (b.scans[0]?.scanned_at ?? 0) - (a.scans[0]?.scanned_at ?? 0));
|
|
return groups;
|
|
}
|
|
|
|
interface SecurityHistoryViewProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) {
|
|
const { isPaid } = useLicense();
|
|
const { isAdmin } = useAuth();
|
|
const { activeNode } = useNodes();
|
|
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchDraft, setSearchDraft] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
const [selected, setSelected] = useState<number[]>([]);
|
|
const [compareIds, setCompareIds] = useState<[number, number] | null>(null);
|
|
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
|
|
const [page, setPage] = useState(0);
|
|
|
|
const load = useCallback(async (pageToLoad: number, searchTerm: string) => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({
|
|
status: 'completed',
|
|
limit: String(PAGE_SIZE),
|
|
offset: String(pageToLoad * PAGE_SIZE),
|
|
});
|
|
if (searchTerm.trim()) params.set('imageRefLike', searchTerm.trim());
|
|
const res = await apiFetch(`/security/scans?${params.toString()}`);
|
|
if (!res.ok) throw new Error('Failed to load scans');
|
|
const body = await res.json();
|
|
const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : [];
|
|
setScans(items);
|
|
setTotal(typeof body?.total === 'number' ? body.total : items.length);
|
|
} catch (err) {
|
|
toast.error((err as Error)?.message || 'Could not load scan history');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const lastNodeIdRef = useRef<number | null>(activeNode?.id ?? null);
|
|
const [reloadToken, setReloadToken] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const id = activeNode?.id ?? null;
|
|
if (lastNodeIdRef.current === id) return;
|
|
lastNodeIdRef.current = id;
|
|
setSelected([]);
|
|
setPage(0);
|
|
setReloadToken((t) => t + 1);
|
|
}, [activeNode?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
load(page, search);
|
|
// reloadToken bumps when the active node changes even if page/search
|
|
// happen to match the previous values, so the fetch re-runs exactly once.
|
|
}, [open, load, page, search, reloadToken]);
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => {
|
|
setSearch(searchDraft);
|
|
setPage(0);
|
|
}, 300);
|
|
return () => clearTimeout(t);
|
|
}, [searchDraft]);
|
|
|
|
const groups = useMemo(() => groupByImage(scans), [scans]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
const safePage = Math.min(page, totalPages - 1);
|
|
const needsPagination = total > PAGE_SIZE;
|
|
|
|
const toggleSelect = (scanId: number) => {
|
|
setSelected((prev) => {
|
|
if (prev.includes(scanId)) return prev.filter((x) => x !== scanId);
|
|
if (prev.length >= 2) return [prev[1], scanId];
|
|
return [...prev, scanId];
|
|
});
|
|
};
|
|
|
|
const compareSelected = () => {
|
|
if (selected.length !== 2) return;
|
|
const [aId, bId] = selected;
|
|
const a = scans.find((s) => s.id === aId);
|
|
const b = scans.find((s) => s.id === bId);
|
|
if (!a || !b) return;
|
|
const [older, newer] = a.scanned_at <= b.scanned_at ? [a, b] : [b, a];
|
|
setCompareIds([older.id, newer.id]);
|
|
};
|
|
|
|
const compareDisabled = selected.length !== 2;
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
|
|
<SheetContent className="sm:max-w-4xl flex flex-col p-0 gap-0">
|
|
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
|
Security · Node {activeNode?.name ?? '-'}
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<SheetTitle className="font-display italic text-2xl">Scan history</SheetTitle>
|
|
<div className="flex items-center gap-2">
|
|
{isPaid && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-border"
|
|
onClick={compareSelected}
|
|
disabled={compareDisabled}
|
|
title={compareDisabled
|
|
? 'Select exactly two completed scans to compare'
|
|
: 'Compare the two selected scans'}
|
|
>
|
|
<GitCompare className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
|
Compare ({selected.length}/2)
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-border"
|
|
onClick={() => load(safePage, search)}
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw
|
|
className={cn('w-4 h-4 mr-2', loading && 'animate-spin')}
|
|
strokeWidth={1.5}
|
|
/>
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<SheetDescription className="text-sm text-muted-foreground">
|
|
Completed vulnerability scans on this node, grouped by image. Select two to compare.
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="flex-1 flex flex-col px-6 py-4 overflow-hidden">
|
|
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
|
<Input
|
|
placeholder="Search by image..."
|
|
value={searchDraft}
|
|
onChange={(e) => setSearchDraft(e.target.value)}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
{needsPagination && (
|
|
<div className="flex items-center gap-1 ml-auto" aria-live="polite">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
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 font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center"
|
|
aria-label={`Page ${safePage + 1} of ${totalPages}`}
|
|
>
|
|
{safePage + 1} / {totalPages}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
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>
|
|
|
|
{groups.length === 0 && !loading ? (
|
|
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
|
|
<ShieldCheck className="w-8 h-8 text-muted-foreground" strokeWidth={1.5} />
|
|
<div className="text-sm text-muted-foreground">
|
|
{search
|
|
? 'No completed scans match your search.'
|
|
: 'No scans have completed on this node yet.'}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ScrollArea className="flex-1 min-h-0">
|
|
<div className="space-y-5 pr-2">
|
|
{groups.map((group) => (
|
|
<div key={group.image_ref}>
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<span className="font-mono text-sm truncate" title={group.image_ref}>
|
|
{group.image_ref}
|
|
</span>
|
|
<span className="text-xs text-stat-subtitle">
|
|
{group.scans.length} scan{group.scans.length === 1 ? '' : 's'}
|
|
</span>
|
|
</div>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[40px]" />
|
|
<TableHead className="w-[180px]">Scanned</TableHead>
|
|
<TableHead className="w-[120px]">Trigger</TableHead>
|
|
<TableHead className="w-[120px]">Highest</TableHead>
|
|
<TableHead className="w-[90px] text-right">Total</TableHead>
|
|
<TableHead className="w-[90px] text-right">Fixable</TableHead>
|
|
<TableHead />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{group.scans.map((scan) => {
|
|
const isSelected = selected.includes(scan.id);
|
|
return (
|
|
<TableRow
|
|
key={scan.id}
|
|
className={cn(isSelected && 'bg-accent/30')}
|
|
>
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onCheckedChange={() => toggleSelect(scan.id)}
|
|
aria-label={`Select scan ${scan.id}`}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs">
|
|
{new Date(scan.scanned_at).toLocaleString()}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs capitalize">
|
|
{scan.triggered_by}
|
|
</TableCell>
|
|
<TableCell>
|
|
{scan.highest_severity ? (
|
|
<SeverityChip severity={scan.highest_severity} />
|
|
) : (
|
|
<span className="text-xs text-success font-mono">none</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono text-xs tabular-nums">
|
|
{scan.total_vulnerabilities}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono text-xs tabular-nums text-success">
|
|
{scan.fixable_count}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
onClick={() => setInspectScanId(scan.id)}
|
|
>
|
|
Open
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
)}
|
|
</div>
|
|
|
|
<ScanComparisonSheet
|
|
baselineScanId={compareIds?.[0] ?? null}
|
|
currentScanId={compareIds?.[1] ?? null}
|
|
onClose={() => setCompareIds(null)}
|
|
/>
|
|
|
|
<VulnerabilityScanSheet
|
|
scanId={inspectScanId}
|
|
onClose={() => setInspectScanId(null)}
|
|
canGenerateSbom={isPaid}
|
|
canCompare={false}
|
|
canManageSuppressions={isPaid && isAdmin}
|
|
/>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|