Gops: Add configuration tracker on the existing IRM page (#85838)

* WIP

* add configure irm componet and pass it to Nav

* add check if theres datasource for Alerts

* update incident steps links

* uncomment done property in the interface

* Fix not having data sources in store and removing warning using wrong Icon name type

* call incidents api in done step

* Show checked steps with an icon instead of a badge

* Implemnt check contact point ready in essentials

* Fix codeowners and prettify file

* Check if there is one oncall integration in at least one contact point

* Refactor

* Check for oncall integration being created

* Add the test oncall integration action

* Do not hide any card in irm page, and refactor

* Refactor: move hooks to a separate file

* Implement oncall chatops checks

* update incidents hook

* Show new irm page only on cloud and for admins

* fix prettier in irmhooks

* remove unused import

* fix prettier in irm hooks

* fix axios method

* Add progress bar on essentials

* Update texts and some styles

* Refactor

* fix api call

* fix check is plugin is installed

* fix async call

* fix lint

* Do not show check icon when done field is undefined in a step

* refactor

* Add test for Essentials

* check if incident plugin installed

* call incidents api to get steps

* add the new api to get config

* fix prettier

* memoize the api call

* fix lint

* add proper api call

* check if response is valid

* fix typo

* use state to save the values

* fix lint

* fix response schema

* fix prettier

* update incident steps copy

* udapte texts in respond tooltips

* Fix confiure IRM route check

* Fix logic for the data source check: check if there is a data source that is alerting compatible

* Use existing header prop in NavLandingPage instead of creating a new prop

* fix wrong updated file

* Update logic for default contact point check and update some links

* Update texts and show only one item for oncall integration with alerting checks

* Update texts following suggestions in the doc

* Fix getting default contact point and update oncall link for slack tab

* Update texts, buttons and checks following last meeting action items

* remove unnecessary component drawer

* Track interactions: user open or close essentials drawer

* Refactor

* remove unnecessary createMonitoringLogger for tracking irm events

* remove unnecessary style

* refactor

* refactor

* Add fallback links and labels for action buttons when step is done

* Update irm card styles

* remove extra space after progress bar numbers

* remove progress bar border

* Address pr review comments

* remove unnecessary AlertmanagerProvider

* fix logic behind default contact point check

* update test

* Address pr review comments part1

* add aria and properties role for Progressbar

* Reorganize hooks into separate folders/files for each app

* move done field to the step level

* Handle empty dropdown options

* Handle loading state

* Update tooltip for connecting alerting to oncall

* Use RTKQ for incidents

* handle loading for oncall hooks

* refactor getting configuration for each app

* fix incident rtkq query to be a query instead of mutation

* refactor: rename variable

* Address some nits in the review

---------

Co-authored-by: reemtariqq <reem.tariq@grafana.com>
This commit is contained in:
Sonia Aguilar 2024-05-14 16:42:22 +02:00 committed by GitHub
parent 367af27d7a
commit ca0dae6812
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1110 additions and 7 deletions

1
.github/CODEOWNERS vendored
View File

@ -283,6 +283,7 @@
/pkg/services/sqlstore/migrations/ualert/ @grafana/alerting-backend-product
/pkg/tests/api/alerting/ @grafana/alerting-backend-product
/public/app/features/alerting/ @grafana/alerting-frontend
/public/app/features/gops/ @grafana/alerting-frontend
# Library Services
/pkg/services/libraryelements/ @grafana/dashboards-squad

View File

@ -0,0 +1,23 @@
import { SupportedPlugin } from '../types/pluginBridges';
import { alertingApi } from './alertingApi';
interface IncidentsPluginConfigDto {
isChatOpsInstalled: boolean;
isIncidentCreated: boolean;
}
const getProxyApiUrl = (path: string) =>
`/api/plugins/grafana-incident-app/resources/${SupportedPlugin.Incident}${path}`;
export const incidentsApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
getIncidentsPluginConfig: build.query<IncidentsPluginConfigDto, void>({
query: (integration) => ({
url: getProxyApiUrl('/api/internal/v1/organization/config-checks/'),
method: 'POST',
showErrorAlert: false,
}),
}),
}),
});

View File

@ -33,6 +33,11 @@ export interface CreateIntegrationDTO {
verbal_name: string;
}
export interface OnCallConfigChecks {
is_chatops_connected: boolean;
is_integration_chatops_connected: boolean;
}
const getProxyApiUrl = (path: string) => `/api/plugin-proxy/${SupportedPlugin.OnCall}${path}`;
export const onCallApi = alertingApi.injectEndpoints({
@ -79,6 +84,12 @@ export const onCallApi = alertingApi.injectEndpoints({
showErrorAlert: false,
}),
}),
onCallConfigChecks: build.query<OnCallConfigChecks, void>({
query: () => ({
url: getProxyApiUrl('/api/internal/v1/organization/config-checks/'),
showErrorAlert: false,
}),
}),
}),
});

