feat(classification): unify host classification into one explainable panel - #575
Conversation
…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>
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
Warning Review limit reached
Next review available in: 45 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughThe 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. ChangesHost identity evidence redesign
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
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. Comment |
There was a problem hiding this comment.
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 winStale 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
📒 Files selected for processing (20)
backend/src/main/java/com/tracepcap/insights/controller/HostIdentitiesController.javabackend/src/main/java/com/tracepcap/insights/dto/HostIdentityEvidenceDto.javabackend/src/main/java/com/tracepcap/insights/service/HostIdentityEvidenceService.javafrontend/src/components/common/DeviceClassificationPopup/DeviceClassificationPopup.tsxfrontend/src/components/common/EntityDetailModal/EntityDetailModal.tsxfrontend/src/components/common/EntityDetailModal/hooks/useHostClassification.tsfrontend/src/components/common/EntityDetailModal/sections/HostClassificationSection.tsxfrontend/src/components/common/HostIdentitySection/HostIdentitySection.tsxfrontend/src/components/common/HostIdentitySection/index.tsfrontend/src/components/conversation/ConversationDetail/ConversationDetail.cssfrontend/src/components/conversation/ConversationDetail/ConversationDetail.tsxfrontend/src/components/network/NodeDetails/NodeDetails.tsxfrontend/src/features/conversation/services/conversationService.tsfrontend/src/features/network/__tests__/classificationAxes.test.tsfrontend/src/features/network/classificationAxes.tsfrontend/src/features/network/types/index.tsfrontend/src/services/api/endpoints.tsfrontend/src/types/common.types.tsfrontend/src/utils/deviceType.tsopenapi/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
| deviceType: | ||
| ev.basis === 'HUMAN' ? undefined : (ev.primaryLabel as DeviceType | undefined), |
There was a problem hiding this comment.
🎯 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 aDeviceTypebefore assigningnodeData.deviceType, soaxisFactsanddetectAxisConflictsee a real code.frontend/src/components/conversation/ConversationDetail/ConversationDetail.tsx#L171-L175: apply the same mapping toidentity?.primaryLabelbefore unioning it withcls?.deviceTypefordeviceTypeColor/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.
| grid-template-columns: 1fr 1fr; | ||
| gap: 12px; | ||
| } | ||
| @media (max-width: 700px) { .cd-groups { grid-template-columns: 1fr; } } |
There was a problem hiding this comment.
📐 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.
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>
|
Thanks @coderabbitai — addressed the findings in 281e311: Fixed
Follow-up issue
Already resolved / N/A
|
|
Tip For best results, initiate chat on the files or code changes.
Keeping the endpoint-only identity fetch optimization scoped to 🐇 ✅ ✏️ Learnings added
If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
) (#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>
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>
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>
* 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>
What & why
After #556, clicking a device badge in the Conversation Detail view opened a legacy single-
deviceTypepopup (DeviceClassificationPopup) — a thinner, different modal than the axis-based explainable classification the network graph shows. This unifies every host-inspection surface onto one sharedHostIdentitySectionso users see how a classification was derived and can correct/append to it everywhere.Changes
Backend
GET /files/{fileId}/hosts/{ip}/identity→HostIdentityEvidenceDto: the adjudicated verdict (label/basis/confidence/contested/candidates) plus the measured evidence axes (hardware, service + nDPI apps, behaviour), composed inHostIdentityEvidenceServicefrom existing SPI lookups — no new queries.conversationCount/peerCount), so a high-peer router still shows real evidence on captures where nDPI never measured flow direction (was "Nothing observed").GeoOrgLookup(offline-capable) — external hosts only.Frontend
HostIdentitySection;NodeDetailsandEntityDetailModalboth render it. RemovesHostClassificationSection/useHostClassificationand the legacyDeviceClassificationPopup.EntityDetailModal. Metadata regrouped into a security strip + collapsible Timing/Geo/HTTP/TLS groups, all open by default.buildDeviceSignalsblock fromdeviceType.ts.Contract
Verification
Notes
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes