feat: node-scoped Networking operator page (#1603)

* feat: add node-scoped Networking operator page

Adds a Networking view with overview, topology, inventory, and findings.

Shared aggregate reads back the page; Resources keeps prune and redirects here.

Includes fail-closed network delete guards, operator docs, and /nodes/:slug/networking routing.

* fix: rename unused variable n to _n to satisfy no-unused-vars lint

* fix: keep top bar search clickable when nav grows

* feat: complete Compose-first Networking Phase 2 operator assistant

* fix: move networking action visibility helper out of component module

* feat(networking): complete Compose-first Networking operator page

Finish the node-scoped Networking page (Overview, Networks, Topology,
Findings) with design-system parity and correct finding semantics.

- Rebuild detail sheets on SystemSheet/SheetSection; align the tab band,
  masthead, and mobile tone with Fleet and Security.
- Encode the host-mode and exposure severity matrix; fix collision counts
  so intentional shared externals are not flagged; add one typed drift
  predicate shared by inventory, topology, badges, and overview counts.
- Preserve per-container attachments and IPs on topology node clicks;
  drawer-only click with an explicit logs action; ownership and boolean
  filters; bound large graphs before layout.
- Aggregate cached Compose Doctor findings into the Findings tab with
  honest source labels, structural merge and dedupe, staleness
  reconciliation, and a shared exposure-context helper both engines use.
- Networks tab: privacy-safe service search, precise ownership counts,
  schema v3 with version-2 adapters on every endpoint, pre-confirm delete
  reasons, and the shared sortable table with an internal scroll region.
- Interop: Fleet node-card networking signal with pending-intent
  navigation, stack-to-node backlink, and Dossier/Drift deep links.
- Enrich sanitized inspect with an allowlisted connected-container list;
  fetch topology once and filter client-side.
- Docs and tests across every new finding kind, adapter, and flow.

* fix(networking): correct drift count, exposure fail-soft, and inspect crash paths

Address code-review findings on the Networking page implementation:
- Fix the Overview drift count to use the shared drift-kind predicate instead
  of a hardcoded list that omitted external-network-missing.
- Gate Compose Doctor's unclassified-exposure and reverse-proxy-undocumented
  rules on exposure-context availability, so a DB read failure no longer
  fabricates findings (mirrors the live engine's existing fail-soft behavior).
- Guard the per-stack exposure-intent read in topology aggregation so a
  transient DB failure degrades to unknown intent instead of failing the
  whole response.
- Harden the network detail drawer against a partial inspect payload from an
  older remote node, and log the real error instead of a bare catch.
- Remove now-duplicated severity-rank and drift-kind helpers in favor of the
  shared modules; drop dead backend-only exports; widen the frontend schema
  version type to a plain number instead of casting past a literal type.
- Add coverage for the delete-guard precedence, the full host-mode severity
  matrix, the schema-2 compatibility adapter, and the sanitized connected-
  container allowlist; tighten two tests that were not exercising the
  behavior they claimed to.

* fix: add missing onOpenNodeNetworking prop to FleetView experimental test

The added required prop on FleetViewProps broke the merge-build when the
test file (on main but not on this branch) was compiled against the
updated FleetView interface.
This commit is contained in:
Anso
2026-07-14 20:18:10 -04:00
committed by GitHub
parent 8096799e49
commit 8980910153
80 changed files with 5686 additions and 667 deletions
+5 -251
View File
@@ -14,34 +14,25 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Loader2, History, FolderOpen, Search } from 'lucide-react';
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Eye, Loader2, History, FolderOpen, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { SeverityBadge } from '@/components/ui/SeverityBadge';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from './NodeManager';
import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events';
import type { ScanSummary } from '@/types/security';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { CapabilityGate } from './CapabilityGate';
import LazyBoundary from './LazyBoundary';
import { formatBytes } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { SENCHO_OPEN_LOGS_EVENT, SENCHO_OPEN_STACK_EVENT } from '@/lib/events';
import type { SenchoOpenStackDetail } from '@/lib/events';
import type { SenchoOpenLogsDetail } from '@/lib/events';
import { lazy, Suspense } from 'react';
import { ReclaimHero } from './resources/ReclaimHero';
import { FootprintTreemap } from './resources/FootprintTreemap';
import { ImageDetailsSheet } from './resources/ImageDetailsSheet';
import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet';
import { VolumeNameLabel } from './resources/VolumeNameLabel';
import { CreateNetworkDialog } from './resources/CreateNetworkDialog';
import { useTableSort } from '@/hooks/useTableSort';
import { SortableTableHead } from '@/components/ui/sortable-table';
import { NetworkDetailSheet, type NetworkInspectData } from './resources/NetworkDetailSheet';
const NetworkTopologyView = lazy(() => import('./NetworkTopologyView'));
// ── Interfaces ─────────────────────────────────────────────────────────────────
@@ -98,8 +89,6 @@ interface UnmanagedContainer {
Image: string;
}
// NetworkInspectData is re-exported from ./resources/NetworkDetailSheet
type ResourceFilter = 'all' | 'managed' | 'unmanaged';
type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes';
type PruneScope = 'managed' | 'all';
@@ -385,11 +374,6 @@ const VOLUME_COMPARATORS: Record<'name' | 'driver', (a: DockerVolume, b: DockerV
name: (a, b) => a.Name.localeCompare(b.Name),
driver: (a, b) => a.Driver.localeCompare(b.Driver),
};
const NETWORK_COMPARATORS: Record<'name' | 'driver' | 'scope', (a: DockerNetwork, b: DockerNetwork) => number> = {
name: (a, b) => a.Name.localeCompare(b.Name),
driver: (a, b) => a.Driver.localeCompare(b.Driver),
scope: (a, b) => a.Scope.localeCompare(b.Scope),
};
// ── Main Component ─────────────────────────────────────────────────────────────
@@ -400,10 +384,9 @@ interface ResourcesViewProps {
export default function ResourcesView({ headerActions }: ResourcesViewProps = {}) {
const isMobile = useIsMobile();
const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'networks' | 'unmanaged'>('images');
const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged'>('images');
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list');
const [usage, setUsage] = useState<UsageData | null>(null);
const [images, setImages] = useState<DockerImage[]>([]);
const [volumes, setVolumes] = useState<DockerVolume[]>([]);
@@ -416,23 +399,18 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
// Filter state
const [imageFilter, setImageFilter] = useState<ResourceFilter>('all');
const [volumeFilter, setVolumeFilter] = useState<ResourceFilter>('all');
const [networkFilter, setNetworkFilter] = useState<ResourceFilter>('all');
// Search state
const [imageSearch, setImageSearch] = useState('');
const [volumeSearch, setVolumeSearch] = useState('');
const [networkSearch, setNetworkSearch] = useState('');
// Collapsible search: icon-only until clicked, stays open while query is active.
const [imageSearchExpanded, setImageSearchExpanded] = useState(false);
const [volumeSearchExpanded, setVolumeSearchExpanded] = useState(false);
const [networkSearchExpanded, setNetworkSearchExpanded] = useState(false);
const imageSearchRef = useRef<HTMLInputElement>(null);
const volumeSearchRef = useRef<HTMLInputElement>(null);
const networkSearchRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (imageSearchExpanded) imageSearchRef.current?.focus(); }, [imageSearchExpanded]);
useEffect(() => { if (volumeSearchExpanded) volumeSearchRef.current?.focus(); }, [volumeSearchExpanded]);
useEffect(() => { if (networkSearchExpanded) networkSearchRef.current?.focus(); }, [networkSearchExpanded]);
// Modal states
const [confirmPrune, setConfirmPrune] = useState<{ target: PruneTarget; scope: PruneScope } | null>(null);
@@ -448,10 +426,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
const [reclaimHeroEnabled, setReclaimHeroEnabled] = useState(true);
const [heroDismissedBytes, setHeroDismissedBytes] = useState<number | null>(null);
// Network create/inspect state
const [showCreateNetwork, setShowCreateNetwork] = useState(false);
const [inspectNetwork, setInspectNetwork] = useState<NetworkInspectData | null>(null);
const [inspectLoadingId, setInspectLoadingId] = useState<string | null>(null);
// Classified image selection is node-bound so a node switch cannot leave
// the previous node's usedByStacks visible beside a new node's inspect.
const [inspectImage, setInspectImage] = useState<(DockerImage & { nodeId: string | number }) | null>(null);
@@ -774,22 +748,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
}
};
const handleInspectNetwork = async (id: string) => {
setInspectLoadingId(id);
try {
const res = await apiFetch(`/system/networks/${id}`);
if (!res.ok) throw new Error('Failed to inspect network');
const data = await res.json();
setInspectNetwork(data);
} catch (error) {
const err = error as Record<string, unknown>;
toast.error(String(err?.message || err?.error || 'Something went wrong.'));
} finally {
setInspectLoadingId(null);
}
};
// Derived filtered lists
const filteredImages = images.filter(img =>
(imageFilter === 'managed' ? img.managedStatus === 'managed' :
imageFilter === 'unmanaged' ? img.managedStatus !== 'managed' : true) &&
@@ -800,16 +758,9 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
volumeFilter === 'unmanaged' ? vol.managedStatus !== 'managed' : true) &&
(volumeSearch === '' || vol.Name.toLowerCase().includes(volumeSearch.toLowerCase()))
);
const filteredNetworks = networks.filter(net =>
(networkFilter === 'managed' ? net.managedStatus === 'managed' :
networkFilter === 'unmanaged' ? net.managedStatus !== 'managed' : true) &&
(networkSearch === '' || net.Name.toLowerCase().includes(networkSearch.toLowerCase()))
);
// Sortable tables (standard sort behavior across the resource tables).
const imageSort = useTableSort(filteredImages, IMAGE_COMPARATORS, 'repo');
const volumeSort = useTableSort(filteredVolumes, VOLUME_COMPARATORS, 'name');
const networkSort = useTableSort(filteredNetworks, NETWORK_COMPARATORS, 'name');
const handleFootprintFilter = (filter: ResourceFilter) => {
setImageFilter(filter);
@@ -991,7 +942,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
tabs={[
{ value: 'images', label: 'Images', count: images.length },
{ value: 'volumes', label: 'Volumes', count: volumes.length },
{ value: 'networks', label: 'Networks', count: networks.length },
{ value: 'unmanaged', label: 'Unmanaged', count: totalOrphansCount },
]}
/>
@@ -999,12 +949,12 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
<div className="flex items-center gap-3 mb-4 flex-wrap rounded-lg border border-card-border bg-card/40 px-2.5 py-1.5">
<TabsList className="border-transparent bg-transparent max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
{(['images', 'volumes', 'networks'] as const).map(tab => (
{(['images', 'volumes'] as const).map(tab => (
<TabsHighlightItem key={tab} value={tab}>
<TabsTrigger value={tab}>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
<span className="ml-1.5 text-[10px] text-stat-subtitle tabular-nums">
{tab === 'images' ? images.length : tab === 'volumes' ? volumes.length : networks.length}
{tab === 'images' ? images.length : volumes.length}
</span>
</TabsTrigger>
</TabsHighlightItem>
@@ -1331,188 +1281,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</div>
</TabsContent>
{/* Networks */}
<TabsContent value="networks" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
<div className="flex flex-wrap items-center gap-2 mb-4">
{networkViewMode === 'list' ? (
<>
{networkSearch !== '' || networkSearchExpanded ? (
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={networkSearchRef}
placeholder="Search networks..."
value={networkSearch}
onChange={(e) => setNetworkSearch(e.target.value)}
onBlur={() => { if (networkSearch === '') setNetworkSearchExpanded(false); }}
className="pl-9 h-9"
/>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setNetworkSearchExpanded(true)} aria-label="Search networks">
<Search className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Search networks</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<FilterToggle
value={networkFilter}
onChange={setNetworkFilter}
counts={{
all: networks.length,
managed: networks.filter(n => n.managedStatus === 'managed').length,
unmanaged: networks.filter(n => n.managedStatus !== 'managed').length,
}}
/>
</>
) : (
<span aria-hidden="true" />
)}
<div className="flex-1" />
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
<button
onClick={() => setNetworkViewMode('list')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200',
networkViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
List
</button>
<button
onClick={() => setNetworkViewMode('topology')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200 flex items-center gap-1',
networkViewMode === 'topology' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
Topology
</button>
</div>
{isAdmin && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={() => setShowCreateNetwork(true)}
>
<Plus className="w-3.5 h-3.5" strokeWidth={1.5} />
Create Network
</Button>
)}
</div>
</div>
{networkViewMode === 'topology' ? (
<div className="p-4">
<CapabilityGate capability="network-topology" featureName="Network Topology">
<LazyBoundary>
<Suspense fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
<span className="text-sm">Loading topology...</span>
</div>
}>
<NetworkTopologyView
key={activeNode?.id}
onContainerClick={(id, name) => {
window.dispatchEvent(new CustomEvent<SenchoOpenLogsDetail>(SENCHO_OPEN_LOGS_EVENT, {
detail: { containerId: id, containerName: name },
}));
}}
/>
</Suspense>
</LazyBoundary>
</CapabilityGate>
</div>
) : (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<ScrollArea className="max-h-[62vh]">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[120px] text-[11px]">ID</TableHead>
<SortableTableHead label="Name" columnKey="name" activeKey={networkSort.sortKey} dir={networkSort.sortDir} onSort={networkSort.toggleSort} />
<SortableTableHead label="Driver" columnKey="driver" activeKey={networkSort.sortKey} dir={networkSort.sortDir} onSort={networkSort.toggleSort} />
<SortableTableHead label="Scope" columnKey="scope" activeKey={networkSort.sortKey} dir={networkSort.sortDir} onSort={networkSort.toggleSort} />
<TableHead className="text-[11px]">Status</TableHead>
<TableHead className="text-right text-[11px]">Actions</TableHead>
</TableRow>
</TableHeader>
{isLoading ? <TableSkeleton cols={6} /> : (
<TableBody>
{networkSort.sorted.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center py-8 text-muted-foreground text-sm">No networks found.</TableCell></TableRow>
) : networkSort.sorted.map((net, i) => (
<TableRow
key={net.Id}
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
style={{ animationDelay: `${Math.min(i * 20, 200)}ms` }}
>
<TableCell className="font-mono text-xs text-muted-foreground">{net.Id.substring(0, 12)}</TableCell>
<TableCell className="font-medium max-w-[200px] truncate">{net.Name}</TableCell>
<TableCell className="text-xs">{net.Driver}</TableCell>
<TableCell><Badge variant="outline" className="text-[10px] h-5">{net.Scope}</Badge></TableCell>
<TableCell>
<div className="flex items-center gap-1.5 flex-wrap">
<ManagedBadge
status={net.managedStatus}
managedBy={net.managedBy}
onOpenStack={activeNode ? (stack) => window.dispatchEvent(
new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }),
) : undefined}
/>
{net.isSencho && <SenchoBadge />}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:text-foreground transition-colors"
disabled={inspectLoadingId !== null}
onClick={() => handleInspectNetwork(net.Id)}
>
{inspectLoadingId === net.Id ? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} /> : <Eye className="w-3.5 h-3.5" strokeWidth={1.5} />}
</Button>
{isAdmin && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={net.isSencho ? 'cursor-not-allowed' : undefined}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
disabled={net.managedStatus === 'system' || net.isSencho}
onClick={() => setConfirmDelete({ type: 'networks', id: net.Id, name: net.Name })}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</span>
</TooltipTrigger>
{net.isSencho && <TooltipContent>Protected · running Sencho instance</TooltipContent>}
</Tooltip>
</TooltipProvider>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
)}
</Table>
</ScrollArea>
</div>
)}
</TabsContent>
{/* Unmanaged Containers */}
<TabsContent value="unmanaged" className="m-0 border-0 p-0 h-full flex flex-col animate-in fade-in-0 duration-200">
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
@@ -1714,13 +1482,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</p>
</ConfirmModal>
{/* Create Network Modal */}
<CreateNetworkDialog
open={showCreateNetwork}
onOpenChange={setShowCreateNetwork}
onCreated={fetchAllData}
/>
{/* Image Details Sheet */}
<ImageDetailsSheet
image={inspectImage && activeNode && inspectImage.nodeId === activeNode.id ? inspectImage : null}
@@ -1733,13 +1494,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
{/* Volume Browser Sheet */}
<VolumeBrowserSheet volumeName={browseVolume} onClose={() => setBrowseVolume(null)} />
{/* Network detail sheet */}
<NetworkDetailSheet
network={inspectNetwork}
onClose={() => setInspectNetwork(null)}
/>
<VulnerabilityScanSheet
scanId={inspectScanId}
onClose={() => setInspectScanId(null)}