@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,51 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
|
|
3
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
4
|
+
import { SystemStateBadgesSlot } from "@checkstack/catalog-common";
|
|
5
|
+
import { SloApi } from "../api";
|
|
6
|
+
import { SLO_STATUS_CHANGED } from "@checkstack/slo-common";
|
|
7
|
+
import { Badge } from "@checkstack/ui";
|
|
8
|
+
|
|
9
|
+
type Props = SlotContext<typeof SystemStateBadgesSlot>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Badge displaying if a system's SLO is breaching, degraded, or at risk.
|
|
13
|
+
* Rendered in SystemStateBadgesSlot on the catalog/dashboard.
|
|
14
|
+
*/
|
|
15
|
+
export const SystemSloBadge: React.FC<Props> = ({ system }) => {
|
|
16
|
+
const sloClient = usePluginClient(SloApi);
|
|
17
|
+
|
|
18
|
+
const { data, refetch } = sloClient.getObjectivesForSystem.useQuery(
|
|
19
|
+
{ systemId: system?.id ?? "" },
|
|
20
|
+
{ enabled: !!system?.id },
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
useSignal(SLO_STATUS_CHANGED, ({ systemId }) => {
|
|
24
|
+
if (system?.id && systemId === system.id) {
|
|
25
|
+
void refetch();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!data || data.length === 0) return;
|
|
30
|
+
|
|
31
|
+
// Determine worst status across all SLOs for this system
|
|
32
|
+
const hasBreaching = data.some((item) => item.status.isBreaching);
|
|
33
|
+
const hasDegraded = data.some((item) => item.status.hasOpenDowntime);
|
|
34
|
+
const hasAtRisk = data.some(
|
|
35
|
+
(item) => item.status.errorBudgetRemainingPercent <= 20,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (hasBreaching) {
|
|
39
|
+
return <Badge variant="destructive">SLO Breaching</Badge>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (hasDegraded) {
|
|
43
|
+
return <Badge variant="warning">SLO Degraded</Badge>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (hasAtRisk) {
|
|
47
|
+
return <Badge variant="warning">SLO At Risk</Badge>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return;
|
|
51
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
|
|
3
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
4
|
+
import { SystemDetailsTopSlot } from "@checkstack/catalog-common";
|
|
5
|
+
import { SloApi } from "../api";
|
|
6
|
+
import { SLO_STATUS_CHANGED, sloRoutes } from "@checkstack/slo-common";
|
|
7
|
+
import { resolveRoute } from "@checkstack/common";
|
|
8
|
+
import { ErrorBudgetBar } from "./ErrorBudgetBar";
|
|
9
|
+
import { BurnRateIndicator } from "./BurnRateIndicator";
|
|
10
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
|
|
11
|
+
import { Target } from "lucide-react";
|
|
12
|
+
import { Link } from "react-router-dom";
|
|
13
|
+
|
|
14
|
+
type Props = SlotContext<typeof SystemDetailsTopSlot>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SLO panel embedded in the system detail page.
|
|
18
|
+
* Shows all SLO objectives for the system with error budget bars.
|
|
19
|
+
*/
|
|
20
|
+
export const SystemSloPanel: React.FC<Props> = ({ system }) => {
|
|
21
|
+
const sloClient = usePluginClient(SloApi);
|
|
22
|
+
|
|
23
|
+
const { data: objectives, refetch } =
|
|
24
|
+
sloClient.getObjectivesForSystem.useQuery(
|
|
25
|
+
{ systemId: system?.id ?? "" },
|
|
26
|
+
{ enabled: !!system?.id },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
useSignal(SLO_STATUS_CHANGED, ({ systemId }) => {
|
|
30
|
+
if (system?.id && systemId === system.id) {
|
|
31
|
+
void refetch();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!objectives || objectives.length === 0) return;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Card className="border-border shadow-sm">
|
|
39
|
+
<CardHeader className="border-b border-border bg-muted/30">
|
|
40
|
+
<div className="flex items-center gap-2">
|
|
41
|
+
<Target className="h-5 w-5 text-muted-foreground" />
|
|
42
|
+
<CardTitle className="text-lg font-semibold">
|
|
43
|
+
Service Level Objectives
|
|
44
|
+
</CardTitle>
|
|
45
|
+
</div>
|
|
46
|
+
</CardHeader>
|
|
47
|
+
<CardContent className="p-6">
|
|
48
|
+
<div className="space-y-4">
|
|
49
|
+
{objectives.map((item) => (
|
|
50
|
+
<Link
|
|
51
|
+
key={item.objective.id}
|
|
52
|
+
to={resolveRoute(sloRoutes.routes.detail, {
|
|
53
|
+
sloId: item.objective.id,
|
|
54
|
+
})}
|
|
55
|
+
className="block space-y-2 rounded-md border border-border p-3 transition-colors hover:border-primary/50 no-underline"
|
|
56
|
+
>
|
|
57
|
+
<div className="flex items-center justify-between">
|
|
58
|
+
<span className="text-sm font-medium">
|
|
59
|
+
{item.objective.target}% / {item.objective.windowDays}d
|
|
60
|
+
</span>
|
|
61
|
+
<BurnRateIndicator burnRate={item.status.burnRate} />
|
|
62
|
+
</div>
|
|
63
|
+
<ErrorBudgetBar
|
|
64
|
+
consumedPercent={
|
|
65
|
+
100 - item.status.errorBudgetRemainingPercent
|
|
66
|
+
}
|
|
67
|
+
warningThreshold={
|
|
68
|
+
item.objective.burnRateThresholds.warningPercent
|
|
69
|
+
}
|
|
70
|
+
criticalThreshold={
|
|
71
|
+
item.objective.burnRateThresholds.criticalPercent
|
|
72
|
+
}
|
|
73
|
+
/>
|
|
74
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
75
|
+
<span>
|
|
76
|
+
{item.status.currentAvailability?.toFixed(3) ?? "—"}%
|
|
77
|
+
availability
|
|
78
|
+
</span>
|
|
79
|
+
<span>
|
|
80
|
+
{item.status.errorBudgetRemainingMinutes.toFixed(1)} min
|
|
81
|
+
remaining
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
</Link>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
</CardContent>
|
|
88
|
+
</Card>
|
|
89
|
+
);
|
|
90
|
+
};
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFrontendPlugin,
|
|
3
|
+
createSlotExtension,
|
|
4
|
+
UserMenuItemsSlot,
|
|
5
|
+
} from "@checkstack/frontend-api";
|
|
6
|
+
import {
|
|
7
|
+
sloRoutes,
|
|
8
|
+
pluginMetadata,
|
|
9
|
+
sloAccess,
|
|
10
|
+
} from "@checkstack/slo-common";
|
|
11
|
+
import {
|
|
12
|
+
SystemDetailsTopSlot,
|
|
13
|
+
SystemStateBadgesSlot,
|
|
14
|
+
} from "@checkstack/catalog-common";
|
|
15
|
+
import { SloOverviewPage } from "./pages/SloOverviewPage";
|
|
16
|
+
import { SloConfigPage } from "./pages/SloConfigPage";
|
|
17
|
+
import { SloDetailPage } from "./pages/SloDetailPage";
|
|
18
|
+
import { SystemSloPanel } from "./components/SystemSloPanel";
|
|
19
|
+
import { SystemSloBadge } from "./components/SystemSloBadge";
|
|
20
|
+
import { SloMenuItems } from "./components/SloMenuItems";
|
|
21
|
+
|
|
22
|
+
export default createFrontendPlugin({
|
|
23
|
+
metadata: pluginMetadata,
|
|
24
|
+
routes: [
|
|
25
|
+
{
|
|
26
|
+
route: sloRoutes.routes.overview,
|
|
27
|
+
element: <SloOverviewPage />,
|
|
28
|
+
title: "SLO Dashboard",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
route: sloRoutes.routes.config,
|
|
32
|
+
element: <SloConfigPage />,
|
|
33
|
+
title: "SLO Management",
|
|
34
|
+
accessRule: sloAccess.slo.manage,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
route: sloRoutes.routes.detail,
|
|
38
|
+
element: <SloDetailPage />,
|
|
39
|
+
title: "SLO Detail",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
apis: [],
|
|
43
|
+
extensions: [
|
|
44
|
+
createSlotExtension(UserMenuItemsSlot, {
|
|
45
|
+
id: "slo.user-menu.items",
|
|
46
|
+
component: SloMenuItems,
|
|
47
|
+
}),
|
|
48
|
+
createSlotExtension(SystemStateBadgesSlot, {
|
|
49
|
+
id: "slo.system-state-badge",
|
|
50
|
+
component: SystemSloBadge,
|
|
51
|
+
}),
|
|
52
|
+
createSlotExtension(SystemDetailsTopSlot, {
|
|
53
|
+
id: "slo.system-details-top.panel",
|
|
54
|
+
component: SystemSloPanel,
|
|
55
|
+
}),
|
|
56
|
+
],
|
|
57
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { useSearchParams } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
usePluginClient,
|
|
5
|
+
accessApiRef,
|
|
6
|
+
useApi,
|
|
7
|
+
wrapInSuspense,
|
|
8
|
+
} from "@checkstack/frontend-api";
|
|
9
|
+
import { SloApi } from "../api";
|
|
10
|
+
import { sloAccess, type SloObjective } from "@checkstack/slo-common";
|
|
11
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
12
|
+
import {
|
|
13
|
+
Card,
|
|
14
|
+
CardHeader,
|
|
15
|
+
CardTitle,
|
|
16
|
+
CardContent,
|
|
17
|
+
Button,
|
|
18
|
+
Badge,
|
|
19
|
+
LoadingSpinner,
|
|
20
|
+
EmptyState,
|
|
21
|
+
Table,
|
|
22
|
+
TableHeader,
|
|
23
|
+
TableRow,
|
|
24
|
+
TableHead,
|
|
25
|
+
TableBody,
|
|
26
|
+
TableCell,
|
|
27
|
+
useToast,
|
|
28
|
+
ConfirmationModal,
|
|
29
|
+
PageLayout,
|
|
30
|
+
} from "@checkstack/ui";
|
|
31
|
+
import { Plus, Target, Trash2, Edit2 } from "lucide-react";
|
|
32
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
33
|
+
import { SloEditor } from "../components/SloEditor";
|
|
34
|
+
|
|
35
|
+
const SloConfigPageContent: React.FC = () => {
|
|
36
|
+
const sloClient = usePluginClient(SloApi);
|
|
37
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
38
|
+
const accessApi = useApi(accessApiRef);
|
|
39
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
40
|
+
const toast = useToast();
|
|
41
|
+
|
|
42
|
+
const { allowed: canManage, loading: accessLoading } = accessApi.useAccess(
|
|
43
|
+
sloAccess.slo.manage,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
data: objectivesData,
|
|
48
|
+
isLoading: objectivesLoading,
|
|
49
|
+
refetch: refetchObjectives,
|
|
50
|
+
} = sloClient.listObjectives.useQuery({});
|
|
51
|
+
|
|
52
|
+
const { data: systemsData, isLoading: systemsLoading } =
|
|
53
|
+
catalogClient.getSystems.useQuery({});
|
|
54
|
+
|
|
55
|
+
const objectives = objectivesData?.objectives ?? [];
|
|
56
|
+
const systems = systemsData?.systems ?? [];
|
|
57
|
+
const loading = objectivesLoading || systemsLoading;
|
|
58
|
+
|
|
59
|
+
// Editor state
|
|
60
|
+
const [editorOpen, setEditorOpen] = useState(false);
|
|
61
|
+
const [editingObjective, setEditingObjective] = useState<
|
|
62
|
+
SloObjective | undefined
|
|
63
|
+
>();
|
|
64
|
+
|
|
65
|
+
// Delete confirmation state
|
|
66
|
+
const [deleteId, setDeleteId] = useState<string | undefined>();
|
|
67
|
+
|
|
68
|
+
// Handle ?action=create URL parameter (from command palette)
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (searchParams.get("action") === "create" && canManage) {
|
|
71
|
+
setEditingObjective(undefined);
|
|
72
|
+
setEditorOpen(true);
|
|
73
|
+
searchParams.delete("action");
|
|
74
|
+
setSearchParams(searchParams, { replace: true });
|
|
75
|
+
}
|
|
76
|
+
}, [searchParams, canManage, setSearchParams]);
|
|
77
|
+
|
|
78
|
+
const handleCreate = () => {
|
|
79
|
+
setEditingObjective(undefined);
|
|
80
|
+
setEditorOpen(true);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleEdit = (obj: SloObjective) => {
|
|
84
|
+
setEditingObjective(obj);
|
|
85
|
+
setEditorOpen(true);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleSave = () => {
|
|
89
|
+
setEditorOpen(false);
|
|
90
|
+
void refetchObjectives();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const deleteMutation = sloClient.deleteObjective.useMutation({
|
|
94
|
+
onSuccess: () => {
|
|
95
|
+
toast.success("SLO objective deleted");
|
|
96
|
+
void refetchObjectives();
|
|
97
|
+
setDeleteId(undefined);
|
|
98
|
+
},
|
|
99
|
+
onError: (error) => {
|
|
100
|
+
toast.error(extractErrorMessage(error, "Failed to delete"));
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const handleDelete = () => {
|
|
105
|
+
if (!deleteId) return;
|
|
106
|
+
deleteMutation.mutate({ id: deleteId });
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const getSystemName = (systemId: string) => {
|
|
110
|
+
return systems.find((s) => s.id === systemId)?.name ?? systemId;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const getExclusionBadge = (mode: SloObjective["dependencyExclusion"]) => {
|
|
114
|
+
switch (mode) {
|
|
115
|
+
case "strict": {
|
|
116
|
+
return <Badge variant="secondary">Strict</Badge>;
|
|
117
|
+
}
|
|
118
|
+
case "self-only": {
|
|
119
|
+
return <Badge variant="info">Self-Only</Badge>;
|
|
120
|
+
}
|
|
121
|
+
default: {
|
|
122
|
+
return <Badge>{mode}</Badge>;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<PageLayout
|
|
129
|
+
title="SLO Management"
|
|
130
|
+
subtitle="Define and manage Service Level Objectives"
|
|
131
|
+
icon={Target}
|
|
132
|
+
loading={accessLoading}
|
|
133
|
+
allowed={canManage}
|
|
134
|
+
actions={
|
|
135
|
+
<Button onClick={handleCreate}>
|
|
136
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
137
|
+
Create SLO
|
|
138
|
+
</Button>
|
|
139
|
+
}
|
|
140
|
+
>
|
|
141
|
+
<Card>
|
|
142
|
+
<CardHeader className="border-b border-border">
|
|
143
|
+
<div className="flex items-center gap-2">
|
|
144
|
+
<Target className="h-5 w-5 text-muted-foreground" />
|
|
145
|
+
<CardTitle>Objectives</CardTitle>
|
|
146
|
+
</div>
|
|
147
|
+
</CardHeader>
|
|
148
|
+
<CardContent className="p-0">
|
|
149
|
+
{loading ? (
|
|
150
|
+
<div className="p-12 flex justify-center">
|
|
151
|
+
<LoadingSpinner />
|
|
152
|
+
</div>
|
|
153
|
+
) : objectives.length === 0 ? (
|
|
154
|
+
<EmptyState
|
|
155
|
+
title="No SLO objectives"
|
|
156
|
+
description="Create your first SLO objective to start tracking reliability."
|
|
157
|
+
/>
|
|
158
|
+
) : (
|
|
159
|
+
<Table>
|
|
160
|
+
<TableHeader>
|
|
161
|
+
<TableRow>
|
|
162
|
+
<TableHead>System</TableHead>
|
|
163
|
+
<TableHead>Target</TableHead>
|
|
164
|
+
<TableHead>Window</TableHead>
|
|
165
|
+
<TableHead>Exclusion Mode</TableHead>
|
|
166
|
+
<TableHead>Status</TableHead>
|
|
167
|
+
<TableHead className="w-24">Actions</TableHead>
|
|
168
|
+
</TableRow>
|
|
169
|
+
</TableHeader>
|
|
170
|
+
<TableBody>
|
|
171
|
+
{objectives.map((item) => (
|
|
172
|
+
<TableRow key={item.objective.id}>
|
|
173
|
+
<TableCell className="font-medium">
|
|
174
|
+
{getSystemName(item.objective.systemId)}
|
|
175
|
+
</TableCell>
|
|
176
|
+
<TableCell>{item.objective.target}%</TableCell>
|
|
177
|
+
<TableCell>{item.objective.windowDays}d</TableCell>
|
|
178
|
+
<TableCell>
|
|
179
|
+
{getExclusionBadge(
|
|
180
|
+
item.objective.dependencyExclusion,
|
|
181
|
+
)}
|
|
182
|
+
</TableCell>
|
|
183
|
+
<TableCell>
|
|
184
|
+
{item.status.isBreaching ? (
|
|
185
|
+
<Badge variant="destructive">Breaching</Badge>
|
|
186
|
+
) : item.status.hasOpenDowntime ? (
|
|
187
|
+
<Badge variant="warning">Degraded</Badge>
|
|
188
|
+
) : item.status.errorBudgetRemainingPercent <= 20 ? (
|
|
189
|
+
<Badge variant="warning">At Risk</Badge>
|
|
190
|
+
) : (
|
|
191
|
+
<Badge variant="success">Healthy</Badge>
|
|
192
|
+
)}
|
|
193
|
+
</TableCell>
|
|
194
|
+
<TableCell>
|
|
195
|
+
<div className="flex gap-2">
|
|
196
|
+
<Button
|
|
197
|
+
variant="ghost"
|
|
198
|
+
size="sm"
|
|
199
|
+
onClick={() => handleEdit(item.objective)}
|
|
200
|
+
>
|
|
201
|
+
<Edit2 className="h-4 w-4" />
|
|
202
|
+
</Button>
|
|
203
|
+
<Button
|
|
204
|
+
variant="ghost"
|
|
205
|
+
size="sm"
|
|
206
|
+
onClick={() => setDeleteId(item.objective.id)}
|
|
207
|
+
>
|
|
208
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
209
|
+
</Button>
|
|
210
|
+
</div>
|
|
211
|
+
</TableCell>
|
|
212
|
+
</TableRow>
|
|
213
|
+
))}
|
|
214
|
+
</TableBody>
|
|
215
|
+
</Table>
|
|
216
|
+
)}
|
|
217
|
+
</CardContent>
|
|
218
|
+
</Card>
|
|
219
|
+
|
|
220
|
+
<SloEditor
|
|
221
|
+
open={editorOpen}
|
|
222
|
+
onOpenChange={setEditorOpen}
|
|
223
|
+
objective={editingObjective}
|
|
224
|
+
systems={systems}
|
|
225
|
+
onSave={handleSave}
|
|
226
|
+
/>
|
|
227
|
+
|
|
228
|
+
<ConfirmationModal
|
|
229
|
+
isOpen={!!deleteId}
|
|
230
|
+
onClose={() => setDeleteId(undefined)}
|
|
231
|
+
title="Delete SLO Objective"
|
|
232
|
+
message="Are you sure you want to delete this SLO objective? All associated downtime events and snapshots will also be deleted."
|
|
233
|
+
confirmText="Delete"
|
|
234
|
+
variant="danger"
|
|
235
|
+
onConfirm={handleDelete}
|
|
236
|
+
isLoading={deleteMutation.isPending}
|
|
237
|
+
/>
|
|
238
|
+
</PageLayout>
|
|
239
|
+
);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export const SloConfigPage = wrapInSuspense(SloConfigPageContent);
|