@incident-io/backstage 0.1.0 → 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.
Files changed (83) hide show
  1. package/README.md +22 -3
  2. package/config.d.ts +11 -5
  3. package/dist/alpha.esm.js +32 -6
  4. package/dist/alpha.esm.js.map +1 -1
  5. package/dist/{esm/client-646572ea.esm.js → api/client.esm.js} +6 -13
  6. package/dist/api/client.esm.js.map +1 -0
  7. package/dist/components/AlertListItem/index.esm.js +68 -0
  8. package/dist/components/AlertListItem/index.esm.js.map +1 -0
  9. package/dist/components/EntityAlertCard/index.esm.js +113 -0
  10. package/dist/components/EntityAlertCard/index.esm.js.map +1 -0
  11. package/dist/components/EntityIncidentCard/index.esm.js +125 -0
  12. package/dist/components/EntityIncidentCard/index.esm.js.map +1 -0
  13. package/dist/components/EntityOnCallCard/index.esm.js +198 -0
  14. package/dist/components/EntityOnCallCard/index.esm.js.map +1 -0
  15. package/dist/components/HomePageAlertCard/Content.esm.js +76 -0
  16. package/dist/components/HomePageAlertCard/Content.esm.js.map +1 -0
  17. package/dist/components/HomePageAlertCard/index.esm.js +2 -0
  18. package/dist/components/HomePageAlertCard/index.esm.js.map +1 -0
  19. package/dist/components/HomePageIncidentCard/Content.esm.js +54 -0
  20. package/dist/components/HomePageIncidentCard/Content.esm.js.map +1 -0
  21. package/dist/components/HomePageIncidentCard/Context.esm.js +33 -0
  22. package/dist/components/HomePageIncidentCard/Context.esm.js.map +1 -0
  23. package/dist/components/HomePageIncidentCard/index.esm.js +3 -0
  24. package/dist/components/HomePageIncidentCard/index.esm.js.map +1 -0
  25. package/dist/components/HomePageOnCallCard/Content.esm.js +38 -0
  26. package/dist/components/HomePageOnCallCard/Content.esm.js.map +1 -0
  27. package/dist/components/HomePageOnCallCard/index.esm.js +6 -0
  28. package/dist/components/HomePageOnCallCard/index.esm.js.map +1 -0
  29. package/dist/components/IncidentListItem/index.esm.js +68 -0
  30. package/dist/components/IncidentListItem/index.esm.js.map +1 -0
  31. package/dist/components/styles.esm.js +34 -0
  32. package/dist/components/styles.esm.js.map +1 -0
  33. package/dist/components/utils.esm.js +19 -0
  34. package/dist/components/utils.esm.js.map +1 -0
  35. package/dist/hooks/useIncidentRequest.esm.js +65 -0
  36. package/dist/hooks/useIncidentRequest.esm.js.map +1 -0
  37. package/dist/hooks/useOnCallRequest.esm.js +116 -0
  38. package/dist/hooks/useOnCallRequest.esm.js.map +1 -0
  39. package/dist/index.d.ts +9 -6
  40. package/dist/index.esm.js +1 -64
  41. package/dist/index.esm.js.map +1 -1
  42. package/dist/plugin.esm.js +99 -0
  43. package/dist/plugin.esm.js.map +1 -0
  44. package/package.json +44 -23
  45. package/src/alpha.test.ts +9 -0
  46. package/src/alpha.tsx +38 -4
  47. package/src/api/client.test.ts +43 -0
  48. package/src/api/types.test.ts +15 -0
  49. package/src/api/types.ts +49796 -11325
  50. package/src/components/AlertListItem/index.tsx +82 -0
  51. package/src/components/EntityAlertCard/index.test.tsx +242 -0
  52. package/src/components/EntityAlertCard/index.tsx +168 -0
  53. package/src/components/EntityIncidentCard/index.test.tsx +135 -0
  54. package/src/components/EntityIncidentCard/index.tsx +3 -23
  55. package/src/components/EntityOnCallCard/index.test.tsx +134 -0
  56. package/src/components/EntityOnCallCard/index.tsx +301 -0
  57. package/src/components/HomePageAlertCard/Content.test.tsx +56 -0
  58. package/src/components/HomePageAlertCard/Content.tsx +85 -0
  59. package/src/components/HomePageAlertCard/index.tsx +1 -0
  60. package/src/components/HomePageIncidentCard/Content.test.tsx +4 -3
  61. package/src/components/HomePageIncidentCard/Content.tsx +2 -2
  62. package/src/components/HomePageIncidentCard/Context.tsx +2 -2
  63. package/src/components/HomePageOnCallCard/Content.test.tsx +90 -0
  64. package/src/components/HomePageOnCallCard/Content.tsx +58 -0
  65. package/src/components/HomePageOnCallCard/index.ts +3 -0
  66. package/src/components/IncidentListItem/index.tsx +3 -26
  67. package/src/components/styles.tsx +30 -0
  68. package/src/components/utils.tsx +24 -0
  69. package/src/hooks/useIncidentRequest.test.ts +189 -0
  70. package/src/hooks/useIncidentRequest.ts +56 -3
  71. package/src/hooks/useOnCallRequest.test.ts +52 -0
  72. package/src/hooks/useOnCallRequest.ts +141 -0
  73. package/src/index.ts +4 -0
  74. package/src/plugin.ts +45 -1
  75. package/src/setupTests.ts +2 -2
  76. package/alpha/package.json +0 -7
  77. package/dist/esm/client-646572ea.esm.js.map +0 -1
  78. package/dist/esm/index-55bf4982.esm.js +0 -72
  79. package/dist/esm/index-55bf4982.esm.js.map +0 -1
  80. package/dist/esm/index-633a0241.esm.js +0 -96
  81. package/dist/esm/index-633a0241.esm.js.map +0 -1
  82. package/dist/esm/index-a220a8f7.esm.js +0 -116
  83. package/dist/esm/index-a220a8f7.esm.js.map +0 -1
