@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.
@@ -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);