Merge pull request #9635 from mdb/mdb/splunk-on-call-read-only

[Plugin] expose 'read only' configuration option in Splunk On-Call plugin
This commit is contained in:
Fredrik Adelöw
2022-02-24 11:16:09 +01:00
committed by GitHub
8 changed files with 120 additions and 14 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-splunk-on-call': patch
---
The Splunk On-Call plugin now supports an optional `readOnly` property (`<SplunkOnCallEntityCard readOnly />`) for suppressing the rendering of incident trigger-acknowledge-resolve controls from the Backstage UI.
+21
View File
@@ -45,6 +45,14 @@ const overviewContent = (
</EntitySwitch>
```
### `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
<EntitySplunkOnCallCard readOnly />
```
## 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 (`<EntitySplunkOnCallCard readOnly />`) 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.
+4 -1
View File
@@ -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)
//
@@ -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(
<ApiProvider apis={apis}>
<EntityProvider entity={mockEntityNoIncidents}>
<EntitySplunkOnCallCard readOnly />
</EntityProvider>
</ApiProvider>,
),
);
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()
@@ -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 (
<>
<Incidents team={teamName} refreshIncidents={refreshIncidents} />
<Incidents
readOnly={readOnly || false}
team={teamName}
refreshIncidents={refreshIncidents}
/>
{usersHashMap && team && (
<EscalationPolicy team={teamName} users={usersHashMap} />
)}
@@ -259,7 +270,7 @@ export const EntitySplunkOnCallCard = () => {
</Typography>,
<HeaderIconLinkRow
key="incident_trigger"
links={[serviceLink, triggerLink]}
links={!readOnly ? [serviceLink, triggerLink] : [serviceLink]}
/>,
]}
/>
@@ -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 && (
<ListItemSecondaryAction>
<IncidentAction
currentPhase={incident.currentPhase || ''}
incidentId={incident.entityId}
resolveAction={handleResolveIncident}
acknowledgeAction={handleAcknowledgeIncident}
/>
{!readOnly && (
<IncidentAction
currentPhase={incident.currentPhase || ''}
incidentId={incident.entityId}
resolveAction={handleResolveIncident}
acknowledgeAction={handleAcknowledgeIncident}
/>
)}
<Tooltip title="View in Splunk On-Call" placement="top">
<IconButton
href={incident.incidentLink}
@@ -44,7 +44,7 @@ describe('Incidents', () => {
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<Incidents refreshIncidents={false} team="test" />
<Incidents readOnly={false} refreshIncidents={false} team="test" />
</ApiProvider>,
),
);
@@ -68,7 +68,7 @@ describe('Incidents', () => {
} = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<Incidents team="test" refreshIncidents={false} />
<Incidents readOnly={false} team="test" refreshIncidents={false} />
</ApiProvider>,
),
);
@@ -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(
<ApiProvider apis={apis}>
<Incidents readOnly team="test" refreshIncidents={false} />
</ApiProvider>,
),
);
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(
<ApiProvider apis={apis}>
<Incidents team="test" refreshIncidents={false} />
<Incidents readOnly={false} team="test" refreshIncidents={false} />
</ApiProvider>,
),
);
@@ -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}
/>
))
)}