mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 14:18:02 +00:00
feat(frontend): SystemSheet §9.11 — security and scheduled sheets (PR 2/3) (#961)
* feat(frontend): add SystemSheet primitive and migrate mesh sheets to §9.11 chrome
DESIGN.md §9.11 codifies one canonical right-side detail-sheet shell (cyan
rail, mono crumb, italic serif name, mono meta, ESC chip + close glyph,
fixed three-slot toolbar, cyan-underline tabs, ScrollArea body, footer
freshness band). Today the 16 sheet consumers each render their own
header chrome with stock shadcn SheetHeader/SheetTitle.
Introduce <SystemSheet> + <SheetSection> in
frontend/src/components/ui/system-sheet.tsx, composing the existing
<Sheet>/<SheetContent> primitive. Add a backward-compatible showClose
prop to SheetContent so SystemSheet can render its own ESC chip + close
glyph instead of the stock cyan square close.
Migrate the four mesh sheets as the first batch:
* MeshActivitySheet: crumb Fleet › Mesh › Activity, footer freshness from
most-recent event timestamp.
* MeshOptInSheet: crumb Fleet › Mesh › {nodeName}, meta of opted-in
count, drops the redundant bottom Close button (ESC chip dismisses).
* MeshDiagnosticsSheet: removes the icon-prefixed title (forbidden by
§9.11), lifts Refresh/Restart buttons from the body into the toolbar
band, three SheetSection blocks for sidecar status, streams, cache.
* MeshRouteDetailSheet: adds Overview/Events/Raw tabs, lifts Test probe
into the toolbar primary slot, footer surfaces last probe latency.
* feat(frontend): migrate security and scheduled sheets to §9.11 chrome
PR 2 of the System Sheet (§9.11) rollout, stacked on the SystemSheet
primitive PR. Migrates six more sheet consumers and merges the Stack
alert + auto-heal sheets into a single tabbed sheet per audit §17.
Sheets migrated:
* NodeUpdatesSheet: crumb Fleet > Updates, Recheck primary, Update-all
secondary when applicable. Stat tiles and node rows lose their
card-in-sheet wrapping (forbidden by §9.11) for flat dividers.
* StackAlertSheet (now the merged stack monitor): tabs Alerts /
Auto-heal, crumb Stack > {name} > Monitor. New initialTab prop lets
callers open directly to either tab. Auto-heal tab is hidden entirely
for Community-tier users (matches the existing tier-gating on the
context menu trigger and keyboard shortcut).
* StackAutoHealSheet.tsx: deleted. Its body became the Auto-heal tab
inside the merged sheet.
* VulnerabilityScanSheet: removed the icon-prefixed title (forbidden by
§9.11). Re-scan, Compare, CSV, SARIF lifted from body cards into the
toolbar band. Tabs Vulnerabilities | Secrets | Misconfigs (counts on
the tab labels). SBOM dropdown stays in the body summary section
pending a primitive enhancement for dropdown-attached toolbar actions.
* ScanComparisonSheet: crumb Security > Scans > Compare, name Diff,
meta with the +added/-removed delta.
* SecurityHistoryView: the inner sheet only. Crumb Security > Scan
history. Compare (paid + 2 selected) and Refresh in toolbar.
* ScheduledOperationsView run-history sheet (lines ~847-935 only):
crumb Schedules > {taskName} > Runs, Download CSV in toolbar, footer
surfaces next-run timestamp.
Hook refactor:
* useOverlayState replaces three separate state vars (alertSheetOpen,
alertSheetStack, autoHealStackName) with one stackMonitor object
carrying { stackName, tab }. New helpers openAlertSheet(stackName),
openAutoHeal(stackName), closeStackMonitor(). Tests rewritten and
pass (11/11).
* useSidebarContextMenu and ShellOverlays updated for the new API. The
three other call sites (useStackMenuItems, useStackKeyboardShortcuts)
already use openAlertSheet/openAutoHeal and need no change.
This commit is contained in:
@@ -5,7 +5,6 @@ import { PolicyBlockDialog } from '../stack/PolicyBlockDialog';
|
|||||||
import { DeleteStackDialog } from './DeleteStackDialog';
|
import { DeleteStackDialog } from './DeleteStackDialog';
|
||||||
import { UnsavedChangesDialog } from './UnsavedChangesDialog';
|
import { UnsavedChangesDialog } from './UnsavedChangesDialog';
|
||||||
import { StackAlertSheet } from '../StackAlertSheet';
|
import { StackAlertSheet } from '../StackAlertSheet';
|
||||||
import { StackAutoHealSheet } from '@/components/StackAutoHealSheet';
|
|
||||||
import { GitSourcePanel } from '../stack/GitSourcePanel';
|
import { GitSourcePanel } from '../stack/GitSourcePanel';
|
||||||
import { LogViewer } from '../LogViewer';
|
import { LogViewer } from '../LogViewer';
|
||||||
import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet';
|
import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet';
|
||||||
@@ -54,9 +53,8 @@ export function ShellOverlays({
|
|||||||
pendingUnsavedLoad,
|
pendingUnsavedLoad,
|
||||||
bashModalOpen, selectedContainer,
|
bashModalOpen, selectedContainer,
|
||||||
logViewerOpen, logContainer,
|
logViewerOpen, logContainer,
|
||||||
alertSheetOpen, closeAlertSheet, alertSheetStack,
|
stackMonitor, closeStackMonitor,
|
||||||
policyBlock, setPolicyBlock, policyBypassing,
|
policyBlock, setPolicyBlock, policyBypassing,
|
||||||
autoHealStackName, setAutoHealStackName,
|
|
||||||
stackMisconfigScanId, setStackMisconfigScanId,
|
stackMisconfigScanId, setStackMisconfigScanId,
|
||||||
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
|
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
|
||||||
} = overlayState;
|
} = overlayState;
|
||||||
@@ -96,11 +94,12 @@ export function ShellOverlays({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stack Alert Sheet */}
|
{/* Stack monitor (alerts + auto-heal as tabs) */}
|
||||||
<StackAlertSheet
|
<StackAlertSheet
|
||||||
isOpen={alertSheetOpen}
|
open={stackMonitor !== null}
|
||||||
onClose={closeAlertSheet}
|
onOpenChange={(open) => { if (!open) closeStackMonitor(); }}
|
||||||
stackName={alertSheetStack}
|
stackName={stackMonitor?.stackName ?? ''}
|
||||||
|
initialTab={stackMonitor?.tab ?? 'alerts'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pre-deploy policy block */}
|
{/* Pre-deploy policy block */}
|
||||||
@@ -114,13 +113,6 @@ export function ShellOverlays({
|
|||||||
onBypass={stackActions.bypassPolicyAndDeploy}
|
onBypass={stackActions.bypassPolicyAndDeploy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stack Auto-Heal Sheet */}
|
|
||||||
<StackAutoHealSheet
|
|
||||||
stackName={autoHealStackName ?? ''}
|
|
||||||
open={autoHealStackName !== null}
|
|
||||||
onOpenChange={(open) => { if (!open) setAutoHealStackName(null); }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Git Source Panel */}
|
{/* Git Source Panel */}
|
||||||
{stackName && (
|
{stackName && (
|
||||||
<GitSourcePanel
|
<GitSourcePanel
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ describe('useOverlayState', () => {
|
|||||||
expect(result.current.selectedContainer).toBeNull();
|
expect(result.current.selectedContainer).toBeNull();
|
||||||
expect(result.current.logViewerOpen).toBe(false);
|
expect(result.current.logViewerOpen).toBe(false);
|
||||||
expect(result.current.logContainer).toBeNull();
|
expect(result.current.logContainer).toBeNull();
|
||||||
expect(result.current.alertSheetOpen).toBe(false);
|
expect(result.current.stackMonitor).toBeNull();
|
||||||
expect(result.current.alertSheetStack).toBe('');
|
|
||||||
expect(result.current.autoHealStackName).toBeNull();
|
|
||||||
expect(result.current.policyBlock).toBeNull();
|
expect(result.current.policyBlock).toBeNull();
|
||||||
expect(result.current.policyBypassing).toBe(false);
|
expect(result.current.policyBypassing).toBe(false);
|
||||||
expect(result.current.stackMisconfigScanId).toBeNull();
|
expect(result.current.stackMisconfigScanId).toBeNull();
|
||||||
@@ -69,32 +67,29 @@ describe('useOverlayState', () => {
|
|||||||
expect(result.current.logContainer).toBeNull();
|
expect(result.current.logContainer).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('openAlertSheet sets sheet state', () => {
|
it('openAlertSheet opens stack monitor on the alerts tab', () => {
|
||||||
const { result } = renderHook(() => useOverlayState());
|
const { result } = renderHook(() => useOverlayState());
|
||||||
act(() => result.current.openAlertSheet('web-stack'));
|
act(() => result.current.openAlertSheet('web-stack'));
|
||||||
expect(result.current.alertSheetOpen).toBe(true);
|
expect(result.current.stackMonitor).toEqual({ stackName: 'web-stack', tab: 'alerts' });
|
||||||
expect(result.current.alertSheetStack).toBe('web-stack');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('openAlertSheet with autoHeal sets autoHealStackName', () => {
|
it('openAutoHeal opens stack monitor on the auto-heal tab', () => {
|
||||||
const { result } = renderHook(() => useOverlayState());
|
const { result } = renderHook(() => useOverlayState());
|
||||||
act(() => result.current.openAlertSheet('web-stack', 'web-stack'));
|
act(() => result.current.openAutoHeal('web-stack'));
|
||||||
expect(result.current.alertSheetOpen).toBe(true);
|
expect(result.current.stackMonitor).toEqual({ stackName: 'web-stack', tab: 'auto-heal' });
|
||||||
expect(result.current.alertSheetStack).toBe('web-stack');
|
|
||||||
expect(result.current.autoHealStackName).toBe('web-stack');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('openAlertSheet without autoHeal leaves autoHealStackName null', () => {
|
it('openAutoHeal after openAlertSheet switches to the auto-heal tab', () => {
|
||||||
const { result } = renderHook(() => useOverlayState());
|
const { result } = renderHook(() => useOverlayState());
|
||||||
act(() => result.current.openAlertSheet('web-stack'));
|
act(() => result.current.openAlertSheet('web-stack'));
|
||||||
expect(result.current.alertSheetOpen).toBe(true);
|
act(() => result.current.openAutoHeal('web-stack'));
|
||||||
expect(result.current.autoHealStackName).toBeNull();
|
expect(result.current.stackMonitor).toEqual({ stackName: 'web-stack', tab: 'auto-heal' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closeAlertSheet sets alertSheetOpen to false', () => {
|
it('closeStackMonitor clears the stack monitor state', () => {
|
||||||
const { result } = renderHook(() => useOverlayState());
|
const { result } = renderHook(() => useOverlayState());
|
||||||
act(() => result.current.openAlertSheet('web-stack'));
|
act(() => result.current.openAlertSheet('web-stack'));
|
||||||
act(() => result.current.closeAlertSheet());
|
act(() => result.current.closeStackMonitor());
|
||||||
expect(result.current.alertSheetOpen).toBe(false);
|
expect(result.current.stackMonitor).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,15 +66,14 @@ export function useOverlayState() {
|
|||||||
return () => window.removeEventListener(SENCHO_OPEN_LOGS_EVENT, handler);
|
return () => window.removeEventListener(SENCHO_OPEN_LOGS_EVENT, handler);
|
||||||
}, [openLogViewer]); // openLogViewer is stable (useCallback with empty deps)
|
}, [openLogViewer]); // openLogViewer is stable (useCallback with empty deps)
|
||||||
|
|
||||||
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
|
const [stackMonitor, setStackMonitor] = useState<{ stackName: string; tab: 'alerts' | 'auto-heal' } | null>(null);
|
||||||
const [alertSheetStack, setAlertSheetStack] = useState('');
|
const openAlertSheet = useCallback((stackName: string) => {
|
||||||
const [autoHealStackName, setAutoHealStackName] = useState<string | null>(null);
|
setStackMonitor({ stackName, tab: 'alerts' });
|
||||||
const openAlertSheet = useCallback((stackName: string, autoHeal?: string | null) => {
|
|
||||||
setAlertSheetStack(stackName);
|
|
||||||
setAutoHealStackName(autoHeal ?? null);
|
|
||||||
setAlertSheetOpen(true);
|
|
||||||
}, []);
|
}, []);
|
||||||
const closeAlertSheet = useCallback(() => setAlertSheetOpen(false), []);
|
const openAutoHeal = useCallback((stackName: string) => {
|
||||||
|
setStackMonitor({ stackName, tab: 'auto-heal' });
|
||||||
|
}, []);
|
||||||
|
const closeStackMonitor = useCallback(() => setStackMonitor(null), []);
|
||||||
|
|
||||||
const [policyBlock, setPolicyBlock] = useState<PolicyBlock | null>(null);
|
const [policyBlock, setPolicyBlock] = useState<PolicyBlock | null>(null);
|
||||||
const [policyBypassing, setPolicyBypassing] = useState(false);
|
const [policyBypassing, setPolicyBypassing] = useState(false);
|
||||||
@@ -91,8 +90,7 @@ export function useOverlayState() {
|
|||||||
pendingUnsavedNode, setPendingUnsavedNode,
|
pendingUnsavedNode, setPendingUnsavedNode,
|
||||||
bashModalOpen, selectedContainer, openBashModal, closeBashModal,
|
bashModalOpen, selectedContainer, openBashModal, closeBashModal,
|
||||||
logViewerOpen, logContainer, openLogViewer, closeLogViewer,
|
logViewerOpen, logContainer, openLogViewer, closeLogViewer,
|
||||||
alertSheetOpen, alertSheetStack, autoHealStackName, openAlertSheet, closeAlertSheet,
|
stackMonitor, openAlertSheet, openAutoHeal, closeStackMonitor,
|
||||||
setAutoHealStackName,
|
|
||||||
policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing,
|
policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing,
|
||||||
stackMisconfigScanId, setStackMisconfigScanId,
|
stackMisconfigScanId, setStackMisconfigScanId,
|
||||||
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
|
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function useSidebarContextMenu({
|
|||||||
menuVisibility: stackActions.getStackMenuVisibility(file),
|
menuVisibility: stackActions.getStackMenuVisibility(file),
|
||||||
autoUpdateEnabled: stackListState.autoUpdateSettings[sName] ?? true,
|
autoUpdateEnabled: stackListState.autoUpdateSettings[sName] ?? true,
|
||||||
openAlertSheet: () => overlayState.openAlertSheet(file),
|
openAlertSheet: () => overlayState.openAlertSheet(file),
|
||||||
openAutoHeal: () => overlayState.setAutoHealStackName(file),
|
openAutoHeal: () => overlayState.openAutoHeal(file),
|
||||||
checkUpdates: () => stackActions.checkUpdatesForStack(),
|
checkUpdates: () => stackActions.checkUpdatesForStack(),
|
||||||
openStackApp: () => stackActions.openStackApp(file),
|
openStackApp: () => stackActions.openStackApp(file),
|
||||||
deploy: () => stackActions.executeStackActionByFile(file, 'deploy', 'deploy'),
|
deploy: () => stackActions.executeStackActionByFile(file, 'deploy', 'deploy'),
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import {
|
|||||||
Search, Loader2, Check, CircleCheck, CircleAlert, AlertTriangle,
|
Search, Loader2, Check, CircleCheck, CircleAlert, AlertTriangle,
|
||||||
Download, RefreshCw, Monitor, Globe,
|
Download, RefreshCw, Monitor, Globe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -64,174 +63,160 @@ export function NodeUpdatesSheet({
|
|||||||
const localEntry = updateStatuses.find(s => s.type === 'local') ?? updateStatuses[0];
|
const localEntry = updateStatuses.find(s => s.type === 'local') ?? updateStatuses[0];
|
||||||
const gatewayLabel = formatVersion(localEntry?.latestVersion);
|
const gatewayLabel = formatVersion(localEntry?.latestVersion);
|
||||||
|
|
||||||
|
const meta = updateStatuses.length === 0
|
||||||
|
? 'No nodes'
|
||||||
|
: `${updateStatuses.length} nodes · ${available} update${available === 1 ? '' : 's'} available`;
|
||||||
|
|
||||||
|
const footerContext = updateStatuses.length === 0
|
||||||
|
? undefined
|
||||||
|
: (gatewayLabel ? `Latest version ${gatewayLabel}` : `${available} update${available === 1 ? '' : 's'} available`);
|
||||||
|
|
||||||
|
const secondaryActions = canBulkUpdate && updatableRemoteCount > 0
|
||||||
|
? [{
|
||||||
|
label: `Update all (${updatableRemoteCount})`,
|
||||||
|
icon: Download,
|
||||||
|
onClick: () => { void triggerUpdateAll(); },
|
||||||
|
}]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
<SystemSheet
|
||||||
<SheetContent side="right" className="w-[700px] sm:max-w-[700px] flex flex-col p-0">
|
open={open}
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 shrink-0 border-b">
|
onOpenChange={handleOpenChange}
|
||||||
<SheetTitle>Node Updates</SheetTitle>
|
crumb={['Fleet', 'Updates']}
|
||||||
<SheetDescription className="sr-only">Check and apply updates across your fleet nodes.</SheetDescription>
|
name="Node updates"
|
||||||
</SheetHeader>
|
meta={meta}
|
||||||
|
primaryAction={{
|
||||||
{checkingUpdates ? (
|
label: 'Recheck',
|
||||||
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm gap-2">
|
icon: recheckingUpdates ? Loader2 : RefreshCw,
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
onClick: () => { void handleRecheck(); },
|
||||||
Checking for updates...
|
disabled: recheckingUpdates || checkingUpdates,
|
||||||
</div>
|
}}
|
||||||
) : updateStatuses.length === 0 ? (
|
secondaryActions={secondaryActions}
|
||||||
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm">
|
footerContext={footerContext}
|
||||||
No nodes found.
|
size="lg"
|
||||||
</div>
|
>
|
||||||
) : (
|
{checkingUpdates ? (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm gap-2">
|
||||||
{/* Summary stats */}
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
<div className="px-6 pt-4 pb-3 shrink-0">
|
Checking for updates...
|
||||||
<div className="grid grid-cols-4 gap-2">
|
</div>
|
||||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
) : updateStatuses.length === 0 ? (
|
||||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{upToDate}</div>
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
No nodes found.
|
||||||
<CircleCheck className="w-3 h-3 text-success" strokeWidth={1.5} /> Up to date
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<SheetSection title="Summary">
|
||||||
|
<div className="grid grid-cols-4 gap-x-4 divide-x divide-card-border/40 text-center">
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{upToDate}</div>
|
||||||
|
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||||
|
<CircleCheck className="w-3 h-3 text-success" strokeWidth={1.5} /> Up to date
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
</div>
|
||||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{available}</div>
|
<div className="px-2">
|
||||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{available}</div>
|
||||||
<CircleAlert className="w-3 h-3 text-warning" strokeWidth={1.5} /> Available
|
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||||
</div>
|
<CircleAlert className="w-3 h-3 text-warning" strokeWidth={1.5} /> Available
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
</div>
|
||||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{updating}</div>
|
<div className="px-2">
|
||||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{updating}</div>
|
||||||
<Loader2 className="w-3 h-3 text-brand" strokeWidth={1.5} /> Updating
|
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||||
</div>
|
<Loader2 className="w-3 h-3 text-brand" strokeWidth={1.5} /> Updating
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 text-center">
|
</div>
|
||||||
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{failed}</div>
|
<div className="px-2">
|
||||||
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{failed}</div>
|
||||||
<AlertTriangle className="w-3 h-3 text-destructive/70" strokeWidth={1.5} /> Failed
|
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
|
||||||
</div>
|
<AlertTriangle className="w-3 h-3 text-destructive/70" strokeWidth={1.5} /> Failed
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</SheetSection>
|
||||||
|
|
||||||
{/* Search + gateway version */}
|
<SheetSection title={`Nodes · ${updateStatuses.length}`}>
|
||||||
<div className="flex items-center gap-2 px-6 pb-3 shrink-0">
|
<div className="relative mb-3">
|
||||||
<div className="relative flex-1">
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
<Input
|
||||||
<Input
|
placeholder="Filter nodes..."
|
||||||
placeholder="Filter nodes..."
|
value={search}
|
||||||
value={search}
|
onChange={e => setSearch(e.target.value)}
|
||||||
onChange={e => setSearch(e.target.value)}
|
className="h-8 pl-8 text-xs"
|
||||||
className="h-8 pl-8 text-xs"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
{gatewayLabel && (
|
<div className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 px-3 pb-1 text-[10px] leading-3 font-mono text-stat-subtitle uppercase tracking-[0.18em]">
|
||||||
<div className="text-[11px] text-muted-foreground shrink-0">
|
<span>Node</span>
|
||||||
Latest: <span className="font-mono tabular-nums text-foreground">{gatewayLabel}</span>
|
<span>Type</span>
|
||||||
|
<span>Current</span>
|
||||||
|
<span>Latest</span>
|
||||||
|
<span className="text-right">Status</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-card-border/40">
|
||||||
|
{filtered.map(s => (
|
||||||
|
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 items-center px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<div className={`flex items-center justify-center w-6 h-6 rounded-md shrink-0 ${s.updateAvailable && !s.updateStatus ? 'bg-warning/10' : 'bg-muted'}`}>
|
||||||
|
{s.type === 'local'
|
||||||
|
? <Monitor className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
|
||||||
|
: <Globe className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium truncate">{s.name}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 w-fit">
|
||||||
|
{s.type}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs font-mono tabular-nums text-muted-foreground">
|
||||||
|
{formatVersion(s.version) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-mono tabular-nums">
|
||||||
|
{formatVersion(s.latestVersion) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
|
||||||
|
</span>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{s.updateStatus && (
|
||||||
|
<UpdateStatusBadge
|
||||||
|
status={s.updateStatus}
|
||||||
|
error={s.error}
|
||||||
|
onRetry={() => retryNodeUpdate(s.nodeId)}
|
||||||
|
onDismiss={() => dismissNodeUpdate(s.nodeId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!s.updateStatus && !s.updateAvailable && (
|
||||||
|
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">
|
||||||
|
<Check className="w-2.5 h-2.5 mr-0.5" /> Up to date
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{s.updateAvailable && !s.updateStatus && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[11px] px-2.5"
|
||||||
|
onClick={() => triggerNodeUpdate(s.nodeId)}
|
||||||
|
disabled={updatingNodeId === s.nodeId}
|
||||||
|
>
|
||||||
|
{updatingNodeId === s.nodeId ? (
|
||||||
|
<><Loader2 className="w-3 h-3 mr-1 animate-spin" />Updating</>
|
||||||
|
) : (
|
||||||
|
<><Download className="w-3 h-3 mr-1" strokeWidth={1.5} />Update</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||||
|
No nodes match “{search}”
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SheetSection>
|
||||||
{/* Table column header */}
|
</>
|
||||||
<div className="px-6 pb-2 shrink-0">
|
)}
|
||||||
<div className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 px-3 text-[10px] leading-3 font-mono text-stat-subtitle uppercase tracking-[0.18em]">
|
</SystemSheet>
|
||||||
<span>Node</span>
|
|
||||||
<span>Type</span>
|
|
||||||
<span>Current</span>
|
|
||||||
<span>Latest</span>
|
|
||||||
<span className="text-right">Status</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Node list — fills remaining height, no cap */}
|
|
||||||
<ScrollArea className="flex-1 min-h-0 px-6">
|
|
||||||
<div className="space-y-1 pb-2">
|
|
||||||
{filtered.map(s => (
|
|
||||||
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 items-center rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
|
||||||
<div className={`flex items-center justify-center w-6 h-6 rounded-md shrink-0 ${s.updateAvailable && !s.updateStatus ? 'bg-warning/10' : 'bg-muted'}`}>
|
|
||||||
{s.type === 'local'
|
|
||||||
? <Monitor className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
|
|
||||||
: <Globe className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium truncate">{s.name}</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 w-fit">
|
|
||||||
{s.type}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs font-mono tabular-nums text-muted-foreground">
|
|
||||||
{formatVersion(s.version) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-mono tabular-nums">
|
|
||||||
{formatVersion(s.latestVersion) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
|
|
||||||
</span>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
{s.updateStatus && (
|
|
||||||
<UpdateStatusBadge
|
|
||||||
status={s.updateStatus}
|
|
||||||
error={s.error}
|
|
||||||
onRetry={() => retryNodeUpdate(s.nodeId)}
|
|
||||||
onDismiss={() => dismissNodeUpdate(s.nodeId)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!s.updateStatus && !s.updateAvailable && (
|
|
||||||
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">
|
|
||||||
<Check className="w-2.5 h-2.5 mr-0.5" /> Up to date
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{s.updateAvailable && !s.updateStatus && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 text-[11px] px-2.5"
|
|
||||||
onClick={() => triggerNodeUpdate(s.nodeId)}
|
|
||||||
disabled={updatingNodeId === s.nodeId}
|
|
||||||
>
|
|
||||||
{updatingNodeId === s.nodeId ? (
|
|
||||||
<><Loader2 className="w-3 h-3 mr-1 animate-spin" />Updating</>
|
|
||||||
) : (
|
|
||||||
<><Download className="w-3 h-3 mr-1" strokeWidth={1.5} />Update</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
|
||||||
No nodes match “{search}”
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border/50 shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs text-muted-foreground"
|
|
||||||
disabled={recheckingUpdates}
|
|
||||||
onClick={handleRecheck}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-3 h-3 mr-1.5 ${recheckingUpdates ? 'animate-spin' : ''}`} strokeWidth={1.5} />
|
|
||||||
Recheck
|
|
||||||
</Button>
|
|
||||||
{canBulkUpdate && updatableRemoteCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={triggerUpdateAll}
|
|
||||||
className="h-7 gap-1.5"
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
||||||
Update All ({updatableRemoteCount})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
GitCompare,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
MinusCircle,
|
MinusCircle,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
@@ -145,93 +144,93 @@ export function ScanComparisonSheet({
|
|||||||
const pageItems = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
const pageItems = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
||||||
const needsPagination = rows.length > PAGE_SIZE;
|
const needsPagination = rows.length > PAGE_SIZE;
|
||||||
|
|
||||||
|
const meta = data
|
||||||
|
? `#${data.scanA.id} → #${data.scanB.id} · +${data.added.length} −${data.removed.length}`
|
||||||
|
: (loading ? 'Loading…' : '');
|
||||||
|
|
||||||
|
const footerContext = data
|
||||||
|
? `${data.scanA.image_ref} → ${data.scanB.image_ref}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
|
<SystemSheet
|
||||||
<SheetContent className="sm:max-w-4xl flex flex-col p-0">
|
open={open}
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
|
onOpenChange={(o) => !o && onClose()}
|
||||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
crumb={['Security', 'Scans', 'Compare']}
|
||||||
Scan comparison
|
name="Diff"
|
||||||
</div>
|
meta={meta}
|
||||||
<SheetTitle className="flex items-center gap-2 font-display italic text-2xl">
|
footerContext={footerContext}
|
||||||
<GitCompare className="w-5 h-5 text-muted-foreground not-italic" strokeWidth={1.5} />
|
size="xl"
|
||||||
Diff
|
>
|
||||||
</SheetTitle>
|
{loading && (
|
||||||
<SheetDescription className="sr-only">
|
<div className="flex items-center justify-center py-12">
|
||||||
Side-by-side comparison of two vulnerability scans showing added, removed, and unchanged findings.
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
|
||||||
</SheetDescription>
|
</div>
|
||||||
</SheetHeader>
|
)}
|
||||||
|
|
||||||
{loading && (
|
{data && !loading && (
|
||||||
<div className="flex items-center justify-center flex-1">
|
<>
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
|
<SheetSection title="Scans">
|
||||||
</div>
|
<div className="flex items-center gap-3 text-xs font-mono tabular-nums mb-3">
|
||||||
)}
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Baseline</div>
|
||||||
{data && !loading && (
|
<div className="text-stat-value truncate">{data.scanA.image_ref}</div>
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
|
||||||
{/* Scan identification */}
|
</div>
|
||||||
<div className="px-6 py-4 border-b space-y-3">
|
<ArrowRight className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||||
<div className="flex items-center gap-3 text-xs font-mono tabular-nums">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Current</div>
|
||||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Baseline</div>
|
<div className="text-stat-value truncate">{data.scanB.image_ref}</div>
|
||||||
<div className="text-stat-value truncate">{data.scanA.image_ref}</div>
|
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
|
||||||
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Current</div>
|
|
||||||
<div className="text-stat-value truncate">{data.scanB.image_ref}</div>
|
|
||||||
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{crossImage && (
|
|
||||||
<div className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
|
|
||||||
<span>
|
|
||||||
You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.truncated && (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
|
|
||||||
<span>
|
|
||||||
Showing the first {data.row_limit ?? 1000} findings per scan. One or both scans exceed this limit, so the comparison may be incomplete.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delta ribbon */}
|
|
||||||
{addedCounts && removedCounts && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => {
|
|
||||||
const delta = formatDelta(sev, addedCounts[sev], removedCounts[sev]);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={sev}
|
|
||||||
aria-label={`${sev} delta ${delta.text}`}
|
|
||||||
data-tone={delta.tone}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-mono tabular-nums shadow-card-bevel',
|
|
||||||
DELTA_TONE_CLASS[delta.tone],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="uppercase tracking-[0.18em] text-[10px]">{sev}</span>
|
|
||||||
<span>{delta.text}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter pills */}
|
{crossImage && (
|
||||||
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
|
<div className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 mb-3 text-xs text-warning">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
|
||||||
|
<span>
|
||||||
|
You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.truncated && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 mb-3 text-xs text-warning"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
|
||||||
|
<span>
|
||||||
|
Showing the first {data.row_limit ?? 1000} findings per scan. One or both scans exceed this limit, so the comparison may be incomplete.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addedCounts && removedCounts && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => {
|
||||||
|
const delta = formatDelta(sev, addedCounts[sev], removedCounts[sev]);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={sev}
|
||||||
|
aria-label={`${sev} delta ${delta.text}`}
|
||||||
|
data-tone={delta.tone}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-mono tabular-nums shadow-card-bevel',
|
||||||
|
DELTA_TONE_CLASS[delta.tone],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="uppercase tracking-[0.18em] text-[10px]">{sev}</span>
|
||||||
|
<span>{delta.text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetSection>
|
||||||
|
|
||||||
|
<SheetSection title={`Findings · ${filter}`}>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap mb-3">
|
||||||
<Button
|
<Button
|
||||||
variant={filter === 'added' ? 'default' : 'ghost'}
|
variant={filter === 'added' ? 'default' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -296,98 +295,96 @@ export function ScanComparisonSheet({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea block className="max-h-[60vh]">
|
||||||
<div className="px-6 py-3">
|
{pageItems.length === 0 ? (
|
||||||
{pageItems.length === 0 ? (
|
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
|
||||||
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
|
<ShieldCheck className="w-8 h-8 text-success" strokeWidth={1.5} />
|
||||||
<ShieldCheck className="w-8 h-8 text-success" strokeWidth={1.5} />
|
<div className="text-sm text-muted-foreground">
|
||||||
<div className="text-sm text-muted-foreground">
|
{filter === 'added' && 'No new findings. Nothing regressed between these scans.'}
|
||||||
{filter === 'added' && 'No new findings. Nothing regressed between these scans.'}
|
{filter === 'removed' && 'No findings were resolved between these scans.'}
|
||||||
{filter === 'removed' && 'No findings were resolved between these scans.'}
|
{filter === 'unchanged' && 'No findings are shared between the two scans.'}
|
||||||
{filter === 'unchanged' && 'No findings are shared between the two scans.'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<Table>
|
) : (
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableRow>
|
<TableHeader>
|
||||||
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
|
<TableRow>
|
||||||
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
|
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
|
||||||
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
|
||||||
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Status</TableHead>
|
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
||||||
</TableRow>
|
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Status</TableHead>
|
||||||
</TableHeader>
|
</TableRow>
|
||||||
<TableBody>
|
</TableHeader>
|
||||||
{pageItems.map((v, idx) => {
|
<TableBody>
|
||||||
const href = cveUrl(v.vulnerability_id, v.primary_url);
|
{pageItems.map((v, idx) => {
|
||||||
const rowClass = cn(
|
const href = cveUrl(v.vulnerability_id, v.primary_url);
|
||||||
SEVERITY_ROW_TINT[v.severity],
|
const rowClass = cn(
|
||||||
filter === 'unchanged' && 'opacity-75',
|
SEVERITY_ROW_TINT[v.severity],
|
||||||
v.suppressed && 'opacity-60',
|
filter === 'unchanged' && 'opacity-75',
|
||||||
);
|
v.suppressed && 'opacity-60',
|
||||||
return (
|
);
|
||||||
<TableRow key={`${v.vulnerability_id}-${v.pkg_name}-${idx}`} className={rowClass}>
|
return (
|
||||||
<TableCell className="font-mono text-xs tabular-nums">
|
<TableRow key={`${v.vulnerability_id}-${v.pkg_name}-${idx}`} className={rowClass}>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<TableCell className="font-mono text-xs tabular-nums">
|
||||||
{v.suppressed && (
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<ShieldOff
|
{v.suppressed && (
|
||||||
className="w-3 h-3 text-muted-foreground"
|
<ShieldOff
|
||||||
strokeWidth={1.5}
|
className="w-3 h-3 text-muted-foreground"
|
||||||
aria-label="Suppressed"
|
strokeWidth={1.5}
|
||||||
/>
|
aria-label="Suppressed"
|
||||||
)}
|
/>
|
||||||
{href ? (
|
)}
|
||||||
<a
|
{href ? (
|
||||||
href={href}
|
<a
|
||||||
target="_blank"
|
href={href}
|
||||||
rel="noreferrer noopener"
|
target="_blank"
|
||||||
className="hover:underline"
|
rel="noreferrer noopener"
|
||||||
>
|
className="hover:underline"
|
||||||
{v.vulnerability_id}
|
>
|
||||||
</a>
|
{v.vulnerability_id}
|
||||||
) : (
|
</a>
|
||||||
v.vulnerability_id
|
) : (
|
||||||
)}
|
v.vulnerability_id
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={v.pkg_name}>
|
||||||
|
{v.pkg_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<SeverityChip severity={v.severity} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{filter === 'added' && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-destructive">
|
||||||
|
<PlusCircle className="w-3 h-3" strokeWidth={1.5} />
|
||||||
|
Added
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={v.pkg_name}>
|
{filter === 'removed' && (
|
||||||
{v.pkg_name}
|
<span className="inline-flex items-center gap-1 text-success">
|
||||||
</TableCell>
|
<MinusCircle className="w-3 h-3" strokeWidth={1.5} />
|
||||||
<TableCell>
|
Removed
|
||||||
<SeverityChip severity={v.severity} />
|
</span>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="font-mono text-xs">
|
{filter === 'unchanged' && (
|
||||||
{filter === 'added' && (
|
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1 text-destructive">
|
<Equal className="w-3 h-3" strokeWidth={1.5} />
|
||||||
<PlusCircle className="w-3 h-3" strokeWidth={1.5} />
|
{crossImage ? 'Shared' : 'Unchanged'}
|
||||||
Added
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</TableCell>
|
||||||
{filter === 'removed' && (
|
</TableRow>
|
||||||
<span className="inline-flex items-center gap-1 text-success">
|
);
|
||||||
<MinusCircle className="w-3 h-3" strokeWidth={1.5} />
|
})}
|
||||||
Removed
|
</TableBody>
|
||||||
</span>
|
</Table>
|
||||||
)}
|
)}
|
||||||
{filter === 'unchanged' && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
|
||||||
<Equal className="w-3 h-3" strokeWidth={1.5} />
|
|
||||||
{crossImage ? 'Shared' : 'Unchanged'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</SheetSection>
|
||||||
)}
|
</>
|
||||||
</SheetContent>
|
)}
|
||||||
</Sheet>
|
</SystemSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -844,31 +843,27 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
|
|
||||||
{/* Run History Sheet */}
|
{/* Run History Sheet */}
|
||||||
<Sheet open={!!runsTask} onOpenChange={(open) => { if (!open) setRunsTask(null); }}>
|
<SystemSheet
|
||||||
<SheetContent className="sm:max-w-xl">
|
open={!!runsTask}
|
||||||
<SheetHeader>
|
onOpenChange={(open) => { if (!open) setRunsTask(null); }}
|
||||||
<div className="flex items-center justify-between">
|
crumb={['Schedules', runsTask?.name ?? '—', 'Runs']}
|
||||||
<SheetTitle>Execution History - {runsTask?.name}</SheetTitle>
|
name={runsTask?.name ?? 'Run history'}
|
||||||
{runsTask && runs.length > 0 && (
|
meta={`${runsTotal} run${runsTotal === 1 ? '' : 's'}`}
|
||||||
<Button
|
secondaryActions={runsTask && runs.length > 0 ? [{
|
||||||
variant="ghost"
|
label: 'Download CSV',
|
||||||
size="sm"
|
icon: Download,
|
||||||
onClick={() => window.open(`/api/scheduled-tasks/${runsTask.id}/runs/export`, '_blank')}
|
onClick: () => window.open(`/api/scheduled-tasks/${runsTask.id}/runs/export`, '_blank'),
|
||||||
title="Export as CSV"
|
}] : undefined}
|
||||||
>
|
footerContext={runsTask?.next_run_at ? `Next run ${formatTimestamp(runsTask.next_run_at)}` : undefined}
|
||||||
<Download className="w-4 h-4" strokeWidth={1.5} />
|
size="lg"
|
||||||
</Button>
|
>
|
||||||
)}
|
<SheetSection title="Executions" hideHeader>
|
||||||
</div>
|
{runsLoading ? (
|
||||||
</SheetHeader>
|
<div className="text-center text-muted-foreground py-8">Loading...</div>
|
||||||
<ScrollArea className="mt-4 flex-1" style={{ maxHeight: 'calc(100vh - 10rem)' }}>
|
) : runs.length === 0 ? (
|
||||||
<div>
|
<div className="text-center text-muted-foreground py-8">No executions yet.</div>
|
||||||
{runsLoading ? (
|
) : (
|
||||||
<div className="text-center text-muted-foreground py-8">Loading...</div>
|
<>
|
||||||
) : runs.length === 0 ? (
|
|
||||||
<div className="text-center text-muted-foreground py-8">No executions yet.</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -927,12 +922,10 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</SheetSection>
|
||||||
</ScrollArea>
|
</SystemSheet>
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import {
|
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -153,198 +147,177 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const compareDisabled = selected.length !== 2;
|
const compareDisabled = selected.length !== 2;
|
||||||
|
const meta = `${total} scan${total === 1 ? '' : 's'} · ${groups.length} image${groups.length === 1 ? '' : 's'}`;
|
||||||
|
const footerContext = `Node ${activeNode?.name ?? '—'}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
|
<SystemSheet
|
||||||
<SheetContent className="sm:max-w-4xl flex flex-col p-0 gap-0">
|
open={open}
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
|
onOpenChange={(next) => { if (!next) onClose(); }}
|
||||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
crumb={['Security', 'Scan history']}
|
||||||
Security · Node {activeNode?.name ?? '-'}
|
name="Scan history"
|
||||||
|
meta={meta}
|
||||||
|
primaryAction={isPaid ? {
|
||||||
|
label: `Compare (${selected.length}/2)`,
|
||||||
|
icon: GitCompare,
|
||||||
|
onClick: compareSelected,
|
||||||
|
disabled: compareDisabled,
|
||||||
|
} : undefined}
|
||||||
|
secondaryActions={[{
|
||||||
|
label: 'Refresh',
|
||||||
|
icon: RefreshCw,
|
||||||
|
onClick: () => load(safePage, search),
|
||||||
|
disabled: loading,
|
||||||
|
}]}
|
||||||
|
footerContext={footerContext}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<SheetSection title="Scans" hideHeader>
|
||||||
|
<div className="flex items-center gap-3 mb-3 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>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
{needsPagination && (
|
||||||
<SheetTitle className="font-display italic text-2xl">Scan history</SheetTitle>
|
<div className="flex items-center gap-1 ml-auto" aria-live="polite">
|
||||||
<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
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
className="border-border"
|
className="h-6 w-6"
|
||||||
onClick={() => load(safePage, search)}
|
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||||
disabled={loading}
|
disabled={safePage === 0}
|
||||||
|
aria-label="Previous page"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||||
className={cn('w-4 h-4 mr-2', loading && 'animate-spin')}
|
</Button>
|
||||||
strokeWidth={1.5}
|
<span
|
||||||
/>
|
className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center"
|
||||||
Refresh
|
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>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ScanComparisonSheet
|
{groups.length === 0 && !loading ? (
|
||||||
baselineScanId={compareIds?.[0] ?? null}
|
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
|
||||||
currentScanId={compareIds?.[1] ?? null}
|
<ShieldCheck className="w-8 h-8 text-muted-foreground" strokeWidth={1.5} />
|
||||||
onClose={() => setCompareIds(null)}
|
<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 block className="max-h-[60vh]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</SheetSection>
|
||||||
|
|
||||||
<VulnerabilityScanSheet
|
<ScanComparisonSheet
|
||||||
scanId={inspectScanId}
|
baselineScanId={compareIds?.[0] ?? null}
|
||||||
onClose={() => setInspectScanId(null)}
|
currentScanId={compareIds?.[1] ?? null}
|
||||||
canGenerateSbom={isPaid}
|
onClose={() => setCompareIds(null)}
|
||||||
canCompare={false}
|
/>
|
||||||
canManageSuppressions={isPaid && isAdmin}
|
|
||||||
/>
|
<VulnerabilityScanSheet
|
||||||
</SheetContent>
|
scanId={inspectScanId}
|
||||||
</Sheet>
|
onClose={() => setInspectScanId(null)}
|
||||||
|
canGenerateSbom={isPaid}
|
||||||
|
canCompare={false}
|
||||||
|
canManageSuppressions={isPaid && isAdmin}
|
||||||
|
/>
|
||||||
|
</SystemSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -20,13 +14,14 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2 } from 'lucide-react';
|
import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { useLicense } from '@/context/LicenseContext';
|
||||||
|
|
||||||
interface StackAlert {
|
interface StackAlert {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -38,10 +33,42 @@ interface StackAlert {
|
|||||||
cooldown_mins: number;
|
cooldown_mins: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AutoHealPolicy {
|
||||||
|
id?: number;
|
||||||
|
stack_name: string;
|
||||||
|
service_name: string | null;
|
||||||
|
unhealthy_duration_mins: number;
|
||||||
|
cooldown_mins: number;
|
||||||
|
max_restarts_per_hour: number;
|
||||||
|
auto_disable_after_failures: number;
|
||||||
|
enabled: number;
|
||||||
|
consecutive_failures: number;
|
||||||
|
last_fired_at: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoHealHistoryEntry {
|
||||||
|
id?: number;
|
||||||
|
policy_id: number;
|
||||||
|
stack_name: string;
|
||||||
|
service_name: string | null;
|
||||||
|
container_name: string;
|
||||||
|
container_id: string;
|
||||||
|
action: 'restarted' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit' | 'failed' | 'policy_auto_disabled';
|
||||||
|
reason: string;
|
||||||
|
success: number;
|
||||||
|
error: string | null;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonitorTab = 'alerts' | 'auto-heal';
|
||||||
|
|
||||||
interface StackAlertSheetProps {
|
interface StackAlertSheetProps {
|
||||||
isOpen: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
stackName: string;
|
stackName: string;
|
||||||
|
initialTab?: MonitorTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentStatus {
|
interface AgentStatus {
|
||||||
@@ -81,7 +108,60 @@ const clampNonNegative = (setter: (v: string) => void) => (e: React.ChangeEvent<
|
|||||||
setter(val);
|
setter(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetProps) {
|
function actionColorClass(action: AutoHealHistoryEntry['action']): string {
|
||||||
|
if (action === 'restarted') return 'text-success';
|
||||||
|
if (action === 'failed' || action === 'policy_auto_disabled') return 'text-destructive';
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: AutoHealHistoryEntry['action']): string {
|
||||||
|
switch (action) {
|
||||||
|
case 'restarted': return 'Restarted';
|
||||||
|
case 'skipped_user_action': return 'Skipped (user action)';
|
||||||
|
case 'skipped_cooldown': return 'Skipped (cooldown)';
|
||||||
|
case 'skipped_rate_limit': return 'Skipped (rate limit)';
|
||||||
|
case 'failed': return 'Failed';
|
||||||
|
case 'policy_auto_disabled': return 'Auto-disabled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StackAlertSheet({ open, onOpenChange, stackName, initialTab = 'alerts' }: StackAlertSheetProps) {
|
||||||
|
const { isPaid } = useLicense();
|
||||||
|
// Per Sencho convention: paid features hide their trigger entirely. Community users
|
||||||
|
// never see the Auto-heal tab, and a stray initialTab='auto-heal' falls back to alerts.
|
||||||
|
const effectiveInitialTab: MonitorTab = !isPaid && initialTab === 'auto-heal' ? 'alerts' : initialTab;
|
||||||
|
const [activeTab, setActiveTab] = useState<MonitorTab>(effectiveInitialTab);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setActiveTab(effectiveInitialTab);
|
||||||
|
}, [open, effectiveInitialTab, stackName]);
|
||||||
|
|
||||||
|
const tabs = isPaid
|
||||||
|
? [
|
||||||
|
{ id: 'alerts', label: 'Alerts' },
|
||||||
|
{ id: 'auto-heal', label: 'Auto-heal' },
|
||||||
|
]
|
||||||
|
: [{ id: 'alerts', label: 'Alerts' }];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SystemSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
crumb={['Stack', stackName || '—', 'Monitor']}
|
||||||
|
name={stackName || 'Stack monitor'}
|
||||||
|
meta={activeTab === 'alerts' ? 'Alert rules' : 'Auto-heal policies'}
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={(id) => setActiveTab(id as MonitorTab)}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{activeTab === 'alerts' && <AlertsTab stackName={stackName} />}
|
||||||
|
{activeTab === 'auto-heal' && isPaid && <AutoHealTab stackName={stackName} open={open} />}
|
||||||
|
</SystemSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertsTab({ stackName }: { stackName: string }) {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { activeNode } = useNodes();
|
const { activeNode } = useNodes();
|
||||||
const isRemote = activeNode?.type === 'remote';
|
const isRemote = activeNode?.type === 'remote';
|
||||||
@@ -95,7 +175,6 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
|||||||
enabledTypes: [],
|
enabledTypes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// New Alert Form State
|
|
||||||
const [metric, setMetric] = useState('cpu_percent');
|
const [metric, setMetric] = useState('cpu_percent');
|
||||||
const [operator, setOperator] = useState('>');
|
const [operator, setOperator] = useState('>');
|
||||||
const [threshold, setThreshold] = useState('');
|
const [threshold, setThreshold] = useState('');
|
||||||
@@ -103,11 +182,10 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
|||||||
const [cooldown, setCooldown] = useState('60');
|
const [cooldown, setCooldown] = useState('60');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && stackName) {
|
if (!stackName) return;
|
||||||
fetchAlerts();
|
fetchAlerts();
|
||||||
fetchAgentStatus();
|
fetchAgentStatus();
|
||||||
}
|
}, [stackName]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
}, [isOpen, stackName]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const fetchAlerts = async () => {
|
const fetchAlerts = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -124,7 +202,6 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
|||||||
const fetchAgentStatus = async () => {
|
const fetchAgentStatus = async () => {
|
||||||
setAgentStatus(prev => ({ ...prev, loading: true }));
|
setAgentStatus(prev => ({ ...prev, loading: true }));
|
||||||
try {
|
try {
|
||||||
// Always fetch agents from the active node (proxied via x-node-id for remote)
|
|
||||||
const res = await apiFetch('/agents');
|
const res = await apiFetch('/agents');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const agents: Array<{ type: string; enabled: boolean }> = await res.json();
|
const agents: Array<{ type: string; enabled: boolean }> = await res.json();
|
||||||
@@ -148,7 +225,6 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
|||||||
toast.error('Please enter a threshold.');
|
toast.error('Please enter a threshold.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const newAlert = {
|
const newAlert = {
|
||||||
stack_name: stackName,
|
stack_name: stackName,
|
||||||
@@ -158,7 +234,6 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
|||||||
duration_mins: parseInt(duration, 10),
|
duration_mins: parseInt(duration, 10),
|
||||||
cooldown_mins: parseInt(cooldown, 10),
|
cooldown_mins: parseInt(cooldown, 10),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/alerts', {
|
const res = await apiFetch('/alerts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -263,179 +338,122 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<TooltipProvider>
|
||||||
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
<SheetSection title="Notification channels">
|
||||||
<SheetContent className="sm:max-w-[420px] flex flex-col">
|
{renderAgentStatusBanner()}
|
||||||
<SheetHeader>
|
</SheetSection>
|
||||||
<SheetTitle>Stack Alerts: {stackName}</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
Configure metric thresholds to trigger notifications for this stack.
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<SheetSection title="Active rules">
|
||||||
<TooltipProvider>
|
{alerts.length === 0 ? (
|
||||||
<div className="mt-4 space-y-5 pr-2">
|
<div className="text-sm text-muted-foreground text-center py-4">
|
||||||
{/* Notification agent status banner */}
|
No active alert rules for this stack.
|
||||||
{renderAgentStatusBanner()}
|
</div>
|
||||||
|
) : (
|
||||||
{/* List Existing Alerts */}
|
<div className="space-y-2">
|
||||||
<div className="space-y-3">
|
{alerts.map(alert => (
|
||||||
<h4 className="text-sm font-medium">Existing Rules</h4>
|
<div key={alert.id} className="flex justify-between items-start gap-2 py-2 border-b border-card-border/40 last:border-b-0 text-sm">
|
||||||
{alerts.length === 0 ? (
|
<div>
|
||||||
<div className="text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg text-center">
|
<span className="font-medium text-foreground">
|
||||||
No active alert rules for this stack.
|
{metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold}
|
||||||
</div>
|
</span>
|
||||||
) : (
|
<div className="text-muted-foreground text-xs mt-0.5">
|
||||||
alerts.map(alert => (
|
Trigger after {alert.duration_mins}m • Cooldown {alert.cooldown_mins}m
|
||||||
<div key={alert.id} className="flex flex-col gap-2 p-3 bg-muted/50 rounded-lg border text-sm">
|
</div>
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold}
|
|
||||||
</span>
|
|
||||||
<div className="text-muted-foreground mt-1">
|
|
||||||
Trigger after {alert.duration_mins}m • Cooldown: {alert.cooldown_mins}m
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isAdmin && <Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive shrink-0"
|
|
||||||
onClick={() => alert.id && setConfirmDeleteId(alert.id)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</Button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
<hr />
|
<Button
|
||||||
|
variant="ghost"
|
||||||
{/* Add New Alert Form */}
|
size="icon"
|
||||||
{isAdmin && <div className="space-y-4">
|
className="h-7 w-7 text-muted-foreground hover:text-destructive shrink-0"
|
||||||
<h4 className="text-sm font-medium">Add New Rule</h4>
|
onClick={() => alert.id && setConfirmDeleteId(alert.id)}
|
||||||
|
disabled={isLoading}
|
||||||
<div className="space-y-2">
|
>
|
||||||
<div className="flex items-center gap-2">
|
<Trash2 className="h-4 w-4" strokeWidth={1.5} />
|
||||||
<Label>Metric</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" strokeWidth={1.5} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="max-w-[200px] text-sm">The system resource or metric to monitor. Select from CPU, Memory, Network I/O, or Restarts.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Combobox
|
|
||||||
options={metricOptions}
|
|
||||||
value={metric}
|
|
||||||
onValueChange={setMetric}
|
|
||||||
placeholder="Select metric..."
|
|
||||||
searchPlaceholder="Search metrics..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label>Operator</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" strokeWidth={1.5} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="max-w-[200px] text-sm">The comparison condition to trigger the alert against the threshold.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Combobox
|
|
||||||
options={operatorOptions}
|
|
||||||
value={operator}
|
|
||||||
onValueChange={setOperator}
|
|
||||||
placeholder="Select operator..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label>Threshold</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" strokeWidth={1.5} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="max-w-[200px] text-sm">The numerical value the metric needs to breach to trigger the conditions.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={threshold}
|
|
||||||
onChange={clampNonNegative(setThreshold)}
|
|
||||||
placeholder="e.g. 90"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label>Duration (mins)</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" strokeWidth={1.5} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="max-w-[200px] text-sm">How long the metric must stay in breach of the threshold before sending an alert.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={duration}
|
|
||||||
onChange={clampNonNegative(setDuration)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label>Cooldown (mins)</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" strokeWidth={1.5} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="max-w-[200px] text-sm">How long to wait before sending another alert if the stack continues to breach.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={cooldown}
|
|
||||||
onChange={clampNonNegative(setCooldown)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button className="w-full mt-2" onClick={addAlert} disabled={isLoading}>
|
|
||||||
{isLoading ? (
|
|
||||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</>
|
|
||||||
) : (
|
|
||||||
'Add Rule'
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
))}
|
||||||
</ScrollArea>
|
</div>
|
||||||
</SheetContent>
|
)}
|
||||||
</Sheet>
|
</SheetSection>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<SheetSection title="Add new rule">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label>Metric</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" strokeWidth={1.5} />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="max-w-[200px] text-sm">The system resource or metric to monitor. Select from CPU, Memory, Network I/O, or Restarts.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Combobox
|
||||||
|
options={metricOptions}
|
||||||
|
value={metric}
|
||||||
|
onValueChange={setMetric}
|
||||||
|
placeholder="Select metric..."
|
||||||
|
searchPlaceholder="Search metrics..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Operator</Label>
|
||||||
|
<Combobox
|
||||||
|
options={operatorOptions}
|
||||||
|
value={operator}
|
||||||
|
onValueChange={setOperator}
|
||||||
|
placeholder="Select operator..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Threshold</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={threshold}
|
||||||
|
onChange={clampNonNegative(setThreshold)}
|
||||||
|
placeholder="e.g. 90"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Duration (mins)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={duration}
|
||||||
|
onChange={clampNonNegative(setDuration)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Cooldown (mins)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={cooldown}
|
||||||
|
onChange={clampNonNegative(setCooldown)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className="w-full mt-2" onClick={addAlert} disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</>
|
||||||
|
) : (
|
||||||
|
'Add Rule'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<AlertDialog open={!!confirmDeleteId} onOpenChange={(open) => !open && setConfirmDeleteId(null)}>
|
<AlertDialog open={!!confirmDeleteId} onOpenChange={(open) => !open && setConfirmDeleteId(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -456,6 +474,349 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutoHealTab({ stackName, open }: { stackName: string; open: boolean }) {
|
||||||
|
const [policies, setPolicies] = useState<AutoHealPolicy[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [serviceOptions, setServiceOptions] = useState<{ value: string; label: string }[]>([]);
|
||||||
|
|
||||||
|
const [service, setService] = useState('');
|
||||||
|
const [unhealthyFor, setUnhealthyFor] = useState('5');
|
||||||
|
const [cooldown, setCooldown] = useState('5');
|
||||||
|
const [maxRestarts, setMaxRestarts] = useState('3');
|
||||||
|
const [autoDisableAfter, setAutoDisableAfter] = useState('5');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !stackName) return;
|
||||||
|
setLoading(true);
|
||||||
|
apiFetch(`/auto-heal/policies?stackName=${encodeURIComponent(stackName)}`)
|
||||||
|
.then(res => res.json() as Promise<AutoHealPolicy[]>)
|
||||||
|
.then(data => setPolicies(data))
|
||||||
|
.catch(() => toast.error('Failed to load auto-heal policies.'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
|
||||||
|
apiFetch(`/stacks/${encodeURIComponent(stackName)}/services`)
|
||||||
|
.then(res => res.json() as Promise<string[]>)
|
||||||
|
.then(names => setServiceOptions(names.map(n => ({ value: n, label: n }))))
|
||||||
|
.catch(() => { /* services list is optional, silently skip */ });
|
||||||
|
}, [open, stackName]);
|
||||||
|
|
||||||
|
const handleToggle = async (id: number, enabled: boolean) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/auto-heal/policies/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ enabled: enabled ? 1 : 0 }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setPolicies(prev =>
|
||||||
|
prev.map(p => p.id === id ? { ...p, enabled: enabled ? 1 : 0 } : p)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({})) as Record<string, unknown>;
|
||||||
|
toast.error((err?.message as string) || (err?.error as string) || 'Failed to update policy.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[StackAlertSheet] Failed to toggle policy:', e);
|
||||||
|
toast.error('Network error. Could not reach the node.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/auto-heal/policies/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('Policy deleted.');
|
||||||
|
setPolicies(prev => prev.filter(p => p.id !== id));
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({})) as Record<string, unknown>;
|
||||||
|
toast.error((err?.message as string) || (err?.error as string) || 'Failed to delete policy.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[StackAlertSheet] Failed to delete policy:', e);
|
||||||
|
toast.error('Network error. Could not reach the node.');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddPolicy = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
const body = {
|
||||||
|
stack_name: stackName,
|
||||||
|
service_name: service === '' ? null : service,
|
||||||
|
unhealthy_duration_mins: parseInt(unhealthyFor, 10) || 5,
|
||||||
|
cooldown_mins: parseInt(cooldown, 10) || 5,
|
||||||
|
max_restarts_per_hour: parseInt(maxRestarts, 10) || 3,
|
||||||
|
auto_disable_after_failures: parseInt(autoDisableAfter, 10) || 5,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/auto-heal/policies', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('Policy added.');
|
||||||
|
setService('');
|
||||||
|
setUnhealthyFor('5');
|
||||||
|
setCooldown('5');
|
||||||
|
setMaxRestarts('3');
|
||||||
|
setAutoDisableAfter('5');
|
||||||
|
apiFetch(`/auto-heal/policies?stackName=${encodeURIComponent(stackName)}`)
|
||||||
|
.then(res => res.json() as Promise<AutoHealPolicy[]>)
|
||||||
|
.then(data => setPolicies(data))
|
||||||
|
.catch(() => toast.error('Failed to reload policies.'));
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({})) as Record<string, unknown>;
|
||||||
|
toast.error((err?.message as string) || (err?.error as string) || 'Failed to add policy.');
|
||||||
|
console.error('[StackAlertSheet] addPolicy failed:', err);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[StackAlertSheet] addPolicy threw:', e);
|
||||||
|
toast.error('Network error. Could not reach the node.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serviceComboOptions = [
|
||||||
|
{ value: '', label: 'All services' },
|
||||||
|
...serviceOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SheetSection title="Active policies">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
|
||||||
|
<span>Loading policies...</span>
|
||||||
|
</div>
|
||||||
|
) : policies.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No auto-heal policies configured for this stack.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{policies.map(policy => (
|
||||||
|
<PolicyRow
|
||||||
|
key={policy.id}
|
||||||
|
policy={policy}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
deleting={deleting}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetSection>
|
||||||
|
|
||||||
|
<SheetSection title="Add new policy">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Service</Label>
|
||||||
|
<Combobox
|
||||||
|
options={serviceComboOptions}
|
||||||
|
value={service}
|
||||||
|
onValueChange={setService}
|
||||||
|
placeholder="All services"
|
||||||
|
searchPlaceholder="Search services..."
|
||||||
|
emptyText="No services found."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="unhealthy-duration">Unhealthy for (minutes)</Label>
|
||||||
|
<Input
|
||||||
|
id="unhealthy-duration"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={unhealthyFor}
|
||||||
|
onChange={clampNonNegative(setUnhealthyFor)}
|
||||||
|
placeholder="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cooldown">Cooldown (minutes)</Label>
|
||||||
|
<Input
|
||||||
|
id="cooldown"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={cooldown}
|
||||||
|
onChange={clampNonNegative(setCooldown)}
|
||||||
|
placeholder="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max-restarts">Max restarts / hr</Label>
|
||||||
|
<Input
|
||||||
|
id="max-restarts"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={maxRestarts}
|
||||||
|
onChange={clampNonNegative(setMaxRestarts)}
|
||||||
|
placeholder="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="auto-disable">Auto-disable after (failures)</Label>
|
||||||
|
<Input
|
||||||
|
id="auto-disable"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={autoDisableAfter}
|
||||||
|
onChange={clampNonNegative(setAutoDisableAfter)}
|
||||||
|
placeholder="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full mt-2"
|
||||||
|
onClick={handleAddPolicy}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" strokeWidth={1.5} />Saving...</>
|
||||||
|
) : (
|
||||||
|
'Add Policy'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PolicyRowProps {
|
||||||
|
policy: AutoHealPolicy;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
onToggle: (id: number, enabled: boolean) => void;
|
||||||
|
deleting: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyRow({ policy, onDelete, onToggle, deleting, saving }: PolicyRowProps) {
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const [history, setHistory] = useState<AutoHealHistoryEntry[]>([]);
|
||||||
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
|
|
||||||
|
const toggleHistory = async () => {
|
||||||
|
if (!historyOpen && history.length === 0 && policy.id != null) {
|
||||||
|
setLoadingHistory(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/auto-heal/policies/${policy.id}/history`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data: AutoHealHistoryEntry[] = await res.json();
|
||||||
|
setHistory(data);
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({})) as Record<string, unknown>;
|
||||||
|
toast.error((err?.message as string) || (err?.error as string) || 'Failed to load history.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[StackAlertSheet] Failed to fetch history:', e);
|
||||||
|
toast.error('Network error. Could not reach the node.');
|
||||||
|
} finally {
|
||||||
|
setLoadingHistory(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHistoryOpen(prev => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0 border-b border-card-border/40 last:border-b-0 text-sm py-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<span className="font-mono text-foreground truncate">
|
||||||
|
{policy.service_name ?? <span className="text-muted-foreground font-sans">All services</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Unhealthy for {policy.unhealthy_duration_mins} min
|
||||||
|
• Cooldown: {policy.cooldown_mins} min
|
||||||
|
• Max {policy.max_restarts_per_hour}/hr
|
||||||
|
</span>
|
||||||
|
{policy.consecutive_failures > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 mt-0.5">
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-xs font-mono tabular-nums text-destructive bg-destructive/10 border border-destructive/20">
|
||||||
|
{policy.consecutive_failures} failure{policy.consecutive_failures !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<TogglePill
|
||||||
|
checked={policy.enabled === 1}
|
||||||
|
onChange={(checked) => policy.id != null && onToggle(policy.id, checked)}
|
||||||
|
disabled={saving}
|
||||||
|
aria-label={`Toggle policy for ${policy.service_name ?? 'all services'}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={toggleHistory}
|
||||||
|
aria-label="Toggle history"
|
||||||
|
disabled={loadingHistory}
|
||||||
|
>
|
||||||
|
{loadingHistory ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
|
||||||
|
) : historyOpen ? (
|
||||||
|
<ChevronUp className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
onClick={() => policy.id != null && onDelete(policy.id)}
|
||||||
|
disabled={deleting}
|
||||||
|
aria-label="Delete policy"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{historyOpen && (
|
||||||
|
<div className="border-t border-card-border/40 mt-2 pt-2 space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">Recent activity</p>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-2">No history yet.</p>
|
||||||
|
) : (
|
||||||
|
history.map((entry) => (
|
||||||
|
<div key={entry.id} className="flex items-start gap-2 text-xs">
|
||||||
|
<span className="text-muted-foreground shrink-0 tabular-nums font-mono">
|
||||||
|
{new Date(entry.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-foreground shrink-0 truncate max-w-[100px]">
|
||||||
|
{entry.container_name}
|
||||||
|
</span>
|
||||||
|
<span className={`shrink-0 font-medium ${actionColorClass(entry.action)}`}>
|
||||||
|
{actionLabel(entry.action)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground truncate">
|
||||||
|
{entry.reason}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,439 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Trash2, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
|
||||||
import { toast } from '@/components/ui/toast-store';
|
|
||||||
import { apiFetch } from '@/lib/api';
|
|
||||||
import { PaidGate } from '@/components/PaidGate';
|
|
||||||
|
|
||||||
interface AutoHealPolicy {
|
|
||||||
id?: number;
|
|
||||||
stack_name: string;
|
|
||||||
service_name: string | null;
|
|
||||||
unhealthy_duration_mins: number;
|
|
||||||
cooldown_mins: number;
|
|
||||||
max_restarts_per_hour: number;
|
|
||||||
auto_disable_after_failures: number;
|
|
||||||
enabled: number;
|
|
||||||
consecutive_failures: number;
|
|
||||||
last_fired_at: number;
|
|
||||||
created_at: number;
|
|
||||||
updated_at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AutoHealHistoryEntry {
|
|
||||||
id?: number;
|
|
||||||
policy_id: number;
|
|
||||||
stack_name: string;
|
|
||||||
service_name: string | null;
|
|
||||||
container_name: string;
|
|
||||||
container_id: string;
|
|
||||||
action: 'restarted' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit' | 'failed' | 'policy_auto_disabled';
|
|
||||||
reason: string;
|
|
||||||
success: number;
|
|
||||||
error: string | null;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StackAutoHealSheetProps {
|
|
||||||
stackName: string;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clampNonNegative = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
let val = e.target.value;
|
|
||||||
if (val !== '' && Number(val) < 0) val = '0';
|
|
||||||
setter(val);
|
|
||||||
};
|
|
||||||
|
|
||||||
function actionColorClass(action: AutoHealHistoryEntry['action']): string {
|
|
||||||
if (action === 'restarted') return 'text-success';
|
|
||||||
if (action === 'failed' || action === 'policy_auto_disabled') return 'text-destructive';
|
|
||||||
return 'text-muted-foreground';
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionLabel(action: AutoHealHistoryEntry['action']): string {
|
|
||||||
switch (action) {
|
|
||||||
case 'restarted': return 'Restarted';
|
|
||||||
case 'skipped_user_action': return 'Skipped (user action)';
|
|
||||||
case 'skipped_cooldown': return 'Skipped (cooldown)';
|
|
||||||
case 'skipped_rate_limit': return 'Skipped (rate limit)';
|
|
||||||
case 'failed': return 'Failed';
|
|
||||||
case 'policy_auto_disabled': return 'Auto-disabled';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PolicyRowProps {
|
|
||||||
policy: AutoHealPolicy;
|
|
||||||
onDelete: (id: number) => void;
|
|
||||||
onToggle: (id: number, enabled: boolean) => void;
|
|
||||||
deleting: boolean;
|
|
||||||
saving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PolicyRow({ policy, onDelete, onToggle, deleting, saving }: PolicyRowProps) {
|
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
|
||||||
const [history, setHistory] = useState<AutoHealHistoryEntry[]>([]);
|
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
|
||||||
|
|
||||||
const toggleHistory = async () => {
|
|
||||||
if (!historyOpen && history.length === 0 && policy.id != null) {
|
|
||||||
setLoadingHistory(true);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch(`/auto-heal/policies/${policy.id}/history`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data: AutoHealHistoryEntry[] = await res.json();
|
|
||||||
setHistory(data);
|
|
||||||
} else {
|
|
||||||
const err = await res.json().catch(() => ({})) as Record<string, unknown>;
|
|
||||||
toast.error((err?.message as string) || (err?.error as string) || 'Failed to load history.');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[StackAutoHealSheet] Failed to fetch history:', e);
|
|
||||||
toast.error('Network error. Could not reach the node.');
|
|
||||||
} finally {
|
|
||||||
setLoadingHistory(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setHistoryOpen(prev => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-0 rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel text-sm">
|
|
||||||
<div className="flex items-center justify-between gap-2 p-3">
|
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
|
||||||
<span className="font-mono text-foreground truncate">
|
|
||||||
{policy.service_name ?? <span className="text-muted-foreground font-sans">All services</span>}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
Unhealthy for {policy.unhealthy_duration_mins} min
|
|
||||||
• Cooldown: {policy.cooldown_mins} min
|
|
||||||
• Max {policy.max_restarts_per_hour}/hr
|
|
||||||
</span>
|
|
||||||
{policy.consecutive_failures > 0 && (
|
|
||||||
<span className="inline-flex items-center gap-1 mt-0.5">
|
|
||||||
<span className="px-1.5 py-0.5 rounded text-xs font-mono tabular-nums text-destructive bg-destructive/10 border border-destructive/20">
|
|
||||||
{policy.consecutive_failures} failure{policy.consecutive_failures !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<TogglePill
|
|
||||||
checked={policy.enabled === 1}
|
|
||||||
onChange={(checked) => policy.id != null && onToggle(policy.id, checked)}
|
|
||||||
disabled={saving}
|
|
||||||
aria-label={`Toggle policy for ${policy.service_name ?? 'all services'}`}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={toggleHistory}
|
|
||||||
aria-label="Toggle history"
|
|
||||||
disabled={loadingHistory}
|
|
||||||
>
|
|
||||||
{loadingHistory ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
|
|
||||||
) : historyOpen ? (
|
|
||||||
<ChevronUp className="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
|
||||||
onClick={() => policy.id != null && onDelete(policy.id)}
|
|
||||||
disabled={deleting}
|
|
||||||
aria-label="Delete policy"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{historyOpen && (
|
|
||||||
<div className="border-t border-card-border px-3 pb-3 pt-2 space-y-1.5">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">Recent Activity</p>
|
|
||||||
{history.length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground text-center py-2">No history yet.</p>
|
|
||||||
) : (
|
|
||||||
history.map((entry) => (
|
|
||||||
<div key={entry.id} className="flex items-start gap-2 text-xs">
|
|
||||||
<span className="text-muted-foreground shrink-0 tabular-nums font-mono">
|
|
||||||
{new Date(entry.timestamp).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-foreground shrink-0 truncate max-w-[100px]">
|
|
||||||
{entry.container_name}
|
|
||||||
</span>
|
|
||||||
<span className={`shrink-0 font-medium ${actionColorClass(entry.action)}`}>
|
|
||||||
{actionLabel(entry.action)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground truncate">
|
|
||||||
{entry.reason}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StackAutoHealSheet({ stackName, open, onOpenChange }: StackAutoHealSheetProps) {
|
|
||||||
const [policies, setPolicies] = useState<AutoHealPolicy[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
const [serviceOptions, setServiceOptions] = useState<{ value: string; label: string }[]>([]);
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [service, setService] = useState('');
|
|
||||||
const [unhealthyFor, setUnhealthyFor] = useState('5');
|
|
||||||
const [cooldown, setCooldown] = useState('5');
|
|
||||||
const [maxRestarts, setMaxRestarts] = useState('3');
|
|
||||||
const [autoDisableAfter, setAutoDisableAfter] = useState('5');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !stackName) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
apiFetch(`/auto-heal/policies?stackName=${encodeURIComponent(stackName)}`)
|
|
||||||
.then(res => res.json() as Promise<AutoHealPolicy[]>)
|
|
||||||
.then(data => setPolicies(data))
|
|
||||||
.catch(() => toast.error('Failed to load auto-heal policies.'))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
|
|
||||||
apiFetch(`/stacks/${encodeURIComponent(stackName)}/services`)
|
|
||||||
.then(res => res.json() as Promise<string[]>)
|
|
||||||
.then(names => setServiceOptions(names.map(n => ({ value: n, label: n }))))
|
|
||||||
.catch(() => { /* services list is optional, silently skip */ });
|
|
||||||
}, [open, stackName]);
|
|
||||||
|
|
||||||
const handleToggle = async (id: number, enabled: boolean) => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch(`/auto-heal/policies/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ enabled: enabled ? 1 : 0 }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setPolicies(prev =>
|
|
||||||
prev.map(p => p.id === id ? { ...p, enabled: enabled ? 1 : 0 } : p)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const err = await res.json().catch(() => ({})) as Record<string, unknown>;
|
|
||||||
toast.error((err?.message as string) || (err?.error as string) || 'Failed to update policy.');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[StackAutoHealSheet] Failed to toggle policy:', e);
|
|
||||||
toast.error('Network error. Could not reach the node.');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
setDeleting(true);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch(`/auto-heal/policies/${id}`, { method: 'DELETE' });
|
|
||||||
if (res.ok) {
|
|
||||||
toast.success('Policy deleted.');
|
|
||||||
setPolicies(prev => prev.filter(p => p.id !== id));
|
|
||||||
} else {
|
|
||||||
const err = await res.json().catch(() => ({})) as Record<string, unknown>;
|
|
||||||
toast.error((err?.message as string) || (err?.error as string) || 'Failed to delete policy.');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[StackAutoHealSheet] Failed to delete policy:', e);
|
|
||||||
toast.error('Network error. Could not reach the node.');
|
|
||||||
} finally {
|
|
||||||
setDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddPolicy = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
const body = {
|
|
||||||
stack_name: stackName,
|
|
||||||
service_name: service === '' ? null : service,
|
|
||||||
unhealthy_duration_mins: parseInt(unhealthyFor, 10) || 5,
|
|
||||||
cooldown_mins: parseInt(cooldown, 10) || 5,
|
|
||||||
max_restarts_per_hour: parseInt(maxRestarts, 10) || 3,
|
|
||||||
auto_disable_after_failures: parseInt(autoDisableAfter, 10) || 5,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/auto-heal/policies', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
toast.success('Policy added.');
|
|
||||||
setService('');
|
|
||||||
setUnhealthyFor('5');
|
|
||||||
setCooldown('5');
|
|
||||||
setMaxRestarts('3');
|
|
||||||
setAutoDisableAfter('5');
|
|
||||||
apiFetch(`/auto-heal/policies?stackName=${encodeURIComponent(stackName)}`)
|
|
||||||
.then(res => res.json() as Promise<AutoHealPolicy[]>)
|
|
||||||
.then(data => setPolicies(data))
|
|
||||||
.catch(() => toast.error('Failed to reload policies.'));
|
|
||||||
} else {
|
|
||||||
const err = await res.json().catch(() => ({})) as Record<string, unknown>;
|
|
||||||
toast.error((err?.message as string) || (err?.error as string) || 'Failed to add policy.');
|
|
||||||
console.error('[StackAutoHealSheet] addPolicy failed:', err);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[StackAutoHealSheet] addPolicy threw:', e);
|
|
||||||
toast.error('Network error. Could not reach the node.');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const serviceComboOptions = [
|
|
||||||
{ value: '', label: 'All services' },
|
|
||||||
...serviceOptions,
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PaidGate>
|
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent className="sm:max-w-[440px] flex flex-col">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>Auto-Heal Policies: {stackName}</SheetTitle>
|
|
||||||
<SheetDescription className="sr-only">
|
|
||||||
Configure auto-heal policies to automatically restart unhealthy containers in this stack.
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="mt-4 space-y-5 pr-2">
|
|
||||||
{/* Existing policies */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-sm font-medium">Active Policies</h4>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
|
|
||||||
<span>Loading policies...</span>
|
|
||||||
</div>
|
|
||||||
) : policies.length === 0 ? (
|
|
||||||
<div className="text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg border text-center">
|
|
||||||
No auto-heal policies configured for this stack.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
policies.map(policy => (
|
|
||||||
<PolicyRow
|
|
||||||
key={policy.id}
|
|
||||||
policy={policy}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onToggle={handleToggle}
|
|
||||||
deleting={deleting}
|
|
||||||
saving={saving}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className="border-card-border" />
|
|
||||||
|
|
||||||
{/* Add new policy form */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="text-sm font-medium">Add New Policy</h4>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Service</Label>
|
|
||||||
<Combobox
|
|
||||||
options={serviceComboOptions}
|
|
||||||
value={service}
|
|
||||||
onValueChange={setService}
|
|
||||||
placeholder="All services"
|
|
||||||
searchPlaceholder="Search services..."
|
|
||||||
emptyText="No services found."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="unhealthy-duration">Unhealthy for (minutes)</Label>
|
|
||||||
<Input
|
|
||||||
id="unhealthy-duration"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={unhealthyFor}
|
|
||||||
onChange={clampNonNegative(setUnhealthyFor)}
|
|
||||||
placeholder="5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cooldown">Cooldown (minutes)</Label>
|
|
||||||
<Input
|
|
||||||
id="cooldown"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={cooldown}
|
|
||||||
onChange={clampNonNegative(setCooldown)}
|
|
||||||
placeholder="5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="max-restarts">Max restarts / hr</Label>
|
|
||||||
<Input
|
|
||||||
id="max-restarts"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={maxRestarts}
|
|
||||||
onChange={clampNonNegative(setMaxRestarts)}
|
|
||||||
placeholder="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="auto-disable">Auto-disable after (failures)</Label>
|
|
||||||
<Input
|
|
||||||
id="auto-disable"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={autoDisableAfter}
|
|
||||||
onChange={clampNonNegative(setAutoDisableAfter)}
|
|
||||||
placeholder="5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="w-full mt-2"
|
|
||||||
onClick={handleAddPolicy}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" strokeWidth={1.5} />Saving...</>
|
|
||||||
) : (
|
|
||||||
'Add Policy'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</PaidGate>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
ShieldCheck,
|
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -27,11 +26,8 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Check,
|
Check,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
KeyRound,
|
|
||||||
FileWarning,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -48,6 +44,7 @@ import { toast } from '@/components/ui/toast-store';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { cveUrl } from '@/lib/cveUrl';
|
import { cveUrl } from '@/lib/cveUrl';
|
||||||
import { SEVERITY_ROW_TINT } from '@/lib/severityStyles';
|
import { SEVERITY_ROW_TINT } from '@/lib/severityStyles';
|
||||||
|
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||||
import type {
|
import type {
|
||||||
VulnerabilityScan,
|
VulnerabilityScan,
|
||||||
VulnerabilityDetail,
|
VulnerabilityDetail,
|
||||||
@@ -371,40 +368,71 @@ export function VulnerabilityScanSheet({
|
|||||||
}
|
}
|
||||||
}, [scan]);
|
}, [scan]);
|
||||||
|
|
||||||
return (
|
const meta = scan
|
||||||
<Sheet open={scanId != null} onOpenChange={(open) => !open && onClose()}>
|
? `${scan.total_vulnerabilities} vulns · ${scan.fixable_count} fixable · ${scan.triggered_by}`
|
||||||
<SheetContent className="sm:max-w-2xl flex flex-col p-0">
|
: (loading ? 'Loading…' : 'No scan');
|
||||||
<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">
|
|
||||||
Vulnerability scan · {scan?.triggered_by ?? '-'}
|
|
||||||
</div>
|
|
||||||
<SheetTitle className="flex items-center gap-2 font-display italic text-2xl">
|
|
||||||
<ShieldCheck className="w-5 h-5 text-muted-foreground" strokeWidth={1.5} />
|
|
||||||
<span className="font-mono text-base truncate not-italic">
|
|
||||||
{scan?.image_ref ?? 'Loading...'}
|
|
||||||
</span>
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetDescription className="sr-only">
|
|
||||||
{scan
|
|
||||||
? `Vulnerability scan results for ${scan.image_ref}: ${scan.total_vulnerabilities} total findings.`
|
|
||||||
: 'Vulnerability scan details.'}
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
|
const footerContext = scan
|
||||||
|
? `Scanned ${formatTimeAgo(new Date(scan.scanned_at).getTime())}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const secondaryActions = scan ? [
|
||||||
|
...(canCompare ? [{
|
||||||
|
label: 'Compare',
|
||||||
|
icon: compareLoading ? Loader2 : GitCompare,
|
||||||
|
onClick: openCompareMenu,
|
||||||
|
disabled: compareLoading,
|
||||||
|
}] : []),
|
||||||
|
...(details.length > 0 ? [{
|
||||||
|
label: 'CSV',
|
||||||
|
icon: Download,
|
||||||
|
onClick: exportCsv,
|
||||||
|
}] : []),
|
||||||
|
...(canGenerateSbom && scan.status === 'completed' ? [{
|
||||||
|
label: 'SARIF',
|
||||||
|
icon: Download,
|
||||||
|
onClick: () => { void exportSarif(); },
|
||||||
|
}] : []),
|
||||||
|
] : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SystemSheet
|
||||||
|
open={scanId != null}
|
||||||
|
onOpenChange={(open) => !open && onClose()}
|
||||||
|
crumb={['Security', 'Scans', scan?.image_ref ?? '…']}
|
||||||
|
name={scan?.image_ref ?? 'Loading…'}
|
||||||
|
meta={meta}
|
||||||
|
primaryAction={onRescan && scan ? {
|
||||||
|
label: 'Re-scan',
|
||||||
|
icon: RefreshCw,
|
||||||
|
onClick: () => onRescan(scan.image_ref),
|
||||||
|
disabled: scan.status === 'in_progress',
|
||||||
|
} : undefined}
|
||||||
|
secondaryActions={secondaryActions}
|
||||||
|
tabs={scan ? [
|
||||||
|
{ id: 'vulns', label: 'Vulnerabilities', count: totalDetails },
|
||||||
|
{ id: 'secrets', label: 'Secrets', count: scan.secret_count ?? secrets.length },
|
||||||
|
{ id: 'misconfigs', label: 'Misconfigs', count: scan.misconfig_count ?? misconfigs.length },
|
||||||
|
] : undefined}
|
||||||
|
activeTab={tab}
|
||||||
|
onTabChange={(id) => setTab(id as FindingTab)}
|
||||||
|
footerContext={footerContext}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
{loading && !scan && (
|
{loading && !scan && (
|
||||||
<div className="flex items-center justify-center flex-1">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{scan && (
|
{scan && (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<>
|
||||||
{/* Summary stats */}
|
<SheetSection title="Summary">
|
||||||
<div className="px-6 py-4 border-b space-y-3">
|
|
||||||
{scan.policy_evaluation?.violated && (
|
{scan.policy_evaluation?.violated && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="relative rounded border border-destructive/40 bg-destructive/10 px-3 py-2 pl-4 shadow-card-bevel"
|
className="relative rounded border border-destructive/40 bg-destructive/10 px-3 py-2 pl-4 mb-3 shadow-card-bevel"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -427,7 +455,7 @@ export function VulnerabilityScanSheet({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
{scan.critical_count > 0 && (
|
{scan.critical_count > 0 && (
|
||||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.CRITICAL)}>
|
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.CRITICAL)}>
|
||||||
{scan.critical_count} CRITICAL
|
{scan.critical_count} CRITICAL
|
||||||
@@ -476,19 +504,8 @@ export function VulnerabilityScanSheet({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
{canGenerateSbom && (
|
||||||
{onRescan && (
|
<div className="flex flex-wrap items-center gap-2 mt-3">
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onRescan(scan.image_ref)}
|
|
||||||
disabled={scan.status === 'in_progress'}
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
|
||||||
Re-scan
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canGenerateSbom && (
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm" disabled={downloadingSbom}>
|
<Button variant="outline" size="sm" disabled={downloadingSbom}>
|
||||||
@@ -509,43 +526,11 @@ export function VulnerabilityScanSheet({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={exportCsv} disabled={details.length === 0}>
|
)}
|
||||||
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
|
||||||
CSV
|
|
||||||
</Button>
|
|
||||||
{canGenerateSbom && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={exportSarif}
|
|
||||||
disabled={scan.status !== 'completed'}
|
|
||||||
title="Export findings as SARIF 2.1.0 for GitHub code scanning"
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
|
||||||
SARIF
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canCompare && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={openCompareMenu}
|
|
||||||
disabled={compareLoading}
|
|
||||||
title="Compare this scan to a previous one for the same image"
|
|
||||||
>
|
|
||||||
{compareLoading ? (
|
|
||||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
|
||||||
) : (
|
|
||||||
<GitCompare className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
|
||||||
)}
|
|
||||||
Compare
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{compareOpen && canCompare && (
|
{compareOpen && canCompare && (
|
||||||
<div className="pt-2 space-y-2">
|
<div className="pt-3 space-y-2">
|
||||||
{compareOptions.length === 0 && !compareLoading ? (
|
{compareOptions.length === 0 && !compareLoading ? (
|
||||||
<div className="rounded border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
<div className="rounded border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||||
No other completed scans for this image yet. Run a second scan to enable comparison.
|
No other completed scans for this image yet. Run a second scan to enable comparison.
|
||||||
@@ -569,44 +554,11 @@ export function VulnerabilityScanSheet({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</SheetSection>
|
||||||
|
|
||||||
<Tabs
|
{tab === 'vulns' && (
|
||||||
value={tab}
|
<SheetSection title={`Vulnerabilities · ${totalDetails}`}>
|
||||||
onValueChange={(v) => setTab(v as FindingTab)}
|
<div className="flex items-center gap-1 flex-wrap mb-3">
|
||||||
className="flex flex-col flex-1 min-h-0"
|
|
||||||
>
|
|
||||||
<div className="px-6 pt-3">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="vulns" className="gap-1.5">
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
||||||
Vulnerabilities
|
|
||||||
<span className="font-mono tabular-nums text-stat-subtitle">
|
|
||||||
({totalDetails})
|
|
||||||
</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="secrets" className="gap-1.5">
|
|
||||||
<KeyRound className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
||||||
Secrets
|
|
||||||
<span className="font-mono tabular-nums text-stat-subtitle">
|
|
||||||
({scan.secret_count ?? secrets.length})
|
|
||||||
</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="misconfigs" className="gap-1.5">
|
|
||||||
<FileWarning className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
||||||
Misconfigs
|
|
||||||
<span className="font-mono tabular-nums text-stat-subtitle">
|
|
||||||
({scan.misconfig_count ?? misconfigs.length})
|
|
||||||
</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent
|
|
||||||
value="vulns"
|
|
||||||
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
|
|
||||||
>
|
|
||||||
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
|
|
||||||
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => (
|
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => (
|
||||||
<Button
|
<Button
|
||||||
key={s}
|
key={s}
|
||||||
@@ -649,35 +601,34 @@ export function VulnerabilityScanSheet({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totalDetails > details.length && (
|
{totalDetails > details.length && (
|
||||||
<div className="px-6 pt-2 text-xs text-stat-subtitle font-mono">
|
<div className="text-xs text-stat-subtitle font-mono mb-2">
|
||||||
Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
|
Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea block className="max-h-[60vh]">
|
||||||
<div className="px-6 py-3">
|
{pageItems.length === 0 ? (
|
||||||
{pageItems.length === 0 ? (
|
<div className="text-center text-sm text-muted-foreground py-12">
|
||||||
<div className="text-center text-sm text-muted-foreground py-12">
|
{details.length === 0
|
||||||
{details.length === 0
|
? 'No vulnerabilities found.'
|
||||||
? 'No vulnerabilities found.'
|
: 'No vulnerabilities match the selected filter.'}
|
||||||
: 'No vulnerabilities match the selected filter.'}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<Table>
|
||||||
<Table>
|
<TableHeader>
|
||||||
<TableHeader>
|
<TableRow>
|
||||||
<TableRow>
|
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
|
||||||
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
|
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
|
||||||
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
|
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
||||||
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Installed</TableHead>
|
||||||
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Installed</TableHead>
|
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fixed</TableHead>
|
||||||
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fixed</TableHead>
|
{canManageSuppressions && <TableHead className="w-[40px]" />}
|
||||||
{canManageSuppressions && <TableHead className="w-[40px]" />}
|
</TableRow>
|
||||||
</TableRow>
|
</TableHeader>
|
||||||
</TableHeader>
|
<TableBody>
|
||||||
<TableBody>
|
{pageItems.map((d) => {
|
||||||
{pageItems.map((d) => {
|
const href = cveUrl(d.vulnerability_id, d.primary_url);
|
||||||
const href = cveUrl(d.vulnerability_id, d.primary_url);
|
return (
|
||||||
return (
|
|
||||||
<TableRow
|
<TableRow
|
||||||
key={d.id}
|
key={d.id}
|
||||||
className={cn(SEVERITY_ROW_TINT[d.severity], d.suppressed && 'opacity-60')}
|
className={cn(SEVERITY_ROW_TINT[d.severity], d.suppressed && 'opacity-60')}
|
||||||
@@ -742,21 +693,19 @@ export function VulnerabilityScanSheet({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</SheetSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent
|
{tab === 'secrets' && (
|
||||||
value="secrets"
|
<SheetSection title={`Secrets · ${scan.secret_count ?? secrets.length}`}>
|
||||||
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
|
|
||||||
>
|
|
||||||
{secretsNeedsPagination && (
|
{secretsNeedsPagination && (
|
||||||
<div className="px-6 pt-3 flex items-center gap-1">
|
<div className="flex items-center gap-1 mb-3">
|
||||||
<div className="flex items-center gap-1 ml-auto">
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -784,70 +733,67 @@ export function VulnerabilityScanSheet({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea block className="max-h-[60vh]">
|
||||||
<div className="px-6 py-3">
|
{secrets.length === 0 ? (
|
||||||
{secrets.length === 0 ? (
|
<div className="text-center text-sm text-muted-foreground py-12">
|
||||||
<div className="text-center text-sm text-muted-foreground py-12">
|
No secrets detected.
|
||||||
No secrets detected.
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<Table>
|
||||||
<Table>
|
<TableHeader>
|
||||||
<TableHeader>
|
<TableRow>
|
||||||
<TableRow>
|
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
||||||
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
<TableHead className="w-[160px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Rule</TableHead>
|
||||||
<TableHead className="w-[160px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Rule</TableHead>
|
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
|
||||||
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
|
<TableHead className="w-[260px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
|
||||||
<TableHead className="w-[260px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
|
</TableRow>
|
||||||
</TableRow>
|
</TableHeader>
|
||||||
</TableHeader>
|
<TableBody>
|
||||||
<TableBody>
|
{secretsPageItems.map((s) => (
|
||||||
{secretsPageItems.map((s) => (
|
<TableRow key={s.id} className={SEVERITY_ROW_TINT[s.severity]}>
|
||||||
<TableRow key={s.id} className={SEVERITY_ROW_TINT[s.severity]}>
|
<TableCell>
|
||||||
<TableCell>
|
<SeverityChip severity={s.severity} />
|
||||||
<SeverityChip severity={s.severity} />
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="font-mono text-xs truncate max-w-[160px]" title={s.rule_id}>
|
||||||
<TableCell className="font-mono text-xs truncate max-w-[160px]" title={s.rule_id}>
|
{s.rule_id}
|
||||||
{s.rule_id}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-xs">
|
||||||
<TableCell className="text-xs">
|
<div className="truncate max-w-[320px]" title={s.title ?? undefined}>
|
||||||
<div className="truncate max-w-[320px]" title={s.title ?? undefined}>
|
{s.title || <span className="text-muted-foreground">-</span>}
|
||||||
{s.title || <span className="text-muted-foreground">-</span>}
|
</div>
|
||||||
|
{s.match_excerpt && (
|
||||||
|
<div
|
||||||
|
className="font-mono text-[11px] text-muted-foreground truncate max-w-[320px]"
|
||||||
|
title={s.match_excerpt}
|
||||||
|
>
|
||||||
|
{s.match_excerpt}
|
||||||
</div>
|
</div>
|
||||||
{s.match_excerpt && (
|
)}
|
||||||
<div
|
</TableCell>
|
||||||
className="font-mono text-[11px] text-muted-foreground truncate max-w-[320px]"
|
<TableCell className="font-mono text-xs truncate max-w-[260px]" title={s.target}>
|
||||||
title={s.match_excerpt}
|
{s.target}
|
||||||
>
|
{s.start_line != null && (
|
||||||
{s.match_excerpt}
|
<span className="text-muted-foreground">
|
||||||
</div>
|
:{s.start_line}
|
||||||
)}
|
{s.end_line != null && s.end_line !== s.start_line
|
||||||
</TableCell>
|
? `-${s.end_line}`
|
||||||
<TableCell className="font-mono text-xs truncate max-w-[260px]" title={s.target}>
|
: ''}
|
||||||
{s.target}
|
</span>
|
||||||
{s.start_line != null && (
|
)}
|
||||||
<span className="text-muted-foreground">
|
</TableCell>
|
||||||
:{s.start_line}
|
</TableRow>
|
||||||
{s.end_line != null && s.end_line !== s.start_line
|
))}
|
||||||
? `-${s.end_line}`
|
</TableBody>
|
||||||
: ''}
|
</Table>
|
||||||
</span>
|
)}
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</SheetSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent
|
{tab === 'misconfigs' && (
|
||||||
value="misconfigs"
|
<SheetSection title={`Misconfigs · ${scan.misconfig_count ?? misconfigs.length}`}>
|
||||||
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
|
|
||||||
>
|
|
||||||
{misconfigsNeedsPagination && (
|
{misconfigsNeedsPagination && (
|
||||||
<div className="px-6 pt-3 flex items-center gap-1">
|
<div className="flex items-center gap-1 mb-3">
|
||||||
<div className="flex items-center gap-1 ml-auto">
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -877,78 +823,77 @@ export function VulnerabilityScanSheet({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea block className="max-h-[60vh]">
|
||||||
<div className="px-6 py-3">
|
{misconfigs.length === 0 ? (
|
||||||
{misconfigs.length === 0 ? (
|
<div className="text-center text-sm text-muted-foreground py-12">
|
||||||
<div className="text-center text-sm text-muted-foreground py-12">
|
No misconfigurations detected.
|
||||||
No misconfigurations detected.
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<Table>
|
||||||
<Table>
|
<TableHeader>
|
||||||
<TableHeader>
|
<TableRow>
|
||||||
<TableRow>
|
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
||||||
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
<TableHead className="w-[140px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Check</TableHead>
|
||||||
<TableHead className="w-[140px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Check</TableHead>
|
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
|
||||||
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
|
<TableHead className="w-[200px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
|
||||||
<TableHead className="w-[200px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
|
<TableHead className="w-[220px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fix</TableHead>
|
||||||
<TableHead className="w-[220px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fix</TableHead>
|
</TableRow>
|
||||||
</TableRow>
|
</TableHeader>
|
||||||
</TableHeader>
|
<TableBody>
|
||||||
<TableBody>
|
{misconfigsPageItems.map((m) => (
|
||||||
{misconfigsPageItems.map((m) => (
|
<TableRow key={m.id} className={SEVERITY_ROW_TINT[m.severity]}>
|
||||||
<TableRow key={m.id} className={SEVERITY_ROW_TINT[m.severity]}>
|
<TableCell>
|
||||||
<TableCell>
|
<SeverityChip severity={m.severity} />
|
||||||
<SeverityChip severity={m.severity} />
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell
|
||||||
<TableCell
|
className="font-mono text-xs truncate max-w-[140px]"
|
||||||
className="font-mono text-xs truncate max-w-[140px]"
|
title={m.check_id ?? m.rule_id}
|
||||||
title={m.check_id ?? m.rule_id}
|
>
|
||||||
>
|
{m.check_id || m.rule_id}
|
||||||
{m.check_id || m.rule_id}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-xs">
|
||||||
<TableCell className="text-xs">
|
<div className="truncate max-w-[320px]" title={m.title ?? undefined}>
|
||||||
<div className="truncate max-w-[320px]" title={m.title ?? undefined}>
|
{m.primary_url ? (
|
||||||
{m.primary_url ? (
|
<a
|
||||||
<a
|
href={m.primary_url}
|
||||||
href={m.primary_url}
|
target="_blank"
|
||||||
target="_blank"
|
rel="noreferrer noopener"
|
||||||
rel="noreferrer noopener"
|
className="inline-flex items-center gap-1 hover:underline"
|
||||||
className="inline-flex items-center gap-1 hover:underline"
|
|
||||||
>
|
|
||||||
{m.title || m.rule_id}
|
|
||||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
m.title || m.rule_id
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{m.message && (
|
|
||||||
<div
|
|
||||||
className="text-[11px] text-muted-foreground truncate max-w-[320px]"
|
|
||||||
title={m.message}
|
|
||||||
>
|
>
|
||||||
{m.message}
|
{m.title || m.rule_id}
|
||||||
</div>
|
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
m.title || m.rule_id
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="font-mono text-xs truncate max-w-[200px]" title={m.target}>
|
{m.message && (
|
||||||
{m.target}
|
<div
|
||||||
</TableCell>
|
className="text-[11px] text-muted-foreground truncate max-w-[320px]"
|
||||||
<TableCell className="text-xs truncate max-w-[220px]" title={m.resolution ?? undefined}>
|
title={m.message}
|
||||||
{m.resolution || <span className="text-muted-foreground">-</span>}
|
>
|
||||||
</TableCell>
|
{m.message}
|
||||||
</TableRow>
|
</div>
|
||||||
))}
|
)}
|
||||||
</TableBody>
|
</TableCell>
|
||||||
</Table>
|
<TableCell className="font-mono text-xs truncate max-w-[200px]" title={m.target}>
|
||||||
)}
|
{m.target}
|
||||||
</div>
|
</TableCell>
|
||||||
|
<TableCell className="text-xs truncate max-w-[220px]" title={m.resolution ?? undefined}>
|
||||||
|
{m.resolution || <span className="text-muted-foreground">-</span>}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</SheetSection>
|
||||||
</Tabs>
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</SheetContent>
|
</SystemSheet>
|
||||||
|
|
||||||
<ScanComparisonSheet
|
<ScanComparisonSheet
|
||||||
baselineScanId={compareBaselineId}
|
baselineScanId={compareBaselineId}
|
||||||
currentScanId={compareBaselineId != null ? scanId : null}
|
currentScanId={compareBaselineId != null ? scanId : null}
|
||||||
@@ -1031,7 +976,7 @@ export function VulnerabilityScanSheet({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Sheet>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user