View File

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { contextSrv, contextSrv as ctx } from 'app/core/services/context_srv';
import { contextSrv as ctx } from 'app/core/services/context_srv';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
@ -9,6 +9,7 @@ import { alertmanagerApi } from '../api/alertmanagerApi';
import { useAlertmanager } from '../state/AlertmanagerContext';
import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { isAdmin } from '../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules';
import { useIsRuleEditable } from './useIsRuleEditable';
@ -197,9 +198,6 @@ export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> {
const notificationsPermissions = getNotificationsPermissions(selectedAlertmanager!);
const instancePermissions = getInstancesPermissions(selectedAlertmanager!);
//we need to know user role to determine if they can view autogenerated policy tree
const isAdmin = contextSrv.hasRole('Admin') || contextSrv.isGrafanaAdmin;
// list out all of the abilities, and if the user has permissions to perform them
const abilities: Abilities<AlertmanagerAction> = {
// -- configuration --
@ -236,7 +234,7 @@ export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> {
isGrafanaFlavoredAlertmanager,
notificationsPermissions.provisioning.readSecrets
),
[AlertmanagerAction.ViewAutogeneratedPolicyTree]: [isGrafanaFlavoredAlertmanager, isAdmin],
[AlertmanagerAction.ViewAutogeneratedPolicyTree]: [isGrafanaFlavoredAlertmanager, isAdmin()],
// -- silences --
// for now, all supported Alertmanager flavors have API endpoints for managing silences
[AlertmanagerAction.CreateSilence]: toAbility(AlwaysSupported, instancePermissions.create),

View File

@ -1,9 +1,10 @@
import { sortBy } from 'lodash';
import { UrlQueryMap, Labels } from '@grafana/data';
import { Labels, UrlQueryMap } from '@grafana/data';
import { GrafanaEdition } from '@grafana/data/src/types/config';
import { config, isFetchError } from '@grafana/runtime';
import { DataSourceRef } from '@grafana/schema';
import { contextSrv } from 'app/core/services/context_srv';
import { escapePathSeparators } from 'app/features/alerting/unified/utils/rule-id';
import { alertInstanceKey, isGrafanaRulerRule } from 'app/features/alerting/unified/utils/rules';
import { SortOrder } from 'app/plugins/panel/alertlist/types';
@ -222,6 +223,10 @@ export function isOpenSourceEdition() {
return buildInfo.edition === GrafanaEdition.OpenSource;
}
export function isAdmin() {
return contextSrv.hasRole('Admin') || contextSrv.isGrafanaAdmin;
}
export function isLocalDevEnv() {
const buildInfo = config.buildInfo;
return buildInfo.env === 'development';

View File

@ -0,0 +1,18 @@
import { reportInteraction } from '@grafana/runtime/src';
export enum IRMInteractionNames {
ViewIRMMainPage = 'grafana_irm_configuration_tracker_main_page_view',
OpenEssentials = 'grafana_irm_configuration_tracker_essentials_open',
CloseEssentials = 'grafana_irm_configuration_tracker_essentials_closed',
}
export interface ConfigurationTrackerContext {
essentialStepsDone: number;
essentialStepsToDo: number;
}
export function trackIrmConfigurationTrackerEvent(
interactionName: IRMInteractionNames,
payload: ConfigurationTrackerContext
) {
reportInteraction(interactionName, { ...payload });
}

View File

@ -0,0 +1,64 @@
import { useEffect } from 'react';
import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
import { alertmanagerApi } from 'app/features/alerting/unified/api/alertmanagerApi';
import { ReceiverTypes } from 'app/features/alerting/unified/components/receivers/grafanaAppReceivers/onCall/onCall';
import { useDataSourceFeatures } from 'app/features/alerting/unified/hooks/useCombinedRule';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { Receiver } from 'app/plugins/datasource/alertmanager/types';
export function useIsCreateAlertRuleDone() {
const [fetchRulerRules, { data, isLoading }] = alertRuleApi.endpoints.rulerRules.useLazyQuery({
refetchOnFocus: true,
refetchOnReconnect: true,
});
const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(GRAFANA_RULES_SOURCE_NAME);
const rulerConfig = dsFeatures?.rulerConfig;
useEffect(() => {
rulerConfig && fetchRulerRules({ rulerConfig });
}, [rulerConfig, fetchRulerRules]);
const rules = data
? Object.entries(data).flatMap(([_, groupDto]) => {
return groupDto.flatMap((group) => group.rules);
})
: [];
const isDone = rules.length > 0;
return { isDone, isLoading: isLoading || isLoadingDsFeatures };
}
export function isOnCallContactPointReady(contactPoints: Receiver[]) {
return contactPoints.some((contactPoint: Receiver) =>
contactPoint.grafana_managed_receiver_configs?.some((receiver) => receiver.type === ReceiverTypes.OnCall)
);
}
export function useGetContactPoints() {
const alertmanagerConfiguration = alertmanagerApi.endpoints.getAlertmanagerConfiguration.useQuery(
GRAFANA_RULES_SOURCE_NAME,
{
refetchOnFocus: true,
refetchOnReconnect: true,
refetchOnMountOrArgChange: true,
}
);
const contactPoints = alertmanagerConfiguration.data?.alertmanager_config?.receivers ?? [];
return { contactPoints, isLoading: alertmanagerConfiguration.isLoading };
}
export function useGetDefaultContactPoint() {
const alertmanagerConfiguration = alertmanagerApi.endpoints.getAlertmanagerConfiguration.useQuery(
GRAFANA_RULES_SOURCE_NAME,
{
refetchOnFocus: true,
refetchOnReconnect: true,
refetchOnMountOrArgChange: true,
}
);
const defaultContactpoint = alertmanagerConfiguration.data?.alertmanager_config?.route?.receiver ?? '';
return { defaultContactpoint, isLoading: alertmanagerConfiguration.isLoading };
}

View File

@ -0,0 +1,15 @@
import { Receiver } from 'app/plugins/datasource/alertmanager/types';
const DEFAULT_EMAIL = '<example@email.com>';
export function isContactPointReady(defaultContactPoint: string, contactPoints: Receiver[]) {
// We consider the contact point ready if the default contact has the address filled
const defaultEmailUpdated = contactPoints.some(
(contactPoint: Receiver) =>
contactPoint.name === defaultContactPoint &&
contactPoint.grafana_managed_receiver_configs?.some(
(receiver) => receiver.name === defaultContactPoint && receiver.settings?.address !== DEFAULT_EMAIL
)
);
return defaultEmailUpdated;
}

View File

@ -0,0 +1,73 @@
// ConfigCard.tsx
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Icon, LoadingPlaceholder, Stack, useStyles2 } from '@grafana/ui';
import { IrmCardConfiguration } from './ConfigureIRM';
import { ProgressBar, StepsStatus } from './ProgressBar';
interface ConfigCardProps {
config: IrmCardConfiguration;
handleActionClick: (id: number, isDone: boolean | undefined) => void;
isLoading: boolean;
}
export function ConfigCard({ config, handleActionClick, isLoading = false }: ConfigCardProps) {
const styles = useStyles2(getStyles);
return (
<Stack direction={'column'} gap={1} justifyContent={'space-around'}>
<div className={styles.cardContent}>
<Stack direction={'column'} gap={1}>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
<Stack direction={'row'} gap={1} alignItems={'center'}>
{config.title}
{config.titleIcon && <Icon name={config.titleIcon} />}
{/* Only show check icon when not loading */}
{config.isDone && !isLoading && <Icon name="check-circle" color="green" size="lg" />}
</Stack>
{config.stepsDone && config.totalStepsToDo && !isLoading && (
<Stack direction="row" gap={0.5}>
<StepsStatus stepsDone={config.stepsDone} totalStepsToDo={config.totalStepsToDo} />
complete
</Stack>
)}
</Stack>
<Stack direction={'column'}>
{!isLoading ? config.description : <LoadingPlaceholder text="Loading configuration...." />}
{/* Only show ProgressBar when not loading */}
{!isLoading && config.stepsDone && config.totalStepsToDo && (
<ProgressBar stepsDone={config.stepsDone} totalStepsToDo={config.totalStepsToDo} />
)}
</Stack>
</Stack>
<Stack direction={'row'} gap={1} justifyContent={'flex-start'} alignItems={'flex-end'}>
<Button variant="secondary" onClick={() => handleActionClick(config.id, config.isDone)}>
{config.actionButtonTitle}
</Button>
</Stack>
</div>
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
cardTitle: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
}),
cardContent: css({
background: theme.colors.background.secondary,
padding: theme.spacing(2),
borderRadius: theme.shape.radius.default,
height: '100%',
gap: theme.spacing(1),
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}),
});

