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 }>,
|
* 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) {
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user