@@ -0,0 +1,134 @@
1
+ /// <reference types="@testing-library/jest-dom" />
2
+ import { render, screen } from "@testing-library/react";
3
+ import { vi } from "vitest";
4
+
5
+ vi.mock("@backstage/core-plugin-api", () => ({
6
+ useApi: vi.fn(),
7
+ createApiRef: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("@backstage/plugin-catalog-react", () => ({
11
+ useEntity: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("../../hooks/useIncidentRequest", () => ({
15
+ useIdentity: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("../../hooks/useOnCallRequest", () => ({
19
+ useOnCallData: vi.fn(),
20
+ useSchedule: vi.fn(),
21
+ useEscalationPath: vi.fn(),
22
+ }));
23
+
24
+ vi.mock("@backstage/core-components", () => ({
25
+ Progress: () => <div data-testid="progress" />,
26
+ }));
27
+
28
+ import { useEntity } from "@backstage/plugin-catalog-react";
29
+ import { useIdentity } from "../../hooks/useIncidentRequest";
30
+ import { useOnCallData, useSchedule, useEscalationPath } from "../../hooks/useOnCallRequest";
31
+ import { EntityOnCallCard } from "./index";
32
+
33
+ const mockEntity = {
34
+ metadata: { name: "core-server", namespace: "default" },
35
+ };
36
+
37
+ const mockIdentityLoaded = {
38
+ value: { identity: { dashboard_url: "https://app.incident.io" } },
39
+ loading: false,
40
+ error: undefined,
41
+ };
42
+
43
+ beforeEach(() => {
44
+ (useEntity as ReturnType<typeof vi.fn>).mockReturnValue({ entity: mockEntity });
45
+ (useIdentity as ReturnType<typeof vi.fn>).mockReturnValue(mockIdentityLoaded);
46
+ (useSchedule as ReturnType<typeof vi.fn>).mockReturnValue({ value: null, loading: false, error: undefined });
47
+ (useEscalationPath as ReturnType<typeof vi.fn>).mockReturnValue({ value: null, loading: false, error: undefined });
48
+ });
49
+
50
+ describe("EntityOnCallCard", () => {
51
+ it("should show a loading indicator while fetching", () => {
52
+ (useOnCallData as ReturnType<typeof vi.fn>).mockReturnValue({
53
+ value: undefined,
54
+ loading: true,
55
+ error: undefined,
56
+ });
57
+
58
+ render(<EntityOnCallCard />);
59
+
60
+ expect(screen.getByTestId("progress")).toBeInTheDocument();
61
+ });
62
+
63
+ it("should show error alerts when EP and schedule fields are missing", () => {
64
+ (useOnCallData as ReturnType<typeof vi.fn>).mockReturnValue({
65
+ value: {
66
+ escalationPath: null,
67
+ schedule: null,
68
+ escalationPathStatus: "no_field",
69
+ scheduleStatus: "no_field",
70
+ },
71
+ loading: false,
72
+ error: undefined,
73
+ });
74
+
75
+ render(<EntityOnCallCard />);
76
+
77
+ expect(screen.getByText(/No escalation path field on this catalog type/i)).toBeInTheDocument();
78
+ expect(screen.getByText(/No schedule field on this catalog type/i)).toBeInTheDocument();
79
+ });
80
+
81
+ it("should show warning alerts when EP and schedule fields are empty", () => {
82
+ (useOnCallData as ReturnType<typeof vi.fn>).mockReturnValue({
83
+ value: {
84
+ escalationPath: null,
85
+ schedule: null,
86
+ escalationPathStatus: "empty",
87
+ scheduleStatus: "empty",
88
+ },
89
+ loading: false,
90
+ error: undefined,
91
+ });
92
+
93
+ render(<EntityOnCallCard />);
94
+
95
+ expect(screen.getByText(/Escalation path field is empty for this component/i)).toBeInTheDocument();
96
+ expect(screen.getByText(/Schedule field is empty for this component/i)).toBeInTheDocument();
97
+ });
98
+
99
+ it("should show escalation path and schedule names when loaded", () => {
100
+ (useOnCallData as ReturnType<typeof vi.fn>).mockReturnValue({
101
+ value: {
102
+ escalationPath: { label: "Primary EP", literal: "ep-1" },
103
+ schedule: { label: "Primary Schedule", literal: "sched-1" },
104
+ escalationPathStatus: "ok",
105
+ scheduleStatus: "ok",
106
+ },
107
+ loading: false,
108
+ error: undefined,
109
+ });
110
+ (useEscalationPath as ReturnType<typeof vi.fn>).mockReturnValue({
111
+ value: {
112
+ ep: { id: "ep-1", name: "Primary EP", path: [], current_responders: [] },
113
+ channelNames: {},
114
+ },
115
+ loading: false,
116
+ error: undefined,
117
+ });
118
+ (useSchedule as ReturnType<typeof vi.fn>).mockReturnValue({
119
+ value: {
120
+ id: "sched-1",
121
+ name: "Primary Schedule",
122
+ current_shifts: [],
123
+ config: { rotations: [] },
124
+ },
125
+ loading: false,
126
+ error: undefined,
127
+ });
128
+
129
+ render(<EntityOnCallCard />);
130
+
131
+ expect(screen.getByText("Primary EP")).toBeInTheDocument();
132
+ expect(screen.getByText("Primary Schedule")).toBeInTheDocument();
133
+ });
134
+ });
@@ -0,0 +1,301 @@
1
+ /*
2
+ * Copyright 2023 The Backstage Authors
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import { Progress } from "@backstage/core-components";
17
+ import { useEntity } from "@backstage/plugin-catalog-react";
18
+ import {
19
+ Box,
20
+ Card,
21
+ CardContent,
22
+ CardHeader,
23
+ Chip,
24
+ Divider,
25
+ IconButton,
26
+ Tooltip,
27
+ Typography,
28
+ Collapse,
29
+ } from "@material-ui/core";
30
+ import CachedIcon from "@material-ui/icons/Cached";
31
+ import OpenInBrowserIcon from "@material-ui/icons/OpenInBrowser";
32
+ import { Alert } from "@material-ui/lab";
33
+ import { useState } from "react";
34
+ import { useIdentity } from "../../hooks/useIncidentRequest";
35
+ import {
36
+ useOnCallData,
37
+ useSchedule,
38
+ useEscalationPath,
39
+ ScheduleRotation,
40
+ EscalationPathNode,
41
+ EscalationPathTarget,
42
+ } from "../../hooks/useOnCallRequest";
43
+
44
+ // ── Schedule helpers ──────────────────────────────────────────────────────────
45
+
46
+ const intervalTypeToUnit = (type: string): string => {
47
+ const map: Record<string, string> = { hourly: "hour", daily: "day", weekly: "week", monthly: "month" };
48
+ return map[type] ?? type;
49
+ };
50
+
51
+ const formatInterval = (rotation: ScheduleRotation): string => {
52
+ const h = rotation.handovers[0];
53
+ if (!h) return "";
54
+ const unit = intervalTypeToUnit(h.interval_type ?? '');
55
+ return `Rotate every ${h.interval} ${unit}${h.interval !== 1 ? "s" : ""}`;
56
+ };
57
+
58
+ const formatShiftEnd = (isoString: string): string =>
59
+ new Date(isoString).toLocaleString("en-GB", {
60
+ weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit",
61
+ });
62
+
63
+ const RotationDisplay = ({ rotation, currentUserId, currentShiftEnd }: {
64
+ rotation: ScheduleRotation;
65
+ currentUserId: string | null;
66
+ currentShiftEnd: string | null;
67
+ }) => (
68
+ <Box mt={1}>
69
+ <Typography variant="caption" color="textSecondary">
70
+ {formatInterval(rotation)}
71
+ </Typography>
72
+ <Box display="flex" flexDirection="column" mt={0.5} style={{ gap: 4 }}>
73
+ {(() => {
74
+ let onCallBadgeShown = false;
75
+ return rotation.users.map((user) => {
76
+ const isCurrent = user.id === currentUserId;
77
+ const showBadge = isCurrent && !onCallBadgeShown;
78
+ if (showBadge) onCallBadgeShown = true;
79
+ return (
80
+ <Box key={user.id} display="flex" alignItems="center" style={{ gap: 8 }}>
81
+ <Box width={10} height={10} borderRadius="50%" bgcolor={showBadge ? "primary.main" : "grey.400"} flexShrink={0} />
82
+ <Box display="flex" alignItems="center" style={{ gap: 6 }}>
83
+ <Typography variant="body2" style={{ fontWeight: showBadge ? 600 : 400 }}>
84
+ {user.name}
85
+ </Typography>
86
+ {showBadge && <Chip label="on call" size="small" color="primary" />}
87
+ {showBadge && currentShiftEnd && (
88
+ <Typography variant="caption" color="textSecondary">
89
+ until {formatShiftEnd(currentShiftEnd)}
90
+ </Typography>
91
+ )}
92
+ </Box>
93
+ </Box>
94
+ );
95
+ });
96
+ })()}
97
+ </Box>
98
+ </Box>
99
+ );
100
+
101
+ // ── Escalation path helpers ───────────────────────────────────────────────────
102
+
103
+ const formatCondition = (cond: EscalationPathNode['if_else'] extends undefined ? never : NonNullable<EscalationPathNode['if_else']>['conditions'][0]): string => {
104
+ const subject = cond.subject.label.replace(/^Escalation → /i, "");
105
+ const op = cond.operation.label;
106
+ const values = cond.param_bindings.flatMap(b => b.array_value?.map(v => v.label) ?? []);
107
+ return values.length > 0 ? `${subject} ${op} ${values.join(", ")}` : `${subject} ${op}`;
108
+ };
109
+
110
+ const targetLabel = (t: EscalationPathTarget, scheduleId: string | null, scheduleName: string | null, channelNames: Record<string, string>): string => {
111
+ if (t.type === "schedule") return t.id === scheduleId && scheduleName ? scheduleName : "schedule";
112
+ if (t.type === "slack_channel") return `${channelNames[t.id] ?? t.id}`;
113
+ return "user";
114
+ };
115
+
116
+ const renderEscalationNodes = (
117
+ nodes: EscalationPathNode[],
118
+ scheduleId: string | null,
119
+ scheduleName: string | null,
120
+ channelNames: Record<string, string>,
121
+ depth = 0,
122
+ ): React.ReactNode[] =>
123
+ nodes.map((node) => {
124
+ const indent = depth * 16;
125
+
126
+ if ((node.type === "level" && node.level) || (node.type === "notify_channel" && node.notify_channel)) {
127
+ const data = node.level ?? node.notify_channel!;
128
+ const minutes = Math.floor((data.time_to_ack_seconds ?? 0) / 60);
129
+ const label = data.targets.map(t => targetLabel(t, scheduleId, scheduleName, channelNames)).join(", ");
130
+ const prefix = node.type === "notify_channel" ? "Notify" : "Page";
131
+ return (
132
+ <Box key={node.id} ml={`${indent}px`} display="flex" alignItems="center" style={{ gap: 6 }} mt={0.5}>
133
+ <Typography variant="body2">└ {prefix}: {label}</Typography>
134
+ <Typography variant="caption" color="textSecondary">· {minutes} min to ack</Typography>
135
+ </Box>
136
+ );
137
+ }
138
+
139
+ if (node.type === "repeat" && node.repeat) {
140
+ return (
141
+ <Box key={node.id} ml={`${indent}px`} mt={0.5}>
142
+ <Typography variant="body2" color="textSecondary">
143
+ └ Retry {node.repeat.repeat_times}x from start
144
+ </Typography>
145
+ </Box>
146
+ );
147
+ }
148
+
149
+ if (node.type === "if_else" && node.if_else) {
150
+ const condLabel = node.if_else.conditions.map(formatCondition).join(", ");
151
+ return (
152
+ <Box key={node.id} ml={`${indent}px`} mt={0.5}>
153
+ <Typography variant="body2"><strong>If {condLabel}:</strong></Typography>
154
+ {node.if_else.then_path.length > 0
155
+ ? renderEscalationNodes(node.if_else.then_path, scheduleId, scheduleName, channelNames, depth + 1)
156
+ : <Box ml="16px"><Typography variant="body2" color="textSecondary">└ Do nothing</Typography></Box>
157
+ }
158
+ <Typography variant="body2" style={{ marginTop: 4 }}><strong>Otherwise:</strong></Typography>
159
+ {node.if_else.else_path.length > 0
160
+ ? renderEscalationNodes(node.if_else.else_path, scheduleId, scheduleName, channelNames, depth + 1)
161
+ : <Box ml="16px"><Typography variant="body2" color="textSecondary">└ Do nothing</Typography></Box>
162
+ }
163
+ </Box>
164
+ );
165
+ }
166
+
167
+ return null;
168
+ });
169
+
170
+ // ── Card ──────────────────────────────────────────────────────────────────────
171
+
172
+ export const EntityOnCallCard = () => {
173
+ const { entity } = useEntity();
174
+ const [reload, setReload] = useState(false);
175
+ const [showPath, setShowPath] = useState(false);
176
+
177
+ const entityExternalId = `${entity.metadata.namespace}/${entity.metadata.name}`;
178
+
179
+ const { value, loading, error } = useOnCallData(entityExternalId, [reload]);
180
+ const { value: schedule, loading: scheduleLoading, error: scheduleError } = useSchedule(
181
+ value?.schedule?.literal ?? null,
182
+ [value?.schedule?.literal, reload],
183
+ );
184
+ const { value: escalationPathResult, loading: escalationLoading, error: escalationError } = useEscalationPath(
185
+ value?.escalationPath?.literal ?? null,
186
+ [value?.escalationPath?.literal, reload],
187
+ );
188
+ const escalationPath = escalationPathResult?.ep ?? null;
189
+ const channelNames = escalationPathResult?.channelNames ?? {};
190
+
191
+ const { value: identity } = useIdentity();
192
+ const baseUrl = identity?.identity.dashboard_url ?? "app.incident.io";
193
+
194
+ const anyLoading = loading || scheduleLoading || escalationLoading;
195
+ const anyError = error || scheduleError || escalationError;
196
+
197
+ return (
198
+ <Card>
199
+ <CardHeader
200
+ title="On-call"
201
+ action={
202
+ <IconButton aria-label="Refresh" title="Refresh" onClick={() => setReload(!reload)}>
203
+ <CachedIcon />
204
+ </IconButton>
205
+ }
206
+ />
207
+ <Divider />
208
+ <CardContent>
209
+ {anyLoading && <Progress />}
210
+ {anyError && <Alert severity="error">{anyError.message}</Alert>}
211
+ {!loading && !error && value && (
212
+ <>
213
+ {/* Escalation path */}
214
+ <Box mb={2}>
215
+ <Typography variant="subtitle1"><strong>Escalation path</strong></Typography>
216
+ {value.escalationPathStatus === 'no_field' && (
217
+ <Alert severity="error">No escalation path field on this catalog type — add one in incident.io.</Alert>
218
+ )}
219
+ {value.escalationPathStatus === 'empty' && (
220
+ <Alert severity="warning">Escalation path field is empty for this component.</Alert>
221
+ )}
222
+ {value.escalationPathStatus === 'ok' && escalationPath && (
223
+ <>
224
+ <Box display="flex" alignItems="center" justifyContent="space-between">
225
+ <Typography variant="subtitle1">{escalationPath.name}</Typography>
226
+ <Tooltip title="View in incident.io" placement="top">
227
+ <IconButton size="small" href={`${baseUrl}/on-call/escalation-paths/${escalationPath.id}`} target="_blank" rel="noopener noreferrer" color="primary">
228
+ <OpenInBrowserIcon fontSize="small" />
229
+ </IconButton>
230
+ </Tooltip>
231
+ </Box>
232
+ {escalationPath.current_responders && escalationPath.current_responders.length > 0 && (
233
+ <Box mb={1}>
234
+ <Typography variant="body2"><strong>Current responders:</strong></Typography>
235
+ {escalationPath.current_responders.map((r) => (
236
+ <Typography key={r.id} variant="body2">{r.name}</Typography>
237
+ ))}
238
+ </Box>
239
+ )}
240
+ <Box
241
+ display="inline-flex"
242
+ alignItems="center"
243
+ style={{ cursor: "pointer", gap: 4 }}
244
+ onClick={() => setShowPath(p => !p)}
245
+ mt={0.5}
246
+ mb={0.5}
247
+ >
248
+ <Typography variant="button" color="primary">
249
+ {showPath ? "Hide path ▲" : "Show path ▼"}
250
+ </Typography>
251
+ </Box>
252
+ <Collapse in={showPath}>
253
+ {renderEscalationNodes(
254
+ escalationPath.path,
255
+ value.schedule?.literal ?? null,
256
+ schedule?.name ?? null,
257
+ channelNames,
258
+ )}
259
+ </Collapse>
260
+ </>
261
+ )}
262
+ </Box>
263
+
264
+ <Divider />
265
+
266
+ {/* Schedule */}
267
+ <Box mt={2}>
268
+ <Typography variant="subtitle1"><strong>Schedule</strong></Typography>
269
+ {value.scheduleStatus === 'no_field' && (
270
+ <Alert severity="error">No schedule field on this catalog type — add one in incident.io.</Alert>
271
+ )}
272
+ {value.scheduleStatus === 'empty' && (
273
+ <Alert severity="warning">Schedule field is empty for this component.</Alert>
274
+ )}
275
+ {value.scheduleStatus === 'ok' && schedule && (
276
+ <>
277
+ <Box display="flex" alignItems="center" justifyContent="space-between">
278
+ <Typography variant="subtitle1">{schedule.name}</Typography>
279
+ <Tooltip title="View in incident.io" placement="top">
280
+ <IconButton size="small" href={`${baseUrl}/on-call/schedules/${schedule.id}`} target="_blank" rel="noopener noreferrer" color="primary">
281
+ <OpenInBrowserIcon fontSize="small" />
282
+ </IconButton>
283
+ </Tooltip>
284
+ </Box>
285
+ {schedule.config?.rotations.map((rotation) => (
286
+ <RotationDisplay
287
+ key={rotation.id}
288
+ rotation={rotation}
289
+ currentUserId={schedule.current_shifts?.[0]?.user?.id ?? null}
290
+ currentShiftEnd={schedule.current_shifts?.[0]?.end_at ?? null}
291
+ />
292
+ ))}
293
+ </>
294
+ )}
295
+ </Box>
296
+ </>
297
+ )}
298
+ </CardContent>
299
+ </Card>
300
+ );
301
+ };
@@ -0,0 +1,56 @@
1
+ /// <reference types="@testing-library/jest-dom" />
2
+ import { TestApiProvider, renderInTestApp } from "@backstage/test-utils";
3
+ import { vi, type Mocked } from "vitest";
4
+ import { IncidentApi, IncidentApiRef } from "../../api/client";
5
+ import { HomePageAlertCardContent } from "./Content";
6
+
7
+ const mockAlert = {
8
+ id: "alert-1",
9
+ title: "High error rate",
10
+ status: "firing",
11
+ created_at: "2026-04-09T12:00:00Z",
12
+ updated_at: "2026-04-09T12:00:00Z",
13
+ deduplication_key: "abc123",
14
+ alert_source_id: "src-1",
15
+ attributes: [],
16
+ };
17
+
18
+ const makeIncidentApi = (
19
+ alerts: typeof mockAlert[],
20
+ ): Mocked<Partial<IncidentApi>> => ({
21
+ request: vi.fn().mockImplementation(({ path }: { path: string }) => {
22
+ if (path.startsWith("/v2/alerts")) {
23
+ return Promise.resolve({ alerts });
24
+ }
25
+ if (path === "/v2/alert_sources") {
26
+ return Promise.resolve({ alert_sources: [] });
27
+ }
28
+ return Promise.resolve({});
29
+ }),
30
+ });
31
+
32
+ const renderContent = (alerts: typeof mockAlert[]) =>
33
+ renderInTestApp(
34
+ <TestApiProvider apis={[[IncidentApiRef, makeIncidentApi(alerts)]]}>
35
+ <HomePageAlertCardContent />
36
+ </TestApiProvider>,
37
+ );
38
+
39
+ describe("HomePageAlertCardContent", () => {
40
+ it("should render an alert chip when an alert is returned", async () => {
41
+ const { findByTestId } = await renderContent([mockAlert]);
42
+ expect(await findByTestId("chip-firing")).toBeInTheDocument();
43
+ });
44
+
45
+ it("should show empty state when API returns no alerts", async () => {
46
+ const { findByText } = await renderContent([]);
47
+ expect(await findByText(/No firing alerts\./i)).toBeInTheDocument();
48
+ });
49
+
50
+ it("should render Firing, Resolved, and All tabs", async () => {
51
+ const { findByText } = await renderContent([]);
52
+ expect(await findByText("Firing")).toBeInTheDocument();
53
+ expect(await findByText("Resolved")).toBeInTheDocument();
54
+ expect(await findByText("All")).toBeInTheDocument();
55
+ });
56
+ });
@@ -0,0 +1,85 @@
1
+ import { useState } from "react";
2
+ import { Progress } from "@backstage/core-components";
3
+ import { configApiRef, useApi } from "@backstage/core-plugin-api";
4
+ import Link from "@material-ui/core/Link";
5
+ import { Alert } from "@material-ui/lab";
6
+ import { Box, Divider, List, Tab, Tabs, Typography } from "@material-ui/core";
7
+ import { useAlertList, useAlertSourceList } from "../../hooks/useIncidentRequest";
8
+ import { AlertListItem } from "../AlertListItem";
9
+
10
+ type StatusFilter = "firing" | "resolved" | undefined;
11
+
12
+ const STATUS_TABS: { label: string; value: StatusFilter }[] = [
13
+ { label: "Firing", value: "firing" },
14
+ { label: "Resolved", value: "resolved" },
15
+ { label: "All", value: undefined },
16
+ ];
17
+
18
+ export const HomePageAlertCardContent = () => {
19
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>("firing");
20
+
21
+ const config = useApi(configApiRef);
22
+ const baseUrl =
23
+ config.getOptionalString("incident.baseUrl") || "https://app.incident.io";
24
+
25
+ const { loading, error, value } = useAlertList(statusFilter, [statusFilter]);
26
+ const { value: sourcesResponse } = useAlertSourceList();
27
+
28
+ const alerts = value?.alerts ?? [];
29
+ const sourceById = Object.fromEntries(
30
+ (sourcesResponse?.alert_sources ?? []).map(s => [s.id, s]),
31
+ );
32
+
33
+ const currentTabIndex = STATUS_TABS.findIndex(t => t.value === statusFilter);
34
+
35
+ if (loading) return <Progress />;
36
+ if (error) return <Alert severity="error">{error.message}</Alert>;
37
+
38
+ return (
39
+ <>
40
+ <Tabs
41
+ value={currentTabIndex}
42
+ onChange={(_, idx) => setStatusFilter(STATUS_TABS[idx].value)}
43
+ indicatorColor="primary"
44
+ textColor="primary"
45
+ style={{ minHeight: "auto" }}
46
+ >
47
+ {STATUS_TABS.map(tab => (
48
+ <Tab key={tab.label} label={tab.label} style={{ minHeight: "auto", padding: "0px 12px" }} />
49
+ ))}
50
+ </Tabs>
51
+ <Divider />
52
+ {alerts.length > 0 && (
53
+ <Typography variant="subtitle1">
54
+ There are <strong>{alerts.length}</strong> {statusFilter ?? ""} alerts.
55
+ </Typography>
56
+ )}
57
+ {alerts.length === 0 && (
58
+ <Typography variant="subtitle1">No {statusFilter ?? ""} alerts.</Typography>
59
+ )}
60
+ <Box style={{ maxHeight: 400, overflowY: "auto" }}>
61
+ <List dense>
62
+ {alerts.map(alert => (
63
+ <AlertListItem
64
+ key={alert.id}
65
+ alert={alert}
66
+ baseUrl={baseUrl}
67
+ source={sourceById[alert.alert_source_id]?.name ?? "-"}
68
+ priority={alert.attributes.find(a => a.attribute.name === "Priority")?.value?.label}
69
+ />
70
+ ))}
71
+ </List>
72
+ </Box>
73
+ <Typography variant="subtitle1">
74
+ Click to{" "}
75
+ <Link target="_blank" href={`${baseUrl}/on-call/alerts`}>
76
+ see more.
77
+ </Link>
78
+ </Typography>
79
+ </>
80
+ );
81
+ };
82
+
83
+ export const Content = () => {
84
+ return <HomePageAlertCardContent />;
85
+ };
@@ -0,0 +1 @@
1
+ export { Content } from "./Content";
@@ -1,11 +1,12 @@
1
+ /// <reference types="@testing-library/jest-dom" />
1
2
  import { TestApiProvider, renderInTestApp } from "@backstage/test-utils";
2
- import React from "react";
3
+ import { vi, type Mocked } from "vitest";
3
4
  import { IncidentApi, IncidentApiRef } from "../../api/client";
4
5
  import { HomePageIncidentCardContent } from "./Content";
5
6
  import { ContextProvider } from "./Context";
6
7
 
7
- const mockIncidentApi: jest.Mocked<Partial<IncidentApi>> = {
8
- request: jest.fn().mockResolvedValue({
8
+ const mockIncidentApi: Mocked<Partial<IncidentApi>> = {
9
+ request: vi.fn().mockResolvedValue({
9
10
  incidents: [
10
11
  {
11
12
  id: "incident-id",
@@ -1,7 +1,7 @@
1
+ import { useMemo } from "react";
1
2
  import { Progress } from "@backstage/core-components";
2
3
  import Link from "@material-ui/core/Link";
3
4
  import { Alert } from "@material-ui/lab";
4
- import React from "react";
5
5
  import { useIncidentList } from "../../hooks/useIncidentRequest";
6
6
  import { Typography, List } from "@material-ui/core";
7
7
  import { IncidentListItem } from "../IncidentListItem";
@@ -14,7 +14,7 @@ export const HomePageIncidentCardContent = () => {
14
14
  const baseUrl =
15
15
  config.getOptionalString("incident.baseUrl") || "https://app.incident.io";
16
16
 
17
- const query = React.useMemo(() => {
17
+ const query = useMemo(() => {
18
18
  const params = new URLSearchParams();
19
19
  params.set(`${filterType}[one_of]`, filter);
20
20
  return params;
@@ -1,4 +1,4 @@
1
- import React, { createContext, useContext, useMemo } from "react";
1
+ import { createContext, useContext, useMemo } from "react";
2
2
 
3
3
  type HomePageIncidentCardContextValue = {
4
4
  filterType: "status_category" | "status";
@@ -10,7 +10,7 @@ const Context = createContext<HomePageIncidentCardContextValue | undefined>(
10
10
  );
11
11
 
12
12
  export const ContextProvider = (props: {
13
- children: React.JSX.Element;
13
+ children: JSX.Element;
14
14
  filterType?: "status_category" | "status";
15
15
  filter?: string;
16
16
  }) => {