@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,107 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import { SloApi } from "../api";
|
|
4
|
+
import { formatDistanceToNow } from "date-fns";
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
|
|
6
|
+
import { Trophy } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
const ACHIEVEMENT_EMOJI: Record<string, string> = {
|
|
9
|
+
first_steps: "๐ฏ",
|
|
10
|
+
iron_uptime: "๐ก๏ธ",
|
|
11
|
+
diamond_uptime: "๐",
|
|
12
|
+
budget_miser: "๐ฐ",
|
|
13
|
+
clean_sheet: "โจ",
|
|
14
|
+
nines_club: "๐",
|
|
15
|
+
cascade_breaker: "๐",
|
|
16
|
+
full_coverage: "๐",
|
|
17
|
+
rapid_recovery: "โก",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ACHIEVEMENT_LABEL: Record<string, string> = {
|
|
21
|
+
first_steps: "First Steps",
|
|
22
|
+
iron_uptime: "Iron Uptime",
|
|
23
|
+
diamond_uptime: "Diamond Uptime",
|
|
24
|
+
budget_miser: "Budget Miser",
|
|
25
|
+
clean_sheet: "Clean Sheet",
|
|
26
|
+
nines_club: "Nines Club",
|
|
27
|
+
cascade_breaker: "Cascade Breaker",
|
|
28
|
+
full_coverage: "Full Coverage",
|
|
29
|
+
rapid_recovery: "Rapid Recovery",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface MilestoneFeedProps {
|
|
33
|
+
limit?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Organization-wide milestone feed showing recent achievement unlocks.
|
|
38
|
+
* Fetches from getRecentMilestones and renders a chronological feed.
|
|
39
|
+
*/
|
|
40
|
+
export const MilestoneFeed: React.FC<MilestoneFeedProps> = ({
|
|
41
|
+
limit = 10,
|
|
42
|
+
}) => {
|
|
43
|
+
const sloClient = usePluginClient(SloApi);
|
|
44
|
+
|
|
45
|
+
const { data } = sloClient.getRecentMilestones.useQuery({ limit });
|
|
46
|
+
|
|
47
|
+
const milestones = data?.milestones;
|
|
48
|
+
|
|
49
|
+
if (!milestones || milestones.length === 0) {
|
|
50
|
+
return (
|
|
51
|
+
<Card>
|
|
52
|
+
<CardHeader className="pb-2">
|
|
53
|
+
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
|
54
|
+
<Trophy className="w-4 h-4" />
|
|
55
|
+
Recent Milestones
|
|
56
|
+
</CardTitle>
|
|
57
|
+
</CardHeader>
|
|
58
|
+
<CardContent>
|
|
59
|
+
<div className="text-sm text-muted-foreground text-center py-4">
|
|
60
|
+
No milestones achieved yet
|
|
61
|
+
</div>
|
|
62
|
+
</CardContent>
|
|
63
|
+
</Card>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Card>
|
|
69
|
+
<CardHeader className="pb-2">
|
|
70
|
+
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
|
71
|
+
<Trophy className="w-4 h-4" />
|
|
72
|
+
Recent Milestones
|
|
73
|
+
</CardTitle>
|
|
74
|
+
</CardHeader>
|
|
75
|
+
<CardContent>
|
|
76
|
+
<div className="space-y-3">
|
|
77
|
+
{milestones.map((milestone, index) => {
|
|
78
|
+
const emoji =
|
|
79
|
+
ACHIEVEMENT_EMOJI[milestone.achievement] ?? "๐
";
|
|
80
|
+
const label =
|
|
81
|
+
ACHIEVEMENT_LABEL[milestone.achievement] ??
|
|
82
|
+
milestone.achievement;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
key={`${milestone.systemId}-${milestone.achievement}-${index}`}
|
|
87
|
+
className="flex items-start gap-3"
|
|
88
|
+
>
|
|
89
|
+
<span className="text-lg shrink-0">{emoji}</span>
|
|
90
|
+
<div className="min-w-0 flex-1">
|
|
91
|
+
<div className="text-sm font-medium">{label}</div>
|
|
92
|
+
<div className="text-xs text-muted-foreground">
|
|
93
|
+
{milestone.systemName ?? milestone.systemId}
|
|
94
|
+
<span className="mx-1">ยท</span>
|
|
95
|
+
{formatDistanceToNow(new Date(milestone.unlockedAt), {
|
|
96
|
+
addSuffix: true,
|
|
97
|
+
})}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</div>
|
|
104
|
+
</CardContent>
|
|
105
|
+
</Card>
|
|
106
|
+
);
|
|
107
|
+
};
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import { SloApi, type SloObjective } from "../api";
|
|
4
|
+
import type { System } from "@checkstack/catalog-common";
|
|
5
|
+
import type { DependencyExclusionMode } from "@checkstack/slo-common";
|
|
6
|
+
import { HealthCheckApi } from "@checkstack/healthcheck-common";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
DialogFooter,
|
|
14
|
+
Button,
|
|
15
|
+
Input,
|
|
16
|
+
Label,
|
|
17
|
+
useToast,
|
|
18
|
+
Select,
|
|
19
|
+
SelectTrigger,
|
|
20
|
+
SelectValue,
|
|
21
|
+
SelectContent,
|
|
22
|
+
SelectItem,
|
|
23
|
+
} from "@checkstack/ui";
|
|
24
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
open: boolean;
|
|
28
|
+
onOpenChange: (open: boolean) => void;
|
|
29
|
+
objective?: SloObjective;
|
|
30
|
+
systems: System[];
|
|
31
|
+
onSave: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const SloEditor: React.FC<Props> = ({
|
|
35
|
+
open,
|
|
36
|
+
onOpenChange,
|
|
37
|
+
objective,
|
|
38
|
+
systems,
|
|
39
|
+
onSave,
|
|
40
|
+
}) => {
|
|
41
|
+
const sloClient = usePluginClient(SloApi);
|
|
42
|
+
const hcClient = usePluginClient(HealthCheckApi);
|
|
43
|
+
const toast = useToast();
|
|
44
|
+
|
|
45
|
+
// Form state
|
|
46
|
+
const [systemId, setSystemId] = useState("");
|
|
47
|
+
const [target, setTarget] = useState("99.9");
|
|
48
|
+
const [windowDays, setWindowDays] = useState("30");
|
|
49
|
+
const [dependencyExclusion, setDependencyExclusion] =
|
|
50
|
+
useState<DependencyExclusionMode>("strict");
|
|
51
|
+
const [warningPercent, setWarningPercent] = useState("50");
|
|
52
|
+
const [criticalPercent, setCriticalPercent] = useState("80");
|
|
53
|
+
const [healthCheckConfigurationId, setHealthCheckConfigurationId] =
|
|
54
|
+
useState<string | undefined>();
|
|
55
|
+
// Fetch health check associations for the selected system
|
|
56
|
+
const effectiveSystemId = systemId || objective?.systemId;
|
|
57
|
+
const { data: hcAssociationsData } =
|
|
58
|
+
hcClient.getSystemAssociations.useQuery(
|
|
59
|
+
{ systemId: effectiveSystemId ?? "" },
|
|
60
|
+
{ enabled: !!effectiveSystemId },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Reset form when objective changes
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (objective) {
|
|
66
|
+
setSystemId(objective.systemId);
|
|
67
|
+
setTarget(String(objective.target));
|
|
68
|
+
setWindowDays(String(objective.windowDays));
|
|
69
|
+
setDependencyExclusion(objective.dependencyExclusion);
|
|
70
|
+
setWarningPercent(
|
|
71
|
+
String(objective.burnRateThresholds.warningPercent),
|
|
72
|
+
);
|
|
73
|
+
setCriticalPercent(
|
|
74
|
+
String(objective.burnRateThresholds.criticalPercent),
|
|
75
|
+
);
|
|
76
|
+
setHealthCheckConfigurationId(
|
|
77
|
+
objective.healthCheckConfigurationId ?? undefined,
|
|
78
|
+
);
|
|
79
|
+
} else {
|
|
80
|
+
setSystemId("");
|
|
81
|
+
setTarget("99.9");
|
|
82
|
+
setWindowDays("30");
|
|
83
|
+
setDependencyExclusion("strict");
|
|
84
|
+
setWarningPercent("50");
|
|
85
|
+
setCriticalPercent("80");
|
|
86
|
+
setHealthCheckConfigurationId(undefined);
|
|
87
|
+
}
|
|
88
|
+
}, [objective, open]);
|
|
89
|
+
|
|
90
|
+
// Mutations
|
|
91
|
+
const createMutation = sloClient.createObjective.useMutation({
|
|
92
|
+
onSuccess: () => {
|
|
93
|
+
toast.success("SLO objective created");
|
|
94
|
+
onSave();
|
|
95
|
+
},
|
|
96
|
+
onError: (error) => {
|
|
97
|
+
toast.error(extractErrorMessage(error, "Failed to create"));
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const updateMutation = sloClient.updateObjective.useMutation({
|
|
102
|
+
onSuccess: () => {
|
|
103
|
+
toast.success("SLO objective updated");
|
|
104
|
+
onSave();
|
|
105
|
+
},
|
|
106
|
+
onError: (error) => {
|
|
107
|
+
toast.error(extractErrorMessage(error, "Failed to update"));
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const handleSubmit = () => {
|
|
112
|
+
const targetNum = Number.parseFloat(target);
|
|
113
|
+
const windowNum = Number.parseInt(windowDays, 10);
|
|
114
|
+
const warningNum = Number.parseFloat(warningPercent);
|
|
115
|
+
const criticalNum = Number.parseFloat(criticalPercent);
|
|
116
|
+
|
|
117
|
+
if (!systemId) {
|
|
118
|
+
toast.error("Please select a system");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (Number.isNaN(targetNum) || targetNum < 0 || targetNum > 100) {
|
|
122
|
+
toast.error("Target must be between 0 and 100");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (Number.isNaN(windowNum) || windowNum < 1) {
|
|
126
|
+
toast.error("Window must be at least 1 day");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (objective) {
|
|
131
|
+
updateMutation.mutate({
|
|
132
|
+
id: objective.id,
|
|
133
|
+
target: targetNum,
|
|
134
|
+
windowDays: windowNum,
|
|
135
|
+
dependencyExclusion,
|
|
136
|
+
burnRateThresholds: {
|
|
137
|
+
warningPercent: warningNum,
|
|
138
|
+
criticalPercent: criticalNum,
|
|
139
|
+
fastBurnMultiplier: 5,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
} else {
|
|
143
|
+
createMutation.mutate({
|
|
144
|
+
systemId,
|
|
145
|
+
healthCheckConfigurationId,
|
|
146
|
+
target: targetNum,
|
|
147
|
+
windowDays: windowNum,
|
|
148
|
+
dependencyExclusion,
|
|
149
|
+
burnRateThresholds: {
|
|
150
|
+
warningPercent: warningNum,
|
|
151
|
+
criticalPercent: criticalNum,
|
|
152
|
+
fastBurnMultiplier: 5,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const saving = createMutation.isPending || updateMutation.isPending;
|
|
159
|
+
|
|
160
|
+
const systemAssociations = hcAssociationsData ?? [];
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
164
|
+
<DialogContent>
|
|
165
|
+
<DialogHeader>
|
|
166
|
+
<DialogTitle>
|
|
167
|
+
{objective ? "Edit SLO Objective" : "Create SLO Objective"}
|
|
168
|
+
</DialogTitle>
|
|
169
|
+
<DialogDescription className="sr-only">
|
|
170
|
+
{objective
|
|
171
|
+
? "Modify SLO objective settings"
|
|
172
|
+
: "Define a new Service Level Objective for a system"}
|
|
173
|
+
</DialogDescription>
|
|
174
|
+
</DialogHeader>
|
|
175
|
+
|
|
176
|
+
<div className="grid gap-4 py-4">
|
|
177
|
+
{/* System selector - only for create */}
|
|
178
|
+
{!objective && (
|
|
179
|
+
<div className="grid gap-2">
|
|
180
|
+
<Label htmlFor="slo-system">System</Label>
|
|
181
|
+
<Select value={systemId} onValueChange={setSystemId}>
|
|
182
|
+
<SelectTrigger id="slo-system">
|
|
183
|
+
<SelectValue placeholder="Select a system" />
|
|
184
|
+
</SelectTrigger>
|
|
185
|
+
<SelectContent>
|
|
186
|
+
{systems.map((system) => (
|
|
187
|
+
<SelectItem key={system.id} value={system.id}>
|
|
188
|
+
{system.name}
|
|
189
|
+
</SelectItem>
|
|
190
|
+
))}
|
|
191
|
+
</SelectContent>
|
|
192
|
+
</Select>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{/* Health Check scope - only show when system has associated HCs */}
|
|
197
|
+
{systemAssociations.length > 0 && (
|
|
198
|
+
<div className="grid gap-2">
|
|
199
|
+
<Label>Health Check Scope</Label>
|
|
200
|
+
<Select
|
|
201
|
+
value={healthCheckConfigurationId ?? "__all__"}
|
|
202
|
+
onValueChange={(v) =>
|
|
203
|
+
setHealthCheckConfigurationId(
|
|
204
|
+
v === "__all__" ? undefined : v,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
>
|
|
208
|
+
<SelectTrigger>
|
|
209
|
+
<SelectValue />
|
|
210
|
+
</SelectTrigger>
|
|
211
|
+
<SelectContent>
|
|
212
|
+
<SelectItem value="__all__">
|
|
213
|
+
All health checks (system-global)
|
|
214
|
+
</SelectItem>
|
|
215
|
+
{systemAssociations.map((assoc) => (
|
|
216
|
+
<SelectItem
|
|
217
|
+
key={assoc.configurationId}
|
|
218
|
+
value={assoc.configurationId}
|
|
219
|
+
>
|
|
220
|
+
{assoc.configurationName}
|
|
221
|
+
</SelectItem>
|
|
222
|
+
))}
|
|
223
|
+
</SelectContent>
|
|
224
|
+
</Select>
|
|
225
|
+
<p className="text-xs text-muted-foreground">
|
|
226
|
+
{healthCheckConfigurationId
|
|
227
|
+
? "This SLO only tracks downtime from the selected health check."
|
|
228
|
+
: "This SLO tracks all health checks for the system."}
|
|
229
|
+
</p>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
{/* Target */}
|
|
235
|
+
<div className="grid gap-2">
|
|
236
|
+
<Label htmlFor="slo-target">Availability Target (%)</Label>
|
|
237
|
+
<Input
|
|
238
|
+
id="slo-target"
|
|
239
|
+
type="number"
|
|
240
|
+
step="0.01"
|
|
241
|
+
min="0"
|
|
242
|
+
max="100"
|
|
243
|
+
value={target}
|
|
244
|
+
onChange={(e) => setTarget(e.target.value)}
|
|
245
|
+
placeholder="99.9"
|
|
246
|
+
/>
|
|
247
|
+
<p className="text-xs text-muted-foreground">
|
|
248
|
+
Common targets: 99.0% (3.65 days/year), 99.9% (8.76
|
|
249
|
+
hours/year), 99.95% (4.38 hours/year)
|
|
250
|
+
</p>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{/* Rolling window */}
|
|
254
|
+
<div className="grid gap-2">
|
|
255
|
+
<Label htmlFor="slo-window">Rolling Window (days)</Label>
|
|
256
|
+
<Input
|
|
257
|
+
id="slo-window"
|
|
258
|
+
type="number"
|
|
259
|
+
min="1"
|
|
260
|
+
value={windowDays}
|
|
261
|
+
onChange={(e) => setWindowDays(e.target.value)}
|
|
262
|
+
placeholder="30"
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
{/* Dependency exclusion mode */}
|
|
267
|
+
<div className="grid gap-2">
|
|
268
|
+
<Label>Dependency Exclusion Mode</Label>
|
|
269
|
+
<Select
|
|
270
|
+
value={dependencyExclusion}
|
|
271
|
+
onValueChange={(v) =>
|
|
272
|
+
setDependencyExclusion(v as DependencyExclusionMode)
|
|
273
|
+
}
|
|
274
|
+
>
|
|
275
|
+
<SelectTrigger>
|
|
276
|
+
<SelectValue />
|
|
277
|
+
</SelectTrigger>
|
|
278
|
+
<SelectContent>
|
|
279
|
+
<SelectItem value="strict">
|
|
280
|
+
Strict โ All downtime counts
|
|
281
|
+
</SelectItem>
|
|
282
|
+
<SelectItem value="self-only">
|
|
283
|
+
Self-Only โ Exclude upstream-attributed downtime
|
|
284
|
+
</SelectItem>
|
|
285
|
+
</SelectContent>
|
|
286
|
+
</Select>
|
|
287
|
+
<p className="text-xs text-muted-foreground">
|
|
288
|
+
{dependencyExclusion === "strict"
|
|
289
|
+
? "All downtime counts against the error budget, regardless of cause."
|
|
290
|
+
: "Only self-caused downtime counts. When an upstream dependency is also down, that time is excluded."}
|
|
291
|
+
</p>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Burn rate thresholds */}
|
|
295
|
+
<div className="grid grid-cols-2 gap-4">
|
|
296
|
+
<div className="grid gap-2">
|
|
297
|
+
<Label htmlFor="slo-warning">Warning Threshold (%)</Label>
|
|
298
|
+
<Input
|
|
299
|
+
id="slo-warning"
|
|
300
|
+
type="number"
|
|
301
|
+
min="0"
|
|
302
|
+
max="100"
|
|
303
|
+
value={warningPercent}
|
|
304
|
+
onChange={(e) => setWarningPercent(e.target.value)}
|
|
305
|
+
/>
|
|
306
|
+
</div>
|
|
307
|
+
<div className="grid gap-2">
|
|
308
|
+
<Label htmlFor="slo-critical">Critical Threshold (%)</Label>
|
|
309
|
+
<Input
|
|
310
|
+
id="slo-critical"
|
|
311
|
+
type="number"
|
|
312
|
+
min="0"
|
|
313
|
+
max="100"
|
|
314
|
+
value={criticalPercent}
|
|
315
|
+
onChange={(e) => setCriticalPercent(e.target.value)}
|
|
316
|
+
/>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<DialogFooter>
|
|
322
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
323
|
+
Cancel
|
|
324
|
+
</Button>
|
|
325
|
+
<Button onClick={handleSubmit} disabled={saving}>
|
|
326
|
+
{saving ? "Saving..." : objective ? "Update" : "Create"}
|
|
327
|
+
</Button>
|
|
328
|
+
</DialogFooter>
|
|
329
|
+
</DialogContent>
|
|
330
|
+
</Dialog>
|
|
331
|
+
);
|
|
332
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Link } from "react-router-dom";
|
|
2
|
+
import { Target, Settings } from "lucide-react";
|
|
3
|
+
import type { UserMenuItemsContext } from "@checkstack/frontend-api";
|
|
4
|
+
import { DropdownMenuItem } from "@checkstack/ui";
|
|
5
|
+
import { resolveRoute } from "@checkstack/common";
|
|
6
|
+
import { sloRoutes, sloAccess, pluginMetadata } from "@checkstack/slo-common";
|
|
7
|
+
|
|
8
|
+
export const SloMenuItems = ({
|
|
9
|
+
accessRules: userPerms,
|
|
10
|
+
}: UserMenuItemsContext) => {
|
|
11
|
+
const qualifiedId = `${pluginMetadata.pluginId}.${sloAccess.slo.manage.id}`;
|
|
12
|
+
const canManage = userPerms.includes("*") || userPerms.includes(qualifiedId);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<Link to={resolveRoute(sloRoutes.routes.overview)}>
|
|
17
|
+
<DropdownMenuItem icon={<Target className="w-4 h-4" />}>
|
|
18
|
+
SLO Dashboard
|
|
19
|
+
</DropdownMenuItem>
|
|
20
|
+
</Link>
|
|
21
|
+
{canManage && (
|
|
22
|
+
<Link to={resolveRoute(sloRoutes.routes.config)}>
|
|
23
|
+
<DropdownMenuItem icon={<Settings className="w-4 h-4" />}>
|
|
24
|
+
SLO Management
|
|
25
|
+
</DropdownMenuItem>
|
|
26
|
+
</Link>
|
|
27
|
+
)}
|
|
28
|
+
</>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
|
|
3
|
+
interface Snapshot {
|
|
4
|
+
date: Date;
|
|
5
|
+
availabilityPercent: number;
|
|
6
|
+
budgetRemainingPercent: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface SloTrendChartProps {
|
|
10
|
+
snapshots: Snapshot[];
|
|
11
|
+
target: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const CHART_HEIGHT = 160;
|
|
15
|
+
const CHART_PADDING = { top: 8, right: 12, bottom: 24, left: 48 };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pure SVG line chart showing SLO availability trend from daily snapshots.
|
|
19
|
+
* Draws a target line, area fill, and data line with responsive sizing.
|
|
20
|
+
*/
|
|
21
|
+
export const SloTrendChart: React.FC<SloTrendChartProps> = ({
|
|
22
|
+
snapshots,
|
|
23
|
+
target,
|
|
24
|
+
}) => {
|
|
25
|
+
const chartData = useMemo(() => {
|
|
26
|
+
if (snapshots.length === 0) return;
|
|
27
|
+
|
|
28
|
+
const sorted = snapshots.toSorted(
|
|
29
|
+
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Y-axis range: ensure target and all data points are visible
|
|
33
|
+
const allValues = sorted.map((s) => s.availabilityPercent);
|
|
34
|
+
const minVal = Math.min(...allValues, target);
|
|
35
|
+
const maxVal = Math.max(...allValues, 100);
|
|
36
|
+
const yMin = Math.max(Math.floor(minVal - 0.5), 0);
|
|
37
|
+
const yMax = Math.min(Math.ceil(maxVal + 0.5), 100);
|
|
38
|
+
const yRange = yMax - yMin || 1;
|
|
39
|
+
|
|
40
|
+
return { sorted, yMin, yMax, yRange };
|
|
41
|
+
}, [snapshots, target]);
|
|
42
|
+
|
|
43
|
+
if (!chartData || chartData.sorted.length < 2) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="text-sm text-muted-foreground text-center py-8">
|
|
46
|
+
{chartData && chartData.sorted.length === 1
|
|
47
|
+
? "Need at least 2 daily snapshots to render a trend chart"
|
|
48
|
+
: "No daily snapshot data available yet"}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { sorted, yMin, yMax, yRange } = chartData;
|
|
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
|
+
}));
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<svg
|
|
96
|
+
viewBox={`0 0 100 ${CHART_HEIGHT}`}
|
|
97
|
+
className="w-full"
|
|
98
|
+
preserveAspectRatio="none"
|
|
99
|
+
style={{ height: CHART_HEIGHT }}
|
|
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}
|
|
177
|
+
>
|
|
178
|
+
{item.label}
|
|
179
|
+
</text>
|
|
180
|
+
))}
|
|
181
|
+
</svg>
|
|
182
|
+
);
|
|
183
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Flame, Trophy } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface StreakCounterProps {
|
|
5
|
+
currentStreak: number;
|
|
6
|
+
bestStreak: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fire-themed streak counter showing consecutive SLO-compliant days.
|
|
11
|
+
* Current streak displays with a flame icon; best streak shown below.
|
|
12
|
+
*/
|
|
13
|
+
export const StreakCounter: React.FC<StreakCounterProps> = ({
|
|
14
|
+
currentStreak,
|
|
15
|
+
bestStreak,
|
|
16
|
+
}) => {
|
|
17
|
+
const getFlameColor = () => {
|
|
18
|
+
if (currentStreak >= 90) return "text-amber-400";
|
|
19
|
+
if (currentStreak >= 30) return "text-orange-500";
|
|
20
|
+
if (currentStreak >= 7) return "text-orange-400";
|
|
21
|
+
return "text-muted-foreground";
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex items-center gap-4">
|
|
26
|
+
<div className="flex items-center gap-2">
|
|
27
|
+
<Flame
|
|
28
|
+
className={`w-5 h-5 ${getFlameColor()} ${currentStreak > 0 ? "animate-pulse" : ""}`}
|
|
29
|
+
/>
|
|
30
|
+
<span className="text-2xl font-bold tabular-nums">
|
|
31
|
+
{currentStreak}
|
|
32
|
+
</span>
|
|
33
|
+
<span className="text-sm text-muted-foreground">
|
|
34
|
+
{currentStreak === 1 ? "day" : "days"}
|
|
35
|
+
</span>
|
|
36
|
+
</div>
|
|
37
|
+
{bestStreak > 0 && bestStreak > currentStreak && (
|
|
38
|
+
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
|
39
|
+
<Trophy className="w-3.5 h-3.5" />
|
|
40
|
+
<span>Best: {bestStreak}d</span>
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
};
|