@incident-io/backstage 0.0.2
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 +115 -0
- package/config.d.ts +64 -0
- package/dist/esm/index-91fad684.esm.js +62 -0
- package/dist/esm/index-91fad684.esm.js.map +1 -0
- package/dist/esm/index-c77be796.esm.js +182 -0
- package/dist/esm/index-c77be796.esm.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.esm.js +3 -0
- package/dist/index.esm.js.map +1 -0
- package/package.json +67 -0
- package/src/api/client.ts +85 -0
- package/src/api/types.ts +13242 -0
- package/src/components/EntityIncidentCard/index.tsx +182 -0
- package/src/components/IncidentListItem/index.tsx +125 -0
- package/src/config.ts +30 -0
- package/src/index.ts +16 -0
- package/src/plugin.test.ts +22 -0
- package/src/plugin.ts +52 -0
- package/src/setupTests.ts +17 -0
|
@@ -0,0 +1,182 @@
|
|
|
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 { Entity } from '@backstage/catalog-model';
|
|
17
|
+
import {
|
|
18
|
+
HeaderIconLinkRow,
|
|
19
|
+
IconLinkVerticalProps,
|
|
20
|
+
Progress,
|
|
21
|
+
} from '@backstage/core-components';
|
|
22
|
+
import { ConfigApi, configApiRef, useApi } from '@backstage/core-plugin-api';
|
|
23
|
+
import { useEntity } from '@backstage/plugin-catalog-react';
|
|
24
|
+
import {
|
|
25
|
+
Card,
|
|
26
|
+
CardContent,
|
|
27
|
+
CardHeader,
|
|
28
|
+
Divider,
|
|
29
|
+
IconButton,
|
|
30
|
+
List,
|
|
31
|
+
Typography,
|
|
32
|
+
} from '@material-ui/core';
|
|
33
|
+
import Link from '@material-ui/core/Link';
|
|
34
|
+
import CachedIcon from '@material-ui/icons/Cached';
|
|
35
|
+
import HistoryIcon from '@material-ui/icons/History';
|
|
36
|
+
import WhatshotIcon from '@material-ui/icons/Whatshot';
|
|
37
|
+
import { Alert } from '@material-ui/lab';
|
|
38
|
+
import React, { useState } from 'react';
|
|
39
|
+
import { useAsync } from 'react-use';
|
|
40
|
+
import { IncidentApiRef } from '../../api/client';
|
|
41
|
+
import { definitions } from '../../api/types';
|
|
42
|
+
import { getBaseUrl } from '../../config';
|
|
43
|
+
import { IncidentListItem } from '../IncidentListItem';
|
|
44
|
+
|
|
45
|
+
// The card displayed on the entity page showing a handful of the most recent
|
|
46
|
+
// incidents that are on-going for that component.
|
|
47
|
+
export const EntityIncidentCard = ({
|
|
48
|
+
maxIncidents = 2,
|
|
49
|
+
}: {
|
|
50
|
+
maxIncidents?: number;
|
|
51
|
+
}) => {
|
|
52
|
+
const config = useApi(configApiRef);
|
|
53
|
+
const baseUrl = getBaseUrl(config);
|
|
54
|
+
const { entity } = useEntity();
|
|
55
|
+
|
|
56
|
+
const IncidentApi = useApi(IncidentApiRef);
|
|
57
|
+
|
|
58
|
+
const [reload, setReload] = useState(false);
|
|
59
|
+
|
|
60
|
+
const entityFieldID = getEntityFieldID(config, entity);
|
|
61
|
+
const entityID = `${entity.metadata.namespace}/${entity.metadata.name}`;
|
|
62
|
+
|
|
63
|
+
// This query filters incidents for those that are associated with this
|
|
64
|
+
// entity.
|
|
65
|
+
const query = new URLSearchParams();
|
|
66
|
+
query.set(`custom_field[${entityFieldID}][one_of]`, entityID);
|
|
67
|
+
|
|
68
|
+
// This restricts the previous filter to focus only on live incidents.
|
|
69
|
+
const queryLive = new URLSearchParams(query);
|
|
70
|
+
queryLive.set(`status_category[one_of]`, 'live');
|
|
71
|
+
|
|
72
|
+
const createIncidentLink: IconLinkVerticalProps = {
|
|
73
|
+
label: 'Create incident',
|
|
74
|
+
disabled: false,
|
|
75
|
+
icon: <WhatshotIcon />,
|
|
76
|
+
href: `${baseUrl}/incidents/create`,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const viewIncidentsLink: IconLinkVerticalProps = {
|
|
80
|
+
label: 'View past incidents',
|
|
81
|
+
disabled: false,
|
|
82
|
+
icon: <HistoryIcon />,
|
|
83
|
+
href: `${baseUrl}/incidents?${query.toString()}`,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const {
|
|
87
|
+
value: incidentsResponse,
|
|
88
|
+
loading: incidentsLoading,
|
|
89
|
+
error: incidentsError,
|
|
90
|
+
} = useAsync(async () => {
|
|
91
|
+
return await IncidentApi.request<
|
|
92
|
+
definitions['IncidentsV2ListResponseBody']
|
|
93
|
+
>({
|
|
94
|
+
path: `/v2/incidents?${queryLive.toString()}`,
|
|
95
|
+
});
|
|
96
|
+
}, [reload]);
|
|
97
|
+
|
|
98
|
+
const incidents = incidentsResponse?.incidents;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Card>
|
|
102
|
+
<CardHeader
|
|
103
|
+
title="Incidents"
|
|
104
|
+
action={
|
|
105
|
+
<>
|
|
106
|
+
<IconButton
|
|
107
|
+
component={Link}
|
|
108
|
+
aria-label="Refresh"
|
|
109
|
+
disabled={false}
|
|
110
|
+
title="Refresh"
|
|
111
|
+
onClick={() => setReload(!reload)}
|
|
112
|
+
>
|
|
113
|
+
<CachedIcon />
|
|
114
|
+
</IconButton>
|
|
115
|
+
</>
|
|
116
|
+
}
|
|
117
|
+
subheader={
|
|
118
|
+
<HeaderIconLinkRow links={[createIncidentLink, viewIncidentsLink]} />
|
|
119
|
+
}
|
|
120
|
+
/>
|
|
121
|
+
<Divider />
|
|
122
|
+
<CardContent>
|
|
123
|
+
{incidentsLoading && <Progress />}
|
|
124
|
+
{incidentsError && (
|
|
125
|
+
<Alert severity="error">{incidentsError.message}</Alert>
|
|
126
|
+
)}
|
|
127
|
+
{!incidentsLoading && !incidentsError && incidents && (
|
|
128
|
+
<>
|
|
129
|
+
{incidents && incidents.length >= 0 && (
|
|
130
|
+
<Typography variant="subtitle1">
|
|
131
|
+
There are <strong>{incidents.length}</strong> ongoing incidents
|
|
132
|
+
involving <strong>{entity.metadata.name}</strong>.
|
|
133
|
+
</Typography>
|
|
134
|
+
)}
|
|
135
|
+
{incidents && incidents.length === 0 && (
|
|
136
|
+
<Typography variant="subtitle1">No ongoing incidents.</Typography>
|
|
137
|
+
)}
|
|
138
|
+
<List dense>
|
|
139
|
+
{incidents?.slice(0, maxIncidents)?.map(incident => {
|
|
140
|
+
return (
|
|
141
|
+
<IncidentListItem
|
|
142
|
+
key={incident.id}
|
|
143
|
+
incident={incident}
|
|
144
|
+
baseUrl={baseUrl}
|
|
145
|
+
/>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</List>
|
|
149
|
+
<Typography variant="subtitle1">
|
|
150
|
+
Click to{' '}
|
|
151
|
+
<Link
|
|
152
|
+
target="_blank"
|
|
153
|
+
href={`${baseUrl}/incidents?${queryLive.toString()}`}
|
|
154
|
+
>
|
|
155
|
+
see more.
|
|
156
|
+
</Link>
|
|
157
|
+
</Typography>
|
|
158
|
+
</>
|
|
159
|
+
)}
|
|
160
|
+
</CardContent>
|
|
161
|
+
</Card>
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Find the ID of the custom field in incident that represents the association
|
|
166
|
+
// to this type of entity.
|
|
167
|
+
//
|
|
168
|
+
// In practice, this will be kind=Component => ID of Affected components field.
|
|
169
|
+
function getEntityFieldID(config: ConfigApi, entity: Entity) {
|
|
170
|
+
switch (entity.kind) {
|
|
171
|
+
case 'API':
|
|
172
|
+
return config.get('integrations.incident.fields.api');
|
|
173
|
+
case 'Component':
|
|
174
|
+
return config.get('integrations.incident.fields.component');
|
|
175
|
+
case 'Domain':
|
|
176
|
+
return config.get('integrations.incident.fields.domain');
|
|
177
|
+
case 'System':
|
|
178
|
+
return config.get('integrations.incident.fields.system');
|
|
179
|
+
default:
|
|
180
|
+
throw new Error(`unrecognised entity kind: ${entity.kind}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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 { DateTime, Duration } from 'luxon';
|
|
17
|
+
import { BackstageTheme } from '@backstage/theme';
|
|
18
|
+
import {
|
|
19
|
+
Chip,
|
|
20
|
+
IconButton,
|
|
21
|
+
ListItem,
|
|
22
|
+
ListItemSecondaryAction,
|
|
23
|
+
ListItemText,
|
|
24
|
+
Tooltip,
|
|
25
|
+
Typography,
|
|
26
|
+
makeStyles,
|
|
27
|
+
} from '@material-ui/core';
|
|
28
|
+
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
|
|
29
|
+
import React from 'react';
|
|
30
|
+
import { definitions } from '../../api/types';
|
|
31
|
+
|
|
32
|
+
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
|
33
|
+
listItemPrimary: {
|
|
34
|
+
display: 'flex', // vertically align with chip
|
|
35
|
+
fontWeight: 'bold',
|
|
36
|
+
},
|
|
37
|
+
warning: {
|
|
38
|
+
borderColor: theme.palette.status.warning,
|
|
39
|
+
color: theme.palette.status.warning,
|
|
40
|
+
'& *': {
|
|
41
|
+
color: theme.palette.status.warning,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
error: {
|
|
45
|
+
borderColor: theme.palette.status.error,
|
|
46
|
+
color: theme.palette.status.error,
|
|
47
|
+
'& *': {
|
|
48
|
+
color: theme.palette.status.error,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
// Single item in the list of on-going incidents.
|
|
54
|
+
export const IncidentListItem = ({
|
|
55
|
+
baseUrl,
|
|
56
|
+
incident,
|
|
57
|
+
}: {
|
|
58
|
+
baseUrl: string;
|
|
59
|
+
incident: definitions['IncidentV2ResponseBody'];
|
|
60
|
+
}) => {
|
|
61
|
+
const classes = useStyles();
|
|
62
|
+
const reportedAt = incident.incident_timestamp_values?.find(ts =>
|
|
63
|
+
ts.incident_timestamp.name.match(/reported/i),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// If reported isn't here for some reason, use created at.
|
|
67
|
+
const reportedAtDate = reportedAt?.value?.value || incident.created_at;
|
|
68
|
+
|
|
69
|
+
const sinceReported =
|
|
70
|
+
new Date().getTime() - new Date(reportedAtDate).getTime();
|
|
71
|
+
const sinceReportedLabel = DateTime.local()
|
|
72
|
+
.minus(Duration.fromMillis(sinceReported))
|
|
73
|
+
.toRelative({ locale: 'en' });
|
|
74
|
+
const lead = incident.incident_role_assignments.find(roleAssignment => {
|
|
75
|
+
return roleAssignment.role.role_type === 'lead';
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<ListItem dense key={incident.id}>
|
|
80
|
+
<ListItemText
|
|
81
|
+
primary={
|
|
82
|
+
<>
|
|
83
|
+
<Chip
|
|
84
|
+
data-testid={`chip-${incident.incident_status.id}`}
|
|
85
|
+
label={incident.incident_status.name}
|
|
86
|
+
size="small"
|
|
87
|
+
variant="outlined"
|
|
88
|
+
className={
|
|
89
|
+
['live'].includes(incident.incident_status.category)
|
|
90
|
+
? classes.error
|
|
91
|
+
: classes.warning
|
|
92
|
+
}
|
|
93
|
+
/>
|
|
94
|
+
{incident.reference} {incident.name}
|
|
95
|
+
</>
|
|
96
|
+
}
|
|
97
|
+
primaryTypographyProps={{
|
|
98
|
+
variant: 'body1',
|
|
99
|
+
className: classes.listItemPrimary,
|
|
100
|
+
}}
|
|
101
|
+
secondary={
|
|
102
|
+
<Typography noWrap variant="body2" color="textSecondary">
|
|
103
|
+
Reported {sinceReportedLabel} and{' '}
|
|
104
|
+
{lead?.assignee
|
|
105
|
+
? `${lead.assignee.name} is lead`
|
|
106
|
+
: 'the lead is unassigned'}
|
|
107
|
+
.
|
|
108
|
+
</Typography>
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
111
|
+
<ListItemSecondaryAction>
|
|
112
|
+
<Tooltip title="View in incident.io" placement="top">
|
|
113
|
+
<IconButton
|
|
114
|
+
href={`${baseUrl}/incidents/${incident.id}`}
|
|
115
|
+
target="_blank"
|
|
116
|
+
rel="noopener noreferrer"
|
|
117
|
+
color="primary"
|
|
118
|
+
>
|
|
119
|
+
<OpenInBrowserIcon />
|
|
120
|
+
</IconButton>
|
|
121
|
+
</Tooltip>
|
|
122
|
+
</ListItemSecondaryAction>
|
|
123
|
+
</ListItem>
|
|
124
|
+
);
|
|
125
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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 { ConfigApi } from '@backstage/core-plugin-api';
|
|
17
|
+
|
|
18
|
+
// Find the baseUrl of the incident dashboard.
|
|
19
|
+
export function getBaseUrl(config: ConfigApi) {
|
|
20
|
+
try {
|
|
21
|
+
const baseUrl = config.getString('incident.baseUrl');
|
|
22
|
+
if (baseUrl !== '') {
|
|
23
|
+
return baseUrl;
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
// no action
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return 'https://app.incident.io';
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
export { incidentPlugin, EntityIncidentCard } from './plugin';
|
|
@@ -0,0 +1,22 @@
|
|
|
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 { incidentPlugin } from "./plugin";
|
|
17
|
+
|
|
18
|
+
describe("incident", () => {
|
|
19
|
+
it("should export plugin", () => {
|
|
20
|
+
expect(incidentPlugin).toBeDefined();
|
|
21
|
+
});
|
|
22
|
+
});
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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 {
|
|
17
|
+
createApiFactory,
|
|
18
|
+
createComponentExtension,
|
|
19
|
+
createPlugin,
|
|
20
|
+
discoveryApiRef,
|
|
21
|
+
identityApiRef,
|
|
22
|
+
} from '@backstage/core-plugin-api';
|
|
23
|
+
|
|
24
|
+
import { IncidentApi, IncidentApiRef } from './api/client';
|
|
25
|
+
|
|
26
|
+
export const incidentPlugin = createPlugin({
|
|
27
|
+
id: 'incident',
|
|
28
|
+
apis: [
|
|
29
|
+
createApiFactory({
|
|
30
|
+
api: IncidentApiRef,
|
|
31
|
+
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
|
|
32
|
+
factory: ({ discoveryApi, identityApi }) => {
|
|
33
|
+
return new IncidentApi({
|
|
34
|
+
discoveryApi: discoveryApi,
|
|
35
|
+
identityApi: identityApi,
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const EntityIncidentCard = incidentPlugin.provide(
|
|
43
|
+
createComponentExtension({
|
|
44
|
+
name: 'EntityIncidentCard',
|
|
45
|
+
component: {
|
|
46
|
+
lazy: () =>
|
|
47
|
+
import('./components/EntityIncidentCard').then(
|
|
48
|
+
m => m.EntityIncidentCard,
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
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 '@testing-library/jest-dom';
|
|
17
|
+
import 'cross-fetch/polyfill';
|