From b111273e5a8cb765b58b40cf3a672c8a50101664 Mon Sep 17 00:00:00 2001 From: jramos Date: Thu, 2 Apr 2026 09:49:32 -0600 Subject: [PATCH 1/7] feat(compliance): add time-based trend charts to Compliance page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 6 Recharts charts in a collapsible Historical Trends panel on the Compliance page, covering all Tier-1 recommendations from the reporting design doc. Backend — 5 new API endpoints: - GET /api/compliance/trends — active totals + per-team counts per upload - GET /api/compliance/mttr — mean days to resolution per team - GET /api/compliance/top-recurring — most persistent active findings by seen_count - GET /api/compliance/category-trend — category breakdown per upload (future use) - GET /api/archer-tickets/status-trend — ticket pipeline by creation date + status Frontend — new ComplianceChartsPanel component: - Active Findings Over Time (multi-line: total + per-team dashed) - Change per Report Cycle (stacked bar: new/recurring + resolved) - Team Compliance Health (multi-line per team) - Mean Time to Resolution (horizontal bar per team) - Most Persistent Findings (horizontal bar top-10 by seen_count) - Archer Exception Pipeline (stacked bar by date + status) All charts degrade gracefully to a no-data placeholder until uploads accumulate. Panel is collapsible to stay out of the way when not needed. Adds recharts dependency to frontend. Co-Authored-By: Claude Sonnet 4.6 --- backend/routes/archerTickets.js | 19 + backend/routes/compliance.js | 122 +++++ docs/time-based-reporting-recommendations.md | 333 ++++++++++++++ frontend/package.json | 1 + .../components/pages/ComplianceChartsPanel.js | 424 ++++++++++++++++++ .../src/components/pages/CompliancePage.js | 4 + 6 files changed, 903 insertions(+) create mode 100644 docs/time-based-reporting-recommendations.md create mode 100644 frontend/src/components/pages/ComplianceChartsPanel.js 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/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/components/pages/ComplianceChartsPanel.js b/frontend/src/components/pages/ComplianceChartsPanel.js new file mode 100644 index 0000000..f74436d --- /dev/null +++ b/frontend/src/components/pages/ComplianceChartsPanel.js @@ -0,0 +1,424 @@ +// ComplianceChartsPanel.js +// Tier-1 time-based compliance charts using Recharts. +// Charts rendered: Active Findings Over Time, Change per Cycle, +// Team Health, MTTR by Team, Persistent Findings, Archer Pipeline. + +import React, { useState, useEffect, useMemo } from 'react'; +import { + LineChart, Line, + BarChart, Bar, + XAxis, YAxis, CartesianGrid, + Tooltip, Legend, + ResponsiveContainer, +} from 'recharts'; +import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +const TEAL = '#14B8A6'; + +const TEAM_COLORS = { + 'STEAM': '#0EA5E9', + 'ACCESS-ENG': '#F59E0B', + 'ACCESS-OPS': '#8B5CF6', + 'INTELDEV': '#10B981', +}; + +const ARCHER_STATUS_COLORS = { + 'Draft': '#475569', + 'Open': '#0EA5E9', + 'Under Review': '#F59E0B', + 'Accepted': '#10B981', +}; + +// --------------------------------------------------------------------------- +// Shared style tokens +// --------------------------------------------------------------------------- +const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' }; +const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' }; +const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' }; + +// --------------------------------------------------------------------------- +// Custom dark tooltip +// --------------------------------------------------------------------------- +function DarkTooltip({ active, payload, label }) { + if (!active || !payload?.length) return null; + return ( +
+
+ {label} +
+ {payload.map(p => ( +
+ {p.name} + + {typeof p.value === 'number' + ? Number.isInteger(p.value) ? p.value : p.value.toFixed(1) + : p.value} + +
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Chart card wrapper +// --------------------------------------------------------------------------- +function ChartCard({ title, subtitle, children }) { + return ( +
+
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// Empty / no-data state +// --------------------------------------------------------------------------- +function NoData({ msg }) { + return ( +
+ {msg || 'No data yet — upload compliance reports to populate this chart'} +
+ ); +} + +// --------------------------------------------------------------------------- +// Shorten a YYYY-MM-DD string 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; +} + +// --------------------------------------------------------------------------- +// Chart 1 — Active Findings Over Time (line, total + per team) +// --------------------------------------------------------------------------- +function ActiveTrendChart({ data }) { + if (data.length < 2) return ; + return ( + + + + + + } /> + + + {Object.entries(TEAM_COLORS).map(([team, color]) => ( + + ))} + + + ); +} + +// --------------------------------------------------------------------------- +// Chart 2 — New / Recurring / Resolved per cycle (stacked + grouped bar) +// --------------------------------------------------------------------------- +function DeltaChart({ data }) { + if (data.length === 0) return ; + return ( + + + + + + } /> + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Chart 3 — Team Health Multi-Line +// --------------------------------------------------------------------------- +function TeamTrendChart({ data }) { + if (data.length < 2) return ; + return ( + + + + + + } /> + + {Object.entries(TEAM_COLORS).map(([team, color]) => ( + + ))} + + + ); +} + +// --------------------------------------------------------------------------- +// Chart 4 — MTTR by Team (horizontal bar) +// --------------------------------------------------------------------------- +function MttrChart({ data }) { + if (data.length === 0) return ; + return ( + + + + + + } /> + `${v}d` }} + /> + + + ); +} + +// --------------------------------------------------------------------------- +// Chart 5 — Most Persistent Findings (horizontal bar by seen_count) +// --------------------------------------------------------------------------- +function RecurringChart({ data }) { + if (data.length === 0) return ; + const top10 = data.slice(0, 10).map(r => ({ + ...r, + label: r.metric_id.length > 18 ? r.metric_id.slice(0, 18) + '…' : r.metric_id, + })); + return ( + + + + + + } formatter={(val, name, props) => [ + `${val} cycles (${props.payload.host_count} host${props.payload.host_count !== 1 ? 's' : ''})`, props.payload.team + ]} /> + `${v}×` }} + /> + + + ); +} + +// --------------------------------------------------------------------------- +// Chart 6 — Archer Exception Ticket Pipeline (stacked bar by creation date) +// --------------------------------------------------------------------------- +function ArcherPipelineChart({ data }) { + if (data.length === 0) return ; + return ( + + + + + + } /> + + {Object.entries(ARCHER_STATUS_COLORS).map(([status, color], i, arr) => ( + + ))} + + + ); +} + +// --------------------------------------------------------------------------- +// Main panel +// --------------------------------------------------------------------------- +export default function ComplianceChartsPanel() { + const [collapsed, setCollapsed] = useState(false); + const [loading, setLoading] = useState(true); + const [trends, setTrends] = useState([]); + const [mttr, setMttr] = useState([]); + const [recurring, setRecurring] = useState([]); + const [archerRaw, setArcherRaw] = useState([]); + + useEffect(() => { + let cancelled = false; + const load = async () => { + setLoading(true); + try { + const [tRes, mRes, rRes, aRes] = await Promise.all([ + fetch(`${API_BASE}/compliance/trends`, { credentials: 'include' }), + fetch(`${API_BASE}/compliance/mttr`, { credentials: 'include' }), + fetch(`${API_BASE}/compliance/top-recurring`, { credentials: 'include' }), + fetch(`${API_BASE}/archer-tickets/status-trend`, { credentials: 'include' }), + ]); + if (cancelled) return; + if (tRes.ok) { const d = await tRes.json(); setTrends(d.trends || []); } + if (mRes.ok) { const d = await mRes.json(); setMttr(d.mttr || []); } + if (rRes.ok) { const d = await rRes.json(); setRecurring(d.items || []); } + if (aRes.ok) { const d = await aRes.json(); setArcherRaw(d.statusTrend || []); } + } catch { /* silent — charts will show no-data state */ } + finally { if (!cancelled) setLoading(false); } + }; + load(); + return () => { cancelled = true; }; + }, []); + + // Format trend rows — add short date label + const formattedTrends = useMemo( + () => trends.map(t => ({ ...t, date: fmtDate(t.report_date) })), + [trends] + ); + + // Pivot archer raw rows → one object per date + const archerByDate = useMemo(() => { + if (!archerRaw.length) return []; + const map = {}; + archerRaw.forEach(r => { + if (!map[r.date]) map[r.date] = { date: fmtDate(r.date) }; + map[r.date][r.status] = r.count; + }); + return Object.values(map).sort((a, b) => a.date.localeCompare(b.date)); + }, [archerRaw]); + + return ( +
+ + {/* ── Section header / collapse toggle ──────────────────────── */} + + + {!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/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 ─────────────────────────────────────────── */}
Date: Thu, 2 Apr 2026 10:12:04 -0600 Subject: [PATCH 2/7] feat(triage): Ivanti findings trend chart + rename Reporting to Vulnerability Triage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add time-based open/closed tracking for Ivanti findings (Tier 2 from the reporting recommendations doc) and rename the Reporting page to Vulnerability Triage to better reflect its purpose. Backend — ivantiFindings.js: - Create ivanti_counts_history table (appended on every sync, never overwritten — Option B from design discussion) - INSERT snapshot after each successful syncClosedCount() call - GET /api/ivanti/findings/counts/history endpoint — returns last snapshot per calendar day using ROW_NUMBER window function, so multiple daily syncs collapse to the end-of-day value Frontend: - New IvantiCountsChart component: collapsible dual-line chart (open vs closed) with dark tooltip, delta label showing change since previous day, and graceful no-data states - Chart placed between the donut metrics panel and the findings table on the Vulnerability Triage page - Renamed page: 'reporting' → 'triage' (page ID, nav label, component export, all cross-file references) - ComplianceDetailPanel "View in Reporting" link updated to "View in Triage" and navigates to the correct page ID Co-Authored-By: Claude Sonnet 4.6 --- backend/routes/ivantiFindings.js | 44 ++++ frontend/src/App.js | 10 +- frontend/src/components/NavDrawer.js | 2 +- .../components/pages/ComplianceDetailPanel.js | 4 +- .../src/components/pages/IvantiCountsChart.js | 207 ++++++++++++++++++ .../src/components/pages/ReportingPage.js | 8 +- 6 files changed, 266 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/pages/IvantiCountsChart.js 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/frontend/src/App.js b/frontend/src/App.js index 7d4a8d3..4fdd12e 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -10,7 +10,7 @@ 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'; @@ -966,14 +966,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 +1043,7 @@ export default function App() {
{/* Page content */} - {currentPage === 'reporting' && } + {currentPage === 'triage' && } {currentPage === 'compliance' && } {currentPage === 'knowledge-base' && } {currentPage === 'exports' && } @@ -2231,7 +2231,7 @@ export default function App() { { setCalendarFilter(dateStr); - setCurrentPage('reporting'); + setCurrentPage('triage'); }} />
diff --git a/frontend/src/components/NavDrawer.js b/frontend/src/components/NavDrawer.js index 365976c..49cffd8 100644 --- a/frontend/src/components/NavDrawer.js +++ b/frontend/src/components/NavDrawer.js @@ -3,7 +3,7 @@ import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-reac const NAV_ITEMS = [ { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' }, - { id: 'reporting', label: 'Reporting', icon: BarChart2, color: '#F59E0B', description: 'Reports & analytics' }, + { id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' }, { id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' }, { id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' }, { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' }, 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/IvantiCountsChart.js b/frontend/src/components/pages/IvantiCountsChart.js new file mode 100644 index 0000000..7bdbb6a --- /dev/null +++ b/frontend/src/components/pages/IvantiCountsChart.js @@ -0,0 +1,207 @@ +// IvantiCountsChart.js +// Collapsible trend panel for the Vulnerability Triage page. +// Shows open vs closed Ivanti finding counts over time (last sync per day). + +import React, { useState, useEffect, useMemo } from 'react'; +import { + LineChart, Line, + XAxis, YAxis, CartesianGrid, + Tooltip, Legend, ReferenceLine, + ResponsiveContainer, +} from 'recharts'; +import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +const AMBER = '#F59E0B'; +const SKY = '#0EA5E9'; +const GREEN = '#10B981'; +const RED = '#EF4444'; + +const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' }; +const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' }; +const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' }; + +// --------------------------------------------------------------------------- +// Custom dark tooltip +// --------------------------------------------------------------------------- +function DarkTooltip({ active, payload, label }) { + if (!active || !payload?.length) return null; + + const openVal = payload.find(p => 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/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 ---------------------------------------------------------------- */} From a0a8979c63fdd652b89c7a631a41120953fc205a Mon Sep 17 00:00:00 2001 From: jramos Date: Thu, 2 Apr 2026 10:13:57 -0600 Subject: [PATCH 3/7] fix(triage): fix missed setCurrentPage('reporting') in Archer ticket filter button One reference to the old page ID was missed in the previous rename commit. The Archer ticket EXC filter button in App.js was still navigating to 'reporting', which would silently fail to navigate. Updated to 'triage'. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.js b/frontend/src/App.js index 4fdd12e..605f5be 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -2337,7 +2337,7 @@ export default function App() {
+ )} +
+ + {/* 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); }} + /> + )} +
+ ); } From 0d48c109b3584ca2330b8a03bfd59fbcfeb5aed8 Mon Sep 17 00:00:00 2001 From: jramos Date: Thu, 2 Apr 2026 14:32:36 -0600 Subject: [PATCH 7/7] refactor(home): remove Knowledge Base panel from home page The dedicated Knowledge Base page now provides the full library experience. Remove the KB sidebar panel, viewer inline embed, upload modal, and all supporting state/functions from App.js. Home page layout adjusts from 3-column to 2-column (9+3 grid): main CVE content expands to col-span-9, right panel unchanged. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.js | 152 +------------------------------------------- 1 file changed, 3 insertions(+), 149 deletions(-) diff --git a/frontend/src/App.js b/frontend/src/App.js index 605f5be..c2425be 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -6,8 +6,6 @@ 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 VulnerabilityTriagePage from './components/pages/ReportingPage'; @@ -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]); @@ -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 */}