mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 05:58:37 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user