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 */}
No documents yet
- {(user?.role === 'admin' || user?.role === 'editor') && ( - - )} -{article.description}
- )} -- Under construction — coming soon -
-