diff --git a/backend/routes/vclMultiVertical.js b/backend/routes/vclMultiVertical.js index 5450908..d73ef5b 100644 --- a/backend/routes/vclMultiVertical.js +++ b/backend/routes/vclMultiVertical.js @@ -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' }); diff --git a/docs/vcl-multi-vertical-design-brief.md b/docs/vcl-multi-vertical-design-brief.md index ecc3a5f..75e133f 100644 --- a/docs/vcl-multi-vertical-design-brief.md +++ b/docs/vcl-multi-vertical-design-brief.md @@ -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 diff --git a/frontend/src/components/pages/CCPMetricsPage.js b/frontend/src/components/pages/CCPMetricsPage.js index 2ac5fa6..df6deb2 100644 --- a/frontend/src/components/pages/CCPMetricsPage.js +++ b/frontend/src/components/pages/CCPMetricsPage.js @@ -417,7 +417,7 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) { onSelectMetric(m.metric_id)} + onClick={() => onSelectMetric(m.metric_id, m)} > {m.metric_id} @@ -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
Loading...
; + const targetVal = Number(metricData?.target || 0); return (
@@ -487,8 +482,126 @@ function MetricDeviceList({ vertical, metricId, onBack }) { Back to Metrics +

+ {vertical} / Metric {metricId} +

+ {metricData?.metric_desc && ( +

{metricData.metric_desc}

+ )} + + {/* Metric rollup stats */} +
+
+
Total
+
{(metricData?.total || 0).toLocaleString()}
+
+
+
Compliant
+
{(metricData?.compliant || 0).toLocaleString()}
+
+
+
Non-Compliant
+
{(metricData?.non_compliant || 0).toLocaleString()}
+
+
+
Compliance
+
+ {(Number(metricData?.compliance_pct || 0) * 100).toFixed(1)}% +
+
+
+
Target
+
{(targetVal * 100).toFixed(0)}%
+
+
+ + {/* Sub-team breakdown table */} +
+
+ Sub-Team Breakdown +
+ {metricData?.sub_teams && metricData.sub_teams.length > 0 ? ( + + + + + + + + + + + + {metricData.sub_teams.map(st => { + const stPct = Number(st.compliance_pct || 0); + const stPctColor = pctColor(st.compliance_pct, metricData.target); + return ( + 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'} + > + + + + + + + ); + })} + +
TeamCompliantNon-CompliantTotalCompliance %
{st.team}{(st.compliant || 0).toLocaleString()}{(st.non_compliant || 0).toLocaleString()}{(st.total || 0).toLocaleString()}{(stPct * 100).toFixed(1)}%
+ ) : ( +
+ No sub-team breakdown available for this metric. +
+ +
+
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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
Loading...
; + + return ( +
+ +

- {vertical} / Metric {metricId} — {devices ? devices.length : 0} non-compliant devices + {vertical} / Metric {metricId}{team ? ` / ${team}` : ''} — {devices ? devices.length : 0} non-compliant devices

@@ -611,6 +724,7 @@ function DataManagementPanel({ onClose, onDataChanged }) {

Manage Data

{/* ⚠️ CONVENTION: Use lucide-react icon instead of raw Unicode character */} + {/* ⚠️ CONVENTION: Use lucide-react icon instead of raw Unicode character */}
@@ -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 (
setSelectedMetric(null)} + team={selectedTeam} + onBack={() => setSelectedTeam(null)} + /> +
+ ); + } + + if (selectedMetric && selectedVertical) { + return ( +
+ { setSelectedMetric(null); setSelectedMetricData(null); }} + onSelectTeam={(team) => setSelectedTeam(team)} />
); @@ -749,7 +880,7 @@ export default function CCPMetricsPage() { setSelectedVertical(null)} - onSelectMetric={(metricId) => setSelectedMetric(metricId)} + onSelectMetric={(metricId, metricData) => { setSelectedMetric(metricId); setSelectedMetricData(metricData); }} />
);