Skip to content

Commit 9f02058

Browse files
committed
Add CI Health stability metrics
1 parent 4570617 commit 9f02058

File tree

2 files changed

+383
-8
lines changed

2 files changed

+383
-8
lines changed
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import dayjs from "dayjs";
2+
import { EChartsOption } from "echarts";
3+
import { useDarkMode } from "lib/DarkModeContext";
4+
import _ from "lodash";
5+
import {
6+
ChartPaper,
7+
getCrosshairTooltipConfig,
8+
GRID_DEFAULT,
9+
} from "./chartUtils";
10+
import { COLOR_SUCCESS, COLOR_ERROR, COLOR_WARNING } from "./constants";
11+
12+
interface TrunkHealthData {
13+
build_started_at: string;
14+
is_green: number;
15+
}
16+
17+
// Helper function to calculate stability score for a window of days
18+
function calculateStabilityScore(healthValues: number[]): number {
19+
if (healthValues.length === 0) return 0;
20+
21+
// Calculate volatility (standard deviation)
22+
const mean = _.mean(healthValues);
23+
const squaredDiffs = healthValues.map((x) => Math.pow(x - mean, 2));
24+
const variance = _.mean(squaredDiffs);
25+
const volatility = Math.sqrt(variance);
26+
27+
// Count state transitions
28+
const transitions = healthValues.reduce((count, current, index) => {
29+
if (index === 0) return 0;
30+
const previous = healthValues[index - 1];
31+
return current !== previous ? count + 1 : count;
32+
}, 0);
33+
34+
// Calculate penalties
35+
const volatilityPenalty = volatility * 50;
36+
const transitionPenalty =
37+
Math.min(transitions / healthValues.length, 1) * 50;
38+
39+
// Return score as percentage (0-1)
40+
return Math.max(0, 100 - volatilityPenalty - transitionPenalty) / 100;
41+
}
42+
43+
// Helper function to format tooltip
44+
function formatTooltip(params: any, stabilityData: any[]): string {
45+
if (!Array.isArray(params) || params.length === 0) return "";
46+
47+
const date = params[0].axisValue;
48+
const dataIndex = params[0].dataIndex;
49+
const data = stabilityData[dataIndex];
50+
51+
if (!data) return "";
52+
53+
let result = `<b>${date}</b><br/>`;
54+
result += `${params[0].marker} Stability Score: <b>${(data.score * 100).toFixed(1)}%</b><br/>`;
55+
result += `<span style="color: #999; font-size: 0.85em;">`;
56+
result += `Volatility: ${(data.volatility * 100).toFixed(1)}% | `;
57+
result += `Transitions: ${data.transitions}`;
58+
result += `</span>`;
59+
60+
return result;
61+
}
62+
63+
// Helper function to get line series
64+
function getLineSeries(data: any[]): any[] {
65+
return [
66+
{
67+
name: "Stability Score",
68+
type: "line",
69+
data: data.map((d) => d.score),
70+
smooth: true,
71+
symbol: "circle",
72+
symbolSize: 6,
73+
lineStyle: { width: 2 },
74+
itemStyle: {
75+
color: (params: any) => {
76+
const score = params.data;
77+
if (score >= 0.7) return COLOR_SUCCESS;
78+
if (score >= 0.5) return COLOR_WARNING;
79+
return COLOR_ERROR;
80+
},
81+
},
82+
areaStyle: {
83+
opacity: 0.2,
84+
color: {
85+
type: "linear",
86+
x: 0,
87+
y: 0,
88+
x2: 0,
89+
y2: 1,
90+
colorStops: [
91+
{ offset: 0, color: COLOR_SUCCESS },
92+
{ offset: 0.5, color: COLOR_WARNING },
93+
{ offset: 1, color: COLOR_ERROR },
94+
],
95+
},
96+
},
97+
markLine: {
98+
silent: true,
99+
symbol: "none",
100+
lineStyle: {
101+
type: "dashed",
102+
color: COLOR_WARNING,
103+
width: 1,
104+
},
105+
label: {
106+
formatter: "Target: 70%",
107+
position: "end",
108+
},
109+
data: [{ yAxis: 0.7 }],
110+
},
111+
},
112+
];
113+
}
114+
115+
export default function CiStabilityTrendPanel({
116+
data,
117+
}: {
118+
data: TrunkHealthData[] | undefined;
119+
}) {
120+
const { darkMode } = useDarkMode();
121+
122+
// Group builds by day and determine daily health status
123+
const buildsByDay = _.groupBy(
124+
data || [],
125+
(d) => d.build_started_at?.slice(0, 10) || ""
126+
);
127+
128+
const dailyHealth = Object.entries(buildsByDay)
129+
.map(([day, builds]) => {
130+
if (!day) return null;
131+
const sortedBuilds = _.sortBy(builds, "build_started_at");
132+
const mostRecent = sortedBuilds[sortedBuilds.length - 1];
133+
return {
134+
date: day,
135+
isGreen: mostRecent?.is_green === 1 ? 1 : 0,
136+
};
137+
})
138+
.filter((d) => d !== null)
139+
.sort((a, b) => a!.date.localeCompare(b!.date)) as {
140+
date: string;
141+
isGreen: number;
142+
}[];
143+
144+
// Calculate rolling stability score (7-day window)
145+
const windowSize = 7;
146+
const stabilityData = dailyHealth
147+
.map((day, index) => {
148+
if (index < windowSize - 1) return null; // Not enough data for window
149+
150+
// Get window of health values
151+
const windowData = dailyHealth
152+
.slice(Math.max(0, index - windowSize + 1), index + 1)
153+
.map((d) => d.isGreen);
154+
155+
// Calculate volatility
156+
const mean = _.mean(windowData);
157+
const squaredDiffs = windowData.map((x) => Math.pow(x - mean, 2));
158+
const variance = _.mean(squaredDiffs);
159+
const volatility = Math.sqrt(variance);
160+
161+
// Count transitions
162+
const transitions = windowData.reduce((count, current, idx) => {
163+
if (idx === 0) return 0;
164+
const previous = windowData[idx - 1];
165+
return current !== previous ? count + 1 : count;
166+
}, 0);
167+
168+
const score = calculateStabilityScore(windowData);
169+
170+
return {
171+
date: day.date,
172+
score,
173+
volatility,
174+
transitions,
175+
};
176+
})
177+
.filter((d) => d !== null) as {
178+
date: string;
179+
score: number;
180+
volatility: number;
181+
transitions: number;
182+
}[];
183+
184+
const dates = stabilityData.map((d) => dayjs(d.date).format("MMM D"));
185+
186+
const options: EChartsOption = {
187+
title: {
188+
text: "CI Stability Score Over Time",
189+
subtext: `7-day rolling window (target: ≥70%)`,
190+
left: "center",
191+
},
192+
grid: GRID_DEFAULT,
193+
xAxis: {
194+
type: "category",
195+
data: dates,
196+
name: "Date",
197+
nameLocation: "middle",
198+
nameGap: 35,
199+
axisLabel: {
200+
rotate: 45,
201+
fontSize: 10,
202+
},
203+
},
204+
yAxis: {
205+
type: "value",
206+
name: "Stability Score",
207+
nameLocation: "middle",
208+
nameGap: 45,
209+
min: 0,
210+
max: 1,
211+
axisLabel: {
212+
formatter: (value: number) => `${(value * 100).toFixed(0)}%`,
213+
},
214+
},
215+
series: stabilityData.length > 0 ? getLineSeries(stabilityData) : [],
216+
tooltip: getCrosshairTooltipConfig(darkMode, (params: any) =>
217+
formatTooltip(params, stabilityData)
218+
),
219+
};
220+
221+
return (
222+
<ChartPaper
223+
tooltip="Measures consistency of CI health over a 7-day rolling window. Combines two factors: (1) Volatility - how much daily health fluctuates, and (2) State Transitions - how often trunk flips between green and red. Score ranges from 0-100%, with ≥70% being the target. Lower volatility and fewer transitions = higher stability score. This is a leading indicator for CI predictability."
224+
option={options}
225+
darkMode={darkMode}
226+
/>
227+
);
228+
}
229+

0 commit comments

Comments
 (0)