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
+57 -2
View File
@@ -72,6 +72,7 @@ const AutoUpdateReadinessView = lazy(() => import('./AutoUpdateReadinessView'));
const AppStoreView = lazy(() => import('./AppStoreView').then(m => ({ default: m.AppStoreView })));
const AuditLogView = lazy(() => import('./AuditLogView').then(m => ({ default: m.AuditLogView })));
const ResourcesView = lazy(() => import('./ResourcesView'));
const NetworkingView = lazy(() => import('./networking/NetworkingView').then(m => ({ default: m.NetworkingView })));
const GlobalObservabilityView = lazy(() => import('./GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView })));
export default function EditorLayout() {
@@ -312,6 +313,12 @@ export default function EditorLayout() {
pendingStackLoadRef,
pendingLogsRef,
} = stackActions;
// Pending-intent target for a cross-node "open this node's Networking page"
// request (e.g. a Fleet networking signal). Mirrors pendingStackLoadRef:
// setActiveNode first, then the node-settled effect below navigates once
// activeNode actually reflects the target, so Networking never briefly
// mounts and fetches against the previous node.
const pendingNetworkingNodeRef = useRef<number | null>(null);
const panelStartedAt = usePanelSessionStartedAt(panelState);
@@ -342,6 +349,7 @@ export default function EditorLayout() {
// Optimistically flip to the detail surface the instant a row is tapped,
// before loadFile's fetch resolves selectedFile; cleared once it settles.
const [pendingDetailStack, setPendingDetailStack] = useState<string | null>(null);
const [pendingAnatomyTab, setPendingAnatomyTab] = useState<'networking' | 'doctor' | 'dossier' | 'drift' | undefined>();
const [fleetUpdatesIntent, setFleetUpdatesIntent] = useState<{ tab: 'nodes' | 'changelog' } | null>(null);
const handleFleetUpdatesIntentConsumed = useCallback(() => setFleetUpdatesIntent(null), []);
@@ -411,6 +419,14 @@ export default function EditorLayout() {
}
}, [pendingDetailStack, detailReady, isFileLoading, stacksLoadStatus, urlHydratingStack, routeDetailError]);
useEffect(() => {
if (pendingAnatomyTab && selectedFile && !isFileLoading) {
const timer = window.setTimeout(() => setPendingAnatomyTab(undefined), 0);
return () => window.clearTimeout(timer);
}
return undefined;
}, [isFileLoading, pendingAnatomyTab, selectedFile]);
// A phone shows one surface at a time, so every mobile navigation tears down
// the current detail and switches surfaces, guarding a dirty editor first.
// `then` runs the destination-specific work (navigate to a view, open
@@ -446,9 +462,20 @@ export default function EditorLayout() {
// is already active, else stash it and switch nodes (the node-switch effect
// loads the pending stack once the registry settles). Mobile shows the
// optimistic detail surface immediately.
const handleFleetNavigateToNode = (nodeId: number, stackName: string) => {
const handleFleetNavigateToNode = (
nodeId: number,
stackName: string,
destination: SenchoOpenStackDetail['destination'] = 'stack',
) => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
setPendingAnatomyTab(
destination === 'anatomy-networking' ? 'networking'
: destination === 'doctor' ? 'doctor'
: destination === 'dossier' ? 'dossier'
: destination === 'drift' ? 'drift'
: undefined,
);
if (isMobile) setPendingDetailStack(stackName);
if (activeNode?.id === nodeId) {
void stackActions.loadFile(stackName);
@@ -467,12 +494,28 @@ export default function EditorLayout() {
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<SenchoOpenStackDetail>).detail;
if (detail) openStackFromEventRef.current(detail.nodeId, detail.stackName);
if (detail) openStackFromEventRef.current(detail.nodeId, detail.stackName, detail.destination);
};
window.addEventListener(SENCHO_OPEN_STACK_EVENT, handler);
return () => window.removeEventListener(SENCHO_OPEN_STACK_EVENT, handler);
}, []);
// Open a node's Networking page from a Fleet card's networking signal.
// Pending-intent gated (see pendingNetworkingNodeRef above): if the node is
// already active, navigate immediately; otherwise switch nodes first and let
// the node-settled effect complete the navigation once activeNode reflects
// the switch.
const handleOpenNodeNetworking = (nodeId: number) => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
if (activeNode?.id === nodeId) {
setActiveView('networking');
return;
}
pendingNetworkingNodeRef.current = nodeId;
setActiveNode(node);
};
// "Inspect" a node from the mobile Fleet screen: switch to it and land on its
// stack list.
const handleInspectNode = (nodeId: number) => {
@@ -536,6 +579,7 @@ export default function EditorLayout() {
const renderEditor = (headerActions?: ReactNode) => (
<EditorView
headerActions={headerActions}
requestedAnatomyTab={pendingAnatomyTab}
stackName={stackName}
isDarkMode={isDarkMode}
containers={containers}
@@ -659,6 +703,8 @@ export default function EditorLayout() {
const pendingStack = pendingStackLoadRef.current;
pendingStackLoadRef.current = null;
const pendingNetworkingNodeId = pendingNetworkingNodeRef.current;
pendingNetworkingNodeRef.current = null;
stackActions.resetEditorState();
// Stack filenames can repeat across nodes; drop the previous node's failure
@@ -667,6 +713,8 @@ export default function EditorLayout() {
if (pendingStack) {
void stackActions.loadFile(pendingStack);
} else if (pendingNetworkingNodeId === activeNode.id) {
setActiveView('networking');
} else if (isRealSwitch) {
setActiveView('dashboard');
}
@@ -892,6 +940,7 @@ export default function EditorLayout() {
}}
onHostConsoleClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')}
onFleetNavigateToNode={handleFleetNavigateToNode}
onOpenNodeNetworking={handleOpenNodeNetworking}
filterNodeId={filterNodeId}
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
schedulePrefill={schedulePrefill}
@@ -1039,6 +1088,12 @@ export default function EditorLayout() {
<ResourcesView headerActions={mobileMastheadActions} />
</Suspense>
);
case 'networking':
return (
<Suspense fallback={lazyFallback}>
<NetworkingView headerActions={mobileMastheadActions} />
</Suspense>
);
case 'global-observability':
// Hub-only, like the desktop content path (ViewRouter); no capability gate.
return (
@@ -216,6 +216,7 @@ export interface EditorViewProps {
// Mobile-only: notifications + more-menu cluster for the detail header right
// slot (the global TopBar is dropped on the full-screen detail surface).
headerActions?: React.ReactNode;
requestedAnatomyTab?: 'networking' | 'doctor' | 'dossier' | 'drift';
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
@@ -679,6 +680,7 @@ export function EditorView(props: EditorViewProps) {
applying={loadingAction === 'update'}
canEdit={can('stack:edit', 'stack', stackName)}
notifications={notifications}
requestedTab={props.requestedAnatomyTab}
/>
)}
</div>
@@ -45,7 +45,10 @@ const AuditLogView = lazy(() =>
const ScheduledOperationsView = lazy(() => import('../ScheduledOperationsView'));
const AutoUpdateReadinessView = lazy(() => import('../AutoUpdateReadinessView'));
const SecurityView = lazy(() =>
import('../SecurityView').then(m => ({ default: m.SecurityView })),
import('../SecurityView').then(m => ({ default: m.SecurityView })),
);
const NetworkingView = lazy(() =>
import('../networking/NetworkingView').then(m => ({ default: m.NetworkingView })),
);
// Sized for the main workspace area (flex-1 with p-6 padding). Visible
@@ -81,6 +84,7 @@ export interface ViewRouterProps {
onTemplateDeploySuccess: (stackName: string) => void;
onHostConsoleClose: () => void;
onFleetNavigateToNode: (nodeId: number, stackName: string) => void;
onOpenNodeNetworking: (nodeId: number) => void;
filterNodeId: number | null;
onClearScheduledOpsFilter: () => void;
schedulePrefill: ScheduleTaskPrefill | null;
@@ -116,6 +120,7 @@ export function ViewRouter({
onTemplateDeploySuccess,
onHostConsoleClose,
onFleetNavigateToNode,
onOpenNodeNetworking,
filterNodeId,
onClearScheduledOpsFilter,
schedulePrefill,
@@ -157,6 +162,13 @@ export function ViewRouter({
if (activeView === 'resources') {
return <ResourcesView />;
}
if (activeView === 'networking') {
return (
<LazyView>
<NetworkingView />
</LazyView>
);
}
if (activeView === 'security') {
// Node-scoped (not hub-only): scan/scanner data follows the active node
// like Resources. The page itself is Community; per-tab gates handle
@@ -211,6 +223,7 @@ export function ViewRouter({
<LazyView>
<FleetView
onNavigateToNode={onFleetNavigateToNode}
onOpenNodeNetworking={onOpenNodeNetworking}
onOpenSettingsSection={onOpenSettingsSection}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
fleetUpdatesIntent={fleetUpdatesIntent}
@@ -228,6 +228,9 @@ describe('useViewNavigationState', () => {
expect(values).toContain('fleet');
expect(values).toContain('resources');
expect(values).toContain('templates');
// Networking is a base nav item; the global search palette's Pages group
// derives from this list, so it needs no separate registration.
expect(values).toContain('networking');
// Logs is an admin-only operator view; a non-admin must not see the entry.
expect(values).not.toContain('global-observability');
expect(values).not.toContain('auto-updates');
@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
Terminal, CloudDownload, Home, HardDrive, ScrollText,
Activity, Radar, RefreshCw, Clock, ShieldCheck,
Activity, Radar, RefreshCw, Clock, ShieldCheck, Network,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
@@ -133,6 +133,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
}
items.push(
{ value: 'resources', label: 'Resources', icon: HardDrive },
{ value: 'networking', label: 'Networking', icon: Network },
{ value: 'security', label: 'Security', icon: ShieldCheck },
{ value: 'templates', label: 'App Store', icon: CloudDownload },
);
@@ -32,7 +32,7 @@ describe('mobile treatments', () => {
// components/mobile/, or a masthead-led mobile branch of the desktop view as
// Security does).
expect([...BESPOKE_MOBILE_VIEWS].sort()).toEqual(
['audit-log', 'auto-updates', 'dashboard', 'fleet', 'global-observability', 'resources', 'scheduled-ops', 'security', 'settings', 'templates'],
['audit-log', 'auto-updates', 'dashboard', 'fleet', 'global-observability', 'networking', 'resources', 'scheduled-ops', 'security', 'settings', 'templates'],
);
});
});
@@ -22,6 +22,7 @@ export const MOBILE_TREATMENTS: Record<ActiveView, MobileTreatment> = {
settings: 'bespoke',
editor: 'detail',
resources: 'bespoke',
networking: 'bespoke',
security: 'bespoke',
templates: 'bespoke',
'global-observability': 'bespoke',
+5
View File
@@ -40,6 +40,8 @@ import type { MuteRuleDraft } from '@/lib/muteRules';
interface FleetViewProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
/** Switches to a node and opens its Networking page (Fleet networking signal). */
onOpenNodeNetworking: (nodeId: number) => void;
/** Opens a Settings section (used to send "Add node" to Settings > Nodes). */
onOpenSettingsSection?: (section: SectionId) => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
@@ -52,6 +54,7 @@ interface FleetViewProps {
export function FleetView({
onNavigateToNode,
onOpenNodeNetworking,
onOpenSettingsSection,
onOpenMuteRulesWithPrefill,
fleetUpdatesIntent,
@@ -275,6 +278,8 @@ export function FleetView({
fleetStackLabelMap={overview.fleetStackLabelMap}
updateStatusMap={overview.updateStatusMap}
onNavigateToNode={onNavigateToNode}
onOpenNodeNetworking={onOpenNodeNetworking}
networkingByNode={overview.networkingByNode}
onUpdate={updateStatus.triggerNodeUpdate}
updatingNodeId={updateStatus.updatingNodeId}
onRetryUpdate={updateStatus.retryNodeUpdate}
+16 -1
View File
@@ -37,6 +37,10 @@ import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from './nodeUtils';
export interface NodeCardProps {
node: FleetNode;
onNavigate: (nodeId: number, stackName: string) => void;
/** Switches to this node and opens its Networking page. */
onOpenNetworking?: (nodeId: number) => void;
/** Networking posture signal from computeNodeNetworkingSummary, if loaded. */
networkingSignal?: { exposed: boolean; unknown: boolean; drift: boolean };
labelMap?: Record<string, StackLabel[]>;
updateStatus?: NodeUpdateStatus;
onUpdate?: (nodeId: number) => void;
@@ -64,7 +68,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
// --- Main Export ---
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) {
export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) {
const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
const [loadingStacks, setLoadingStacks] = useState(false);
@@ -255,6 +259,17 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
<Ban className="w-2.5 h-2.5 mr-0.5" /> Cordoned
</Badge>
)}
{onOpenNetworking && networkingSignal && (networkingSignal.exposed || networkingSignal.unknown || networkingSignal.drift) && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4 shrink-0 cursor-pointer bg-warning/10 text-warning border-warning/30 hover:bg-warning/20"
onClick={(event) => { event.stopPropagation(); onOpenNetworking(node.id); }}
title="Open this node's Networking page"
>
Networking ·
{networkingSignal.drift ? ' drift' : networkingSignal.exposed ? ' exposed' : ' unknown exposure'}
</Badge>
)}
</div>
</div>
</div>
@@ -29,6 +29,8 @@ interface OverviewTabProps {
fleetStackLabelMap: Record<number, Record<string, StackLabel[]>>;
updateStatusMap: Map<number, NodeUpdateStatus>;
onNavigateToNode: (nodeId: number, stackName: string) => void;
onOpenNodeNetworking: (nodeId: number) => void;
networkingByNode: Map<number, { exposed: boolean; unknown: boolean; drift: boolean }>;
onUpdate?: (nodeId: number) => void;
updatingNodeId: number | null;
onRetryUpdate?: (nodeId: number) => void;
@@ -65,6 +67,8 @@ export function OverviewTab({
fleetStackLabelMap,
updateStatusMap,
onNavigateToNode,
onOpenNodeNetworking,
networkingByNode,
onUpdate,
updatingNodeId,
onRetryUpdate,
@@ -143,6 +147,8 @@ export function OverviewTab({
key={node.id}
node={node}
onNavigate={onNavigateToNode}
onOpenNetworking={onOpenNodeNetworking}
networkingSignal={networkingByNode.get(node.id)}
labelMap={fleetStackLabelMap[node.id] ?? {}}
updateStatus={updateStatusMap.get(node.id)}
onUpdate={onUpdate}
@@ -128,4 +128,31 @@ describe('NodeCard', () => {
expect(screen.getByText('Pinned')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument();
});
it('shows the networking signal badge and switches to the node on click', async () => {
const onOpenNetworking = vi.fn();
const user = userEvent.setup();
render(
<NodeCard
{...baseProps(onlineNode())}
onOpenNetworking={onOpenNetworking}
networkingSignal={{ exposed: false, unknown: false, drift: true }}
/>,
);
const badge = screen.getByText(/Networking/);
expect(badge).toBeInTheDocument();
await user.click(badge);
expect(onOpenNetworking).toHaveBeenCalledWith(2);
});
it('hides the networking signal badge when there is nothing to flag', () => {
render(
<NodeCard
{...baseProps(onlineNode())}
onOpenNetworking={vi.fn()}
networkingSignal={{ exposed: false, unknown: false, drift: false }}
/>,
);
expect(screen.queryByText(/Networking/)).not.toBeInTheDocument();
});
});
@@ -35,6 +35,8 @@ function props(overrides: Partial<React.ComponentProps<typeof OverviewTab>> = {}
fleetStackLabelMap: {},
updateStatusMap: new Map(),
onNavigateToNode: vi.fn(),
onOpenNodeNetworking: vi.fn(),
networkingByNode: new Map(),
updatingNodeId: null,
topologyMode: 'hub' as const,
onTopologyModeChange: vi.fn(),
@@ -255,5 +255,6 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee
distinctNodeLabels: distinctLabels,
activeFilterCount,
clearFilters,
networkingByNode,
};
}
+224 -87
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
ReactFlow,
Background,
@@ -22,24 +22,31 @@ import { TogglePill } from '@/components/ui/toggle-pill';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
DEFAULT_TOPOLOGY_FILTERS, filterTopologyNetworks, isMissingTopologyNetwork, normalizeTopologyResponse,
TOPOLOGY_ANIMATION_EDGE_LIMIT, TOPOLOGY_RENDER_CAP, countTopologyGraphSize,
type NetworkingTopologyFilters,
} from '@/lib/networkingTopology';
import type {
NetworkingTopologyContainerDetail, NetworkingTopologyNetwork,
} from '@/types/networking';
// ── Types ─────────────────────────────────────────────────────────────────────
interface TopologyContainer {
id: string;
type TopologyNetwork = NetworkingTopologyNetwork;
interface ContainerAggregate {
name: string;
ip: string;
attachments: { network: string; ip: string }[];
state: string;
image: string;
stack: string | null;
}
interface TopologyNetwork {
Id: string;
Name: string;
Driver: string;
managedStatus: 'managed' | 'unmanaged' | 'system';
containers: TopologyContainer[];
service: string | null;
composeAliases: string[];
publishedPorts: NetworkingTopologyContainerDetail['publishedPorts'];
exposureIntent: NetworkingTopologyContainerDetail['exposureIntent'];
findingIds: string[];
driftFlags: string[];
}
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -54,16 +61,34 @@ function stateColor(state: string): string {
}
}
/** Aggregates containers across networks the same way for both the cheap
* pre-layout size counter and the real dagre layout, so the render cap is
* evaluated against the same node/edge counts the graph will actually use. */
function aggregateContainers(networksList: TopologyNetwork[]): Map<string, ContainerAggregate> {
const containerMap = new Map<string, ContainerAggregate>();
for (const net of networksList) {
for (const c of net.containers) {
if (!containerMap.has(c.id)) {
containerMap.set(c.id, {
name: c.name, attachments: [],
state: c.state, image: c.image, stack: c.stack, service: c.service,
composeAliases: c.composeAliases, publishedPorts: c.publishedPorts,
exposureIntent: c.exposureIntent,
findingIds: c.findingIds, driftFlags: c.driftFlags,
});
}
containerMap.get(c.id)!.attachments.push({ network: net.name, ip: c.ip });
}
}
return containerMap;
}
// ── Custom Nodes ──────────────────────────────────────────────────────────────
interface ContainerNodeData {
interface ContainerNodeData extends ContainerAggregate {
label: string;
containerId: string;
networks: string[];
ipAddresses: Record<string, string>;
state: string;
image: string;
stack: string | null;
onStackClick?: (stack: string) => void;
}
function ContainerNodeComponent({ data }: { data: ContainerNodeData }) {
@@ -71,7 +96,6 @@ function ContainerNodeComponent({ data }: { data: ContainerNodeData }) {
<div
className={cn(
'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 min-w-[160px]',
(!data.state || data.state === 'running') && 'cursor-pointer hover:border-t-card-border-hover',
)}
>
<Handle type="target" position={Position.Top} className="!bg-muted-foreground !w-2 !h-2" />
@@ -81,15 +105,24 @@ function ContainerNodeComponent({ data }: { data: ContainerNodeData }) {
<span className="text-xs font-medium truncate max-w-[140px]">{data.label}</span>
</div>
{data.stack && (
<Badge variant="outline" className="text-[9px] h-4 px-1 font-mono mb-1">{data.stack}</Badge>
<Badge
variant="outline"
className="text-[9px] h-4 px-1 font-mono mb-1 cursor-pointer hover:bg-muted"
onClick={(event) => {
event.stopPropagation();
data.onStackClick?.(data.stack!);
}}
>
{data.stack}
</Badge>
)}
{data.networks.length > 0 && (
{data.attachments.length > 0 && (
<div className="space-y-0.5">
{data.networks.map(netName => (
<div key={netName} className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{netName}</span>
{data.attachments.map(({ network, ip }) => (
<div key={network} className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{network}</span>
<span className="font-mono text-[10px] tabular-nums text-muted-foreground">
{data.ipAddresses[netName]?.replace(/\/\d+$/, '') || ''}
{ip?.replace(/\/\d+$/, '') || ''}
</span>
</div>
))}
@@ -103,13 +136,14 @@ function ContainerNodeComponent({ data }: { data: ContainerNodeData }) {
);
}
function NetworkNodeComponent({ data }: { data: { label: string; driver: string; status: string } }) {
const statusColor = data.status === 'managed' ? 'text-success' : data.status === 'system' ? 'text-muted-foreground' : 'text-warning';
function NetworkNodeComponent({ data }: { data: { label: string; driver: string; ownership: NetworkingTopologyNetwork['ownership']; missing: boolean; exposed: boolean; drift: boolean } }) {
const statusColor = data.ownership === 'sencho-managed' ? 'text-success' : data.ownership === 'system' ? 'text-muted-foreground' : data.missing ? 'text-destructive' : 'text-warning';
return (
<div className={cn(
'rounded-lg border-2 border-dashed px-4 py-2.5 min-w-[140px] text-center',
data.status === 'managed' ? 'border-success/30 bg-success/5' :
data.status === 'system' ? 'border-muted-foreground/20 bg-muted/20' :
data.missing ? 'border-destructive/40 bg-destructive/5 cursor-pointer' :
data.ownership === 'sencho-managed' ? 'border-success/30 bg-success/5 cursor-pointer' :
data.ownership === 'system' ? 'border-muted-foreground/20 bg-muted/20 cursor-pointer' :
'border-warning/30 bg-warning/5'
)}>
<Handle type="target" position={Position.Top} className="!bg-transparent !border-none !w-0 !h-0" />
@@ -118,6 +152,11 @@ function NetworkNodeComponent({ data }: { data: { label: string; driver: string;
<span className="text-xs font-medium">{data.label}</span>
</div>
<Badge variant="outline" className="text-[9px] h-4">{data.driver}</Badge>
{(data.exposed || data.drift || data.missing) && (
<div className="mt-1 font-mono text-[9px] uppercase text-stat-subtitle">
{data.missing ? 'missing external' : data.drift ? 'drift' : 'exposed'}
</div>
)}
<Handle type="source" position={Position.Bottom} className="!bg-transparent !border-none !w-0 !h-0" />
</div>
);
@@ -140,72 +179,62 @@ const EDGE_COLORS = [
'oklch(0.70 0.10 340)', // pink
];
// ── Layout Helper (dagre) ───────────────────────────────────────────────────
const TOPOLOGY_LEGEND: { label: string; swatchClass: string }[] = [
{ label: 'Sencho-managed', swatchClass: 'bg-success/60 border-success' },
{ label: 'External / unmanaged', swatchClass: 'bg-warning/60 border-warning' },
{ label: 'System', swatchClass: 'bg-muted-foreground/40 border-muted-foreground' },
{ label: 'Missing external', swatchClass: 'bg-destructive/60 border-destructive' },
];
// ── Layout Helper (dagre) ────────────────────────────────────────────────────
function layoutGraph(
networksList: TopologyNetwork[],
onStackClick?: (stack: string) => void,
): { nodes: Node[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: 'TB', ranksep: 120, nodesep: 60 });
g.setDefaultEdgeLabel(() => ({}));
// Deduplicate containers across networks
const containerMap = new Map<string, {
name: string;
networks: string[];
ipAddresses: Record<string, string>;
state: string;
image: string;
stack: string | null;
}>();
for (const net of networksList) {
for (const c of net.containers) {
if (!containerMap.has(c.id)) {
containerMap.set(c.id, {
name: c.name, networks: [], ipAddresses: {},
state: c.state, image: c.image, stack: c.stack,
});
}
const entry = containerMap.get(c.id)!;
entry.networks.push(net.Name);
entry.ipAddresses[net.Name] = c.ip;
}
}
const containerMap = aggregateContainers(networksList);
// Add nodes to dagre graph
for (const net of networksList) {
g.setNode(`net-${net.Id}`, { width: 160, height: 60 });
g.setNode(`net-${net.id}`, { width: 160, height: 60 });
}
for (const [id] of containerMap) {
g.setNode(`ctr-${id}`, { width: 200, height: 100 });
}
// Add edges and collect for React Flow
const seenEdges = new Set<string>();
const edgeList: { netId: string; ctrId: string; color: string }[] = [];
networksList.forEach((net, ni) => {
const color = EDGE_COLORS[ni % EDGE_COLORS.length];
for (const c of net.containers) {
const edgeKey = `${net.Id}-${c.id}`;
const edgeKey = `${net.id}-${c.id}`;
if (!seenEdges.has(edgeKey)) {
seenEdges.add(edgeKey);
g.setEdge(`net-${net.Id}`, `ctr-${c.id}`);
edgeList.push({ netId: net.Id, ctrId: c.id, color });
g.setEdge(`net-${net.id}`, `ctr-${c.id}`);
edgeList.push({ netId: net.id, ctrId: c.id, color });
}
}
});
dagre.layout(g);
// Convert dagre positions (center-based) to React Flow positions (top-left)
const flowNodes: Node[] = [];
for (const net of networksList) {
const pos = g.node(`net-${net.Id}`);
const pos = g.node(`net-${net.id}`);
flowNodes.push({
id: `net-${net.Id}`,
id: `net-${net.id}`,
type: 'network',
position: { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 },
data: { label: net.Name, driver: net.Driver, status: net.managedStatus },
data: {
label: net.name, driver: net.driver, ownership: net.ownership,
missing: isMissingTopologyNetwork(net),
exposed: net.containers.some((container) => container.publishedPorts.length > 0),
drift: net.findingIds.length > 0 || net.containers.some((container) => container.findingIds.length > 0 || container.driftFlags.length > 0),
network: net,
},
draggable: true,
});
}
@@ -218,21 +247,28 @@ function layoutGraph(
data: {
label: ctr.name,
containerId: id,
networks: ctr.networks,
ipAddresses: ctr.ipAddresses,
attachments: ctr.attachments,
state: ctr.state,
image: ctr.image,
stack: ctr.stack,
service: ctr.service,
composeAliases: ctr.composeAliases,
publishedPorts: ctr.publishedPorts,
exposureIntent: ctr.exposureIntent,
findingIds: ctr.findingIds,
driftFlags: ctr.driftFlags,
onStackClick,
},
draggable: true,
});
}
const animated = edgeList.length <= TOPOLOGY_ANIMATION_EDGE_LIMIT;
const flowEdges: Edge[] = edgeList.map(({ netId, ctrId, color }) => ({
id: `edge-${netId}-${ctrId}`,
source: `net-${netId}`,
target: `ctr-${ctrId}`,
animated: true,
animated,
style: { stroke: color, strokeWidth: 1.5 },
}));
@@ -242,41 +278,112 @@ function layoutGraph(
// ── Main Component ────────────────────────────────────────────────────────────
interface NetworkTopologyViewProps {
onContainerClick?: (containerId: string, containerName: string) => void;
onContainerSelect?: (container: NetworkingTopologyContainerDetail) => void;
onNetworkClick?: (network: NetworkingTopologyNetwork) => void;
onStackClick?: (stack: string) => void;
/** API path for topology data. Defaults to the Resources maintenance route. */
endpoint?: string;
/** When false, hides the include-system toggle (caller controls scope). */
showSystemToggle?: boolean;
/** Controlled include-system value when showSystemToggle is false. */
includeSystem?: boolean;
filters?: NetworkingTopologyFilters;
}
export default function NetworkTopologyView({ onContainerClick }: NetworkTopologyViewProps) {
export default function NetworkTopologyView({
onContainerSelect,
onNetworkClick,
onStackClick,
endpoint = '/system/networks/topology',
showSystemToggle = true,
includeSystem: controlledIncludeSystem,
filters,
}: NetworkTopologyViewProps) {
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [loading, setLoading] = useState(true);
const [includeSystem, setIncludeSystem] = useState(false);
const onContainerClickRef = useRef(onContainerClick);
onContainerClickRef.current = onContainerClick;
const [internalIncludeSystem, setInternalIncludeSystem] = useState(false);
const includeSystem = controlledIncludeSystem ?? internalIncludeSystem;
const onContainerSelectRef = useRef(onContainerSelect);
onContainerSelectRef.current = onContainerSelect;
const onNetworkClickRef = useRef(onNetworkClick);
onNetworkClickRef.current = onNetworkClick;
const onStackClickRef = useRef(onStackClick);
onStackClickRef.current = onStackClick;
const [runtimeAvailable, setRuntimeAvailable] = useState(true);
const [loadError, setLoadError] = useState(false);
// Raw (unfiltered) networks from the last fetch; filters are applied
// client-side so toggling them never triggers a new request (Workstream J).
const [rawNetworks, setRawNetworks] = useState<TopologyNetwork[]>([]);
const [overCap, setOverCap] = useState(false);
const fetchTopology = useCallback(async () => {
setLoading(true);
setLoadError(false);
try {
const res = await apiFetch(`/system/networks/topology?includeSystem=${includeSystem}`);
const res = await apiFetch(`${endpoint}?includeSystem=${includeSystem}`);
if (!res.ok) throw new Error('Failed to fetch topology');
const inspected = await res.json();
const { nodes: layoutNodes, edges: layoutEdges } = layoutGraph(inspected);
setNodes(layoutNodes);
setEdges(layoutEdges);
const inspected: unknown = await res.json();
const topology = normalizeTopologyResponse(inspected);
setRuntimeAvailable(topology.runtimeAvailable);
setRawNetworks(topology.networks);
} catch (error) {
const err = error as Record<string, unknown>;
toast.error(String(err?.message || err?.error || 'Something went wrong.'));
setLoadError(true);
setRawNetworks([]);
} finally {
setLoading(false);
}
}, [setNodes, setEdges, includeSystem]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [includeSystem, endpoint]);
useEffect(() => { fetchTopology(); }, [fetchTopology]);
const handleNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
if (node.type === 'container' && (!node.data.state || node.data.state === 'running')) {
onContainerClickRef.current?.(node.data.containerId as string, node.data.label as string);
// Filters, the cheap size count, the cap check, and layout all run client-side
// against the cached raw response (no refetch per filter/search keystroke).
const networksList = useMemo(() => filterTopologyNetworks(
rawNetworks.filter((network) => includeSystem || !network.isSystem),
filters ?? DEFAULT_TOPOLOGY_FILTERS,
), [rawNetworks, includeSystem, filters]);
useEffect(() => {
const size = countTopologyGraphSize(networksList);
if (size.nodeCount + size.edgeCount > TOPOLOGY_RENDER_CAP) {
setOverCap(true);
setNodes([]);
setEdges([]);
return;
}
setOverCap(false);
const { nodes: layoutNodes, edges: layoutEdges } = layoutGraph(networksList, onStackClickRef.current);
setNodes(layoutNodes);
setEdges(layoutEdges);
}, [networksList, setNodes, setEdges]);
const handleNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
if (node.type === 'network') {
onNetworkClickRef.current?.(node.data.network as NetworkingTopologyNetwork);
}
if (node.type === 'container') {
onContainerSelectRef.current?.({
id: node.data.containerId as string,
name: node.data.label as string,
attachments: node.data.attachments as { network: string; ip: string }[],
state: node.data.state as string,
image: node.data.image as string,
stack: node.data.stack as string | null,
service: node.data.service as string | null,
composeAliases: node.data.composeAliases as string[],
publishedPorts: node.data.publishedPorts as NetworkingTopologyContainerDetail['publishedPorts'],
exposureIntent: node.data.exposureIntent as NetworkingTopologyContainerDetail['exposureIntent'],
findingIds: node.data.findingIds as string[],
driftFlags: node.data.driftFlags as string[],
});
}
// Node click opens the drawer only; viewing logs is an explicit action
// inside the container drawer (previously this also auto-opened logs,
// which raced the drawer for the user's attention).
}, []);
if (loading) {
@@ -288,15 +395,33 @@ export default function NetworkTopologyView({ onContainerClick }: NetworkTopolog
);
}
if (overCap) {
return (
<div className="flex flex-col items-center justify-center h-[400px] text-muted-foreground gap-3">
<Network className="w-8 h-8 opacity-40" strokeWidth={1.5} />
<p className="text-sm">This graph is too large to render.</p>
<p className="text-xs opacity-70">Narrow the filters to bring it under {TOPOLOGY_RENDER_CAP} nodes and edges, or use the Networks table instead.</p>
</div>
);
}
if (nodes.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-[400px] text-muted-foreground gap-3">
<Network className="w-8 h-8 opacity-40" strokeWidth={1.5} />
<p className="text-sm">
{includeSystem ? 'No networks found.' : 'No user-created networks found.'}
{loadError
? 'Could not load topology for this node.'
: !runtimeAvailable
? 'Docker runtime unavailable.'
: includeSystem
? 'No networks found.'
: 'No user-created networks found.'}
</p>
<p className="text-xs opacity-70">
{includeSystem
{!runtimeAvailable
? 'Topology is unavailable until Docker responds on this node.'
: includeSystem
? 'No Docker networks are available on this node.'
: 'Create a network or deploy stacks with custom networks to see the topology.'}
</p>
@@ -306,11 +431,23 @@ export default function NetworkTopologyView({ onContainerClick }: NetworkTopolog
return (
<div className="rounded-lg border border-card-border bg-card shadow-card-bevel overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-card-border">
<TogglePill id="show-system" checked={includeSystem} onChange={setIncludeSystem} />
<Label htmlFor="show-system" className="text-xs cursor-pointer">
Show system networks
</Label>
<div className="flex flex-wrap items-center gap-3 px-3 py-2 border-b border-card-border">
{showSystemToggle && (
<>
<TogglePill id="show-system" checked={includeSystem} onChange={setInternalIncludeSystem} />
<Label htmlFor="show-system" className="text-xs cursor-pointer">
Show system networks
</Label>
</>
)}
<div className="flex flex-wrap items-center gap-2">
{TOPOLOGY_LEGEND.map((entry) => (
<span key={entry.label} className="flex items-center gap-1 font-mono text-[10px] text-stat-subtitle">
<span className={cn('h-2 w-2 rounded-full border', entry.swatchClass)} />
{entry.label}
</span>
))}
</div>
<Button
variant="ghost"
size="icon"
+1 -1
View File
@@ -30,7 +30,7 @@ interface NodeSchedulingSummary {
export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate';
export interface SenchoNavigateDetail {
view: 'scheduled-ops' | 'auto-updates' | 'security' | 'fleet';
view: 'scheduled-ops' | 'auto-updates' | 'security' | 'fleet' | 'networking' | 'resources';
nodeId?: number;
/** Target tab when navigating to the Security view. */
tab?: SecurityTab;
+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)}
+13 -1
View File
@@ -34,6 +34,7 @@ interface StackAnatomyPanelProps {
canEdit: boolean;
applying?: boolean;
notifications?: NotificationItem[];
requestedTab?: 'networking' | 'doctor' | 'dossier' | 'drift';
}
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
@@ -99,6 +100,7 @@ export default function StackAnatomyPanel({
canEdit,
applying = false,
notifications,
requestedTab,
}: StackAnatomyPanelProps) {
const anatomy = useMemo(() => parseAnatomy(content), [content]);
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
@@ -138,6 +140,16 @@ export default function StackAnatomyPanel({
attemptedAt?: number;
errorMessage?: string | null;
} | null>(null);
const [activeTab, setActiveTab] = useState('anatomy');
useEffect(() => {
if (requestedTab === 'networking' && networkingEnabled) setActiveTab('networking');
if (requestedTab === 'doctor' && doctorEnabled) setActiveTab('doctor');
// Dossier and Drift are unconditional base tabs (no capability gate), unlike
// Doctor/Networking which require a node capability.
if (requestedTab === 'dossier') setActiveTab('dossier');
if (requestedTab === 'drift') setActiveTab('drift');
}, [doctorEnabled, networkingEnabled, requestedTab]);
const { dismissed: scanBannerDismissed, dismiss: dismissScanBanner } =
useScanBannerDismiss(stackName, activeNode?.id, scanStatus);
@@ -397,7 +409,7 @@ export default function StackAnatomyPanel({
return (
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
<Tabs defaultValue="anatomy" className="flex flex-col h-full min-h-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between border-b border-muted px-3 py-1.5 gap-2">
<ScrollableTabRow surface="card" wrapperClassName="min-w-0 flex-1">
<TabsList className="h-7 w-max gap-0.5 bg-transparent border-none p-0">
+15 -3
View File
@@ -59,7 +59,14 @@ export function TopBar({
{/* NAV ZONE: Navigation (hidden on mobile) */}
<TooltipProvider delayDuration={300} disableHoverableContent>
<nav aria-label="Primary" className="hidden md:flex self-stretch items-stretch">
<nav
aria-label="Primary"
className={cn(
'hidden md:flex self-stretch items-stretch',
showLabels && 'min-w-0 flex-1 overflow-x-auto [scrollbar-width:none]',
!showLabels && centered && 'shrink-0',
)}
>
{navItems.map(({ value, label, icon: Icon }) => {
const isActive = activeView === value;
const button = (
@@ -68,7 +75,7 @@ export function TopBar({
aria-label={label}
aria-current={isActive ? 'page' : undefined}
className={cn(
'relative inline-flex h-full items-center gap-2 px-4',
'relative inline-flex h-full shrink-0 items-center gap-2 px-4',
'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
@@ -99,7 +106,12 @@ export function TopBar({
</TooltipProvider>
{/* RIGHT ZONE: Utilities + identity pin */}
<div className="flex flex-1 min-w-0 items-center justify-end gap-2">
<div
className={cn(
'flex items-center justify-end gap-2',
centered ? 'flex-1 min-w-0' : showLabels ? 'relative z-10 shrink-0' : 'flex-1 min-w-0',
)}
>
{search}
{themeSwitch}
{notifications}
@@ -106,14 +106,14 @@ describe('FleetView experimental discovery', () => {
});
it('shows Routing and Secrets when experimental discovery is on for paid admin', () => {
render(<FleetView onNavigateToNode={vi.fn()} />);
render(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
expect(screen.getByRole('tab', { name: /routing/i })).toBeTruthy();
expect(screen.getByRole('tab', { name: /secrets/i })).toBeTruthy();
});
it('hides Routing and Secrets when experimental discovery is off', () => {
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
render(<FleetView onNavigateToNode={vi.fn()} />);
render(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
expect(screen.queryByRole('tab', { name: /routing/i })).toBeNull();
expect(screen.queryByRole('tab', { name: /secrets/i })).toBeNull();
expect(screen.getByRole('tab', { name: /deployments/i })).toBeTruthy();
@@ -127,6 +127,7 @@ describe('FleetView experimental discovery', () => {
const { rerender } = render(
<FleetView
onNavigateToNode={vi.fn()}
onOpenNodeNetworking={vi.fn()}
fleetActiveTab="routing"
onFleetActiveTabChange={onTab}
/>,
@@ -138,6 +139,7 @@ describe('FleetView experimental discovery', () => {
rerender(
<FleetView
onNavigateToNode={vi.fn()}
onOpenNodeNetworking={vi.fn()}
fleetActiveTab="routing"
onFleetActiveTabChange={onTab}
/>,
@@ -152,6 +154,7 @@ describe('FleetView experimental discovery', () => {
const { rerender } = render(
<FleetView
onNavigateToNode={vi.fn()}
onOpenNodeNetworking={vi.fn()}
fleetActiveTab="routing"
onFleetActiveTabChange={onTab}
/>,
@@ -162,6 +165,7 @@ describe('FleetView experimental discovery', () => {
rerender(
<FleetView
onNavigateToNode={vi.fn()}
onOpenNodeNetworking={vi.fn()}
fleetActiveTab="routing"
onFleetActiveTabChange={onTab}
/>,
@@ -60,7 +60,6 @@ vi.mock('../resources/ReclaimHero', () => ({
vi.mock('../resources/FootprintTreemap', () => ({ FootprintTreemap: () => null }));
vi.mock('../resources/ImageDetailsSheet', () => ({ ImageDetailsSheet: () => null }));
vi.mock('../resources/VolumeBrowserSheet', () => ({ VolumeBrowserSheet: () => null }));
vi.mock('../resources/NetworkDetailSheet', () => ({ NetworkDetailSheet: () => null }));
vi.mock('../NetworkTopologyView', () => ({ default: () => null }));
vi.mock('../CapabilityGate', () => ({ CapabilityGate: ({ children }: { children: React.ReactNode }) => <>{children}</> }));
vi.mock('../LazyBoundary', () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children}</> }));
@@ -0,0 +1,175 @@
import { useEffect, useState } from 'react';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { Badge } from '@/components/ui/badge';
import { Container } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import type { NetworkingEnvelope, SanitizedNetworkInspect } from '@/types/networking';
export function NetworkDetailDrawer({
networkId,
onClose,
}: {
networkId: string | null;
onClose: () => void;
}) {
const [detail, setDetail] = useState<SanitizedNetworkInspect | null>(null);
const [loading, setLoading] = useState(false);
const [runtimeUnavailable, setRuntimeUnavailable] = useState(false);
useEffect(() => {
if (!networkId) {
setDetail(null);
setRuntimeUnavailable(false);
return;
}
let cancelled = false;
setDetail(null);
setRuntimeUnavailable(false);
const run = async () => {
setLoading(true);
try {
const res = await apiFetch(`/networking/networks/${encodeURIComponent(networkId)}`);
if (res.status === 503) {
if (!cancelled) setRuntimeUnavailable(true);
return;
}
if (!res.ok) throw new Error('inspect failed');
const body = await res.json() as NetworkingEnvelope & { network: Partial<SanitizedNetworkInspect> };
if (!cancelled) {
// An older remote may omit the newer array fields; backfill every array
// the drawer renders so a partial shape cannot crash the component.
const n = body.network;
setDetail({
...n,
labelKeys: n.labelKeys ?? [],
subnets: n.subnets ?? [],
gateways: n.gateways ?? [],
connectedContainers: n.connectedContainers ?? [],
} as SanitizedNetworkInspect);
}
} catch (error) {
if (!cancelled) {
console.error('[Networking] Failed to inspect network:', error);
toast.error('Failed to load network details.');
}
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => { cancelled = true; };
}, [networkId]);
// connectedContainers is always an array after backfill; fall back to the count
// field so an older remote (empty array, populated count) still shows the total.
const containerCount = detail ? (detail.connectedContainers.length || detail.connectedCount) : 0;
const meta = detail
? `${detail.driver} · ${detail.scope}${detail.subnets[0] ? ` · ${detail.subnets[0]}` : ''} · ${containerCount} container${containerCount === 1 ? '' : 's'}`
: '';
return (
<SystemSheet
open={networkId !== null}
onOpenChange={(open) => { if (!open) onClose(); }}
crumb={['Networking', 'Networks', detail?.name ?? '—']}
name={detail?.name ?? 'Network'}
meta={meta}
size="md"
>
{loading && <p className="text-sm text-muted-foreground">Loading</p>}
{runtimeUnavailable && (
<p className="text-sm text-warning">
Docker runtime is unavailable, so this network cannot be inspected.
</p>
)}
{detail && (
<>
<SheetSection title="Overview">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-xs text-muted-foreground">Driver</span>
<span className="mt-0.5 block text-xs">
<Badge variant="outline" className="h-5 text-[10px]">{detail.driver}</Badge>
</span>
</div>
<div>
<span className="text-xs text-muted-foreground">Scope</span>
<span className="mt-0.5 block text-xs">
<Badge variant="outline" className="h-5 text-[10px]">{detail.scope}</Badge>
</span>
</div>
<div>
<span className="text-xs text-muted-foreground">Internal</span>
<p className="mt-0.5 text-xs">{detail.internal ? 'Yes' : 'No'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Attachable</span>
<p className="mt-0.5 text-xs">{detail.attachable ? 'Yes' : 'No'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Stack</span>
<p className="mt-0.5 text-xs">{detail.stack ?? 'none'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Compose project</span>
<p className="mt-0.5 text-xs">{detail.composeProject ?? 'none'}</p>
</div>
<div className="col-span-2">
<span className="text-xs text-muted-foreground">Label keys</span>
<p className="mt-0.5 font-mono text-xs">{detail.labelKeys.length ? detail.labelKeys.join(', ') : 'none'}</p>
</div>
</div>
</SheetSection>
{(detail.subnets.length > 0 || detail.gateways.length > 0) && (
<SheetSection title="IPAM configuration">
<div className="divide-y divide-card-border/40">
{detail.subnets.map((subnet, i) => (
<div key={subnet} className="flex items-center justify-between gap-2 py-2">
<span className="text-xs text-muted-foreground">Subnet</span>
<span className="font-mono text-xs tabular-nums">{subnet}</span>
{detail.gateways[i] && (
<>
<span className="text-xs text-muted-foreground">Gateway</span>
<span className="font-mono text-xs tabular-nums">{detail.gateways[i]}</span>
</>
)}
</div>
))}
</div>
</SheetSection>
)}
<SheetSection title="Connected" meta={`${containerCount} container${containerCount === 1 ? '' : 's'}`}>
{containerCount === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">No containers connected to this network.</p>
) : (
<div className="divide-y divide-card-border/40">
{detail.connectedContainers.map((c) => (
<div key={c.name} className="space-y-1 py-2">
<div className="flex items-center gap-2">
<Container className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
<span className="truncate text-sm font-medium">{c.name}</span>
{c.stack && <Badge variant="outline" className="h-4 px-1 font-mono text-[9px]">{c.stack}</Badge>}
</div>
<div className="grid grid-cols-2 gap-2 pl-5">
<div>
<span className="text-[10px] text-muted-foreground">Service</span>
<p className="font-mono text-xs">{c.service ?? 'none'}</p>
</div>
<div>
<span className="text-[10px] text-muted-foreground">IPv4</span>
<p className="font-mono text-xs tabular-nums">{c.ipv4 ?? 'N/A'}</p>
</div>
</div>
</div>
))}
</div>
)}
</SheetSection>
</>
)}
</SystemSheet>
);
}
@@ -0,0 +1,336 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { SortableTableHead } from '@/components/ui/sortable-table';
import { useTableSort } from '@/hooks/useTableSort';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Eye, Search, Trash2, ExternalLink, GitBranch } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { filterNetworkRows, rowHasDriftFinding, type NetworkFilter } from '@/lib/networking';
import { type NetworkingFinding, type NetworkingFindingKind, type NetworkingNetworkRow, type NetworkingOwnership } from '@/types/networking';
export type { NetworkingNetworkRow } from '@/types/networking';
const OWNERSHIP_BADGE_CLASS: Record<NetworkingOwnership, string> = {
'sencho-managed': 'border-success/40 text-success',
'compose-managed': 'border-brand/40 text-brand',
unmanaged: 'border-warning/40 text-warning',
system: 'border-muted-foreground/30 text-muted-foreground',
};
const OWNERSHIP_LABEL: Record<NetworkingOwnership, string> = {
'sencho-managed': 'Sencho-managed',
'compose-managed': 'Compose-managed',
unmanaged: 'Unmanaged',
system: 'System',
};
function OwnershipBadge({ ownership }: { ownership: NetworkingOwnership }) {
return (
<Badge variant="outline" className={cn('h-5 text-[10px] font-mono', OWNERSHIP_BADGE_CLASS[ownership])}>
{OWNERSHIP_LABEL[ownership]}
</Badge>
);
}
const COLUMN_COUNT = 9;
function NetworkTableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<TableBody>
{Array.from({ length: rows }).map((_, r) => (
<TableRow key={r} className="animate-in fade-in-0" style={{ animationDelay: `${r * 40}ms` }}>
{Array.from({ length: COLUMN_COUNT }).map((_, c) => (
<TableCell key={c}>
<Skeleton className={cn('h-4', c === 0 ? 'w-32' : c === 3 ? 'w-24' : 'w-10')} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
);
}
/** A network is unsafe to delete without a pre-confirm explanation when it has
* connected containers or is declared by a stack's Compose file; the backend
* 409-guards these, but the UI should explain BEFORE the confirm dialog rather
* than let a generic confirmation surprise the user with a rejection. */
function deleteBlockReason(row: NetworkingNetworkRow): string | null {
if (row.connectedCount > 0) {
return `Connected to ${row.connectedCount} container${row.connectedCount === 1 ? '' : 's'}; disconnect them first.`;
}
if (row.declaredByStacks.length > 0) {
return `Declared by ${row.declaredByStacks.join(', ')}; remove the declaration first.`;
}
return null;
}
const FILTER_OPTIONS: { key: NetworkFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'managed', label: 'Managed' },
{ key: 'external', label: 'External dep.' },
{ key: 'system', label: 'System' },
{ key: 'shared', label: 'Shared' },
{ key: 'exposed', label: 'Exposed' },
{ key: 'drift', label: 'Drift' },
];
function FilterToggle({
value,
onChange,
counts,
}: {
value: NetworkFilter;
onChange: (v: NetworkFilter) => void;
counts: Record<NetworkFilter, number>;
}) {
return (
<div className="flex items-center gap-1">
{FILTER_OPTIONS.map(({ key, label }) => (
<Button
key={key}
type="button"
variant={value === key ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5 gap-1.5"
onClick={() => onChange(key)}
>
{label}
<span className="font-mono tabular-nums text-[10px] opacity-70">{counts[key]}</span>
</Button>
))}
</div>
);
}
function countNetworkFilters(rows: NetworkingNetworkRow[], findingKindById: Map<string, NetworkingFindingKind>): Record<NetworkFilter, number> {
const counts: Record<NetworkFilter, number> = {
all: rows.length,
managed: 0,
external: 0,
system: 0,
shared: 0,
exposed: 0,
drift: 0,
};
for (const row of rows) {
if (row.ownership === 'sencho-managed') counts.managed += 1;
if (row.isExternalDependency) counts.external += 1;
if (row.ownership === 'system') counts.system += 1;
if (row.sharedStackCount > 1) counts.shared += 1;
if (row.exposureSummary?.broadExposureCount) counts.exposed += 1;
if (rowHasDriftFinding(row, findingKindById)) counts.drift += 1;
}
return counts;
}
// Stable comparator map (module scope so useTableSort does not re-sort every
// render), mirroring the Resources image/volume table sort standard.
type NetworkSortKey = 'name' | 'driver' | 'scope' | 'ownership' | 'shared' | 'exposure' | 'findings' | 'connected';
const NETWORK_COMPARATORS: Record<NetworkSortKey, (a: NetworkingNetworkRow, b: NetworkingNetworkRow) => 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),
ownership: (a, b) => a.ownership.localeCompare(b.ownership),
shared: (a, b) => a.sharedStackCount - b.sharedStackCount,
exposure: (a, b) => (a.exposureSummary?.broadExposureCount ?? 0) - (b.exposureSummary?.broadExposureCount ?? 0),
findings: (a, b) => a.findingIds.length - b.findingIds.length,
connected: (a, b) => a.connectedCount - b.connectedCount,
};
export function NetworkInventoryTable({
rows,
findings,
loading,
isAdmin,
onInspect,
onDelete,
onOpenStack,
onFilterTopology,
}: {
rows: NetworkingNetworkRow[];
findings: NetworkingFinding[];
loading: boolean;
isAdmin: boolean;
onInspect: (id: string) => void;
onDelete: (id: string, name: string) => void;
onOpenStack: (stack: string) => void;
onFilterTopology: (name: string) => void;
}) {
const [filter, setFilter] = useState<NetworkFilter>('all');
const [search, setSearch] = useState('');
const [searchExpanded, setSearchExpanded] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (searchExpanded) searchRef.current?.focus(); }, [searchExpanded]);
const findingKindById = useMemo(
() => new Map(findings.map((f) => [f.id, f.kind])),
[findings],
);
const counts = useMemo(() => countNetworkFilters(rows, findingKindById), [rows, findingKindById]);
const filtered = useMemo(
() => filterNetworkRows(rows, filter, search, findingKindById),
[rows, filter, search, findingKindById],
);
const { sorted, sortKey, sortDir, toggleSort } = useTableSort(filtered, NETWORK_COMPARATORS, 'name');
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 mb-4">
{search !== '' || searchExpanded ? (
<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={searchRef}
placeholder="Search network, stack, service, or driver..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onBlur={() => { if (search === '') setSearchExpanded(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={() => setSearchExpanded(true)} aria-label="Search networks">
<Search className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Search networks</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<FilterToggle value={filter} onChange={setFilter} counts={counts} />
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<div className="max-h-[62vh] overflow-auto">
<TooltipProvider>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<SortableTableHead label="Name" columnKey="name" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Driver" columnKey="driver" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Scope" columnKey="scope" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Ownership" columnKey="ownership" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Shared" columnKey="shared" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Exposure" columnKey="exposure" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Findings" columnKey="findings" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-[11px]" />
<SortableTableHead label="Connected" columnKey="connected" activeKey={sortKey} dir={sortDir} onSort={toggleSort} className="text-right text-[11px]" />
<TableHead className="w-40 text-right text-[11px]">Actions</TableHead>
</TableRow>
</TableHeader>
{loading ? <NetworkTableSkeleton /> : (
<TableBody>
{sorted.length === 0 ? (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="text-center py-8 text-muted-foreground text-sm">
No networks match this filter.
</TableCell>
</TableRow>
) : sorted.map((row, i) => (
<TableRow
key={row.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">{row.name}</TableCell>
<TableCell className="text-xs text-stat-subtitle">{row.driver}</TableCell>
<TableCell className="text-xs text-stat-subtitle">{row.scope}</TableCell>
<TableCell><OwnershipBadge ownership={row.ownership} /></TableCell>
<TableCell className="text-xs tabular-nums">{row.sharedStackCount || '—'}</TableCell>
<TableCell className="text-xs tabular-nums">
{row.exposureSummary?.broadExposureCount ?? '—'}
</TableCell>
<TableCell className="text-xs tabular-nums">{row.findingIds.length || '—'}</TableCell>
<TableCell className="text-right tabular-nums text-xs">{row.connectedCount}</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
{row.stack && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onOpenStack(row.stack!)}
aria-label={`Open ${row.stack}`}
>
<ExternalLink className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Open stack</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onInspect(row.id)}
aria-label={`Inspect ${row.name}`}
>
<Eye className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>View details</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onFilterTopology(row.name)}
aria-label={`Show ${row.name} in topology`}
>
<GitBranch className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Show in topology</TooltipContent>
</Tooltip>
{isAdmin && (() => {
const blockReason = deleteBlockReason(row);
const protectedReason = row.isSencho
? 'Protected · running Sencho instance'
: row.isSystem
? 'System network'
: blockReason;
const disabled = row.isSystem || row.isSencho || blockReason !== null;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={disabled ? '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={disabled}
onClick={() => onDelete(row.id, row.name)}
aria-label={`Delete ${row.name}`}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{protectedReason ?? 'Delete network'}</TooltipContent>
</Tooltip>
);
})()}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
)}
</Table>
</TooltipProvider>
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import type { useAuth } from '@/context/AuthContext';
import { isNetworkingActionVisible } from '@/lib/networking';
import {
FINDING_GROUP_LABELS, findingSourceLabel, groupFindings, SEVERITY_TEXT_CLASS, type NetworkingFindingGroup,
} from '@/lib/networkingSeverity';
import type { NetworkingFinding, NetworkingRecommendedAction } from '@/types/networking';
const GROUP_ORDER: NetworkingFindingGroup[] = ['needs-action', 'review-recommended', 'informational'];
const GROUP_CLASS: Record<NetworkingFindingGroup, string> = {
'needs-action': 'text-destructive',
'review-recommended': 'text-warning',
informational: 'text-stat-subtitle',
};
export function NetworkingFindingsList({
findings,
loading,
canEdit,
isAdmin,
onAction,
disabled = false,
}: {
findings: NetworkingFinding[];
loading: boolean;
canEdit: ReturnType<typeof useAuth>['can'];
isAdmin: boolean;
onAction: (action: NetworkingRecommendedAction) => void | Promise<void>;
disabled?: boolean;
}) {
if (loading) return <p className="text-sm text-muted-foreground">Loading findings</p>;
if (findings.length === 0) {
return <p className="text-sm text-muted-foreground">No networking issues detected.</p>;
}
const groups = groupFindings(findings);
return (
<div className="space-y-6">
{GROUP_ORDER.map((group) => {
const items = groups[group];
if (!items.length) return null;
return (
<section key={group}>
<p className={`mb-2 font-mono text-[10px] uppercase tracking-[0.18em] ${GROUP_CLASS[group]}`}>
{FINDING_GROUP_LABELS[group]} · {items.length}
</p>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<div className="max-h-[62vh] overflow-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-20 text-[11px]">Severity</TableHead>
<TableHead className="text-[11px]">Finding</TableHead>
<TableHead className="w-32 text-[11px]">Stack</TableHead>
<TableHead className="w-32 text-[11px]">Service</TableHead>
<TableHead className="w-32 text-[11px]">Network</TableHead>
<TableHead className="w-40 text-[11px]">Source</TableHead>
{!disabled && <TableHead className="w-44 text-right text-[11px]">Action</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items.map((finding, i) => {
const sourceLabel = findingSourceLabel(finding);
const primary = finding.recommendedActions.find((action) =>
isNetworkingActionVisible(action, isAdmin, (stack) => canEdit('stack:edit', 'stack', stack)),
);
return (
<TableRow
key={finding.id}
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
style={{ animationDelay: `${Math.min(i * 20, 200)}ms` }}
>
<TableCell>
<span className={`font-mono text-[10px] uppercase tracking-wide ${SEVERITY_TEXT_CLASS[finding.severity]}`}>
{finding.severity}
</span>
</TableCell>
<TableCell>
<p className="text-sm font-medium text-stat-value">{finding.title}</p>
<p className="text-xs text-stat-subtitle">{finding.message}</p>
</TableCell>
<TableCell className="font-mono text-xs text-stat-subtitle">{finding.stack ?? ''}</TableCell>
<TableCell className="font-mono text-xs text-stat-subtitle">{finding.service ?? ''}</TableCell>
<TableCell className="font-mono text-xs text-stat-subtitle">{finding.network ?? ''}</TableCell>
<TableCell className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle/80">
{sourceLabel ?? ''}
</TableCell>
{!disabled && (
<TableCell className="text-right">
{primary && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => void onAction(primary)}>
{primary.label}
</Button>
)}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</section>
);
})}
</div>
);
}
@@ -0,0 +1,235 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { SegmentedControl } from '@/components/ui/segmented-control';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { ScrollText, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import NetworkTopologyView from '@/components/NetworkTopologyView';
import { CapabilityGate } from '@/components/CapabilityGate';
import { SENCHO_OPEN_LOGS_EVENT, type SenchoOpenLogsDetail } from '@/lib/events';
import {
DEFAULT_TOPOLOGY_FILTERS,
formatTopologyPort,
isMissingTopologyNetwork,
type NetworkingTopologyFilters,
type TopologyOwnershipFilter,
} from '@/lib/networkingTopology';
import type { NetworkingTopologyContainerDetail, NetworkingTopologyNetwork } from '@/types/networking';
import { NetworkDetailDrawer } from './NetworkDetailDrawer';
const BOOLEAN_FILTERS: { key: keyof Pick<NetworkingTopologyFilters, 'exposedOnly' | 'driftOnly' | 'missingExternalOnly' | 'sharedOnly'>; label: string }[] = [
{ key: 'exposedOnly', label: 'Exposed' },
{ key: 'driftOnly', label: 'Drift' },
{ key: 'missingExternalOnly', label: 'Missing external' },
{ key: 'sharedOnly', label: 'Shared' },
];
const OWNERSHIP_OPTIONS: { key: TopologyOwnershipFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'managed', label: 'Sencho-managed' },
{ key: 'external', label: 'External' },
{ key: 'system', label: 'System' },
];
// Chip toggle mirroring the Fleet Map filter strip so the two topology-style
// views share one filter affordance.
function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors',
active
? 'border-brand/60 bg-brand/15 text-brand'
: 'border-card-border text-muted-foreground hover:text-foreground hover:bg-muted/40',
)}
>
{children}
</button>
);
}
export function NetworkingTopologyPanel({
reloadKey,
pendingNetworkFilter,
onPendingNetworkFilterApplied,
onOpenStack,
}: {
reloadKey: number;
pendingNetworkFilter?: string;
onPendingNetworkFilterApplied: () => void;
onOpenStack: (stack: string) => void;
}) {
const [includeSystem, setIncludeSystem] = useState(false);
const [filters, setFilters] = useState<NetworkingTopologyFilters>(DEFAULT_TOPOLOGY_FILTERS);
const [selectedNetwork, setSelectedNetwork] = useState<NetworkingTopologyNetwork | null>(null);
const [selectedContainer, setSelectedContainer] = useState<NetworkingTopologyContainerDetail | null>(null);
useEffect(() => {
if (!pendingNetworkFilter) return;
setFilters((value) => ({ ...value, network: pendingNetworkFilter }));
onPendingNetworkFilterApplied();
}, [pendingNetworkFilter, onPendingNetworkFilterApplied]);
const missingSelected = selectedNetwork !== null && isMissingTopologyNetwork(selectedNetwork);
const viewLogs = () => {
if (!selectedContainer) return;
window.dispatchEvent(new CustomEvent<SenchoOpenLogsDetail>(SENCHO_OPEN_LOGS_EVENT, {
detail: { containerId: selectedContainer.id, containerName: selectedContainer.name },
}));
};
return (
<CapabilityGate capability="network-topology" featureName="Network Topology">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
className="h-8 w-56 pl-8 text-sm"
placeholder="Filter stack or service…"
value={filters.stack}
onChange={(event) => setFilters((value) => ({ ...value, stack: event.target.value }))}
/>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
className="h-8 w-48 pl-8 text-sm"
placeholder="Filter network…"
value={filters.network}
onChange={(event) => setFilters((value) => ({ ...value, network: event.target.value }))}
/>
</div>
<SegmentedControl
value={filters.ownership}
onChange={(key) => setFilters((value) => ({ ...value, ownership: key }))}
ariaLabel="Network ownership"
options={OWNERSHIP_OPTIONS.map(({ key, label }) => ({ value: key, label }))}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip active={includeSystem} onClick={() => setIncludeSystem(!includeSystem)}>
Include system
</FilterChip>
{BOOLEAN_FILTERS.map(({ key, label }) => (
<FilterChip key={key} active={filters[key]} onClick={() => setFilters((value) => ({ ...value, [key]: !value[key] }))}>
{label}
</FilterChip>
))}
</div>
<NetworkTopologyView
key={`${reloadKey}-${includeSystem}`}
endpoint="/networking/topology"
showSystemToggle={false}
includeSystem={includeSystem}
filters={filters}
onNetworkClick={setSelectedNetwork}
onContainerSelect={setSelectedContainer}
onStackClick={onOpenStack}
/>
<NetworkDetailDrawer
networkId={selectedNetwork && !isMissingTopologyNetwork(selectedNetwork) ? selectedNetwork.id : null}
onClose={() => setSelectedNetwork(null)}
/>
<SystemSheet
open={missingSelected}
onOpenChange={(open) => { if (!open) setSelectedNetwork(null); }}
crumb={['Networking', 'Topology', selectedNetwork?.name ?? 'Missing external network']}
name={selectedNetwork?.name ?? 'Missing external network'}
meta="External dependency · not present in Docker"
size="md"
>
{selectedNetwork && (
<SheetSection title="Missing external network">
<p className="text-sm text-warning">
This external dependency is declared by Compose but is not present in Docker.
</p>
<div className="mt-3 space-y-2 text-sm">
<div>
<span className="text-xs text-stat-subtitle">Declaring stacks</span>
<p className="mt-0.5">{selectedNetwork.declaredExternalByStacks.join(', ') || 'none'}</p>
</div>
<div>
<span className="text-xs text-stat-subtitle">Finding IDs</span>
<p className="mt-0.5 font-mono text-xs">{selectedNetwork.findingIds.join(', ') || 'none'}</p>
</div>
</div>
</SheetSection>
)}
</SystemSheet>
<SystemSheet
open={selectedContainer !== null}
onOpenChange={(open) => { if (!open) setSelectedContainer(null); }}
crumb={['Networking', 'Topology', selectedContainer?.name ?? 'Container']}
name={selectedContainer?.name ?? 'Container'}
meta={selectedContainer ? `${selectedContainer.state} · ${selectedContainer.image}` : ''}
size="md"
primaryAction={selectedContainer && selectedContainer.state === 'running' ? {
label: 'View logs',
icon: ScrollText,
onClick: viewLogs,
} : undefined}
>
{selectedContainer && (
<>
<SheetSection title="Overview">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-xs text-muted-foreground">Stack</span>
<p className="mt-0.5 text-xs">
{selectedContainer.stack ? (
<button
type="button"
className="font-mono text-brand hover:underline"
onClick={() => onOpenStack(selectedContainer.stack!)}
>
{selectedContainer.stack}
</button>
) : 'none'}
</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Service</span>
<p className="mt-0.5 text-xs">{selectedContainer.service ?? 'none'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Exposure intent</span>
<p className="mt-0.5 text-xs">{selectedContainer.exposureIntent ?? 'unknown'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Aliases</span>
<p className="mt-0.5 text-xs">{selectedContainer.composeAliases.join(', ') || 'none'}</p>
</div>
</div>
</SheetSection>
<SheetSection title="Network attachments">
<div className="divide-y divide-card-border/40">
{selectedContainer.attachments.map((attachment) => (
<div key={attachment.network} className="flex items-center justify-between py-1.5">
<Badge variant="outline" className="h-5 font-mono text-[10px]">{attachment.network}</Badge>
<span className="font-mono text-xs tabular-nums">{attachment.ip?.replace(/\/\d+$/, '') || 'N/A'}</span>
</div>
))}
</div>
</SheetSection>
<SheetSection title="Published ports">
<p className="text-xs">{selectedContainer.publishedPorts.map(formatTopologyPort).join(', ') || 'none'}</p>
</SheetSection>
<SheetSection title="Findings and drift">
<div className="space-y-1 text-xs">
<p><span className="text-stat-subtitle">Findings</span> {selectedContainer.findingIds.join(', ') || 'none'}</p>
<p><span className="text-stat-subtitle">Drift</span> {selectedContainer.driftFlags.join(', ') || 'none'}</p>
</div>
</SheetSection>
</>
)}
</SystemSheet>
</div>
</CapabilityGate>
);
}
@@ -0,0 +1,603 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import {
LayoutDashboard, Network, GitBranch, AlertTriangle, RefreshCw, Plus, Unplug,
} from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { PageMasthead, type MastheadMetadataItem } from '@/components/ui/PageMasthead';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { LockCard } from '@/components/ui/LockCard';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { Masthead, MobileSubTabs, type Tone } from '@/components/mobile/mobile-ui';
import { springs } from '@/lib/motion';
import { CreateNetworkDialog } from '@/components/resources/CreateNetworkDialog';
import { ConfirmModal } from '@/components/ui/modal';
import { NetworkDetailDrawer } from './NetworkDetailDrawer';
import { NetworkInventoryTable } from './NetworkInventoryTable';
import { NetworkingFindingsList } from './NetworkingFindingsList';
import { NetworkingTopologyPanel } from './NetworkingTopologyPanel';
import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events';
import {
adaptNetworkingOverview, buildExternalNetworkSnippet, canUseNetworkName, getNetworkingPosture,
isNetworkingActionVisible,
} from '@/lib/networking';
import { rankFindings, SEVERITY_TEXT_CLASS } from '@/lib/networkingSeverity';
import { isNetworkDriftFindingKind } from '@/types/networking';
import type {
NetworkingFinding, NetworkingOverviewEnvelope, NetworkingRecommendedAction,
NetworkingNetworkRow, NodeNetworkingOverview,
} from '@/types/networking';
export type NetworkingTab = 'overview' | 'topology' | 'networks' | 'findings';
interface NetworkingViewProps {
headerActions?: ReactNode;
}
const TABS: { value: NetworkingTab; label: string; icon: typeof LayoutDashboard }[] = [
{ value: 'overview', label: 'Overview', icon: LayoutDashboard },
{ value: 'networks', label: 'Networks', icon: Network },
{ value: 'topology', label: 'Topology', icon: GitBranch },
{ value: 'findings', label: 'Findings', icon: AlertTriangle },
];
const POSTURE_TONE: Record<ReturnType<typeof getNetworkingPosture>['tone'], 'error' | 'warn' | 'idle' | 'live'> = {
critical: 'error',
warning: 'warn',
neutral: 'idle',
live: 'live',
};
// Maps the shared posture tone onto the mobile masthead's dot tone, state-word
// color, and pulse, mirroring SecurityView's MOBILE_MASTHEAD_TONE table.
type StateWordClass = 'text-destructive' | 'text-warning' | 'text-stat-value' | 'text-stat-title';
const MOBILE_MASTHEAD_TONE: Record<'error' | 'warn' | 'idle' | 'live', { dot: Tone; word: StateWordClass; pulse: boolean }> = {
error: { dot: 'destructive', word: 'text-destructive', pulse: true },
warn: { dot: 'warning', word: 'text-warning', pulse: true },
live: { dot: 'brand', word: 'text-stat-value', pulse: false },
idle: { dot: 'warning', word: 'text-stat-title', pulse: false },
};
function destinationForAction(kind: NetworkingRecommendedAction['kind']): SenchoOpenStackDetail['destination'] {
switch (kind) {
case 'open-stack-networking':
case 'set-exposure-intent':
return 'anatomy-networking';
case 'open-stack-doctor':
return 'doctor';
case 'open-stack-editor':
return 'editor';
case 'open-stack-dossier':
return 'dossier';
case 'open-stack-drift':
return 'drift';
default:
return 'stack';
}
}
export function NetworkingView({ headerActions }: NetworkingViewProps) {
const { isAdmin, can } = useAuth();
const { activeNode } = useNodes();
const isMobile = useIsMobile();
const nodeId = activeNode?.id;
const [tab, setTab] = useState<NetworkingTab>('overview');
const [loading, setLoading] = useState(true);
const [unsupported, setUnsupported] = useState(false);
const [overview, setOverview] = useState<NodeNetworkingOverview | null>(null);
const [networks, setNetworks] = useState<NetworkingNetworkRow[]>([]);
const [findings, setFindings] = useState<NetworkingFinding[]>([]);
const [recentActivity, setRecentActivity] = useState<NetworkingOverviewEnvelope['recentActivity']>([]);
const [runtimeAvailable, setRuntimeAvailable] = useState(true);
const [isLegacy, setIsLegacy] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [showCreateNetwork, setShowCreateNetwork] = useState(false);
const [initialNetworkName, setInitialNetworkName] = useState<string | undefined>();
const [selectedNetworkId, setSelectedNetworkId] = useState<string | null>(null);
const [pendingTopologyFilter, setPendingTopologyFilter] = useState<string | undefined>();
const [confirmDeleteNetwork, setConfirmDeleteNetwork] = useState<{ id: string; name: string } | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteNetwork = useCallback(async () => {
if (!confirmDeleteNetwork) return;
setIsDeleting(true);
const loadingId = toast.loading('Deleting network...');
try {
const res = await apiFetch('/system/networks/delete', {
method: 'POST',
body: JSON.stringify({ id: confirmDeleteNetwork.id }),
});
const data = await res.json().catch(() => null) as { error?: string } | null;
if (!res.ok) {
throw new Error(data?.error || 'Failed to delete network');
}
toast.success('Network deleted');
setReloadKey(k => k + 1);
} catch (error) {
const err = error as Record<string, unknown>;
toast.error(String(err?.message || 'Failed to delete network'));
} finally {
toast.dismiss(loadingId);
setIsDeleting(false);
setConfirmDeleteNetwork(null);
}
}, [confirmDeleteNetwork]);
useEffect(() => {
const controller = new AbortController();
let stale = false;
setLoading(true);
setUnsupported(false);
setOverview(null);
setNetworks([]);
setFindings([]);
setRecentActivity([]);
setIsLegacy(false);
const load = async () => {
try {
const response = await apiFetch('/networking/overview', { nodeId, signal: controller.signal });
if (response.status === 404) {
if (!stale) setUnsupported(true);
return;
}
if (!response.ok) throw new Error('Failed to load networking data.');
const body = await response.json() as Partial<NetworkingOverviewEnvelope>;
if (stale) return;
const adapted = adaptNetworkingOverview(body);
setIsLegacy(adapted.isLegacy);
setRuntimeAvailable(adapted.runtimeAvailable);
setOverview(adapted.overview);
setNetworks(adapted.networks);
setFindings(adapted.findings);
setRecentActivity(adapted.recentActivity);
} catch (error) {
if (!stale && !(error instanceof DOMException && error.name === 'AbortError')) {
console.error('[Networking] Failed to load overview:', error);
toast.error('Failed to load networking data.');
}
} finally {
if (!stale) setLoading(false);
}
};
void load();
return () => {
stale = true;
controller.abort();
};
}, [nodeId, reloadKey]);
const openStack = useCallback((stackName: string, destination: SenchoOpenStackDetail['destination'] = 'stack') => {
if (nodeId === undefined) return;
window.dispatchEvent(new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, {
detail: { nodeId, stackName, destination },
}));
}, [nodeId]);
const dispatchAction = useCallback(async (action: NetworkingRecommendedAction) => {
switch (action.kind) {
case 'open-stack':
case 'open-stack-networking':
case 'open-stack-doctor':
case 'open-stack-editor':
case 'open-stack-dossier':
case 'open-stack-drift':
case 'set-exposure-intent':
openStack(action.stack, destinationForAction(action.kind));
return;
case 'create-network':
if (!isAdmin) {
toast.error('Creating networks requires admin access.');
return;
}
setInitialNetworkName(action.networkName);
setShowCreateNetwork(true);
return;
case 'inspect-network':
setSelectedNetworkId(action.networkId);
return;
case 'filter-topology':
setPendingTopologyFilter(action.networkName);
setTab('topology');
return;
case 'refresh':
setReloadKey((value) => value + 1);
return;
case 'open-docs':
if (action.docsPath.startsWith('/features/') || action.docsPath.startsWith('/operations/')) {
window.open(action.docsPath, '_blank', 'noopener,noreferrer');
}
return;
case 'copy-compose-snippet':
case 'copy-docker-command': {
if (!canUseNetworkName(action.networkName)) {
toast.error('Network name is not safe to copy.');
return;
}
const text = action.kind === 'copy-compose-snippet'
? `networks:\n ${action.networkName}:\n external: true`
: `docker network create ${action.networkName}`;
try {
await navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
} catch {
toast.error('Failed to copy to clipboard.');
}
return;
}
default:
return;
}
}, [isAdmin, openStack]);
if (unsupported) {
return (
<LockCard
icon={Unplug}
title="Networking is not available on this node"
body="Upgrade the node to use the Networking page."
/>
);
}
const posture = getNetworkingPosture(findings, runtimeAvailable, isLegacy);
const mobileTone = MOBILE_MASTHEAD_TONE[POSTURE_TONE[posture.tone]];
const needsActionCount = findings.filter((f) => f.severity === 'critical' || f.severity === 'high').length;
const reviewCount = findings.filter((f) => f.severity === 'medium').length;
const driftCount = findings.filter((f) => isNetworkDriftFindingKind(f.kind)).length;
const mastheadMetadata: MastheadMetadataItem[] | undefined = overview ? [
{ label: 'NEEDS ACTION', value: String(needsActionCount), tone: needsActionCount > 0 ? 'error' : 'value' },
{ label: 'REVIEW', value: String(reviewCount), tone: reviewCount > 0 ? 'warn' : 'value' },
{ label: 'DRIFT', value: overview.networkCount === null ? '—' : String(driftCount) },
] : undefined;
const masthead = isMobile ? (
<Masthead
kicker="networking"
state={posture.label}
meta={activeNode?.name}
right={headerActions}
stateTone={mobileTone.dot}
stateClassName={mobileTone.word}
live={mobileTone.pulse}
/>
) : (
<PageMasthead
kicker="networking"
state={posture.label}
tone={POSTURE_TONE[posture.tone]}
subtitle={isLegacy ? 'Update this node to unlock findings and attention.' : 'Compose-first network inventory, topology, and findings for this node.'}
metadata={mastheadMetadata}
size="hero"
className="rounded-lg"
/>
);
const externalDependencies = networks.filter((n) => n.isExternalDependency);
const sharedNetworks = networks.filter((n) => n.sharedStackCount >= 2);
const unclassifiedExposureNetworks = networks.filter((n) => (n.exposureSummary?.unclassifiedStackCount ?? 0) > 0);
const topFindings = rankFindings(findings).slice(0, 5);
const overviewCards = overview ? (
<div className="space-y-4">
{!runtimeAvailable && !isLegacy && (
<Card className="border-warning/40 bg-warning/5">
<CardContent className="p-3 text-sm text-warning">Docker runtime is unavailable. Compose-model signals remain available.</CardContent>
</Card>
)}
<div className="grid overflow-hidden rounded-lg border border-card-border bg-card shadow-card-bevel sm:grid-cols-2 xl:grid-cols-4">
{[
{ label: 'Networks', value: overview.networkCount ?? '—', tab: 'networks' as const },
{ label: 'Managed', value: overview.senchoManagedNetworkCount ?? '—', tab: 'networks' as const },
{ label: 'External deps.', value: overview.externalDependencyNetworkCount ?? '—', tab: 'networks' as const },
{ label: 'System', value: overview.systemNetworkCount ?? '—', tab: 'networks' as const },
{ label: 'Exposed stacks', value: overview.exposedStackCount, tab: 'networks' as const },
{ label: 'Unknown exposure', value: overview.unknownExposureStackCount, tab: 'findings' as const },
{ label: 'Missing externals', value: overview.missingExternalCount, tab: 'findings' as const },
{ label: 'Collisions', value: overview.networkCollisionCount, tab: 'findings' as const },
].map((item) => (
<button
key={item.label}
type="button"
onClick={() => setTab(item.tab)}
className="border-r border-b border-card-border p-4 text-left hover:bg-muted/20 sm:[&:nth-child(2n)]:border-r-0 xl:[&:nth-child(4n)]:border-r-0 xl:[&:nth-child(n+5)]:border-b-0"
>
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">{item.label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-stat-value">{item.value}</p>
</button>
))}
</div>
<Card>
<CardContent className="p-4">
<div className="mb-2 flex items-center justify-between">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Operator attention</p>
{findings.length > 5 && (
<button type="button" className="text-xs text-brand hover:underline" onClick={() => setTab('findings')}>
View all findings
</button>
)}
</div>
{isLegacy ? (
<p className="text-sm text-stat-value">This node provides a partial networking response. Update it to review enriched findings.</p>
) : topFindings.length === 0 ? (
<p className="text-sm text-stat-value">No networking issues detected.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Severity</TableHead>
<TableHead>Finding</TableHead>
<TableHead>Stack</TableHead>
<TableHead className="w-44">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topFindings.map((finding) => {
const primary = finding.recommendedActions.find((action) =>
isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack)),
);
return (
<TableRow key={finding.id}>
<TableCell className="w-20">
<span className={`font-mono text-[10px] uppercase tracking-wide ${SEVERITY_TEXT_CLASS[finding.severity]}`}>
{finding.severity}
</span>
</TableCell>
<TableCell className="text-sm text-stat-value">{finding.title}</TableCell>
<TableCell className="font-mono text-xs text-stat-subtitle">{finding.stack ?? finding.network ?? ''}</TableCell>
<TableCell className="w-44">
{primary && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => void dispatchAction(primary)}>
{primary.label}
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{!isLegacy && (
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">External network dependencies</p>
{externalDependencies.length === 0 ? (
<p className="mt-2 text-sm text-stat-subtitle">No stack depends on an external network.</p>
) : (
<ul className="mt-2 space-y-1 text-sm">
{externalDependencies.slice(0, 5).map((n) => (
<li key={n.id} className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-xs">{n.name}</span>
<span className="text-xs text-stat-subtitle">{n.declaredExternalByStacks.join(', ')}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Shared networks</p>
{sharedNetworks.length === 0 ? (
<p className="mt-2 text-sm text-stat-subtitle">No network is shared across stacks.</p>
) : (
<ul className="mt-2 space-y-1 text-sm">
{sharedNetworks.slice(0, 5).map((n) => (
<li key={n.id} className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-xs">{n.name}</span>
<span className="text-xs text-stat-subtitle">{n.sharedStackCount} stacks</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Exposure without intent</p>
{unclassifiedExposureNetworks.length === 0 ? (
<p className="mt-2 text-sm text-stat-subtitle">Every publishing service has a classified intent.</p>
) : (
<ul className="mt-2 space-y-1 text-sm">
{unclassifiedExposureNetworks.slice(0, 5).map((n) => (
<li key={n.id} className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-xs">{n.name}</span>
<span className="text-xs text-stat-subtitle">{n.exposureSummary?.unclassifiedStackCount ?? 0}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
)}
{recentActivity.length > 0 && (
<Card>
<CardContent className="p-4">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Recent activity</p>
<ul className="mt-2 space-y-1 text-sm text-stat-subtitle">
{recentActivity.slice(0, 5).map((activity) => (
<li key={activity.id}>
{activity.stack_name ? (
<button type="button" className="hover:underline" onClick={() => openStack(activity.stack_name!)}>
{activity.message}
</button>
) : activity.message}
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
) : null;
const tabControls = (
<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}>
{TABS.map(t => (
<TabsHighlightItem key={t.value} value={t.value}>
<TabsTrigger value={t.value}>
<t.icon className="mr-1.5 h-4 w-4" strokeWidth={1.5} />
{t.label}
</TabsTrigger>
</TabsHighlightItem>
))}
</TabsHighlight>
</TabsList>
);
return (
<div className="h-full overflow-auto p-4 md:p-6">
{masthead}
<Tabs value={tab} onValueChange={(v) => setTab(v as NetworkingTab)}>
{isMobile ? (
<MobileSubTabs
ariaLabel="Networking sections"
active={tab}
onSelect={(v) => setTab(v as NetworkingTab)}
tabs={TABS.map(t => ({ value: t.value, label: t.label }))}
/>
) : (
<div className="flex items-center justify-between gap-3 mb-4 mt-4 flex-wrap rounded-lg border border-card-border bg-card/40 px-2.5 py-1.5">
{tabControls}
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => setReloadKey(k => k + 1)}
disabled={loading}
className="h-9 w-9 p-0"
aria-label="Refresh"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
</TooltipProvider>
{isAdmin && (
<Button size="sm" className="gap-1.5" onClick={() => setShowCreateNetwork(true)}>
<Plus className="w-3.5 h-3.5" strokeWidth={1.5} />
Create network
</Button>
)}
</div>
</div>
)}
<TabsContent value="overview" className="mt-4">
{loading ? <p className="text-sm text-muted-foreground">Loading overview</p> : overviewCards}
{overview && overview.renderFailedStacks.length > 0 && (
<p className="mt-3 text-sm text-warning">
{overview.renderFailedStacks.length} stack(s) could not render for full findings.
</p>
)}
</TabsContent>
<TabsContent value="networks" className="mt-4">
<NetworkInventoryTable
rows={networks}
findings={findings}
loading={loading}
isAdmin={isAdmin}
onInspect={(id) => setSelectedNetworkId(id)}
onDelete={(id, name) => setConfirmDeleteNetwork({ id, name })}
onOpenStack={openStack}
onFilterTopology={(networkName) => {
setPendingTopologyFilter(networkName);
setTab('topology');
}}
/>
</TabsContent>
<TabsContent value="topology" className="mt-4">
{isLegacy ? (
<Card>
<CardContent className="p-4 text-sm text-stat-subtitle">
Topology enrichment requires the complete networking response. Update this node to view it.
</CardContent>
</Card>
) : (
<NetworkingTopologyPanel
reloadKey={reloadKey}
pendingNetworkFilter={pendingTopologyFilter}
onPendingNetworkFilterApplied={() => setPendingTopologyFilter(undefined)}
onOpenStack={openStack}
/>
)}
</TabsContent>
<TabsContent value="findings" className="mt-4">
<NetworkingFindingsList findings={findings} loading={loading} canEdit={can} isAdmin={isAdmin} onAction={dispatchAction} disabled={isLegacy} />
</TabsContent>
</Tabs>
<CreateNetworkDialog
open={showCreateNetwork}
onOpenChange={setShowCreateNetwork}
initialName={initialNetworkName}
onCreated={({ name }) => {
if (initialNetworkName) {
const snippet = buildExternalNetworkSnippet(name);
if (snippet) {
toast.success(`Network "${name}" created`, {
action: {
label: 'Copy Compose YAML',
onClick: () => {
void navigator.clipboard.writeText(snippet)
.then(() => toast.success('Compose YAML copied'))
.catch(() => toast.error('Failed to copy Compose YAML.'));
},
},
});
}
}
setInitialNetworkName(undefined);
setReloadKey(k => k + 1);
}}
/>
<NetworkDetailDrawer
networkId={selectedNetworkId}
onClose={() => setSelectedNetworkId(null)}
/>
<ConfirmModal
open={confirmDeleteNetwork !== null}
onOpenChange={(open) => { if (!open) setConfirmDeleteNetwork(null); }}
variant="destructive"
kicker="NETWORKING · DELETE · IRREVERSIBLE"
title="Delete network"
confirmLabel={isDeleting ? 'Deleting...' : 'Delete'}
confirming={isDeleting}
onConfirm={handleDeleteNetwork}
>
<p className="text-sm text-stat-subtitle">
Permanently deletes{' '}
<span className="font-mono font-medium text-stat-value">
{confirmDeleteNetwork?.name ?? confirmDeleteNetwork?.id.substring(0, 12)}
</span>.
</p>
</ConfirmModal>
</div>
);
}
@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NetworkInventoryTable } from '../NetworkInventoryTable';
import type { NetworkingNetworkRow } from '@/types/networking';
function row(overrides: Partial<NetworkingNetworkRow> = {}): NetworkingNetworkRow {
return {
id: overrides.id ?? Math.random().toString(36),
name: 'a-net', driver: 'bridge', scope: 'local', isSystem: false, ingress: false,
composeProject: 'app', stack: 'app', connectedCount: 0, isSencho: false, ownership: 'compose-managed',
declaredByStacks: [], declaredExternalByStacks: [], isExternalDependency: false,
sharedStackCount: 0, exposureSummary: null, findingIds: [], serviceNames: [],
...overrides,
};
}
describe('NetworkInventoryTable', () => {
it('sorts rows when a column header is clicked', async () => {
const rows = [row({ id: '1', name: 'zeta' }), row({ id: '2', name: 'alpha' })];
const user = userEvent.setup();
render(
<NetworkInventoryTable
rows={rows}
findings={[]}
loading={false}
isAdmin={false}
onInspect={vi.fn()}
onDelete={vi.fn()}
onOpenStack={vi.fn()}
onFilterTopology={vi.fn()}
/>,
);
// Default sort is name ascending, independent of input row order.
const cellsBefore = screen.getAllByRole('row').slice(1).map((r) => within(r).getAllByRole('cell')[0].textContent);
expect(cellsBefore).toEqual(['alpha', 'zeta']);
// Clicking the active column's header reverses the direction.
await user.click(screen.getByRole('button', { name: /Name/ }));
const cellsAfter = screen.getAllByRole('row').slice(1).map((r) => within(r).getAllByRole('cell')[0].textContent);
expect(cellsAfter).toEqual(['zeta', 'alpha']);
});
it('orders row actions as Open, View, Topology, Delete with tooltips', () => {
const rowWithStack = row({ stack: 'app' });
render(
<NetworkInventoryTable
rows={[rowWithStack]}
findings={[]}
loading={false}
isAdmin
onInspect={vi.fn()}
onDelete={vi.fn()}
onOpenStack={vi.fn()}
onFilterTopology={vi.fn()}
/>,
);
const actionButtons = screen.getAllByRole('button').filter((b) =>
['Open app', 'Inspect a-net', 'Show a-net in topology', 'Delete a-net'].includes(b.getAttribute('aria-label') ?? ''),
);
expect(actionButtons.map((b) => b.getAttribute('aria-label'))).toEqual([
'Open app', 'Inspect a-net', 'Show a-net in topology', 'Delete a-net',
]);
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { NetworkingFindingsList } from '../NetworkingFindingsList';
import type { NetworkingFinding } from '@/types/networking';
function finding(overrides: Partial<NetworkingFinding> = {}): NetworkingFinding {
return {
id: overrides.id ?? Math.random().toString(36),
kind: 'network-mode-host',
severity: 'medium',
title: 'Some finding',
message: 'message',
evidence: [],
recommendedActions: [],
sources: ['live'],
doctorFindings: [],
...overrides,
};
}
const canEdit = () => true;
describe('NetworkingFindingsList', () => {
it('shows a calm empty state when there are no findings', () => {
render(<NetworkingFindingsList findings={[]} loading={false} canEdit={canEdit} isAdmin onAction={vi.fn()} />);
expect(screen.getByText('No networking issues detected.')).toBeInTheDocument();
});
it('groups findings into Needs action, Review recommended, and Informational', () => {
render(
<NetworkingFindingsList
findings={[
finding({ id: 'a', severity: 'critical', title: 'Critical issue' }),
finding({ id: 'b', severity: 'medium', title: 'Medium issue' }),
finding({ id: 'c', severity: 'info', title: 'Info issue' }),
]}
loading={false}
canEdit={canEdit}
isAdmin
onAction={vi.fn()}
/>,
);
expect(screen.getByText(/Needs action/)).toBeInTheDocument();
expect(screen.getByText(/Review recommended/)).toBeInTheDocument();
expect(screen.getByText(/Informational/)).toBeInTheDocument();
});
it('shows the merged source label for a card found by both engines', () => {
render(
<NetworkingFindingsList
findings={[finding({
sources: ['live', 'doctor'],
doctorFindings: [{ ruleId: 'sensitive-service-broad-exposure', ranAt: new Date().toISOString(), title: 't', message: 'm', severity: 'high' }],
})]}
loading={false}
canEdit={canEdit}
isAdmin
onAction={vi.fn()}
/>,
);
expect(screen.getByText('Live · also found by Doctor')).toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
import { Combobox } from '@/components/ui/combobox';
@@ -27,8 +27,8 @@ const EMPTY_FORM: CreateNetworkForm = {
interface CreateNetworkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a network is created so the caller can refresh its view. */
onCreated?: () => void | Promise<void>;
initialName?: string;
onCreated?: (network: { name: string }) => void | Promise<void>;
}
/**
@@ -36,10 +36,14 @@ interface CreateNetworkDialogProps {
* `/system/networks`, so it can be reused from the Resources Networks tab and
* the stack-detail Networking tab without sharing parent state.
*/
export function CreateNetworkDialog({ open, onOpenChange, onCreated }: CreateNetworkDialogProps) {
export function CreateNetworkDialog({ open, onOpenChange, initialName, onCreated }: CreateNetworkDialogProps) {
const [form, setForm] = useState<CreateNetworkForm>(EMPTY_FORM);
const [creating, setCreating] = useState(false);
useEffect(() => {
if (open) setForm((current) => ({ ...current, name: initialName ?? current.name }));
}, [initialName, open]);
const handleCreate = async () => {
setCreating(true);
try {
@@ -61,7 +65,7 @@ export function CreateNetworkDialog({ open, onOpenChange, onCreated }: CreateNet
toast.success(`Network "${form.name}" created`);
onOpenChange(false);
setForm(EMPTY_FORM);
await onCreated?.();
await onCreated?.({ name: form.name });
} catch (error) {
const err = error as Record<string, unknown>;
toast.error(String(err?.message || err?.error || 'Something went wrong.'));
@@ -1,193 +0,0 @@
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
import { toast } from '@/components/ui/toast-store';
import { copyToClipboard } from '@/lib/clipboard';
import { Container, Copy } from 'lucide-react';
export interface NetworkInspectData {
Id: string;
Name: string;
Created: string;
Scope: string;
Driver: string;
Internal: boolean;
Attachable: boolean;
Labels: Record<string, string>;
IPAM: {
Driver: string;
Config: Array<{ Subnet?: string; Gateway?: string; IPRange?: string }>;
};
Containers: Record<string, {
Name: string;
EndpointID: string;
MacAddress: string;
IPv4Address: string;
IPv6Address: string;
}>;
Options: Record<string, string>;
}
interface NetworkDetailSheetProps {
network: NetworkInspectData | null;
onClose: () => void;
}
export function NetworkDetailSheet({ network, onClose }: NetworkDetailSheetProps) {
const containerCount = network ? Object.keys(network.Containers || {}).length : 0;
const subnet = network?.IPAM?.Config?.[0]?.Subnet;
const meta = network
? `${network.Driver} · ${network.Scope}${subnet ? ` · ${subnet}` : ''} · ${containerCount} container${containerCount === 1 ? '' : 's'}`
: '';
const footerContext = network?.Created
? `Created ${new Date(network.Created).toLocaleString()}`
: undefined;
return (
<SystemSheet
open={!!network}
onOpenChange={(open) => { if (!open) onClose(); }}
crumb={['Resources', 'Networks', network?.Name ?? '—']}
name={network?.Name ?? 'Network'}
meta={meta}
footerContext={footerContext}
size="md"
>
{network && (
<>
<SheetSection title="Overview">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-xs text-muted-foreground">ID</span>
<p className="font-mono text-xs mt-0.5 flex items-center gap-1.5">
{network.Id.substring(0, 12)}
<button
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={async () => {
try { await copyToClipboard(network.Id); toast.success('ID copied'); }
catch { toast.error('Copy failed.'); }
}}
aria-label="Copy network ID"
>
<Copy className="w-3 h-3" strokeWidth={1.5} />
</button>
</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Driver</span>
<span className="text-xs mt-0.5 block">
<Badge variant="outline" className="text-[10px] h-5">{network.Driver}</Badge>
</span>
</div>
<div>
<span className="text-xs text-muted-foreground">Scope</span>
<span className="text-xs mt-0.5 block">
<Badge variant="outline" className="text-[10px] h-5">{network.Scope}</Badge>
</span>
</div>
<div>
<span className="text-xs text-muted-foreground">Created</span>
<p className="text-xs mt-0.5">{new Date(network.Created).toLocaleString()}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Internal</span>
<p className="text-xs mt-0.5">{network.Internal ? 'Yes' : 'No'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Attachable</span>
<p className="text-xs mt-0.5">{network.Attachable ? 'Yes' : 'No'}</p>
</div>
</div>
</SheetSection>
{network.IPAM?.Config?.length > 0 && (
<SheetSection title="IPAM configuration">
<div className="divide-y divide-card-border/40">
{network.IPAM.Config.map((cfg, i) => (
<div key={i} className="py-2 space-y-1.5">
{cfg.Subnet && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Subnet</span>
<span className="font-mono text-xs tabular-nums">{cfg.Subnet}</span>
</div>
)}
{cfg.Gateway && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Gateway</span>
<span className="font-mono text-xs tabular-nums">{cfg.Gateway}</span>
</div>
)}
{cfg.IPRange && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">IP Range</span>
<span className="font-mono text-xs tabular-nums">{cfg.IPRange}</span>
</div>
)}
</div>
))}
</div>
</SheetSection>
)}
{network.Options && Object.keys(network.Options).length > 0 && (
<SheetSection title="Options">
<Table>
<TableBody>
{Object.entries(network.Options).map(([key, val]) => (
<TableRow key={key} className="hover:bg-muted/30">
<TableCell className="font-mono text-xs py-1.5 text-muted-foreground">{key}</TableCell>
<TableCell className="font-mono text-xs py-1.5 text-right">{val}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SheetSection>
)}
<SheetSection title={`Connected · ${containerCount} container${containerCount === 1 ? '' : 's'}`}>
{containerCount === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">No containers connected to this network.</p>
) : (
<div className="divide-y divide-card-border/40">
{Object.entries(network.Containers).map(([id, c]) => (
<div key={id} className="py-2 space-y-1.5">
<div className="flex items-center gap-2">
<Container className="w-3.5 h-3.5 text-muted-foreground" strokeWidth={1.5} />
<span className="text-sm font-medium truncate">{c.Name}</span>
</div>
<div className="grid grid-cols-2 gap-2 pl-5">
<div>
<span className="text-[10px] text-muted-foreground">IPv4</span>
<p className="font-mono text-xs tabular-nums">{c.IPv4Address || 'N/A'}</p>
</div>
<div>
<span className="text-[10px] text-muted-foreground">MAC</span>
<p className="font-mono text-xs tabular-nums">{c.MacAddress || 'N/A'}</p>
</div>
</div>
</div>
))}
</div>
)}
</SheetSection>
{network.Labels && Object.keys(network.Labels).length > 0 && (
<SheetSection title="Labels">
<Table>
<TableBody>
{Object.entries(network.Labels).map(([key, val]) => (
<TableRow key={key} className="hover:bg-muted/30">
<TableCell className="font-mono text-xs py-1.5 text-muted-foreground">{key}</TableCell>
<TableCell className="font-mono text-xs py-1.5 text-right">{val}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SheetSection>
)}
</>
)}
</SystemSheet>
);
}
@@ -152,4 +152,17 @@ describe('StackNetworkingPanel', () => {
expect(after).toBeGreaterThan(before);
});
});
it('dispatches the node-navigate event to open the Networking page', async () => {
mockApi(facts());
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
await screen.findByText('web_backend');
const handler = vi.fn();
window.addEventListener('sencho-navigate', handler);
fireEvent.click(screen.getByText(/view node networking/i));
expect(handler).toHaveBeenCalledTimes(1);
const detail = (handler.mock.calls[0][0] as CustomEvent).detail;
expect(detail).toEqual({ view: 'networking' });
window.removeEventListener('sencho-navigate', handler);
});
});
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { CreateNetworkDialog } from '@/components/resources/CreateNetworkDialog';
import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from '@/components/NodeManager';
// Mirrors the backend networking payload shapes (the frontend never imports
// backend). IntentEntry intentionally keeps only the fields this panel reads.
@@ -336,6 +337,17 @@ export default function StackNetworkingPanel({ stackName, canEdit, doctorEnabled
<ArrowRight className="h-3 w-3" strokeWidth={1.5} /> deploy and security findings are in the Doctor tab
</div>
)}
<button
type="button"
onClick={() => {
window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
detail: { view: 'networking' },
}));
}}
className="flex items-center gap-1 font-mono text-[10px] text-stat-subtitle hover:text-stat-value transition-colors"
>
<ArrowRight className="h-3 w-3" strokeWidth={1.5} /> view node networking
</button>
</div>
);
}