Restructure CCP Metrics to metric-first hierarchy, fix Jira cross-project sync
CCP Metrics View Restructure: - Add GET /metrics endpoint (aggregated across verticals) - Add GET /metric/:id/verticals endpoint (per-vertical breakdown) - Replace VerticalTable with MetricTable on overview (one row per metric) - Add MetricDetailView for metric-first drill-down - Restructure navigation: Metric → Vertical → Subteam → Devices - Remove By Vertical table from AggregatedBurndownChart Jira Sync Fix: - Remove hardcoded project filter from getIssue() and searchIssuesByKeys() - Issue keys are globally unique; project filter broke cross-project tickets - Fixes 502 Bad Gateway when syncing tickets from non-STEAM projects
This commit is contained in:
@@ -276,7 +276,9 @@ function jiraDelete(urlPath, options) {
|
|||||||
* @param {string[]} [fields] - Jira field names to return
|
* @param {string[]} [fields] - Jira field names to return
|
||||||
*/
|
*/
|
||||||
async function getIssue(issueKey, fields) {
|
async function getIssue(issueKey, fields) {
|
||||||
const jql = `key = "${issueKey}" AND project = ${JIRA_PROJECT_KEY}`;
|
// Don't filter by project — issue keys are globally unique in Jira and
|
||||||
|
// tickets may belong to projects other than JIRA_PROJECT_KEY (e.g. AA_ADTRAN).
|
||||||
|
const jql = `key = "${issueKey}"`;
|
||||||
const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 });
|
const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 });
|
||||||
if (result.ok && result.data.issues && result.data.issues.length > 0) {
|
if (result.ok && result.data.issues && result.data.issues.length > 0) {
|
||||||
return { ok: true, data: result.data.issues[0] };
|
return { ok: true, data: result.data.issues[0] };
|
||||||
@@ -300,11 +302,10 @@ async function searchIssuesByKeys(issueKeys, opts) {
|
|||||||
return { ok: true, data: { total: 0, issues: [] } };
|
return { ok: true, data: { total: 0, issues: [] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build JQL: key in (KEY-1, KEY-2, ...) — Charter requires project+updated
|
// Build JQL: key in (KEY-1, KEY-2, ...) — issue keys are globally unique,
|
||||||
// or similar, but key-based search is inherently scoped. We add updated
|
// so no project filter needed. Add updated clause for Charter compliance.
|
||||||
// clause for compliance.
|
|
||||||
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
|
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
|
||||||
const jql = `key in (${keyList}) AND updated >= -72h AND project = ${JIRA_PROJECT_KEY}`;
|
const jql = `key in (${keyList}) AND updated >= -72h`;
|
||||||
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
||||||
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -1240,6 +1240,129 @@ function createVCLMultiVerticalRouter(upload) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /metric/:id/verticals — Per-vertical breakdown for a specific metric
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /metric/:id/verticals
|
||||||
|
* Returns per-vertical breakdown for a specific metric, including sub-team data
|
||||||
|
* within each vertical. Uses only the latest upload per vertical.
|
||||||
|
*
|
||||||
|
* @method GET
|
||||||
|
* @route /metric/:id/verticals
|
||||||
|
* @param {string} id — metric identifier (e.g., "VM-001")
|
||||||
|
*
|
||||||
|
* @response 200
|
||||||
|
* {
|
||||||
|
* metric_id: string,
|
||||||
|
* metric_desc: string|null,
|
||||||
|
* category: string|null,
|
||||||
|
* verticals: Array<{
|
||||||
|
* vertical: string,
|
||||||
|
* non_compliant: number,
|
||||||
|
* compliant: number,
|
||||||
|
* total: number,
|
||||||
|
* compliance_pct: number,
|
||||||
|
* target: number,
|
||||||
|
* sub_teams: Array<{
|
||||||
|
* team: string,
|
||||||
|
* non_compliant: number,
|
||||||
|
* compliant: number,
|
||||||
|
* total: number,
|
||||||
|
* compliance_pct: number
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
* @response 400 { error: string } — metric ID exceeds 50 characters
|
||||||
|
* @response 500 { error: string }
|
||||||
|
*/
|
||||||
|
router.get('/metric/:id/verticals', async (req, res) => {
|
||||||
|
const metricId = req.params.id;
|
||||||
|
if (!metricId || metricId.length > 50) {
|
||||||
|
return res.status(400).json({ error: 'Invalid metric ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get latest upload per vertical
|
||||||
|
const { rows: latestUploads } = await pool.query(`
|
||||||
|
SELECT DISTINCT ON (vertical) id, vertical
|
||||||
|
FROM compliance_uploads
|
||||||
|
WHERE vertical IS NOT NULL
|
||||||
|
ORDER BY vertical, id DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (latestUploads.length === 0) {
|
||||||
|
return res.json({ metric_id: metricId, metric_desc: null, category: null, verticals: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestUploadIds = latestUploads.map(u => u.id);
|
||||||
|
|
||||||
|
// Get all rows for this metric from latest uploads
|
||||||
|
const { rows: allRows } = await pool.query(`
|
||||||
|
SELECT vertical, metric_desc, category, team,
|
||||||
|
non_compliant, compliant, total, compliance_pct, target
|
||||||
|
FROM vcl_multi_vertical_summary
|
||||||
|
WHERE upload_id = ANY($1) AND metric_id = $2
|
||||||
|
ORDER BY vertical, team
|
||||||
|
`, [latestUploadIds, metricId]);
|
||||||
|
|
||||||
|
// If no rows found, return empty verticals with metric_id echoed back
|
||||||
|
if (allRows.length === 0) {
|
||||||
|
return res.json({ metric_id: metricId, metric_desc: null, category: null, verticals: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metric_desc and category from the first row
|
||||||
|
const metric_desc = allRows[0].metric_desc || null;
|
||||||
|
const category = allRows[0].category || null;
|
||||||
|
|
||||||
|
// Separate rollup rows (ALL:) from sub-team rows and build per-vertical entries
|
||||||
|
const verticalMap = {};
|
||||||
|
|
||||||
|
for (const row of allRows) {
|
||||||
|
const isRollup = row.team && row.team.startsWith('ALL:');
|
||||||
|
|
||||||
|
if (isRollup) {
|
||||||
|
// Primary vertical entry from rollup row
|
||||||
|
verticalMap[row.vertical] = {
|
||||||
|
vertical: row.vertical,
|
||||||
|
non_compliant: row.non_compliant,
|
||||||
|
compliant: row.compliant,
|
||||||
|
total: row.total,
|
||||||
|
compliance_pct: row.compliance_pct,
|
||||||
|
target: row.target,
|
||||||
|
sub_teams: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: attach sub-team rows to their parent vertical
|
||||||
|
for (const row of allRows) {
|
||||||
|
const isRollup = row.team && row.team.startsWith('ALL:');
|
||||||
|
const isOther = row.team === '(Other)';
|
||||||
|
if (isRollup || isOther) continue;
|
||||||
|
|
||||||
|
if (verticalMap[row.vertical]) {
|
||||||
|
verticalMap[row.vertical].sub_teams.push({
|
||||||
|
team: row.team,
|
||||||
|
non_compliant: row.non_compliant,
|
||||||
|
compliant: row.compliant,
|
||||||
|
total: row.total,
|
||||||
|
compliance_pct: row.compliance_pct,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort verticals by non_compliant DESC
|
||||||
|
const verticals = Object.values(verticalMap).sort((a, b) => b.non_compliant - a.non_compliant);
|
||||||
|
|
||||||
|
res.json({ metric_id: metricId, metric_desc, category, verticals });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[VCL Multi] GET /metric/:id/verticals error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// GET /burndown — Aggregated cross-vertical burndown forecast
|
// GET /burndown — Aggregated cross-vertical burndown forecast
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -1288,6 +1411,78 @@ function createVCLMultiVerticalRouter(upload) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /metrics — Metrics aggregated across all verticals
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /metrics
|
||||||
|
* Returns all metrics aggregated across verticals using only ALL: rollup rows
|
||||||
|
* from the latest upload per vertical.
|
||||||
|
*
|
||||||
|
* @method GET
|
||||||
|
* @route /metrics
|
||||||
|
*
|
||||||
|
* @response 200
|
||||||
|
* {
|
||||||
|
* metrics: Array<{
|
||||||
|
* metric_id: string,
|
||||||
|
* metric_desc: string,
|
||||||
|
* category: string,
|
||||||
|
* non_compliant: number,
|
||||||
|
* compliant: number,
|
||||||
|
* total: number,
|
||||||
|
* compliance_pct: number,
|
||||||
|
* target: number
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
* @response 500 { error: "Database error" }
|
||||||
|
*/
|
||||||
|
router.get('/metrics', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get latest upload ID per vertical
|
||||||
|
const { rows: latestUploads } = await pool.query(`
|
||||||
|
SELECT DISTINCT ON (vertical) id, vertical
|
||||||
|
FROM compliance_uploads
|
||||||
|
WHERE vertical IS NOT NULL
|
||||||
|
ORDER BY vertical, id DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (latestUploads.length === 0) {
|
||||||
|
return res.json({ metrics: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestUploadIds = latestUploads.map(u => u.id);
|
||||||
|
|
||||||
|
// Aggregate metrics across verticals (ALL: rows only)
|
||||||
|
const { rows: metrics } = await pool.query(`
|
||||||
|
SELECT metric_id,
|
||||||
|
MAX(metric_desc) AS metric_desc,
|
||||||
|
MAX(category) AS category,
|
||||||
|
SUM(non_compliant)::int AS non_compliant,
|
||||||
|
SUM(compliant)::int AS compliant,
|
||||||
|
SUM(total)::int AS total,
|
||||||
|
ROUND(AVG(target::numeric), 4) AS target
|
||||||
|
FROM vcl_multi_vertical_summary
|
||||||
|
WHERE upload_id = ANY($1) AND team LIKE 'ALL:%'
|
||||||
|
GROUP BY metric_id
|
||||||
|
ORDER BY non_compliant DESC
|
||||||
|
`, [latestUploadIds]);
|
||||||
|
|
||||||
|
// Compute compliance_pct for each metric
|
||||||
|
const result = metrics.map(m => ({
|
||||||
|
...m,
|
||||||
|
target: parseFloat(m.target) || 0,
|
||||||
|
compliance_pct: m.total > 0 ? m.compliant / m.total : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ metrics: result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[VCL Multi] GET /metrics error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
docs/bugs/jira-sync-cross-project-failure.md
Normal file
42
docs/bugs/jira-sync-cross-project-failure.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# [Bug]: Jira sync fails for tickets in projects other than STEAM
|
||||||
|
|
||||||
|
**Labels:** kind/bug, status/resolved
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Syncing individual Jira tickets (or bulk "Sync All") fails with a 502 "Failed to fetch issue from Jira" error when the ticket belongs to a Jira project other than the configured `JIRA_PROJECT_KEY` (STEAM). For example, ticket `AA_ADTRAN-541` in the `AA_ADTRAN` project cannot be synced because the JQL query hardcodes `AND project = STEAM`, which excludes all cross-project tickets.
|
||||||
|
|
||||||
|
This affects both single-ticket sync and the "Sync All" bulk operation.
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
|
||||||
|
1. Go to the Jira Tickets page
|
||||||
|
2. Add or have a ticket with a key from a non-STEAM project (e.g., `AA_ADTRAN-541`)
|
||||||
|
3. Click the sync button on that ticket (or click "Sync All")
|
||||||
|
4. See browser alert: "Failed to fetch issue from Jira."
|
||||||
|
5. Console shows: `POST /api/jira-tickets/:id/sync` returns 502 (Bad Gateway)
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Browser: Chrome (any)
|
||||||
|
- Server: Node.js on 71.85.90.9:3001
|
||||||
|
- Jira: Charter Jira Data Center (on-prem)
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
`backend/helpers/jiraApi.js` — both `getIssue()` and `searchIssuesByKeys()` constructed JQL with `AND project = ${JIRA_PROJECT_KEY}` (resolves to `AND project = STEAM`). Since Jira issue keys are globally unique (the project prefix is part of the key), this filter is redundant for key-based lookups and breaks any ticket not in the STEAM project.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
Removed the `AND project = ${JIRA_PROJECT_KEY}` clause from:
|
||||||
|
- `getIssue()` — now uses `key = "${issueKey}"` only
|
||||||
|
- `searchIssuesByKeys()` — now uses `key in (...) AND updated >= -72h` only
|
||||||
|
|
||||||
|
`JIRA_PROJECT_KEY` is still used for issue creation (where it belongs).
|
||||||
|
|
||||||
|
## Relevant Log Output
|
||||||
|
|
||||||
|
```
|
||||||
|
POST http://71.85.90.9:3001/api/jira-tickets/:id/sync 502 (Bad Gateway)
|
||||||
|
Response: { "error": "Failed to fetch issue from Jira.", "details": "Issue not found" }
|
||||||
|
```
|
||||||
@@ -4,6 +4,7 @@ import { useAuth } from '../../contexts/AuthContext';
|
|||||||
import { PieChart, Pie, Cell, ComposedChart, Bar, BarChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
|
import { PieChart, Pie, Cell, ComposedChart, Bar, BarChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
|
||||||
import MultiVerticalUploadModal from './MultiVerticalUploadModal';
|
import MultiVerticalUploadModal from './MultiVerticalUploadModal';
|
||||||
|
|
||||||
|
// ⚠️ CONVENTION: Use relative API path (e.g. '/api') instead of absolute URL with localhost. The fallback 'http://localhost:3001/api' should be a relative path since Express serves both API and frontend on the same port in production.
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const TEAL = '#14B8A6';
|
const TEAL = '#14B8A6';
|
||||||
const PURPLE = '#A78BFA';
|
const PURPLE = '#A78BFA';
|
||||||
@@ -321,35 +322,6 @@ function AggregatedBurndownChart({ data, loading, error }) {
|
|||||||
All {data.blockers.toLocaleString()} non-compliant devices lack remediation dates.
|
All {data.blockers.toLocaleString()} non-compliant devices lack remediation dates.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Per-vertical contribution table */}
|
|
||||||
{data.by_vertical && data.by_vertical.length > 0 && (
|
|
||||||
<div style={{ marginTop: '1rem' }}>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.5rem' }}>
|
|
||||||
By Vertical
|
|
||||||
</div>
|
|
||||||
<table style={{ ...TABLE_STYLE, fontSize: '0.7rem' }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ ...TH_STYLE, fontSize: '0.6rem' }}>Vertical</th>
|
|
||||||
<th style={{ ...TH_STYLE, fontSize: '0.6rem', textAlign: 'right' }}>Total</th>
|
|
||||||
<th style={{ ...TH_STYLE, fontSize: '0.6rem', textAlign: 'right' }}>Blockers</th>
|
|
||||||
<th style={{ ...TH_STYLE, fontSize: '0.6rem', textAlign: 'right' }}>With Dates</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.by_vertical.map(v => (
|
|
||||||
<tr key={v.vertical}>
|
|
||||||
<td style={{ ...TD_STYLE, color: PURPLE, fontWeight: '600', padding: '0.5rem 1rem' }}>{v.vertical}</td>
|
|
||||||
<td style={{ ...TD_STYLE, textAlign: 'right', padding: '0.5rem 1rem' }}>{v.total.toLocaleString()}</td>
|
|
||||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: v.blockers > 0 ? '#EF4444' : '#64748B', padding: '0.5rem 1rem' }}>{v.blockers.toLocaleString()}</td>
|
|
||||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#F59E0B', padding: '0.5rem 1rem' }}>{v.with_dates.toLocaleString()}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -357,6 +329,7 @@ function AggregatedBurndownChart({ data, loading, error }) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Vertical Breakdown Table
|
// Vertical Breakdown Table
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
function VerticalTable({ breakdown, onSelectVertical }) {
|
function VerticalTable({ breakdown, onSelectVertical }) {
|
||||||
if (!breakdown || breakdown.length === 0) return null;
|
if (!breakdown || breakdown.length === 0) return null;
|
||||||
|
|
||||||
@@ -406,9 +379,215 @@ function VerticalTable({ breakdown, onSelectVertical }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Metric Table (metric-first overview — one row per metric aggregated across verticals)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function MetricTable({ metrics, onSelectMetric }) {
|
||||||
|
if (!metrics || metrics.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ ...CARD_STYLE, marginTop: '1.5rem', textAlign: 'center', padding: '2rem' }}>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#64748B' }}>No metrics data</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ ...CARD_STYLE, marginTop: '1.5rem' }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||||
|
Metrics Overview
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={TABLE_STYLE}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={TH_STYLE}>Metric ID</th>
|
||||||
|
<th style={TH_STYLE}>Description</th>
|
||||||
|
<th style={TH_STYLE}>Category</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>
|
||||||
|
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Target %</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{metrics.map(m => {
|
||||||
|
const pct = Number(m.compliance_pct || 0);
|
||||||
|
const target = Number(m.target || 0);
|
||||||
|
const pctColor = pct >= target ? '#10B981' : pct >= target * 0.85 ? '#F59E0B' : '#EF4444';
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={m.metric_id}
|
||||||
|
onClick={() => onSelectMetric(m.metric_id)}
|
||||||
|
style={{ cursor: 'pointer', transition: 'background 0.15s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(167, 139, 250, 0.08)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<td style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}>{m.metric_id}</td>
|
||||||
|
<td style={{ ...TD_STYLE, fontSize: '0.7rem', color: '#94A3B8', maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.metric_desc}</td>
|
||||||
|
<td style={{ ...TD_STYLE, fontSize: '0.7rem' }}>{m.category}</td>
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{(m.compliant || 0).toLocaleString()}</td>
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{(m.non_compliant || 0).toLocaleString()}</td>
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{(m.total || 0).toLocaleString()}</td>
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: pctColor }}>{(pct * 100).toFixed(1)}%</td>
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#64748B' }}>{(target * 100).toFixed(1)}%</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Metric Detail View (metric-first drill-down: overview → metric → verticals)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function MetricDetailView({ metricId, onBack, onSelectVertical }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetch(`${API_BASE}/compliance/vcl-multi/metric/${encodeURIComponent(metricId)}/verticals`, { credentials: 'include' })
|
||||||
|
.then(r => { if (!r.ok) throw new Error('Failed to load metric details'); return r.json(); })
|
||||||
|
.then(d => { setData(d); setLoading(false); })
|
||||||
|
.catch(err => { setError(err.message); setLoading(false); });
|
||||||
|
}, [metricId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}>
|
||||||
|
<Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
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 Overview
|
||||||
|
</button>
|
||||||
|
<div style={{ ...CARD_STYLE, borderColor: 'rgba(239, 68, 68, 0.3)', display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem' }}>
|
||||||
|
<AlertCircle style={{ width: '18px', height: '18px', color: '#EF4444', flexShrink: 0 }} />
|
||||||
|
<span style={{ color: '#EF4444', fontSize: '0.8rem' }}>{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute aggregated stats from verticals
|
||||||
|
const verticals = data?.verticals || [];
|
||||||
|
const totalCompliant = verticals.reduce((sum, v) => sum + (v.compliant || 0), 0);
|
||||||
|
const totalNonCompliant = verticals.reduce((sum, v) => sum + (v.non_compliant || 0), 0);
|
||||||
|
const totalDevices = verticals.reduce((sum, v) => sum + (v.total || 0), 0);
|
||||||
|
const compliancePct = totalDevices > 0 ? totalCompliant / totalDevices : 0;
|
||||||
|
|
||||||
|
const pctColor = (pct) => {
|
||||||
|
return pct >= 0.95 ? '#10B981' : pct >= 0.80 ? '#F59E0B' : '#EF4444';
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Overview
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<h2 style={{ fontSize: '1.2rem', color: '#E2E8F0', marginBottom: '0.25rem', fontWeight: '700' }}>
|
||||||
|
{data?.metric_id || metricId}
|
||||||
|
</h2>
|
||||||
|
{data?.metric_desc && (
|
||||||
|
<p style={{ fontSize: '0.8rem', color: '#94A3B8', margin: '0 0 0.25rem 0' }}>{data.metric_desc}</p>
|
||||||
|
)}
|
||||||
|
{data?.category && (
|
||||||
|
<p style={{ fontSize: '0.7rem', color: '#64748B', margin: '0 0 1.5rem 0' }}>Category: {data.category}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Aggregated stats cards */}
|
||||||
|
<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' }}>{totalDevices.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' }}>{totalCompliant.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' }}>{totalNonCompliant.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(compliancePct) }}>
|
||||||
|
{(compliancePct * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verticals table */}
|
||||||
|
<div style={CARD_STYLE}>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||||
|
Verticals
|
||||||
|
</div>
|
||||||
|
{verticals.length > 0 ? (
|
||||||
|
<table style={TABLE_STYLE}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={TH_STYLE}>Vertical</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>
|
||||||
|
{verticals.map(v => {
|
||||||
|
const vPct = Number(v.compliance_pct || 0);
|
||||||
|
const vPctColor = pctColor(vPct);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={v.vertical}
|
||||||
|
onClick={() => onSelectVertical(v.vertical, v)}
|
||||||
|
style={{ cursor: 'pointer', transition: 'background 0.15s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(167, 139, 250, 0.08)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<td style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}>{v.vertical}</td>
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{(v.compliant || 0).toLocaleString()}</td>
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{(v.non_compliant || 0).toLocaleString()}</td>
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{(v.total || 0).toLocaleString()}</td>
|
||||||
|
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: vPctColor }}>{(vPct * 100).toFixed(1)}%</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#64748B', fontSize: '0.8rem', padding: '1rem', textAlign: 'center' }}>
|
||||||
|
No verticals found for this metric.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Vertical Detail View (metric drill-down)
|
// Vertical Detail View (metric drill-down)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
|
function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
|
||||||
const [metrics, setMetrics] = useState(null);
|
const [metrics, setMetrics] = useState(null);
|
||||||
const [categories, setCategories] = useState(null);
|
const [categories, setCategories] = useState(null);
|
||||||
@@ -683,7 +862,7 @@ function MetricSubTeamView({ vertical, metricId, metricData, onBack, onSelectTea
|
|||||||
onClick={onBack}
|
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 }}
|
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 Metrics
|
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Verticals
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h3 style={{ fontSize: '1.1rem', color: '#E2E8F0', marginBottom: '0.25rem', fontWeight: '700' }}>
|
<h3 style={{ fontSize: '1.1rem', color: '#E2E8F0', marginBottom: '0.25rem', fontWeight: '700' }}>
|
||||||
@@ -1015,6 +1194,7 @@ export default function CCPMetricsPage() {
|
|||||||
const { isAdmin, canWrite } = useAuth();
|
const { isAdmin, canWrite } = useAuth();
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [trend, setTrend] = useState(null);
|
const [trend, setTrend] = useState(null);
|
||||||
|
const [metricsData, setMetricsData] = useState(null);
|
||||||
const [burndownData, setBurndownData] = useState(null);
|
const [burndownData, setBurndownData] = useState(null);
|
||||||
const [burndownLoading, setBurndownLoading] = useState(true);
|
const [burndownLoading, setBurndownLoading] = useState(true);
|
||||||
const [burndownError, setBurndownError] = useState(null);
|
const [burndownError, setBurndownError] = useState(null);
|
||||||
@@ -1024,10 +1204,11 @@ export default function CCPMetricsPage() {
|
|||||||
const [showManage, setShowManage] = useState(false);
|
const [showManage, setShowManage] = useState(false);
|
||||||
const [showMetricBreakdown, setShowMetricBreakdown] = useState(false);
|
const [showMetricBreakdown, setShowMetricBreakdown] = useState(false);
|
||||||
|
|
||||||
// Drill-down state
|
// Drill-down state (metric-first hierarchy: metric → vertical → team)
|
||||||
const [selectedVertical, setSelectedVertical] = useState(null);
|
|
||||||
const [selectedMetric, setSelectedMetric] = useState(null);
|
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||||
const [selectedMetricData, setSelectedMetricData] = useState(null);
|
const [selectedMetricData, setSelectedMetricData] = useState(null); // eslint-disable-line no-unused-vars
|
||||||
|
const [selectedVertical, setSelectedVertical] = useState(null);
|
||||||
|
const [selectedVerticalData, setSelectedVerticalData] = useState(null);
|
||||||
const [selectedTeam, setSelectedTeam] = useState(null);
|
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||||
|
|
||||||
const fetchData = useCallback(() => {
|
const fetchData = useCallback(() => {
|
||||||
@@ -1038,9 +1219,11 @@ export default function CCPMetricsPage() {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(`${API_BASE}/compliance/vcl-multi/stats`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load stats'); return r.json(); }),
|
fetch(`${API_BASE}/compliance/vcl-multi/stats`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load stats'); return r.json(); }),
|
||||||
fetch(`${API_BASE}/compliance/vcl-multi/trend`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load trend'); return r.json(); }),
|
fetch(`${API_BASE}/compliance/vcl-multi/trend`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load trend'); return r.json(); }),
|
||||||
]).then(([statsData, trendData]) => {
|
fetch(`${API_BASE}/compliance/vcl-multi/metrics`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load metrics'); return r.json(); }),
|
||||||
|
]).then(([statsData, trendData, metricsResult]) => {
|
||||||
setStats(statsData);
|
setStats(statsData);
|
||||||
setTrend(trendData);
|
setTrend(trendData);
|
||||||
|
setMetricsData(metricsResult);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -1061,7 +1244,7 @@ export default function CCPMetricsPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render drill-down views
|
// Render drill-down views (metric-first hierarchy)
|
||||||
if (selectedTeam !== null && selectedMetric && selectedVertical) {
|
if (selectedTeam !== null && selectedMetric && selectedVertical) {
|
||||||
return (
|
return (
|
||||||
<div style={PAGE_STYLE}>
|
<div style={PAGE_STYLE}>
|
||||||
@@ -1075,27 +1258,27 @@ export default function CCPMetricsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedMetric && selectedVertical) {
|
if (selectedVertical && selectedMetric) {
|
||||||
return (
|
return (
|
||||||
<div style={PAGE_STYLE}>
|
<div style={PAGE_STYLE}>
|
||||||
<MetricSubTeamView
|
<MetricSubTeamView
|
||||||
vertical={selectedVertical}
|
vertical={selectedVertical}
|
||||||
metricId={selectedMetric}
|
metricId={selectedMetric}
|
||||||
metricData={selectedMetricData}
|
metricData={selectedVerticalData}
|
||||||
onBack={() => { setSelectedMetric(null); setSelectedMetricData(null); }}
|
onBack={() => { setSelectedVertical(null); setSelectedVerticalData(null); }}
|
||||||
onSelectTeam={(team) => setSelectedTeam(team)}
|
onSelectTeam={(team) => setSelectedTeam(team)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedVertical) {
|
if (selectedMetric) {
|
||||||
return (
|
return (
|
||||||
<div style={PAGE_STYLE}>
|
<div style={PAGE_STYLE}>
|
||||||
<VerticalDetailView
|
<MetricDetailView
|
||||||
vertical={selectedVertical}
|
metricId={selectedMetric}
|
||||||
onBack={() => setSelectedVertical(null)}
|
onBack={() => { setSelectedMetric(null); setSelectedMetricData(null); }}
|
||||||
onSelectMetric={(metricId, metricData) => { setSelectedMetric(metricId); setSelectedMetricData(metricData); }}
|
onSelectVertical={(vertical, verticalData) => { setSelectedVertical(vertical); setSelectedVerticalData(verticalData); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1205,10 +1388,10 @@ export default function CCPMetricsPage() {
|
|||||||
error={burndownError}
|
error={burndownError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Vertical breakdown table */}
|
{/* Metrics overview table (metric-first model) */}
|
||||||
<VerticalTable
|
<MetricTable
|
||||||
breakdown={stats.vertical_breakdown}
|
metrics={metricsData?.metrics}
|
||||||
onSelectVertical={setSelectedVertical}
|
onSelectMetric={(metricId) => setSelectedMetric(metricId)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Last upload info */}
|
{/* Last upload info */}
|
||||||
|
|||||||
Reference in New Issue
Block a user