@checkstack/slo-frontend 0.2.0
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 +37 -0
- package/package.json +35 -0
- package/src/api.ts +13 -0
- package/src/components/AchievementBadge.tsx +105 -0
- package/src/components/AttributionChart.tsx +112 -0
- package/src/components/BurnRateIndicator.tsx +50 -0
- package/src/components/DependencyExclusionConfig.tsx +144 -0
- package/src/components/DowntimeTimeline.tsx +104 -0
- package/src/components/ErrorBudgetBar.tsx +48 -0
- package/src/components/MilestoneFeed.tsx +107 -0
- package/src/components/SloEditor.tsx +332 -0
- package/src/components/SloMenuItems.tsx +30 -0
- package/src/components/SloTrendChart.tsx +183 -0
- package/src/components/StreakCounter.tsx +45 -0
- package/src/components/SystemSloBadge.tsx +51 -0
- package/src/components/SystemSloPanel.tsx +90 -0
- package/src/index.tsx +57 -0
- package/src/pages/SloConfigPage.tsx +242 -0
- package/src/pages/SloDetailPage.tsx +283 -0
- package/src/pages/SloOverviewPage.tsx +140 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { useParams } from "react-router-dom";
|
|
3
|
+
import { usePluginClient, wrapInSuspense } from "@checkstack/frontend-api";
|
|
4
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
5
|
+
import { SloApi } from "../api";
|
|
6
|
+
import { SLO_STATUS_CHANGED } from "@checkstack/slo-common";
|
|
7
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
8
|
+
import { ErrorBudgetBar } from "../components/ErrorBudgetBar";
|
|
9
|
+
import { BurnRateIndicator } from "../components/BurnRateIndicator";
|
|
10
|
+
import { StreakCounter } from "../components/StreakCounter";
|
|
11
|
+
import { AttributionChart } from "../components/AttributionChart";
|
|
12
|
+
import { DowntimeTimeline } from "../components/DowntimeTimeline";
|
|
13
|
+
import { SloTrendChart } from "../components/SloTrendChart";
|
|
14
|
+
import {
|
|
15
|
+
AchievementBadge,
|
|
16
|
+
NoAchievements,
|
|
17
|
+
} from "../components/AchievementBadge";
|
|
18
|
+
import {
|
|
19
|
+
Card,
|
|
20
|
+
CardContent,
|
|
21
|
+
CardHeader,
|
|
22
|
+
CardTitle,
|
|
23
|
+
PageLayout,
|
|
24
|
+
LoadingSpinner,
|
|
25
|
+
Badge,
|
|
26
|
+
} from "@checkstack/ui";
|
|
27
|
+
import {
|
|
28
|
+
Target,
|
|
29
|
+
Clock,
|
|
30
|
+
Shield,
|
|
31
|
+
TrendingUp,
|
|
32
|
+
Flame,
|
|
33
|
+
Award,
|
|
34
|
+
BarChart3,
|
|
35
|
+
LineChart,
|
|
36
|
+
} from "lucide-react";
|
|
37
|
+
import { subDays } from "date-fns";
|
|
38
|
+
|
|
39
|
+
const SloDetailPageContent: React.FC = () => {
|
|
40
|
+
const { sloId } = useParams<{ sloId: string }>();
|
|
41
|
+
const sloClient = usePluginClient(SloApi);
|
|
42
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
43
|
+
|
|
44
|
+
const { data, isLoading, refetch } = sloClient.getObjective.useQuery(
|
|
45
|
+
{ id: sloId ?? "" },
|
|
46
|
+
{ enabled: !!sloId },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const { data: eventsData, refetch: refetchEvents } =
|
|
50
|
+
sloClient.getDowntimeEvents.useQuery(
|
|
51
|
+
{ objectiveId: sloId ?? "", limit: 20 },
|
|
52
|
+
{ enabled: !!sloId },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const { data: streaksData } = sloClient.getStreaks.useQuery({});
|
|
56
|
+
|
|
57
|
+
const { data: systemData } = catalogClient.getSystem.useQuery(
|
|
58
|
+
{ systemId: data?.objective?.systemId ?? "" },
|
|
59
|
+
{ enabled: !!data?.objective?.systemId },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const { data: achievementsData } = sloClient.getAchievements.useQuery(
|
|
63
|
+
{ systemId: data?.objective?.systemId ?? "" },
|
|
64
|
+
{ enabled: !!data?.objective?.systemId },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const snapshotWindowDays = data?.objective?.windowDays ?? 30;
|
|
68
|
+
const snapshotRange = useMemo(() => ({
|
|
69
|
+
startDate: subDays(new Date(), snapshotWindowDays),
|
|
70
|
+
endDate: new Date(),
|
|
71
|
+
}), [snapshotWindowDays]);
|
|
72
|
+
|
|
73
|
+
const { data: snapshotsData } = sloClient.getDailySnapshots.useQuery(
|
|
74
|
+
{
|
|
75
|
+
objectiveId: sloId ?? "",
|
|
76
|
+
...snapshotRange,
|
|
77
|
+
},
|
|
78
|
+
{ enabled: !!sloId },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const events = eventsData?.events;
|
|
82
|
+
|
|
83
|
+
useSignal(SLO_STATUS_CHANGED, ({ objectiveId }) => {
|
|
84
|
+
if (objectiveId === sloId) {
|
|
85
|
+
void refetch();
|
|
86
|
+
void refetchEvents();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (isLoading || !data) {
|
|
91
|
+
return (
|
|
92
|
+
<PageLayout title="SLO Detail" icon={Target}>
|
|
93
|
+
<div className="p-12 flex justify-center">
|
|
94
|
+
<LoadingSpinner />
|
|
95
|
+
</div>
|
|
96
|
+
</PageLayout>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { objective, status } = data;
|
|
101
|
+
const systemName = systemData?.name ?? objective.systemId;
|
|
102
|
+
|
|
103
|
+
// Find streak for this objective
|
|
104
|
+
const streak = streaksData?.streaks.find(
|
|
105
|
+
(s) => s.objectiveId === objective.id,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Filter achievements for this system
|
|
109
|
+
const achievements = achievementsData?.achievements ?? [];
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<PageLayout
|
|
113
|
+
title={`${objective.target}% / ${objective.windowDays}d SLO`}
|
|
114
|
+
subtitle={`System: ${systemName}`}
|
|
115
|
+
icon={Target}
|
|
116
|
+
>
|
|
117
|
+
{/* Status Cards */}
|
|
118
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
119
|
+
<Card>
|
|
120
|
+
<CardHeader className="pb-2">
|
|
121
|
+
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
|
122
|
+
<Shield className="w-4 h-4" />
|
|
123
|
+
Current Availability
|
|
124
|
+
</CardTitle>
|
|
125
|
+
</CardHeader>
|
|
126
|
+
<CardContent>
|
|
127
|
+
<div className="text-3xl font-bold">
|
|
128
|
+
{status.currentAvailability?.toFixed(3) ?? "—"}%
|
|
129
|
+
</div>
|
|
130
|
+
<div className="text-sm text-muted-foreground mt-1">
|
|
131
|
+
Target: {objective.target}%
|
|
132
|
+
</div>
|
|
133
|
+
</CardContent>
|
|
134
|
+
</Card>
|
|
135
|
+
|
|
136
|
+
<Card>
|
|
137
|
+
<CardHeader className="pb-2">
|
|
138
|
+
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
|
139
|
+
<Clock className="w-4 h-4" />
|
|
140
|
+
Error Budget
|
|
141
|
+
</CardTitle>
|
|
142
|
+
</CardHeader>
|
|
143
|
+
<CardContent>
|
|
144
|
+
<div className="text-3xl font-bold">
|
|
145
|
+
{status.errorBudgetRemainingMinutes.toFixed(1)}
|
|
146
|
+
<span className="text-lg text-muted-foreground"> min</span>
|
|
147
|
+
</div>
|
|
148
|
+
<ErrorBudgetBar
|
|
149
|
+
consumedPercent={100 - status.errorBudgetRemainingPercent}
|
|
150
|
+
warningThreshold={
|
|
151
|
+
objective.burnRateThresholds.warningPercent
|
|
152
|
+
}
|
|
153
|
+
criticalThreshold={
|
|
154
|
+
objective.burnRateThresholds.criticalPercent
|
|
155
|
+
}
|
|
156
|
+
label="Budget consumption"
|
|
157
|
+
/>
|
|
158
|
+
</CardContent>
|
|
159
|
+
</Card>
|
|
160
|
+
|
|
161
|
+
<Card>
|
|
162
|
+
<CardHeader className="pb-2">
|
|
163
|
+
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
|
164
|
+
<TrendingUp className="w-4 h-4" />
|
|
165
|
+
Burn Rate
|
|
166
|
+
</CardTitle>
|
|
167
|
+
</CardHeader>
|
|
168
|
+
<CardContent>
|
|
169
|
+
<div className="text-3xl font-bold">
|
|
170
|
+
<BurnRateIndicator burnRate={status.burnRate} />
|
|
171
|
+
</div>
|
|
172
|
+
<div className="text-sm text-muted-foreground mt-1">
|
|
173
|
+
{status.isBreaching ? (
|
|
174
|
+
<Badge variant="destructive">Breaching</Badge>
|
|
175
|
+
) : status.hasOpenDowntime ? (
|
|
176
|
+
<Badge variant="warning">Degraded</Badge>
|
|
177
|
+
) : (
|
|
178
|
+
<Badge variant="success">Within Budget</Badge>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
</CardContent>
|
|
182
|
+
</Card>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{/* Streak & Achievements Row */}
|
|
186
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
187
|
+
<Card>
|
|
188
|
+
<CardHeader className="pb-2">
|
|
189
|
+
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
|
190
|
+
<Flame className="w-4 h-4" />
|
|
191
|
+
Compliance Streak
|
|
192
|
+
</CardTitle>
|
|
193
|
+
</CardHeader>
|
|
194
|
+
<CardContent>
|
|
195
|
+
{streak ? (
|
|
196
|
+
<StreakCounter
|
|
197
|
+
currentStreak={streak.currentStreak}
|
|
198
|
+
bestStreak={streak.bestStreak}
|
|
199
|
+
/>
|
|
200
|
+
) : (
|
|
201
|
+
<div className="text-sm text-muted-foreground">
|
|
202
|
+
Streaks will be calculated after the first daily snapshot
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</CardContent>
|
|
206
|
+
</Card>
|
|
207
|
+
|
|
208
|
+
<Card>
|
|
209
|
+
<CardHeader className="pb-2">
|
|
210
|
+
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
|
211
|
+
<Award className="w-4 h-4" />
|
|
212
|
+
Achievements
|
|
213
|
+
</CardTitle>
|
|
214
|
+
</CardHeader>
|
|
215
|
+
<CardContent>
|
|
216
|
+
{achievements.length > 0 ? (
|
|
217
|
+
<div className="flex flex-wrap gap-2">
|
|
218
|
+
{achievements.map((a) => (
|
|
219
|
+
<AchievementBadge
|
|
220
|
+
key={a.id}
|
|
221
|
+
achievement={a.achievement}
|
|
222
|
+
unlockedAt={a.unlockedAt}
|
|
223
|
+
/>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
) : (
|
|
227
|
+
<NoAchievements />
|
|
228
|
+
)}
|
|
229
|
+
</CardContent>
|
|
230
|
+
</Card>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{/* Attribution Chart */}
|
|
234
|
+
{status.attribution.length > 0 && (
|
|
235
|
+
<Card>
|
|
236
|
+
<CardHeader>
|
|
237
|
+
<CardTitle className="text-sm flex items-center gap-2">
|
|
238
|
+
<BarChart3 className="w-4 h-4" />
|
|
239
|
+
Budget Attribution
|
|
240
|
+
</CardTitle>
|
|
241
|
+
</CardHeader>
|
|
242
|
+
<CardContent>
|
|
243
|
+
<AttributionChart
|
|
244
|
+
attribution={status.attribution}
|
|
245
|
+
totalBudgetMinutes={status.errorBudgetTotalMinutes}
|
|
246
|
+
/>
|
|
247
|
+
</CardContent>
|
|
248
|
+
</Card>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{/* Availability Trend Chart */}
|
|
252
|
+
<Card>
|
|
253
|
+
<CardHeader>
|
|
254
|
+
<CardTitle className="text-sm flex items-center gap-2">
|
|
255
|
+
<LineChart className="w-4 h-4" />
|
|
256
|
+
Availability Trend
|
|
257
|
+
</CardTitle>
|
|
258
|
+
</CardHeader>
|
|
259
|
+
<CardContent>
|
|
260
|
+
<SloTrendChart
|
|
261
|
+
snapshots={snapshotsData?.snapshots ?? []}
|
|
262
|
+
target={objective.target}
|
|
263
|
+
/>
|
|
264
|
+
</CardContent>
|
|
265
|
+
</Card>
|
|
266
|
+
|
|
267
|
+
{/* Downtime Events Timeline */}
|
|
268
|
+
<Card>
|
|
269
|
+
<CardHeader>
|
|
270
|
+
<CardTitle className="text-sm flex items-center gap-2">
|
|
271
|
+
<Clock className="w-4 h-4" />
|
|
272
|
+
Recent Downtime Events
|
|
273
|
+
</CardTitle>
|
|
274
|
+
</CardHeader>
|
|
275
|
+
<CardContent>
|
|
276
|
+
<DowntimeTimeline events={events ?? []} />
|
|
277
|
+
</CardContent>
|
|
278
|
+
</Card>
|
|
279
|
+
</PageLayout>
|
|
280
|
+
);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export const SloDetailPage = wrapInSuspense(SloDetailPageContent);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { usePluginClient, wrapInSuspense } from "@checkstack/frontend-api";
|
|
3
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
4
|
+
import { SloApi } from "../api";
|
|
5
|
+
import { SLO_STATUS_CHANGED, sloRoutes } from "@checkstack/slo-common";
|
|
6
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
7
|
+
import { ErrorBudgetBar } from "../components/ErrorBudgetBar";
|
|
8
|
+
import { BurnRateIndicator } from "../components/BurnRateIndicator";
|
|
9
|
+
import { MilestoneFeed } from "../components/MilestoneFeed";
|
|
10
|
+
import {
|
|
11
|
+
Card,
|
|
12
|
+
CardContent,
|
|
13
|
+
CardHeader,
|
|
14
|
+
CardTitle,
|
|
15
|
+
PageLayout,
|
|
16
|
+
EmptyState,
|
|
17
|
+
LoadingSpinner,
|
|
18
|
+
Badge,
|
|
19
|
+
} from "@checkstack/ui";
|
|
20
|
+
import { Target, ArrowRight } from "lucide-react";
|
|
21
|
+
import { Link } from "react-router-dom";
|
|
22
|
+
import { resolveRoute } from "@checkstack/common";
|
|
23
|
+
|
|
24
|
+
const SloOverviewPageContent: React.FC = () => {
|
|
25
|
+
const sloClient = usePluginClient(SloApi);
|
|
26
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
data: objectivesData,
|
|
30
|
+
isLoading: objectivesLoading,
|
|
31
|
+
refetch,
|
|
32
|
+
} = sloClient.listObjectives.useQuery({});
|
|
33
|
+
|
|
34
|
+
const { data: systemsData, isLoading: systemsLoading } =
|
|
35
|
+
catalogClient.getSystems.useQuery({});
|
|
36
|
+
|
|
37
|
+
useSignal(SLO_STATUS_CHANGED, () => {
|
|
38
|
+
void refetch();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const objectives = objectivesData?.objectives ?? [];
|
|
42
|
+
const isLoading = objectivesLoading || systemsLoading;
|
|
43
|
+
|
|
44
|
+
const systemNameMap = useMemo(() => {
|
|
45
|
+
const map = new Map<string, string>();
|
|
46
|
+
for (const system of systemsData?.systems ?? []) {
|
|
47
|
+
map.set(system.id, system.name);
|
|
48
|
+
}
|
|
49
|
+
return map;
|
|
50
|
+
}, [systemsData]);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<PageLayout
|
|
54
|
+
title="SLO Dashboard"
|
|
55
|
+
subtitle="Service Level Objective performance across all systems"
|
|
56
|
+
icon={Target}
|
|
57
|
+
actions={
|
|
58
|
+
<Link
|
|
59
|
+
to={resolveRoute(sloRoutes.routes.config)}
|
|
60
|
+
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
|
61
|
+
>
|
|
62
|
+
Manage SLOs
|
|
63
|
+
<ArrowRight className="w-4 h-4" />
|
|
64
|
+
</Link>
|
|
65
|
+
}
|
|
66
|
+
>
|
|
67
|
+
{isLoading ? (
|
|
68
|
+
<div className="p-12 flex justify-center">
|
|
69
|
+
<LoadingSpinner />
|
|
70
|
+
</div>
|
|
71
|
+
) : objectives.length === 0 ? (
|
|
72
|
+
<EmptyState
|
|
73
|
+
title="No SLOs configured"
|
|
74
|
+
description="Define Service Level Objectives to track reliability."
|
|
75
|
+
/>
|
|
76
|
+
) : (
|
|
77
|
+
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-6">
|
|
78
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
79
|
+
{objectives.map((item) => (
|
|
80
|
+
<Link
|
|
81
|
+
key={item.objective.id}
|
|
82
|
+
to={resolveRoute(sloRoutes.routes.detail, {
|
|
83
|
+
sloId: item.objective.id,
|
|
84
|
+
})}
|
|
85
|
+
className="block no-underline"
|
|
86
|
+
>
|
|
87
|
+
<Card className="transition-colors hover:border-primary/50">
|
|
88
|
+
<CardHeader className="pb-3">
|
|
89
|
+
<div className="flex items-center justify-between">
|
|
90
|
+
<CardTitle className="text-sm font-medium">
|
|
91
|
+
{systemNameMap.get(item.objective.systemId) ??
|
|
92
|
+
item.objective.systemId}
|
|
93
|
+
</CardTitle>
|
|
94
|
+
{item.status.isBreaching ? (
|
|
95
|
+
<Badge variant="destructive">Breaching</Badge>
|
|
96
|
+
) : item.status.hasOpenDowntime ? (
|
|
97
|
+
<Badge variant="warning">Degraded</Badge>
|
|
98
|
+
) : item.status.errorBudgetRemainingPercent <= 20 ? (
|
|
99
|
+
<Badge variant="warning">At Risk</Badge>
|
|
100
|
+
) : (
|
|
101
|
+
<Badge variant="success">Healthy</Badge>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
</CardHeader>
|
|
105
|
+
<CardContent>
|
|
106
|
+
<div className="text-lg font-semibold mb-2">
|
|
107
|
+
{item.objective.target}% / {item.objective.windowDays}d
|
|
108
|
+
</div>
|
|
109
|
+
<ErrorBudgetBar
|
|
110
|
+
consumedPercent={
|
|
111
|
+
100 - item.status.errorBudgetRemainingPercent
|
|
112
|
+
}
|
|
113
|
+
warningThreshold={
|
|
114
|
+
item.objective.burnRateThresholds.warningPercent
|
|
115
|
+
}
|
|
116
|
+
criticalThreshold={
|
|
117
|
+
item.objective.burnRateThresholds.criticalPercent
|
|
118
|
+
}
|
|
119
|
+
/>
|
|
120
|
+
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
|
|
121
|
+
<span>
|
|
122
|
+
{item.status.currentAvailability?.toFixed(3) ?? "—"}%
|
|
123
|
+
</span>
|
|
124
|
+
<BurnRateIndicator burnRate={item.status.burnRate} />
|
|
125
|
+
</div>
|
|
126
|
+
</CardContent>
|
|
127
|
+
</Card>
|
|
128
|
+
</Link>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
<div className="lg:sticky lg:top-4 self-start">
|
|
132
|
+
<MilestoneFeed />
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</PageLayout>
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const SloOverviewPage = wrapInSuspense(SloOverviewPageContent);
|