diff --git a/backend/migrations/add_ivanti_counts_history_table.js b/backend/migrations/add_ivanti_counts_history_table.js new file mode 100644 index 0000000..f474525 --- /dev/null +++ b/backend/migrations/add_ivanti_counts_history_table.js @@ -0,0 +1,41 @@ +// Migration: Add ivanti_counts_history table +// +// Stores a snapshot of open/closed Ivanti finding counts on every sync. +// Unlike ivanti_counts_cache (single-row, always overwritten), this table +// accumulates all snapshots so time-series charts can be built from it. +// +// The GET /api/ivanti/findings/counts/history endpoint aggregates these rows +// to the last snapshot per calendar day using a ROW_NUMBER window function. +// +// NOTE: This table is also created automatically at server startup via +// CREATE TABLE IF NOT EXISTS in initTables() (ivantiFindings.js). +// This script is provided for manual setup on fresh installs and for +// documentation consistency with other migration files. +// +// Usage: node backend/migrations/add_ivanti_counts_history_table.js + +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const dbPath = path.join(__dirname, '..', 'cve_database.db'); +const db = new sqlite3.Database(dbPath); + +console.log('Starting ivanti_counts_history migration...'); + +db.serialize(() => { + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_counts_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + open_count INTEGER NOT NULL, + closed_count INTEGER NOT NULL, + recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `, (err) => { + if (err) console.error('Error creating ivanti_counts_history table:', err); + else console.log('✓ ivanti_counts_history table created (or already exists)'); + }); +}); + +db.close(() => { + console.log('Migration complete.'); +}); diff --git a/backend/routes/archerTickets.js b/backend/routes/archerTickets.js index 147ce47..3c28342 100644 --- a/backend/routes/archerTickets.js +++ b/backend/routes/archerTickets.js @@ -217,6 +217,25 @@ function createArcherTicketsRouter(db) { }); }); + // GET /status-trend — ticket counts grouped by creation date + status + // Used for time-based Archer pipeline chart on the Compliance page. + router.get('/status-trend', requireAuth(db), (req, res) => { + db.all( + `SELECT DATE(created_at) AS date, status, COUNT(*) AS count + FROM archer_tickets + GROUP BY DATE(created_at), status + ORDER BY date ASC`, + [], + (err, rows) => { + if (err) { + console.error('Error fetching Archer status trend:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + res.json({ statusTrend: rows }); + } + ); + }); + return router; } diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js index a9262e5..640f0c9 100644 --- a/backend/routes/compliance.js +++ b/backend/routes/compliance.js @@ -584,6 +584,128 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) { } }); + // ----------------------------------------------------------------------- + // GET /trends + // Per-upload active totals + per-team counts for time-series charts. + // Returns rows ordered ascending by report_date. + // ----------------------------------------------------------------------- + router.get('/trends', async (req, res) => { + try { + const uploads = await dbAll(db, + `SELECT id, report_date, + COALESCE(new_count, 0) AS new_count, + COALESCE(recurring_count, 0) AS recurring_count, + COALESCE(resolved_count, 0) AS resolved_count, + COALESCE(new_count, 0) + COALESCE(recurring_count, 0) AS total_active + FROM compliance_uploads + ORDER BY report_date ASC` + ); + + if (uploads.length === 0) return res.json({ trends: [] }); + + // Per-team active counts — items whose upload_id matches the upload + // (recurring items have upload_id bumped each cycle, so this is accurate) + const teamRows = await dbAll(db, + `SELECT ci.upload_id, ci.team, COUNT(ci.id) AS count + FROM compliance_items ci + WHERE ci.team IS NOT NULL + GROUP BY ci.upload_id, ci.team` + ); + + const teamMap = {}; + teamRows.forEach(r => { + if (!teamMap[r.upload_id]) teamMap[r.upload_id] = {}; + teamMap[r.upload_id][r.team] = r.count; + }); + + const trends = uploads.map(u => ({ + report_date: u.report_date, + new_count: u.new_count, + recurring_count: u.recurring_count, + resolved_count: u.resolved_count, + total_active: u.total_active, + STEAM: teamMap[u.id]?.STEAM || 0, + 'ACCESS-ENG': teamMap[u.id]?.['ACCESS-ENG'] || 0, + 'ACCESS-OPS': teamMap[u.id]?.['ACCESS-OPS'] || 0, + INTELDEV: teamMap[u.id]?.INTELDEV || 0, + })); + + res.json({ trends }); + } catch (err) { + console.error('[Compliance] GET /trends error:', err.message); + res.status(500).json({ error: 'Database error' }); + } + }); + + // ----------------------------------------------------------------------- + // GET /mttr + // Mean time to resolution (calendar days) per team, for resolved items. + // ----------------------------------------------------------------------- + router.get('/mttr', async (req, res) => { + try { + const rows = await dbAll(db, + `SELECT + ci.team, + ROUND(AVG(JULIANDAY(ru.report_date) - JULIANDAY(fu.report_date)), 1) AS avg_days, + COUNT(*) AS resolved_count + FROM compliance_items ci + JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id + JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id + WHERE ci.resolved_upload_id IS NOT NULL + AND fu.report_date IS NOT NULL + AND ru.report_date IS NOT NULL + GROUP BY ci.team + ORDER BY avg_days DESC` + ); + res.json({ mttr: rows }); + } catch (err) { + console.error('[Compliance] GET /mttr error:', err.message); + res.status(500).json({ error: 'Database error' }); + } + }); + + // ----------------------------------------------------------------------- + // GET /top-recurring + // Active findings grouped by team + metric_id, sorted by seen_count desc. + // Identifies chronic compliance gaps that keep reappearing. + // ----------------------------------------------------------------------- + router.get('/top-recurring', async (req, res) => { + try { + const rows = await dbAll(db, + `SELECT team, metric_id, metric_desc, seen_count, COUNT(*) AS host_count + FROM compliance_items + WHERE status = 'active' + GROUP BY team, metric_id + ORDER BY seen_count DESC, host_count DESC + LIMIT 20` + ); + res.json({ items: rows }); + } catch (err) { + console.error('[Compliance] GET /top-recurring error:', err.message); + res.status(500).json({ error: 'Database error' }); + } + }); + + // ----------------------------------------------------------------------- + // GET /category-trend + // Active item counts per category per upload, for stacked area chart. + // ----------------------------------------------------------------------- + router.get('/category-trend', async (req, res) => { + try { + const rows = await dbAll(db, + `SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id) AS count + FROM compliance_uploads cu + JOIN compliance_items ci ON ci.upload_id = cu.id + GROUP BY cu.id, category + ORDER BY cu.report_date ASC` + ); + res.json({ categoryTrend: rows }); + } catch (err) { + console.error('[Compliance] GET /category-trend error:', err.message); + res.status(500).json({ error: 'Database error' }); + } + }); + return router; } diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 98ad0e1..1158497 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -175,6 +175,15 @@ function initTables(db) { db.run(` CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id ON ivanti_finding_overrides(finding_id) + `, (err) => { if (err) return reject(err); }); + + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_counts_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + open_count INTEGER NOT NULL, + closed_count INTEGER NOT NULL, + recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) `, (err) => { if (err) reject(err); else resolve(); @@ -271,6 +280,14 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) { `UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`, [openCount, closedCount] ); + + // Append a snapshot to history — every sync is stored; the history + // endpoint aggregates to last-per-day at query time (Option B). + await dbRun(db, + `INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`, + [openCount, closedCount] + ); + console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`); } catch (err) { console.error('[Ivanti Findings] Failed to fetch closed count:', err.message); @@ -576,6 +593,33 @@ function createIvantiFindingsRouter(db, requireAuth) { } }); + // GET /counts/history — last snapshot per day, ascending, for the trend chart. + // Uses a window function (ROW_NUMBER) to pick the final sync of each calendar day. + router.get('/counts/history', async (req, res) => { + try { + const rows = await new Promise((resolve, reject) => { + db.all( + `SELECT date, open_count, closed_count FROM ( + SELECT DATE(recorded_at) AS date, + open_count, closed_count, + ROW_NUMBER() OVER ( + PARTITION BY DATE(recorded_at) + ORDER BY recorded_at DESC + ) AS rn + FROM ivanti_counts_history + ) WHERE rn = 1 + ORDER BY date ASC`, + [], + (err, rows) => { if (err) reject(err); else resolve(rows || []); } + ); + }); + res.json({ history: rows }); + } catch (err) { + console.error('[Ivanti Findings] GET /counts/history error:', err.message); + res.status(500).json({ error: 'Database error' }); + } + }); + // GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed) router.get('/fp-workflow-counts', async (req, res) => { try { diff --git a/docs/security-posture-workflow-diagrams.md b/docs/security-posture-workflow-diagrams.md index 6df302e..b1eb658 100644 --- a/docs/security-posture-workflow-diagrams.md +++ b/docs/security-posture-workflow-diagrams.md @@ -9,7 +9,7 @@ Renders natively in GitHub, GitLab, and most modern documentation tools. ```mermaid flowchart TD - START([Open Reporting Page]) --> SYNC + START([Open Vulnerability Triage Page]) --> SYNC SYNC["① Sync & Sort
Click Sync · Sort Due Date ascending"] SYNC --> DUE{Overdue
findings?} @@ -94,11 +94,11 @@ flowchart TD ## Diagram 2 — FP Workflow Badge Status Decision Tree -What to do when a finding already has a workflow badge in the Reporting page. +What to do when a finding already has a workflow badge in the Vulnerability Triage page. ```mermaid flowchart LR - A([Finding in
Reporting Page]) --> B{"Check
Workflow column"} + A([Finding in
Vulnerability Triage]) --> B{"Check
Workflow column"} B -->|No badge| C["UNTRIAGED
No action on record"] C --> C1(["Follow the
Step 1–5 triage workflow ↑"]) diff --git a/docs/team-training-agenda.md b/docs/team-training-agenda.md new file mode 100644 index 0000000..477b3fe --- /dev/null +++ b/docs/team-training-agenda.md @@ -0,0 +1,158 @@ +# STEAM Security Dashboard — Team Training Agenda + +**Session length:** 30–40 minutes +**Format:** Live walkthrough (share your screen on the dashboard) +**Reference docs:** `security-posture-workflow.md` for full detail on anything covered here + +--- + +## Pre-meeting prep + +- Have the dashboard open and logged in before the meeting starts +- Sync Vulnerability Triage page so data is fresh when you get there +- Print or share `security-posture-workflow.md` as a take-home reference + +--- + +## Segment 1 — Why this tool exists (3 min) + +**Talking points:** +- We have open Ivanti findings in the 8.5–9.9 VRR range — these are the ones we own and are accountable for +- Every finding needs a documented action within **60 days of detection** (the SLA rule) +- Findings that age past their Due Date make a device non-compliant in AEO posture reporting +- This dashboard is how we track, triage, and prove we've actioned everything — replaces manual spreadsheet tracking + +--- + +## Segment 2 — Dashboard orientation (4 min) + +**Show on screen:** Navigate through each page in the nav drawer + +- **Home (CVE Management)** — our CVE research library; this is where we store screenshots, advisories, and Archer EXC numbers against each CVE/vendor pair +- **Vulnerability Triage (Host Findings)** — the daily operational page; this is where you spend most of your time +- **Compliance** — AEO posture data uploaded from the NTS_AEO xlsx; shows metric health per team +- **Knowledge Base** — internal docs, runbooks, advisories +- **Exports** — bulk data extracts when needed + +> Tell the team: *"The Vulnerability Triage page is what we'll focus on today — that's where the workflow lives."* + +--- + +## Segment 3 — The three things you can do with a finding (5 min) + +**Talking points — before showing the table, set context:** + +Every finding in our range gets one of three designations: + +1. **Remediation** — you fix the root cause + - Firmware/software upgrade → no ticket needed, finding drops off on next scan + - Configuration change → **Archer EXC ticket required** (if the config is ever rolled back, the vulnerability comes back — the ticket documents that we know) + +2. **False Positive (FP)** — the scanner flagged something that doesn't actually apply to our platform or version + - Requires an FP workflow opened in Ivanti + - Evidence requirements: (a) **screenshot from the device** showing hostname, IP, and SW version — CLI text is not accepted; (b) vendor documentation (advisory, email, support ticket) confirming it doesn't affect us + - Upload evidence to the CVE database on the Home page so we can reuse it when the FP expires + +3. **Risk Acceptance (Archer EXC)** — we can't patch, for a documented reason + - Vendor hasn't released a patch yet + - Device is EOL/EOS — needs mitigation steps + remediation plan in the ticket + - Business constraint — needs justification and compensating controls + - Format: enter `EXC-XXXXX` in the finding's Notes cell after the ticket is created + +> Tell the team: *"Knowing which path you're on before you touch the dashboard makes triage fast. The workflow is just deciding which of these three it is."* + +--- + +## Segment 4 — The 5-step workflow on the Vulnerability Triage page (15 min) + +**Show on screen:** Vulnerability Triage page, live walkthrough on a real finding + +### Step 1 — Sync and sort (1 min) +- Click **Sync** top-right, wait for timestamp to update +- Click **Due Date** column to sort ascending — reds first, then ambers +- Red = overdue, Amber = due within 30 days — work these first + +### Step 2 — Identify the host (3 min) +- Use the **IP address** in the row to verify the hostname in Infoblox (preferred) or IPControl +- If Ivanti has a stale hostname: click the **Host cell** directly in the table — it's inline editable +- An amber dot appears on overridden cells; original value is preserved and can be restored +- Show the revert button (↻) so they know corrections aren't permanent unless they want them to be + +### Step 3 — Check who owns the asset (2 min) +- Look at the **BU column** +- If it's `NTS-AEO-STEAM` or `NTS-AEO-ACCESS-ENG` → our team, continue +- Anything else (or blank) → not ours → **CARD queue** + - Check the row checkbox, select CARD, click Add to Queue + - IP address is captured automatically for the CARD search + - Process CARD items in a separate session + +### Step 4 — Look up the CVEs (4 min) +- Each row shows up to 2 CVEs; hover the **+N badge** to see more +- Go to Home page, search for the CVE ID + - If it exists → review existing notes, docs, and any EXC numbers already linked + - If not → click **Add CVE**, enter the CVE ID, NVD auto-fill populates the rest +- Research: vendor advisory portal (Juniper PSN, Cisco Bug Search) — determine if it's an FP, can be patched, or needs an Archer ticket + +### Step 5 — Take action (5 min) +- **Patch available (firmware/SW)** — plan the upgrade, add a note to the finding row, done +- **Config change only** — checkbox → Vendor → select **Archer** → Add to Queue → process in Ivanti later +- **False Positive** — collect screenshot + vendor doc, upload to Home page CVE entry, then checkbox → Vendor → select **FP** → Add to Queue → submit FP in Ivanti in a separate session +- **Can't patch (Archer)** — same as config change path; once EXC number is issued, paste it into the finding's **Notes cell** (`EXC-XXXXX` format) + +--- + +## Segment 5 — The Ivanti Queue (5 min) + +**Show on screen:** Click the Queue button, show the panel + +- **Purpose:** tag findings as you triage, then batch all the Ivanti / Archer work in one focused session instead of context-switching constantly +- Three types: **FP** (amber), **Archer** (sky blue), **CARD** (green) +- CARD items show the IP address so you can search directly in CARD +- Check the green checkbox on an item when the Ivanti/Archer action is done +- Multi-select delete: check the small red boxes, click **Delete (N)** in the footer +- Queue is **personal to your login** — each person has their own; it persists across sessions + +--- + +## Segment 6 — Workflow badge colours (3 min) + +**Show on screen:** Workflow column on the Vulnerability Triage table + +Quick rule: **red = act now, amber = act soon, blue = monitor, no badge = needs triage** + +| Badge | What it means | What to do | +|---|---|---| +| Red — Expired | FP ticket lapsed, finding re-opened | Submit a new FP in Ivanti | +| Red — Rejected | Security team denied the FP | Remediate — do not resubmit without new evidence | +| Amber — Reworked | Reviewer returned the ticket | Open in Ivanti, update justification, resubmit | +| Amber — Actionable | Ticket flagged for team response | Open in Ivanti and respond | +| Blue — Requested | FP submitted, awaiting approval | Monitor; follow up if SLA is approaching | +| No badge | Never been triaged | Run it through the 5-step workflow | + +--- + +## Segment 7 — Quick tips (2 min) + +Quick features worth pointing out before Q&A: + +- **Filter to untriaged only** — click the **Pending** segment on the Action Coverage donut chart +- **Find all findings tied to an Archer ticket** — click the EXC badge on the Home page CVE row +- **Filter by vendor, IP, SLA status** — click the filter icon (⊙) on any column header +- **Save evidence once, reuse it** — uploading screenshots/advisories to the CVE database means when an FP expires you already have the files + +--- + +## Segment 8 — Q&A (remaining time) + +Suggested prompts to open discussion if no questions come up: +- *"Walk me through what you'd do if you saw a red 'Rejected' badge on a finding."* +- *"When would you use the Ivanti Queue versus just actioning something immediately?"* +- *"What's the difference between Path B (config change) and Path D (risk acceptance) — when does each apply?"* + +--- + +## Takeaway for the team + +Point them to: +- `docs/security-posture-workflow.md` — the full process guide with all the steps, evidence requirements, and decision matrix +- `docs/security-posture-workflow-diagrams.md` — the Mermaid flowcharts if they're visual learners diff --git a/docs/time-based-reporting-recommendations.md b/docs/time-based-reporting-recommendations.md new file mode 100644 index 0000000..5069add --- /dev/null +++ b/docs/time-based-reporting-recommendations.md @@ -0,0 +1,333 @@ +# Time-Based Reporting Recommendations +**Date:** 2026-04-02 +**Author:** Engineering (Claude Code) +**Status:** Draft — for director review + +--- + +## Executive Summary + +This document analyzes the current CVE Dashboard data model and recommends a set of time-based visualizations that can be added to the Reporting page. Recommendations are grouped by feasibility: **Tier 1** can be built with data already in the database, **Tier 2** requires a lightweight new tracking table, and **Tier 3** requires structural additions. + +--- + +## Current Data Inventory + +### What Already Has Time-Series History + +| Source | Table | Date Fields | History? | +|--------|-------|-------------|----------| +| Compliance uploads | `compliance_uploads` | `report_date`, `uploaded_at` | **Yes** — one row per report cycle | +| Compliance items | `compliance_items` | `created_at`, `first_seen_upload_id`, `resolved_upload_id` | **Yes** — tracks lifecycle | +| Archer tickets | `archer_tickets` | `created_at`, `updated_at` | **Yes** — full history | +| Todo queue | `ivanti_todo_queue` | `created_at`, `updated_at` | **Yes** — by action | +| Finding notes | `ivanti_finding_notes` | `updated_at` | **Yes** — note activity | + +### What Is Point-in-Time Only (no history yet) + +| Source | Table | Problem | +|--------|-------|---------| +| Ivanti findings | `ivanti_findings_cache` | Single-row cache — overwritten on every sync | +| Ivanti counts | `ivanti_counts_cache` | Single-row cache — no snapshots stored | +| FP workflow states | Computed from `findings_json` | Ephemeral — not persisted historically | + +--- + +## Tier 1 Recommendations — Build Now (No Schema Changes) + +All of these use data that is already in the database. + +--- + +### 1.1 Compliance Trend Line — Total Active Findings Over Time + +**Description:** A line chart showing the total number of active (non-compliant) items per compliance upload date. This directly answers "are we improving over time?" + +**Data Source:** +```sql +SELECT + cu.report_date, + COUNT(ci.id) AS active_count +FROM compliance_uploads cu +JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active' +GROUP BY cu.id +ORDER BY cu.report_date ASC; +``` + +**Chart Type:** Line chart with data points per upload +**Axes:** X = Report Date, Y = Number of Active Findings +**Value-add:** Overlay a trend line (linear regression) to show trajectory + +--- + +### 1.2 New / Recurring / Resolved Bar Chart — Per Report Cycle + +**Description:** A grouped or stacked bar chart showing the delta breakdown for each compliance upload: how many findings were newly introduced, how many recurred from a prior cycle, and how many were resolved. + +**Data Source:** Already computed and stored in `compliance_uploads`: +```sql +SELECT report_date, new_count, recurring_count, resolved_count +FROM compliance_uploads +ORDER BY report_date ASC; +``` + +**Chart Type:** Stacked bar chart (one bar per upload date) +**Legend:** New (red/amber), Recurring (yellow), Resolved (green) +**Value-add:** Shows whether each reporting cycle is improving (more resolved than new) or degrading + +--- + +### 1.3 Team Compliance Health Over Time — Multi-Line Chart + +**Description:** A multi-line chart showing the active finding count per team per upload date. Answers "which team is trending better or worse?" + +**Data Source:** +```sql +SELECT + cu.report_date, + ci.team, + COUNT(ci.id) AS active_count +FROM compliance_uploads cu +JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active' +GROUP BY cu.id, ci.team +ORDER BY cu.report_date ASC; +``` + +**Chart Type:** Multi-line chart (one line per team) +**Teams:** STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV +**Value-add:** Immediately visible which team is outlier or improving fastest + +--- + +### 1.4 Mean Time to Resolution (MTTR) — Per Team + +**Description:** A bar chart showing average number of upload cycles between when a finding first appeared and when it was resolved, broken out by team. + +**Data Source:** +```sql +SELECT + ci.team, + AVG(ci.resolved_upload_id - ci.first_seen_upload_id) AS avg_cycles_to_resolve, + COUNT(*) AS resolved_count +FROM compliance_items ci +WHERE ci.resolved_upload_id IS NOT NULL +GROUP BY ci.team; +``` + +**Chart Type:** Horizontal bar chart +**Axes:** Y = Team, X = Average Cycles to Resolution +**Value-add:** Normalize to calendar days by joining with upload dates for true MTTR in days + +--- + +### 1.5 Recurring Findings Heatmap — Seen Count Distribution + +**Description:** A heatmap or bubble chart showing findings grouped by how many times they have recurred (`seen_count`). Identifies chronic, long-standing compliance gaps. + +**Data Source:** +```sql +SELECT + team, + metric_id, + metric_desc, + seen_count, + COUNT(*) AS host_count +FROM compliance_items +WHERE status = 'active' +GROUP BY team, metric_id +ORDER BY seen_count DESC; +``` + +**Chart Type:** Horizontal bar chart sorted by `seen_count`, grouped by team +**Value-add:** Highlights the "chronic" findings that repeatedly appear — high priority for remediation + +--- + +### 1.6 Archer Exception Ticket Status Over Time + +**Description:** A line chart or cumulative area chart showing Archer ticket status transitions over time using `created_at` and `updated_at`. + +**Data Source:** +```sql +SELECT + DATE(created_at) AS date, + status, + COUNT(*) AS count +FROM archer_tickets +GROUP BY DATE(created_at), status +ORDER BY date ASC; +``` + +**Chart Type:** Stacked area chart +**Statuses:** Draft, Open, Under Review, Accepted +**Value-add:** Tracks exception request pipeline velocity — are exceptions getting processed or stacking up? + +--- + +### 1.7 Compliance Category Breakdown Over Time + +**Description:** A stacked area chart showing what categories of compliance failures are driving the total over time (if the `category` field in `compliance_items` is populated). + +**Data Source:** +```sql +SELECT + cu.report_date, + ci.category, + COUNT(ci.id) AS count +FROM compliance_uploads cu +JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active' +WHERE ci.category IS NOT NULL +GROUP BY cu.id, ci.category +ORDER BY cu.report_date ASC; +``` + +**Chart Type:** Stacked area chart +**Value-add:** Shows whether one category dominates or if failures are spread across areas + +--- + +## Tier 2 Recommendations — Lightweight Schema Addition Required + +These require adding one new table to persist snapshots of data that is currently overwritten on each sync. + +--- + +### 2.1 Ivanti Findings Count Over Time — Open vs Closed Trend + +**Description:** The single most-requested metric: "are we making progress on vulnerabilities?" A line chart showing open and closed Ivanti finding counts over time. + +**Problem:** The current `ivanti_counts_cache` is a single-row table overwritten on each sync. No history is kept. + +**Solution:** Add a `ivanti_counts_history` table and append a row on every successful sync: +```sql +CREATE TABLE ivanti_counts_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + open_count INTEGER NOT NULL, + closed_count INTEGER NOT NULL, + recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +**Backend change:** In the sync route (`POST /api/ivanti/findings/sync`), after updating the cache, also `INSERT INTO ivanti_counts_history`. + +**New API endpoint:** `GET /api/ivanti/findings/counts/history` +```sql +SELECT open_count, closed_count, recorded_at +FROM ivanti_counts_history +ORDER BY recorded_at ASC; +``` + +**Chart Type:** Dual-line chart +**Lines:** Open findings (red), Closed findings (green) +**Value-add:** Most direct measure of vulnerability remediation velocity + +--- + +### 2.2 FP Workflow State Snapshots Over Time + +**Description:** A stacked area or line chart showing how FP workflow states (Actionable, Requested, Approved, Rejected, Expired) trend over sync cycles. + +**Solution:** Add a `ivanti_fp_workflow_history` table: +```sql +CREATE TABLE ivanti_fp_workflow_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + state TEXT NOT NULL, + finding_count INTEGER NOT NULL DEFAULT 0, + id_count INTEGER NOT NULL DEFAULT 0, + recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +**Chart Type:** Stacked area chart +**Value-add:** Shows whether FP requests are being worked through or stacking up in "Requested" state + +--- + +### 2.3 Todo Queue Velocity — Items Added vs Completed Per Week + +**Description:** A bar chart showing weekly queue throughput (items added vs items marked complete). + +**Data Source:** Already available in `ivanti_todo_queue.created_at` and `updated_at` + `status = 'complete'`: +```sql +SELECT + STRFTIME('%Y-W%W', created_at) AS week, + COUNT(*) AS items_added, + SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END) AS items_completed +FROM ivanti_todo_queue +GROUP BY week +ORDER BY week ASC; +``` + +**Chart Type:** Grouped bar chart (weekly) +**Value-add:** Measures operational pace of the team's workflow action throughput + +--- + +## Tier 3 Recommendations — Structural Additions (Future Consideration) + +These require more significant changes but would provide powerful long-term reporting. + +--- + +### 3.1 Finding Age / Dwell Time Distribution + +**Description:** A histogram showing how long open findings have been open (age in days). The `lastFoundOn` field exists in the Ivanti findings JSON but is not persisted to a structured table. + +**Requirement:** Parse and store `lastFoundOn` from findings JSON into a structured column during sync. + +**Value-add:** Highlights findings that have been open for 90+ days — high-priority remediation targets. + +--- + +### 3.2 SLA Breach Trends + +**Description:** Track how many findings breach SLA (Due Date exceeded) over time. Currently SLA status is computed in the frontend on-the-fly. + +**Requirement:** Add SLA breach tracking during sync — stamp findings that cross SLA date. + +**Value-add:** Compliance and audit reporting for SLA adherence metrics. + +--- + +## Recommended Implementation Order + +| Priority | Chart | Effort | Impact | +|----------|-------|--------|--------| +| 1 | 1.2 — New/Recurring/Resolved bar chart | Low (data ready) | High | +| 2 | 1.1 — Compliance trend line | Low (data ready) | High | +| 3 | 1.3 — Team health multi-line | Low (data ready) | High | +| 4 | 2.1 — Ivanti open/closed history | Medium (new table) | Very High | +| 5 | 1.4 — MTTR per team | Low (data ready) | Medium | +| 6 | 1.6 — Archer ticket pipeline | Low (data ready) | Medium | +| 7 | 2.3 — Queue velocity | Low (data ready) | Medium | +| 8 | 1.5 — Recurring findings heatmap | Low (data ready) | Medium | +| 9 | 2.2 — FP workflow snapshots | Medium (new table) | Medium | +| 10 | 1.7 — Category breakdown | Low (data ready) | Low–Medium | + +--- + +## Charting Library Consideration + +The current implementation uses **hand-rolled SVG donut charts** (no external library). For time-series line/bar/area charts, the team should decide: + +| Option | Pros | Cons | +|--------|------|------| +| **Continue hand-rolled SVG** | Zero dependencies, full style control | Significant effort for axes, labels, tooltips | +| **Recharts** (React-native) | Well-matched to React 19, composable, responsive | ~500KB dependency | +| **Chart.js via react-chartjs-2** | Mature, widely documented | Less React-idiomatic | +| **Lightweight: uPlot or Chart.xkcd** | Very small bundle | Less community support | + +**Recommendation:** Recharts aligns best with the React 19 stack and allows declaring charts as JSX components consistent with the existing code style. It supports all chart types listed above. + +--- + +## Notes for Director Review + +- All **Tier 1** recommendations can be implemented with zero database migrations — the data is already there. +- The **single highest-value addition** is `2.1 — Ivanti open/closed count history`, as it captures the most direct remediation progress metric. It only requires one new table and one line added to the sync handler. +- **Compliance charts (1.1–1.5)** will only be meaningful once multiple compliance uploads have been committed. If only 1–2 uploads exist currently, the trend will not show much until more data accumulates — but building the charts now means data will automatically populate them. +- All queries listed above have been validated against the actual database schema. + +--- + +*Next step: Review with director, confirm priority order, then schedule sprint for implementation.* diff --git a/frontend/package.json b/frontend/package.json index cd53864..b0f1df0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "react-dom": "^19.2.4", "react-markdown": "^10.1.0", "react-scripts": "5.0.1", + "recharts": "^3.8.1", "web-vitals": "^2.1.4", "xlsx": "^0.18.5" }, diff --git a/frontend/src/App.js b/frontend/src/App.js index 7d4a8d3..c2425be 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -6,11 +6,9 @@ import UserMenu from './components/UserMenu'; import UserManagement from './components/UserManagement'; import AuditLog from './components/AuditLog'; import NvdSyncModal from './components/NvdSyncModal'; -import KnowledgeBaseModal from './components/KnowledgeBaseModal'; -import KnowledgeBaseViewer from './components/KnowledgeBaseViewer'; import NavDrawer from './components/NavDrawer'; import CalendarWidget from './components/CalendarWidget'; -import ReportingPage from './components/pages/ReportingPage'; +import VulnerabilityTriagePage from './components/pages/ReportingPage'; import KnowledgeBasePage from './components/pages/KnowledgeBasePage'; import ExportsPage from './components/pages/ExportsPage'; import CompliancePage from './components/pages/CompliancePage'; @@ -185,9 +183,6 @@ export default function App() { const [showUserManagement, setShowUserManagement] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false); const [showNvdSync, setShowNvdSync] = useState(false); - const [showKnowledgeBase, setShowKnowledgeBase] = useState(false); - const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]); - const [selectedKBArticle, setSelectedKBArticle] = useState(null); const [newCVE, setNewCVE] = useState({ cve_id: '', vendor: '', @@ -311,19 +306,6 @@ export default function App() { } }; - const fetchKnowledgeBaseArticles = async () => { - try { - const response = await fetch(`${API_BASE}/knowledge-base`, { - credentials: 'include' - }); - if (!response.ok) throw new Error('Failed to fetch knowledge base articles'); - const data = await response.json(); - setKnowledgeBaseArticles(data); - } catch (err) { - console.error('Error fetching knowledge base articles:', err); - } - }; - const fetchJiraTickets = async () => { try { const response = await fetch(`${API_BASE}/jira-tickets`, { @@ -442,45 +424,6 @@ export default function App() { alert(`Exporting ${selectedDocuments.length} documents for report attachment`); }; - const handleViewKBArticle = async (articleId) => { - try { - const response = await fetch(`${API_BASE}/knowledge-base/${articleId}`, { - credentials: 'include' - }); - - if (!response.ok) throw new Error('Failed to fetch article'); - - const article = await response.json(); - setSelectedKBArticle(article); - } catch (err) { - console.error('Error fetching knowledge base article:', err); - setError('Failed to load article'); - } - }; - - const handleDownloadKBArticle = async (id, filename) => { - try { - const response = await fetch(`${API_BASE}/knowledge-base/${id}/download`, { - credentials: 'include' - }); - - if (!response.ok) throw new Error('Download failed'); - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - } catch (err) { - console.error('Error downloading knowledge base article:', err); - setError('Failed to download document'); - } - }; - const handleAddCVE = async (e) => { e.preventDefault(); try { @@ -916,7 +859,6 @@ export default function App() { fetchJiraTickets(); fetchArcherTickets(); fetchIvantiWorkflows(); - fetchKnowledgeBaseArticles(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated]); @@ -966,14 +908,14 @@ export default function App() { currentPage={currentPage} onNavigate={(page) => { // Clear contextual filters when navigating directly via the nav drawer - if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); } + if (page === 'triage') { setCalendarFilter(null); setReportingExcFilter(null); } setCurrentPage(page); }} /> {/* Scanning line effect */}
-
+
{/* Header */}
@@ -1043,7 +985,7 @@ export default function App() {
{/* Page content */} - {currentPage === 'reporting' && } + {currentPage === 'triage' && } {currentPage === 'compliance' && } {currentPage === 'knowledge-base' && } {currentPage === 'exports' && } @@ -1063,14 +1005,6 @@ export default function App() { setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} /> )} - {/* Knowledge Base Modal */} - {showKnowledgeBase && ( - setShowKnowledgeBase(false)} - onUpdate={fetchKnowledgeBaseArticles} - /> - )} - {/* Add CVE Modal */} {showAddCVE && (
@@ -1661,90 +1595,11 @@ export default function App() {
)} - {/* Three Column Layout - Home page only */} + {/* Two Column Layout - Home page only */} {currentPage === 'home' &&
- {/* LEFT PANEL - Wiki/Knowledge Base */} -
-
-
-

- Knowledge Base -

- {(user?.role === 'admin' || user?.role === 'editor') && ( - - )} -
- - {/* Knowledge Base Entries */} -
- {knowledgeBaseArticles.length === 0 ? ( -
- -

No documents yet

- {(user?.role === 'admin' || user?.role === 'editor') && ( - - )} -
- ) : ( - knowledgeBaseArticles.slice(0, 5).map((article) => ( -
handleViewKBArticle(article.id)} - style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} - className="hover:border-intel-success" - > -

{article.title}

- {article.description && ( -

{article.description}

- )} -
- - {new Date(article.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} - - {article.category && article.category !== 'General' && ( - - {article.category} - - )} -
-
- )) - )} - {knowledgeBaseArticles.length > 5 && ( - - )} -
-
-
- {/* CENTER PANEL - Main Content */} -
- {/* Knowledge Base Viewer */} - {selectedKBArticle ? ( - setSelectedKBArticle(null)} - /> - ) : ( - <> +
+ <> {/* Quick Check */}
@@ -2216,7 +2071,6 @@ export default function App() {
)} - )}
{/* End Center Panel */} @@ -2231,7 +2085,7 @@ export default function App() { { setCalendarFilter(dateStr); - setCurrentPage('reporting'); + setCurrentPage('triage'); }} />
@@ -2337,7 +2191,7 @@ export default function App() {
+ + {!collapsed && ( +
+ + {/* 1. Active findings over time */} + + + + + {/* 2. New / Recurring / Resolved delta per cycle */} + + + + + {/* 3. Team health multi-line */} + + + + + {/* 4. MTTR per team */} + + + + + {/* 5. Most persistent / recurring findings */} + + + + + {/* 6. Archer ticket pipeline */} + + + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/pages/ComplianceDetailPanel.js b/frontend/src/components/pages/ComplianceDetailPanel.js index aafd20e..5c49d77 100644 --- a/frontend/src/components/pages/ComplianceDetailPanel.js +++ b/frontend/src/components/pages/ComplianceDetailPanel.js @@ -333,7 +333,7 @@ function MetricRow({ metric, resolved, onNavigate }) {
{onNavigate && ( )}
diff --git a/frontend/src/components/pages/CompliancePage.js b/frontend/src/components/pages/CompliancePage.js index 85f01d4..d585b7b 100644 --- a/frontend/src/components/pages/CompliancePage.js +++ b/frontend/src/components/pages/CompliancePage.js @@ -3,6 +3,7 @@ import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-re import { useAuth } from '../../contexts/AuthContext'; import ComplianceUploadModal from './ComplianceUploadModal'; import ComplianceDetailPanel from './ComplianceDetailPanel'; +import ComplianceChartsPanel from './ComplianceChartsPanel'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const TEAL = '#14B8A6'; @@ -323,6 +324,9 @@ export default function CompliancePage({ onNavigate }) {
) : null} + {/* ── Historical trend charts ──────────────────────────────── */} + + {/* ── Device table ─────────────────────────────────────────── */}
p.dataKey === 'open_count')?.value; + const closedVal = payload.find(p => p.dataKey === 'closed_count')?.value; + + return ( +
+
+ {label} +
+ {payload.map(p => ( +
+ {p.name} + {p.value} +
+ ))} + {openVal != null && closedVal != null && ( +
+ total + {openVal + closedVal} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Shorten YYYY-MM-DD to MM/DD/YY +// --------------------------------------------------------------------------- +function fmtDate(d) { + if (!d) return ''; + const p = d.split('-'); + if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`; + return d; +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- +export default function IvantiCountsChart() { + const [collapsed, setCollapsed] = useState(false); + const [loading, setLoading] = useState(true); + const [history, setHistory] = useState([]); + + useEffect(() => { + let cancelled = false; + const load = async () => { + setLoading(true); + try { + const res = await fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' }); + if (res.ok && !cancelled) { + const d = await res.json(); + setHistory(d.history || []); + } + } catch { /* silent — chart shows no-data state */ } + finally { if (!cancelled) setLoading(false); } + }; + load(); + return () => { cancelled = true; }; + }, []); + + const chartData = useMemo( + () => history.map(r => ({ ...r, date: fmtDate(r.date) })), + [history] + ); + + // Compute a simple delta label for the latest vs previous point + const deltaLabel = useMemo(() => { + if (chartData.length < 2) return null; + const latest = chartData[chartData.length - 1]; + const prev = chartData[chartData.length - 2]; + const delta = latest.open_count - prev.open_count; + if (delta === 0) return { text: 'no change in open', color: '#475569' }; + if (delta < 0) return { text: `▼ ${Math.abs(delta)} open since ${prev.date}`, color: GREEN }; + return { text: `▲ ${delta} open since ${prev.date}`, color: RED }; + }, [chartData]); + + return ( +
+ + {/* ── Header ────────────────────────────────────────────────── */} + + + {!collapsed && ( +
+
+
+ Open vs Closed — end-of-day snapshot per sync day +
+ {chartData.length > 0 && ( +
+ {chartData.length} day{chartData.length !== 1 ? 's' : ''} of data +
+ )} +
+ + {chartData.length < 2 ? ( +
+ {chartData.length === 0 + ? 'Trend data begins accumulating after the first sync — check back tomorrow' + : 'Need at least 2 days of syncs to display a trend'} +
+ ) : ( + + + + + + } /> + + + + + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/pages/KnowledgeBasePage.js b/frontend/src/components/pages/KnowledgeBasePage.js index bae66ab..719e388 100644 --- a/frontend/src/components/pages/KnowledgeBasePage.js +++ b/frontend/src/components/pages/KnowledgeBasePage.js @@ -1,25 +1,484 @@ -import React from 'react'; -import { BookOpen } from 'lucide-react'; +// KnowledgeBasePage.js +// Full-page knowledge base library — browse, search, filter, and read +// articles inline. Upload and delete require editor/admin role. +// Reuses existing KnowledgeBaseViewer and KnowledgeBaseModal components. -export default function KnowledgeBasePage() { - return ( -
-
-
- -
-

- Knowledge Base -

-

- Under construction — coming soon -

-
-
- ); +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + BookOpen, Search, Upload, RefreshCw, Loader, + AlertCircle, FileText, File, Trash2, X, +} from 'lucide-react'; +import { useAuth } from '../../contexts/AuthContext'; +import KnowledgeBaseModal from '../KnowledgeBaseModal'; +import KnowledgeBaseViewer from '../KnowledgeBaseViewer'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; +const GREEN = '#10B981'; + +// --------------------------------------------------------------------------- +// Static config +// --------------------------------------------------------------------------- +const CATEGORY_COLORS = { + General: '#94A3B8', + Policy: '#0EA5E9', + Procedure: GREEN, + Guide: '#F59E0B', + Reference: '#8B5CF6', +}; + +const FILE_EXT_COLORS = { + pdf: '#EF4444', + md: '#10B981', + txt: '#94A3B8', + doc: '#0EA5E9', + docx: '#0EA5E9', + xls: '#10B981', + xlsx: '#10B981', + ppt: '#F97316', + pptx: '#F97316', + html: '#8B5CF6', +}; + +const CATEGORY_ORDER = ['Procedure', 'Guide', 'Policy', 'Reference', 'General']; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function extOf(filename) { + return (filename || '').split('.').pop().toLowerCase(); +} + +function extColor(filename) { + return FILE_EXT_COLORS[extOf(filename)] || '#64748B'; +} + +function fmtSize(bytes) { + if (!bytes) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function fmtDate(str) { + if (!str) return ''; + return new Date(str).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); +} + +function catColor(cat) { + return CATEGORY_COLORS[cat] || '#94A3B8'; +} + +// --------------------------------------------------------------------------- +// ArticleCard +// --------------------------------------------------------------------------- +function ArticleCard({ article, selected, onSelect, onDelete, canDelete }) { + const color = catColor(article.category); + const fileColor = extColor(article.file_name); + const ext = extOf(article.file_name).toUpperCase(); + + return ( +
onSelect(article)} + style={{ + background: selected + ? `linear-gradient(135deg,rgba(16,185,129,0.1) 0%,rgba(15,23,42,0.98) 100%)` + : 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)', + border: `1.5px solid ${selected ? GREEN : 'rgba(16,185,129,0.12)'}`, + borderRadius: '0.5rem', + padding: '1rem', + cursor: 'pointer', + transition: 'all 0.15s', + position: 'relative', + display: 'flex', + flexDirection: 'column', + gap: '0.5rem', + }} + onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.35)'; }} + onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.12)'; }} + > + {/* File type badge + delete button */} +
+ + {ext} + + {canDelete && ( + + )} +
+ + {/* Title */} +
+ {article.title} +
+ + {/* Description */} + {article.description && ( +
+ {article.description} +
+ )} + + {/* Footer — category + date */} +
+ + {article.category} + +
+ {article.file_size && ( + + {fmtSize(article.file_size)} + + )} + + {fmtDate(article.created_at)} + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- +function EmptyState({ hasFilter, onClear }) { + return ( +
+ +
+ {hasFilter ? 'No articles match your search' : 'No articles yet'} +
+ {hasFilter ? ( + + ) : ( +
+ Upload a document to get started +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- +export default function KnowledgeBasePage() { + const { canWrite } = useAuth(); + + const [articles, setArticles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [activeCategory, setActiveCategory] = useState('All'); + const [selected, setSelected] = useState(null); + const [showUpload, setShowUpload] = useState(false); + + // ------------------------------------------------------------------------- + // Fetch + // ------------------------------------------------------------------------- + const fetchArticles = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' }); + if (!res.ok) throw new Error('Failed to load articles'); + const data = await res.json(); + setArticles(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchArticles(); }, [fetchArticles]); + + // ------------------------------------------------------------------------- + // Delete + // ------------------------------------------------------------------------- + const handleDelete = useCallback(async (article) => { + if (!window.confirm(`Delete "${article.title}"? This cannot be undone.`)) return; + try { + const res = await fetch(`${API_BASE}/knowledge-base/${article.id}`, { + method: 'DELETE', credentials: 'include', + }); + if (!res.ok) throw new Error('Delete failed'); + setArticles(prev => prev.filter(a => a.id !== article.id)); + if (selected?.id === article.id) setSelected(null); + } catch (err) { + alert(`Failed to delete: ${err.message}`); + } + }, [selected]); + + // ------------------------------------------------------------------------- + // Filtering + // ------------------------------------------------------------------------- + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return articles.filter(a => { + const matchesCat = activeCategory === 'All' || a.category === activeCategory; + const matchesSearch = !q || + a.title.toLowerCase().includes(q) || + (a.description || '').toLowerCase().includes(q); + return matchesCat && matchesSearch; + }); + }, [articles, activeCategory, search]); + + // Category tab counts (always from full list, not filtered by search) + const categoryCounts = useMemo(() => { + const counts = { All: articles.length }; + CATEGORY_ORDER.forEach(cat => { + counts[cat] = articles.filter(a => a.category === cat).length; + }); + return counts; + }, [articles]); + + const activeTabs = ['All', ...CATEGORY_ORDER.filter(c => categoryCounts[c] > 0)]; + + const clearFilters = () => { setSearch(''); setActiveCategory('All'); }; + + const hasFilter = search.trim() !== '' || activeCategory !== 'All'; + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + return ( +
+ + {/* ── Page header ─────────────────────────────────────────── */} +
+
+

+ Knowledge Base +

+
+ {loading ? '…' : `${articles.length} article${articles.length !== 1 ? 's' : ''}`} + {articles.length > 0 && activeCategory !== 'All' && ( + + · {categoryCounts[activeCategory] || 0} in {activeCategory} + + )} +
+
+ +
+ + {canWrite() && ( + + )} +
+
+ + {/* ── Search + category tabs ───────────────────────────────── */} +
+ + {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search articles…" + style={{ + paddingLeft: '2rem', paddingRight: search ? '2rem' : '0.625rem', + paddingTop: '0.4rem', paddingBottom: '0.4rem', + background: 'rgba(15,23,42,0.8)', + border: '1px solid rgba(16,185,129,0.2)', + borderRadius: '0.375rem', color: '#E2E8F0', + outline: 'none', fontFamily: 'monospace', fontSize: '0.75rem', + width: '220px', + }} + onFocus={e => e.target.style.borderColor = `${GREEN}60`} + onBlur={e => e.target.style.borderColor = 'rgba(16,185,129,0.2)'} + /> + {search && ( + + )} +
+ + {/* Category tabs */} +
+ {activeTabs.map(cat => { + const isActive = activeCategory === cat; + const color = cat === 'All' ? GREEN : catColor(cat); + return ( + + ); + })} +
+
+ + {/* ── Error state ──────────────────────────────────────────── */} + {error && ( +
+ + {error} + +
+ )} + + {/* ── Loading state ────────────────────────────────────────── */} + {loading && ( +
+ +
+ )} + + {/* ── Article grid ─────────────────────────────────────────── */} + {!loading && !error && ( +
+ {filtered.length === 0 ? ( + + ) : ( + filtered.map(article => ( + setSelected(selected?.id === a.id ? null : a)} + onDelete={handleDelete} + canDelete={canWrite()} + /> + )) + )} +
+ )} + + {/* ── Inline viewer ────────────────────────────────────────── */} + {selected && ( +
+ setSelected(null)} + /> +
+ )} + + {/* ── Upload modal ─────────────────────────────────────────── */} + {showUpload && ( + setShowUpload(false)} + onUpdate={() => { fetchArticles(); setShowUpload(false); }} + /> + )} +
+ ); } diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index bfd12c7..bd34ac4 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react'; import * as XLSX from 'xlsx'; import { useAuth } from '../../contexts/AuthContext'; +import IvantiCountsChart from './IvantiCountsChart'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const STORAGE_KEY = 'steam_findings_columns_v2'; @@ -1536,7 +1537,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on // --------------------------------------------------------------------------- // Main ReportingPage // --------------------------------------------------------------------------- -export default function ReportingPage({ filterDate, filterEXC }) { +export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { const { canWrite } = useAuth(); const [findings, setFindings] = useState([]); const [total, setTotal] = useState(null); @@ -1965,6 +1966,11 @@ export default function ReportingPage({ filterDate, filterEXC }) {
+ {/* ---------------------------------------------------------------- + Panel 1.5 — Open vs Closed trend over time + ---------------------------------------------------------------- */} + + {/* ---------------------------------------------------------------- Panel 2 — Findings table ---------------------------------------------------------------- */}