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..0dc5d56 100644 --- a/frontend/src/components/pages/CCPMetricsPage.js +++ b/frontend/src/components/pages/CCPMetricsPage.js @@ -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 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 TEAL = '#14B8A6'; const PURPLE = '#A78BFA'; @@ -321,35 +322,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 && ( -
-
- By Vertical -
- - - - - - - - - - - {data.by_vertical.map(v => ( - - - - - - - ))} - -
VerticalTotalBlockersWith Dates
{v.vertical}{v.total.toLocaleString()} 0 ? '#EF4444' : '#64748B', padding: '0.5rem 1rem' }}>{v.blockers.toLocaleString()}{v.with_dates.toLocaleString()}
-
- )} ); } @@ -357,6 +329,7 @@ function AggregatedBurndownChart({ data, loading, error }) { // --------------------------------------------------------------------------- // Vertical Breakdown Table // --------------------------------------------------------------------------- +// eslint-disable-next-line no-unused-vars function VerticalTable({ breakdown, onSelectVertical }) { 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 ( +
+
No metrics data
+
+ ); + } + + return ( +
+
+ Metrics Overview +
+
+ + + + + + + + + + + + + + + {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 ( + 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'} + > + + + + + + + + + + ); + })} + +
Metric IDDescriptionCategoryCompliantNon-CompliantTotalCompliance %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)}%
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ Loading... +
+ ); + } + + if (error) { + return ( +
+ +
+ + {error} +
+
+ ); + } + + // 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 ( +
+ + + {/* Header */} +

+ {data?.metric_id || metricId} +

+ {data?.metric_desc && ( +

{data.metric_desc}

+ )} + {data?.category && ( +

Category: {data.category}

+ )} + + {/* Aggregated stats cards */} +
+
+
Total
+
{totalDevices.toLocaleString()}
+
+
+
Compliant
+
{totalCompliant.toLocaleString()}
+
+
+
Non-Compliant
+
{totalNonCompliant.toLocaleString()}
+
+
+
Compliance
+
+ {(compliancePct * 100).toFixed(1)}% +
+
+
+ + {/* Verticals table */} +
+
+ Verticals +
+ {verticals.length > 0 ? ( + + + + + + + + + + + + {verticals.map(v => { + const vPct = Number(v.compliance_pct || 0); + const vPctColor = pctColor(vPct); + return ( + 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'} + > + + + + + + + ); + })} + +
VerticalCompliantNon-CompliantTotalCompliance %
{v.vertical}{(v.compliant || 0).toLocaleString()}{(v.non_compliant || 0).toLocaleString()}{(v.total || 0).toLocaleString()}{(vPct * 100).toFixed(1)}%
+ ) : ( +
+ No verticals found for this metric. +
+ )} +
+
+ ); +} + // --------------------------------------------------------------------------- // Vertical Detail View (metric drill-down) // --------------------------------------------------------------------------- +// eslint-disable-next-line no-unused-vars function VerticalDetailView({ vertical, onBack, onSelectMetric }) { const [metrics, setMetrics] = useState(null); const [categories, setCategories] = useState(null); @@ -683,7 +862,7 @@ function MetricSubTeamView({ vertical, metricId, metricData, onBack, onSelectTea 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 }} > - Back to Metrics + Back to Verticals

@@ -1015,6 +1194,7 @@ export default function CCPMetricsPage() { const { isAdmin, canWrite } = useAuth(); const [stats, setStats] = useState(null); const [trend, setTrend] = useState(null); + const [metricsData, setMetricsData] = useState(null); const [burndownData, setBurndownData] = useState(null); const [burndownLoading, setBurndownLoading] = useState(true); const [burndownError, setBurndownError] = useState(null); @@ -1024,10 +1204,11 @@ export default function CCPMetricsPage() { const [showManage, setShowManage] = useState(false); const [showMetricBreakdown, setShowMetricBreakdown] = useState(false); - // Drill-down state - const [selectedVertical, setSelectedVertical] = useState(null); + // Drill-down state (metric-first hierarchy: metric → vertical → team) 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 fetchData = useCallback(() => { @@ -1038,9 +1219,11 @@ export default function CCPMetricsPage() { 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/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); setTrend(trendData); + setMetricsData(metricsResult); setLoading(false); }).catch(err => { setError(err.message); @@ -1061,7 +1244,7 @@ export default function CCPMetricsPage() { fetchData(); }; - // Render drill-down views + // Render drill-down views (metric-first hierarchy) if (selectedTeam !== null && selectedMetric && selectedVertical) { return (
@@ -1075,27 +1258,27 @@ export default function CCPMetricsPage() { ); } - if (selectedMetric && selectedVertical) { + if (selectedVertical && selectedMetric) { return (
{ setSelectedMetric(null); setSelectedMetricData(null); }} + metricData={selectedVerticalData} + onBack={() => { setSelectedVertical(null); setSelectedVerticalData(null); }} onSelectTeam={(team) => setSelectedTeam(team)} />
); } - if (selectedVertical) { + if (selectedMetric) { return (
- setSelectedVertical(null)} - onSelectMetric={(metricId, metricData) => { setSelectedMetric(metricId); setSelectedMetricData(metricData); }} + { setSelectedMetric(null); setSelectedMetricData(null); }} + onSelectVertical={(vertical, verticalData) => { setSelectedVertical(vertical); setSelectedVerticalData(verticalData); }} />
); @@ -1205,10 +1388,10 @@ export default function CCPMetricsPage() { error={burndownError} /> - {/* Vertical breakdown table */} - setSelectedMetric(metricId)} /> {/* Last upload info */}