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(() => { + // Guard every async state write: the modal is reused across nodes, so a slow response for a + // previous entity must not overwrite the newer one's history. + let alive = true; + setHistory([]); + setHistoryRoles({}); + setHistoryError(null); + setPage(1); + setHistoryLoading(true); + entityNotesService + .getHistory(entityType, entityKey) + .then(entries => { + if (!alive) return; + 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 => { if (alive) setHistoryRoles(Object.fromEntries(pairs)); }); + }) + .catch(() => { if (alive) setHistoryError('Failed to load history'); }) + .finally(() => { if (alive) setHistoryLoading(false); }); + return () => { alive = 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..5e16f7f3 --- /dev/null +++ b/frontend/src/components/common/EntityDetailModal/sections/GraphNodeDetailsSection.tsx @@ -0,0 +1,209 @@ +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'; +import { formatBytes, formatNumber } from '../format'; + +const PEERS_PAGE_SIZE = 15; + +/** + * Human-readable phantom-node explanations, joined with a space (the icmp/no-response messages are + * mutually exclusive with the more specific arp/ttl ones, mirroring the original precedence). + */ +function ghostMessages(flags: string[]): string[] { + const msgs: string[] = []; + if (flags.includes('arp-no-reply')) msgs.push('ARP request target — never replied.'); + if (flags.includes('ttl-exceeded')) msgs.push('Traceroute intermediate hop — only appeared via ICMP TTL-exceeded replies.'); + if (flags.includes('icmp-unreachable') && !flags.includes('arp-no-reply') && !flags.includes('ttl-exceeded')) { + msgs.push('ICMP probe target — never responded.'); + } + if (flags.includes('no-response') && !flags.includes('arp-no-reply') && !flags.includes('icmp-unreachable') && !flags.includes('ttl-exceeded')) { + msgs.push('Scan target — received traffic but never sent a reply.'); + } + return msgs; +} + +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 && ( + +