View File

@ -0,0 +1,128 @@
import { css } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { IconName, Text, useStyles2 } from '@grafana/ui';
import { getFirstCompatibleDataSource } from 'app/features/alerting/unified/utils/datasource';
import { DATASOURCES_ROUTES } from 'app/features/datasources/constants';
import { IRMInteractionNames, trackIrmConfigurationTrackerEvent } from '../Analytics';
import { useGetConfigurationForUI, useGetEssentialsConfiguration } from '../irmHooks';
import { ConfigCard } from './ConfigCard';
import { Essentials } from './Essentials';
export interface IrmCardConfiguration {
id: number;
title: string;
description: string;
actionButtonTitle: string;
isDone?: boolean;
stepsDone?: number;
totalStepsToDo?: number;
titleIcon?: IconName;
}
export enum ConfigurationStepsEnum {
CONNECT_DATASOURCE,
ESSENTIALS,
}
export interface DataSourceConfigurationData {
dataSourceCompatibleWithAlerting: boolean;
}
function useGetDataSourceConfiguration(): DataSourceConfigurationData {
return {
dataSourceCompatibleWithAlerting: Boolean(getFirstCompatibleDataSource()),
};
}
export function ConfigureIRM() {
const styles = useStyles2(getStyles);
const history = useHistory();
// track only once when the component is mounted
useEffect(() => {
trackIrmConfigurationTrackerEvent(IRMInteractionNames.ViewIRMMainPage, {
essentialStepsDone: 0,
essentialStepsToDo: 0,
});
}, []);
// get all the configuration data
const dataSourceConfigurationData = useGetDataSourceConfiguration();
const essentialsConfigurationData = useGetEssentialsConfiguration();
const configuration: IrmCardConfiguration[] = useGetConfigurationForUI({
dataSourceConfigurationData,
essentialsConfigurationData,
});
const [essentialsOpen, setEssentialsOpen] = useState(false);
const handleActionClick = (configID: number, isDone?: boolean) => {
switch (configID) {
case ConfigurationStepsEnum.CONNECT_DATASOURCE:
if (isDone) {
history.push(DATASOURCES_ROUTES.List);
} else {
history.push(DATASOURCES_ROUTES.New);
}
break;
case ConfigurationStepsEnum.ESSENTIALS:
setEssentialsOpen(true);
trackIrmConfigurationTrackerEvent(IRMInteractionNames.OpenEssentials, {
essentialStepsDone: essentialsConfigurationData.stepsDone,
essentialStepsToDo: essentialsConfigurationData.totalStepsToDo,
});
break;
default:
return;
}
};
function onCloseEssentials() {
setEssentialsOpen(false);
trackIrmConfigurationTrackerEvent(IRMInteractionNames.CloseEssentials, {
essentialStepsDone: essentialsConfigurationData.stepsDone,
essentialStepsToDo: essentialsConfigurationData.totalStepsToDo,
});
}
return (
<>
<Text element="h4" variant="h4">
Configure
</Text>
<section className={styles.container}>
{configuration.map((config) => (
<ConfigCard
key={config.id}
config={config}
handleActionClick={handleActionClick}
isLoading={essentialsConfigurationData.isLoading}
/>
))}
{essentialsOpen && (
<Essentials
onClose={onCloseEssentials}
essentialsConfig={essentialsConfigurationData.essentialContent}
stepsDone={essentialsConfigurationData.stepsDone}
totalStepsToDo={essentialsConfigurationData.totalStepsToDo}
/>
)}
</section>
<Text element="h4" variant="h4">
IRM apps
</Text>
</>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
marginBottom: 0,
display: 'grid',
gap: theme.spacing(3),
gridTemplateColumns: ' 1fr 1fr',
}),
});

