From 7577ab12190eb3dc1c3205b9cdd41f1620f0e33e Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Thu, 14 May 2026 15:24:10 -0600 Subject: [PATCH] =?UTF-8?q?Make=20Non-Compliant=20stat=20clickable=20?= =?UTF-8?q?=E2=80=94=20reveals=20metric=20breakdown=20buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .kiro/steering/tech.md | 88 +++++++++++++++ backend/routes/vclMultiVertical.js | 22 ++++ .../src/components/pages/CCPMetricsPage.js | 106 +++++++++++++++++- 3 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 .kiro/steering/tech.md diff --git a/.kiro/steering/tech.md b/.kiro/steering/tech.md new file mode 100644 index 0000000..a4a4b90 --- /dev/null +++ b/.kiro/steering/tech.md @@ -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 + +# 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 | diff --git a/backend/routes/vclMultiVertical.js b/backend/routes/vclMultiVertical.js index d73ef5b..a513226 100644 --- a/backend/routes/vclMultiVertical.js +++ b/backend/routes/vclMultiVertical.js @@ -436,6 +436,14 @@ function createVCLMultiVerticalRouter(upload) { * forecast_burndown: Array<{ month: string, projected_remaining: number }>, * 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 * } * @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({ stats: { total_devices: aggTotal, @@ -550,6 +571,7 @@ function createVCLMultiVerticalRouter(upload) { }, donut, vertical_breakdown, + metric_breakdown: metricBreakdown, last_upload_date: uploadDates.length > 0 ? uploadDates.reduce((max, r) => r.last_upload > max ? r.last_upload : max, '') : null, }); } catch (err) { diff --git a/frontend/src/components/pages/CCPMetricsPage.js b/frontend/src/components/pages/CCPMetricsPage.js index df6deb2..dd630a0 100644 --- a/frontend/src/components/pages/CCPMetricsPage.js +++ b/frontend/src/components/pages/CCPMetricsPage.js @@ -57,21 +57,39 @@ const TD_STYLE = { // --------------------------------------------------------------------------- // Stats Bar // --------------------------------------------------------------------------- -function StatsBar({ stats }) { +function StatsBar({ stats, onNonCompliantClick, ncExpanded }) { if (!stats) return null; const items = [ { label: 'Total Devices', value: stats.total_devices.toLocaleString(), color: '#94A3B8' }, { 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: 'Target %', value: `${stats.target_pct}%`, color: PURPLE }, ]; return (
- {items.map(({ label, value, color }) => ( -
-
{label}
+ {items.map(({ label, value, color, clickable }) => ( +
{ 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} + > +
+ {label}{clickable && {ncExpanded ? '▾' : '▸'}} +
{value}
))} @@ -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 ( +
+
+ Non-Compliant by Metric +
+
+ {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 ( + + ); + })} +
+
+ ); +} + // --------------------------------------------------------------------------- // Donut Chart // --------------------------------------------------------------------------- @@ -816,6 +893,7 @@ export default function CCPMetricsPage() { const [error, setError] = useState(null); const [showUpload, setShowUpload] = useState(false); const [showManage, setShowManage] = useState(false); + const [showMetricBreakdown, setShowMetricBreakdown] = useState(false); // Drill-down state const [selectedVertical, setSelectedVertical] = useState(null); @@ -966,7 +1044,23 @@ export default function CCPMetricsPage() { {!loading && !error && stats && ( <> {/* Stats bar */} - + setShowMetricBreakdown(!showMetricBreakdown)} + ncExpanded={showMetricBreakdown} + /> + + {/* Metric breakdown (revealed when Non-Compliant is clicked) */} + {showMetricBreakdown && ( + { + // Find the first vertical that has this metric with non-compliant > 0 + // and navigate to it + setShowMetricBreakdown(false); + }} + /> + )} {/* Charts row */}