Merge pull request #4719 from ayshiff/feature/splunk-on-call-plugin-rest-endpoint

Splunk On-Call plugin: REST endpoint
This commit is contained in:
Ben Lambert
2021-03-04 18:46:42 +01:00
committed by GitHub
13 changed files with 347 additions and 420 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-splunk-on-call': minor
---
Updated splunk-on-call plugin to use the REST endpoint (incident creation-acknowledgement-resolution).
It implies switching from `splunkOnCall.username` configuration to `splunkOnCall.eventsRestEndpoint` configuration, this is a breaking change.
+13 -5
View File
@@ -48,18 +48,18 @@ import {
## Client configuration
In order to be able to perform certain action (create-acknowledge-resolve an action), you need to provide the username of the user making the action.
The user supplied must be a valid Splunk On-Call user and a member of your organization.
In order to be able to perform certain action (create-acknowledge-resolve an action), you need to provide a REST Endpoint.
To enable the REST Endpoint integration you can go on https://portal.victorops.com/ inside Integrations > 3rd Party Integrations > REST Generic.
You can now copy the URL to notify: `<SPLUNK_ON_CALL_REST_ENDPOINT>/$routing_key`
In `app-config.yaml`:
```yaml
splunkOnCall:
username: <SPLUNK_ON_CALL_USERNAME>
eventsRestEndpoint: <SPLUNK_ON_CALL_REST_ENDPOINT>
```
The user supplied must be a valid Splunk On-Call user and a member of your organization.
In order to make the API calls, you need to provide a new proxy config which will redirect to the Splunk On-Call API endpoint and add authentication information in the headers:
```yaml
@@ -87,6 +87,14 @@ annotations:
splunk.com/on-call-team': <SPLUNK_ON_CALL_TEAM_NAME>
```
### Create the Routing Key
To be able to use the REST Endpoint seen above, you must have created a routing key with the **same name** as the provided team.
You can create a new routing key on https://portal.victorops.com/ by going to Settings > Routing Keys.
You can read [Create & Manage Alert Routing Keys](https://help.victorops.com/knowledge-base/routing-keys/#routing-key-tips-tricks) for further information.
## Providing the API key and API id
In order for the client to make requests to the [Splunk On-Call API](https://portal.victorops.com/public/api-docs.html#/) it needs an [API ID and an API Key](https://help.victorops.com/knowledge-base/api/).
+18 -62
View File
@@ -31,7 +31,6 @@ import {
RequestOptions,
ListUserResponse,
EscalationPolicyResponse,
PatchIncidentRequest,
} from './types';
export class UnauthorizedError extends Error {}
@@ -43,10 +42,10 @@ export const splunkOnCallApiRef = createApiRef<SplunkOnCallApi>({
export class SplunkOnCallClient implements SplunkOnCallApi {
static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) {
const usernameFromConfig: string | null =
configApi.getOptionalString('splunkOnCall.username') || null;
const eventsRestEndpoint: string | null =
configApi.getOptionalString('splunkOnCall.eventsRestEndpoint') || null;
return new SplunkOnCallClient({
username: usernameFromConfig,
eventsRestEndpoint,
discoveryApi,
});
}
@@ -80,50 +79,6 @@ export class SplunkOnCallClient implements SplunkOnCallApi {
return teams;
}
async acknowledgeIncident({
incidentNames,
}: PatchIncidentRequest): Promise<Response> {
const options = {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
userName: this.config.username,
incidentNames,
}),
};
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/splunk-on-call/v1/incidents/ack`;
return this.request(url, options);
}
async resolveIncident({
incidentNames,
}: PatchIncidentRequest): Promise<Response> {
const options = {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
userName: this.config.username,
incidentNames,
}),
};
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/splunk-on-call/v1/incidents/resolve`;
return this.request(url, options);
}
async getUsers(): Promise<User[]> {
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
@@ -142,19 +97,22 @@ export class SplunkOnCallClient implements SplunkOnCallApi {
return policies;
}
async triggerAlarm({
summary,
details,
userName,
targets,
isMultiResponder,
async incidentAction({
routingKey,
incidentType,
incidentId,
incidentDisplayName,
incidentMessage,
incidentStartTime,
}: TriggerAlarmRequest): Promise<Response> {
const body = JSON.stringify({
summary,
details,
userName: this.config.username || userName,
targets,
isMultiResponder,
message_type: incidentType,
...(incidentId ? { entity_id: incidentId } : {}),
...(incidentDisplayName
? { entity_display_name: incidentDisplayName }
: {}),
...(incidentMessage ? { state_message: incidentMessage } : {}),
...(incidentStartTime ? { state_start_time: incidentStartTime } : {}),
});
const options = {
@@ -166,9 +124,7 @@ export class SplunkOnCallClient implements SplunkOnCallApi {
body,
};
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/splunk-on-call/v1/incidents`;
const url = `${this.config.eventsRestEndpoint}/${routingKey}`;
return this.request(url, options);
}
+15 -32
View File
@@ -23,22 +23,20 @@ import {
} from '../components/types';
import { DiscoveryApi } from '@backstage/core';
export enum TargetType {
UserValue = 'User',
EscalationPolicyValue = 'EscalationPolicy',
}
export type IncidentTarget = {
type: TargetType;
slug: string;
};
export type MessageType =
| 'CRITICAL'
| 'WARNING'
| 'ACKNOWLEDGEMENT'
| 'INFO'
| 'RECOVERY';
export type TriggerAlarmRequest = {
targets: IncidentTarget[];
details: string;
summary: string;
userName: string;
isMultiResponder?: boolean;
routingKey?: string;
incidentType: MessageType;
incidentId?: string;
incidentDisplayName?: string;
incidentMessage?: string;
incidentStartTime?: number;
};
export interface SplunkOnCallApi {
@@ -53,19 +51,9 @@ export interface SplunkOnCallApi {
getOnCallUsers(): Promise<OnCall[]>;
/**
* Triggers an incident to specific users and/or specific teams.
* Triggers-Resolves-Acknowledge an incident.
*/
triggerAlarm(request: TriggerAlarmRequest): Promise<Response>;
/**
* Resolves an incident.
*/
resolveIncident(request: PatchIncidentRequest): Promise<Response>;
/**
* Acknowledge an incident.
*/
acknowledgeIncident(request: PatchIncidentRequest): Promise<Response>;
incidentAction(request: TriggerAlarmRequest): Promise<Response>;
/**
* Get a list of users for your organization.
@@ -83,11 +71,6 @@ export interface SplunkOnCallApi {
getEscalationPolicies(): Promise<EscalationPolicyInfo[]>;
}
export type PatchIncidentRequest = {
incidentNames: string[];
message?: string;
};
export type EscalationPolicyResponse = {
policies: EscalationPolicyInfo[];
};
@@ -106,7 +89,7 @@ export type OnCallsResponse = {
};
export type ClientApiConfig = {
username: string | null;
eventsRestEndpoint: string | null;
discoveryApi: DiscoveryApi;
};
@@ -15,7 +15,13 @@
*/
import React from 'react';
import { List, ListSubheader } from '@material-ui/core';
import {
createStyles,
List,
ListSubheader,
makeStyles,
Theme,
} from '@material-ui/core';
import { EscalationUsersEmptyState } from './EscalationUsersEmptyState';
import { EscalationUser } from './EscalationUser';
import { useAsync } from 'react-use';
@@ -24,12 +30,28 @@ import { useApi, Progress } from '@backstage/core';
import { Alert } from '@material-ui/lab';
import { User } from '../types';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
maxHeight: '400px',
overflow: 'auto',
},
subheader: {
backgroundColor: theme.palette.background.paper,
},
progress: {
margin: `0 ${theme.spacing(2)}px`,
},
}),
);
type Props = {
users: { [key: string]: User };
team: string;
};
export const EscalationPolicy = ({ users, team }: Props) => {
const classes = useStyles();
const api = useApi(splunkOnCallApiRef);
const { value: userNames, loading, error } = useAsync(async () => {
@@ -54,24 +76,30 @@ export const EscalationPolicy = ({ users, team }: Props) => {
);
}
if (loading) {
return <Progress />;
}
if (!userNames?.length) {
if (!loading && !userNames?.length) {
return <EscalationUsersEmptyState />;
}
return (
<List dense subheader={<ListSubheader>ON CALL</ListSubheader>}>
{userNames &&
<List
className={classes.root}
dense
subheader={
<ListSubheader className={classes.subheader}>ON CALL</ListSubheader>
}
>
{loading ? (
<Progress className={classes.progress} />
) : (
userNames &&
userNames.map(
(userName, index) =>
userName &&
userName in users && (
<EscalationUser key={index} user={users[userName]} />
),
)}
)
)}
</List>
);
};
@@ -39,7 +39,7 @@ import { Incident, IncidentPhase } from '../types';
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
import { splunkOnCallApiRef } from '../../api/client';
import { useAsyncFn } from 'react-use';
import { PatchIncidentRequest } from '../../api/types';
import { TriggerAlarmRequest } from '../../api/types';
const useStyles = makeStyles({
denseListIcon: {
@@ -61,6 +61,7 @@ const useStyles = makeStyles({
});
type Props = {
team: string;
incident: Incident;
onIncidentAction: () => void;
};
@@ -93,20 +94,24 @@ const incidentPhaseTooltip = (currentPhase: IncidentPhase) => {
const IncidentAction = ({
currentPhase,
incidentNames,
incidentId,
resolveAction,
acknowledgeAction,
}: {
currentPhase: string;
incidentNames: string[];
resolveAction: (args: PatchIncidentRequest) => void;
acknowledgeAction: (args: PatchIncidentRequest) => void;
incidentId: string;
resolveAction: (args: TriggerAlarmRequest) => void;
acknowledgeAction: (args: TriggerAlarmRequest) => void;
}) => {
switch (currentPhase) {
case 'UNACKED':
return (
<Tooltip title="Aknowledge" placement="top">
<IconButton onClick={() => acknowledgeAction({ incidentNames })}>
<IconButton
onClick={() =>
acknowledgeAction({ incidentId, incidentType: 'ACKNOWLEDGEMENT' })
}
>
<DoneIcon />
</IconButton>
</Tooltip>
@@ -114,7 +119,11 @@ const IncidentAction = ({
case 'ACKED':
return (
<Tooltip title="Resolve" placement="top">
<IconButton onClick={() => resolveAction({ incidentNames })}>
<IconButton
onClick={() =>
resolveAction({ incidentId, incidentType: 'RECOVERY' })
}
>
<DoneAllIcon />
</IconButton>
</Tooltip>
@@ -124,7 +133,11 @@ const IncidentAction = ({
}
};
export const IncidentListItem = ({ incident, onIncidentAction }: Props) => {
export const IncidentListItem = ({
incident,
onIncidentAction,
team,
}: Props) => {
const classes = useStyles();
const duration =
new Date().getTime() - new Date(incident.startTime!).getTime();
@@ -136,17 +149,26 @@ export const IncidentListItem = ({ incident, onIncidentAction }: Props) => {
const hasBeenManuallyTriggered = incident.monitorName?.includes('vouser-');
const user = hasBeenManuallyTriggered
? incident.monitorName?.replace('vouser-', '')
: incident.monitorName;
const source = () => {
if (hasBeenManuallyTriggered) {
return incident.monitorName?.replace('vouser-', '');
}
if (incident.monitorType === 'API') {
return '{ REST }';
}
return incident.monitorName;
};
const [
{ value: resolveValue, error: resolveError },
handleResolveIncident,
] = useAsyncFn(
async ({ incidentNames }: PatchIncidentRequest) =>
await api.resolveIncident({
incidentNames,
async ({ incidentId, incidentType }: TriggerAlarmRequest) =>
await api.incidentAction({
routingKey: team,
incidentType,
incidentId,
}),
);
@@ -154,9 +176,11 @@ export const IncidentListItem = ({ incident, onIncidentAction }: Props) => {
{ value: acknowledgeValue, error: acknowledgeError },
handleAcknowledgeIncident,
] = useAsyncFn(
async ({ incidentNames }: PatchIncidentRequest) =>
await api.acknowledgeIncident({
incidentNames,
async ({ incidentId, incidentType }: TriggerAlarmRequest) =>
await api.incidentAction({
routingKey: team,
incidentType,
incidentId,
}),
);
@@ -211,7 +235,8 @@ export const IncidentListItem = ({ incident, onIncidentAction }: Props) => {
}}
secondary={
<Typography noWrap variant="body2" color="textSecondary">
Created {createdAt} {user && `by ${user}`}
#{incident.incidentNumber} - Created {createdAt}{' '}
{source() && `by ${source()}`}
</Typography>
}
/>
@@ -220,7 +245,7 @@ export const IncidentListItem = ({ incident, onIncidentAction }: Props) => {
<ListItemSecondaryAction>
<IncidentAction
currentPhase={incident.currentPhase || ''}
incidentNames={[incident.incidentNumber]}
incidentId={incident.entityId}
resolveAction={handleResolveIncident}
acknowledgeAction={handleAcknowledgeIncident}
/>
@@ -62,7 +62,10 @@ describe('Incidents', () => {
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('Nice! No incidents found!')).toBeInTheDocument();
await waitFor(
() => expect(getByText('Nice! No incidents found!')).toBeInTheDocument(),
{ timeout: 2000 },
);
});
it('Renders all incidents', async () => {
@@ -87,11 +90,15 @@ describe('Incidents', () => {
),
);
await waitFor(() => !queryByTestId('progress'));
expect(
getByText('user', {
exact: false,
}),
).toBeInTheDocument();
await waitFor(
() =>
expect(
getByText('user', {
exact: false,
}),
).toBeInTheDocument(),
{ timeout: 2000 },
);
expect(getByText('test-incident')).toBeInTheDocument();
expect(getByTitle('Acknowledged')).toBeInTheDocument();
expect(getByLabelText('Status warning')).toBeInTheDocument();
@@ -113,8 +120,14 @@ describe('Incidents', () => {
),
);
await waitFor(() => !queryByTestId('progress'));
expect(
getByText('Error encountered while fetching information. Error occurred'),
).toBeInTheDocument();
await waitFor(
() =>
expect(
getByText(
'Error encountered while fetching information. Error occurred',
),
).toBeInTheDocument(),
{ timeout: 2000 },
);
});
});
@@ -15,7 +15,13 @@
*/
import React, { useEffect } from 'react';
import { List, ListSubheader } from '@material-ui/core';
import {
createStyles,
List,
ListSubheader,
makeStyles,
Theme,
} from '@material-ui/core';
import { IncidentListItem } from './IncidentListItem';
import { IncidentsEmptyState } from './IncidentEmptyState';
import { useAsyncFn } from 'react-use';
@@ -23,16 +29,36 @@ import { splunkOnCallApiRef } from '../../api';
import { useApi, Progress } from '@backstage/core';
import { Alert } from '@material-ui/lab';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
maxHeight: '400px',
overflow: 'auto',
},
subheader: {
backgroundColor: theme.palette.background.paper,
},
progress: {
margin: `0 ${theme.spacing(2)}px`,
},
}),
);
type Props = {
refreshIncidents: boolean;
team: string;
};
export const Incidents = ({ refreshIncidents, team }: Props) => {
const classes = useStyles();
const api = useApi(splunkOnCallApiRef);
const [{ value: incidents, loading, error }, getIncidents] = useAsyncFn(
async () => {
// For some reason the changes applied to incidents (trigger-resolve-acknowledge)
// may take some time to actually be applied after receiving the response from the server.
// The timeout compensates for this latency.
await new Promise(resolve => setTimeout(resolve, 2000));
const allIncidents = await api.getIncidents();
const teams = await api.getTeams();
const teamSlug = teams.find(teamValue => teamValue.name === team)?.slug;
@@ -57,23 +83,32 @@ export const Incidents = ({ refreshIncidents, team }: Props) => {
);
}
if (loading) {
return <Progress />;
}
if (!incidents?.length) {
if (!loading && !incidents?.length) {
return <IncidentsEmptyState />;
}
return (
<List dense subheader={<ListSubheader>INCIDENTS</ListSubheader>}>
{incidents!.map((incident, index) => (
<IncidentListItem
onIncidentAction={() => getIncidents()}
key={index}
incident={incident}
/>
))}
<List
className={classes.root}
dense
subheader={
<ListSubheader className={classes.subheader}>
CRITICAL INCIDENTS
</ListSubheader>
}
>
{loading ? (
<Progress className={classes.progress} />
) : (
incidents!.map((incident, index) => (
<IncidentListItem
onIncidentAction={() => getIncidents()}
key={index}
team={team}
incident={incident}
/>
))
)}
</List>
);
};
@@ -50,7 +50,7 @@ const mockSplunkOnCallApi: Partial<SplunkOnCallClient> = {
const configApi: ConfigApi = new ConfigReader({
splunkOnCall: {
username: MOCKED_USER.username,
eventsRestEndpoint: 'EXAMPLE_REST_ENDPOINT',
},
});
@@ -91,7 +91,10 @@ describe('SplunkOnCallCard', () => {
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('Create Incident')).toBeInTheDocument();
expect(getByText('Nice! No incidents found!')).toBeInTheDocument();
await waitFor(
() => expect(getByText('Nice! No incidents found!')).toBeInTheDocument(),
{ timeout: 2000 },
);
expect(getByText('Empty escalation policy')).toBeInTheDocument();
});
@@ -33,6 +33,7 @@ import {
} from '@material-ui/core';
import { Incidents } from './Incident';
import { EscalationPolicy } from './Escalation';
import WebIcon from '@material-ui/icons/Web';
import { useAsync } from 'react-use';
import { Alert } from '@material-ui/lab';
import { splunkOnCallApiRef, UnauthorizedError } from '../api';
@@ -47,12 +48,12 @@ export const MissingTeamAnnotation = () => (
<MissingAnnotationEmptyState annotation={SPLUNK_ON_CALL_TEAM} />
);
export const MissingUsername = () => (
export const MissingEventsRestEndpoint = () => (
<CardContent>
<EmptyState
title="No Splunk On-Call user available."
title="No Splunk On-Call REST endpoint available."
missing="info"
description="You need to add a valid username to your 'app-config.yaml' if you want to enable Splunk On-Call. Make sure that the user is a member of your organization."
description="You need to add a valid REST endpoint to your 'app-config.yaml' if you want to enable Splunk On-Call."
/>
</CardContent>
);
@@ -71,7 +72,8 @@ export const SplunkOnCallCard = ({ entity }: Props) => {
const [refreshIncidents, setRefreshIncidents] = useState<boolean>(false);
const team = entity.metadata.annotations![SPLUNK_ON_CALL_TEAM];
const username = config.getOptionalString('splunkOnCall.username');
const eventsRestEndpoint =
config.getOptionalString('splunkOnCall.eventsRestEndpoint') || null;
const handleRefresh = useCallback(() => {
setRefreshIncidents(x => !x);
@@ -95,9 +97,6 @@ export const SplunkOnCallCard = ({ entity }: Props) => {
return { usersHashMap, userList: allUsers };
});
const incidentCreator =
username && users?.userList.find(user => user.username === username);
if (error instanceof UnauthorizedError) {
return <MissingApiKeyOrApiIdError />;
}
@@ -118,8 +117,9 @@ export const SplunkOnCallCard = ({ entity }: Props) => {
if (!team) {
return <MissingTeamAnnotation />;
}
if (!username || !incidentCreator) {
return <MissingUsername />;
if (!eventsRestEndpoint) {
return <MissingEventsRestEndpoint />;
}
return (
@@ -128,15 +128,12 @@ export const SplunkOnCallCard = ({ entity }: Props) => {
{users?.usersHashMap && team && (
<EscalationPolicy team={team} users={users.usersHashMap} />
)}
{users && incidentCreator && (
<TriggerDialog
users={users.userList}
incidentCreator={incidentCreator}
showDialog={showDialog}
handleDialog={handleDialog}
onIncidentCreated={handleRefresh}
/>
)}
<TriggerDialog
team={team}
showDialog={showDialog}
handleDialog={handleDialog}
onIncidentCreated={handleRefresh}
/>
</>
);
};
@@ -148,15 +145,22 @@ export const SplunkOnCallCard = ({ entity }: Props) => {
icon: <AlarmAddIcon />,
};
const serviceLink = {
label: 'Portal',
href: 'https://portal.victorops.com/',
icon: <WebIcon />,
};
return (
<Card>
<CardHeader
title="Splunk On-Call"
subheader={[
<Typography key="team_name">Team: {team}</Typography>,
username && (
<HeaderIconLinkRow key="incident_trigger" links={[triggerLink]} />
),
<HeaderIconLinkRow
key="incident_trigger"
links={[serviceLink, triggerLink]}
/>,
]}
/>
<Divider />
@@ -24,13 +24,11 @@ import {
} from '@backstage/core';
import { splunkOnCallApiRef } from '../../api';
import { TriggerDialog } from './TriggerDialog';
import { ESCALATION_POLICIES, MOCKED_USER } from '../../api/mocks';
describe('TriggerDialog', () => {
const mockTriggerAlarmFn = jest.fn();
const mockSplunkOnCallApi = {
triggerAlarm: mockTriggerAlarmFn,
getEscalationPolicies: async () => ESCALATION_POLICIES,
incidentAction: mockTriggerAlarmFn,
};
const apis = ApiRegistry.from([
@@ -45,14 +43,13 @@ describe('TriggerDialog', () => {
]);
it('open the dialog and trigger an alarm', async () => {
const { getByText, getByRole, getAllByRole, getByTestId } = render(
const { getByText, getByRole, getByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<TriggerDialog
team="Example"
showDialog
incidentCreator={MOCKED_USER}
handleDialog={() => {}}
users={[MOCKED_USER]}
onIncidentCreated={() => {}}
/>
</ApiProvider>,
@@ -65,36 +62,20 @@ describe('TriggerDialog', () => {
exact: false,
}),
).toBeInTheDocument();
const summary = getByTestId('trigger-summary-input');
const body = getByTestId('trigger-body-input');
const behavior = getByTestId('trigger-select-behavior');
const description = 'Test Trigger Alarm';
await act(async () => {
fireEvent.change(summary, { target: { value: description } });
fireEvent.change(body, { target: { value: description } });
fireEvent.change(behavior, { target: { value: '0' } });
fireEvent.mouseDown(getAllByRole('button')[0]);
});
const incidentType = getByTestId('trigger-incident-type');
const incidentId = getByTestId('trigger-incident-id');
const incidentDisplayName = getByTestId('trigger-incident-displayName');
const incidentMessage = getByTestId('trigger-incident-message');
// Trigger user targets select
const options = getAllByRole('option');
await act(async () => {
fireEvent.click(options[0]);
fireEvent.keyDown(options[0], {
key: 'Escape',
code: 'Escape',
keyCode: 27,
charCode: 27,
fireEvent.change(incidentType, { target: { value: 'CRITICAL' } });
fireEvent.change(incidentId, { target: { value: 'incident-id' } });
fireEvent.change(incidentDisplayName, {
target: { value: 'incident-display-name' },
});
fireEvent.change(incidentMessage, {
target: { value: 'incident-message' },
});
});
// Trigger policy targets select
await act(async () => {
fireEvent.mouseDown(getAllByRole('button')[1]);
});
const policiesOptions = getAllByRole('option');
await act(async () => {
fireEvent.click(policiesOptions[0]);
});
// Trigger incident creation button
@@ -104,14 +85,11 @@ describe('TriggerDialog', () => {
});
expect(mockTriggerAlarmFn).toHaveBeenCalled();
expect(mockTriggerAlarmFn).toHaveBeenCalledWith({
summary: description,
details: description,
userName: 'test_user',
targets: [
{ slug: 'test_user', type: 'User' },
{ slug: 'team-zEalMCgwYSA0Lt40', type: 'EscalationPolicy' },
],
isMultiResponder: false,
incidentType: 'CRITICAL',
incidentId: 'incident-id',
routingKey: 'Example',
incidentDisplayName: 'incident-display-name',
incidentMessage: 'incident-message',
});
});
});
@@ -26,8 +26,6 @@ import {
CircularProgress,
Select,
MenuItem,
Input,
Chip,
createStyles,
makeStyles,
Theme,
@@ -35,23 +33,13 @@ import {
InputLabel,
} from '@material-ui/core';
import { useApi, alertApiRef } from '@backstage/core';
import { useAsync, useAsyncFn } from 'react-use';
import { useAsyncFn } from 'react-use';
import { splunkOnCallApiRef } from '../../api';
import { Alert } from '@material-ui/lab';
import { User } from '../types';
import { IncidentTarget, TargetType } from '../../api/types';
const MenuProps = {
PaperProps: {
style: {
width: 250,
},
},
};
import { TriggerAlarmRequest } from '../../api/types';
type Props = {
users: User[];
incidentCreator: User;
team: string;
showDialog: boolean;
handleDialog: () => void;
onIncidentCreated: () => void;
@@ -68,8 +56,17 @@ const useStyles = makeStyles((theme: Theme) =>
},
formControl: {
margin: theme.spacing(1),
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
minWidth: `calc(100% - ${theme.spacing(2)}px)`,
},
formHeader: {
width: '50%',
},
incidentType: {
width: '90%',
},
targets: {
display: 'flex',
flexDirection: 'column',
@@ -79,8 +76,7 @@ const useStyles = makeStyles((theme: Theme) =>
);
export const TriggerDialog = ({
users,
incidentCreator,
team,
showDialog,
handleDialog,
onIncidentCreated: onIncidentCreated,
@@ -89,72 +85,46 @@ export const TriggerDialog = ({
const api = useApi(splunkOnCallApiRef);
const classes = useStyles();
const [userTargets, setUserTargets] = useState<string[]>([]);
const [policyTargets, setPolicyTargets] = useState<string[]>([]);
const [detailsValue, setDetails] = useState<string>('');
const [summaryValue, setSummary] = useState<string>('');
const [isMultiResponderValue, setIsMultiResponder] = useState<string>('1');
const [incidentType, setIncidentType] = useState<string>('');
const [incidentId, setIncidentId] = useState<string>();
const [incidentDisplayName, setIncidentDisplayName] = useState<string>('');
const [incidentMessage, setIncidentMessage] = useState<string>('');
const [incidentStartTime, setIncidentStartTime] = useState<number>();
const [
{ value, loading: triggerLoading, error: triggerError },
handleTriggerAlarm,
] = useAsyncFn(
async (
summary: string,
details: string,
userName: string,
targets: IncidentTarget[],
isMultiResponder: boolean,
) =>
await api.triggerAlarm({
summary,
details,
userName,
targets,
isMultiResponder,
}),
async (params: TriggerAlarmRequest) => await api.incidentAction(params),
);
const {
value: policies,
loading: policiesLoaading,
error: policiesError,
} = useAsync(async () => {
const allPolicies = await api.getEscalationPolicies();
return allPolicies;
});
const handleUserTargets = (event: React.ChangeEvent<{ value: unknown }>) => {
setUserTargets(event.target.value as string[]);
const handleIncidentType = (event: React.ChangeEvent<{ value: unknown }>) => {
setIncidentType(event.target.value as string);
};
const handlePolicyTargets = (
event: React.ChangeEvent<{ value: unknown }>,
const handleIncidentId = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setIncidentId(event.target.value as string);
};
const handleIncidentDisplayName = (
event: React.ChangeEvent<HTMLTextAreaElement>,
) => {
setPolicyTargets(event.target.value as string[]);
setIncidentDisplayName(event.target.value);
};
const detailsChanged = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setDetails(event.target.value);
};
const summaryChanged = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setSummary(event.target.value);
};
const isMultiResponderChanged = (
event: React.ChangeEvent<{ value: unknown }>,
const handleIncidentMessage = (
event: React.ChangeEvent<HTMLTextAreaElement>,
) => {
setIsMultiResponder(event.target.value as string);
setIncidentMessage(event.target.value);
};
const targets = (): IncidentTarget[] => [
...userTargets.map(user => ({ slug: user, type: TargetType.UserValue })),
...policyTargets.map(user => ({
slug: user,
type: TargetType.EscalationPolicyValue,
})),
];
const handleIncidentStartTime = (
event: React.ChangeEvent<HTMLTextAreaElement>,
) => {
const dateTime = new Date(event.target.value).getTime();
const dateTimeInSeconds = Math.floor(dateTime / 1000);
setIncidentStartTime(dateTimeInSeconds);
};
useEffect(() => {
if (value) {
@@ -178,10 +148,7 @@ export const TriggerDialog = ({
<DialogTitle>This action will trigger an incident</DialogTitle>
<DialogContent>
<Typography variant="subtitle1" gutterBottom align="justify">
Created by:{' '}
<b>
{incidentCreator?.firstName} {incidentCreator?.lastName}
</b>
Created by: <b>{`{ REST } Endpoint`}</b>
</Typography>
<Alert severity="info">
<Typography variant="body1" align="justify">
@@ -199,136 +166,66 @@ export const TriggerDialog = ({
align="justify"
>
Please describe the problem you want to report. Be as descriptive as
possible. Your signed in user and a reference to the current page will
automatically be amended to the alarm so that the receiver can reach
out to you if necessary.
</Typography>
<div style={{ marginTop: '1em' }}>
<Typography color="textSecondary" gutterBottom>
Select the targets
</Typography>
<div className={classes.targets}>
<FormControl className={classes.formControl}>
<InputLabel>Select Users</InputLabel>
<Select
id="user-targets"
multiple
value={userTargets}
onChange={handleUserTargets}
input={<Input />}
renderValue={selected => (
<div className={classes.chips}>
{(selected as string[]).map(selectedUser => {
const element = users.find(
user => user.username === selectedUser,
);
return (
<Chip
key={selectedUser}
label={`${element?.firstName} ${element?.lastName}`}
className={classes.chip}
/>
);
})}
</div>
)}
MenuProps={MenuProps}
>
{users.map(user => (
<MenuItem key={user.email} value={user.username}>
{user.firstName} {user.lastName}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl className={classes.formControl}>
<InputLabel>Select Teams / Policies</InputLabel>
<Select
id="policy-targets"
multiple
value={policyTargets}
onChange={handlePolicyTargets}
input={<Input />}
renderValue={selected => (
<div className={classes.chips}>
{(selected as string[]).map(selectedPolicy => {
const element = policies?.find(
policy => policy.policy.slug === selectedPolicy,
);
return (
<Chip
key={selectedPolicy}
label={element?.policy.name}
className={classes.chip}
/>
);
})}
</div>
)}
MenuProps={MenuProps}
>
{!policiesError &&
!policiesLoaading &&
policies &&
policies.map(policy => (
<MenuItem
key={policy.policy.slug}
value={policy.policy.slug}
>
{policy.policy.name}
</MenuItem>
))}
</Select>
</FormControl>
</div>
</div>
<Typography
style={{ marginTop: '1em' }}
color="textSecondary"
gutterBottom
>
Acknowledge Behavior
possible. <br />
Note that only the <b>Incident type</b>, <b>Incident display name</b>{' '}
and the <b>Incident message</b> fields are <b>required</b>.
</Typography>
<FormControl className={classes.formControl}>
<Select
id="multi-responder"
value={isMultiResponderValue}
onChange={isMultiResponderChanged}
inputProps={{ 'data-testid': 'trigger-select-behavior' }}
>
<MenuItem value="1">
Stop paging after a single escalation policy or user has
acknowledged
</MenuItem>
<MenuItem value="0">
Continue paging until each escalation policy or user above has
acknowledged
</MenuItem>
</Select>
<div className={classes.formHeader}>
<InputLabel id="demo-simple-select-label">Incident type</InputLabel>
<Select
id="incident-type"
className={classes.incidentType}
value={incidentType}
onChange={handleIncidentType}
inputProps={{ 'data-testid': 'trigger-incident-type' }}
>
<MenuItem value="CRITICAL">Critical</MenuItem>
<MenuItem value="WARNING">Warning</MenuItem>
<MenuItem value="INFO">Info</MenuItem>
</Select>
</div>
<TextField
className={classes.formHeader}
id="datetime-local"
label="Incident start time"
type="datetime-local"
onChange={handleIncidentStartTime}
InputLabelProps={{
shrink: true,
}}
/>
</FormControl>
<TextField
required
inputProps={{ 'data-testid': 'trigger-summary-input' }}
inputProps={{ 'data-testid': 'trigger-incident-id' }}
id="summary"
multiline
fullWidth
rows="4"
margin="normal"
label="Incident summary"
label="Incident id"
variant="outlined"
onChange={summaryChanged}
onChange={handleIncidentId}
/>
<TextField
required
inputProps={{ 'data-testid': 'trigger-body-input' }}
inputProps={{ 'data-testid': 'trigger-incident-displayName' }}
id="summary"
fullWidth
margin="normal"
label="Incident display name"
variant="outlined"
onChange={handleIncidentDisplayName}
/>
<TextField
required
inputProps={{ 'data-testid': 'trigger-incident-message' }}
id="details"
multiline
fullWidth
rows="2"
margin="normal"
label="Incident body"
label="Incident message"
variant="outlined"
onChange={detailsChanged}
onChange={handleIncidentMessage}
/>
</DialogContent>
<DialogActions>
@@ -337,20 +234,21 @@ export const TriggerDialog = ({
id="trigger"
color="secondary"
disabled={
!detailsValue ||
!summaryValue ||
(!userTargets.length && !policyTargets.length) ||
!incidentType.length ||
!incidentDisplayName ||
!incidentMessage ||
triggerLoading
}
variant="contained"
onClick={() =>
handleTriggerAlarm(
summaryValue,
detailsValue,
incidentCreator.username!,
targets(),
!!Number(isMultiResponderValue),
)
handleTriggerAlarm({
routingKey: team,
incidentType,
incidentDisplayName,
incidentMessage,
...(incidentId ? { incidentId } : {}),
...(incidentStartTime ? { incidentStartTime } : {}),
} as TriggerAlarmRequest)
}
endIcon={triggerLoading && <CircularProgress size={16} />}
>
+1 -11
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { IncidentTarget } from '../api/types';
export type Team = {
name?: string;
slug?: string;
@@ -68,14 +66,6 @@ export type User = {
_selfUrl?: string;
};
export type CreateIncidentRequest = {
summary: string;
details: string;
userName: string;
targets: IncidentTarget;
isMultiResponder: boolean;
};
export type IncidentPhase = 'UNACKED' | 'ACKED' | 'RESOLVED';
export type Incident = {
@@ -88,7 +78,7 @@ export type Incident = {
alertCount?: number;
lastAlertTime?: string;
lastAlertId?: string;
entityId?: string;
entityId: string;
host?: string;
service?: string;
pagedUsers?: string[];