mirror of https://github.com/grafana/grafana.git
				
				
				
			Merge remote-tracking branch 'origin/main' into drclau/unistor/replace-authenticators-3
This commit is contained in:
		
						commit
						8686c46be5
					
				|  | @ -72,7 +72,10 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { | |||
|         AccessControlAction.AlertingSilenceRead, | ||||
|       ]), | ||||
|       component: importAlertingComponent( | ||||
|         () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') | ||||
|         () => | ||||
|           import( | ||||
|             /* webpackChunkName: "SilencesTablePage" */ 'app/features/alerting/unified/components/silences/SilencesTable' | ||||
|           ) | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|  | @ -84,13 +87,16 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { | |||
|         AccessControlAction.AlertingSilenceUpdate, | ||||
|       ]), | ||||
|       component: importAlertingComponent( | ||||
|         () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') | ||||
|         () => import(/* webpackChunkName: "NewSilencePage" */ 'app/features/alerting/unified/NewSilencePage') | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       path: '/alerting/silence/:id/edit', | ||||
|       component: importAlertingComponent( | ||||
|         () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') | ||||
|         () => | ||||
|           import( | ||||
|             /* webpackChunkName: "ExistingSilenceEditorPage" */ 'app/features/alerting/unified/components/silences/SilencesEditor' | ||||
|           ) | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|  |  | |||
|  | @ -0,0 +1,51 @@ | |||
| import { useLocation } from 'react-router-dom-v5-compat'; | ||||
| 
 | ||||
| import { withErrorBoundary } from '@grafana/ui'; | ||||
| import { | ||||
|   defaultsFromQuery, | ||||
|   getDefaultSilenceFormValues, | ||||
| } from 'app/features/alerting/unified/components/silences/utils'; | ||||
| import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants'; | ||||
| import { parseQueryParamMatchers } from 'app/features/alerting/unified/utils/matchers'; | ||||
| 
 | ||||
| import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; | ||||
| import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; | ||||
| import { SilencesEditor } from './components/silences/SilencesEditor'; | ||||
| import { useAlertmanager } from './state/AlertmanagerContext'; | ||||
| 
 | ||||
| const SilencesEditorComponent = () => { | ||||
|   const location = useLocation(); | ||||
|   const queryParams = new URLSearchParams(location.search); | ||||
|   const { selectedAlertmanager = '' } = useAlertmanager(); | ||||
|   const potentialAlertRuleMatcher = parseQueryParamMatchers(queryParams.getAll('matcher')).find( | ||||
|     (m) => m.name === MATCHER_ALERT_RULE_UID | ||||
|   ); | ||||
| 
 | ||||
|   const potentialRuleUid = potentialAlertRuleMatcher?.value; | ||||
|   const formValues = getDefaultSilenceFormValues(defaultsFromQuery(queryParams)); | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <GrafanaAlertmanagerDeliveryWarning currentAlertmanager={selectedAlertmanager} /> | ||||
|       <SilencesEditor | ||||
|         formValues={formValues} | ||||
|         alertManagerSourceName={selectedAlertmanager} | ||||
|         ruleUid={potentialRuleUid} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| function NewSilencePage() { | ||||
|   const pageNav = { | ||||
|     id: 'silence-new', | ||||
|     text: 'Silence alert rule', | ||||
|     subTitle: 'Configure silences to stop notifications from a particular alert rule', | ||||
|   }; | ||||
|   return ( | ||||
|     <AlertmanagerPageWrapper navId="silences" pageNav={pageNav} accessType="instance"> | ||||
|       <SilencesEditorComponent /> | ||||
|     </AlertmanagerPageWrapper> | ||||
|   ); | ||||
| } | ||||
| export default withErrorBoundary(NewSilencePage, { style: 'page' }); | ||||
|  | @ -1,4 +1,4 @@ | |||
| import { useParams } from 'react-router-dom-v5-compat'; | ||||
| import { Route, Routes } from 'react-router-dom-v5-compat'; | ||||
| import { render, screen, userEvent, waitFor, within } from 'test/test-utils'; | ||||
| import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector'; | ||||
| 
 | ||||
|  | @ -17,7 +17,9 @@ import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/cons | |||
| import { MatcherOperator, SilenceState } from 'app/plugins/datasource/alertmanager/types'; | ||||
| import { AccessControlAction } from 'app/types'; | ||||
| 
 | ||||
| import Silences from './Silences'; | ||||
| import NewSilencePage from './NewSilencePage'; | ||||
| import ExistingSilenceEditorPage from './components/silences/SilencesEditor'; | ||||
| import SilencesTablePage from './components/silences/SilencesTable'; | ||||
| import { | ||||
|   MOCK_SILENCE_ID_EXISTING, | ||||
|   MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID, | ||||
|  | @ -30,21 +32,21 @@ import { grafanaRulerRule } from './mocks/grafanaRulerApi'; | |||
| import { setupDataSources } from './testSetup/datasources'; | ||||
| import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; | ||||
| 
 | ||||
| jest.mock('app/core/services/context_srv'); | ||||
| 
 | ||||
| jest.mock('react-router-dom-v5-compat', () => ({ | ||||
|   ...jest.requireActual('react-router-dom-v5-compat'), | ||||
|   useParams: jest.fn(), | ||||
| })); | ||||
| 
 | ||||
| const TEST_TIMEOUT = 60000; | ||||
| 
 | ||||
| const renderSilences = (location = '/alerting/silences/') => { | ||||
|   return render(<Silences />, { | ||||
|   return render( | ||||
|     <Routes> | ||||
|       <Route path="/alerting/silences" element={<SilencesTablePage />} /> | ||||
|       <Route path="/alerting/silence/new" element={<NewSilencePage />} /> | ||||
|       <Route path="/alerting/silence/:id/edit" element={<ExistingSilenceEditorPage />} /> | ||||
|     </Routes>, | ||||
|     { | ||||
|       historyOptions: { | ||||
|         initialEntries: [location], | ||||
|       }, | ||||
|   }); | ||||
|     } | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const dataSources = { | ||||
|  | @ -124,8 +126,7 @@ describe('Silences', () => { | |||
|   it( | ||||
|     'loads and shows silences', | ||||
|     async () => { | ||||
|       const user = userEvent.setup(); | ||||
|       renderSilences(); | ||||
|       const { user } = renderSilences(); | ||||
| 
 | ||||
|       expect(await ui.notExpiredTable.find()).toBeInTheDocument(); | ||||
| 
 | ||||
|  | @ -174,8 +175,7 @@ describe('Silences', () => { | |||
|   it( | ||||
|     'filters silences by matchers', | ||||
|     async () => { | ||||
|       const user = userEvent.setup(); | ||||
|       renderSilences(); | ||||
|       const { user } = renderSilences(); | ||||
| 
 | ||||
|       const queryBar = await ui.queryBar.find(); | ||||
|       await user.type(queryBar, 'foo=bar'); | ||||
|  | @ -260,8 +260,7 @@ describe('Silence create/edit', () => { | |||
|   it( | ||||
|     'creates a new silence', | ||||
|     async () => { | ||||
|       const user = userEvent.setup(); | ||||
|       renderSilences(`${baseUrlPath}?alertmanager=${GRAFANA_RULES_SOURCE_NAME}`); | ||||
|       const { user } = renderSilences(`${baseUrlPath}?alertmanager=${GRAFANA_RULES_SOURCE_NAME}`); | ||||
|       expect(await ui.editor.durationField.find()).toBeInTheDocument(); | ||||
| 
 | ||||
|       const postRequest = waitForServerRequest(silenceCreateHandler()); | ||||
|  | @ -320,20 +319,17 @@ describe('Silence create/edit', () => { | |||
|   }); | ||||
| 
 | ||||
|   it('shows an error when existing silence cannot be found', async () => { | ||||
|     (useParams as jest.Mock).mockReturnValue({ id: 'foo-bar' }); | ||||
|     renderSilences('/alerting/silence/foo-bar/edit'); | ||||
| 
 | ||||
|     expect(await ui.existingSilenceNotFound.find()).toBeInTheDocument(); | ||||
|   }); | ||||
| 
 | ||||
|   it('shows an error when user cannot edit/recreate silence', async () => { | ||||
|     (useParams as jest.Mock).mockReturnValue({ id: MOCK_SILENCE_ID_LACKING_PERMISSIONS }); | ||||
|     renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_LACKING_PERMISSIONS}/edit`); | ||||
|     expect(await ui.noPermissionToEdit.find()).toBeInTheDocument(); | ||||
|   }); | ||||
| 
 | ||||
|   it('populates form with existing silence information', async () => { | ||||
|     (useParams as jest.Mock).mockReturnValue({ id: MOCK_SILENCE_ID_EXISTING }); | ||||
|     renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_EXISTING}/edit`); | ||||
| 
 | ||||
|     // Await the first value to be populated, after which we can expect that all of the other
 | ||||
|  | @ -344,7 +340,6 @@ describe('Silence create/edit', () => { | |||
|   }); | ||||
| 
 | ||||
|   it('populates form with existing silence information that has __alert_rule_uid__', async () => { | ||||
|     (useParams as jest.Mock).mockReturnValue({ id: MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID }); | ||||
|     mockAlertRuleApi(server).getAlertRule(MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID, grafanaRulerRule); | ||||
|     renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID}/edit`); | ||||
|     expect(await screen.findByLabelText(/alert rule/i)).toHaveValue(grafanaRulerRule.grafana_alert.title); | ||||
|  | @ -358,11 +353,9 @@ describe('Silence create/edit', () => { | |||
|   it( | ||||
|     'silences page should contain alertmanager parameter after creating a silence', | ||||
|     async () => { | ||||
|       const user = userEvent.setup(); | ||||
| 
 | ||||
|       const postRequest = waitForServerRequest(silenceCreateHandler()); | ||||
| 
 | ||||
|       renderSilences(`${baseUrlPath}?alertmanager=${GRAFANA_RULES_SOURCE_NAME}`); | ||||
|       const { user } = renderSilences(`${baseUrlPath}?alertmanager=${GRAFANA_RULES_SOURCE_NAME}`); | ||||
|       await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); | ||||
| 
 | ||||
|       await enterSilenceLabel(0, 'foo', MatcherOperator.equal, 'bar'); | ||||
|  |  | |||
|  | @ -1,74 +0,0 @@ | |||
| import { Route, Switch } from 'react-router-dom'; | ||||
| import { useLocation } from 'react-router-dom-v5-compat'; | ||||
| 
 | ||||
| import { withErrorBoundary } from '@grafana/ui'; | ||||
| import { | ||||
|   defaultsFromQuery, | ||||
|   getDefaultSilenceFormValues, | ||||
| } from 'app/features/alerting/unified/components/silences/utils'; | ||||
| import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants'; | ||||
| import { parseQueryParamMatchers } from 'app/features/alerting/unified/utils/matchers'; | ||||
| 
 | ||||
| import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; | ||||
| import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; | ||||
| import ExistingSilenceEditor, { SilencesEditor } from './components/silences/SilencesEditor'; | ||||
| import SilencesTable from './components/silences/SilencesTable'; | ||||
| import { useSilenceNavData } from './hooks/useSilenceNavData'; | ||||
| import { useAlertmanager } from './state/AlertmanagerContext'; | ||||
| 
 | ||||
| const Silences = () => { | ||||
|   const { selectedAlertmanager } = useAlertmanager(); | ||||
| 
 | ||||
|   if (!selectedAlertmanager) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <GrafanaAlertmanagerDeliveryWarning currentAlertmanager={selectedAlertmanager} /> | ||||
|       <Switch> | ||||
|         <Route exact path="/alerting/silences"> | ||||
|           <SilencesTable alertManagerSourceName={selectedAlertmanager} /> | ||||
|         </Route> | ||||
|         <Route exact path="/alerting/silence/new"> | ||||
|           <SilencesEditorComponent selectedAlertmanager={selectedAlertmanager} /> | ||||
|         </Route> | ||||
|         <Route exact path="/alerting/silence/:id/edit"> | ||||
|           <ExistingSilenceEditor alertManagerSourceName={selectedAlertmanager} /> | ||||
|         </Route> | ||||
|       </Switch> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| function SilencesPage() { | ||||
|   const pageNav = useSilenceNavData(); | ||||
| 
 | ||||
|   return ( | ||||
|     <AlertmanagerPageWrapper navId="silences" pageNav={pageNav} accessType="instance"> | ||||
|       <Silences /> | ||||
|     </AlertmanagerPageWrapper> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default withErrorBoundary(SilencesPage, { style: 'page' }); | ||||
| 
 | ||||
| type SilencesEditorComponentProps = { | ||||
|   selectedAlertmanager: string; | ||||
| }; | ||||
| const SilencesEditorComponent = ({ selectedAlertmanager }: SilencesEditorComponentProps) => { | ||||
|   const location = useLocation(); | ||||
|   const queryParams = new URLSearchParams(location.search); | ||||
| 
 | ||||
|   const potentialAlertRuleMatcher = parseQueryParamMatchers(queryParams.getAll('matcher')).find( | ||||
|     (m) => m.name === MATCHER_ALERT_RULE_UID | ||||
|   ); | ||||
| 
 | ||||
|   const potentialRuleUid = potentialAlertRuleMatcher?.value; | ||||
| 
 | ||||
|   const formValues = getDefaultSilenceFormValues(defaultsFromQuery(queryParams)); | ||||
| 
 | ||||
|   return ( | ||||
|     <SilencesEditor formValues={formValues} alertManagerSourceName={selectedAlertmanager} ruleUid={potentialRuleUid} /> | ||||
|   ); | ||||
| }; | ||||
|  | @ -25,6 +25,7 @@ import { | |||
|   Stack, | ||||
|   TextArea, | ||||
|   useStyles2, | ||||
|   withErrorBoundary, | ||||
| } from '@grafana/ui'; | ||||
| import { alertSilencesApi, SilenceCreatedResponse } from 'app/features/alerting/unified/api/alertSilencesApi'; | ||||
| import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants'; | ||||
|  | @ -32,26 +33,26 @@ import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from 'app/features/ale | |||
| import { MatcherOperator, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; | ||||
| 
 | ||||
| import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; | ||||
| import { useAlertmanager } from '../../state/AlertmanagerContext'; | ||||
| import { SilenceFormFields } from '../../types/silence-form'; | ||||
| import { matcherFieldToMatcher } from '../../utils/alertmanager'; | ||||
| import { makeAMLink } from '../../utils/misc'; | ||||
| import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; | ||||
| import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning'; | ||||
| 
 | ||||
| import MatchersField from './MatchersField'; | ||||
| import { SilencePeriod } from './SilencePeriod'; | ||||
| import { SilencedInstancesPreview } from './SilencedInstancesPreview'; | ||||
| import { getDefaultSilenceFormValues, getFormFieldsForSilence } from './utils'; | ||||
| 
 | ||||
| interface Props { | ||||
|   alertManagerSourceName: string; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Silences editor for editing an existing silence. | ||||
|  * | ||||
|  * Fetches silence details from API, based on `silenceId` | ||||
|  */ | ||||
| const ExistingSilenceEditor = ({ alertManagerSourceName }: Props) => { | ||||
| const ExistingSilenceEditor = () => { | ||||
|   const { id: silenceId = '' } = useParams(); | ||||
|   const { selectedAlertmanager: alertManagerSourceName = '' } = useAlertmanager(); | ||||
|   const { | ||||
|     data: silence, | ||||
|     isLoading: getSilenceIsLoading, | ||||
|  | @ -91,7 +92,10 @@ const ExistingSilenceEditor = ({ alertManagerSourceName }: Props) => { | |||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <GrafanaAlertmanagerDeliveryWarning currentAlertmanager={alertManagerSourceName} /> | ||||
|       <SilencesEditor ruleUid={ruleUid} formValues={defaultValues} alertManagerSourceName={alertManagerSourceName} /> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
|  | @ -279,4 +283,16 @@ const getStyles = (theme: GrafanaTheme2) => ({ | |||
|   }), | ||||
| }); | ||||
| 
 | ||||
| export default ExistingSilenceEditor; | ||||
| function ExistingSilenceEditorPage() { | ||||
|   const pageNav = { | ||||
|     id: 'silence-edit', | ||||
|     text: 'Edit silence', | ||||
|     subTitle: 'Recreate existing silence to stop notifications from a particular alert rule', | ||||
|   }; | ||||
|   return ( | ||||
|     <AlertmanagerPageWrapper navId="silences" pageNav={pageNav} accessType="instance"> | ||||
|       <ExistingSilenceEditor /> | ||||
|     </AlertmanagerPageWrapper> | ||||
|   ); | ||||
| } | ||||
| export default withErrorBoundary(ExistingSilenceEditorPage, { style: 'page' }); | ||||
|  |  | |||
|  | @ -12,6 +12,7 @@ import { | |||
|   LoadingPlaceholder, | ||||
|   Stack, | ||||
|   useStyles2, | ||||
|   withErrorBoundary, | ||||
| } from '@grafana/ui'; | ||||
| import { useQueryParams } from 'app/core/hooks/useQueryParams'; | ||||
| import { Trans } from 'app/core/internationalization'; | ||||
|  | @ -23,10 +24,13 @@ import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource | |||
| 
 | ||||
| import { alertmanagerApi } from '../../api/alertmanagerApi'; | ||||
| import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; | ||||
| import { useAlertmanager } from '../../state/AlertmanagerContext'; | ||||
| import { parsePromQLStyleMatcherLooseSafe } from '../../utils/matchers'; | ||||
| import { getSilenceFiltersFromUrlParams, makeAMLink, stringifyErrorLike } from '../../utils/misc'; | ||||
| import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; | ||||
| import { Authorize } from '../Authorize'; | ||||
| import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; | ||||
| import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning'; | ||||
| 
 | ||||
| import { Matchers } from './Matchers'; | ||||
| import { NoSilencesSplash } from './NoSilencesCTA'; | ||||
|  | @ -40,13 +44,11 @@ export interface SilenceTableItem extends Silence { | |||
| 
 | ||||
| type SilenceTableColumnProps = DynamicTableColumnProps<SilenceTableItem>; | ||||
| type SilenceTableItemProps = DynamicTableItemProps<SilenceTableItem>; | ||||
| interface Props { | ||||
|   alertManagerSourceName: string; | ||||
| } | ||||
| 
 | ||||
| const API_QUERY_OPTIONS = { pollingInterval: SILENCES_POLL_INTERVAL_MS, refetchOnFocus: true }; | ||||
| 
 | ||||
| const SilencesTable = ({ alertManagerSourceName }: Props) => { | ||||
| const SilencesTable = () => { | ||||
|   const { selectedAlertmanager: alertManagerSourceName = '' } = useAlertmanager(); | ||||
|   const [previewAlertsSupported, previewAlertsAllowed] = useAlertmanagerAbility( | ||||
|     AlertmanagerAction.PreviewSilencedInstances | ||||
|   ); | ||||
|  | @ -135,6 +137,7 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => { | |||
| 
 | ||||
|   return ( | ||||
|     <div data-testid="silences-table"> | ||||
|       <GrafanaAlertmanagerDeliveryWarning currentAlertmanager={alertManagerSourceName} /> | ||||
|       {!!silences.length && ( | ||||
|         <Stack direction="column"> | ||||
|           <SilencesFilter /> | ||||
|  | @ -382,4 +385,12 @@ function useColumns(alertManagerSourceName: string) { | |||
|     return columns; | ||||
|   }, [alertManagerSourceName, expireSilence, isGrafanaFlavoredAlertmanager, updateAllowed, updateSupported]); | ||||
| } | ||||
| export default SilencesTable; | ||||
| 
 | ||||
| function SilencesTablePage() { | ||||
|   return ( | ||||
|     <AlertmanagerPageWrapper navId="silences" accessType="instance"> | ||||
|       <SilencesTable /> | ||||
|     </AlertmanagerPageWrapper> | ||||
|   ); | ||||
| } | ||||
| export default withErrorBoundary(SilencesTablePage, { style: 'page' }); | ||||
|  |  | |||
|  | @ -1,40 +0,0 @@ | |||
| import { render } from '@testing-library/react'; | ||||
| import { useMatch } from 'react-router-dom-v5-compat'; | ||||
| 
 | ||||
| import { useSilenceNavData } from './useSilenceNavData'; | ||||
| 
 | ||||
| jest.mock('react-router-dom-v5-compat', () => ({ | ||||
|   ...jest.requireActual('react-router-dom-v5-compat'), | ||||
|   useMatch: jest.fn(), | ||||
| })); | ||||
| 
 | ||||
| const setup = () => { | ||||
|   let result: ReturnType<typeof useSilenceNavData>; | ||||
|   function TestComponent() { | ||||
|     result = useSilenceNavData(); | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   render(<TestComponent />); | ||||
| 
 | ||||
|   return { result }; | ||||
| }; | ||||
| describe('useSilenceNavData', () => { | ||||
|   it('should return correct nav data when route is "/alerting/silence/new"', () => { | ||||
|     (useMatch as jest.Mock).mockImplementation((param) => param === '/alerting/silence/new'); | ||||
|     const { result } = setup(); | ||||
| 
 | ||||
|     expect(result).toMatchObject({ | ||||
|       text: 'Silence alert rule', | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   it('should return correct nav data when route is "/alerting/silence/:id/edit"', () => { | ||||
|     (useMatch as jest.Mock).mockImplementation((param) => param === '/alerting/silence/:id/edit'); | ||||
|     const { result } = setup(); | ||||
| 
 | ||||
|     expect(result).toMatchObject({ | ||||
|       text: 'Edit silence', | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -1,34 +0,0 @@ | |||
| import { useEffect, useState } from 'react'; | ||||
| import { useMatch } from 'react-router-dom-v5-compat'; | ||||
| 
 | ||||
| import { NavModelItem } from '@grafana/data'; | ||||
| 
 | ||||
| const defaultPageNav: Partial<NavModelItem> = { | ||||
|   icon: 'bell-slash', | ||||
| }; | ||||
| 
 | ||||
| export function useSilenceNavData() { | ||||
|   const [pageNav, setPageNav] = useState<NavModelItem | undefined>(); | ||||
|   const isNewPath = useMatch('/alerting/silence/new'); | ||||
|   const isEditPath = useMatch('/alerting/silence/:id/edit'); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (isNewPath) { | ||||
|       setPageNav({ | ||||
|         ...defaultPageNav, | ||||
|         id: 'silence-new', | ||||
|         text: 'Silence alert rule', | ||||
|         subTitle: 'Configure silences to stop notifications from a particular alert rule', | ||||
|       }); | ||||
|     } else if (isEditPath) { | ||||
|       setPageNav({ | ||||
|         ...defaultPageNav, | ||||
|         id: 'silence-edit', | ||||
|         text: 'Edit silence', | ||||
|         subTitle: 'Recreate existing silence to stop notifications from a particular alert rule', | ||||
|       }); | ||||
|     } | ||||
|   }, [isEditPath, isNewPath]); | ||||
| 
 | ||||
|   return pageNav; | ||||
| } | ||||
|  | @ -22,7 +22,12 @@ import { ScopesInput } from './ScopesInput'; | |||
| import { ScopesTree } from './ScopesTree'; | ||||
| import { fetchNodes, fetchScope, fetchSelectedScopes } from './api'; | ||||
| import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types'; | ||||
| import { getBasicScope, getScopeNamesFromSelectedScopes, getTreeScopesFromSelectedScopes } from './utils'; | ||||
| import { | ||||
|   getBasicScope, | ||||
|   getScopeNamesFromSelectedScopes, | ||||
|   getScopesAndTreeScopesWithPaths, | ||||
|   getTreeScopesFromSelectedScopes, | ||||
| } from './utils'; | ||||
| 
 | ||||
| export interface ScopesSelectorSceneState extends SceneObjectState { | ||||
|   dashboards: SceneObjectRef<ScopesDashboardsScene> | null; | ||||
|  | @ -126,7 +131,14 @@ export class ScopesSelectorScene extends SceneObjectBase<ScopesSelectorSceneStat | |||
|           }) | ||||
|         ) | ||||
|         .subscribe((childNodes) => { | ||||
|           const persistedNodes = this.state.treeScopes | ||||
|           const [scopes, treeScopes] = getScopesAndTreeScopesWithPaths( | ||||
|             this.state.scopes, | ||||
|             this.state.treeScopes, | ||||
|             path, | ||||
|             childNodes | ||||
|           ); | ||||
| 
 | ||||
|           const persistedNodes = treeScopes | ||||
|             .map(({ path }) => path[path.length - 1]) | ||||
|             .filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes)) | ||||
|             .reduce<NodesMap>((acc, nodeName) => { | ||||
|  | @ -140,7 +152,7 @@ export class ScopesSelectorScene extends SceneObjectBase<ScopesSelectorSceneStat | |||
| 
 | ||||
|           currentNode.nodes = { ...persistedNodes, ...childNodes }; | ||||
| 
 | ||||
|           this.setState({ nodes }); | ||||
|           this.setState({ nodes, scopes, treeScopes }); | ||||
| 
 | ||||
|           this.nodesFetchingSub?.unsubscribe(); | ||||
|         }); | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import { Scope, ScopeDashboardBinding } from '@grafana/data'; | ||||
| 
 | ||||
| import { SelectedScope, SuggestedDashboardsFoldersMap, TreeScope } from './types'; | ||||
| import { NodesMap, SelectedScope, SuggestedDashboardsFoldersMap, TreeScope } from './types'; | ||||
| 
 | ||||
| export function getBasicScope(name: string): Scope { | ||||
|   return { | ||||
|  | @ -44,6 +44,59 @@ export function getScopeNamesFromSelectedScopes(scopes: SelectedScope[]): string | |||
|   return scopes.map(({ scope }) => scope.metadata.name); | ||||
| } | ||||
| 
 | ||||
| // helper func to get the selected/tree scopes together with their paths
 | ||||
| // needed to maintain selected scopes in tree for example when navigating
 | ||||
| // between categories or when loading scopes from URL to find the scope's path
 | ||||
| export function getScopesAndTreeScopesWithPaths( | ||||
|   selectedScopes: SelectedScope[], | ||||
|   treeScopes: TreeScope[], | ||||
|   path: string[], | ||||
|   childNodes: NodesMap | ||||
| ): [SelectedScope[], TreeScope[]] { | ||||
|   const childNodesArr = Object.values(childNodes); | ||||
| 
 | ||||
|   // Get all scopes without paths
 | ||||
|   // We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
 | ||||
|   const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName); | ||||
| 
 | ||||
|   // We search for the path of each scope name without a path
 | ||||
|   const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce<Record<string, string[]>>((acc, scopeName) => { | ||||
|     const possibleParent = childNodesArr.find((childNode) => childNode.isSelectable && childNode.linkId === scopeName); | ||||
| 
 | ||||
|     if (possibleParent) { | ||||
|       acc[scopeName] = [...path, possibleParent.name]; | ||||
|     } | ||||
| 
 | ||||
|     return acc; | ||||
|   }, {}); | ||||
| 
 | ||||
|   // Update the paths of the selected scopes based on what we found
 | ||||
|   const newSelectedScopes = selectedScopes.map((selectedScope) => { | ||||
|     if (selectedScope.path.length > 0) { | ||||
|       return selectedScope; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       ...selectedScope, | ||||
|       path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [], | ||||
|     }; | ||||
|   }); | ||||
| 
 | ||||
|   // Update the paths of the tree scopes based on what we found
 | ||||
|   const newTreeScopes = treeScopes.map((treeScope) => { | ||||
|     if (treeScope.path.length > 0) { | ||||
|       return treeScope; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       ...treeScope, | ||||
|       path: scopeNamesWithPaths[treeScope.scopeName] ?? [], | ||||
|     }; | ||||
|   }); | ||||
| 
 | ||||
|   return [newSelectedScopes, newTreeScopes]; | ||||
| } | ||||
| 
 | ||||
| export function groupDashboards(dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap { | ||||
|   return dashboards.reduce<SuggestedDashboardsFoldersMap>( | ||||
|     (acc, dashboard) => { | ||||
|  |  | |||
|  | @ -36,6 +36,8 @@ import { | |||
|   expectResultCloudOpsSelected, | ||||
|   expectScopesHeadline, | ||||
|   expectScopesSelectorValue, | ||||
|   expectSelectedScopePath, | ||||
|   expectTreeScopePath, | ||||
| } from './utils/assertions'; | ||||
| import { fetchNodesSpy, fetchScopeSpy, getDatasource, getInstanceSettings, getMock } from './utils/mocks'; | ||||
| import { renderDashboard, resetScenes } from './utils/render'; | ||||
|  | @ -244,4 +246,28 @@ describe('Tree', () => { | |||
|     expect(fetchNodesSpy).toHaveBeenCalledTimes(3); | ||||
|     expectScopesHeadline('No results found for your query'); | ||||
|   }); | ||||
| 
 | ||||
|   it('Updates the paths for scopes without paths on nodes fetching', async () => { | ||||
|     const selectedScopeName = 'grafana'; | ||||
|     const unselectedScopeName = 'mimir'; | ||||
|     const selectedScopeNameFromOtherGroup = 'dev'; | ||||
| 
 | ||||
|     await updateScopes([selectedScopeName, selectedScopeNameFromOtherGroup]); | ||||
|     expectSelectedScopePath(selectedScopeName, []); | ||||
|     expectTreeScopePath(selectedScopeName, []); | ||||
|     expectSelectedScopePath(unselectedScopeName, undefined); | ||||
|     expectTreeScopePath(unselectedScopeName, undefined); | ||||
|     expectSelectedScopePath(selectedScopeNameFromOtherGroup, []); | ||||
|     expectTreeScopePath(selectedScopeNameFromOtherGroup, []); | ||||
| 
 | ||||
|     await openSelector(); | ||||
|     await expandResultApplications(); | ||||
|     const expectedPath = ['', 'applications', 'applications-grafana']; | ||||
|     expectSelectedScopePath(selectedScopeName, expectedPath); | ||||
|     expectTreeScopePath(selectedScopeName, expectedPath); | ||||
|     expectSelectedScopePath(unselectedScopeName, undefined); | ||||
|     expectTreeScopePath(unselectedScopeName, undefined); | ||||
|     expectSelectedScopePath(selectedScopeNameFromOtherGroup, []); | ||||
|     expectTreeScopePath(selectedScopeNameFromOtherGroup, []); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -12,8 +12,10 @@ import { | |||
|   getResultApplicationsMimirSelect, | ||||
|   getResultCloudDevRadio, | ||||
|   getResultCloudOpsRadio, | ||||
|   getSelectedScope, | ||||
|   getSelectorInput, | ||||
|   getTreeHeadline, | ||||
|   getTreeScope, | ||||
|   queryAllDashboard, | ||||
|   queryDashboard, | ||||
|   queryDashboardFolderExpand, | ||||
|  | @ -80,3 +82,8 @@ export const expectOldDashboardDTO = (scopes?: string[]) => | |||
|   expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', scopes ? { scopes } : undefined); | ||||
| export const expectNewDashboardDTO = () => | ||||
|   expect(getMock).toHaveBeenCalledWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto'); | ||||
| 
 | ||||
| export const expectSelectedScopePath = (name: string, path: string[] | undefined) => | ||||
|   expect(getSelectedScope(name)?.path).toEqual(path); | ||||
| export const expectTreeScopePath = (name: string, path: string[] | undefined) => | ||||
|   expect(getTreeScope(name)?.path).toEqual(path); | ||||
|  |  | |||
|  | @ -1,5 +1,7 @@ | |||
| import { screen } from '@testing-library/react'; | ||||
| 
 | ||||
| import { scopesSelectorScene } from '../../instance'; | ||||
| 
 | ||||
| const selectors = { | ||||
|   tree: { | ||||
|     search: 'scopes-tree-search', | ||||
|  | @ -82,3 +84,9 @@ export const getResultCloudDevRadio = () => | |||
|   screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-dev', 'result')); | ||||
| export const getResultCloudOpsRadio = () => | ||||
|   screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-ops', 'result')); | ||||
| 
 | ||||
| export const getListOfSelectedScopes = () => scopesSelectorScene?.state.scopes; | ||||
| export const getListOfTreeScopes = () => scopesSelectorScene?.state.treeScopes; | ||||
| export const getSelectedScope = (name: string) => | ||||
|   getListOfSelectedScopes()?.find((selectedScope) => selectedScope.scope.metadata.name === name); | ||||
| export const getTreeScope = (name: string) => getListOfTreeScopes()?.find((treeScope) => treeScope.scopeName === name); | ||||
|  |  | |||
|  | @ -2218,7 +2218,9 @@ | |||
|     "datasource-names": "", | ||||
|     "delete-query-button": "", | ||||
|     "query-template-get-error": "", | ||||
|     "search": "" | ||||
|     "search": "", | ||||
|     "user-info-get-error": "", | ||||
|     "user-names": "" | ||||
|   }, | ||||
|   "query-operation": { | ||||
|     "header": { | ||||
|  |  | |||
|  | @ -2218,7 +2218,9 @@ | |||
|     "datasource-names": "", | ||||
|     "delete-query-button": "", | ||||
|     "query-template-get-error": "", | ||||
|     "search": "" | ||||
|     "search": "", | ||||
|     "user-info-get-error": "", | ||||
|     "user-names": "" | ||||
|   }, | ||||
|   "query-operation": { | ||||
|     "header": { | ||||
|  |  | |||
|  | @ -2218,7 +2218,9 @@ | |||
|     "datasource-names": "", | ||||
|     "delete-query-button": "", | ||||
|     "query-template-get-error": "", | ||||
|     "search": "" | ||||
|     "search": "", | ||||
|     "user-info-get-error": "", | ||||
|     "user-names": "" | ||||
|   }, | ||||
|   "query-operation": { | ||||
|     "header": { | ||||
|  |  | |||
|  | @ -2218,7 +2218,9 @@ | |||
|     "datasource-names": "", | ||||
|     "delete-query-button": "", | ||||
|     "query-template-get-error": "", | ||||
|     "search": "" | ||||
|     "search": "", | ||||
|     "user-info-get-error": "", | ||||
|     "user-names": "" | ||||
|   }, | ||||
|   "query-operation": { | ||||
|     "header": { | ||||
|  |  | |||
|  | @ -2208,7 +2208,9 @@ | |||
|     "datasource-names": "", | ||||
|     "delete-query-button": "", | ||||
|     "query-template-get-error": "", | ||||
|     "search": "" | ||||
|     "search": "", | ||||
|     "user-info-get-error": "", | ||||
|     "user-names": "" | ||||
|   }, | ||||
|   "query-operation": { | ||||
|     "header": { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue