Make Non-Compliant stat clickable — reveals metric breakdown buttons

Clicking the Non-Compliant card on the CCP Metrics overview now toggles a
panel of metric buttons below it, each showing the metric ID, category,
non-compliant count, and compliance % vs target. Styled like the compliance
page's MetricHealthCard pattern.

Backend: added metric_breakdown to the /stats response — aggregated
cross-vertical metric totals (ALL: rows only, grouped by metric_id).

Also updated tech steering file to document the single-port Express
architecture and the requirement to run npm run build after frontend changes.
This commit is contained in:
Jordan Ramos
2026-05-14 15:24:10 -06:00
parent a2bc1ff564
commit 7577ab1219
3 changed files with 210 additions and 6 deletions

88
.kiro/steering/tech.md Normal file
View File

@@ -0,0 +1,88 @@
# Tech Stack & Build System
## Stack
| Layer | Technology |
|-------|-----------|
| Backend | Node.js 18+, Express 5 |
| Database | PostgreSQL (via `pg` pool in `backend/db.js`) |
| Auth | bcryptjs, cookie-based sessions (httpOnly, 24h expiry) |
| File uploads | Multer 2 (10MB limit) |
| Frontend | React 19 (Create React App / react-scripts 5) |
| Frontend serving | Express serves `frontend/build/` as static files on port 3001 |
| UI Icons | lucide-react |
| Charts | recharts |
| Spreadsheet parsing | xlsx (frontend), pandas + openpyxl (backend Python scripts) |
| Markdown rendering | react-markdown |
| Diagrams | mermaid |
## Architecture: Single-Port Serving
Express on port 3001 serves **both** the API and the production frontend build:
- API routes: `/api/*` — handled by Express route handlers
- Frontend: everything else — served as static files from `frontend/build/`
There is no separate frontend server in production. The React dev server (`npm start` on port 3000) is only for local development with hot-reload. In production and on the dev server, you must run `npm run build` in `frontend/` after any frontend code change, then restart the backend.
**After editing frontend source files:**
```bash
cd frontend && npm run build # Compile new bundle into frontend/build/
# Then restart backend (or it will serve the new static files on next request)
```
The CI/CD pipeline handles this automatically — `build-frontend` stage runs before deploy.
## Common Commands
### Backend
```bash
cd backend
node setup.js # Initialize DB, tables, indexes, default admin user
node server.js # Start backend on port 3001 (serves API + frontend build)
```
### Frontend
```bash
cd frontend
npm install # Install dependencies
npm run build # Production build → frontend/build/ (REQUIRED after code changes)
npm start # Dev server on port 3000 (local dev only, NOT used in production)
npm test # Run tests (react-scripts test)
```
### Both servers (from project root)
```bash
./start-servers.sh # Start backend + frontend in background
./stop-servers.sh # Stop all servers
```
### Database Migrations (run from `backend/`)
```bash
node migrations/run-all.js # Runs all migrations in order (idempotent)
```
### Python Scripts (from `backend/scripts/`)
```bash
# Compliance xlsx parsing (called automatically by upload flow)
python3 parse_compliance_xlsx.py <file>
# Bulk notes import
python3 import_notes_from_csv.py input.csv --dry-run
python3 import_notes_from_csv.py input.csv
```
Python dependencies: `pandas>=2.0.0`, `openpyxl>=3.0.0` (install via apt or venv).
## Environment Configuration
- `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials
- `frontend/.env` — REACT_APP_API_BASE, REACT_APP_API_HOST
- Both `.env` files are gitignored; see `.env.example` files for templates.
- React env vars are baked in at **build time** — you must rebuild (`npm run build`) after changing them.
## Ports
| Environment | URL | Notes |
|---|---|---|
| Production / Dev server | http://IP:3001 | Express serves API + static frontend build |
| Local dev (frontend only) | http://localhost:3000 | React dev server with hot-reload, proxies API to :3001 |

View File

@@ -436,6 +436,14 @@ function createVCLMultiVerticalRouter(upload) {
* forecast_burndown: Array<{ month: string, projected_remaining: number }>, * forecast_burndown: Array<{ month: string, projected_remaining: number }>,
* last_upload: string|null * last_upload: string|null
* }>, * }>,
* metric_breakdown: Array<{
* metric_id: string,
* category: string,
* non_compliant: number,
* compliant: number,
* total: number,
* target: number
* }>,
* last_upload_date: string|null * last_upload_date: string|null
* } * }
* @response 500 { error: string } * @response 500 { error: string }
@@ -540,6 +548,19 @@ function createVCLMultiVerticalRouter(upload) {
}; };
}); });
// Cross-vertical metric breakdown (aggregated across all verticals, ALL: rows only)
const { rows: metricBreakdown } = await pool.query(`
SELECT metric_id, category,
SUM(non_compliant)::int AS non_compliant,
SUM(compliant)::int AS compliant,
SUM(total)::int AS total,
ROUND(AVG(target::numeric), 2) AS target
FROM vcl_multi_vertical_summary
WHERE upload_id = ANY($1) AND team LIKE 'ALL:%'
GROUP BY metric_id, category
ORDER BY non_compliant DESC
`, [latestUploadIds]);
res.json({ res.json({
stats: { stats: {
total_devices: aggTotal, total_devices: aggTotal,
@@ -550,6 +571,7 @@ function createVCLMultiVerticalRouter(upload) {
}, },
donut, donut,
vertical_breakdown, vertical_breakdown,
metric_breakdown: metricBreakdown,
last_upload_date: uploadDates.length > 0 ? uploadDates.reduce((max, r) => r.last_upload > max ? r.last_upload : max, '') : null, last_upload_date: uploadDates.length > 0 ? uploadDates.reduce((max, r) => r.last_upload > max ? r.last_upload : max, '') : null,
}); });
} catch (err) { } catch (err) {

View File

@@ -57,21 +57,39 @@ const TD_STYLE = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Stats Bar // Stats Bar
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function StatsBar({ stats }) { function StatsBar({ stats, onNonCompliantClick, ncExpanded }) {
if (!stats) return null; if (!stats) return null;
const items = [ const items = [
{ label: 'Total Devices', value: stats.total_devices.toLocaleString(), color: '#94A3B8' }, { label: 'Total Devices', value: stats.total_devices.toLocaleString(), color: '#94A3B8' },
{ label: 'Compliant', value: stats.compliant.toLocaleString(), color: '#10B981' }, { label: 'Compliant', value: stats.compliant.toLocaleString(), color: '#10B981' },
{ label: 'Non-Compliant', value: stats.non_compliant.toLocaleString(), color: '#EF4444' }, { label: 'Non-Compliant', value: stats.non_compliant.toLocaleString(), color: '#EF4444', clickable: true },
{ label: 'Current %', value: `${stats.compliance_pct}%`, color: stats.compliance_pct >= stats.target_pct ? '#10B981' : '#F59E0B' }, { label: 'Current %', value: `${stats.compliance_pct}%`, color: stats.compliance_pct >= stats.target_pct ? '#10B981' : '#F59E0B' },
{ label: 'Target %', value: `${stats.target_pct}%`, color: PURPLE }, { label: 'Target %', value: `${stats.target_pct}%`, color: PURPLE },
]; ];
return ( return (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}> <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
{items.map(({ label, value, color }) => ( {items.map(({ label, value, color, clickable }) => (
<div key={label} style={STAT_CARD_STYLE}> <div
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.5rem' }}>{label}</div> key={label}
onClick={clickable ? onNonCompliantClick : undefined}
style={{
...STAT_CARD_STYLE,
cursor: clickable ? 'pointer' : 'default',
border: clickable && ncExpanded
? '1px solid rgba(239, 68, 68, 0.6)'
: STAT_CARD_STYLE.border,
background: clickable && ncExpanded
? 'rgba(239, 68, 68, 0.08)'
: STAT_CARD_STYLE.background,
transition: 'all 0.15s',
}}
onMouseEnter={clickable ? e => { e.currentTarget.style.borderColor = 'rgba(239, 68, 68, 0.5)'; } : undefined}
onMouseLeave={clickable ? e => { if (!ncExpanded) e.currentTarget.style.borderColor = 'rgba(167, 139, 250, 0.2)'; } : undefined}
>
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.5rem' }}>
{label}{clickable && <span style={{ marginLeft: '0.4rem', fontSize: '0.6rem', color: '#64748B' }}>{ncExpanded ? '▾' : '▸'}</span>}
</div>
<div style={{ fontSize: '1.5rem', fontWeight: '700', color }}>{value}</div> <div style={{ fontSize: '1.5rem', fontWeight: '700', color }}>{value}</div>
</div> </div>
))} ))}
@@ -79,6 +97,65 @@ function StatsBar({ stats }) {
); );
} }
// ---------------------------------------------------------------------------
// Metric Breakdown Panel (shown when Non-Compliant is clicked)
// ---------------------------------------------------------------------------
function MetricBreakdownPanel({ metrics, onSelectMetric }) {
if (!metrics || metrics.length === 0) return null;
// Only show metrics with non_compliant > 0
const ncMetrics = metrics.filter(m => m.non_compliant > 0);
if (ncMetrics.length === 0) return null;
return (
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem' }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
Non-Compliant by Metric
</div>
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap' }}>
{ncMetrics.map(m => {
const pct = m.total > 0 ? (m.compliant / m.total) : 0;
const target = Number(m.target || 0);
const color = pct >= target ? '#10B981' : pct >= target * 0.85 ? '#F59E0B' : '#EF4444';
return (
<button
key={m.metric_id}
onClick={() => onSelectMetric(m.metric_id)}
style={{
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
border: `1.5px solid ${color}40`,
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s',
minWidth: '140px',
flex: '1 1 0',
maxWidth: '200px',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = color + '80'; e.currentTarget.style.background = `${color}10`; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = color + '40'; e.currentTarget.style.background = 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)'; }}
>
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', marginBottom: '0.2rem' }}>
{m.metric_id}
</div>
<div style={{ fontSize: '0.6rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.5rem' }}>
{m.category}
</div>
<div style={{ fontSize: '1.1rem', fontWeight: '700', color: '#EF4444', marginBottom: '0.2rem' }}>
{m.non_compliant.toLocaleString()}
</div>
<div style={{ fontSize: '0.6rem', color: '#64748B', fontFamily: 'monospace' }}>
{(pct * 100).toFixed(0)}% / target {(target * 100).toFixed(0)}%
</div>
</button>
);
})}
</div>
</div>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Donut Chart // Donut Chart
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -816,6 +893,7 @@ export default function CCPMetricsPage() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
const [showManage, setShowManage] = useState(false); const [showManage, setShowManage] = useState(false);
const [showMetricBreakdown, setShowMetricBreakdown] = useState(false);
// Drill-down state // Drill-down state
const [selectedVertical, setSelectedVertical] = useState(null); const [selectedVertical, setSelectedVertical] = useState(null);
@@ -966,7 +1044,23 @@ export default function CCPMetricsPage() {
{!loading && !error && stats && ( {!loading && !error && stats && (
<> <>
{/* Stats bar */} {/* Stats bar */}
<StatsBar stats={stats.stats} /> <StatsBar
stats={stats.stats}
onNonCompliantClick={() => setShowMetricBreakdown(!showMetricBreakdown)}
ncExpanded={showMetricBreakdown}
/>
{/* Metric breakdown (revealed when Non-Compliant is clicked) */}
{showMetricBreakdown && (
<MetricBreakdownPanel
metrics={stats.metric_breakdown}
onSelectMetric={(metricId) => {
// Find the first vertical that has this metric with non-compliant > 0
// and navigate to it
setShowMetricBreakdown(false);
}}
/>
)}
{/* Charts row */} {/* Charts row */}
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>