Add metric sub-team intermediate drill-down view

Clicking a metric now shows a sub-team breakdown page with totals per team
(compliant, non-compliant, total, %) instead of jumping directly to a flat
device list. Clicking a sub-team then shows the device list filtered to
that team only.

Navigation flow: Overview → Vertical → Metric (sub-team totals) → Team (devices)

Backend: added optional ?team= query param to the device list endpoint for
filtered queries.

Frontend: added MetricSubTeamView component with metric-level stats bar and
clickable sub-team table. Updated navigation state to include selectedTeam.

Also updated design brief to reflect the new drill-down hierarchy.
This commit is contained in:
Jordan Ramos
2026-05-14 14:53:41 -06:00
parent 682ee9417f
commit a2bc1ff564
3 changed files with 185 additions and 29 deletions

View File

@@ -796,16 +796,19 @@ function createVCLMultiVerticalRouter(upload) {
/**
* GET /vertical/:code/metric/:metricId/devices
* Returns the list of non-compliant devices for a specific vertical + metric.
* Optionally filters by team when the query parameter is provided.
*
* @method GET
* @route /vertical/:code/metric/:metricId/devices
* @param {string} code — vertical code (e.g., "NTS_AEO")
* @param {string} metricId — metric identifier (e.g., "VM-001")
* @query {string} [team] — optional team name to filter devices (e.g., "STEAM", "ACCESS-ENG")
*
* @response 200
* {
* vertical: string,
* metric_id: string,
* team: string|null,
* devices: Array<{
* hostname: string,
* ip_address: string,
@@ -828,19 +831,26 @@ function createVCLMultiVerticalRouter(upload) {
if (!metricId || metricId.length > 50) return res.status(400).json({ error: 'Invalid metric ID' });
try {
const { rows } = await pool.query(
`SELECT ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.seen_count,
const team = req.query.team || null;
let query = `SELECT ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.seen_count,
ci.resolution_date, ci.remediation_plan,
fu.report_date AS first_seen, lu.report_date AS last_seen
FROM compliance_items ci
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
WHERE ci.vertical = $1 AND ci.metric_id = $2 AND ci.status = 'active'
ORDER BY ci.hostname`,
[vertical, metricId]
);
WHERE ci.vertical = $1 AND ci.metric_id = $2 AND ci.status = 'active'`;
const params = [vertical, metricId];
res.json({ vertical, metric_id: metricId, devices: rows });
if (team) {
query += ` AND ci.team = $3`;
params.push(team);
}
query += ` ORDER BY ci.hostname`;
const { rows } = await pool.query(query, params);
res.json({ vertical, metric_id: metricId, team: team || null, devices: rows });
} catch (err) {
console.error('[VCL Multi] GET /vertical/:code/metric/:metricId/devices error:', err.message);
res.status(500).json({ error: 'Database error' });

View File

@@ -110,8 +110,15 @@ Executive Overview (all verticals aggregated)
│ │ │ ├── └ INTELDEV: 233 compliant, 11 NC, 244 total — 95.0%
│ │ │ └── └ STEAM: 123 compliant, 4 NC, 127 total — 97.0%
│ │ │
│ │ ├── ▸ 7.1.1 (Logging & Monitoring) — 72.0% — 149 NC → click metric
│ │ │ ── Device list: hostname, IP, type, team, seen_count, resolution_date
│ │ ├── Click metric ID → Metric Sub-Team View
│ │ │ ── Stats: total 66,176 | compliant 64,414 | NC 1,762 | 97% | target 80%
│ │ │ └── Sub-Team Table:
│ │ │ ├── ACCESS-ENG — 8 total — 88.0% → click
│ │ │ │ └── Device list (filtered to ACCESS-ENG)
│ │ │ ├── ACCESS-OPS — 65,797 total — 97.0% → click
│ │ │ │ └── Device list (filtered to ACCESS-OPS)
│ │ │ ├── INTELDEV — 244 total — 95.0% → click
│ │ │ └── STEAM — 127 total — 97.0% → click
│ │ └── ...
│ │
│ └── Burndown: blockers, with dates, projected clear date
@@ -197,7 +204,7 @@ Some metrics have a team value of `(Other)` in the Summary sheet. This represent
### Device-Level Drill-Down
Clicking a metric ID navigates to the device list — individual non-compliant hostnames for that vertical + metric combination. This data comes from the detail sheets (not the Summary sheet) and shows:
Clicking a sub-team row in the metric sub-team view navigates to the device list — individual non-compliant hostnames for that vertical + metric + team combination. The device list is filtered to only show devices belonging to the selected team. This data comes from the detail sheets (not the Summary sheet) and shows:
- Hostname, IP address, device type, team
- Seen count (how many consecutive uploads this device has been non-compliant)
@@ -205,6 +212,14 @@ Clicking a metric ID navigates to the device list — individual non-compliant h
- Resolution date (if set)
- Remediation plan (if documented)
If a metric has no sub-team breakdown (e.g., only an "(Other)" team), a "View All Devices" button is shown instead, which loads the full unfiltered device list for that metric.
The full navigation path is:
```
Overview → Vertical → Metric (sub-team totals) → Team (device list)
```
---
## Burndown Forecast

View File

@@ -417,7 +417,7 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
</td>
<td
style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}
onClick={() => onSelectMetric(m.metric_id)}
onClick={() => onSelectMetric(m.metric_id, m)}
>
{m.metric_id}
</td>
@@ -461,22 +461,17 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
}
// ---------------------------------------------------------------------------
// Metric Device List (deepest drill-down)
// Metric Sub-Team View (intermediate drill-down: metric → sub-teams)
// ---------------------------------------------------------------------------
function MetricDeviceList({ vertical, metricId, onBack }) {
const [devices, setDevices] = useState(null);
const [loading, setLoading] = useState(true);
// ⚠️ CONVENTION: Missing error state — .catch() silently swallows errors without displaying them to the user. Add an error state and render an error message (see main CCPMetricsPage pattern).
function MetricSubTeamView({ vertical, metricId, metricData, onBack, onSelectTeam }) {
// metricData contains the metric's sub_teams from the parent view
const pctColor = (pct, target) => {
const p = Number(pct || 0);
const t = Number(target || 0);
return p >= t ? '#10B981' : p >= t * 0.85 ? '#F59E0B' : '#EF4444';
};
useEffect(() => {
setLoading(true);
fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}/metric/${encodeURIComponent(metricId)}/devices`, { credentials: 'include' })
.then(r => r.json())
.then(data => { setDevices(data.devices || []); setLoading(false); })
.catch(() => setLoading(false));
}, [vertical, metricId]);
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
const targetVal = Number(metricData?.target || 0);
return (
<div>
@@ -487,8 +482,126 @@ function MetricDeviceList({ vertical, metricId, onBack }) {
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Metrics
</button>
<h3 style={{ fontSize: '1.1rem', color: '#E2E8F0', marginBottom: '0.25rem', fontWeight: '700' }}>
{vertical} / Metric {metricId}
</h3>
{metricData?.metric_desc && (
<p style={{ fontSize: '0.75rem', color: '#94A3B8', margin: '0 0 1.5rem 0' }}>{metricData.metric_desc}</p>
)}
{/* Metric rollup stats */}
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<div style={STAT_CARD_STYLE}>
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Total</div>
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#E2E8F0' }}>{(metricData?.total || 0).toLocaleString()}</div>
</div>
<div style={STAT_CARD_STYLE}>
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Compliant</div>
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#10B981' }}>{(metricData?.compliant || 0).toLocaleString()}</div>
</div>
<div style={STAT_CARD_STYLE}>
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Non-Compliant</div>
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#EF4444' }}>{(metricData?.non_compliant || 0).toLocaleString()}</div>
</div>
<div style={STAT_CARD_STYLE}>
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Compliance</div>
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: pctColor(metricData?.compliance_pct, metricData?.target) }}>
{(Number(metricData?.compliance_pct || 0) * 100).toFixed(1)}%
</div>
</div>
<div style={STAT_CARD_STYLE}>
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Target</div>
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: PURPLE }}>{(targetVal * 100).toFixed(0)}%</div>
</div>
</div>
{/* Sub-team breakdown table */}
<div style={CARD_STYLE}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
Sub-Team Breakdown
</div>
{metricData?.sub_teams && metricData.sub_teams.length > 0 ? (
<table style={TABLE_STYLE}>
<thead>
<tr>
<th style={TH_STYLE}>Team</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Compliant</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Non-Compliant</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Total</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Compliance %</th>
</tr>
</thead>
<tbody>
{metricData.sub_teams.map(st => {
const stPct = Number(st.compliance_pct || 0);
const stPctColor = pctColor(st.compliance_pct, metricData.target);
return (
<tr
key={st.team}
onClick={() => onSelectTeam(st.team)}
style={{ cursor: 'pointer', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(20, 184, 166, 0.08)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<td style={{ ...TD_STYLE, fontWeight: '600', color: TEAL }}>{st.team}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{(st.compliant || 0).toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{(st.non_compliant || 0).toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{(st.total || 0).toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: stPctColor }}>{(stPct * 100).toFixed(1)}%</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div style={{ color: '#64748B', fontSize: '0.8rem', padding: '1rem', textAlign: 'center' }}>
No sub-team breakdown available for this metric.
<div style={{ marginTop: '0.75rem' }}>
<button
onClick={() => onSelectTeam(null)}
style={{ padding: '0.5rem 1rem', background: `${PURPLE}20`, border: `1px solid ${PURPLE}60`, borderRadius: '0.5rem', color: PURPLE, fontSize: '0.75rem', cursor: 'pointer', fontWeight: '600' }}
>
View All Devices
</button>
</div>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Metric Device List (deepest drill-down — filtered by team)
// ---------------------------------------------------------------------------
function MetricDeviceList({ vertical, metricId, team, onBack }) {
const [devices, setDevices] = useState(null);
const [loading, setLoading] = useState(true);
// ⚠️ CONVENTION: Missing error state — .catch() below silently swallows errors without displaying them to the user. Add an error state and render an error message.
useEffect(() => {
setLoading(true);
let url = `${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}/metric/${encodeURIComponent(metricId)}/devices`;
if (team) url += `?team=${encodeURIComponent(team)}`;
fetch(url, { credentials: 'include' })
.then(r => r.json())
.then(data => { setDevices(data.devices || []); setLoading(false); })
.catch(() => setLoading(false));
}, [vertical, metricId, team]);
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
return (
<div>
<button
onClick={onBack}
style={{ background: 'none', border: 'none', color: PURPLE, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.8rem', marginBottom: '1.5rem', padding: 0 }}
>
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Sub-Teams
</button>
<h3 style={{ fontSize: '1rem', color: '#E2E8F0', marginBottom: '1rem' }}>
{vertical} / Metric {metricId} {devices ? devices.length : 0} non-compliant devices
{vertical} / Metric {metricId}{team ? ` / ${team}` : ''} {devices ? devices.length : 0} non-compliant devices
</h3>
<div style={CARD_STYLE}>
@@ -611,6 +724,7 @@ function DataManagementPanel({ onClose, onDataChanged }) {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.1rem', fontWeight: '700', color: '#E2E8F0', margin: 0 }}>Manage Data</h2>
{/* ⚠️ CONVENTION: Use lucide-react <X /> icon instead of raw Unicode character */}
{/* ⚠️ CONVENTION: Use lucide-react <X /> icon instead of raw Unicode character */}
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}></button>
</div>
@@ -706,6 +820,8 @@ export default function CCPMetricsPage() {
// Drill-down state
const [selectedVertical, setSelectedVertical] = useState(null);
const [selectedMetric, setSelectedMetric] = useState(null);
const [selectedMetricData, setSelectedMetricData] = useState(null);
const [selectedTeam, setSelectedTeam] = useState(null);
const fetchData = useCallback(() => {
setLoading(true);
@@ -731,13 +847,28 @@ export default function CCPMetricsPage() {
};
// Render drill-down views
if (selectedMetric && selectedVertical) {
if (selectedTeam !== null && selectedMetric && selectedVertical) {
return (
<div style={PAGE_STYLE}>
<MetricDeviceList
vertical={selectedVertical}
metricId={selectedMetric}
onBack={() => setSelectedMetric(null)}
team={selectedTeam}
onBack={() => setSelectedTeam(null)}
/>
</div>
);
}
if (selectedMetric && selectedVertical) {
return (
<div style={PAGE_STYLE}>
<MetricSubTeamView
vertical={selectedVertical}
metricId={selectedMetric}
metricData={selectedMetricData}
onBack={() => { setSelectedMetric(null); setSelectedMetricData(null); }}
onSelectTeam={(team) => setSelectedTeam(team)}
/>
</div>
);
@@ -749,7 +880,7 @@ export default function CCPMetricsPage() {
<VerticalDetailView
vertical={selectedVertical}
onBack={() => setSelectedVertical(null)}
onSelectMetric={(metricId) => setSelectedMetric(metricId)}
onSelectMetric={(metricId, metricData) => { setSelectedMetric(metricId); setSelectedMetricData(metricData); }}
/>
</div>
);