Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,12 +30,28 @@ export function EntityDetailModal({
snapshots,
onClose,
zIndex,
graphNode,
graphEdges,
changeHighlight,
onNavigate,
}: EntityDetailModalProps) {
const [activeTab, setActiveTab] = useState<Tab>('details');
const [nestedIp, setNestedIp] = useState<string | null>(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<unknown, unknown>[] = isGraph
? (graphNode!.data.serviceRoles ?? [])
.map(getServiceTab)
.filter((t): t is ServiceTabConfig<unknown, unknown> => 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);
Expand All @@ -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'
Expand Down Expand Up @@ -106,17 +134,18 @@ export function EntityDetailModal({
<button type="button" className="btn-close ms-3" onClick={onClose} title="Close (Esc)" />
</div>

{/* Tabs */}
{/* Tabs — graph context adds a History tab (cross-capture role trail) and service-role tabs. */}
<div className="modal-header py-0 border-bottom-0">
<ul className="nav nav-pills gap-1" style={{ paddingTop: '4px', paddingBottom: '4px' }}>
{(['details', 'notes'] as Tab[]).map(tab => (
{((isGraph ? ['details', 'history', 'notes'] : ['details', 'notes']) as Tab[]).map(tab => (
<li key={tab} className="nav-item">
<button
className={`nav-link py-1 px-3${activeTab === tab ? ' active' : ''}`}
style={{ fontSize: '0.875rem' }}
onClick={() => setActiveTab(tab)}
>
{tab === 'details' && <i className="bi bi-bar-chart me-1" />}
{tab === 'history' && <i className="bi bi-clock-history me-1" />}
{tab === 'notes' && (
<>
<i className="bi bi-sticky me-1" />
Expand All @@ -129,6 +158,21 @@ export function EntityDetailModal({
</button>
</li>
))}
{serviceTabs.map(svc => {
const id = `svc:${svc.role}`;
return (
<li key={id} className="nav-item">
<button
className={`nav-link py-1 px-3${activeTab === id ? ' active' : ''}`}
style={{ fontSize: '0.875rem' }}
onClick={() => setActiveTab(id)}
>
<i className={`bi ${svc.icon} me-1`} />
{svc.label}
</button>
</li>
);
})}
</ul>
</div>

Expand All @@ -137,14 +181,37 @@ export function EntityDetailModal({
{/* ── DETAILS TAB ──────────────────────────────────────── */}
{activeTab === 'details' && (
<div>
{/* Change-event highlight banner (Monitor snapshot / Compare diff). */}
{changeHighlight && (
<div
className="d-flex align-items-center gap-2 rounded p-2 mb-3 small"
style={{ background: changeHighlight.color + '22', border: `1px solid ${changeHighlight.color}55` }}
>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: changeHighlight.color, flexShrink: 0, display: 'inline-block' }} />
<span style={{ color: changeHighlight.color, fontWeight: 600 }}>{changeHighlight.label}</span>
{changeHighlight.description && <span className="text-muted">— {changeHighlight.description}</span>}
</div>
)}

{/* 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 && <RoleSection fileId={fileId} role={role} readOnly={showSnapshotHistory} />}
{showRole && <RoleSection fileId={fileId} role={role} readOnly={showSnapshotHistory} raisedModal={zIndex != null} />}

{entityType === 'IP' && fileId && (
<HostIdentitySection fileId={fileId} ip={entityKey} zIndex={zIndex} />
)}

{/* Graph host detail: traffic counters, protocol chips, per-peer Connections table. */}
{isGraph && !graphNode!.data.isL2 && graphEdges && (
<GraphNodeDetailsSection
node={graphNode!}
edges={graphEdges}
fileId={fileId}
onOpenPeer={setNestedIp}
onNavigate={navigateAndClose}
/>
)}

{hasFileStats && (
<EntityStatsSection
stats={stats}
Expand All @@ -155,7 +222,7 @@ export function EntityDetailModal({
)}

{/* Fallback for entity types without file stats and no role section */}
{!showRole && !hasFileStats && (
{!showRole && !hasFileStats && !isGraph && (
<p className="text-muted small fst-italic">
No per-file stats available in this context.
</p>
Expand All @@ -173,7 +240,9 @@ export function EntityDetailModal({
</div>
)}

{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 ? (
<SnapshotHistoryTable
entityType={entityType}
entityKey={entityKey}
Expand All @@ -188,10 +257,25 @@ export function EntityDetailModal({
historyError={historyError}
onClose={onClose}
/>
)}
))}
</div>
)}

{/* ── HISTORY TAB (graph context only) ─────────────────── */}
{activeTab === 'history' && isGraph && (
<GraphHistoryTab
entityType={entityType}
entityKey={entityKey}
fileId={fileId}
onNavigate={navigateAndClose}
/>
)}

{/* ── SERVICE-ROLE TAB (DNS, …) ────────────────────────── */}
{activeServiceTab && graphNode && (
<ServiceLogTab fileId={fileId} ip={graphNode.data.ip} config={activeServiceTab} />
)}

{/* ── NOTES TAB ────────────────────────────────────────── */}
{activeTab === 'notes' && (
<NotesTab
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/common/EntityDetailModal/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { parseDateTime } from '@/utils/dateUtils';
export 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));
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
// Clamp so multi-TB totals (prod captures reach tens of TB) never index past the unit list and
// render "1.00 undefined".
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { GraphNode } from '@/features/network/types';
import type { EntityType } from '@/features/notes/services/entityNotesService';

/**
* Derives the {@link EntityDetailModal} identity props from a graph node, mirroring the mapping the
* former NodeDetails did internally (#578): a pure-L2 node is a DEVICE keyed by MAC; everything else
* is an IP keyed by its address.
*/
export function graphNodeEntity(node: GraphNode): {
entityType: EntityType;
entityKey: string;
displayName: string;
} {
const entityType: EntityType = node.data.isL2 ? 'DEVICE' : 'IP';
const entityKey = node.data.isL2 ? (node.data.mac ?? node.data.ip) : node.data.ip;
return { entityType, entityKey, displayName: entityKey };
}
1 change: 1 addition & 0 deletions frontend/src/components/common/EntityDetailModal/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { EntityDetailModal } from './EntityDetailModal';
export { graphNodeEntity } from './graphNodeEntity';
Loading
Loading