View File

@ -0,0 +1,128 @@
import { render, screen, within } from '@testing-library/react';
import React from 'react';
import { byRole } from 'testing-library-selector';
import { SectionsDto } from '../irmHooks';
import { Essentials } from './Essentials';
function mockSectionsDto(): SectionsDto {
return {
sections: [
{
title: 'Detect',
description: 'Configure something1',
steps: [
{
title: 'Create something1',
description: 'description1',
button: {
type: 'openLink',
urlLink: {
url: '/url1',
},
label: 'label1',
},
done: true,
},
{
title: 'Create something2',
description: 'description2',
button: {
type: 'openLink',
urlLink: {
url: '/url2',
},
label: 'label2',
},
done: false,
},
{
title: 'testing step',
description: 'description3',
button: {
type: 'openLink',
urlLink: {
url: '/url3',
},
label: 'label3',
},
},
{
title: 'testing step',
description: 'description3',
button: {
type: 'openLink',
urlLink: {
url: '/url4',
},
urlLinkOnDone: {
url: '/url4',
},
label: 'testNotDone',
labelOnDone: 'testDone',
},
done: true,
},
],
},
],
};
}
describe('Essentials', () => {
it('renders progress status correctly', async () => {
const essentialsConfig = mockSectionsDto();
const stepsDone = 2;
const totalStepsToDo = 5;
render(
<Essentials
essentialsConfig={essentialsConfig}
stepsDone={stepsDone}
totalStepsToDo={totalStepsToDo}
onClose={jest.fn()}
/>
);
const progressBar = screen.getByText(/your progress/i);
expect(within(progressBar).getByText(/2/i)).toBeInTheDocument();
expect(within(progressBar).getByText(/of 5/i)).toBeInTheDocument();
});
it('renders steps correctly', async () => {
const essentialsConfig = mockSectionsDto();
const stepsDone = 0;
const totalStepsToDo = 5;
render(
<Essentials
essentialsConfig={essentialsConfig}
stepsDone={stepsDone}
totalStepsToDo={totalStepsToDo}
onClose={jest.fn()}
/>
);
// step1 is done and labelonDone is not defined
expect(byRole('heading', { name: /detect/i }).get()).toBeInTheDocument();
const step1 = screen.getAllByTestId('step')[0];
expect(within(step1).getByText(/create something1/i)).toBeInTheDocument();
expect(within(step1).getByTestId('checked-step')).toBeInTheDocument();
expect(within(step1).queryByRole('link', { name: /label1/i })).not.toBeInTheDocument();
// step2 is not done
const step2 = screen.getAllByTestId('step')[1];
expect(within(step2).getByText(/create something2/i)).toBeInTheDocument();
expect(within(step2).getByTestId('unckecked-step')).toBeInTheDocument();
expect(byRole('link', { name: /label2/i }).get()).toBeInTheDocument();
// step3 , done is not defined
const step3 = screen.getAllByTestId('step')[2];
expect(within(step3).getByText(/testing step/i)).toBeInTheDocument();
expect(within(step3).queryByTestId('checked-step')).not.toBeInTheDocument();
expect(within(step3).queryByTestId('unckecked-step')).not.toBeInTheDocument();
expect(within(step3).queryByTestId('step-button')).not.toBeInTheDocument();
expect(within(step3).queryByRole('link', { name: /label3/i })).toBeInTheDocument();
// step4 is done and labelonDone is defined
const step4 = screen.getAllByTestId('step')[3];
expect(within(step4).getByText(/testing step/i)).toBeInTheDocument();
expect(within(step4).getByTestId('checked-step')).toBeInTheDocument();
expect(within(step4).queryByRole('link', { name: /testNotDone/i })).not.toBeInTheDocument();
expect(within(step4).queryByRole('link', { name: /testDone/i })).toBeInTheDocument();
});
});

