feat(splunk-on-call-plugin): add REST endpoint

Signed-off-by: Remi <remi.d45@gmail.com>
This commit is contained in:
Remi
2021-02-25 22:56:01 +01:00
parent 49be39b92a
commit fde006b434
8 changed files with 177 additions and 305 deletions
+1
View File
@@ -56,6 +56,7 @@ 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.
+18 -59
View File
@@ -31,7 +31,6 @@ import {
RequestOptions,
ListUserResponse,
EscalationPolicyResponse,
PatchIncidentRequest,
} from './types';
export class UnauthorizedError extends Error {}
@@ -45,8 +44,11 @@ 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 +82,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 +100,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 +127,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 -31
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[];
};
@@ -107,6 +90,7 @@ export type OnCallsResponse = {
export type ClientApiConfig = {
username: string | null;
eventsRestEndpoint: string | null;
discoveryApi: DiscoveryApi;
};
@@ -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}
/>
@@ -33,6 +33,10 @@ export const Incidents = ({ refreshIncidents, team }: Props) => {
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;
@@ -71,6 +75,7 @@ export const Incidents = ({ refreshIncidents, team }: Props) => {
<IncidentListItem
onIncidentAction={() => getIncidents()}
key={index}
team={team}
incident={incident}
/>
))}
@@ -74,6 +74,16 @@ export const MissingUsername = () => (
</CardContent>
);
export const MissingEventsRestEndpoint = () => (
<CardContent>
<EmptyState
title="No Splunk On-Call REST endpoint available."
missing="info"
description="You need to add a valid REST endpoint to your 'app-config.yaml' if you want to enable Splunk On-Call."
/>
</CardContent>
);
export const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[SPLUNK_ON_CALL_TEAM]);
@@ -89,7 +99,10 @@ 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 username = config.getOptionalString('splunkOnCall.username') || null;
const eventsRestEndpoint =
config.getOptionalString('splunkOnCall.eventsRestEndpoint') || null;
const handleRefresh = useCallback(() => {
setRefreshIncidents(x => !x);
@@ -140,15 +153,19 @@ export const SplunkOnCallCard = ({ entity }: Props) => {
return <MissingUsername />;
}
if (!eventsRestEndpoint) {
return <MissingEventsRestEndpoint />;
}
return (
<>
<Incidents team={team} refreshIncidents={refreshIncidents} />
{users?.usersHashMap && team && (
<EscalationPolicy team={team} users={users.usersHashMap} />
)}
{users && incidentCreator && (
{incidentCreator && (
<TriggerDialog
users={users.userList}
team={team}
incidentCreator={incidentCreator}
showDialog={showDialog}
handleDialog={handleDialog}
@@ -26,8 +26,6 @@ import {
CircularProgress,
Select,
MenuItem,
Input,
Chip,
createStyles,
makeStyles,
Theme,
@@ -35,22 +33,14 @@ 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[];
team: string;
incidentCreator: User;
showDialog: boolean;
handleDialog: () => void;
@@ -79,7 +69,7 @@ const useStyles = makeStyles((theme: Theme) =>
);
export const TriggerDialog = ({
users,
team,
incidentCreator,
showDialog,
handleDialog,
@@ -89,72 +79,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) {
@@ -203,132 +167,58 @@ export const TriggerDialog = ({
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
</Typography>
<FormControl className={classes.formControl}>
<InputLabel id="demo-simple-select-label">Incident type</InputLabel>
<Select
id="multi-responder"
value={isMultiResponderValue}
onChange={isMultiResponderChanged}
inputProps={{ 'data-testid': 'trigger-select-behavior' }}
id="incident-type"
value={incidentType}
onChange={handleIncidentType}
inputProps={{ 'data-testid': 'trigger-incident-type' }}
>
<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>
<MenuItem value="CRITICAL">Critical</MenuItem>
<MenuItem value="WARNING">Warning</MenuItem>
<MenuItem value="INFO">Info</MenuItem>
</Select>
</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}
/>
<TextField
id="datetime-local"
label="Incident start time"
type="datetime-local"
onChange={handleIncidentStartTime}
InputLabelProps={{
shrink: true,
}}
/>
</DialogContent>
<DialogActions>
@@ -337,20 +227,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[];