Skip to content

feat(classification): unify host classification into one explainable panel - #575

Merged
NotYuSheng merged 2 commits into
mainfrom
feature/unified-host-classification
Jul 26, 2026
Merged

feat(classification): unify host classification into one explainable panel#575
NotYuSheng merged 2 commits into
mainfrom
feature/unified-host-classification

Conversation

@NotYuSheng

@NotYuSheng NotYuSheng commented Jul 26, 2026

Copy link
Copy Markdown
Owner

What & why

After #556, clicking a device badge in the Conversation Detail view opened a legacy single-deviceType popup (DeviceClassificationPopup) — a thinner, different modal than the axis-based explainable classification the network graph shows. This unifies every host-inspection surface onto one shared HostIdentitySection so users see how a classification was derived and can correct/append to it everywhere.

Changes

Backend

  • New GET /files/{fileId}/hosts/{ip}/identityHostIdentityEvidenceDto: the adjudicated verdict (label/basis/confidence/contested/candidates) plus the measured evidence axes (hardware, service + nDPI apps, behaviour), composed in HostIdentityEvidenceService from existing SPI lookups — no new queries.
  • Behaviour axis carries direction-independent fan-out (conversationCount/peerCount), so a high-peer router still shows real evidence on captures where nDPI never measured flow direction (was "Nothing observed").
  • Geolocation (country/flag, ASN, org, geo-source) added to the DTO via GeoOrgLookup (offline-capable) — external hosts only.

Frontend

  • Extracted the graph-only Identity + 3-axes + adjudication block into shared HostIdentitySection; NodeDetails and EntityDetailModal both render it. Removes HostClassificationSection/useHostClassification and the legacy DeviceClassificationPopup.
  • New Geolocation block in the shared panel (restored from the old conversation modal, now unreachable once host detail routed through NodeDetails).
  • Conversation redesign: Source ↔ Destination flow header; each host card shows its identity chip (confidence pill top-left) and opens the full EntityDetailModal. Metadata regrouped into a security strip + collapsible Timing/Geo/HTTP/TLS groups, all open by default.
  • Removed the dead buildDeviceSignals block from deviceType.ts.

Contract

  • OpenAPI baseline regenerated for the new endpoint + DTO fields.

Verification

  • Backend endpoint returns real data end-to-end; geo verified with a seeded external record.
  • Frontend type-check clean; all 145 unit tests pass (2 new fan-out tests).
  • Docker stack builds and runs; Playwright confirms the flow header + host click opening the rich modal in light and dark themes.

Notes

  • The "Why → LABEL (0)" empty reasons on the existing fixtures is stale data — those files were analyzed before scores/reasons were persisted; re-analyzing fills them in. Not a code change.
  • The fixture captures are all-internal traffic, so the Geolocation block is correctly empty on them (external hosts show it).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added detailed host identity evidence for individual IP addresses, including verdicts, confidence, device and service details, behavior metrics, and geolocation.
    • Redesigned host details to show explainable identity evidence, conflicts, candidate classifications, and data provenance.
    • Replaced the device classification popup with a shared host details experience across conversation and network views.
    • Improved conversation details with grouped metadata, host identity cards, confidence indicators, and security signals.
  • Bug Fixes

    • Behavior evidence now remains available when connection direction data is unavailable by using conversation and peer metrics.

…panel

Route every host-inspection surface through a single shared
HostIdentitySection so the network graph, the conversation host click,
and EntityDetailModal all render the same "verdict, and why" experience:
adjudicated Identity, the three evidence axes, "I disagree" override, and
add-evidence.

Backend: new GET /files/{fileId}/hosts/{ip}/identity endpoint returning
HostIdentityEvidenceDto — the adjudicated verdict plus the measured
evidence axes (hardware, service + nDPI apps, behaviour), composed in
HostIdentityEvidenceService from existing SPI lookups (no new queries).

Behaviour axis now also carries direction-independent fan-out
(conversationCount / peerCount) so a high-peer router still shows real
evidence on captures where nDPI never measured flow direction, instead of
"Nothing observed".

Geolocation (country/flag, ASN, org, geo-source provenance) restored to
the shared host panel via GeoOrgLookup — it previously lived only in the
conversation modal, so it was unreachable once host detail routed through
NodeDetails/EntityDetailModal. External hosts only; private IPs omit it.

Conversation modal redesigned around a Source <-> Destination flow header:
each host card shows its adjudicated identity chip (confidence pill
top-left) and opens the full EntityDetailModal on click. Metadata regrouped
into a security strip + collapsible Timing/Geo/HTTP/TLS groups, all open by
default. Removes the legacy DeviceClassificationPopup and the dead
buildDeviceSignals block.

OpenAPI baseline regenerated. All 145 frontend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@NotYuSheng, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2a8a29aa-d8ff-46fd-8de9-098af2639d9f

📥 Commits

Reviewing files that changed from the base of the PR and between 024dc17 and 281e311.

📒 Files selected for processing (6)
  • backend/src/main/java/com/tracepcap/insights/service/HostIdentityEvidenceService.java
  • frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx
  • frontend/src/components/common/HostIdentitySection/HostIdentitySection.tsx
  • frontend/src/components/conversation/ConversationDetail/ConversationDetail.css
  • frontend/src/components/conversation/ConversationDetail/ConversationDetail.tsx
  • frontend/src/components/network/NodeDetails/NodeDetails.tsx