View File

@ -0,0 +1,195 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Drawer, Dropdown, Icon, LinkButton, Menu, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { createUrl } from 'app/features/alerting/unified/utils/url';
import { SectionDto, SectionDtoStep, SectionsDto, StepButtonDto } from '../irmHooks';
import { ProgressBar, StepsStatus } from './ProgressBar';
export interface EssentialsProps {
onClose: () => void;
essentialsConfig: SectionsDto;
stepsDone: number;
totalStepsToDo: number;
}
export function Essentials({ onClose, essentialsConfig, stepsDone, totalStepsToDo }: EssentialsProps) {
return (
<Drawer title="Essentials" subtitle="Complete the following configuration tasks" onClose={onClose}>
<EssentialContent essentialContent={essentialsConfig} stepsDone={stepsDone} totalStepsToDo={totalStepsToDo} />
</Drawer>
);
}
export function EssentialContent({
essentialContent,
stepsDone,
totalStepsToDo,
}: {
essentialContent: SectionsDto;
stepsDone: number;
totalStepsToDo: number;
}) {
return (
<Stack direction={'column'} gap={1}>
<ProgressStatus stepsDone={stepsDone} totalStepsToDo={totalStepsToDo} />
{essentialContent.sections.map((section: SectionDto) => (
<Section key={section.title} section={section} />
))}
</Stack>
);
}
interface SectionProps {
section: SectionDto;
}
function Section({ section }: SectionProps) {
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
<Text element="h4">{section.title}</Text>
<Text color="secondary">{section.description}</Text>
<Stack direction={'column'} gap={2}>
{section.steps.map((step, index) => (
<Step key={index} step={step} />
))}
</Stack>
</div>
);
}
function DoneIcon({ done }: { done: boolean }) {
return done ? (
<Icon name="check-circle" color="green" data-testid="checked-step" />
) : (
<Icon name="circle" data-testid="unckecked-step" />
);
}
interface StepProps {
step: SectionDtoStep;
}
function Step({ step }: StepProps) {
return (
<Stack direction={'row'} justifyContent={'space-between'} data-testid="step">
<Stack direction={'row'} alignItems="center">
{step.done !== undefined && <DoneIcon done={step.done} />}
<Text variant="body">{step.title}</Text>
<Tooltip content={step.description} placement="right">
<Icon name="question-circle" />
</Tooltip>
</Stack>
<StepButton {...step.button} done={step.done} data-testid="step-button" />
</Stack>
);
}
interface LinkButtonProps {
urlLink?: { url: string; queryParams?: Record<string, string> };
label: string;
urlLinkOnDone?: { url: string; queryParams?: Record<string, string> };
labelOnDone?: string;
done?: boolean;
}
function OpenLinkButton(props: LinkButtonProps) {
const { urlLink, label, urlLinkOnDone, labelOnDone, done } = props;
const urlToGoWhenNotDone = urlLink?.url
? createUrl(urlLink.url, {
returnTo: location.pathname + location.search,
...urlLink.queryParams,
})
: '';
const urlToGoWhenDone = urlLinkOnDone?.url
? createUrl(urlLinkOnDone.url, {
returnTo: location.pathname + location.search,
...urlLinkOnDone.queryParams,
})
: '';
const urlToGo = done ? urlToGoWhenDone : urlToGoWhenNotDone;
return (
<LinkButton href={urlToGo} variant="secondary">
{done ? labelOnDone ?? label : label}
</LinkButton>
);
}
interface StepButtonProps extends StepButtonDto {
done?: boolean;
}
function StepButton({
type,
urlLink,
urlLinkOnDone,
label,
labelOnDone,
options,
onClickOption,
done,
stepNotAvailableText,
}: StepButtonProps) {
switch (type) {
case 'openLink':
return (
<OpenLinkButton
urlLink={urlLink}
label={label}
urlLinkOnDone={urlLinkOnDone}
labelOnDone={labelOnDone}
done={done}
/>
);
case 'dropDown':
if (Boolean(options?.length)) {
return (
<Dropdown
overlay={
<Menu>
{options?.map((option) => (
<Menu.Item
label={option.label}
key={option.value}
onClick={() => {
onClickOption?.(option.value);
}}
/>
))}
</Menu>
}
>
<Button variant="secondary" size="md">
{label}
<Icon name="angle-down" />
</Button>
</Dropdown>
);
} else {
return <Text>{stepNotAvailableText ?? 'No options available'} </Text>;
}
}
}
function ProgressStatus({ stepsDone, totalStepsToDo }: { stepsDone: number; totalStepsToDo: number }) {
return (
<Stack direction={'row'} gap={1} alignItems="center">
Your progress
<ProgressBar stepsDone={stepsDone} totalStepsToDo={totalStepsToDo} />
<StepsStatus stepsDone={stepsDone} totalStepsToDo={totalStepsToDo} />
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({
margin: theme.spacing(2, 0),
padding: theme.spacing(2),
border: `1px solid ${theme.colors.border.medium}`,
borderRadius: theme.shape.radius.default,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}),
};
};

