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
|
||||
*/
|
||||
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 });
|
||||
if (result.ok && result.data.issues && result.data.issues.length > 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: [] } };
|
||||
}
|
||||
|
||||
// Build JQL: key in (KEY-1, KEY-2, ...) — Charter requires project+updated
|
||||
// or similar, but key-based search is inherently scoped. We add updated
|
||||
// clause for compliance.
|
||||
// Build JQL: key in (KEY-1, KEY-2, ...) — issue keys are globally unique,
|
||||
// so no project filter needed. Add updated clause for Charter compliance.
|
||||
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 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
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user