feat(compliance): add time-based trend charts to Compliance page

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 09:49:32 -06:00
parent a7c74f625f
commit b111273e5a
6 changed files with 903 additions and 0 deletions

View File

@@ -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; return router;
} }

View File

@@ -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; return router;
} }

View File

@@ -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) | LowMedium |
---
## 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.11.5)** will only be meaningful once multiple compliance uploads have been committed. If only 12 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.*

View File

@@ -12,6 +12,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"recharts": "^3.8.1",
"web-vitals": "^2.1.4", "web-vitals": "^2.1.4",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },

View File

@@ -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 (
<div style={{
background: 'rgba(10,17,32,0.97)',
border: '1px solid rgba(20,184,166,0.3)',
borderRadius: '0.375rem',
padding: '0.5rem 0.75rem',
fontFamily: 'monospace',
fontSize: '0.7rem',
minWidth: '130px',
}}>
<div style={{ color: TEAL, marginBottom: '0.3rem', fontWeight: '700', fontSize: '0.65rem' }}>
{label}
</div>
{payload.map(p => (
<div key={p.dataKey} style={{ color: p.color || '#94A3B8', marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
<span style={{ opacity: 0.8 }}>{p.name}</span>
<span style={{ fontWeight: '700' }}>
{typeof p.value === 'number'
? Number.isInteger(p.value) ? p.value : p.value.toFixed(1)
: p.value}
</span>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Chart card wrapper
// ---------------------------------------------------------------------------
function ChartCard({ title, subtitle, children }) {
return (
<div style={{
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
border: '1px solid rgba(20,184,166,0.15)',
borderRadius: '0.5rem',
padding: '1rem 1.125rem 0.875rem',
}}>
<div style={{ marginBottom: '0.75rem' }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
color: '#CBD5E1', textTransform: 'uppercase', letterSpacing: '0.08em',
}}>
{title}
</div>
{subtitle && (
<div style={{ fontSize: '0.62rem', color: '#334155', marginTop: '0.2rem', fontFamily: 'monospace' }}>
{subtitle}
</div>
)}
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// Empty / no-data state
// ---------------------------------------------------------------------------
function NoData({ msg }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '160px', color: '#334155',
fontFamily: 'monospace', fontSize: '0.72rem',
border: '1px dashed rgba(20,184,166,0.1)',
borderRadius: '0.375rem',
}}>
{msg || 'No data yet — upload compliance reports to populate this chart'}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 <NoData />;
return (
<ResponsiveContainer width="100%" height={210}>
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
<Line
type="monotone" dataKey="total_active" name="Total"
stroke={TEAL} strokeWidth={2}
dot={{ r: 3, fill: TEAL, strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
{Object.entries(TEAM_COLORS).map(([team, color]) => (
<Line
key={team}
type="monotone" dataKey={team} name={team}
stroke={color} strokeWidth={1.5}
dot={false} strokeDasharray="5 3"
activeDot={{ r: 4, fill: color }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 2 — New / Recurring / Resolved per cycle (stacked + grouped bar)
// ---------------------------------------------------------------------------
function DeltaChart({ data }) {
if (data.length === 0) return <NoData />;
return (
<ResponsiveContainer width="100%" height={210}>
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
<Bar dataKey="new_count" name="New" stackId="in" fill="#EF4444" fillOpacity={0.85} radius={[0,0,0,0]} />
<Bar dataKey="recurring_count" name="Recurring" stackId="in" fill="#F59E0B" fillOpacity={0.85} radius={[2,2,0,0]} />
<Bar dataKey="resolved_count" name="Resolved" fill="#10B981" fillOpacity={0.8} radius={[2,2,2,2]} />
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 3 — Team Health Multi-Line
// ---------------------------------------------------------------------------
function TeamTrendChart({ data }) {
if (data.length < 2) return <NoData />;
return (
<ResponsiveContainer width="100%" height={210}>
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
{Object.entries(TEAM_COLORS).map(([team, color]) => (
<Line
key={team}
type="monotone" dataKey={team} name={team}
stroke={color} strokeWidth={2}
dot={{ r: 3, fill: color, strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 4 — MTTR by Team (horizontal bar)
// ---------------------------------------------------------------------------
function MttrChart({ data }) {
if (data.length === 0) return <NoData msg="No resolved findings yet — MTTR will appear after items are remediated" />;
return (
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 44 + 40)}>
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis type="number" tick={AXIS_STYLE} unit=" d" />
<YAxis type="category" dataKey="team" tick={AXIS_STYLE} width={86} />
<Tooltip content={<DarkTooltip />} />
<Bar dataKey="avg_days" name="Avg Days" fill={TEAL} fillOpacity={0.8} radius={[0, 3, 3, 0]}
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}d` }}
/>
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 5 — Most Persistent Findings (horizontal bar by seen_count)
// ---------------------------------------------------------------------------
function RecurringChart({ data }) {
if (data.length === 0) return <NoData />;
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 (
<ResponsiveContainer width="100%" height={Math.max(160, top10.length * 28 + 40)}>
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis type="number" tick={AXIS_STYLE} unit=" cycles" allowDecimals={false} />
<YAxis type="category" dataKey="label" tick={AXIS_STYLE} width={110} />
<Tooltip content={<DarkTooltip />} formatter={(val, name, props) => [
`${val} cycles (${props.payload.host_count} host${props.payload.host_count !== 1 ? 's' : ''})`, props.payload.team
]} />
<Bar dataKey="seen_count" name="Cycles Seen" fill="#F59E0B" fillOpacity={0.85} radius={[0, 3, 3, 0]}
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}×` }}
/>
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 6 — Archer Exception Ticket Pipeline (stacked bar by creation date)
// ---------------------------------------------------------------------------
function ArcherPipelineChart({ data }) {
if (data.length === 0) return <NoData msg="No Archer tickets recorded yet" />;
return (
<ResponsiveContainer width="100%" height={210}>
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
{Object.entries(ARCHER_STATUS_COLORS).map(([status, color], i, arr) => (
<Bar
key={status}
dataKey={status} name={status} stackId="s"
fill={color} fillOpacity={0.85}
radius={i === arr.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div style={{ marginBottom: '1.5rem' }}>
{/* ── Section header / collapse toggle ──────────────────────── */}
<button
onClick={() => setCollapsed(c => !c)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer',
padding: '0 0 0.625rem 0',
borderBottom: collapsed ? 'none' : '1px solid rgba(20,184,166,0.1)',
marginBottom: collapsed ? 0 : '0.875rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<TrendingUp style={{ width: '14px', height: '14px', color: TEAL }} />
<span style={{
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
}}>
Historical Trends
</span>
{loading && (
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
)}
</div>
{collapsed
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
}
</button>
{!collapsed && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(420px, 1fr))',
gap: '1rem',
}}>
{/* 1. Active findings over time */}
<ChartCard
title="Active Findings Over Time"
subtitle="Total non-compliant items per report cycle (solid) + per team (dashed)"
>
<ActiveTrendChart data={formattedTrends} />
</ChartCard>
{/* 2. New / Recurring / Resolved delta per cycle */}
<ChartCard
title="Change per Report Cycle"
subtitle="New (red) and recurring (amber) stacked; resolved (green) as separate bars"
>
<DeltaChart data={formattedTrends} />
</ChartCard>
{/* 3. Team health multi-line */}
<ChartCard
title="Team Compliance Health"
subtitle="Active findings per team per cycle — lower is better"
>
<TeamTrendChart data={formattedTrends} />
</ChartCard>
{/* 4. MTTR per team */}
<ChartCard
title="Mean Time to Resolution"
subtitle="Average calendar days between first-seen and resolved, by team"
>
<MttrChart data={mttr} />
</ChartCard>
{/* 5. Most persistent / recurring findings */}
<ChartCard
title="Most Persistent Findings"
subtitle="Active items with the highest recurrence count (top 10)"
>
<RecurringChart data={recurring} />
</ChartCard>
{/* 6. Archer ticket pipeline */}
<ChartCard
title="Archer Exception Pipeline"
subtitle="Exception ticket status distribution by creation date"
>
<ArcherPipelineChart data={archerByDate} />
</ChartCard>
</div>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-re
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import ComplianceUploadModal from './ComplianceUploadModal'; import ComplianceUploadModal from './ComplianceUploadModal';
import ComplianceDetailPanel from './ComplianceDetailPanel'; import ComplianceDetailPanel from './ComplianceDetailPanel';
import ComplianceChartsPanel from './ComplianceChartsPanel';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6'; const TEAL = '#14B8A6';
@@ -323,6 +324,9 @@ export default function CompliancePage({ onNavigate }) {
</div> </div>
) : null} ) : null}
{/* ── Historical trend charts ──────────────────────────────── */}
<ComplianceChartsPanel />
{/* ── Device table ─────────────────────────────────────────── */} {/* ── Device table ─────────────────────────────────────────── */}
<div style={{ <div style={{
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)', background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',