View File

@ -0,0 +1,43 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Text, useStyles2 } from '@grafana/ui';
export function ProgressBar({ stepsDone, totalStepsToDo }: { stepsDone: number; totalStepsToDo: number }) {
const styles = useStyles2(getStyles);
if (totalStepsToDo === 0) {
return null;
}
return (
<div className={styles.containerStyles} role="progressbar" aria-valuenow={stepsDone} aria-valuemax={totalStepsToDo}>
<div className={styles.fillerStyles((stepsDone / totalStepsToDo) * 100)} />
</div>
);
}
export function StepsStatus({ stepsDone, totalStepsToDo }: { stepsDone: number; totalStepsToDo: number }) {
return (
<div>
<Text color="success">{stepsDone}</Text> of {totalStepsToDo}
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
containerStyles: css({
height: theme.spacing(2),
borderRadius: theme.shape.radius.pill,
backgroundColor: theme.colors.border.weak,
flex: 'auto',
}),
fillerStyles: (stepsDone: number) =>
css({
height: '100%',
width: `${stepsDone}%`,
backgroundColor: theme.colors.success.main,
borderRadius: theme.shape.radius.pill,
textAlign: 'right',
}),
};
}

View File

@ -0,0 +1,25 @@
import { incidentsApi } from 'app/features/alerting/unified/api/incidentsApi';
import { usePluginBridge } from 'app/features/alerting/unified/hooks/usePluginBridge';
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
interface IncidentsPluginConfig {
isInstalled: boolean;
isChatOpsInstalled: boolean;
isIncidentCreated: boolean;
isLoading: boolean;
}
export function useGetIncidentPluginConfig(): IncidentsPluginConfig {
const { installed: incidentPluginInstalled, loading: loadingPluginSettings } = usePluginBridge(
SupportedPlugin.Incident
);
const { data: incidentsConfig, isLoading: loadingPluginConfig } =
incidentsApi.endpoints.getIncidentsPluginConfig.useQuery();
return {
isInstalled: incidentPluginInstalled ?? false,
isChatOpsInstalled: incidentsConfig?.isChatOpsInstalled ?? false,
isIncidentCreated: incidentsConfig?.isIncidentCreated ?? false,
isLoading: loadingPluginSettings || loadingPluginConfig,
};
}

View File

