|
| 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