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:
@@ -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' });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user