@ -0,0 +1,322 @@
import { useMemo } from 'react';
import { locationService } from '@grafana/runtime';
import { createUrl } from 'app/features/alerting/unified/utils/url';
import {
isOnCallContactPointReady,
useGetContactPoints,
useGetDefaultContactPoint,
useIsCreateAlertRuleDone,
} from './alerting/hooks';
import { isContactPointReady } from './alerting/utils';
import { ConfigurationStepsEnum, DataSourceConfigurationData, IrmCardConfiguration } from './components/ConfigureIRM';
import { useGetIncidentPluginConfig } from './incidents/hooks';
import { useOnCallChatOpsConnections, useOnCallOptions } from './onCall/hooks';
interface UrlLink {
url: string;
queryParams?: Record<string, string>;
}
export interface StepButtonDto {
type: 'openLink' | 'dropDown';
label: string;
labelOnDone?: string;
urlLink?: UrlLink; // only for openLink
urlLinkOnDone?: UrlLink; // only for openLink
options?: Array<{ label: string; value: string }>; // only for dropDown
onClickOption?: (value: string) => void; // only for dropDown
stepNotAvailableText?: string;
}
export interface SectionDtoStep {
title: string;
description: string;
button: StepButtonDto;
done?: boolean;
}
export interface SectionDto {
title: string;
description: string;
steps: SectionDtoStep[];
}
export interface SectionsDto {
sections: SectionDto[];
}
export interface EssentialsConfigurationData {
essentialContent: SectionsDto;
stepsDone: number;
totalStepsToDo: number;
isLoading: boolean;
}
function useGetConfigurationForApps() {
// configuration checks for alerting
const { contactPoints, isLoading: isLoadingContactPoints } = useGetContactPoints();
const { defaultContactpoint, isLoading: isLoadingDefaultContactPoint } = useGetDefaultContactPoint();
const { isDone: isCreateAlertRuleDone, isLoading: isLoadingAlertCreatedDone } = useIsCreateAlertRuleDone();
// configuration checks for incidents
const {
isChatOpsInstalled,
isInstalled: isIncidentsInstalled,
isLoading: isIncidentsConfigLoading,
} = useGetIncidentPluginConfig();
// configuration checks for oncall
const onCallOptions = useOnCallOptions();
const {
is_chatops_connected,
is_integration_chatops_connected,
isLoading: isOnCallConfigLoading,
} = useOnCallChatOpsConnections();
// check if any of the configurations are loading
const isLoading =
isLoadingContactPoints ||
isLoadingDefaultContactPoint ||
isLoadingAlertCreatedDone ||
isIncidentsConfigLoading ||
isOnCallConfigLoading;
return {
alerting: {
contactPoints,
defaultContactpoint,
isCreateAlertRuleDone,
},
incidents: {
isChatOpsInstalled,
isIncidentsInstalled,
},
onCall: {
onCallOptions,
is_chatops_connected,
is_integration_chatops_connected,
},
isLoading,
};
}
export function useGetEssentialsConfiguration(): EssentialsConfigurationData {
const {
alerting: { contactPoints, defaultContactpoint, isCreateAlertRuleDone },
incidents: { isChatOpsInstalled, isIncidentsInstalled },
onCall: { onCallOptions, is_chatops_connected, is_integration_chatops_connected },
isLoading,
} = useGetConfigurationForApps();
function onIntegrationClick(integrationId: string, url: string) {
const urlToGoWithIntegration = createUrl(url + integrationId, {
returnTo: location.pathname + location.search,
});
locationService.push(urlToGoWithIntegration);
}
const essentialContent: SectionsDto = {
sections: [
{
title: 'Detect',
description: 'Configure Grafana Alerting',
steps: [
{
title: 'Update default email contact point',
description: 'Add a valid email to the default email contact point.',
button: {
type: 'openLink',
urlLink: {
url: `/alerting/notifications/receivers/${defaultContactpoint}/edit`,
queryParams: { alertmanager: 'grafana' },
},
label: 'Edit',
labelOnDone: 'View',
urlLinkOnDone: {
url: `/alerting/notifications`,
},
},
done: isContactPointReady(defaultContactpoint, contactPoints),
},
{
title: 'Connect alerting to OnCall',
description: 'Create an OnCall integration for an alerting contact point.',
button: {
type: 'openLink',
urlLink: {
url: '/alerting/notifications/receivers/new',
},
label: 'Connect',
urlLinkOnDone: {
url: '/alerting/notifications',
},
labelOnDone: 'View',
},
done: isOnCallContactPointReady(contactPoints),
},
{
title: 'Create alert rule',
description: 'Create an alert rule to monitor your system.',
button: {
type: 'openLink',
urlLink: {
url: '/alerting/new',
},
label: 'Create',
urlLinkOnDone: {
url: '/alerting/list',
},
labelOnDone: 'View',
},
done: isCreateAlertRuleDone,
},
],
},
{
title: 'Respond',
description: 'Configure OnCall and Incident',
steps: [
{
title: 'Initialize Incident plugin',
description: 'Initialize the Incident plugin to declare and manage incidents.',
button: {
type: 'openLink',
urlLink: {
url: '/a/grafana-incident-app/walkthrough/generate-key',
},
label: 'Initialize',
urlLinkOnDone: {
url: '/a/grafana-incident-app',
},
labelOnDone: 'View',
},
done: isIncidentsInstalled,
},
{
title: 'Connect your Messaging workspace to OnCall',
description: 'Receive alerts and oncall notifications within your chat environment.',
button: {
type: 'openLink',
urlLink: {
url: '/a/grafana-oncall-app/settings',
queryParams: { tab: 'ChatOps', chatOpsTab: 'Slack' },
},
label: 'Connect',
urlLinkOnDone: {
url: '/a/grafana-oncall-app/settings',
queryParams: { tab: 'ChatOps' },
},
labelOnDone: 'View',
},
done: is_chatops_connected,
},
{
title: 'Connect your Messaging workspace to Incident',
description:
'Automatically create an incident channel and manage incidents directly within your chat environment.',
button: {
type: 'openLink',
urlLink: {
url: '/a/grafana-incident-app/integrations/grate.slack',
},
label: 'Connect',
urlLinkOnDone: {
url: '/a/grafana-incident-app/integrations',
},
},
done: isChatOpsInstalled,
},
{
title: 'Add Messaging workspace channel to OnCall Integration',
description: 'Select ChatOps channels to route notifications',
button: {
type: 'openLink',
urlLink: {
url: '/a/grafana-oncall-app/integrations/',
},
label: 'Add',
urlLinkOnDone: {
url: '/a/grafana-oncall-app/integrations/',
},
labelOnDone: 'View',
},
done: is_integration_chatops_connected,
},
],
},
{
title: 'Test your configuration',
description: '',
steps: [
{
title: 'Send OnCall demo alert',
description: 'In the integration page, click Send demo alert, to review your notification',
button: {
type: 'dropDown',
label: 'Select integration',
options: onCallOptions,
onClickOption: (value) => onIntegrationClick(value, '/a/grafana-oncall-app/integrations/'),
stepNotAvailableText: 'No integrations available',
},
},
{
title: 'Create Incident drill',
description: 'Practice solving an Incident',
button: {
type: 'openLink',
urlLink: {
url: '/a/grafana-incident-app',
queryParams: { declare: 'new', drill: '1' },
},
label: 'Start drill',
},
},
],
},
],
};
const { stepsDone, totalStepsToDo } = essentialContent.sections.reduce(
(acc, section) => {
const stepsDone = section.steps.filter((step) => step.done).length;
const totalStepsToForSection = section.steps.reduce((acc, step) => (step.done !== undefined ? acc + 1 : acc), 0);
return {
stepsDone: acc.stepsDone + stepsDone,
totalStepsToDo: acc.totalStepsToDo + totalStepsToForSection,
};
},
{ stepsDone: 0, totalStepsToDo: 0 }
);
return { essentialContent, stepsDone, totalStepsToDo, isLoading };
}
interface UseConfigurationProps {
dataSourceConfigurationData: DataSourceConfigurationData;
essentialsConfigurationData: EssentialsConfigurationData;
}
export const useGetConfigurationForUI = ({
dataSourceConfigurationData: { dataSourceCompatibleWithAlerting },
essentialsConfigurationData: { stepsDone, totalStepsToDo },
}: UseConfigurationProps): IrmCardConfiguration[] => {
return useMemo(() => {
function getConnectDataSourceConfiguration() {
const description = dataSourceCompatibleWithAlerting
? 'You have connected a datasource.'
: 'Connect at least one data source to start receiving data.';
const actionButtonTitle = dataSourceCompatibleWithAlerting ? 'View' : 'Connect';
return {
id: ConfigurationStepsEnum.CONNECT_DATASOURCE,
title: 'Connect data source',
description,
actionButtonTitle,
isDone: dataSourceCompatibleWithAlerting,
};
}
return [
getConnectDataSourceConfiguration(),
{
id: ConfigurationStepsEnum.ESSENTIALS,
title: 'Essentials',
titleIcon: 'star',
description: 'Configure the features you need to start using Grafana IRM workflows',
actionButtonTitle: 'Start',
stepsDone,
totalStepsToDo,
},
];
}, [dataSourceCompatibleWithAlerting, stepsDone, totalStepsToDo]);
};

