@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.
- package/README.md +22 -3
- package/config.d.ts +11 -5
- package/dist/alpha.esm.js +32 -6
- package/dist/alpha.esm.js.map +1 -1
- package/dist/{esm/client-646572ea.esm.js → api/client.esm.js} +6 -13
- package/dist/api/client.esm.js.map +1 -0
- package/dist/components/AlertListItem/index.esm.js +68 -0
- package/dist/components/AlertListItem/index.esm.js.map +1 -0
- package/dist/components/EntityAlertCard/index.esm.js +113 -0
- package/dist/components/EntityAlertCard/index.esm.js.map +1 -0
- package/dist/components/EntityIncidentCard/index.esm.js +125 -0
- package/dist/components/EntityIncidentCard/index.esm.js.map +1 -0
- package/dist/components/EntityOnCallCard/index.esm.js +198 -0
- package/dist/components/EntityOnCallCard/index.esm.js.map +1 -0
- package/dist/components/HomePageAlertCard/Content.esm.js +76 -0
- package/dist/components/HomePageAlertCard/Content.esm.js.map +1 -0
- package/dist/components/HomePageAlertCard/index.esm.js +2 -0
- package/dist/components/HomePageAlertCard/index.esm.js.map +1 -0
- package/dist/components/HomePageIncidentCard/Content.esm.js +54 -0
- package/dist/components/HomePageIncidentCard/Content.esm.js.map +1 -0
- package/dist/components/HomePageIncidentCard/Context.esm.js +33 -0
- package/dist/components/HomePageIncidentCard/Context.esm.js.map +1 -0
- package/dist/components/HomePageIncidentCard/index.esm.js +3 -0
- package/dist/components/HomePageIncidentCard/index.esm.js.map +1 -0
- package/dist/components/HomePageOnCallCard/Content.esm.js +38 -0
- package/dist/components/HomePageOnCallCard/Content.esm.js.map +1 -0
- package/dist/components/HomePageOnCallCard/index.esm.js +6 -0
- package/dist/components/HomePageOnCallCard/index.esm.js.map +1 -0
- package/dist/components/IncidentListItem/index.esm.js +68 -0
- package/dist/components/IncidentListItem/index.esm.js.map +1 -0
- package/dist/components/styles.esm.js +34 -0
- package/dist/components/styles.esm.js.map +1 -0
- package/dist/components/utils.esm.js +19 -0
- package/dist/components/utils.esm.js.map +1 -0
- package/dist/hooks/useIncidentRequest.esm.js +65 -0
- package/dist/hooks/useIncidentRequest.esm.js.map +1 -0
- package/dist/hooks/useOnCallRequest.esm.js +116 -0
- package/dist/hooks/useOnCallRequest.esm.js.map +1 -0
- package/dist/index.d.ts +9 -6
- package/dist/index.esm.js +1 -64
- package/dist/index.esm.js.map +1 -1
- package/dist/plugin.esm.js +99 -0
- package/dist/plugin.esm.js.map +1 -0
- package/package.json +44 -23
- package/src/alpha.test.ts +9 -0
- package/src/alpha.tsx +38 -4
- package/src/api/client.test.ts +43 -0
- package/src/api/types.test.ts +15 -0
- package/src/api/types.ts +49796 -11325
- package/src/components/AlertListItem/index.tsx +82 -0
- package/src/components/EntityAlertCard/index.test.tsx +242 -0
- package/src/components/EntityAlertCard/index.tsx +168 -0
- package/src/components/EntityIncidentCard/index.test.tsx +135 -0
- package/src/components/EntityIncidentCard/index.tsx +3 -23
- package/src/components/EntityOnCallCard/index.test.tsx +134 -0
- package/src/components/EntityOnCallCard/index.tsx +301 -0
- package/src/components/HomePageAlertCard/Content.test.tsx +56 -0
- package/src/components/HomePageAlertCard/Content.tsx +85 -0
- package/src/components/HomePageAlertCard/index.tsx +1 -0
- package/src/components/HomePageIncidentCard/Content.test.tsx +4 -3
- package/src/components/HomePageIncidentCard/Content.tsx +2 -2
- package/src/components/HomePageIncidentCard/Context.tsx +2 -2
- package/src/components/HomePageOnCallCard/Content.test.tsx +90 -0
- package/src/components/HomePageOnCallCard/Content.tsx +58 -0
- package/src/components/HomePageOnCallCard/index.ts +3 -0
- package/src/components/IncidentListItem/index.tsx +3 -26
- package/src/components/styles.tsx +30 -0
- package/src/components/utils.tsx +24 -0
- package/src/hooks/useIncidentRequest.test.ts +189 -0
- package/src/hooks/useIncidentRequest.ts +56 -3
- package/src/hooks/useOnCallRequest.test.ts +52 -0
- package/src/hooks/useOnCallRequest.ts +141 -0
- package/src/index.ts +4 -0
- package/src/plugin.ts +45 -1
- package/src/setupTests.ts +2 -2
- package/alpha/package.json +0 -7
- package/dist/esm/client-646572ea.esm.js.map +0 -1
- package/dist/esm/index-55bf4982.esm.js +0 -72
- package/dist/esm/index-55bf4982.esm.js.map +0 -1
- package/dist/esm/index-633a0241.esm.js +0 -96
- package/dist/esm/index-633a0241.esm.js.map +0 -1
- package/dist/esm/index-a220a8f7.esm.js +0 -116
- package/dist/esm/index-a220a8f7.esm.js.map +0 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { DateTime } from "luxon";
|
|
2
|
+
import {
|
|
3
|
+
Chip,
|
|
4
|
+
IconButton,
|
|
5
|
+
ListItem,
|
|
6
|
+
ListItemSecondaryAction,
|
|
7
|
+
ListItemText,
|
|
8
|
+
Tooltip,
|
|
9
|
+
Typography,
|
|
10
|
+
} from "@material-ui/core";
|
|
11
|
+
import OpenInBrowserIcon from "@material-ui/icons/OpenInBrowser";
|
|
12
|
+
import { components } from "../../api/types";
|
|
13
|
+
import { useStyles } from "../styles";
|
|
14
|
+
|
|
15
|
+
// Single item in the list of on-going alerts.
|
|
16
|
+
export const AlertListItem = ({
|
|
17
|
+
baseUrl,
|
|
18
|
+
alert,
|
|
19
|
+
source,
|
|
20
|
+
priority
|
|
21
|
+
}: {
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
alert: components["schemas"]["AlertV2"];
|
|
24
|
+
source: string;
|
|
25
|
+
priority?: string;
|
|
26
|
+
}) => {
|
|
27
|
+
const classes = useStyles();
|
|
28
|
+
|
|
29
|
+
const sinceCreatedLabel = DateTime.fromISO(alert.created_at).toRelative({ base: DateTime.now() });
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<ListItem dense key={alert.id}>
|
|
33
|
+
<ListItemText
|
|
34
|
+
primary={
|
|
35
|
+
<>
|
|
36
|
+
<Chip
|
|
37
|
+
data-testid={`chip-${alert.status}`}
|
|
38
|
+
label={alert.status}
|
|
39
|
+
size="small"
|
|
40
|
+
variant="outlined"
|
|
41
|
+
className={
|
|
42
|
+
["firing"].includes(alert.status)
|
|
43
|
+
? classes.error
|
|
44
|
+
: classes.success
|
|
45
|
+
}
|
|
46
|
+
/>
|
|
47
|
+
{alert.title}
|
|
48
|
+
{priority && (
|
|
49
|
+
<Chip
|
|
50
|
+
data-testid={`chip-${priority}`}
|
|
51
|
+
label={priority}
|
|
52
|
+
size="small"
|
|
53
|
+
variant="outlined"
|
|
54
|
+
/>
|
|
55
|
+
)}
|
|
56
|
+
</>
|
|
57
|
+
}
|
|
58
|
+
primaryTypographyProps={{
|
|
59
|
+
variant: "body1",
|
|
60
|
+
className: classes.listItemPrimary,
|
|
61
|
+
}}
|
|
62
|
+
secondary={
|
|
63
|
+
<Typography noWrap variant="body2" color="textSecondary">
|
|
64
|
+
Created {sinceCreatedLabel} from {source}.
|
|
65
|
+
</Typography>
|
|
66
|
+
}
|
|
67
|
+
/>
|
|
68
|
+
<ListItemSecondaryAction>
|
|
69
|
+
<Tooltip title="View in incident.io" placement="top">
|
|
70
|
+
<IconButton
|
|
71
|
+
href={`${baseUrl}/on-call/alerts/${alert.id}/details`}
|
|
72
|
+
target="_blank"
|
|
73
|
+
rel="noopener noreferrer"
|
|
74
|
+
color="primary"
|
|
75
|
+
>
|
|
76
|
+
<OpenInBrowserIcon />
|
|
77
|
+
</IconButton>
|
|
78
|
+
</Tooltip>
|
|
79
|
+
</ListItemSecondaryAction>
|
|
80
|
+
</ListItem>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
configApiRef: {},
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@backstage/plugin-catalog-react", () => ({
|
|
11
|
+
useEntity: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("../../hooks/useIncidentRequest", () => ({
|
|
15
|
+
useIncidentList: vi.fn(),
|
|
16
|
+
useIncidentAlertList: vi.fn(),
|
|
17
|
+
useAlertList: vi.fn(),
|
|
18
|
+
useAlertSourceList: vi.fn(),
|
|
19
|
+
useIdentity: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("@backstage/core-components", () => ({
|
|
23
|
+
Progress: () => <div data-testid="progress" />,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("../AlertListItem", () => ({
|
|
27
|
+
AlertListItem: ({ alert }: any) => (
|
|
28
|
+
<div data-testid={`alert-${alert.id}`}>{alert.title}</div>
|
|
29
|
+
),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("../utils", () => ({
|
|
33
|
+
getEntityFieldID: vi.fn(),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import { useApi } from "@backstage/core-plugin-api";
|
|
37
|
+
import { useEntity } from "@backstage/plugin-catalog-react";
|
|
38
|
+
import {
|
|
39
|
+
useIncidentList,
|
|
40
|
+
useIncidentAlertList,
|
|
41
|
+
useAlertList,
|
|
42
|
+
useAlertSourceList,
|
|
43
|
+
useIdentity,
|
|
44
|
+
} from "../../hooks/useIncidentRequest";
|
|
45
|
+
import { getEntityFieldID } from "../utils";
|
|
46
|
+
import { EntityAlertCard } from "./index";
|
|
47
|
+
|
|
48
|
+
const mockEntity = {
|
|
49
|
+
kind: "Component",
|
|
50
|
+
metadata: { name: "my-service", namespace: "default" },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const mockIdentityLoaded = {
|
|
54
|
+
value: { identity: { dashboard_url: "https://app.incident.io" } },
|
|
55
|
+
loading: false,
|
|
56
|
+
error: undefined,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const mockSourcesLoaded = {
|
|
60
|
+
value: { alert_sources: [] },
|
|
61
|
+
loading: false,
|
|
62
|
+
error: undefined,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
(useEntity as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
67
|
+
entity: mockEntity,
|
|
68
|
+
});
|
|
69
|
+
(useIdentity as ReturnType<typeof vi.fn>).mockReturnValue(mockIdentityLoaded);
|
|
70
|
+
(useAlertSourceList as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
71
|
+
mockSourcesLoaded,
|
|
72
|
+
);
|
|
73
|
+
(useApi as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
74
|
+
getOptional: () => "01FIELD123",
|
|
75
|
+
getOptionalString: () => undefined,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("EntityAlertCard", () => {
|
|
80
|
+
it("should show MissingConfigCard when getEntityFieldID returns undefined", () => {
|
|
81
|
+
(getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
|
82
|
+
(useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
83
|
+
value: undefined,
|
|
84
|
+
loading: false,
|
|
85
|
+
error: undefined,
|
|
86
|
+
});
|
|
87
|
+
(useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
88
|
+
value: undefined,
|
|
89
|
+
loading: false,
|
|
90
|
+
error: undefined,
|
|
91
|
+
});
|
|
92
|
+
(useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
93
|
+
value: undefined,
|
|
94
|
+
loading: false,
|
|
95
|
+
error: undefined,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
render(<EntityAlertCard />);
|
|
99
|
+
|
|
100
|
+
expect(
|
|
101
|
+
screen.getByText(/No custom field configuration was found/i),
|
|
102
|
+
).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should show progress when a hook is loading", () => {
|
|
106
|
+
(getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue("01FIELD123");
|
|
107
|
+
(useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
108
|
+
value: undefined,
|
|
109
|
+
loading: true,
|
|
110
|
+
error: undefined,
|
|
111
|
+
});
|
|
112
|
+
(useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
113
|
+
value: undefined,
|
|
114
|
+
loading: false,
|
|
115
|
+
error: undefined,
|
|
116
|
+
});
|
|
117
|
+
(useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
118
|
+
value: undefined,
|
|
119
|
+
loading: false,
|
|
120
|
+
error: undefined,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
render(<EntityAlertCard />);
|
|
124
|
+
|
|
125
|
+
expect(screen.getByTestId("progress")).toBeInTheDocument();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should show 'No alerts.' when incidentIds is empty", () => {
|
|
129
|
+
(getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue("01FIELD123");
|
|
130
|
+
(useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
131
|
+
value: { incidents: [] },
|
|
132
|
+
loading: false,
|
|
133
|
+
error: undefined,
|
|
134
|
+
});
|
|
135
|
+
(useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
136
|
+
value: { incident_alerts: [] },
|
|
137
|
+
loading: false,
|
|
138
|
+
error: undefined,
|
|
139
|
+
});
|
|
140
|
+
(useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
141
|
+
value: { alerts: [] },
|
|
142
|
+
loading: false,
|
|
143
|
+
error: undefined,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
render(<EntityAlertCard />);
|
|
147
|
+
|
|
148
|
+
expect(screen.getByText("No alerts.")).toBeInTheDocument();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("should show alert count and AlertListItem when alerts are linked", () => {
|
|
152
|
+
(getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue("01FIELD123");
|
|
153
|
+
(useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
154
|
+
value: { incidents: [{ id: "inc-1" }] },
|
|
155
|
+
loading: false,
|
|
156
|
+
error: undefined,
|
|
157
|
+
});
|
|
158
|
+
(useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
159
|
+
value: { incident_alerts: [{ alert: { id: "alert-1" } }] },
|
|
160
|
+
loading: false,
|
|
161
|
+
error: undefined,
|
|
162
|
+
});
|
|
163
|
+
(useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
164
|
+
value: {
|
|
165
|
+
alerts: [
|
|
166
|
+
{
|
|
167
|
+
id: "alert-1",
|
|
168
|
+
title: "High error rate",
|
|
169
|
+
status: "firing",
|
|
170
|
+
alert_source_id: "src-1",
|
|
171
|
+
attributes: [],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
loading: false,
|
|
176
|
+
error: undefined,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
render(<EntityAlertCard />);
|
|
180
|
+
|
|
181
|
+
expect(
|
|
182
|
+
screen.getByText((_, el) => {
|
|
183
|
+
if (!el) return false;
|
|
184
|
+
return (
|
|
185
|
+
el.tagName === "H6" &&
|
|
186
|
+
!!el.textContent?.includes("There are") &&
|
|
187
|
+
!!el.textContent?.includes("1") &&
|
|
188
|
+
!!el.textContent?.includes("firing")
|
|
189
|
+
);
|
|
190
|
+
}),
|
|
191
|
+
).toBeInTheDocument();
|
|
192
|
+
expect(screen.getByTestId("alert-alert-1")).toBeInTheDocument();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should render a refresh button", () => {
|
|
196
|
+
(getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue("01FIELD123");
|
|
197
|
+
(useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
198
|
+
value: { incidents: [] },
|
|
199
|
+
loading: false,
|
|
200
|
+
error: undefined,
|
|
201
|
+
});
|
|
202
|
+
(useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
203
|
+
value: { incident_alerts: [] },
|
|
204
|
+
loading: false,
|
|
205
|
+
error: undefined,
|
|
206
|
+
});
|
|
207
|
+
(useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
208
|
+
value: { alerts: [] },
|
|
209
|
+
loading: false,
|
|
210
|
+
error: undefined,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
render(<EntityAlertCard />);
|
|
214
|
+
|
|
215
|
+
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("should render status filter tabs", () => {
|
|
219
|
+
(getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue("01FIELD123");
|
|
220
|
+
(useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
221
|
+
value: { incidents: [] },
|
|
222
|
+
loading: false,
|
|
223
|
+
error: undefined,
|
|
224
|
+
});
|
|
225
|
+
(useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
226
|
+
value: { incident_alerts: [] },
|
|
227
|
+
loading: false,
|
|
228
|
+
error: undefined,
|
|
229
|
+
});
|
|
230
|
+
(useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
231
|
+
value: { alerts: [] },
|
|
232
|
+
loading: false,
|
|
233
|
+
error: undefined,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
render(<EntityAlertCard />);
|
|
237
|
+
|
|
238
|
+
expect(screen.getByRole("tab", { name: "Firing" })).toBeInTheDocument();
|
|
239
|
+
expect(screen.getByRole("tab", { name: "Resolved" })).toBeInTheDocument();
|
|
240
|
+
expect(screen.getByRole("tab", { name: "All" })).toBeInTheDocument();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Progress } from "@backstage/core-components";
|
|
2
|
+
import { configApiRef, useApi } from "@backstage/core-plugin-api";
|
|
3
|
+
import { useEntity } from "@backstage/plugin-catalog-react";
|
|
4
|
+
import {
|
|
5
|
+
Box,
|
|
6
|
+
Card,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardHeader,
|
|
9
|
+
Divider,
|
|
10
|
+
IconButton,
|
|
11
|
+
List,
|
|
12
|
+
Tab,
|
|
13
|
+
Tabs,
|
|
14
|
+
Typography,
|
|
15
|
+
} from "@material-ui/core";
|
|
16
|
+
import Link from "@material-ui/core/Link";
|
|
17
|
+
import { Alert } from "@material-ui/lab";
|
|
18
|
+
import CachedIcon from "@material-ui/icons/Cached";
|
|
19
|
+
import { useState } from "react";
|
|
20
|
+
import {
|
|
21
|
+
useAlertList,
|
|
22
|
+
useAlertSourceList,
|
|
23
|
+
useIdentity,
|
|
24
|
+
useIncidentAlertList,
|
|
25
|
+
useIncidentList,
|
|
26
|
+
} from "../../hooks/useIncidentRequest";
|
|
27
|
+
import { getEntityFieldID } from "../utils";
|
|
28
|
+
import { AlertListItem } from "../AlertListItem";
|
|
29
|
+
|
|
30
|
+
type StatusFilter = "firing" | "resolved" | undefined;
|
|
31
|
+
|
|
32
|
+
const STATUS_TABS: { label: string; value: StatusFilter }[] = [
|
|
33
|
+
{ label: "Firing", value: "firing" },
|
|
34
|
+
{ label: "Resolved", value: "resolved" },
|
|
35
|
+
{ label: "All", value: undefined },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const IncorrectConfigCard = () => (
|
|
39
|
+
<Card>
|
|
40
|
+
<CardHeader title="Alerts" />
|
|
41
|
+
<Divider />
|
|
42
|
+
<CardContent>
|
|
43
|
+
<Typography variant="subtitle1">
|
|
44
|
+
No custom field configuration was found. In order to display alerts,
|
|
45
|
+
this entity must be mapped to an incident.io custom field ID in
|
|
46
|
+
Backstage's app-config.yaml.
|
|
47
|
+
</Typography>
|
|
48
|
+
</CardContent>
|
|
49
|
+
</Card>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
export const EntityAlertCard = () => {
|
|
53
|
+
const [statusFilter, setStatusFilter] = useState<StatusFilter>("firing");
|
|
54
|
+
const [reload, setReload] = useState(false);
|
|
55
|
+
|
|
56
|
+
const config = useApi(configApiRef);
|
|
57
|
+
const { entity } = useEntity();
|
|
58
|
+
const entityFieldID = getEntityFieldID(config, entity);
|
|
59
|
+
const entityID = `${entity.metadata.namespace}/${entity.metadata.name}`;
|
|
60
|
+
|
|
61
|
+
// query for incidents associated with this entity
|
|
62
|
+
const incidentQuery = new URLSearchParams();
|
|
63
|
+
incidentQuery.set(`custom_field[${entityFieldID}][one_of]`, entityID);
|
|
64
|
+
|
|
65
|
+
const { value: incidentsResponse, loading: incidentsLoading } =
|
|
66
|
+
useIncidentList(incidentQuery, [reload]);
|
|
67
|
+
const incidentIds = (incidentsResponse?.incidents ?? []).map(i => i.id);
|
|
68
|
+
|
|
69
|
+
// query for alerts linked to those incidents
|
|
70
|
+
const { value: incidentAlertsResponse, loading: incidentAlertsLoading } =
|
|
71
|
+
useIncidentAlertList(incidentIds, [reload]);
|
|
72
|
+
const linkedAlertIds = new Set(
|
|
73
|
+
(incidentAlertsResponse?.incident_alerts ?? []).map(ia => ia.alert.id),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const { value: alertsResponse, loading: alertsLoading, error } =
|
|
77
|
+
useAlertList(statusFilter, [reload]);
|
|
78
|
+
const { value: sourcesResponse } = useAlertSourceList();
|
|
79
|
+
const { value: identityResponse } = useIdentity();
|
|
80
|
+
|
|
81
|
+
// get alerts for this entity's incidents
|
|
82
|
+
const allAlerts = alertsResponse?.alerts ?? [];
|
|
83
|
+
const alerts = incidentIds.length > 0
|
|
84
|
+
? allAlerts.filter(a => linkedAlertIds.has(a.id))
|
|
85
|
+
: [];
|
|
86
|
+
|
|
87
|
+
const baseUrl = identityResponse?.identity.dashboard_url ?? "";
|
|
88
|
+
|
|
89
|
+
const sourceById = Object.fromEntries(
|
|
90
|
+
(sourcesResponse?.alert_sources ?? []).map(s => [s.id, s]),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const currentTabIndex = STATUS_TABS.findIndex(t => t.value === statusFilter);
|
|
94
|
+
if (!entityFieldID) {
|
|
95
|
+
return <IncorrectConfigCard />;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (incidentsLoading || incidentAlertsLoading || alertsLoading) {
|
|
99
|
+
return <Progress />;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<Card>
|
|
104
|
+
<CardHeader
|
|
105
|
+
title="incident.io Alerts"
|
|
106
|
+
action={
|
|
107
|
+
<>
|
|
108
|
+
<IconButton
|
|
109
|
+
component={Link}
|
|
110
|
+
aria-label="Refresh"
|
|
111
|
+
disabled={false}
|
|
112
|
+
title="Refresh"
|
|
113
|
+
onClick={() => setReload(!reload)}
|
|
114
|
+
>
|
|
115
|
+
<CachedIcon />
|
|
116
|
+
</IconButton>
|
|
117
|
+
</>
|
|
118
|
+
}
|
|
119
|
+
/>
|
|
120
|
+
<Divider />
|
|
121
|
+
<Tabs
|
|
122
|
+
value={currentTabIndex}
|
|
123
|
+
onChange={(_, idx) => setStatusFilter(STATUS_TABS[idx].value)}
|
|
124
|
+
indicatorColor="primary"
|
|
125
|
+
textColor="primary"
|
|
126
|
+
>
|
|
127
|
+
{STATUS_TABS.map(tab => (
|
|
128
|
+
<Tab key={tab.label} label={tab.label} />
|
|
129
|
+
))}
|
|
130
|
+
</Tabs>
|
|
131
|
+
<Divider />
|
|
132
|
+
<CardContent>
|
|
133
|
+
{error && <Alert severity="error">{error.message}</Alert>}
|
|
134
|
+
{!error && alerts.length === 0 && (
|
|
135
|
+
<Typography variant="subtitle1">No alerts.</Typography>
|
|
136
|
+
)}
|
|
137
|
+
{!error && alerts.length > 0 && (
|
|
138
|
+
<>
|
|
139
|
+
<Typography variant="subtitle1">
|
|
140
|
+
There are <strong>{alerts.length}</strong>{" "}
|
|
141
|
+
{statusFilter ?? ""} alerts.
|
|
142
|
+
</Typography>
|
|
143
|
+
<Box style={{ maxHeight: 400, overflowY: "auto" }}>
|
|
144
|
+
<List dense>
|
|
145
|
+
{alerts.map(alert => (
|
|
146
|
+
<AlertListItem
|
|
147
|
+
key={alert.id}
|
|
148
|
+
alert={alert}
|
|
149
|
+
baseUrl={baseUrl}
|
|
150
|
+
source={sourceById[alert.alert_source_id]?.name ?? "-"}
|
|
151
|
+
priority={alert.attributes.find(a => a.attribute.name === "Priority")?.value?.label}
|
|
152
|
+
/>
|
|
153
|
+
))}
|
|
154
|
+
</List>
|
|
155
|
+
</Box>
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
{baseUrl && (
|
|
159
|
+
<Typography variant="subtitle1">
|
|
160
|
+
<Link target="_blank" href={`${baseUrl}/on-call/alerts`}>
|
|
161
|
+
View all alerts
|
|
162
|
+
</Link>
|
|
163
|
+
</Typography>
|
|
164
|
+
)}
|
|
165
|
+
</CardContent>
|
|
166
|
+
</Card>
|
|
167
|
+
);
|
|
168
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
configApiRef: {},
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@backstage/plugin-catalog-react", () => ({
|
|
11
|
+
useEntity: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("../../hooks/useIncidentRequest", () => ({
|
|
15
|
+
useIncidentList: vi.fn(),
|
|
16
|
+
useIdentity: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("@backstage/core-components", () => ({
|
|
20
|
+
Progress: () => <div data-testid="progress" />,
|
|
21
|
+
HeaderIconLinkRow: () => null,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("../IncidentListItem", () => ({
|
|
25
|
+
IncidentListItem: ({ incident }: any) => <div>{incident.name}</div>,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { useApi } from "@backstage/core-plugin-api";
|
|
29
|
+
import { useEntity } from "@backstage/plugin-catalog-react";
|
|
30
|
+
import { useIncidentList, useIdentity } from "../../hooks/useIncidentRequest";
|
|
31
|
+
import { EntityIncidentCard } from "./index";
|
|
32
|
+
|
|
33
|
+
const mockEntity = {
|
|
34
|
+
kind: "Component",
|
|
35
|
+
metadata: { name: "my-service", namespace: "default" },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const mockIdentityLoaded = {
|
|
39
|
+
value: { identity: { dashboard_url: "https://app.incident.io" } },
|
|
40
|
+
loading: false,
|
|
41
|
+
error: undefined,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
(useEntity as ReturnType<typeof vi.fn>).mockReturnValue({ entity: mockEntity });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("EntityIncidentCard", () => {
|
|
49
|
+
it("should show misconfiguration message when no custom field is configured", () => {
|
|
50
|
+
(useApi as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
51
|
+
getOptional: () => undefined,
|
|
52
|
+
});
|
|
53
|
+
(useIdentity as ReturnType<typeof vi.fn>).mockReturnValue(mockIdentityLoaded);
|
|
54
|
+
(useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
55
|
+
value: undefined,
|
|
56
|
+
loading: false,
|
|
57
|
+
error: undefined,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
render(<EntityIncidentCard />);
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
screen.getByText(/No custom field configuration was found/i),
|
|
64
|
+
).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should show a loading indicator while fetching", () => {
|
|
68
|
+
(useApi as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
69
|
+
getOptional: () => "01FIELD123",
|
|
70
|
+
});
|
|
71
|
+
(useIdentity as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
72
|
+
value: undefined,
|
|
73
|
+
loading: true,
|
|
74
|
+
error: undefined,
|
|
75
|
+
});
|
|
76
|
+
(useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
77
|
+
value: undefined,
|
|
78
|
+
loading: true,
|
|
79
|
+
error: undefined,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
render(<EntityIncidentCard />);
|
|
83
|
+
|
|
84
|
+
expect(screen.getByTestId("progress")).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should show empty state when there are no ongoing incidents", () => {
|
|
88
|
+
(useApi as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
89
|
+
getOptional: () => "01FIELD123",
|
|
90
|
+
});
|
|
91
|
+
(useIdentity as ReturnType<typeof vi.fn>).mockReturnValue(mockIdentityLoaded);
|
|
92
|
+
(useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
93
|
+
value: { incidents: [] },
|
|
94
|
+
loading: false,
|
|
95
|
+
error: undefined,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
render(<EntityIncidentCard />);
|
|
99
|
+
|
|
100
|
+
expect(screen.getByText(/No ongoing incidents/i)).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should show incident count and list items when incidents exist", () => {
|
|
104
|
+
(useApi as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
105
|
+
getOptional: () => "01FIELD123",
|
|
106
|
+
});
|
|
107
|
+
(useIdentity as ReturnType<typeof vi.fn>).mockReturnValue(mockIdentityLoaded);
|
|
108
|
+
(useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
109
|
+
value: {
|
|
110
|
+
incidents: [
|
|
111
|
+
{ id: "INC-1", name: "Database down" },
|
|
112
|
+
{ id: "INC-2", name: "API latency spike" },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
loading: false,
|
|
116
|
+
error: undefined,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
render(<EntityIncidentCard />);
|
|
120
|
+
|
|
121
|
+
expect(
|
|
122
|
+
screen.getByText((_, el) => {
|
|
123
|
+
if (!el) return false;
|
|
124
|
+
return (
|
|
125
|
+
el.tagName === "H6" &&
|
|
126
|
+
!!el.textContent?.includes("There are") &&
|
|
127
|
+
!!el.textContent?.includes("2") &&
|
|
128
|
+
!!el.textContent?.includes("ongoing incidents")
|
|
129
|
+
);
|
|
130
|
+
})
|
|
131
|
+
).toBeInTheDocument();
|
|
132
|
+
expect(screen.getByText("Database down")).toBeInTheDocument();
|
|
133
|
+
expect(screen.getByText("API latency spike")).toBeInTheDocument();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -13,13 +13,12 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import { Entity } from "@backstage/catalog-model";
|
|
17
16
|
import {
|
|
18
17
|
HeaderIconLinkRow,
|
|
19
18
|
IconLinkVerticalProps,
|
|
20
19
|
Progress,
|
|
21
20
|
} from "@backstage/core-components";
|
|
22
|
-
import {
|
|
21
|
+
import { configApiRef, useApi } from "@backstage/core-plugin-api";
|
|
23
22
|
import { useEntity } from "@backstage/plugin-catalog-react";
|
|
24
23
|
import {
|
|
25
24
|
Card,
|
|
@@ -35,9 +34,10 @@ import CachedIcon from "@material-ui/icons/Cached";
|
|
|
35
34
|
import HistoryIcon from "@material-ui/icons/History";
|
|
36
35
|
import WhatshotIcon from "@material-ui/icons/Whatshot";
|
|
37
36
|
import { Alert } from "@material-ui/lab";
|
|
38
|
-
import
|
|
37
|
+
import { useState } from "react";
|
|
39
38
|
import { useIncidentList, useIdentity } from "../../hooks/useIncidentRequest";
|
|
40
39
|
import { IncidentListItem } from "../IncidentListItem";
|
|
40
|
+
import { getEntityFieldID } from "../utils";
|
|
41
41
|
|
|
42
42
|
const IncorrectConfigCard = () => {
|
|
43
43
|
return (
|
|
@@ -183,23 +183,3 @@ export const EntityIncidentCard = ({
|
|
|
183
183
|
);
|
|
184
184
|
};
|
|
185
185
|
|
|
186
|
-
// Find the ID of the custom field in incident that represents the association
|
|
187
|
-
// to this type of entity.
|
|
188
|
-
//
|
|
189
|
-
// In practice, this will be kind=Component => ID of Affected components field.
|
|
190
|
-
function getEntityFieldID(config: ConfigApi, entity: Entity) {
|
|
191
|
-
switch (entity.kind) {
|
|
192
|
-
case "API":
|
|
193
|
-
return config.getOptional("incident.fields.api");
|
|
194
|
-
case "Component":
|
|
195
|
-
return config.getOptional("incident.fields.component");
|
|
196
|
-
case "Domain":
|
|
197
|
-
return config.getOptional("incident.fields.domain");
|
|
198
|
-
case "System":
|
|
199
|
-
return config.getOptional("incident.fields.system");
|
|
200
|
-
case "Group":
|
|
201
|
-
return config.getOptional("incident.fields.group");
|
|
202
|
-
default:
|
|
203
|
-
throw new Error(`unrecognised entity kind: ${entity.kind}`);
|
|
204
|
-
}
|
|
205
|
-
}
|