Merge pull request #9362 from mdb/mdb/new-splunk-on-call-annotation

[Plugin] Splunk On-Call plugin support for 'splunk.com/on-call-routing-key' annotation
This commit is contained in:
Ben Lambert
2022-02-09 14:00:03 +01:00
committed by GitHub
9 changed files with 301 additions and 58 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-splunk-on-call': patch
---
Add Splunk On-Call plugin support for a `splunk.com/on-call-routing-key` annotation. If the `splunk.com/on-call-routing-key` is provided, the plugin displays a Splunk On-Call card for each of the teams associated with the routing key.
+18 -8
View File
@@ -2,16 +2,16 @@
## Overview
This plugin displays Splunk On-Call, formerly VictorOps, information about an entity.
This plugin displays Splunk On-Call (formerly VictorOps) information associated with an entity.
There is a way to trigger an new incident directly to specific users or/and specific teams.
It also provides the ability to trigger new incidents to specific users and/or specific teams from within Backstage.
This plugin requires that entities are annotated with a team name. See more further down in this document.
This plugin requires that entities feature either a `splunk.com/on-call-team` or a `splunk.com/on-call-routing-key` annotation. See below for further details.
This plugin provides:
- A list of incidents
- A way to trigger a new incident to specific users or/and teams
- A way to trigger a new incident to specific users and/or teams
- A way to acknowledge/resolve an incident
- Information details about the persons on-call
@@ -47,7 +47,7 @@ const overviewContent = (
## Client configuration
In order to be able to perform certain action (create-acknowledge-resolve an action), you need to provide a REST Endpoint.
In order to be able to perform certain actions (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`
@@ -76,12 +76,22 @@ In addition, to make certain API calls (trigger-resolve-acknowledge an incident)
### Adding your team name to the entity annotation
The information displayed for each entity is based on the team name.
If you want to use this plugin for an entity, you need to label it with the below annotation:
The information displayed for each entity is based on either an associated team name or an associated routing key.
To use this plugin for an entity, the entity must be labeled with either a `splunk.com/on-call-team` or a `splunk.com/on-call-routing-key` annotation.
For example, by specifying a `splunk.com/on-call-team`, the plugin displays Splunk On-Call data associated with the specified team:
```yaml
annotations:
splunk.com/on-call-team': <SPLUNK_ON_CALL_TEAM_NAME>
splunk.com/on-call-team: <SPLUNK_ON_CALL_TEAM_NAME>
```
Alternatively, by specifying a `splunk.com/on-call-routing-key`, the plugin displays Splunk On-Call data associated with _each_ of the teams associated with the specified routing key:
```yaml
annotations:
splunk.com/on-call-routing-key: <SPLUNK_ON_CALL_ROUTING_KEY>
```
### Create the Routing Key
+4
View File
@@ -51,6 +51,10 @@ export class SplunkOnCallClient implements SplunkOnCallApi {
//
// (undocumented)
getOnCallUsers(): Promise<OnCall[]>;
// Warning: (ae-forgotten-export) The symbol "RoutingKey" needs to be exported by the entry point index.d.ts
//
// (undocumented)
getRoutingKeys(): Promise<RoutingKey[]>;
// Warning: (ae-forgotten-export) The symbol "Team" needs to be exported by the entry point index.d.ts
//
// (undocumented)
+11
View File
@@ -19,6 +19,7 @@ import {
OnCall,
User,
EscalationPolicyInfo,
RoutingKey,
Team,
} from '../components/types';
import {
@@ -30,6 +31,7 @@ import {
RequestOptions,
ListUserResponse,
EscalationPolicyResponse,
ListRoutingKeyResponse,
} from './types';
import {
createApiRef,
@@ -82,6 +84,15 @@ export class SplunkOnCallClient implements SplunkOnCallApi {
return teams;
}
async getRoutingKeys(): Promise<RoutingKey[]> {
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/splunk-on-call/v1/org/routing-keys`;
const { routingKeys } = await this.getByUrl<ListRoutingKeyResponse>(url);
return routingKeys;
}
async getUsers(): Promise<User[]> {
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
+13
View File
@@ -17,6 +17,7 @@
import {
EscalationPolicyInfo,
Incident,
RoutingKey,
Team,
User,
} from '../components/types';
@@ -88,6 +89,18 @@ export const MOCK_TEAM: Team = {
isDefaultTeam: false,
};
export const MOCK_ROUTING_KEY: RoutingKey = {
routingKey: 'test-routing-key',
targets: [
{
policyName: 'some policy',
policySlug: MOCK_TEAM.slug || '',
_teamUrl: `/api-public/v1/team/${MOCK_TEAM.slug}`,
},
],
isDefault: false,
};
export const MOCK_TEAM_NO_INCIDENTS: Team = {
...MOCK_TEAM,
name: 'test-noincidents',
+11
View File
@@ -18,6 +18,7 @@ import {
EscalationPolicyInfo,
Incident,
OnCall,
RoutingKey,
Team,
User,
} from '../components/types';
@@ -65,6 +66,11 @@ export interface SplunkOnCallApi {
*/
getTeams(): Promise<Team[]>;
/**
* Get a list of routing keys for your organization.
*/
getRoutingKeys(): Promise<RoutingKey[]>;
/**
* Get a list of escalation policies for your organization.
*/
@@ -80,6 +86,11 @@ export type ListUserResponse = {
_selfUrl?: string;
};
export type ListRoutingKeyResponse = {
routingKeys: RoutingKey[];
_selfUrl?: string;
};
export type IncidentsResponse = {
incidents: Incident[];
};
@@ -28,6 +28,7 @@ import {
MOCKED_ON_CALL,
MOCKED_USER,
MOCK_INCIDENT,
MOCK_ROUTING_KEY,
MOCK_TEAM,
MOCK_TEAM_NO_INCIDENTS,
} from '../api/mocks';
@@ -45,6 +46,7 @@ const mockSplunkOnCallApi: Partial<SplunkOnCallClient> = {
getIncidents: async () => [MOCK_INCIDENT],
getOnCallUsers: async () => MOCKED_ON_CALL,
getTeams: async () => [MOCK_TEAM],
getRoutingKeys: async () => [MOCK_ROUTING_KEY],
getEscalationPolicies: async () => ESCALATION_POLICIES,
};
@@ -71,6 +73,26 @@ const mockEntity = {
},
} as Entity;
const mockEntityWithRoutingKeyAnnotation = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'splunkoncall-test',
annotations: {
'splunk.com/on-call-routing-key': MOCK_ROUTING_KEY.routingKey,
},
},
} as Entity;
const mockEntityNoAnnotation = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'splunkoncall-test',
annotations: {},
},
} as Entity;
const mockEntityNoIncidents = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
@@ -109,6 +131,34 @@ describe('SplunkOnCallCard', () => {
expect(getByText('Empty escalation policy')).toBeInTheDocument();
});
it('handles a "splunk.com/on-call-routing-key" annotation', async () => {
mockSplunkOnCallApi.getUsers = jest
.fn()
.mockImplementationOnce(async () => [MOCKED_USER]);
mockSplunkOnCallApi.getRoutingKeys = jest
.fn()
.mockImplementationOnce(async () => [MOCK_ROUTING_KEY]);
mockSplunkOnCallApi.getTeams = jest
.fn()
.mockImplementation(async () => [MOCK_TEAM]);
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={mockEntityWithRoutingKeyAnnotation}>
<EntitySplunkOnCallCard />
</EntityProvider>
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText(`Team: ${MOCK_TEAM.name}`)).toBeInTheDocument();
await waitFor(
() => expect(getByText('test-incident')).toBeInTheDocument(),
{ timeout: 2000 },
);
});
it('Handles custom error for missing token', async () => {
mockSplunkOnCallApi.getUsers = jest
.fn()
@@ -151,6 +201,20 @@ describe('SplunkOnCallCard', () => {
).toBeInTheDocument();
});
it('handles warning for missing required annotations', async () => {
const { getAllByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={mockEntityNoAnnotation}>
<EntitySplunkOnCallCard />
</EntityProvider>
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getAllByText('Missing Annotation').length).toEqual(1);
});
it('handles warning for incorrect team annotation', async () => {
mockSplunkOnCallApi.getUsers = jest
.fn()
@@ -170,7 +234,37 @@ describe('SplunkOnCallCard', () => {
);
await waitFor(() => !queryByTestId('progress'));
expect(
getByText('Could not find team named "test" in the Splunk On-Call API'),
getByText(
'Splunk On-Call API returned no record of teams associated with the "test" team name',
),
).toBeInTheDocument();
});
it('handles warning for incorrect routing key annotation', async () => {
mockSplunkOnCallApi.getUsers = jest
.fn()
.mockImplementationOnce(async () => [MOCKED_USER]);
mockSplunkOnCallApi.getRoutingKeys = jest
.fn()
.mockImplementationOnce(async () => [MOCK_ROUTING_KEY]);
mockSplunkOnCallApi.getTeams = jest
.fn()
.mockImplementationOnce(async () => []);
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={mockEntityWithRoutingKeyAnnotation}>
<EntitySplunkOnCallCard />
</EntityProvider>
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(
getByText(
`Splunk On-Call API returned no record of teams associated with the "${MOCK_ROUTING_KEY.routingKey}" routing key`,
),
).toBeInTheDocument();
});
@@ -22,6 +22,7 @@ import {
CardContent,
CardHeader,
Divider,
makeStyles,
Typography,
} from '@material-ui/core';
import AlarmAddIcon from '@material-ui/icons/AlarmAdd';
@@ -32,8 +33,7 @@ import { MissingApiKeyOrApiIdError } from './Errors/MissingApiKeyOrApiIdError';
import { EscalationPolicy } from './Escalation';
import { Incidents } from './Incident';
import { TriggerDialog } from './TriggerDialog';
import { User } from './types';
import { Team, User } from './types';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import {
@@ -45,20 +45,49 @@ import {
} from '@backstage/core-components';
export const SPLUNK_ON_CALL_TEAM = 'splunk.com/on-call-team';
export const SPLUNK_ON_CALL_ROUTING_KEY = 'splunk.com/on-call-routing-key';
export const MissingTeamAnnotation = () => (
<MissingAnnotationEmptyState annotation={SPLUNK_ON_CALL_TEAM} />
export const MissingAnnotation = () => (
<div>
<Typography>
The Splunk On Call plugin requires setting either the{' '}
<code>{SPLUNK_ON_CALL_TEAM}</code> or the{' '}
<code>{SPLUNK_ON_CALL_ROUTING_KEY}</code> annotation.
</Typography>
<MissingAnnotationEmptyState annotation={SPLUNK_ON_CALL_TEAM} />
</div>
);
export const InvalidTeamAnnotation = ({ teamName }: { teamName: string }) => (
<CardContent>
<EmptyState
title={`Could not find team named "${teamName}" in the Splunk On-Call API`}
missing="info"
description={`Escalation Policy and incident information unavailable. Please verify that the team you added "${teamName}" is valid if you want to enable Splunk On-Call.`}
/>
</CardContent>
);
export const InvalidAnnotation = ({
teamName,
routingKey,
}: {
teamName: string | undefined;
routingKey: string | undefined;
}) => {
let titleSuffix = 'provided annotation';
if (routingKey) {
titleSuffix = `"${routingKey}" routing key`;
}
if (teamName) {
titleSuffix = `"${teamName}" team name`;
}
return (
<Card>
<CardHeader title="Splunk On-Call" />
<CardContent>
<EmptyState
title={`Splunk On-Call API returned no record of teams associated with the ${titleSuffix}`}
missing="info"
description="Escalation Policy and incident information unavailable. Splunk On-Call requires a valid team name or routing key."
/>
</CardContent>
</Card>
);
};
export const MissingEventsRestEndpoint = () => (
<CardContent>
@@ -71,15 +100,28 @@ export const MissingEventsRestEndpoint = () => (
);
export const isSplunkOnCallAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[SPLUNK_ON_CALL_TEAM]);
Boolean(entity.metadata.annotations?.[SPLUNK_ON_CALL_TEAM]) ||
Boolean(entity.metadata.annotations?.[SPLUNK_ON_CALL_ROUTING_KEY]);
const useStyles = makeStyles({
onCallCard: {
marginBottom: '1em',
},
});
export const EntitySplunkOnCallCard = () => {
const classes = useStyles();
const config = useApi(configApiRef);
const api = useApi(splunkOnCallApiRef);
const { entity } = useEntity();
const [showDialog, setShowDialog] = useState<boolean>(false);
const [refreshIncidents, setRefreshIncidents] = useState<boolean>(false);
const team = entity.metadata.annotations![SPLUNK_ON_CALL_TEAM];
const teamAnnotation = entity
? entity.metadata.annotations![SPLUNK_ON_CALL_TEAM]
: undefined;
const routingKeyAnnotation = entity
? entity.metadata.annotations![SPLUNK_ON_CALL_ROUTING_KEY]
: undefined;
const eventsRestEndpoint =
config.getOptionalString('splunkOnCall.eventsRestEndpoint') || null;
@@ -93,7 +135,7 @@ export const EntitySplunkOnCallCard = () => {
}, []);
const {
value: usersAndTeam,
value: usersAndTeams,
loading,
error,
} = useAsync(async () => {
@@ -108,10 +150,38 @@ export const EntitySplunkOnCallCard = () => {
{},
);
const teams = await api.getTeams();
const foundTeam = teams.find(teamValue => teamValue.name === team);
return { usersHashMap, foundTeam };
let foundTeams = [
teams.find(teamValue => teamValue.name === teamAnnotation),
].filter(team => team !== undefined);
if (!foundTeams.length && routingKeyAnnotation) {
const routingKeys = await api.getRoutingKeys();
const foundRoutingKey = routingKeys.find(
key => key.routingKey === routingKeyAnnotation,
);
foundTeams = foundRoutingKey
? foundRoutingKey.targets
.map(target => {
const teamUrlParts = target._teamUrl.split('/');
const teamSlug = teamUrlParts[teamUrlParts.length - 1];
return teams.find(teamValue => teamValue.slug === teamSlug);
})
.filter(team => team !== undefined)
: [];
}
return { usersHashMap, foundTeams };
});
if (!teamAnnotation && !routingKeyAnnotation) {
return <MissingAnnotation />;
}
if (!eventsRestEndpoint) {
return <MissingEventsRestEndpoint />;
}
if (error instanceof UnauthorizedError) {
return <MissingApiKeyOrApiIdError />;
}
@@ -128,27 +198,32 @@ export const EntitySplunkOnCallCard = () => {
return <Progress />;
}
const Content = () => {
if (!team) {
return <MissingTeamAnnotation />;
}
if (!usersAndTeams?.foundTeams || !usersAndTeams?.foundTeams.length) {
return (
<InvalidAnnotation
teamName={teamAnnotation}
routingKey={routingKeyAnnotation}
/>
);
}
if (!usersAndTeam?.foundTeam) {
return <InvalidTeamAnnotation teamName={team} />;
}
if (!eventsRestEndpoint) {
return <MissingEventsRestEndpoint />;
}
const Content = ({
team,
usersHashMap,
}: {
team: Team | undefined;
usersHashMap: any;
}) => {
const teamName = team?.name ?? '';
return (
<>
<Incidents team={team} refreshIncidents={refreshIncidents} />
{usersAndTeam?.usersHashMap && team && (
<EscalationPolicy team={team} users={usersAndTeam.usersHashMap} />
<Incidents team={teamName} refreshIncidents={refreshIncidents} />
{usersHashMap && team && (
<EscalationPolicy team={teamName} users={usersHashMap} />
)}
<TriggerDialog
team={team}
team={teamName}
showDialog={showDialog}
handleDialog={handleDialog}
onIncidentCreated={handleRefresh}
@@ -170,22 +245,30 @@ export const EntitySplunkOnCallCard = () => {
icon: <WebIcon />,
};
const teams = usersAndTeams?.foundTeams || [];
return (
<Card>
<CardHeader
title="Splunk On-Call"
subheader={[
<Typography key="team_name">Team: {team}</Typography>,
<HeaderIconLinkRow
key="incident_trigger"
links={[serviceLink, triggerLink]}
/>,
]}
/>
<Divider />
<CardContent>
<Content />
</CardContent>
</Card>
<>
{teams.map((team, i) => (
<Card key={i} className={classes.onCallCard}>
<CardHeader
title="Splunk On-Call"
subheader={[
<Typography key="team_name">
Team: {team && team.name ? team.name : ''}
</Typography>,
<HeaderIconLinkRow
key="incident_trigger"
links={[serviceLink, triggerLink]}
/>,
]}
/>
<Divider />
<CardContent>
<Content team={team} usersHashMap={usersAndTeams?.usersHashMap} />
</CardContent>
</Card>
))}
</>
);
};
@@ -117,3 +117,15 @@ export type EscalationPolicyTeam = {
name: string;
slug: string;
};
export type RoutingKey = {
routingKey: string;
targets: RoutingKeyTarget[];
isDefault: boolean;
};
export type RoutingKeyTarget = {
policyName: string;
policySlug: string;
_teamUrl: string;
};