From ca0dae6812704f161f2bdce9e1df8f97c6315af4 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Tue, 14 May 2024 16:42:22 +0200 Subject: [PATCH] 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 --- .github/CODEOWNERS | 1 + .../alerting/unified/api/incidentsApi.ts | 23 ++ .../alerting/unified/api/onCallApi.ts | 11 + .../alerting/unified/hooks/useAbilities.ts | 8 +- .../features/alerting/unified/utils/misc.ts | 7 +- .../gops/configuration-tracker/Analytics.ts | 18 + .../configuration-tracker/alerting/hooks.ts | 64 ++++ .../configuration-tracker/alerting/utils.ts | 15 + .../components/ConfigCard.tsx | 73 ++++ .../components/ConfigureIRM.tsx | 128 +++++++ .../components/Essentials.test.tsx | 128 +++++++ .../components/Essentials.tsx | 195 +++++++++++ .../components/ProgressBar.tsx | 43 +++ .../configuration-tracker/incidents/hooks.ts | 25 ++ .../gops/configuration-tracker/irmHooks.ts | 322 ++++++++++++++++++ .../configuration-tracker/onCall/hooks.ts | 45 +++ public/app/routes/routes.tsx | 11 +- 17 files changed, 1110 insertions(+), 7 deletions(-) create mode 100644 public/app/features/alerting/unified/api/incidentsApi.ts create mode 100644 public/app/features/gops/configuration-tracker/Analytics.ts create mode 100644 public/app/features/gops/configuration-tracker/alerting/hooks.ts create mode 100644 public/app/features/gops/configuration-tracker/alerting/utils.ts create mode 100644 public/app/features/gops/configuration-tracker/components/ConfigCard.tsx create mode 100644 public/app/features/gops/configuration-tracker/components/ConfigureIRM.tsx create mode 100644 public/app/features/gops/configuration-tracker/components/Essentials.test.tsx create mode 100644 public/app/features/gops/configuration-tracker/components/Essentials.tsx create mode 100644 public/app/features/gops/configuration-tracker/components/ProgressBar.tsx create mode 100644 public/app/features/gops/configuration-tracker/incidents/hooks.ts create mode 100644 public/app/features/gops/configuration-tracker/irmHooks.ts create mode 100644 public/app/features/gops/configuration-tracker/onCall/hooks.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ecc76556591..dc26acbb5b0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/public/app/features/alerting/unified/api/incidentsApi.ts b/public/app/features/alerting/unified/api/incidentsApi.ts new file mode 100644 index 00000000000..e237390f639 --- /dev/null +++ b/public/app/features/alerting/unified/api/incidentsApi.ts @@ -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({ + query: (integration) => ({ + url: getProxyApiUrl('/api/internal/v1/organization/config-checks/'), + method: 'POST', + showErrorAlert: false, + }), + }), + }), +}); diff --git a/public/app/features/alerting/unified/api/onCallApi.ts b/public/app/features/alerting/unified/api/onCallApi.ts index a3a4ebdfb6b..6a71824a4fa 100644 --- a/public/app/features/alerting/unified/api/onCallApi.ts +++ b/public/app/features/alerting/unified/api/onCallApi.ts @@ -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({ + query: () => ({ + url: getProxyApiUrl('/api/internal/v1/organization/config-checks/'), + showErrorAlert: false, + }), + }), }), }); diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 93ce5d66c72..fa6a10da960 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -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 { 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 = { // -- configuration -- @@ -236,7 +234,7 @@ export function useAllAlertmanagerAbilities(): Abilities { 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), diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 9106eb4a022..d0ba8e153c3 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -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'; diff --git a/public/app/features/gops/configuration-tracker/Analytics.ts b/public/app/features/gops/configuration-tracker/Analytics.ts new file mode 100644 index 00000000000..89013b74e52 --- /dev/null +++ b/public/app/features/gops/configuration-tracker/Analytics.ts @@ -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 }); +} diff --git a/public/app/features/gops/configuration-tracker/alerting/hooks.ts b/public/app/features/gops/configuration-tracker/alerting/hooks.ts new file mode 100644 index 00000000000..3dbc9844d18 --- /dev/null +++ b/public/app/features/gops/configuration-tracker/alerting/hooks.ts @@ -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 }; +} diff --git a/public/app/features/gops/configuration-tracker/alerting/utils.ts b/public/app/features/gops/configuration-tracker/alerting/utils.ts new file mode 100644 index 00000000000..481f42504af --- /dev/null +++ b/public/app/features/gops/configuration-tracker/alerting/utils.ts @@ -0,0 +1,15 @@ +import { Receiver } from 'app/plugins/datasource/alertmanager/types'; +const DEFAULT_EMAIL = ''; + +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; +} diff --git a/public/app/features/gops/configuration-tracker/components/ConfigCard.tsx b/public/app/features/gops/configuration-tracker/components/ConfigCard.tsx new file mode 100644 index 00000000000..180d5ab6538 --- /dev/null +++ b/public/app/features/gops/configuration-tracker/components/ConfigCard.tsx @@ -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 ( + +
+ + + + {config.title} + {config.titleIcon && } + {/* Only show check icon when not loading */} + {config.isDone && !isLoading && } + + {config.stepsDone && config.totalStepsToDo && !isLoading && ( + + + complete + + )} + + + {!isLoading ? config.description : } + {/* Only show ProgressBar when not loading */} + {!isLoading && config.stepsDone && config.totalStepsToDo && ( + + )} + + + + + +
+
+ ); +} + +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', + }), +}); diff --git a/public/app/features/gops/configuration-tracker/components/ConfigureIRM.tsx b/public/app/features/gops/configuration-tracker/components/ConfigureIRM.tsx new file mode 100644 index 00000000000..de024df258a --- /dev/null +++ b/public/app/features/gops/configuration-tracker/components/ConfigureIRM.tsx @@ -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 ( + <> + + Configure + +
+ {configuration.map((config) => ( + + ))} + {essentialsOpen && ( + + )} +
+ + IRM apps + + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + marginBottom: 0, + display: 'grid', + gap: theme.spacing(3), + gridTemplateColumns: ' 1fr 1fr', + }), +}); diff --git a/public/app/features/gops/configuration-tracker/components/Essentials.test.tsx b/public/app/features/gops/configuration-tracker/components/Essentials.test.tsx new file mode 100644 index 00000000000..84291824a97 --- /dev/null +++ b/public/app/features/gops/configuration-tracker/components/Essentials.test.tsx @@ -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( + + ); + 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( + + ); + // 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(); + }); +}); diff --git a/public/app/features/gops/configuration-tracker/components/Essentials.tsx b/public/app/features/gops/configuration-tracker/components/Essentials.tsx new file mode 100644 index 00000000000..00cdeeb68a8 --- /dev/null +++ b/public/app/features/gops/configuration-tracker/components/Essentials.tsx @@ -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 ( + + + + ); +} + +export function EssentialContent({ + essentialContent, + stepsDone, + totalStepsToDo, +}: { + essentialContent: SectionsDto; + stepsDone: number; + totalStepsToDo: number; +}) { + return ( + + + {essentialContent.sections.map((section: SectionDto) => ( +
+ ))} + + ); +} +interface SectionProps { + section: SectionDto; +} +function Section({ section }: SectionProps) { + const styles = useStyles2(getStyles); + return ( +
+ {section.title} + + {section.description} + + {section.steps.map((step, index) => ( + + ))} + +
+ ); +} +function DoneIcon({ done }: { done: boolean }) { + return done ? ( + + ) : ( + + ); +} +interface StepProps { + step: SectionDtoStep; +} + +function Step({ step }: StepProps) { + return ( + + + {step.done !== undefined && } + {step.title} + + + + + + + ); +} + +interface LinkButtonProps { + urlLink?: { url: string; queryParams?: Record }; + label: string; + urlLinkOnDone?: { url: string; queryParams?: Record }; + 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 ( + + {done ? labelOnDone ?? label : label} + + ); +} + +interface StepButtonProps extends StepButtonDto { + done?: boolean; +} +function StepButton({ + type, + urlLink, + urlLinkOnDone, + label, + labelOnDone, + options, + onClickOption, + done, + stepNotAvailableText, +}: StepButtonProps) { + switch (type) { + case 'openLink': + return ( + + ); + case 'dropDown': + if (Boolean(options?.length)) { + return ( + + {options?.map((option) => ( + { + onClickOption?.(option.value); + }} + /> + ))} + + } + > + + + ); + } else { + return {stepNotAvailableText ?? 'No options available'} ; + } + } +} + +function ProgressStatus({ stepsDone, totalStepsToDo }: { stepsDone: number; totalStepsToDo: number }) { + return ( + + Your progress + + + + ); +} + +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), + }), + }; +}; diff --git a/public/app/features/gops/configuration-tracker/components/ProgressBar.tsx b/public/app/features/gops/configuration-tracker/components/ProgressBar.tsx new file mode 100644 index 00000000000..e1a766fd14b --- /dev/null +++ b/public/app/features/gops/configuration-tracker/components/ProgressBar.tsx @@ -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 ( +
+
+
+ ); +} +export function StepsStatus({ stepsDone, totalStepsToDo }: { stepsDone: number; totalStepsToDo: number }) { + return ( +
+ {stepsDone} of {totalStepsToDo} +
+ ); +} + +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', + }), + }; +} diff --git a/public/app/features/gops/configuration-tracker/incidents/hooks.ts b/public/app/features/gops/configuration-tracker/incidents/hooks.ts new file mode 100644 index 00000000000..aabbaf56a2b --- /dev/null +++ b/public/app/features/gops/configuration-tracker/incidents/hooks.ts @@ -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, + }; +} diff --git a/public/app/features/gops/configuration-tracker/irmHooks.ts b/public/app/features/gops/configuration-tracker/irmHooks.ts new file mode 100644 index 00000000000..200540936e6 --- /dev/null +++ b/public/app/features/gops/configuration-tracker/irmHooks.ts @@ -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; +} +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]); +}; diff --git a/public/app/features/gops/configuration-tracker/onCall/hooks.ts b/public/app/features/gops/configuration-tracker/onCall/hooks.ts new file mode 100644 index 00000000000..7fb4862970d --- /dev/null +++ b/public/app/features/gops/configuration-tracker/onCall/hooks.ts @@ -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 }; +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index fc6a9873644..423e3ce9fff 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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: () => , + component: () => { + return ( + : undefined} + /> + ); + }, }, { path: '/testing-and-synthetics',