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:
88
.kiro/steering/tech.md
Normal file
88
.kiro/steering/tech.md
Normal 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 |
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
|
||||
{items.map(({ label, value, color }) => (
|
||||
<div key={label} style={STAT_CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.5rem' }}>{label}</div>
|
||||
{items.map(({ label, value, color, clickable }) => (
|
||||
<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>
|
||||
))}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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 */}
|
||||
<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 */}
|
||||
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
|
||||
Reference in New Issue
Block a user