diff --git a/backend/src/main/java/com/tracepcap/insights/controller/HostIdentitiesController.java b/backend/src/main/java/com/tracepcap/insights/controller/HostIdentitiesController.java index e4ada868..1e2b6d99 100644 --- a/backend/src/main/java/com/tracepcap/insights/controller/HostIdentitiesController.java +++ b/backend/src/main/java/com/tracepcap/insights/controller/HostIdentitiesController.java @@ -1,8 +1,11 @@ package com.tracepcap.insights.controller; +import com.tracepcap.common.exception.ResourceNotFoundException; import com.tracepcap.insights.dto.HostIdentityDto; +import com.tracepcap.insights.dto.HostIdentityEvidenceDto; import com.tracepcap.insights.dto.NodeRoleDto; import com.tracepcap.insights.repository.HostIdentityRepository; +import com.tracepcap.insights.service.HostIdentityEvidenceService; import com.tracepcap.insights.service.HostIdentityService; import com.tracepcap.insights.service.NodeRoleService; import lombok.extern.slf4j.Slf4j; @@ -28,6 +31,7 @@ public class HostIdentitiesController { private final HostIdentityRepository hostIdentityRepository; private final HostIdentityService hostIdentityService; + private final HostIdentityEvidenceService hostIdentityEvidenceService; private final NodeRoleService nodeRoleService; @GetMapping("/{fileId}/host-identities") @@ -70,6 +74,24 @@ public ResponseEntity> getHostIdentities(@PathVariable UUI return ResponseEntity.ok(result); } + @GetMapping("/{fileId}/hosts/{ip}/identity") + @Operation( + summary = "Adjudicated identity plus evidence axes for one host", + description = + "The full explainable classification for a single IP — the verdict (label/basis/confidence/" + + "contested/candidates) and the measured facts behind it (hardware, service, behaviour) —" + + " so any surface holding a fileId+ip can render 'the verdict, and why' without the graph.") + public ResponseEntity getHostIdentityEvidence( + @PathVariable UUID fileId, @PathVariable String ip) { + return ResponseEntity.ok( + hostIdentityEvidenceService + .evidenceFor(fileId, ip) + .orElseThrow( + () -> + new ResourceNotFoundException( + "No host " + ip + " in file " + fileId))); + } + @GetMapping("/{fileId}/node-roles") @Operation( summary = "All human-confirmed entity roles in a file", diff --git a/backend/src/main/java/com/tracepcap/insights/dto/HostIdentityEvidenceDto.java b/backend/src/main/java/com/tracepcap/insights/dto/HostIdentityEvidenceDto.java new file mode 100644 index 00000000..d3c1985e --- /dev/null +++ b/backend/src/main/java/com/tracepcap/insights/dto/HostIdentityEvidenceDto.java @@ -0,0 +1,85 @@ +package com.tracepcap.insights.dto; + +import java.util.List; +import java.util.Map; +import lombok.Builder; +import lombok.Value; + +/** + * The full explainable-classification payload for one host in one file (#556 follow-up). + * + *

Composes the adjudicated identity (the verdict) with the measured evidence axes (the facts that + * fed the vote), so any host-inspection surface — the network-graph node, the conversation host + * click, the monitor drift panels — can render the same "verdict, and why" experience from just a + * {@code fileId + ip}. Before this, that experience lived only in the network graph, which had the + * axis facts because it derived them frontend-side from the conversation list; a bare IP had no way + * to obtain them. + * + *

Grades, deliberately kept distinct (mirrors the SPI split): + * + *

+ * + * The axes carry facts only — scores belong to {@code candidates}, never to a fact (#499). + */ +@Value +@Builder +public class HostIdentityEvidenceDto { + String ip; + + // ── Verdict (adjudicated identity) ────────────────────────────────────────── + /** The one answer to "what is this host?" — a device type, or the human's label verbatim. */ + String primaryLabel; + /** HUMAN (confirmed node-role/override label) or MACHINE (classification vote). */ + String basis; + int confidence; + /** True when machine candidates were too close to call; render the contest, not the winner. */ + boolean contested; + /** Competing candidates when contested; each is {@code {label, source, score, reasons[]}}. */ + List> candidates; + + // ── Hardware facts ────────────────────────────────────────────────────────── + String manufacturer; + Integer ttl; + + // ── Service facts ─────────────────────────────────────────────────────────── + /** Confirmed service roles (e.g. ["dns"]); never null, may be empty. */ + List serviceRoles; + /** nDPI application names identified in this host's traffic; never null, may be empty. */ + List ndpiApps; + + // ── Behaviour facts (#496) ────────────────────────────────────────────────── + /** Connections this host opened (sent SYN without ACK). */ + int initiatedConversations; + /** Connections opened by peers that this host answered. */ + int answeredConversations; + /** Total conversations this host took part in, regardless of measured direction. */ + int conversationCount; + /** Distinct peers this host talked to — the fan-out the router signal keys on. */ + int peerCount; + + // ── Geolocation (external hosts only) ─────────────────────────────────────── + // From the persisted GeoIP cache (offline MMDB or ipinfo, per the offline requirement). All null + // for private/internal IPs, which are never geolocated — the panel omits the whole block then. + /** Country name, e.g. "United States". */ + String country; + /** ISO country code, e.g. "US" — drives the flag emoji. */ + String countryCode; + /** Autonomous-system number, e.g. "AS8075" (ipinfo only; null under the offline MMDB). */ + String asn; + /** Owning organisation, e.g. "Microsoft Corporation". */ + String org; + /** Which resolver produced this — "ipinfo" (online) or "mmdb" (offline DB). */ + String geoSource; +} diff --git a/backend/src/main/java/com/tracepcap/insights/service/HostIdentityEvidenceService.java b/backend/src/main/java/com/tracepcap/insights/service/HostIdentityEvidenceService.java new file mode 100644 index 00000000..7bb65d8f --- /dev/null +++ b/backend/src/main/java/com/tracepcap/insights/service/HostIdentityEvidenceService.java @@ -0,0 +1,147 @@ +package com.tracepcap.insights.service; + +import com.tracepcap.analysis.spi.ConversationLookup; +import com.tracepcap.analysis.spi.ConversationLookup.ConversationFacts; +import com.tracepcap.analysis.spi.GeoOrgLookup; +import com.tracepcap.analysis.spi.GeoOrgLookup.IpPlace; +import com.tracepcap.analysis.spi.HostClassificationLookup; +import com.tracepcap.analysis.spi.HostClassificationLookup.HostFacts; +import com.tracepcap.insights.dto.HostIdentityEvidenceDto; +import com.tracepcap.insights.entity.HostIdentityEntity; +import com.tracepcap.insights.repository.HostIdentityRepository; +import org.springframework.dao.DataIntegrityViolationException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * Composes the full explainable-classification payload for one host (#556 follow-up): the + * adjudicated identity plus the measured evidence axes, so any surface holding a {@code fileId + ip} + * can render the "verdict, and why" experience that used to live only in the network graph. + * + *

The verdict comes from the adjudicated {@link HostIdentityEntity}; hardware/service facts from + * {@link HostClassificationLookup}; and the behaviour facts (who opened the connection) plus the + * nDPI app list are derived here from {@link ConversationLookup#conversationFactsForIp}, the same + * per-IP fact base the frontend previously folded by hand off the whole graph. + */ +@Service +@RequiredArgsConstructor +public class HostIdentityEvidenceService { + + private final HostIdentityRepository hostIdentityRepository; + private final HostIdentityService hostIdentityService; + private final HostClassificationLookup hostClassificationLookup; + private final ConversationLookup conversationLookup; + private final GeoOrgLookup geoOrgLookup; + + /** + * The identity + evidence for one host, or empty when the file has no such host. Lazily backfills + * identities for legacy files (mirrors {@code HostIdentitiesController}) so a host classified + * before the adjudicator existed still resolves a verdict. + */ + public Optional evidenceFor(UUID fileId, String ip) { + Optional factsOpt = hostClassificationLookup.hostFactsByIp(fileId, ip); + if (factsOpt.isEmpty()) { + return Optional.empty(); + } + HostFacts facts = factsOpt.get(); + + // Lazy backfill (#521): files analysed before the adjudicator existed have classifications but no + // identities. Adjudicate on first read. This mirrors HostIdentitiesController#getHostIdentities, + // including the concurrent-first-read recovery: two panels opened at once can both find it empty + // and both delete-and-regenerate; the loser hits a unique (file_id, ip) violation, which is fine + // as long as the winner's rows are now present — otherwise it was a real failure and propagates. + List identities = hostIdentityRepository.findByFileId(fileId); + if (identities.isEmpty()) { + try { + hostIdentityService.adjudicateFile(fileId); + } catch (DataIntegrityViolationException raced) { + if (hostIdentityRepository.findByFileId(fileId).isEmpty()) { + throw raced; + } + } + identities = hostIdentityRepository.findByFileId(fileId); + } + HostIdentityEntity identity = + identities.stream().filter(h -> ip.equals(h.getIp())).findFirst().orElse(null); + + // Behaviour + nDPI apps, folded from this IP's conversations (#496). + // + // initiated/answered are direction-gated (require a measured initiator); conversationCount and + // the distinct-peer set are NOT — they are the raw fan-out the traffic-pattern signal weighs, so + // the panel still shows real evidence on a capture where nDPI never measured flow direction. + int initiated = 0; + int answered = 0; + int conversationCount = 0; + Set peers = new LinkedHashSet<>(); + Set apps = new LinkedHashSet<>(); + for (ConversationFacts c : conversationLookup.conversationFactsForIp(fileId, ip)) { + conversationCount++; + String src = c.flow().srcIp(); + String dst = c.flow().dstIp(); + String peer = ip.equals(src) ? dst : src; + if (peer != null && !peer.isBlank() && !ip.equals(peer)) { + peers.add(peer); + } + String initiator = c.flow().initiatorIp(); + if (initiator != null) { + if (ip.equals(initiator)) { + initiated++; + } else { + answered++; + } + } + String app = c.findings().appName(); + if (app != null && !app.isBlank()) { + apps.add(app); + } + } + + HostIdentityEvidenceDto.HostIdentityEvidenceDtoBuilder b = + HostIdentityEvidenceDto.builder() + .ip(ip) + .manufacturer(facts.manufacturer()) + .ttl(facts.ttl()) + .serviceRoles(new ArrayList<>(facts.serviceRoles())) + .ndpiApps(new ArrayList<>(apps)) + .initiatedConversations(initiated) + .answeredConversations(answered) + .conversationCount(conversationCount) + .peerCount(peers.size()); + + // Geolocation for external hosts (INFERRED, provenance-carrying). Absent from the map for + // private/internal IPs, which are never geolocated — the builder stays null and the panel omits + // the block. This reads the persisted geo cache; it does not trigger a fresh lookup on view. + IpPlace place = geoOrgLookup.placesFor(List.of(ip)).get(ip); + if (place != null) { + b.country(place.country()) + .countryCode(place.countryCode()) + .asn(place.asn()) + .org(place.org()) + .geoSource(place.geoSource()); + } + + if (identity != null) { + b.primaryLabel(identity.getPrimaryLabel()) + .basis(identity.getBasis()) + .confidence(identity.getConfidence()) + .contested(identity.isContested()) + .candidates(identity.getCandidates()); + } else { + // No adjudicated identity (e.g. a host with no conversations the adjudicator saw): fall back to + // the raw classification so the surface still names something rather than blank. + b.primaryLabel(facts.deviceType()) + .basis(HostIdentityEntity.BASIS_MACHINE) + .confidence(facts.confidence()) + .contested(false) + .candidates(List.of()); + } + + return Optional.of(b.build()); + } +} diff --git a/frontend/src/components/common/DeviceClassificationPopup/DeviceClassificationPopup.tsx b/frontend/src/components/common/DeviceClassificationPopup/DeviceClassificationPopup.tsx deleted file mode 100644 index 9ace3d74..00000000 --- a/frontend/src/components/common/DeviceClassificationPopup/DeviceClassificationPopup.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { useRef } from 'react'; -import { Badge } from '@govtechsg/sgds-react'; -import { deviceTypeLabel, deviceTypeColor, confidenceLevel, buildDeviceSignals } from '@/utils/deviceType'; -import { portToServiceLabel } from '@/utils/portUtils'; -import { useClickOutside } from '@/utils/useClickOutside'; - -export interface DeviceClassificationInfo { - ip: string; - deviceType: string; - confidence: number; - manufacturer?: string; - ttl?: number; - role?: 'client' | 'server'; - conversationPort?: number; -} - -interface DeviceClassificationPopupProps { - info: DeviceClassificationInfo; - onClose: () => void; -} - -export function DeviceClassificationPopup({ info, onClose }: DeviceClassificationPopupProps) { - const popupRef = useRef(null); - const { fired: signals } = buildDeviceSignals({ manufacturer: info.manufacturer, ttl: info.ttl, confidence: info.confidence, deviceType: info.deviceType?.toString() }); - const level = confidenceLevel(info.confidence); - const badgeBg = deviceTypeColor(info.deviceType); - - useClickOutside(popupRef, onClose); - - return ( -

- {/* Header */} -
- Classification - -
- -
-

{info.ip}

- - {/* Type row */} - {(() => { - let typeLabel: string | null = null; - let typeNote: string | null = null; - if (info.role === 'client') { - typeLabel = 'Client'; - typeNote = 'Initiated this conversation'; - } else if (info.role === 'server' && info.conversationPort != null) { - typeLabel = portToServiceLabel(info.conversationPort) ?? 'Server'; - typeNote = `Based on destination port ${info.conversationPort} in this conversation`; - } - if (!typeLabel) return null; - return ( -
- - Type - -
- {typeLabel} - {typeNote && ( -
- {typeNote} -
- )} -
-
- ); - })()} - - {/* Device row */} -
- - Device - -
- - {deviceTypeLabel(info.deviceType)} - - {signals.length > 0 && ( -
    - {signals.map((s, i) => ( -
  • {s}
  • - ))} -
- )} -
-
-
-
- - {info.confidence}% — {level} - -
-
-
- - {/* Role row */} - {info.role && ( -
- - Role - -
- - {info.role.charAt(0).toUpperCase() + info.role.slice(1)} - -
- {info.role === 'client' - ? 'Initiated this conversation' - : 'Received this conversation'} -
-
-
- )} -
-
- ); -} diff --git a/frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx b/frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx index 26534ee5..ca242c5a 100644 --- a/frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx +++ b/frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx @@ -3,10 +3,9 @@ import { useEntityRole } from './hooks/useEntityRole'; import { useEntityStats } from './hooks/useEntityStats'; import { useEntityNote } from './hooks/useEntityNote'; import { useEntityHistory } from './hooks/useEntityHistory'; -import { useHostClassification } from './hooks/useHostClassification'; import { useIpSnapshotHistory } from './hooks/useIpSnapshotHistory'; import { RoleSection } from './sections/RoleSection'; -import { HostClassificationSection } from './sections/HostClassificationSection'; +import { HostIdentitySection } from '@components/common/HostIdentitySection'; import { EntityStatsSection } from './sections/EntityStatsSection'; import { SnapshotHistoryTable } from './sections/SnapshotHistoryTable'; import { CaptureHistoryTable } from './sections/CaptureHistoryTable'; @@ -36,7 +35,6 @@ export function EntityDetailModal({ const { stats, statsLoading, statsError } = useEntityStats(entityType, entityKey, fileId); const note = useEntityNote(entityType, entityKey); const { history, historyLoading, historyError } = useEntityHistory(entityType, entityKey); - const hostClass = useHostClassification(entityType, entityKey, fileId); const { ipSnapHistory, ipHistoryLoading, reload: reloadIpHistory } = useIpSnapshotHistory(entityType, entityKey, snapshots); // ESC closes — but not if a nested IP modal is open (let the nested one handle it first) @@ -143,8 +141,8 @@ export function EntityDetailModal({ so the top card is a read-only present-day summary. */} {showRole && } - {entityType === 'IP' && hostClass && ( - + {entityType === 'IP' && fileId && ( + )} {hasFileStats && ( diff --git a/frontend/src/components/common/EntityDetailModal/hooks/useHostClassification.ts b/frontend/src/components/common/EntityDetailModal/hooks/useHostClassification.ts deleted file mode 100644 index c11edfdb..00000000 --- a/frontend/src/components/common/EntityDetailModal/hooks/useHostClassification.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useState } from 'react'; -import { apiClient } from '@/services/api/client'; -import type { EntityType } from '@/features/notes/services/entityNotesService'; -import type { HostClassification } from '../types'; - -/** - * Host classification signals (manufacturer/device type/TTL/confidence) for an IP - * in the current file. - */ -export function useHostClassification(entityType: EntityType, entityKey: string, fileId: string) { - const [hostClass, setHostClass] = useState(null); - - useEffect(() => { - let active = true; - // Reset so a previous entity's classification can't leak when the modal is reused. - setHostClass(null); - if (entityType !== 'IP' || !fileId) return; - apiClient - .get(`/files/${fileId}/host-classifications`) - .then(r => { - if (!active) return; - const match = r.data.find(h => h.ip === entityKey); - setHostClass(match ?? null); - }) - .catch(() => {}); - return () => { active = false; }; - }, [entityType, entityKey, fileId]); - - return hostClass; -} diff --git a/frontend/src/components/common/EntityDetailModal/sections/HostClassificationSection.tsx b/frontend/src/components/common/EntityDetailModal/sections/HostClassificationSection.tsx deleted file mode 100644 index 8f23a3de..00000000 --- a/frontend/src/components/common/EntityDetailModal/sections/HostClassificationSection.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Alert } from '@components/common/Alert'; -import { buildDeviceSignals, confidenceLevel, type DeviceSignalInfo } from '@/utils/deviceType'; -import type { HostClassification } from '../types'; - -interface HostClassificationSectionProps { - hostClass: HostClassification; -} - -/** Device classification signals (manufacturer/type/TTL/confidence) for an IP. */ -export function HostClassificationSection({ hostClass }: HostClassificationSectionProps) { - const signalInfo: DeviceSignalInfo = { - manufacturer: hostClass.manufacturer ?? undefined, - ttl: hostClass.ttl ?? undefined, - confidence: hostClass.confidence ?? 0, - deviceType: hostClass.deviceType ?? undefined, - apps: [], - }; - const { fired, missing } = buildDeviceSignals(signalInfo); - - return ( -
-
Device Classification
-
- {hostClass.manufacturer && ( -
- Manufacturer - {hostClass.manufacturer} -
- )} - {hostClass.deviceType && ( -
- Device Type - {hostClass.deviceType} -
- )} - {hostClass.ttl != null && ( -
- TTL - {hostClass.ttl} -
- )} - {hostClass.confidence != null && ( -
- Confidence - {hostClass.confidence}%{hostClass.confidence != null && — {confidenceLevel(hostClass.confidence)}} -
- )} -
- {fired.length > 0 && ( - - - How this is derived - -
    - {fired.map((s, i) =>
  • {s}
  • )} -
-
- )} - {missing.length > 0 && ( - - - What would improve confidence - -
    - {missing.map((s, i) =>
  • {s}
  • )} -
-
- )} -
- ); -} diff --git a/frontend/src/components/common/HostIdentitySection/HostIdentitySection.tsx b/frontend/src/components/common/HostIdentitySection/HostIdentitySection.tsx new file mode 100644 index 00000000..fcb61458 --- /dev/null +++ b/frontend/src/components/common/HostIdentitySection/HostIdentitySection.tsx @@ -0,0 +1,336 @@ +import { useEffect, useRef, useState } from 'react'; +import { Modal } from '@govtechsg/sgds-react'; +import { AdjudicationPanel } from '@components/common/AdjudicationPanel/AdjudicationPanel'; +import { + AXIS_ORDER, + AXIS_META, + detectAxisConflict, + axisFacts, + type AxisKey, +} from '@/features/network/classificationAxes'; +import type { NodeData, NodeType } from '@/features/network/types'; +import { DEVICE_TYPES, deviceTypeLabel } from '@/utils/deviceType'; +import { conversationService } from '@/features/conversation/services/conversationService'; +import type { DeviceType, HostIdentityEvidence } from '@/types'; +import { Spinner } from '@components/common/Spinner/Spinner'; + +/** Predefined override/evidence label options for the Identity panel (matches NodeDetails). */ +const DEVICE_LABELS = DEVICE_TYPES.filter(t => t !== 'UNKNOWN').map(deviceTypeLabel); + +/** + * The observed-service nodeType for the conflict check, derived from confirmed service roles. This is + * deliberately the *service* signal (what the host served), NOT the identity verdict — the whole + * point of the conflict banner is to notice when the two disagree (#499). + */ +function nodeTypeFromServiceRoles(roles: string[]): NodeType { + if (roles.includes('dns')) return 'dns-server'; + if (roles.includes('web')) return 'web-server'; + if (roles.includes('api')) return 'web-server'; + return 'unknown'; +} + +/** ISO country code → flag emoji (regional indicators). */ +function countryFlag(code: string): string { + return code + .toUpperCase() + .split('') + .map(c => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65)) + .join(''); +} + +/** Which geo resolver answered — provenance the user can judge (online ipinfo vs bundled offline DB). */ +const GEO_SOURCE_LABEL: Record = { + ipinfo: { + label: 'ipinfo.io', + title: 'Resolved online via the ipinfo.io API (cached locally). Includes ASN/org.', + }, + mmdb: { + label: 'Offline DB', + title: 'Resolved from the bundled DB-IP Lite database (offline, or ipinfo unreachable). Accuracy may be lower, especially for cloud IPs.', + }, +}; + +/** + * Geolocation block for external hosts — the country/flag, ASN, org and geo-source provenance the + * conversation modal used to be the only place to see. Rendered only when the host has a geo record + * (private/internal IPs never do, so the block is simply absent for them). + */ +function GeoBlock({ ev }: { ev: HostIdentityEvidence }) { + if (!ev.countryCode && !ev.country && !ev.asn && !ev.org) return null; + const src = ev.geoSource ? GEO_SOURCE_LABEL[ev.geoSource] : undefined; + return ( +
+
+ Geolocation +
+
+
Country
+
+ {ev.countryCode ? ( + <> + {countryFlag(ev.countryCode)} {ev.country ?? ev.countryCode} ({ev.countryCode}) + + ) : ( + Unknown + )} + {src && ( + + {src.label} + + )} +
+ {ev.asn && ( + <> +
ASN
+
{ev.asn}
+ + )} + {ev.org && ( + <> +
Organisation
+
{ev.org}
+ + )} +
+

+ Inferred from a geo database — the address range’s registration, not the device’s true location. +

+
+ ); +} + +/** + * Adapts the flat evidence DTO into the partial {@link NodeData} shape the axis helpers already read, + * so `axisFacts` / `detectAxisConflict` stay a single tested implementation rather than being + * duplicated for a second data source. + */ +function toNodeData(ev: HostIdentityEvidence): NodeData { + return { + ip: ev.ip, + manufacturer: ev.manufacturer ?? undefined, + ttl: ev.ttl ?? undefined, + serviceRoles: ev.serviceRoles, + nodeTypeEvidence: { ndpiApps: ev.ndpiApps }, + initiatedConversations: ev.initiatedConversations, + answeredConversations: ev.answeredConversations, + conversationCount: ev.conversationCount, + peerCount: ev.peerCount, + nodeType: nodeTypeFromServiceRoles(ev.serviceRoles), + deviceType: + ev.basis === 'HUMAN' ? undefined : (ev.primaryLabel as DeviceType | undefined), + // Fields the axis helpers never read — filled to satisfy the type. + packetsSent: 0, + packetsReceived: 0, + bytesSent: 0, + bytesReceived: 0, + totalBytes: 0, + role: 'unknown', + protocols: [], + connections: 0, + }; +} + +interface Props { + fileId: string; + ip: string; + /** Z-index of a surrounding raised modal, so the add-evidence popup stacks above it. */ + zIndex?: number; + /** Called after an override/evidence change, so a parent (e.g. the graph) can refresh its copy. */ + onChanged?: () => void; +} + +/** + * The shared "verdict, and why" host-classification block (#556 follow-up): the adjudicated Identity + * (with override / evidence tools) plus the three measured evidence axes and the conflict banner. + * + *

This is the block that used to live only inside NodeDetails. Promoting it here — fed by a + * fileId+ip evidence fetch — lets the conversation host click, the monitor drift panels, and the + * graph node all render the same explainable classification, so users see how a verdict was derived + * and can correct or append to it everywhere, not only in the graph. + */ +export function HostIdentitySection({ fileId, ip, zIndex, onChanged }: Props) { + const [evidence, setEvidence] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedAxis, setExpandedAxis] = useState(null); + const [evidenceInfoOpen, setEvidenceInfoOpen] = useState(false); + + // Guard against a slow response landing after the section has moved to another host. + const currentIpRef = useRef(ip); + currentIpRef.current = ip; + + const load = () => { + setLoading(true); + setError(null); + conversationService + .getHostIdentityEvidence(fileId, ip) + .then(ev => { + if (ip !== currentIpRef.current) return; + setEvidence(ev); + }) + .catch(() => { + if (ip !== currentIpRef.current) return; + setError('Could not load this host’s classification.'); + }) + .finally(() => { + if (ip === currentIpRef.current) setLoading(false); + }); + }; + + useEffect(() => { + setExpandedAxis(null); + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileId, ip]); + + const handleChanged = () => { + // Re-fetch our own copy so the verdict reflects the override, then let the parent refresh too. + load(); + onChanged?.(); + }; + + if (loading) { + return ( +

+ Loading classification… +
+ ); + } + if (error || !evidence) { + return

{error ?? 'No classification available.'}

; + } + + const nodeData = toNodeData(evidence); + + return ( + <> + {/* Adjudicated identity — verdict, override, evidence, "why". */} + + + {/* Evidence weighed — the independent measured signals behind the verdict. */} +
+
+ + Evidence weighed + +