diff --git a/backend/helpers/jiraApi.js b/backend/helpers/jiraApi.js index 69cc885..00bbc85 100644 --- a/backend/helpers/jiraApi.js +++ b/backend/helpers/jiraApi.js @@ -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); diff --git a/backend/routes/vclMultiVertical.js b/backend/routes/vclMultiVertical.js index 1028f10..8e1098e 100644 --- a/backend/routes/vclMultiVertical.js +++ b/backend/routes/vclMultiVertical.js @@ -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; } diff --git a/docs/bugs/jira-sync-cross-project-failure.md b/docs/bugs/jira-sync-cross-project-failure.md new file mode 100644 index 0000000..c6c8883 --- /dev/null +++ b/docs/bugs/jira-sync-cross-project-failure.md @@ -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" } +``` diff --git a/frontend/src/components/pages/CCPMetricsPage.js b/frontend/src/components/pages/CCPMetricsPage.js index 4e27441..e715146 100644 --- a/frontend/src/components/pages/CCPMetricsPage.js +++ b/frontend/src/components/pages/CCPMetricsPage.js @@ -321,35 +321,6 @@ function AggregatedBurndownChart({ data, loading, error }) { All {data.blockers.toLocaleString()} non-compliant devices lack remediation dates. )} - - {/* Per-vertical contribution table */} - {data.by_vertical && data.by_vertical.length > 0 && ( -
| Vertical | -Total | -Blockers | -With Dates | -
|---|---|---|---|
| {v.vertical} | -{v.total.toLocaleString()} | -0 ? '#EF4444' : '#64748B', padding: '0.5rem 1rem' }}>{v.blockers.toLocaleString()} | -{v.with_dates.toLocaleString()} | -
| Metric ID | +Description | +Category | +Compliant | +Non-Compliant | +Total | +Compliance % | +Target % | +
|---|---|---|---|---|---|---|---|
| {m.metric_id} | +{m.metric_desc} | +{m.category} | +{(m.compliant || 0).toLocaleString()} | +{(m.non_compliant || 0).toLocaleString()} | +{(m.total || 0).toLocaleString()} | +{(pct * 100).toFixed(1)}% | +{(target * 100).toFixed(1)}% | +
{data.metric_desc}
+ )} + {data?.category && ( +Category: {data.category}
+ )} + + {/* Aggregated stats cards */} +| Vertical | +Compliant | +Non-Compliant | +Total | +Compliance % | +
|---|---|---|---|---|
| {v.vertical} | +{(v.compliant || 0).toLocaleString()} | +{(v.non_compliant || 0).toLocaleString()} | +{(v.total || 0).toLocaleString()} | +{(vPct * 100).toFixed(1)}% | +