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:
@@ -321,35 +321,6 @@ function AggregatedBurndownChart({ data, loading, error }) {
|
||||
All {data.blockers.toLocaleString()} non-compliant devices lack remediation dates.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -406,6 +377,211 @@ 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -683,7 +859,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 }}
|
||||
>
|
||||
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Metrics
|
||||
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Verticals
|
||||
</button>
|
||||
|
||||
<h3 style={{ fontSize: '1.1rem', color: '#E2E8F0', marginBottom: '0.25rem', fontWeight: '700' }}>
|
||||
@@ -1015,6 +1191,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 +1201,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 [selectedVertical, setSelectedVertical] = useState(null);
|
||||
const [selectedVerticalData, setSelectedVerticalData] = useState(null);
|
||||
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
@@ -1038,9 +1216,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 +1241,7 @@ export default function CCPMetricsPage() {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// Render drill-down views
|
||||
// Render drill-down views (metric-first hierarchy)
|
||||
if (selectedTeam !== null && selectedMetric && selectedVertical) {
|
||||
return (
|
||||
<div style={PAGE_STYLE}>
|
||||
@@ -1075,27 +1255,27 @@ export default function CCPMetricsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedMetric && selectedVertical) {
|
||||
if (selectedVertical && selectedMetric) {
|
||||
return (
|
||||
<div style={PAGE_STYLE}>
|
||||
<MetricSubTeamView
|
||||
vertical={selectedVertical}
|
||||
metricId={selectedMetric}
|
||||
metricData={selectedMetricData}
|
||||
onBack={() => { setSelectedMetric(null); setSelectedMetricData(null); }}
|
||||
metricData={selectedVerticalData}
|
||||
onBack={() => { setSelectedVertical(null); setSelectedVerticalData(null); }}
|
||||
onSelectTeam={(team) => setSelectedTeam(team)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedVertical) {
|
||||
if (selectedMetric) {
|
||||
return (
|
||||
<div style={PAGE_STYLE}>
|
||||
<VerticalDetailView
|
||||
vertical={selectedVertical}
|
||||
onBack={() => setSelectedVertical(null)}
|
||||
onSelectMetric={(metricId, metricData) => { setSelectedMetric(metricId); setSelectedMetricData(metricData); }}
|
||||
<MetricDetailView
|
||||
metricId={selectedMetric}
|
||||
onBack={() => { setSelectedMetric(null); setSelectedMetricData(null); }}
|
||||
onSelectVertical={(vertical, verticalData) => { setSelectedVertical(vertical); setSelectedVerticalData(verticalData); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1205,10 +1385,10 @@ export default function CCPMetricsPage() {
|
||||
error={burndownError}
|
||||
/>
|
||||
|
||||
{/* Vertical breakdown table */}
|
||||
<VerticalTable
|
||||
breakdown={stats.vertical_breakdown}
|
||||
onSelectVertical={setSelectedVertical}
|
||||
{/* Metrics overview table (metric-first model) */}
|
||||
<MetricTable
|
||||
metrics={metricsData?.metrics}
|
||||
onSelectMetric={(metricId) => setSelectedMetric(metricId)}
|
||||
/>
|
||||
|
||||
{/* Last upload info */}
|
||||
|
||||
Reference in New Issue
Block a user