@checkstack/slo-frontend 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +8 -7
- package/src/components/SloTrendChart.tsx +91 -133
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @checkstack/slo-frontend
|
|
2
2
|
|
|
3
|
+
## 0.3.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- edc9ee0: Refactored SloTrendChart to use Recharts, fixing responsive layout issues and preventing visual distortion. The new implementation correctly scales and preserves SVG aspect ratios.
|
|
8
|
+
- @checkstack/dashboard-frontend@0.4.3
|
|
9
|
+
- @checkstack/catalog-common@1.4.1
|
|
10
|
+
|
|
11
|
+
## 0.3.2
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Updated dependencies [3da7582]
|
|
16
|
+
- @checkstack/ui@1.5.0
|
|
17
|
+
- @checkstack/dashboard-frontend@0.4.2
|
|
18
|
+
|
|
3
19
|
## 0.3.1
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/slo-frontend",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.tsx",
|
|
6
6
|
"checkstack": {
|
|
@@ -12,19 +12,20 @@
|
|
|
12
12
|
"lint:code": "eslint . --max-warnings 0"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@checkstack/catalog-common": "1.
|
|
15
|
+
"@checkstack/catalog-common": "1.4.0",
|
|
16
16
|
"@checkstack/common": "0.6.5",
|
|
17
|
-
"@checkstack/dashboard-frontend": "0.
|
|
18
|
-
"@checkstack/frontend-api": "0.3.9",
|
|
19
|
-
"@checkstack/signal-frontend": "0.0.15",
|
|
17
|
+
"@checkstack/dashboard-frontend": "0.4.2",
|
|
20
18
|
"@checkstack/dependency-common": "0.2.1",
|
|
19
|
+
"@checkstack/frontend-api": "0.3.9",
|
|
21
20
|
"@checkstack/healthcheck-common": "0.11.0",
|
|
21
|
+
"@checkstack/signal-frontend": "0.0.15",
|
|
22
22
|
"@checkstack/slo-common": "0.2.0",
|
|
23
|
-
"@checkstack/ui": "1.
|
|
23
|
+
"@checkstack/ui": "1.5.0",
|
|
24
24
|
"date-fns": "^4.1.0",
|
|
25
25
|
"lucide-react": "^0.344.0",
|
|
26
26
|
"react": "^18.2.0",
|
|
27
|
-
"react-router-dom": "^6.20.0"
|
|
27
|
+
"react-router-dom": "^6.20.0",
|
|
28
|
+
"recharts": "^3.8.1"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"typescript": "^5.0.0",
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import React, { useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ResponsiveContainer,
|
|
4
|
+
AreaChart,
|
|
5
|
+
Area,
|
|
6
|
+
XAxis,
|
|
7
|
+
YAxis,
|
|
8
|
+
Tooltip,
|
|
9
|
+
ReferenceLine,
|
|
10
|
+
} from "recharts";
|
|
2
11
|
|
|
3
12
|
interface Snapshot {
|
|
4
13
|
date: Date;
|
|
@@ -12,11 +21,9 @@ interface SloTrendChartProps {
|
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
const CHART_HEIGHT = 160;
|
|
15
|
-
const CHART_PADDING = { top: 8, right: 12, bottom: 24, left: 48 };
|
|
16
24
|
|
|
17
25
|
/**
|
|
18
|
-
*
|
|
19
|
-
* Draws a target line, area fill, and data line with responsive sizing.
|
|
26
|
+
* Recharts area chart showing SLO availability trend from daily snapshots.
|
|
20
27
|
*/
|
|
21
28
|
export const SloTrendChart: React.FC<SloTrendChartProps> = ({
|
|
22
29
|
snapshots,
|
|
@@ -29,155 +36,106 @@ export const SloTrendChart: React.FC<SloTrendChartProps> = ({
|
|
|
29
36
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
|
30
37
|
);
|
|
31
38
|
|
|
39
|
+
// Format data for Recharts
|
|
40
|
+
const data = sorted.map((s) => ({
|
|
41
|
+
date: new Date(s.date).toLocaleDateString("en-US", {
|
|
42
|
+
month: "short",
|
|
43
|
+
day: "numeric",
|
|
44
|
+
}),
|
|
45
|
+
availabilityPercent: s.availabilityPercent,
|
|
46
|
+
budgetRemainingPercent: s.budgetRemainingPercent,
|
|
47
|
+
fullDate: s.date,
|
|
48
|
+
}));
|
|
49
|
+
|
|
32
50
|
// Y-axis range: ensure target and all data points are visible
|
|
33
51
|
const allValues = sorted.map((s) => s.availabilityPercent);
|
|
34
52
|
const minVal = Math.min(...allValues, target);
|
|
35
53
|
const maxVal = Math.max(...allValues, 100);
|
|
36
54
|
const yMin = Math.max(Math.floor(minVal - 0.5), 0);
|
|
37
55
|
const yMax = Math.min(Math.ceil(maxVal + 0.5), 100);
|
|
38
|
-
const yRange = yMax - yMin || 1;
|
|
39
56
|
|
|
40
|
-
return {
|
|
57
|
+
return { data, yMin, yMax };
|
|
41
58
|
}, [snapshots, target]);
|
|
42
59
|
|
|
43
|
-
if (!chartData || chartData.
|
|
60
|
+
if (!chartData || chartData.data.length < 2) {
|
|
44
61
|
return (
|
|
45
62
|
<div className="text-sm text-muted-foreground text-center py-8">
|
|
46
|
-
{chartData && chartData.
|
|
63
|
+
{chartData && chartData.data.length === 1
|
|
47
64
|
? "Need at least 2 daily snapshots to render a trend chart"
|
|
48
65
|
: "No daily snapshot data available yet"}
|
|
49
66
|
</div>
|
|
50
67
|
);
|
|
51
68
|
}
|
|
52
69
|
|
|
53
|
-
const {
|
|
54
|
-
const drawWidth = 100 - CHART_PADDING.left - CHART_PADDING.right;
|
|
55
|
-
|
|
56
|
-
// Compute scaled positions (in viewBox %)
|
|
57
|
-
const scaleX = (index: number) =>
|
|
58
|
-
CHART_PADDING.left + (index / (sorted.length - 1)) * drawWidth;
|
|
59
|
-
|
|
60
|
-
const drawHeight = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom;
|
|
61
|
-
const scaleY = (value: number) =>
|
|
62
|
-
CHART_PADDING.top + ((yMax - value) / yRange) * drawHeight;
|
|
63
|
-
|
|
64
|
-
// Build SVG path for the line
|
|
65
|
-
const linePath = sorted
|
|
66
|
-
.map(
|
|
67
|
-
(s, i) =>
|
|
68
|
-
`${i === 0 ? "M" : "L"} ${scaleX(i).toFixed(2)} ${scaleY(s.availabilityPercent).toFixed(2)}`,
|
|
69
|
-
)
|
|
70
|
-
.join(" ");
|
|
71
|
-
|
|
72
|
-
// Build SVG path for the area fill
|
|
73
|
-
const areaPath = `${linePath} L ${scaleX(sorted.length - 1).toFixed(2)} ${scaleY(yMin).toFixed(2)} L ${scaleX(0).toFixed(2)} ${scaleY(yMin).toFixed(2)} Z`;
|
|
74
|
-
|
|
75
|
-
// Target line Y position
|
|
76
|
-
const targetY = scaleY(target);
|
|
77
|
-
|
|
78
|
-
// Y-axis tick values
|
|
79
|
-
const yTicks = [yMin, target, yMax].filter(
|
|
80
|
-
(v, i, arr) => arr.indexOf(v) === i,
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
// X-axis labels (first, middle, last)
|
|
84
|
-
const xLabels = [0, Math.floor(sorted.length / 2), sorted.length - 1]
|
|
85
|
-
.filter((v, i, arr) => arr.indexOf(v) === i)
|
|
86
|
-
.map((i) => ({
|
|
87
|
-
x: scaleX(i),
|
|
88
|
-
label: new Date(sorted[i].date).toLocaleDateString("en-US", {
|
|
89
|
-
month: "short",
|
|
90
|
-
day: "numeric",
|
|
91
|
-
}),
|
|
92
|
-
}));
|
|
70
|
+
const { data, yMin, yMax } = chartData;
|
|
93
71
|
|
|
94
72
|
return (
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
>
|
|
101
|
-
{/* Area fill */}
|
|
102
|
-
<path d={areaPath} fill="url(#slo-gradient)" opacity={0.3} />
|
|
103
|
-
|
|
104
|
-
{/* Gradient definition */}
|
|
105
|
-
<defs>
|
|
106
|
-
<linearGradient id="slo-gradient" x1="0" y1="0" x2="0" y2="1">
|
|
107
|
-
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.4} />
|
|
108
|
-
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
|
109
|
-
</linearGradient>
|
|
110
|
-
</defs>
|
|
111
|
-
|
|
112
|
-
{/* Target line */}
|
|
113
|
-
<line
|
|
114
|
-
x1={CHART_PADDING.left}
|
|
115
|
-
y1={targetY}
|
|
116
|
-
x2={100 - CHART_PADDING.right}
|
|
117
|
-
y2={targetY}
|
|
118
|
-
stroke="hsl(var(--destructive))"
|
|
119
|
-
strokeWidth={0.3}
|
|
120
|
-
strokeDasharray="1 1"
|
|
121
|
-
opacity={0.6}
|
|
122
|
-
/>
|
|
123
|
-
|
|
124
|
-
{/* Data line */}
|
|
125
|
-
<path
|
|
126
|
-
d={linePath}
|
|
127
|
-
fill="none"
|
|
128
|
-
stroke="hsl(var(--primary))"
|
|
129
|
-
strokeWidth={0.5}
|
|
130
|
-
strokeLinejoin="round"
|
|
131
|
-
strokeLinecap="round"
|
|
132
|
-
/>
|
|
133
|
-
|
|
134
|
-
{/* Data points */}
|
|
135
|
-
{sorted.map((s, i) => (
|
|
136
|
-
<circle
|
|
137
|
-
key={i}
|
|
138
|
-
cx={scaleX(i)}
|
|
139
|
-
cy={scaleY(s.availabilityPercent)}
|
|
140
|
-
r={0.8}
|
|
141
|
-
fill="hsl(var(--primary))"
|
|
142
|
-
>
|
|
143
|
-
<title>
|
|
144
|
-
{new Date(s.date).toLocaleDateString("en-US", {
|
|
145
|
-
month: "short",
|
|
146
|
-
day: "numeric",
|
|
147
|
-
})}
|
|
148
|
-
: {s.availabilityPercent.toFixed(3)}%
|
|
149
|
-
</title>
|
|
150
|
-
</circle>
|
|
151
|
-
))}
|
|
152
|
-
|
|
153
|
-
{/* Y-axis labels */}
|
|
154
|
-
{yTicks.map((tick) => (
|
|
155
|
-
<text
|
|
156
|
-
key={tick}
|
|
157
|
-
x={CHART_PADDING.left - 2}
|
|
158
|
-
y={scaleY(tick)}
|
|
159
|
-
textAnchor="end"
|
|
160
|
-
dominantBaseline="middle"
|
|
161
|
-
className="fill-muted-foreground"
|
|
162
|
-
fontSize={3.5}
|
|
163
|
-
>
|
|
164
|
-
{tick}%
|
|
165
|
-
</text>
|
|
166
|
-
))}
|
|
167
|
-
|
|
168
|
-
{/* X-axis labels */}
|
|
169
|
-
{xLabels.map((item) => (
|
|
170
|
-
<text
|
|
171
|
-
key={item.x}
|
|
172
|
-
x={item.x}
|
|
173
|
-
y={CHART_HEIGHT - 4}
|
|
174
|
-
textAnchor="middle"
|
|
175
|
-
className="fill-muted-foreground"
|
|
176
|
-
fontSize={3}
|
|
73
|
+
<div className="w-full" style={{ height: CHART_HEIGHT }}>
|
|
74
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
75
|
+
<AreaChart
|
|
76
|
+
data={data}
|
|
77
|
+
margin={{ top: 8, right: 12, bottom: 0, left: -20 }}
|
|
177
78
|
>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
79
|
+
<defs>
|
|
80
|
+
<linearGradient id="slo-gradient" x1="0" y1="0" x2="0" y2="1">
|
|
81
|
+
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.4} />
|
|
82
|
+
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
|
83
|
+
</linearGradient>
|
|
84
|
+
</defs>
|
|
85
|
+
<XAxis
|
|
86
|
+
dataKey="date"
|
|
87
|
+
axisLine={false}
|
|
88
|
+
tickLine={false}
|
|
89
|
+
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 11 }}
|
|
90
|
+
dy={8}
|
|
91
|
+
minTickGap={30}
|
|
92
|
+
/>
|
|
93
|
+
<YAxis
|
|
94
|
+
domain={[yMin, yMax]}
|
|
95
|
+
axisLine={false}
|
|
96
|
+
tickLine={false}
|
|
97
|
+
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
|
|
98
|
+
tickCount={3}
|
|
99
|
+
tickFormatter={(value) => `${value}%`}
|
|
100
|
+
/>
|
|
101
|
+
<Tooltip
|
|
102
|
+
content={({ active, payload }) => {
|
|
103
|
+
// eslint-disable-next-line unicorn/no-null
|
|
104
|
+
if (!active || !payload?.length) return null;
|
|
105
|
+
const payloadData = payload[0].payload as (typeof data)[number];
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
className="rounded-md border bg-popover p-2 text-sm shadow-md"
|
|
109
|
+
style={{
|
|
110
|
+
backgroundColor: "hsl(var(--popover))",
|
|
111
|
+
border: "1px solid hsl(var(--border))",
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<p className="font-medium text-foreground mb-1">{payloadData.date}</p>
|
|
115
|
+
<p className="text-primary font-bold">
|
|
116
|
+
{payloadData.availabilityPercent.toFixed(3)}% Availability
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}}
|
|
121
|
+
/>
|
|
122
|
+
<ReferenceLine
|
|
123
|
+
y={target}
|
|
124
|
+
stroke="hsl(var(--destructive))"
|
|
125
|
+
strokeDasharray="4 4"
|
|
126
|
+
opacity={0.6}
|
|
127
|
+
/>
|
|
128
|
+
<Area
|
|
129
|
+
type="monotone"
|
|
130
|
+
dataKey="availabilityPercent"
|
|
131
|
+
stroke="hsl(var(--primary))"
|
|
132
|
+
strokeWidth={2}
|
|
133
|
+
fill="url(#slo-gradient)"
|
|
134
|
+
activeDot={{ r: 4, fill: "hsl(var(--primary))" }}
|
|
135
|
+
isAnimationActive={false}
|
|
136
|
+
/>
|
|
137
|
+
</AreaChart>
|
|
138
|
+
</ResponsiveContainer>
|
|
139
|
+
</div>
|
|
182
140
|
);
|
|
183
141
|
};
|