diff --git a/.changeset/honest-foxes-scream.md b/.changeset/honest-foxes-scream.md new file mode 100644 index 0000000000..f2d80b63a9 --- /dev/null +++ b/.changeset/honest-foxes-scream.md @@ -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. diff --git a/plugins/splunk-on-call/README.md b/plugins/splunk-on-call/README.md index 52803473b0..0200cacc50 100644 --- a/plugins/splunk-on-call/README.md +++ b/plugins/splunk-on-call/README.md @@ -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: `/$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.com/on-call-team: +``` + +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: ``` ### Create the Routing Key diff --git a/plugins/splunk-on-call/api-report.md b/plugins/splunk-on-call/api-report.md index 19f60ffd13..8e8076d9e8 100644 --- a/plugins/splunk-on-call/api-report.md +++ b/plugins/splunk-on-call/api-report.md @@ -51,6 +51,10 @@ export class SplunkOnCallClient implements SplunkOnCallApi { // // (undocumented) getOnCallUsers(): Promise; + // Warning: (ae-forgotten-export) The symbol "RoutingKey" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getRoutingKeys(): Promise; // Warning: (ae-forgotten-export) The symbol "Team" needs to be exported by the entry point index.d.ts // // (undocumented) diff --git a/plugins/splunk-on-call/src/api/client.ts b/plugins/splunk-on-call/src/api/client.ts index 437c6365ed..6c0ff9bdc4 100644 --- a/plugins/splunk-on-call/src/api/client.ts +++ b/plugins/splunk-on-call/src/api/client.ts @@ -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 { + const url = `${await this.config.discoveryApi.getBaseUrl( + 'proxy', + )}/splunk-on-call/v1/org/routing-keys`; + const { routingKeys } = await this.getByUrl(url); + + return routingKeys; + } + async getUsers(): Promise { const url = `${await this.config.discoveryApi.getBaseUrl( 'proxy', diff --git a/plugins/splunk-on-call/src/api/mocks.ts b/plugins/splunk-on-call/src/api/mocks.ts index d5335bfef4..8db06fb9d3 100644 --- a/plugins/splunk-on-call/src/api/mocks.ts +++ b/plugins/splunk-on-call/src/api/mocks.ts @@ -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', diff --git a/plugins/splunk-on-call/src/api/types.ts b/plugins/splunk-on-call/src/api/types.ts index 0bdd093ad1..6a414d18bd 100644 --- a/plugins/splunk-on-call/src/api/types.ts +++ b/plugins/splunk-on-call/src/api/types.ts @@ -18,6 +18,7 @@ import { EscalationPolicyInfo, Incident, OnCall, + RoutingKey, Team, User, } from '../components/types'; @@ -65,6 +66,11 @@ export interface SplunkOnCallApi { */ getTeams(): Promise; + /** + * Get a list of routing keys for your organization. + */ + getRoutingKeys(): Promise; + /** * 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[]; }; diff --git a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx index eb5c99d4e7..a83db136e4 100644 --- a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx +++ b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx @@ -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 = { 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( + + + + + , + ), + ); + 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( + + + + + , + ), + ); + 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( + + + + + , + ), + ); + 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(); }); diff --git a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx index bca53a3b0a..4a6f983636 100644 --- a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx +++ b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx @@ -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 = () => ( - +export const MissingAnnotation = () => ( +
+ + The Splunk On Call plugin requires setting either the{' '} + {SPLUNK_ON_CALL_TEAM} or the{' '} + {SPLUNK_ON_CALL_ROUTING_KEY} annotation. + + +
); -export const InvalidTeamAnnotation = ({ teamName }: { teamName: string }) => ( - - - -); +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 ( + + + + + + + ); +}; export const MissingEventsRestEndpoint = () => ( @@ -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(false); const [refreshIncidents, setRefreshIncidents] = useState(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 ; + } + + if (!eventsRestEndpoint) { + return ; + } + if (error instanceof UnauthorizedError) { return ; } @@ -128,27 +198,32 @@ export const EntitySplunkOnCallCard = () => { return ; } - const Content = () => { - if (!team) { - return ; - } + if (!usersAndTeams?.foundTeams || !usersAndTeams?.foundTeams.length) { + return ( + + ); + } - if (!usersAndTeam?.foundTeam) { - return ; - } - - if (!eventsRestEndpoint) { - return ; - } + const Content = ({ + team, + usersHashMap, + }: { + team: Team | undefined; + usersHashMap: any; + }) => { + const teamName = team?.name ?? ''; return ( <> - - {usersAndTeam?.usersHashMap && team && ( - + + {usersHashMap && team && ( + )} { icon: , }; + const teams = usersAndTeams?.foundTeams || []; + return ( - - Team: {team}, - , - ]} - /> - - - - - + <> + {teams.map((team, i) => ( + + + Team: {team && team.name ? team.name : ''} + , + , + ]} + /> + + + + + + ))} + ); }; diff --git a/plugins/splunk-on-call/src/components/types.ts b/plugins/splunk-on-call/src/components/types.ts index 3c1902e52b..0feefe8b03 100644 --- a/plugins/splunk-on-call/src/components/types.ts +++ b/plugins/splunk-on-call/src/components/types.ts @@ -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; +};