📝 Walkthrough

Walkthrough

The change adds a host identity evidence API and shared frontend identity section, replaces legacy classification displays, redesigns conversation metadata and host navigation, centralizes node identity rendering, and adds behaviour-axis fan-out evidence fallback logic.

Changes

Host identity evidence redesign

Layer / File(s) Summary
Evidence API and data contract
backend/src/main/java/com/tracepcap/insights/..., frontend/src/types/..., frontend/src/services/api/endpoints.ts, frontend/src/features/conversation/services/..., openapi/baseline.json
Adds the evidence DTO and service, exposes GET /files/{fileId}/hosts/{ip}/identity, aggregates identity, conversation, and geolocation facts, and publishes matching frontend and OpenAPI contracts.
Shared identity evidence section
frontend/src/components/common/HostIdentitySection/*, frontend/src/components/common/EntityDetailModal/..., frontend/src/utils/deviceType.ts
Replaces legacy classification rendering with shared verdict, evidence-axis, conflict, geolocation, loading, and adjudication UI.
Conversation identity flow and metadata
frontend/src/components/conversation/ConversationDetail/*
Adds identity-backed host flow cards, IP detail navigation, grouped metadata, security signals, responsive layout, and themed styling.
Network node identity integration
frontend/src/components/network/NodeDetails/NodeDetails.tsx
Removes local adjudication state and evidence panels and delegates identity rendering to HostIdentitySection.
Behaviour evidence fallback
frontend/src/features/network/classificationAxes.ts, frontend/src/features/network/types/index.ts, frontend/src/features/network/__tests__/classificationAxes.test.ts
Adds direction-independent conversation and peer metrics as a fallback when initiator direction is unavailable, with precedence tests for measured direction data.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ConversationDetail
  participant EntityDetailModal
  participant HostIdentitySection
  participant conversationService
  participant IdentityEvidenceAPI
  ConversationDetail->>EntityDetailModal: Open IP details
  EntityDetailModal->>HostIdentitySection: Render fileId and ip
  HostIdentitySection->>conversationService: getHostIdentityEvidence(fileId, ip)
  conversationService->>IdentityEvidenceAPI: GET host identity evidence
  IdentityEvidenceAPI-->>conversationService: HostIdentityEvidence
  conversationService-->>HostIdentitySection: Evidence payload
  HostIdentitySection-->>EntityDetailModal: Render verdict and evidence axes
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: consolidating host classification into a shared explainable panel.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/network/NodeDetails/NodeDetails.tsx (1)

52-52: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale comment.

This comment describes the evidence explainer/axis-expansion state that moved into HostIdentitySection; it now sits above the tab state. Remove it.

🧹 Drop the orphaned comment
-  // Evidence header explainer modal, and which axis fact row is expanded to its derivation.
   const [activeTab, setActiveTab] = useState<Tab>('details');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/network/NodeDetails/NodeDetails.tsx` at line 52,
Remove the stale evidence explainer/axis-expansion comment near the tab state in
NodeDetails; that state now belongs to HostIdentitySection, so leave the
surrounding tab-state code unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@backend/src/main/java/com/tracepcap/insights/service/HostIdentityEvidenceService.java`:
- Around line 53-60: The lazy adjudication flow in HostIdentityEvidenceService
must handle concurrent adjudication safely. Update the logic around
hostIdentityRepository.findByFileId and hostIdentityService.adjudicateFile to
catch a losing DataIntegrityViolationException, then re-read the identities and
continue the existing IP filtering path; reuse the established getHostIdentities
recovery behavior or centralize that handling in the adjudication service.

In `@frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx`:
- Around line 144-146: Update the IP branch in EntityDetailModal so
HostIdentitySection renders only when fileId is non-empty, matching the existing
fileId guards used by the roles/statistics sections while preserving the
entityType === 'IP' condition.

In `@frontend/src/components/common/HostIdentitySection/HostIdentitySection.tsx`:
- Around line 336-338: Update the explanatory paragraph in HostIdentitySection
so its opening reference says “the evidence axes in this panel” instead of “the
evidence axes below,” accurately referring to the modal’s content while
preserving the rest of the copy.
- Line 290: Add aria-hidden="true" to the decorative chevron icon in
HostIdentitySection and the warning glyph at the corresponding warning-icon
location, matching the existing treatment of the info icon. Preserve their
current classes and rendering behavior.
- Around line 123-124: Introduce or reuse a shared deviceTypeFromLabel() helper
in `@/utils/deviceType`, then map the adjudicated primaryLabel through it instead
of casting: update HostIdentitySection.tsx lines 123-124 before assigning
nodeData.deviceType, and ConversationDetail.tsx lines 171-175 before unioning
identity?.primaryLabel with cls?.deviceType. Ensure axisFacts,
detectAxisConflict, deviceTypeColor, and deviceTypeIcon receive actual
DeviceType codes; no direct change is needed elsewhere.

In
`@frontend/src/components/conversation/ConversationDetail/ConversationDetail.css`:
- Around line 67-77: Merge the later .cd-host-role declaration containing
margin-left: auto with the existing .cd-host-role block, keeping all
declarations in one rule. Remove the duplicate selector and preserve the
resulting styles, including the intended margin-left behavior alongside
align-self.
- Line 161: Remove the duplicate `@media` (max-width: 700px) rule for .cd-groups
in ConversationDetail.css, keeping a single rule with grid-template-columns:
1fr.
- Around line 119-124: Update the .cd-host-open visibility rules alongside
.cd-host:hover so the hint also becomes visible when the associated .cd-host
receives :focus-visible. Preserve the existing hover behavior and styling.

In
`@frontend/src/components/conversation/ConversationDetail/ConversationDetail.tsx`:
- Around line 374-416: The HostFlowCard instances in the flow header should only
be interactive when ConversationDetailProps.fileId is defined. Update the
onOpen/interactive behavior for both source and destination cards, and suppress
the “Open full host details →” affordance when fileId is absent, while
preserving the existing modal behavior when fileId is available.
- Around line 299-316: Update the identity-loading effect around
getHostIdentities and the EntityDetailModal close flow so identityMap is
refreshed whenever the host modal closes after an override, not only when fileId
changes. Reuse the existing fetch/stale-response protection, and scope the
request to the two endpoint IPs if the service API supports it.

---

Outside diff comments:
In `@frontend/src/components/network/NodeDetails/NodeDetails.tsx`:
- Line 52: Remove the stale evidence explainer/axis-expansion comment near the
tab state in NodeDetails; that state now belongs to HostIdentitySection, so
leave the surrounding tab-state code unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 11039a35-4e8d-4900-b40f-8526bbd9226c

📥 Commits

Reviewing files that changed from the base of the PR and between 41a819e and 024dc17.

📒 Files selected for processing (20)
  • backend/src/main/java/com/tracepcap/insights/controller/HostIdentitiesController.java
  • backend/src/main/java/com/tracepcap/insights/dto/HostIdentityEvidenceDto.java
  • backend/src/main/java/com/tracepcap/insights/service/HostIdentityEvidenceService.java
  • frontend/src/components/common/DeviceClassificationPopup/DeviceClassificationPopup.tsx
  • frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx
  • frontend/src/components/common/EntityDetailModal/hooks/useHostClassification.ts
  • frontend/src/components/common/EntityDetailModal/sections/HostClassificationSection.tsx
  • frontend/src/components/common/HostIdentitySection/HostIdentitySection.tsx
  • frontend/src/components/common/HostIdentitySection/index.ts
  • frontend/src/components/conversation/ConversationDetail/ConversationDetail.css
  • frontend/src/components/conversation/ConversationDetail/ConversationDetail.tsx
  • frontend/src/components/network/NodeDetails/NodeDetails.tsx
  • frontend/src/features/conversation/services/conversationService.ts
  • frontend/src/features/network/__tests__/classificationAxes.test.ts
  • frontend/src/features/network/classificationAxes.ts
  • frontend/src/features/network/types/index.ts
  • frontend/src/services/api/endpoints.ts
  • frontend/src/types/common.types.ts
  • frontend/src/utils/deviceType.ts
  • openapi/baseline.json
💤 Files with no reviewable changes (4)
  • frontend/src/components/common/EntityDetailModal/hooks/useHostClassification.ts
  • frontend/src/components/common/DeviceClassificationPopup/DeviceClassificationPopup.tsx
  • frontend/src/components/common/EntityDetailModal/sections/HostClassificationSection.tsx
  • frontend/src/utils/deviceType.ts

Comment thread frontend/src/components/common/EntityDetailModal/EntityDetailModal.tsx Outdated
Comment on lines +123 to +124
deviceType:
ev.basis === 'HUMAN' ? undefined : (ev.primaryLabel as DeviceType | undefined),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Adjudicated primaryLabel is a display string but is cast to DeviceType in two places. The verdict vocabulary (DEVICE_TYPES.map(deviceTypeLabel)) differs from the DeviceType codes consumed by the axis helpers and the icon/color utilities, so both casts silently produce unmatched values rather than a type error.

  • frontend/src/components/common/HostIdentitySection/HostIdentitySection.tsx#L123-L124: map the label back to a DeviceType before assigning nodeData.deviceType, so axisFacts and detectAxisConflict see a real code.
  • frontend/src/components/conversation/ConversationDetail/ConversationDetail.tsx#L171-L175: apply the same mapping to identity?.primaryLabel before unioning it with cls?.deviceType for deviceTypeColor/deviceTypeIcon.

Cleanest fix is a shared deviceTypeFromLabel() helper in @/utils/deviceType (or have the evidence DTO carry the code alongside the label).

📍 Affects 2 files
  • frontend/src/components/common/HostIdentitySection/HostIdentitySection.tsx#L123-L124 (this comment)
  • frontend/src/components/conversation/ConversationDetail/ConversationDetail.tsx#L171-L175
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/common/HostIdentitySection/HostIdentitySection.tsx`
around lines 123 - 124, Introduce or reuse a shared deviceTypeFromLabel() helper
in `@/utils/deviceType`, then map the adjudicated primaryLabel through it instead
of casting: update HostIdentitySection.tsx lines 123-124 before assigning
nodeData.deviceType, and ConversationDetail.tsx lines 171-175 before unioning
identity?.primaryLabel with cls?.deviceType. Ensure axisFacts,
detectAxisConflict, deviceTypeColor, and deviceTypeIcon receive actual
DeviceType codes; no direct change is needed elsewhere.

Comment thread frontend/src/components/common/HostIdentitySection/HostIdentitySection.tsx Outdated
Comment thread frontend/src/components/common/HostIdentitySection/HostIdentitySection.tsx Outdated
Comment thread frontend/src/components/conversation/ConversationDetail/ConversationDetail.css Outdated
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 700px) { .cd-groups { grid-template-columns: 1fr; } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Duplicate media query.

The @media (max-width: 700px) { .cd-groups … } rule is emitted twice back to back; drop one.

🧹 Remove the duplicate
 `@media` (max-width: 700px) { .cd-groups { grid-template-columns: 1fr; } }
-@media (max-width: 700px) { .cd-groups { grid-template-columns: 1fr; } }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@frontend/src/components/conversation/ConversationDetail/ConversationDetail.css`
at line 161, Remove the duplicate `@media` (max-width: 700px) rule for .cd-groups
in ConversationDetail.css, keeping a single rule with grid-template-columns:
1fr.

Comment thread frontend/src/components/conversation/ConversationDetail/ConversationDetail.tsx Outdated
Backend
- Make lazy adjudication in HostIdentityEvidenceService race-safe: catch
  DataIntegrityViolationException from a concurrent first-read and re-read
  the winner's rows, mirroring HostIdentitiesController. Also collapse the
  double findByFileId into a single load.

Frontend
- ConversationDetail flow chip: stop casting a HUMAN primaryLabel (free
  text, e.g. "Bob's Laptop") to DeviceType for the icon/colour lookup; fall
  back to the machine classification's deviceType instead.
- Host flow cards render non-interactive (no button, no "open" hint) when
  there is no fileId to open the detail modal — no more dead affordance.
- Refetch the flow-header identity map when the host modal closes, so an
  in-modal "I disagree" override reflects in the chip without a remount.
- EntityDetailModal: guard HostIdentitySection on fileId, matching sibling
  sections.
- Drop the dead `seed` prop from HostIdentitySection (no caller used it).
- a11y: aria-hidden on decorative chevron/warning/contested icons;
  reveal the host-card "open" hint on keyboard focus too.
- Copy: "evidence axes below" -> "in this panel" in the explainer modal.
- Merge the split .cd-host-role rules; remove a stale NodeDetails comment.

Follow-up #576 filed for scoping the identity fetch to the two endpoint
IPs instead of the whole file.

All 145 frontend tests pass; backend compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@NotYuSheng

Copy link
Copy Markdown
Owner Author

Thanks @coderabbitai — addressed the findings in 281e311:

Fixed

  • Race-safe lazy adjudication (Major) — HostIdentityEvidenceService now catches DataIntegrityViolationException from a concurrent first-read and re-reads the winner's rows, mirroring HostIdentitiesController. Also collapsed the double findByFileId into one load.
  • primaryLabel cast to DeviceType (Major) — the ConversationDetail flow chip no longer casts a HUMAN label (free text like "Bob's Laptop") to DeviceType; it falls back to the machine classification's deviceType for the icon/colour. (The toNodeData cast already guards HUMAN → undefined, so machine verdicts there are always codes.)
  • Host cards clickable without fileId (Minor) — cards now render non-interactive (plain div, no "open" hint) when there's no fileId.
  • Stale identity chips after in-modal override (Minor) — the flow-header identity map refetches when the host modal closes.
  • EntityDetailModal fileId guard (Minor) — HostIdentitySection now gated on fileId, matching sibling sections.
  • a11yaria-hidden on the decorative chevron, warning, and contested icons; the host-card "open" hint now also reveals on :focus-visible.
  • Copy — "evidence axes below" → "in this panel".
  • Split .cd-host-role declarations — merged into one block.
  • Stale NodeDetails comment — removed.

Follow-up issue

Already resolved / N/A

  • Duplicate @media (max-width: 700px) rule — only one instance in the current file; no change needed.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@NotYuSheng Thanks for the thorough follow-up. The fixes in 281e311 address the reported correctness, interaction, accessibility, and stale-state concerns.

Keeping the endpoint-only identity fetch optimization scoped to #576 is reasonable.

🐇 ✅


✏️ Learnings added
Learnt from: NotYuSheng
URL: https://github.com/NotYuSheng/TracePcap/pull/575

Timestamp: 2026-07-26T05:18:30.612Z
Learning: For the conversation flow-header identity map in `frontend/src/components/conversation/ConversationDetail/ConversationDetail.tsx`, restricting the host identity fetch to only the two endpoint IPs is intentionally deferred to GitHub issue `#576`; the current whole-file fetch has no correctness impact.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@NotYuSheng
NotYuSheng merged commit f22652d into main Jul 26, 2026
6 checks passed
@NotYuSheng
NotYuSheng deleted the feature/unified-host-classification branch July 26, 2026 05:45
NotYuSheng added a commit that referenced this pull request Jul 26, 2026
) (#581)

* refactor(host-detail): collapse NodeDetails into EntityDetailModal (#578)

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 <noreply@anthropic.com>

* fix(review): address PR #581 CodeRabbit findings

- GraphHistoryTab: add a cancellation guard so a slow history/role fetch for a
  previous node can't overwrite the newer node's state (the modal is reused
  across nodes).
- formatBytes: clamp the unit index and extend to TB/PB so multi-TB totals no
  longer render "1.00 undefined" (fixed once in the shared format.ts;
  GraphNodeDetailsSection now imports it instead of its own copy).
- Phantom-node messages: build them as an array joined with a space so
  arp-no-reply + ttl-exceeded no longer concatenate without a separator.
- Tab type: use `(string & {})` so literal autocomplete for the known tab
  names survives alongside the dynamic `svc:<role>` tabs.

Type-check clean; all 145 tests pass; graph modal re-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(geo): always show Geolocation section with an explicit empty state

Previously the Geolocation block was hidden entirely when a host had no geo
data, so private IPs (and unresolved public ones) showed a blank where the
section should be — reads as missing/broken.

Now the section always renders; when there is no data it says why:
- Private IP (RFC 1918 / loopback / link-local / ULA): "Private IP — not
  geolocated. Internal addresses (RFC 1918) are not registered to any location."
- Public IP with no cache record: "No geolocation on record for this address."

Shared HostIdentitySection change, so it applies on every host-detail surface
(graph, monitor, conversation, analysis) at once.

145 tests pass; verified the private-IP empty state renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(ip): consolidate isPrivateIp/CIDR helpers into one shared util

Addresses the /code-review findings on the geo empty-state:

- **Custom private-range overrides ignored** (correctness): the GeoBlock's
  private/public decision used a bare RFC1918 regex, so an IP the user
  overrode (public→private or vice versa) via Classification-overrides got the
  wrong "why no geo" message. HostIdentitySection now fetches the custom ranges
  and feeds them to the shared isPrivateIp, matching the rest of the app.
- **Duplicated logic**: isPrivateIp/isRfc1918/ipInCidr/ipToInt were copied
  across ConversationDetail, IpDriftPanel, SubnetDiagramModal and (newly)
  HostIdentitySection, already drifting. Consolidated into
  utils/ipClassification.ts as the single source of truth; all callers import
  it. (SubnetDiagramModal's ipInCidr also gains the IPv6 guard it lacked.)
- **Malformed JSDoc**: removed the stray '/**' left over from the replaced
  GeoBlock comment.
- The narrow-regex fallback is now the one canonical, unit-tested heuristic.

New utils/ipClassification.test.ts covers RFC1918 edges, the IPv6 CIDR guard,
and override-wins-either-direction. 153 tests pass; private-IP geo message
re-verified in the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
NotYuSheng added a commit that referenced this pull request Jul 27, 2026
Eight pacing/framing refinements to the walkthrough, plus repairs to steps
the merge from main had broken.

Framing:
- Conversation modal re-frames after each tab's content loads. The session
  panel is taller with its spinner up than with text rendered, so the browser
  clamps scrollTop when it shrinks; a single scroll gets undone a frame later.
  Scroll twice with a settle between.
- Topology diagram anchors its card header under the sticky navbar, re-applied
  after layout and fit-view since both change the card's height. The card is
  the graph body's grandparent — xpath=.. lands inside it and excludes the
  header this is meant to keep on screen.
- Extracted files stops on the table instead of scrolling to footer whitespace.
- Filter generator holds on the generated BPF expression before executing.
- Story Q&A waits for the answer to stop streaming, not just for the spinner
  to clear, so the reply is not filmed half-written. Inside the fast-forward
  span: still waiting on the model.
- Snapshot modal tabs and the IP drift modal hold long enough to read.

Repairs:
- Restore the edge-colour cycle (transport/application/volume). It was absent
  from the spec entirely.
- Snapshot tabs matched nothing: decorative <i> icons pad the accessible name,
  so /^Changes/ never fired and the loop `continue`d past all five, filming a
  2s flash of the diagram. Match loosely, scope to the pill strip, and assert
  rather than skip.
- Conversation host details and graph node details followed markup that #575
  and #578 replaced (title="Click for details" and the "Node Details" title).
  Anchor on the aria-label and on #entity-detail-title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NotYuSheng added a commit that referenced this pull request Jul 27, 2026
The merge from main brought in #575/#578, which collapsed the conversation
device-type badge and the graph's "Node Details" modal into the shared
EntityDetailModal. Both selectors were dead: the recording failed outright on
the badge, and would have failed on the node modal a section later. Re-anchored
on the host card's aria-label and on #entity-detail-title, since the new modal's
visible title is the host's own display name.

The edge-colour cycle had been lost entirely — a `select` filtered by hasText
matches against its options, so it silently found nothing and skipped all three
modes. Anchored on the option values instead, and asserted rather than guarded,
because the silent skip is what hid it.

Framing fixes, each measured against the running app rather than guessed:

- Conversation packet/session tabs: the session panel keeps growing after its
  payload first renders, so scrollHeight is stale at that moment and scrolling
  to it stops ~54px short. Anchor on the tab strip's own offset instead.
- Topology: the card is the graph body's grandparent, not its parent, so the
  previous anchor excluded the very header it was meant to keep on screen. It
  also needs re-framing after layout and fit-view, both of which resize the card.
- Story Q&A: "Thinking..." disappearing means the first token landed, not the
  last — wait for the reply to stop growing, inside the fast-forward span so the
  wait still races.
- Filter generator: hold on the generated BPF expression before executing it.
- Extracted files: stop on the table rather than scrolling on to page bottom.
- Snapshot tabs and the IP drift modal: hold long enough to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NotYuSheng added a commit that referenced this pull request Jul 27, 2026
* Reproducible README demo GIF — Playwright drives, ffmpeg encodes

The demo GIF was previously hand-recorded, so refreshing it after a UI
change meant redoing it by hand. This makes it a scripted artifact:
`scripts/record-demo.sh` drives the real app through a scripted
walkthrough, records WebM, and encodes an optimized GIF.

Recording is opt-in. `playwright test` runs every project by default and
--project alone can't express "exclude from the default run", so the demo
project is only registered when DEMO=1 (`npm run demo:record`), with
testIgnore keeping demo.spec.ts out of the chromium project. A plain e2e
run stays at 3 tests and doesn't pay the walkthrough's deliberate pauses.

Viewport is 1280x720 — 16:9 at 2x the README's render width, so the GIF
downscale stays sharp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0165VsVLYm3G8DnE5N9vXToS

* Demo GIF covers the whole product — analysis, then Monitor over time

The recorded walkthrough stopped at the analysis tabs, so the README GIF
never showed Monitor, which is half of what TracePcap does. This extends
it to eleven sections: one capture's life (upload with Suricata on, the
overview charts, a Telegram conversation down to its reassembled session
and host-identity evidence, an LLM story, a generated filter, extracted
files, topology, geography) and then eight weekly captures of a different
network becoming change detection.

Setup is not story, so beforeAll seeds the eight snapshots, the network,
and the role labels via the API, and asserts network insights already
exist — a ~20s LLM call at the end of a long recording is not worth
filming, but an empty insights panel is worth failing on.

The LLM waits are real and long (story generation alone ran 115s), so
Timeline now races them at 40x rather than 10x: the spinner still shows,
briefly, because the point is that the work is real, not that you watch
it. The demo project's timeout goes to 20min to bound a hang without
asserting performance — a recording that dies at minute eight wastes
everything before it.

Encoder settings corrected against the finished walkthrough rather than a
single frame. The old comment called MAX_COLORS an expensive lever based
on the topology frame's ~15k colours, but that frame is a few seconds of
a 75s tour; across the whole thing 256->128 is ~40dB PSNR on the worst
frames and saves 2.5MB. Per-segment cost is near-uniform, so there is no
hot section to trim — the levers are global, and colour is the cheapest.
Result: 6.9MB for the full product tour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop on each component in the demo instead of scrolling past it

The Story tab is seven stacked panels and the recording scrolled past all
of them with one blind scrollBy, so the tab that explains how the LLM is
grounded showed none of that. It now stops on each in page order — the
generation settings, full-dataset traffic intelligence, the deterministic
findings the narrative is accountable to, the LLM's own investigation
steps, then the narrative with its Key Events rail — and asks a suggested
question in the chat, which is the interactive half of the tab and never
appeared before.

Monitor's snapshot modal was filmed mid-load: the tab loop skipped the
Network Diagram tab it actually opens on, and the wait keyed on loading
copy that Insights doesn't render (a bare Spinner), so the assertion
passed instantly. It now waits for the spinner to clear *and* for Sigma
to mount and paint its canvas, then holds — the blank grey panel it used
to dwell on is gone, and each tab gets a real hold rather than a glance.

Network Visualization gains the node-detail modal, after the hierarchical
layout and before the close. Sigma renders to WebGL, so a node has no DOM
element, no addressable pixel and no selector — the old hover sweep was
guessing at fractions of the plot area and mostly hovering empty space.
NetworkGraph now exposes its Sigma instance on window.__sigma so the
recording can ask where nodes actually are; both the hover sweep and the
click use it. Coordinates are read after the fit-view camera settles,
because positions sampled mid-animation are stale by the time the click
lands, and the click is asserted rather than best-effort so a miss fails
the recording instead of silently leaving a gap.

Three selectors were pointing at UI that no longer exists — the nginx
image had been serving a frontend build from an older commit, so the
walkthrough was written against it. "Evidence weighed" and "Inspect
Hardware" are in no build (the destination badge opens the Classification
popup, which is what this now films); the edge-colour <select> and the
Node-to-Node Volume heatmap are gone from the product. Every remaining
selector was re-verified against the running app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Demo: per-section framing fixes, and repair two silently-skipped steps

Eight pacing/framing refinements to the walkthrough, plus repairs to steps
the merge from main had broken.

Framing:
- Conversation modal re-frames after each tab's content loads. The session
  panel is taller with its spinner up than with text rendered, so the browser
  clamps scrollTop when it shrinks; a single scroll gets undone a frame later.
  Scroll twice with a settle between.
- Topology diagram anchors its card header under the sticky navbar, re-applied
  after layout and fit-view since both change the card's height. The card is
  the graph body's grandparent — xpath=.. lands inside it and excludes the
  header this is meant to keep on screen.
- Extracted files stops on the table instead of scrolling to footer whitespace.
- Filter generator holds on the generated BPF expression before executing.
- Story Q&A waits for the answer to stop streaming, not just for the spinner
  to clear, so the reply is not filmed half-written. Inside the fast-forward
  span: still waiting on the model.
- Snapshot modal tabs and the IP drift modal hold long enough to read.

Repairs:
- Restore the edge-colour cycle (transport/application/volume). It was absent
  from the spec entirely.
- Snapshot tabs matched nothing: decorative <i> icons pad the accessible name,
  so /^Changes/ never fired and the loop `continue`d past all five, filming a
  2s flash of the diagram. Match loosely, scope to the pill strip, and assert
  rather than skip.
- Conversation host details and graph node details followed markup that #575
  and #578 replaced (title="Click for details" and the "Node Details" title).
  Anchor on the aria-label and on #entity-detail-title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Demo: repair selectors after main merge, and fix six framing misses

The merge from main brought in #575/#578, which collapsed the conversation
device-type badge and the graph's "Node Details" modal into the shared
EntityDetailModal. Both selectors were dead: the recording failed outright on
the badge, and would have failed on the node modal a section later. Re-anchored
on the host card's aria-label and on #entity-detail-title, since the new modal's
visible title is the host's own display name.

The edge-colour cycle had been lost entirely — a `select` filtered by hasText
matches against its options, so it silently found nothing and skipped all three
modes. Anchored on the option values instead, and asserted rather than guarded,
because the silent skip is what hid it.

Framing fixes, each measured against the running app rather than guessed:

- Conversation packet/session tabs: the session panel keeps growing after its
  payload first renders, so scrollHeight is stale at that moment and scrolling
  to it stops ~54px short. Anchor on the tab strip's own offset instead.
- Topology: the card is the graph body's grandparent, not its parent, so the
  previous anchor excluded the very header it was meant to keep on screen. It
  also needs re-framing after layout and fit-view, both of which resize the card.
- Story Q&A: "Thinking..." disappearing means the first token landed, not the
  last — wait for the reply to stop growing, inside the fast-forward span so the
  wait still races.
- Filter generator: hold on the generated BPF expression before executing it.
- Extracted files: stop on the table rather than scrolling on to page bottom.
- Snapshot tabs and the IP drift modal: hold long enough to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Demo: lead the Story tab with Ask the LLM, frame each panel at the top

Restructures the Story section to follow the page's own order: the chat card
first (click a suggested question, wait for the full answer, hold on it), then
each panel in turn with its header at the top of the screen.

The framing needed a new helper. reveal() centres its target, which for a card
taller than the fold pushes the header off the top entirely — so the recording
showed panel bodies with no indication of which panel they belonged to.
frameCardTop anchors the card's top edge just under the sticky navbar instead,
and it takes the card rather than the heading, since scrolling the heading
itself clips the card's own header padding.

The real cause of the misses, though, was that html { scroll-behavior: smooth }
is set globally: every scroll animates for ~1s whether or not the call asks it
to, and the fixed 120–200ms waits were sampling mid-flight. Two measurements
that looked like "the card cannot reach the top" (page-bottom clamping, cards
taller than the viewport) were both this. settleScroll polls scrollY until it
stops — three consecutive equal samples, because a smooth scroll eases in and
out and two reads 80ms apart can match while it is still moving. reveal(),
scrollBy() and frameCardTop() all wait on it now, which also fixes holds
elsewhere in the tour that were starting before their scroll had landed.

Also sets MAX_COLORS to 256: the colour-coded views (edge-colour modes, the
volume gradient, the country choropleth) are the feature being demonstrated, so
fidelity is worth the bytes. DEMO_COLORS=128 restores the smaller encode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Demo: one fixed pause length, and stop filming the smooth-scroll animation

The Story tab felt slow, but the holds were not the cause: of ~26s on that tab,
only 8.2s was deliberate pauses and ~17.6s was scroll animation. The app sets
html { scroll-behavior: smooth } globally, so every jump animates for ~1s.

disableSmoothScroll turns that off for the recording. It is also the single
biggest size lever found so far — a pan repaints the whole frame on every frame,
which is the worst case for GIF inter-frame compression. Combined with the
trims below: 112s/11.6MB -> 91s/7.1MB, at 256 colours either way.

Pauses are now one constant. Per-step timings had drifted to fifteen different
values across sixty-odd beat() calls, which reads as arbitrary rather than
deliberate; HOLD replaces them all, with READ_HOLD (2x) kept for the one thing
that has to be read rather than recognised — the LLM's answer. DEMO_HOLD retimes
the whole demo in one number.

Framing, all anchored so the section header sits at the top of the screen:
- Overview opens on the Analysis Summary, and both distribution charts keep
  their headers in view (previously centred, which pushed the header off-screen
  and left the pies unlabelled).
- Network Intelligence frames the Network Cluster View header before switching
  Group by to Country.
- Network Visualization ends on the node-to-node volume heatmap, expanded from
  its collapsed default.

Also drops the fullscreen topology step, and ends the Monitor section on the
Network Insights components rather than scrolling on to the page footer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Demo: make every pause actually equal, and fix cardOf matching card-header

The pauses were not uniform, despite every beat() call passing HOLD. Six raw
waitForTimeout calls sat immediately before a beat() — in openTab, gotoSection,
the filter results, the edge-colour cycle and two monitor modals — so those
stops ran HOLD plus 400-1200ms. Because the padding was outside beat(), it did
not show up as a different hold value; it just made some sections feel slower.

Where the wait was real (a canvas paint, a tab-swap relayout) it is now a poll
on the condition instead of a fixed delay, so the visible pause is one HOLD.
Verified by instrumenting beat(): 50 of 51 pauses land at 600ms with a max
overshoot of 4ms, the 51st being the deliberate 1200ms READ_HOLD.

cardOf matched card-header. contains(@Class,"card") is a substring test, so it
also matches "card-header" and "card-body" — it was returning the 57px header
rather than the card. For the heatmap that meant framing against an element
whose top only coincidentally matched the card's and whose height never changes
when the card expands, leaving the shot ~130px low with the topology's tail
still in it, and breaking a height check that assumed the real card. Now matches
the class token exactly: 59px collapsed, 1237px expanded, framed at y=110.

The heatmap also needed the same settle the session panel did — the grid keeps
laying out after .tp-heatmap-body turns visible, so framing on visibility alone
used stale geometry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Demo: reorder the Conversations flow, and frame the filter panel at the top

The section now runs in the order the flow actually reads: open the tab, frame
the filter panel at the top of the screen, expand it, pick the Telegram badge,
open the first conversation, inspect the destination host, return to the
conversation, then Packets and Session.

Previously the packet/session tabs came first and the host modal last, so the
section ended inside a nested modal and doubled back on itself. Framing the
filter panel before expanding it also means the panel unfolds into an empty
viewport rather than off the bottom edge.

On the Session tab "auto scroll": there is no autoscroll to remove. The only
scrollIntoView in ConversationDetail is the deep-link packet jump, which this
recording never triggers. What actually happens is a clamp — measured, the modal
body sits at scrollTop 212, clicking Session swaps in the shorter spinner panel
so the browser clamps to the new max of 180, and when the payload renders the
panel grows back and the browser restores 212. It self-corrects by ~800ms, and
the existing settle loop already waits for it.

Also fixes an assertion that would have failed for the wrong reason: hostModal
is getByRole('dialog').last(), which re-resolves to the conversation modal once
the host modal closes, so expect(hostModal).toBeHidden() was checking the wrong
element. Asserts on #entity-detail-title and the dialog count instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Demo: split pauses into BEAT and FEATURE, and fix a silently-skipped step

Two tiers instead of one. BEAT (600ms) for transitions — opening a filter,
switching a tab, moving between panels. FEATURE (1100ms) for landing on the
thing a section exists to show: a chart, a diagram, a modal, a result. 20 stops
are now FEATURE, the rest BEAT, and the LLM answer keeps 2x FEATURE.

Both are plain milliseconds of finished GIF, deliberately not divided by PACE.
PACE shortens waits *while recording*, so what is filmed is what plays back —
there is no speed-up pass on top, which meant the previous 600ms uniform hold
was 0.6s on screen for every feature, not 1.5s.

Also fixes a step that was never running. The generated-filter pause matched
'code, pre', but the expression renders into an editable Form.Control (#filter)
— so the locator found nothing, and because it sat behind an isVisible() guard
the whole step was skipped without failing. It is asserted now, and the section
frames "Ask in Natural Language" at the top before typing so the prompt, the
button and the generated filter stay in one shot.

Extracted Files frames the Files card header and stops there rather than
scrolling on. The heatmap zooms out until the whole 50x50 matrix fits: measured
on the grid element, not the card, since the card keeps full width at any cell
size — a height-only test overshot to 6px/cell and left half the panel empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Demo: give Monitor's section landings the FEATURE hold

The drift panels — and the whole Monitor section — were still on the 600ms BEAT.
The earlier tier split promoted stops that follow frameCardTop/reveal, but the
Monitor page navigates with gotoSection, so none of its landings matched: six
drift panel stops and the closing Network Insights all cut away in 0.6s.

Every gotoSection landing is now FEATURE, and the shadow-host modal (192.0.2.99)
gets READ_HOLD — it is the payoff of the drift section and the panel is dense
enough to need reading, not just recognising.

Sweeping the rest of the file the same way found eleven more feature landings
still on BEAT: the node-label modal and its preview, the fitted hierarchical
layout, the node-details identity panel, the country map switch, the Italy and
Pistoia drill-downs, the conversation packet/session framing, the evidence
panel, the hardware axis, and the matched packets. Final split: 37 FEATURE,
24 BEAT, 2 READ_HOLD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Demo: ship the full-product walkthrough GIF at 128 colours

Replaces the old Monitor-only recording with the eleven-section tour, encoded at
640px/6fps/128c — 5.4MB for 118s.

128 rather than 256: measured on this walkthrough it costs ~40dB PSNR on the
worst frames (topology, world map, pie charts), which leaves the colour-coded
views reading correctly, and saves ~2MB. The tuning comment now carries the
measured ladder for this recording rather than the older one's numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Demo: drop dead resetNetwork, derive the heatmap fit from the live viewport

Two findings from reviewing PR #520.

resetNetwork was exported but never called, and it contradicts seedNetwork's
documented reuse: deleting the network would discard the operator-generated
insights the recording films in step 11, which assertNetworkInsights then fails
loudly about. Removing it rather than wiring it up.

The heatmap zoom loop compared against a literal 720. The recording viewport is
defined in playwright.config.ts, so the two could drift apart silently and the
matrix would stop fitting. Reads page.viewportSize() instead — same result
(stops at 8px/cell, 408px grid), no coupling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant