feat: add CVE tooltip on hover in Reporting Page
- Add GET /api/cves/:cveId/tooltip backend endpoint with description truncation - Create CveTooltip portal component with caching, severity badges, and viewport-aware positioning - Integrate tooltip into ReportingPage with 300ms hover delay on CVE badge spans
This commit is contained in:
1
.kiro/specs/cve-tooltip-hover/.config.kiro
Normal file
1
.kiro/specs/cve-tooltip-hover/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "b8855eb4-3949-426e-86ac-36fe069a6bb1", "workflowType": "requirements-first", "specType": "feature"}
|
||||
229
.kiro/specs/cve-tooltip-hover/design.md
Normal file
229
.kiro/specs/cve-tooltip-hover/design.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Design Document: CVE Tooltip Hover
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds a hover tooltip to CVE badges in the Reporting Page findings table. When a user pauses their cursor over a CVE identifier badge, the system fetches a brief description and severity from the backend and displays it in a styled floating tooltip. Responses are cached in-memory to avoid redundant API calls, and a 300ms hover delay prevents tooltip flicker during fast mouse movement.
|
||||
|
||||
The implementation spans two layers:
|
||||
1. A new lightweight backend endpoint (`/api/cves/:cveId/tooltip`) that queries the existing `cves` SQLite table and returns a trimmed response.
|
||||
2. A frontend `CveTooltip` component rendered via a React portal, with an in-memory cache (React ref), hover delay timer, and viewport-aware positioning.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant CVEBadge as CVE Badge (ReportingPage)
|
||||
participant Tooltip as CveTooltip Component
|
||||
participant Cache as Tooltip Cache (useRef)
|
||||
participant API as /api/cves/:cveId/tooltip
|
||||
participant DB as SQLite (cves table)
|
||||
|
||||
User->>CVEBadge: mouseenter
|
||||
CVEBadge->>Tooltip: start 300ms delay timer
|
||||
Note over Tooltip: If mouseout before 300ms, cancel
|
||||
|
||||
alt Cache hit
|
||||
Tooltip->>Cache: lookup(cveId)
|
||||
Cache-->>Tooltip: cached data
|
||||
Tooltip->>User: show tooltip (or skip if exists:false)
|
||||
else Cache miss
|
||||
Tooltip->>API: GET /api/cves/:cveId/tooltip
|
||||
API->>DB: SELECT cve_id, description, severity FROM cves WHERE cve_id = ?
|
||||
DB-->>API: row or null
|
||||
API-->>Tooltip: { exists, cve_id, description, severity }
|
||||
Tooltip->>Cache: store response
|
||||
Tooltip->>User: show tooltip (or skip if exists:false)
|
||||
end
|
||||
|
||||
User->>CVEBadge: mouseleave
|
||||
CVEBadge->>Tooltip: hide + clear timer
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
1. **Inline endpoint in server.js** — The tooltip endpoint is a single GET route on the existing `/api/cves` path prefix. It follows the pattern of other simple CVE endpoints already defined inline in `server.js` (e.g., `/api/cves/check/:cveId`, `/api/cves/:cveId/vendors`). No separate route module needed.
|
||||
|
||||
2. **React portal for tooltip rendering** — The tooltip is rendered via `ReactDOM.createPortal` to `document.body`, avoiding overflow/clipping issues from the table's scroll container. The ReportingPage already imports `ReactDOM` for other portal usage.
|
||||
|
||||
3. **useRef for cache instead of useState** — The cache is a plain `Map` stored in a `useRef`. This avoids re-renders when cache entries are added and persists across renders without triggering updates. The cache is cleared when the findings data is re-synced.
|
||||
|
||||
4. **Single shared tooltip instance** — Only one tooltip is visible at a time. The parent component tracks which CVE badge is hovered and passes the active CVE ID + badge position to the tooltip component.
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend
|
||||
|
||||
#### `GET /api/cves/:cveId/tooltip`
|
||||
|
||||
Added inline in `server.js` alongside existing CVE endpoints.
|
||||
|
||||
- **Auth**: `requireAuth(db)` — session cookie required
|
||||
- **Params**: `:cveId` — validated against `CVE_ID_PATTERN` (`/^CVE-\d{4}-\d{4,}$/`)
|
||||
- **Query**: `SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1`
|
||||
- **Response (found)**:
|
||||
```json
|
||||
{
|
||||
"exists": true,
|
||||
"cve_id": "CVE-2024-12345",
|
||||
"description": "A vulnerability in...",
|
||||
"severity": "High"
|
||||
}
|
||||
```
|
||||
- **Response (not found)**:
|
||||
```json
|
||||
{ "exists": false }
|
||||
```
|
||||
- **Description truncation**: If `description.length > 300`, return `description.substring(0, 300) + '…'`
|
||||
|
||||
### Frontend
|
||||
|
||||
#### `CveTooltip` Component (new file: `frontend/src/components/CveTooltip.js`)
|
||||
|
||||
A portal-rendered tooltip that receives positioning data and CVE info.
|
||||
|
||||
**Props:**
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `cveId` | `string \| null` | The CVE ID to display. `null` hides the tooltip. |
|
||||
| `anchorRect` | `DOMRect \| null` | Bounding rect of the hovered badge for positioning. |
|
||||
| `cache` | `React.MutableRefObject<Map>` | Shared cache ref from parent. |
|
||||
|
||||
**Internal state:**
|
||||
- `data` — fetched tooltip payload (`{ exists, cve_id, description, severity }` or `null`)
|
||||
- `loading` — boolean, true while fetch is in-flight
|
||||
|
||||
**Behavior:**
|
||||
1. When `cveId` changes to a non-null value, check `cache.current` for the CVE ID.
|
||||
2. If cached and `exists: false`, render nothing.
|
||||
3. If cached and `exists: true`, display immediately.
|
||||
4. If not cached, set `loading = true`, fetch from API, store result in cache, set `loading = false`.
|
||||
5. Position the tooltip above the badge by default. If the tooltip would overflow the top of the viewport, position it below instead.
|
||||
6. Render via `ReactDOM.createPortal` to `document.body`.
|
||||
|
||||
#### ReportingPage Integration
|
||||
|
||||
Modifications to the existing `renderCell` function for the `'cves'` case:
|
||||
|
||||
- Add `onMouseEnter` / `onMouseLeave` handlers to each CVE badge `<span>`.
|
||||
- `onMouseEnter`: Start a 300ms `setTimeout`. On fire, set active CVE ID + badge `getBoundingClientRect()` into state.
|
||||
- `onMouseLeave`: Clear the timeout. Set active CVE ID to `null`.
|
||||
- Render a single `<CveTooltip>` instance at the bottom of the component, passing the active CVE ID, anchor rect, and cache ref.
|
||||
- On data sync (when findings are refreshed), call `cache.current.clear()`.
|
||||
|
||||
## Data Models
|
||||
|
||||
### Existing: `cves` Table (SQLite)
|
||||
|
||||
The tooltip endpoint queries the existing table. No schema changes required.
|
||||
|
||||
```sql
|
||||
CREATE TABLE cves (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL,
|
||||
severity TEXT CHECK(severity IN ('Critical', 'High', 'Medium', 'Low')),
|
||||
description TEXT,
|
||||
published_date TEXT,
|
||||
status TEXT DEFAULT 'Open',
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(cve_id, vendor)
|
||||
);
|
||||
```
|
||||
|
||||
The query uses `LIMIT 1` since a CVE may have multiple vendor rows — the description and severity from any row suffice for the tooltip blurb.
|
||||
|
||||
### Frontend Cache Structure
|
||||
|
||||
```javascript
|
||||
// cache.current is a Map<string, object>
|
||||
// Key: CVE ID string (e.g. "CVE-2024-12345")
|
||||
// Value: API response object
|
||||
// { exists: false }
|
||||
// OR
|
||||
// { exists: true, cve_id: string, description: string, severity: string }
|
||||
```
|
||||
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||
|
||||
### Property 1: Tooltip endpoint returns correct data for existing CVEs
|
||||
|
||||
*For any* CVE record inserted into the `cves` table with a valid `cve_id`, `description`, and `severity`, a GET request to `/api/cves/:cveId/tooltip` SHALL return `{ exists: true }` with the matching `cve_id` and `severity`, and a `description` that is either the original (if ≤ 300 chars) or truncated to 300 chars + ellipsis.
|
||||
|
||||
**Validates: Requirements 1.1, 1.3, 1.5**
|
||||
|
||||
### Property 2: Description truncation preserves content and enforces length
|
||||
|
||||
*For any* string of arbitrary length, the truncation function SHALL return the original string unchanged if its length is ≤ 300, or return exactly the first 300 characters followed by "…" if its length exceeds 300. In both cases, the output starts with the same characters as the input.
|
||||
|
||||
**Validates: Requirements 1.5**
|
||||
|
||||
### Property 3: Tooltip positioning flips based on available viewport space
|
||||
|
||||
*For any* anchor rectangle position and viewport height, the tooltip SHALL be positioned above the anchor when `anchorRect.top` provides sufficient space for the tooltip height, and below the anchor otherwise. The tooltip SHALL never overflow the top or bottom of the viewport.
|
||||
|
||||
**Validates: Requirements 3.1, 3.2**
|
||||
|
||||
### Property 4: Cache round-trip — fetch then cache-hit avoids network call
|
||||
|
||||
*For any* CVE ID, after the tooltip system fetches data from the API and stores it in the cache, a subsequent tooltip request for the same CVE ID SHALL return the identical cached data object without making an additional network request.
|
||||
|
||||
**Validates: Requirements 4.1, 4.2**
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Layer | Behavior |
|
||||
|----------|-------|----------|
|
||||
| Invalid CVE ID format in URL param | Backend | Return `400 { error: 'Invalid CVE ID format.' }` |
|
||||
| Database query error | Backend | Log error, return `500 { error: 'Internal server error.' }` |
|
||||
| No session cookie / expired session | Backend | `requireAuth` middleware returns `401` |
|
||||
| Network error during fetch | Frontend | Catch error, hide tooltip (do not cache failures), log to console |
|
||||
| Fetch timeout / slow response | Frontend | Show loading state; if user moves away, cancel via AbortController |
|
||||
| Component unmounts during fetch | Frontend | AbortController signal aborts in-flight request, no state update |
|
||||
|
||||
**Key principle**: Transient errors (network failures, timeouts) are NOT cached. Only successful API responses (both `exists: true` and `exists: false`) are stored in the cache. This ensures a retry on next hover for failed requests.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (Example-Based)
|
||||
|
||||
| Test | Validates |
|
||||
|------|-----------|
|
||||
| Endpoint returns `{ exists: false }` for unknown CVE ID | Req 1.2 |
|
||||
| Endpoint returns 401 without session cookie | Req 1.4 |
|
||||
| Endpoint returns 400 for malformed CVE ID (e.g. "not-a-cve") | Req 1.1 (error path) |
|
||||
| Tooltip appears after 300ms hover delay | Req 5.1 |
|
||||
| Tooltip cancelled if mouseout before 300ms | Req 5.2 |
|
||||
| Tooltip hidden on mouseleave | Req 2.2 |
|
||||
| Loading indicator shown while fetching | Req 2.5 |
|
||||
| No tooltip shown when API returns `exists: false` | Req 2.6 |
|
||||
| Severity badge uses correct color per level | Req 2.4 |
|
||||
| Tooltip has max-width of 320px | Req 3.3 |
|
||||
| Tooltip includes directional arrow element | Req 3.5 |
|
||||
| Cache cleared on data sync/refresh | Req 4.4 |
|
||||
| Cached `exists: false` suppresses tooltip and API call | Req 4.3 |
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
Property-based tests use **fast-check** (JavaScript PBT library, already compatible with the Jest/react-scripts test runner).
|
||||
|
||||
Each property test runs a minimum of **100 iterations**.
|
||||
|
||||
| Property | Tag | Focus |
|
||||
|----------|-----|-------|
|
||||
| Property 1 | `Feature: cve-tooltip-hover, Property 1: Tooltip endpoint returns correct data for existing CVEs` | Generate random CVE records (varying description lengths 0–1000, all 4 severity levels), insert into test DB, call endpoint, verify response shape and truncation |
|
||||
| Property 2 | `Feature: cve-tooltip-hover, Property 2: Description truncation preserves content and enforces length` | Generate random strings of length 0–2000, apply truncation function, verify length invariant and prefix preservation |
|
||||
| Property 3 | `Feature: cve-tooltip-hover, Property 3: Tooltip positioning flips based on available viewport space` | Generate random anchorRect.top (0–2000), tooltip height (50–200), viewport height (400–1200), verify position is within viewport bounds |
|
||||
| Property 4 | `Feature: cve-tooltip-hover, Property 4: Cache round-trip` | Generate random CVE IDs and response payloads, store in cache Map, verify subsequent lookups return identical objects and no fetch is triggered |
|
||||
|
||||
### Test Configuration
|
||||
|
||||
- Test runner: `react-scripts test` (Jest) — already configured in the project
|
||||
- PBT library: `fast-check` — install via `npm install --save-dev fast-check` in the `frontend/` directory
|
||||
- Backend endpoint tests: Use supertest or direct handler invocation with a test SQLite DB
|
||||
- Frontend component tests: React Testing Library with mocked fetch
|
||||
73
.kiro/specs/cve-tooltip-hover/requirements.md
Normal file
73
.kiro/specs/cve-tooltip-hover/requirements.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
Add a hover tooltip to CVE badges in the Reporting Page (vuln triage view). When a user hovers over a CVE identifier badge in the findings table, the system checks whether that CVE exists in the local SQLite database. If it does, a small tooltip appears showing a brief description/blurb about that CVE. CVEs not present in the database show no tooltip.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Reporting_Page**: The vulnerability triage view at `frontend/src/components/pages/ReportingPage.js` that displays Ivanti host findings in a sortable, filterable table.
|
||||
- **CVE_Badge**: The styled `<span>` element in the CVEs column of the findings table that displays a CVE identifier (e.g. CVE-2024-12345) with a purple pill/box appearance.
|
||||
- **CVE_Tooltip**: A small floating box that appears on mouse hover over a CVE_Badge, displaying a text blurb about the CVE.
|
||||
- **CVE_Database**: The `cves` table in the SQLite database (`backend/cve_database.db`) that stores CVE records including descriptions, severity, and vendor information.
|
||||
- **Tooltip_Cache**: An in-memory lookup (React state or ref) that stores previously fetched CVE descriptions to avoid redundant API calls during the same session.
|
||||
- **API_Server**: The Express backend at `backend/server.js` that serves CVE data via `/api` endpoints.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: CVE Tooltip Data Endpoint
|
||||
|
||||
**User Story:** As a frontend component, I want to fetch a brief description for a given CVE ID, so that the tooltip can display relevant information without loading unnecessary data.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a GET request is made to `/api/cves/:cveId/tooltip`, THE API_Server SHALL return a JSON object containing the `cve_id`, `description`, and `severity` fields for the matching CVE record.
|
||||
2. WHEN a GET request is made to `/api/cves/:cveId/tooltip` for a CVE ID that does not exist in the CVE_Database, THE API_Server SHALL return a JSON object with `{ exists: false }` and HTTP status 200.
|
||||
3. WHEN a GET request is made to `/api/cves/:cveId/tooltip` for a CVE ID that exists in the CVE_Database, THE API_Server SHALL return a JSON object with `{ exists: true, cve_id, description, severity }` and HTTP status 200.
|
||||
4. THE API_Server SHALL require a valid session cookie for the `/api/cves/:cveId/tooltip` endpoint.
|
||||
5. WHEN the `description` field exceeds 300 characters, THE API_Server SHALL truncate the description to 300 characters and append an ellipsis ("…").
|
||||
|
||||
### Requirement 2: Tooltip Display on CVE Badge Hover
|
||||
|
||||
**User Story:** As a security analyst triaging findings, I want to see a brief description of a CVE when I hover over its badge in the findings table, so that I can quickly understand the vulnerability without leaving the page.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user hovers the mouse cursor over a CVE_Badge in the Reporting_Page findings table, THE Reporting_Page SHALL display a CVE_Tooltip near the hovered badge.
|
||||
2. WHEN the user moves the mouse cursor away from the CVE_Badge, THE Reporting_Page SHALL hide the CVE_Tooltip.
|
||||
3. THE CVE_Tooltip SHALL display the CVE description text returned by the API_Server.
|
||||
4. THE CVE_Tooltip SHALL display the severity level of the CVE using the existing severity color scheme (Critical: red, High: amber, Medium: sky blue, Low: emerald).
|
||||
5. WHILE the CVE data is being fetched from the API_Server, THE CVE_Tooltip SHALL display a loading indicator.
|
||||
6. WHEN the API_Server returns `exists: false` for a CVE ID, THE Reporting_Page SHALL not display a CVE_Tooltip for that badge.
|
||||
|
||||
### Requirement 3: Tooltip Positioning and Styling
|
||||
|
||||
**User Story:** As a security analyst, I want the CVE tooltip to be readable and not obstruct other table content, so that I can continue triaging while viewing CVE details.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE CVE_Tooltip SHALL appear above the hovered CVE_Badge by default.
|
||||
2. WHEN there is insufficient viewport space above the CVE_Badge, THE CVE_Tooltip SHALL appear below the badge instead.
|
||||
3. THE CVE_Tooltip SHALL have a maximum width of 320 pixels.
|
||||
4. THE CVE_Tooltip SHALL use the design system dark theme styling: dark background gradient, accent border, monospace font for the CVE ID, and standard font for the description text.
|
||||
5. THE CVE_Tooltip SHALL include a small directional arrow pointing toward the CVE_Badge.
|
||||
|
||||
### Requirement 4: Tooltip Response Caching
|
||||
|
||||
**User Story:** As a security analyst scrolling through many findings, I want CVE tooltip data to load instantly for CVEs I have already hovered over, so that repeated hovers do not cause redundant network requests.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Reporting_Page fetches tooltip data for a CVE ID, THE Tooltip_Cache SHALL store the response for that CVE ID.
|
||||
2. WHEN the user hovers over a CVE_Badge for a CVE ID that exists in the Tooltip_Cache, THE Reporting_Page SHALL display the cached data without making an API call.
|
||||
3. WHEN the user hovers over a CVE_Badge for a CVE ID where the Tooltip_Cache stores `exists: false`, THE Reporting_Page SHALL not display a tooltip and SHALL not make an API call.
|
||||
4. WHEN the Reporting_Page performs a full data sync (refresh), THE Tooltip_Cache SHALL be cleared.
|
||||
|
||||
### Requirement 5: Hover Delay
|
||||
|
||||
**User Story:** As a security analyst, I want the tooltip to only appear after a brief pause on a CVE badge, so that tooltips do not flash distractingly when I move the mouse across the table quickly.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user hovers over a CVE_Badge, THE Reporting_Page SHALL wait 300 milliseconds before initiating the tooltip display sequence.
|
||||
2. IF the user moves the mouse away from the CVE_Badge before 300 milliseconds have elapsed, THEN THE Reporting_Page SHALL cancel the tooltip display and not make an API call.
|
||||
107
.kiro/specs/cve-tooltip-hover/tasks.md
Normal file
107
.kiro/specs/cve-tooltip-hover/tasks.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Implementation Plan: CVE Tooltip Hover
|
||||
|
||||
## Overview
|
||||
|
||||
Implement a hover tooltip for CVE badges in the Reporting Page findings table. The feature spans a backend endpoint (`GET /api/cves/:cveId/tooltip`) and a frontend `CveTooltip` portal component with in-memory caching and 300ms hover delay. Tasks are ordered backend-first, then frontend component, then integration, with property tests alongside each layer.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Add backend tooltip endpoint
|
||||
- [x] 1.1 Add `GET /api/cves/:cveId/tooltip` route inline in `backend/server.js`
|
||||
- Place it alongside existing CVE endpoints (after `/api/cves/:cveId/vendors`)
|
||||
- Validate `:cveId` against existing `CVE_ID_PATTERN`; return 400 for invalid format
|
||||
- Query: `SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1`
|
||||
- If no row: return `{ exists: false }` with status 200
|
||||
- If row found: truncate `description` to 300 chars + "…" if needed, return `{ exists: true, cve_id, description, severity }`
|
||||
- Protect with `requireAuth(db)` middleware
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||
|
||||
- [ ]* 1.2 Write property test for tooltip endpoint data correctness
|
||||
- **Property 1: Tooltip endpoint returns correct data for existing CVEs**
|
||||
- Install `fast-check` as dev dependency in `frontend/` (shared test runner)
|
||||
- Generate random CVE records with description lengths 0–1000 and all 4 severity levels
|
||||
- Verify response shape, truncation at 300 chars, and prefix preservation
|
||||
- **Validates: Requirements 1.1, 1.3, 1.5**
|
||||
|
||||
- [ ]* 1.3 Write property test for description truncation
|
||||
- **Property 2: Description truncation preserves content and enforces length**
|
||||
- Extract truncation logic into a testable pure function
|
||||
- Generate random strings of length 0–2000, verify length invariant and prefix match
|
||||
- **Validates: Requirements 1.5**
|
||||
|
||||
- [x] 2. Checkpoint — Verify backend endpoint
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 3. Create CveTooltip frontend component
|
||||
- [x] 3.1 Create `frontend/src/components/CveTooltip.js`
|
||||
- Portal-rendered component using `ReactDOM.createPortal` to `document.body`
|
||||
- Props: `cveId` (string|null), `anchorRect` (DOMRect|null), `cache` (useRef Map)
|
||||
- Internal state: `data`, `loading`
|
||||
- On `cveId` change: check cache → if miss, fetch from `/api/cves/:cveId/tooltip` with AbortController
|
||||
- If cached `exists: false` or fetch returns `exists: false`, render nothing
|
||||
- Show loading spinner (Loader from lucide-react) while fetching
|
||||
- Display: CVE ID in monospace, severity badge with design system colors, description text
|
||||
- Max-width 320px, dark theme gradient background, accent border, directional arrow
|
||||
- Position above anchor by default; flip below if insufficient viewport space above
|
||||
- Do not cache transient errors (network failures)
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 3.1, 3.2, 3.3, 3.4, 3.5_
|
||||
|
||||
- [ ]* 3.2 Write property test for tooltip positioning logic
|
||||
- **Property 3: Tooltip positioning flips based on available viewport space**
|
||||
- Extract positioning calculation into a pure function
|
||||
- Generate random anchorRect.top (0–2000), tooltip height (50–200), viewport height (400–1200)
|
||||
- Verify tooltip never overflows top or bottom of viewport
|
||||
- **Validates: Requirements 3.1, 3.2**
|
||||
|
||||
- [ ]* 3.3 Write unit tests for CveTooltip component
|
||||
- Test loading state renders spinner
|
||||
- Test `exists: false` renders nothing
|
||||
- Test severity badge uses correct color per level
|
||||
- Test max-width constraint
|
||||
- Test directional arrow element is present
|
||||
- _Requirements: 2.4, 2.5, 2.6, 3.3, 3.5_
|
||||
|
||||
- [x] 4. Checkpoint — Verify CveTooltip component
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 5. Integrate tooltip into ReportingPage
|
||||
- [x] 5.1 Add hover state and cache ref to ReportingPage
|
||||
- Add state: `tooltipCveId` (string|null), `tooltipAnchorRect` (DOMRect|null)
|
||||
- Add `useRef(new Map())` for tooltip cache
|
||||
- Add `useRef` for hover delay timer
|
||||
- Clear cache when findings data is re-synced (inside existing sync callback)
|
||||
- _Requirements: 4.1, 4.4, 5.1_
|
||||
|
||||
- [x] 5.2 Add mouseenter/mouseleave handlers to CVE badge spans
|
||||
- In the `renderCell` function for the `'cves'` column case, wrap each CVE badge `<span>` with `onMouseEnter` and `onMouseLeave`
|
||||
- `onMouseEnter`: start 300ms setTimeout; on fire, set `tooltipCveId` and `tooltipAnchorRect` from `getBoundingClientRect()`
|
||||
- `onMouseLeave`: clear timeout, set `tooltipCveId` to null
|
||||
- _Requirements: 2.1, 2.2, 5.1, 5.2_
|
||||
|
||||
- [x] 5.3 Render CveTooltip instance in ReportingPage
|
||||
- Add single `<CveTooltip>` at the bottom of the ReportingPage return, passing `tooltipCveId`, `tooltipAnchorRect`, and cache ref
|
||||
- _Requirements: 2.1, 4.2, 4.3_
|
||||
|
||||
- [ ]* 5.4 Write property test for cache round-trip behavior
|
||||
- **Property 4: Cache round-trip — fetch then cache-hit avoids network call**
|
||||
- Generate random CVE IDs and response payloads, store in Map, verify lookups return identical objects
|
||||
- **Validates: Requirements 4.1, 4.2**
|
||||
|
||||
- [ ]* 5.5 Write unit tests for hover delay and cache integration
|
||||
- Test tooltip appears after 300ms delay (use fake timers)
|
||||
- Test tooltip cancelled if mouseout before 300ms
|
||||
- Test cached `exists: false` suppresses tooltip and API call
|
||||
- Test cache cleared on data sync/refresh
|
||||
- _Requirements: 4.3, 4.4, 5.1, 5.2_
|
||||
|
||||
- [x] 6. Final checkpoint — Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- Each task references specific requirements for traceability
|
||||
- Checkpoints ensure incremental validation
|
||||
- Property tests validate universal correctness properties from the design document
|
||||
- Unit tests validate specific examples and edge cases
|
||||
- The project uses plain JavaScript (no TypeScript), fast-check for PBT, and react-scripts test (Jest)
|
||||
@@ -348,6 +348,29 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Get tooltip data for a specific CVE (authenticated users)
|
||||
app.get('/api/cves/:cveId/tooltip', requireAuth(db), (req, res) => {
|
||||
const { cveId } = req.params;
|
||||
|
||||
if (!CVE_ID_PATTERN.test(cveId)) {
|
||||
return res.status(400).json({ error: 'Invalid CVE ID format.' });
|
||||
}
|
||||
|
||||
db.get('SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1', [cveId], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching CVE tooltip:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!row) {
|
||||
return res.json({ exists: false });
|
||||
}
|
||||
let description = row.description || '';
|
||||
if (description.length > 300) {
|
||||
description = description.substring(0, 300) + '\u2026';
|
||||
}
|
||||
res.json({ exists: true, cve_id: row.cve_id, description, severity: row.severity });
|
||||
});
|
||||
});
|
||||
|
||||
// Compliance export — reads from cve_document_status view
|
||||
app.get('/api/cves/compliance', requireAuth(db), (req, res) => {
|
||||
|
||||
243
frontend/src/components/CveTooltip.js
Normal file
243
frontend/src/components/CveTooltip.js
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Severity color mapping — matches DESIGN_SYSTEM.md badge colors
|
||||
// ---------------------------------------------------------------------------
|
||||
const SEVERITY_COLORS = {
|
||||
Critical: { border: '#EF4444', bg: 'rgba(239, 68, 68, 0.25)', text: '#FCA5A5', dot: '#EF4444' },
|
||||
High: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.25)', text: '#FCD34D', dot: '#F59E0B' },
|
||||
Medium: { border: '#0EA5E9', bg: 'rgba(14, 165, 233, 0.25)', text: '#7DD3FC', dot: '#0EA5E9' },
|
||||
Low: { border: '#10B981', bg: 'rgba(16, 185, 129, 0.25)', text: '#6EE7B7', dot: '#10B981' },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure positioning function — exported for testability
|
||||
// ---------------------------------------------------------------------------
|
||||
const TOOLTIP_GAP = 8;
|
||||
const ARROW_SIZE = 6;
|
||||
|
||||
export function calcTooltipPosition(anchorRect, tooltipHeight, viewportHeight) {
|
||||
const spaceAbove = anchorRect.top;
|
||||
const spaceBelow = viewportHeight - anchorRect.bottom;
|
||||
const needed = tooltipHeight + TOOLTIP_GAP + ARROW_SIZE;
|
||||
|
||||
const placeAbove = spaceAbove >= needed || spaceAbove >= spaceBelow;
|
||||
|
||||
let top;
|
||||
if (placeAbove) {
|
||||
top = anchorRect.top - tooltipHeight - TOOLTIP_GAP - ARROW_SIZE;
|
||||
if (top < 0) top = 0;
|
||||
} else {
|
||||
top = anchorRect.bottom + TOOLTIP_GAP + ARROW_SIZE;
|
||||
if (top + tooltipHeight > viewportHeight) top = viewportHeight - tooltipHeight;
|
||||
}
|
||||
|
||||
const left = anchorRect.left + anchorRect.width / 2;
|
||||
|
||||
return { top, left, placeAbove };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CveTooltip component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function CveTooltip({ cveId, anchorRect, cache }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cveId) {
|
||||
setData(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (cache.current.has(cveId)) {
|
||||
setData(cache.current.get(cveId));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache miss — fetch from API
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setData(null);
|
||||
|
||||
fetch(`${API_BASE}/cves/${encodeURIComponent(cveId)}/tooltip`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
cache.current.set(cveId, payload);
|
||||
setData(payload);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return;
|
||||
// Do not cache transient errors
|
||||
console.error('CveTooltip fetch error:', err);
|
||||
setData(null);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [cveId, cache]);
|
||||
|
||||
// Nothing to show
|
||||
if (!cveId || !anchorRect) return null;
|
||||
if (!loading && !data) return null;
|
||||
if (data && data.exists === false) return null;
|
||||
|
||||
const severity = data?.severity || '';
|
||||
const colors = SEVERITY_COLORS[severity] || SEVERITY_COLORS.Medium;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<TooltipBody
|
||||
data={data}
|
||||
loading={loading}
|
||||
anchorRect={anchorRect}
|
||||
colors={colors}
|
||||
severity={severity}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TooltipBody — inner component that measures itself for positioning
|
||||
// ---------------------------------------------------------------------------
|
||||
function TooltipBody({ data, loading, anchorRect, colors, severity }) {
|
||||
const tooltipRef = React.useRef(null);
|
||||
const [pos, setPos] = React.useState({ top: 0, left: 0, placeAbove: true });
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!tooltipRef.current || !anchorRect) return;
|
||||
const rect = tooltipRef.current.getBoundingClientRect();
|
||||
const vp = window.innerHeight;
|
||||
setPos(calcTooltipPosition(anchorRect, rect.height, vp));
|
||||
}, [anchorRect, data, loading]);
|
||||
|
||||
const tooltipStyle = {
|
||||
position: 'fixed',
|
||||
zIndex: 99999,
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: 320,
|
||||
minWidth: 200,
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
|
||||
border: `1.5px solid ${colors.border}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${colors.border}33`,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.15s ease',
|
||||
};
|
||||
|
||||
// Directional arrow
|
||||
const arrowStyle = {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: `${ARROW_SIZE}px solid transparent`,
|
||||
borderRight: `${ARROW_SIZE}px solid transparent`,
|
||||
...(pos.placeAbove
|
||||
? {
|
||||
bottom: -ARROW_SIZE,
|
||||
borderTop: `${ARROW_SIZE}px solid ${colors.border}`,
|
||||
borderBottom: 'none',
|
||||
}
|
||||
: {
|
||||
top: -ARROW_SIZE,
|
||||
borderBottom: `${ARROW_SIZE}px solid ${colors.border}`,
|
||||
borderTop: 'none',
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={tooltipRef} style={tooltipStyle} data-testid="cve-tooltip">
|
||||
{/* Arrow */}
|
||||
<div style={arrowStyle} data-testid="cve-tooltip-arrow" />
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0.5rem 0' }}>
|
||||
<Loader
|
||||
style={{ width: 18, height: 18, color: '#0EA5E9', animation: 'spin 1s linear infinite' }}
|
||||
data-testid="cve-tooltip-loader"
|
||||
/>
|
||||
</div>
|
||||
) : data && data.exists ? (
|
||||
<>
|
||||
{/* CVE ID header */}
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700,
|
||||
color: '#E2E8F0',
|
||||
marginBottom: '0.4rem',
|
||||
letterSpacing: '0.02em',
|
||||
}}>
|
||||
{data.cve_id}
|
||||
</div>
|
||||
|
||||
{/* Severity badge */}
|
||||
{severity && (
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
padding: '0.2rem 0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
border: `1.5px solid ${colors.border}`,
|
||||
background: colors.bg,
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
{/* Glow dot */}
|
||||
<span style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: '50%',
|
||||
background: colors.dot,
|
||||
boxShadow: `0 0 6px ${colors.dot}`,
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
color: colors.text,
|
||||
}}>
|
||||
{severity}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{data.description && (
|
||||
<div style={{
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1.5,
|
||||
color: '#CBD5E1',
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, Chevr
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import IvantiCountsChart from './IvantiCountsChart';
|
||||
import CveTooltip from '../CveTooltip';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||
@@ -920,7 +921,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render a single table cell by column key
|
||||
// ---------------------------------------------------------------------------
|
||||
function TableCell({ colKey, finding, canWrite }) {
|
||||
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave }) {
|
||||
switch (colKey) {
|
||||
case 'findingId':
|
||||
return (
|
||||
@@ -956,7 +957,12 @@ function TableCell({ colKey, finding, canWrite }) {
|
||||
<td style={{ padding: '0.45rem 0.75rem', minWidth: '160px', maxWidth: '240px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
|
||||
{shown.map((cve) => (
|
||||
<span key={cve} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }}>
|
||||
<span
|
||||
key={cve}
|
||||
onMouseEnter={onCveMouseEnter ? (e) => onCveMouseEnter(cve, e) : undefined}
|
||||
onMouseLeave={onCveMouseLeave || undefined}
|
||||
style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{cve}
|
||||
</span>
|
||||
))}
|
||||
@@ -2312,11 +2318,32 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const [batchWorkflowType, setBatchWorkflowType] = useState('FP');
|
||||
const [batchVendor, setBatchVendor] = useState('');
|
||||
|
||||
// CVE tooltip state & refs
|
||||
const [tooltipCveId, setTooltipCveId] = useState(null);
|
||||
const [tooltipAnchorRect, setTooltipAnchorRect] = useState(null);
|
||||
const tooltipCacheRef = useRef(new Map());
|
||||
const hoverTimerRef = useRef(null);
|
||||
|
||||
const updateColumns = useCallback((newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
saveColumnOrder(newOrder);
|
||||
}, []);
|
||||
|
||||
// CVE tooltip hover handlers
|
||||
const handleCveMouseEnter = useCallback((cveId, e) => {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setTooltipCveId(cveId);
|
||||
setTooltipAnchorRect(e.target.getBoundingClientRect());
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleCveMouseLeave = useCallback(() => {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
setTooltipCveId(null);
|
||||
setTooltipAnchorRect(null);
|
||||
}, []);
|
||||
|
||||
const applyState = (data) => {
|
||||
setTotal(data.total ?? 0);
|
||||
setFindings(data.findings || []);
|
||||
@@ -2358,7 +2385,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok) applyState(data);
|
||||
if (res.ok) {
|
||||
applyState(data);
|
||||
tooltipCacheRef.current.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading findings:', e);
|
||||
} finally {
|
||||
@@ -2373,6 +2403,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
applyState(data);
|
||||
tooltipCacheRef.current.clear();
|
||||
fetchCounts(); // refresh counts after sync
|
||||
fetchFPWorkflowCounts(); // refresh FP workflow counts after sync
|
||||
}
|
||||
@@ -3182,7 +3213,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
/>
|
||||
</td>
|
||||
{visibleCols.map((col) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} />
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
@@ -3245,6 +3276,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
selectedItems={fpModalItems}
|
||||
onSuccess={handleFpWorkflowSuccess}
|
||||
/>
|
||||
<CveTooltip
|
||||
cveId={tooltipCveId}
|
||||
anchorRect={tooltipAnchorRect}
|
||||
cache={tooltipCacheRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user