diff --git a/.changeset/spicy-elephants-decide.md b/.changeset/spicy-elephants-decide.md new file mode 100644 index 0000000000..2444a60b05 --- /dev/null +++ b/.changeset/spicy-elephants-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-splunk-on-call': patch +--- + +The Splunk On-Call plugin now supports an optional `readOnly` property (``) for suppressing the rendering of incident trigger-acknowledge-resolve controls from the Backstage UI. diff --git a/plugins/splunk-on-call/README.md b/plugins/splunk-on-call/README.md index 0200cacc50..5078dfa38b 100644 --- a/plugins/splunk-on-call/README.md +++ b/plugins/splunk-on-call/README.md @@ -45,6 +45,14 @@ const overviewContent = ( ``` +### `readOnly` mode + +To suppress the rendering of the actionable create-acknowledge-resolve incident buttons and UI controls, the `EntitySplunkOnCallCard` can also be instantiated in `readOnly` mode: + +```ts + +``` + ## Client configuration In order to be able to perform certain actions (create-acknowledge-resolve an action), you need to provide a REST Endpoint. @@ -74,6 +82,19 @@ proxy: In addition, to make certain API calls (trigger-resolve-acknowledge an incident) you need to add the `PATCH` method to the backend `cors` methods list: `[GET, POST, PUT, DELETE, PATCH]`. +**WARNING**: In current implementation, the Splunk OnCall plugin requires the `/splunk-on-call` proxy endpoint be exposed by the Backstage backend as an unprotected endpoint, in effect enabling Splunk OnCall API access using the configured `SPLUNK_ON_CALL_API_KEY` for any user or process with access to the `/splunk-on-call` Backstage backend endpoint. See below for further configuration options enabling protection of this endpoint. If you regard this as problematic, consider using the plugin in `readOnly` mode (``) using the following proxy configuration: + +```yaml +proxy: + '/splunk-on-call': + target: https://api.victorops.com/api-public + headers: + X-VO-Api-Id: ${SPLUNK_ON_CALL_API_ID} + X-VO-Api-Key: ${SPLUNK_ON_CALL_API_KEY} + # prohibit the `/splunk-on-call` proxy endpoint from servicing non-GET requests + allowedMethods: ['GET'] +``` + ### Adding your team name to the entity annotation The information displayed for each entity is based on either an associated team name or an associated routing key. diff --git a/plugins/splunk-on-call/api-report.md b/plugins/splunk-on-call/api-report.md index 8e8076d9e8..60ec38ca6d 100644 --- a/plugins/splunk-on-call/api-report.md +++ b/plugins/splunk-on-call/api-report.md @@ -12,10 +12,13 @@ import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { RouteRef } from '@backstage/core-plugin-api'; +// Warning: (ae-forgotten-export) The symbol "EntitySplunkOnCallCardProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "EntitySplunkOnCallCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const EntitySplunkOnCallCard: () => JSX.Element; +export const EntitySplunkOnCallCard: ( + props: EntitySplunkOnCallCardProps, +) => JSX.Element; // Warning: (ae-missing-release-tag) "isSplunkOnCallAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx index a83db136e4..0688fb8237 100644 --- a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx +++ b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx @@ -131,6 +131,32 @@ describe('SplunkOnCallCard', () => { expect(getByText('Empty escalation policy')).toBeInTheDocument(); }); + it('does not render a "Create incident" link in read only mode', async () => { + mockSplunkOnCallApi.getUsers = jest + .fn() + .mockImplementationOnce(async () => [MOCKED_USER]); + mockSplunkOnCallApi.getTeams = jest + .fn() + .mockImplementation(async () => [MOCK_TEAM_NO_INCIDENTS]); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(() => getByText('Create Incident')).toThrow(); + await waitFor( + () => expect(getByText('Nice! No incidents found!')).toBeInTheDocument(), + { timeout: 2000 }, + ); + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + }); + it('handles a "splunk.com/on-call-routing-key" annotation', async () => { mockSplunkOnCallApi.getUsers = jest .fn() diff --git a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx index 4a6f983636..4f6bcd9a0e 100644 --- a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx +++ b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx @@ -109,7 +109,14 @@ const useStyles = makeStyles({ }, }); -export const EntitySplunkOnCallCard = () => { +/** @public */ +export type EntitySplunkOnCallCardProps = { + readOnly?: boolean; +}; + +/** @public */ +export const EntitySplunkOnCallCard = (props: EntitySplunkOnCallCardProps) => { + const { readOnly } = props; const classes = useStyles(); const config = useApi(configApiRef); const api = useApi(splunkOnCallApiRef); @@ -218,7 +225,11 @@ export const EntitySplunkOnCallCard = () => { return ( <> - + {usersHashMap && team && ( )} @@ -259,7 +270,7 @@ export const EntitySplunkOnCallCard = () => { , , ]} /> diff --git a/plugins/splunk-on-call/src/components/Incident/IncidentListItem.tsx b/plugins/splunk-on-call/src/components/Incident/IncidentListItem.tsx index 4c9975bd06..d8b6a56efc 100644 --- a/plugins/splunk-on-call/src/components/Incident/IncidentListItem.tsx +++ b/plugins/splunk-on-call/src/components/Incident/IncidentListItem.tsx @@ -64,6 +64,7 @@ type Props = { team: string; incident: Incident; onIncidentAction: () => void; + readOnly: boolean; }; const IncidentPhaseStatus = ({ @@ -135,6 +136,7 @@ const IncidentAction = ({ export const IncidentListItem = ({ incident, + readOnly, onIncidentAction, team, }: Props) => { @@ -241,12 +243,14 @@ export const IncidentListItem = ({ {incident.incidentLink && incident.incidentNumber && ( - + {!readOnly && ( + + )} { const { getByText, queryByTestId } = render( wrapInTestApp( - + , ), ); @@ -68,7 +68,7 @@ describe('Incidents', () => { } = render( wrapInTestApp( - + , ), ); @@ -90,6 +90,40 @@ describe('Incidents', () => { expect(getAllByTitle('View in Splunk On-Call').length).toEqual(1); }); + it('does not render incident action buttons in read only mode', async () => { + mockSplunkOnCallApi.getIncidents.mockResolvedValue([MOCK_INCIDENT]); + mockSplunkOnCallApi.getTeams.mockResolvedValue([MOCK_TEAM]); + + const { getByText, getAllByTitle, getByLabelText, queryByTestId } = render( + wrapInTestApp( + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + await waitFor( + () => + expect( + getByText('user', { + exact: false, + }), + ).toBeInTheDocument(), + { timeout: 2000 }, + ); + expect(getByText('test-incident')).toBeInTheDocument(); + expect(getByLabelText('Status warning')).toBeInTheDocument(); + expect(() => getAllByTitle('Acknowledge')).toThrow( + 'Unable to find an element with the title: Acknowledge.', + ); + expect(() => getAllByTitle('Resolve')).toThrow( + 'Unable to find an element with the title: Resolve.', + ); + + // assert links, mailto and hrefs, date calculation + expect(getAllByTitle('View in Splunk On-Call').length).toEqual(1); + }); + it('Handle errors', async () => { mockSplunkOnCallApi.getIncidents.mockRejectedValueOnce( new Error('Error occurred'), @@ -99,7 +133,7 @@ describe('Incidents', () => { const { getByText, queryByTestId } = render( wrapInTestApp( - + , ), ); diff --git a/plugins/splunk-on-call/src/components/Incident/Incidents.tsx b/plugins/splunk-on-call/src/components/Incident/Incidents.tsx index 3c557773fd..e2ec9609bd 100644 --- a/plugins/splunk-on-call/src/components/Incident/Incidents.tsx +++ b/plugins/splunk-on-call/src/components/Incident/Incidents.tsx @@ -49,9 +49,10 @@ const useStyles = makeStyles((theme: Theme) => type Props = { refreshIncidents: boolean; team: string; + readOnly: boolean; }; -export const Incidents = ({ refreshIncidents, team }: Props) => { +export const Incidents = ({ readOnly, refreshIncidents, team }: Props) => { const classes = useStyles(); const api = useApi(splunkOnCallApiRef); @@ -108,6 +109,7 @@ export const Incidents = ({ refreshIncidents, team }: Props) => { key={index} team={team} incident={incident} + readOnly={readOnly} /> )) )}