From c6ec277ef259c7e4918110331ac5e5df4ee008ef Mon Sep 17 00:00:00 2001 From: NotYuSheng Date: Sun, 26 Jul 2026 14:18:56 +0800 Subject: [PATCH 1/4] refactor(host-detail): collapse NodeDetails into EntityDetailModal (#578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There were two near-identical host-detail modals: the graph's NodeDetails and the entity panel EntityDetailModal (monitor/conversation/analysis). After #575 both embedded HostIdentitySection, but the shells still diverged — so a host clicked in the graph looked "almost but not quite" like the same host clicked in monitor, and geo/other bits differed by surface. Unify on EntityDetailModal as the single shell. When given graph context (`graphNode`/`graphEdges`), it now renders the graph-only pieces that used to live in NodeDetails: - GraphNodeDetailsSection: ghost-node warning, traffic counters, protocol chips, and the per-peer Connections table (from graph edges). - GraphHistoryTab: the cross-capture role-trail history (its own tab). - Service-role log tabs (DNS/web/…) and the change-event highlight banner. Migrate all three NodeDetails call sites — NetworkDiagramPage, ComparePage, SnapshotDetailModal (monitor) — to EntityDetailModal via a small `graphNodeEntity(node)` helper (L2 → DEVICE/MAC, else IP). Delete NodeDetails and its CSS. Side fix: the .tp-nested-modal z-index rules lived in NodeDetails.css and only loaded when the graph panel was mounted; they now ship with EntityDetailModal.css, so the add-evidence/explainer/role modals stack correctly on every surface, not just the graph. Frontend-only. Type-check clean; all 145 tests pass. Playwright-verified the graph node click opens the unified modal with Identity + evidence axes + geolocation + Connections + Protocols + History/service tabs. Co-Authored-By: Claude Opus 4.8 --- .../EntityDetailModal/EntityDetailModal.css | 23 + .../EntityDetailModal/EntityDetailModal.tsx | 96 ++- .../EntityDetailModal/graphNodeEntity.ts | 17 + .../common/EntityDetailModal/index.ts | 1 + .../sections/GraphHistoryTab.tsx | 155 ++++ .../sections/GraphNodeDetailsSection.tsx | 206 ++++++ .../common/EntityDetailModal/types.ts | 19 +- .../SnapshotDetailModal.tsx | 12 +- .../network/NodeDetails/NodeDetails.css | 96 --- .../network/NodeDetails/NodeDetails.tsx | 662 ------------------ .../components/network/NodeDetails/index.ts | 1 - frontend/src/pages/Compare/ComparePage.tsx | 10 +- .../NetworkDiagram/NetworkDiagramPage.tsx | 13 +- 13 files changed, 532 insertions(+), 779 deletions(-) create mode 100644 frontend/src/components/common/EntityDetailModal/EntityDetailModal.css create mode 100644 frontend/src/components/common/EntityDetailModal/graphNodeEntity.ts create mode 100644 frontend/src/components/common/EntityDetailModal/sections/GraphHistoryTab.tsx create mode 100644 frontend/src/components/common/EntityDetailModal/sections/GraphNodeDetailsSection.tsx delete mode 100644 frontend/src/components/network/NodeDetails/NodeDetails.css delete mode 100644 frontend/src/components/network/NodeDetails/NodeDetails.tsx delete mode 100644 frontend/src/components/network/NodeDetails/index.ts diff --git a/frontend/src/components/common/EntityDetailModal/EntityDetailModal.css b/frontend/src/components/common/EntityDetailModal/EntityDetailModal.css new file mode 100644 index 00000000..7987fda4 --- /dev/null +++ b/frontend/src/components/common/EntityDetailModal/EntityDetailModal.css @@ -0,0 +1,23 @@ +/* Peer-row affordance in the graph Connections table (GraphNodeDetailsSection). */ +.node-details-peer-row { + cursor: pointer; +} +.node-details-peer-icon { + font-size: 0.7rem; +} +.node-details-hostname { + font-size: 0.85rem; +} + +/* Nested modals (add-evidence, evidence explainer, role help) opened from within this modal, which + itself renders as a raised overlay in monitor/graph mode. These must sit above it so they aren't + hidden behind the parent panel (modal-in-modal). React-Bootstrap sets its own INLINE z-index on + the .modal/.modal-backdrop elements, so !important is required to win over it. + (Previously these lived in NodeDetails.css and only loaded when the graph node panel was on the + page — now they ship with the shared modal, so the conversation/monitor paths get them too.) */ +.tp-nested-modal { + z-index: 2000 !important; +} +.tp-nested-modal-backdrop { + z-index: 1990 !important; +} diff --git a/frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx b/frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx index ca242c5a..41939d12 100644 --- a/frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx +++ b/frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx @@ -10,7 +10,12 @@ import { EntityStatsSection } from './sections/EntityStatsSection'; import { SnapshotHistoryTable } from './sections/SnapshotHistoryTable'; import { CaptureHistoryTable } from './sections/CaptureHistoryTable'; import { NotesTab } from './sections/NotesTab'; +import { GraphNodeDetailsSection } from './sections/GraphNodeDetailsSection'; +import { GraphHistoryTab } from './sections/GraphHistoryTab'; +import { ServiceLogTab } from '@components/network/ServiceLogTab/ServiceLogTab'; +import { getServiceTab, type ServiceTabConfig } from '@/features/network/serviceTabs'; import type { EntityDetailModalProps, Tab } from './types'; +import './EntityDetailModal.css'; export function EntityDetailModal({ entityType, @@ -25,12 +30,28 @@ export function EntityDetailModal({ snapshots, onClose, zIndex, + graphNode, + graphEdges, + changeHighlight, + onNavigate, }: EntityDetailModalProps) { const [activeTab, setActiveTab] = useState('details'); const [nestedIp, setNestedIp] = useState(null); const showRole = entityType === 'IP' || entityType === 'DEVICE'; + // Graph context (#578): when a node is supplied, the modal renders the network-graph host detail — + // traffic counters + Connections table + a History tab + service-role log tabs. + const isGraph = !!graphNode; + const serviceTabs: ServiceTabConfig[] = isGraph + ? (graphNode!.data.serviceRoles ?? []) + .map(getServiceTab) + .filter((t): t is ServiceTabConfig => Boolean(t)) + : []; + const activeServiceTab = serviceTabs.find(t => `svc:${t.role}` === activeTab); + // A peer/history navigation: route via the caller, then close (the graph is behind this modal). + const navigateAndClose = (path: string) => { onClose(); onNavigate?.(path); }; + const role = useEntityRole(entityType, entityKey, fileId, showRole); const { stats, statsLoading, statsError } = useEntityStats(entityType, entityKey, fileId); const note = useEntityNote(entityType, entityKey); @@ -52,6 +73,13 @@ export function EntityDetailModal({ return () => { document.body.style.overflow = ''; }; }, []); + // If the active service tab is no longer offered (modal reused for a node without that role), + // fall back to Details so the body never goes blank. + useEffect(() => { + const isBaseTab = activeTab === 'details' || activeTab === 'notes' || activeTab === 'history'; + if (!isBaseTab && !activeServiceTab) setActiveTab('details'); + }, [activeTab, activeServiceTab]); + const entityLabel = entityType === 'PROTOCOL' ? 'protocol' : entityType === 'APPLICATION' ? 'application' @@ -106,10 +134,10 @@ export function EntityDetailModal({ ))} + {serviceTabs.map(svc => { + const id = `svc:${svc.role}`; + return ( +
  • + +
  • + ); + })} @@ -137,14 +181,37 @@ export function EntityDetailModal({ {/* ── DETAILS TAB ──────────────────────────────────────── */} {activeTab === 'details' && (
    + {/* Change-event highlight banner (Monitor snapshot / Compare diff). */} + {changeHighlight && ( +
    + + {changeHighlight.label} + {changeHighlight.description && — {changeHighlight.description}} +
    + )} + {/* Multi-snapshot context: role is edited per-snapshot in the history table below, so the top card is a read-only present-day summary. */} - {showRole && } + {showRole && } {entityType === 'IP' && fileId && ( )} + {/* Graph host detail: traffic counters, protocol chips, per-peer Connections table. */} + {isGraph && !graphNode!.data.isL2 && graphEdges && ( + + )} + {hasFileStats && ( No per-file stats available in this context.

    @@ -173,7 +240,9 @@ export function EntityDetailModal({
    )} - {showSnapshotHistory ? ( + {/* Cross-capture history: the graph surface has its own tab for it (role trail), so + only the non-graph entity panel renders it inline here. */} + {!isGraph && (showSnapshotHistory ? ( - )} + ))} )} + {/* ── HISTORY TAB (graph context only) ─────────────────── */} + {activeTab === 'history' && isGraph && ( + + )} + + {/* ── SERVICE-ROLE TAB (DNS, …) ────────────────────────── */} + {activeServiceTab && graphNode && ( + + )} + {/* ── NOTES TAB ────────────────────────────────────────── */} {activeTab === 'notes' && ( void; +} + +/** + * The graph host's cross-capture history with a per-file role trail — ported from the former + * NodeDetails History tab (#578). Distinct from the plain CaptureHistoryTable: it adds the Role + * column (with carried/stale badges) and a "current" marker, and links each row to its analysis. + */ +export function GraphHistoryTab({ entityType, entityKey, fileId, onNavigate }: Props) { + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); + const [historyRoles, setHistoryRoles] = useState>({}); + const [page, setPage] = useState(1); + + useEffect(() => { + setHistory([]); + setHistoryRoles({}); + setHistoryError(null); + setPage(1); + setHistoryLoading(true); + entityNotesService + .getHistory(entityType, entityKey) + .then(entries => { + setHistory(entries); + Promise.all( + entries.map(e => + insightsService + .getNodeRole(entityType, entityKey, e.fileId) + .then(r => [e.fileId, { label: r?.roleLabel ?? null, origin: r?.origin ?? null, stale: !!r?.staleSince }] as const) + .catch(() => [e.fileId, { label: null, origin: null, stale: false }] as const), + ), + ).then(pairs => setHistoryRoles(Object.fromEntries(pairs))); + }) + .catch(() => setHistoryError('Failed to load history')) + .finally(() => setHistoryLoading(false)); + }, [entityType, entityKey]); + + const totalPages = Math.ceil(history.length / HISTORY_PAGE_SIZE); + const pageClamped = Math.min(page, Math.max(1, totalPages)); + const visible = history.slice((pageClamped - 1) * HISTORY_PAGE_SIZE, pageClamped * HISTORY_PAGE_SIZE); + + return ( +
    +

    + Files in which this {entityType === 'DEVICE' ? 'device (MAC)' : 'IP address'} has appeared, most recent first. +

    + {historyLoading && ( +
    +
    +

    Loading history…

    +
    + )} + {historyError &&
    {historyError}
    } + {!historyLoading && !historyError && history.length === 0 && ( +

    No history found across uploaded files.

    + )} + {!historyLoading && history.length > 0 && ( +
    + + + + + + + + + + + + + {visible.map(entry => { + const r = historyRoles[entry.fileId]; + return ( + + + + + + + + + ); + })} + +
    FileRoleCapture StartPacketsBytes
    + {entry.fileName} + {entry.fileId === fileId && ( + current + )} + + {r?.label ? ( + + {r.label} + {r.origin === 'CARRIED_FORWARD' && ( + carried + )} + {r.stale && ( + + + {entry.startTime ? new Date(entry.startTime).toLocaleString('en-GB') : '—'} + + {entry.packetCount != null ? formatNumber(entry.packetCount) : '—'} + + {entry.totalBytes != null ? formatBytes(entry.totalBytes) : '—'} + +
    + {totalPages > 1 && ( + + )} +
    + )} +
    + ); +} diff --git a/frontend/src/components/common/EntityDetailModal/sections/GraphNodeDetailsSection.tsx b/frontend/src/components/common/EntityDetailModal/sections/GraphNodeDetailsSection.tsx new file mode 100644 index 00000000..6fea021c --- /dev/null +++ b/frontend/src/components/common/EntityDetailModal/sections/GraphNodeDetailsSection.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react'; +import { Badge } from '@govtechsg/sgds-react'; +import type { GraphNode, GraphEdge } from '@/features/network/types'; +import { getProtocolColor } from '@/features/network/constants'; +import { HostnameSourceBadge } from '@components/common/HostnameSourceBadge/HostnameSourceBadge'; +import { Pagination } from '@components/common/Pagination/Pagination'; +import { Alert } from '@components/common/Alert'; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; +} + +const formatNumber = (num: number) => num.toLocaleString(); + +const PEERS_PAGE_SIZE = 15; + +const GHOST_META: Record = { + 'no-response': { label: 'No response', color: '#e74c3c' }, + 'arp-no-reply': { label: 'ARP no-reply', color: '#e67e22' }, + 'icmp-unreachable': { label: 'ICMP unreachable', color: '#c0392b' }, + 'ttl-exceeded': { label: 'TTL exceeded', color: '#8e44ad' }, +}; + +interface Props { + node: GraphNode; + edges: GraphEdge[]; + fileId: string; + /** Open a peer IP in a nested modal. */ + onOpenPeer: (ip: string) => void; + /** Navigate to a route (peer-row → conversations); the host routes and closes. */ + onNavigate?: (path: string) => void; +} + +/** + * The network-graph-only host detail — measured traffic counters, protocol chips, and the per-peer + * Connections table (derived from graph edges). Extracted verbatim from the former standalone + * NodeDetails so EntityDetailModal can host the same content when it is given graph context (#578). + */ +export function GraphNodeDetailsSection({ node, edges, fileId, onOpenPeer, onNavigate }: Props) { + const [peersPage, setPeersPage] = useState(1); + + const connectedEdges = edges.filter(e => e.source === node.id || e.target === node.id); + + // Per-peer summary: peerIp → { packets, bytes, apps } + const peerMap = new Map }>(); + connectedEdges.forEach(edge => { + const peer = edge.source === node.id ? edge.target : edge.source; + const existing = peerMap.get(peer) ?? { packets: 0, bytes: 0, apps: new Set() }; + existing.packets += edge.data.packetCount; + existing.bytes += edge.data.totalBytes; + existing.apps.add(edge.data.appName ?? edge.data.protocol); + peerMap.set(peer, existing); + }); + + const peers = Array.from(peerMap.entries()).sort((a, b) => b[1].bytes - a[1].bytes); + const peersTotalPages = Math.ceil(peers.length / PEERS_PAGE_SIZE); + const peersPageClamped = Math.min(peersPage, Math.max(1, peersTotalPages)); + const visiblePeers = peers.slice( + (peersPageClamped - 1) * PEERS_PAGE_SIZE, + peersPageClamped * PEERS_PAGE_SIZE, + ); + + return ( + <> + {/* Ghost node warning */} + {node.data.ghostFlags && node.data.ghostFlags.length > 0 && ( + +