View File

@ -0,0 +1,45 @@
import { onCallApi } from 'app/features/alerting/unified/api/onCallApi';
import { usePluginBridge } from 'app/features/alerting/unified/hooks/usePluginBridge';
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
export function useGetOnCallIntegrations() {
const { installed: onCallPluginInstalled } = usePluginBridge(SupportedPlugin.OnCall);
const { data: onCallIntegrations } = onCallApi.endpoints.grafanaOnCallIntegrations.useQuery(undefined, {
skip: !onCallPluginInstalled,
refetchOnFocus: true,
refetchOnReconnect: true,
refetchOnMountOrArgChange: true,
});
return onCallIntegrations ?? [];
}
function useGetOnCallConfigurationChecks() {
const { data: onCallConfigChecks, isLoading } = onCallApi.endpoints.onCallConfigChecks.useQuery(undefined, {
refetchOnFocus: true,
refetchOnReconnect: true,
refetchOnMountOrArgChange: true,
});
return {
isLoading,
onCallConfigChecks: onCallConfigChecks ?? { is_chatops_connected: false, is_integration_chatops_connected: false },
};
}
export function useOnCallOptions() {
const onCallIntegrations = useGetOnCallIntegrations();
return onCallIntegrations.map((integration) => ({
label: integration.display_name,
value: integration.value,
}));
}
export function useOnCallChatOpsConnections() {
const {
onCallConfigChecks: { is_chatops_connected, is_integration_chatops_connected },
isLoading,
} = useGetOnCallConfigurationChecks();
return { is_chatops_connected, is_integration_chatops_connected, isLoading };
}

View File

@ -10,10 +10,12 @@ import { contextSrv } from 'app/core/services/context_srv';
import UserAdminPage from 'app/features/admin/UserAdminPage';
import LdapPage from 'app/features/admin/ldap/LdapPage';
import { getAlertingRoutes } from 'app/features/alerting/routes';
import { isAdmin, isLocalDevEnv, isOpenSourceEdition } from 'app/features/alerting/unified/utils/misc';
import { ConnectionsRedirectNotice } from 'app/features/connections/components/ConnectionsRedirectNotice';
import { ROUTES as CONNECTIONS_ROUTES } from 'app/features/connections/constants';
import { getRoutes as getDataConnectionsRoutes } from 'app/features/connections/routes';
import { DATASOURCES_ROUTES } from 'app/features/datasources/constants';
import { ConfigureIRM } from 'app/features/gops/configuration-tracker/components/ConfigureIRM';
import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/routes';
import { getAppPluginRoutes } from 'app/features/plugins/routes';
import { getProfileRoutes } from 'app/features/profile/routes';
@ -171,7 +173,14 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/alerts-and-incidents',
component: () => <NavLandingPage navId="alerts-and-incidents" />,
component: () => {
return (
<NavLandingPage
navId="alerts-and-incidents"
header={(!isOpenSourceEdition() && isAdmin()) || isLocalDevEnv() ? <ConfigureIRM /> : undefined}
/>
);
},
},
{
path: '/testing-and-synthetics',