mirror of https://github.com/grafana/grafana
Alerting: Add details and edit pages for groups (#100884)
* Add basic details page for groups * Remove unused imports * Add basic edit page for groups * Add functional group details page * Improve form, add namespaces for DS groups * Add support for multiple actions in useProduceNewRuleGroup * Attach real actions to form submit * Add tests for the group details page * Add basic tests for the group edit page * Add tests for Mimir update * Add rule group consistency check * Extract draggable rules table to a separate file * Add prom consistency waiting after group saving * Add duration measure for Prometheus reconciliation time * Remove a blinking error when redirecting to a new group * Improve group details page. Use ruler or prom api depending on the ds capabilities * Add group delete action for DMA * Fix GroupDetailsPage tests * Update tests * Add and improve Edit page tests * Add Group export for GMA groups * Fix RulesGroup tests, add translations * Disable editing plugin provided groups * Fix alertingApi options, fix tests * Fix lint errors, update translations * use name for grafana managed recording rules * add namespace to nav * Remove group modals from the list page * add cancel button to edit form * add test for cancel butotn * fix recording rule badge for Grafana managed rules * Add doc comments, improve code * Move url changes to be the last action in form submit * Add returnTo URL handling for alert rule group navigation * Create dedicated Title component showing breadcrumb navigation between folder and group name. Add label distinction between folders and namespaces based on the rule source (Grafana vs external). * Address PR feedback, minor refactorings * Update rule group links to include return path and refactor rule type checks - Modified `RulesGroup` and `GroupDetailsPage` components to include `includeReturnTo` in edit page links. - Refactored rule type checks in `DraggableRulesTable` and `GroupDetailsPage` to use `rulerRuleType` for better clarity and maintainability. - Updated documentation in `useUpdateRuleGroup` to clarify functionality for updating or moving rule groups. * Refactor RulesGroup component and tests for improved link handling and permissions checks - Added `includeReturnTo` parameter to rule group detail links in `RulesGroup` for better navigation. - Updated test cases to verify rendering of edit and view buttons based on user permissions. - Simplified test setup by removing unnecessary Redux provider wrapping in tests. * Refactor: Update routing and test assertions in GroupDetails and GroupEdit pages - Modified route paths in GroupDetailsPage and GroupEditPage tests to use `dataSourceUid` instead of `sourceId`. - Updated test assertions to reflect changes in folder title and link structure in GroupDetailsPage. - Simplified Title component by removing folder-related props and logic, focusing solely on the group name. * Refactor: Simplify Title rendering in GroupDetailsPage - Updated the renderTitle function in GroupDetailsPage to remove the folder prop from the Title component, focusing solely on the group name. * Update GroupDetailsPage to prevent editing of provisioned groups * Fix imports * Improve styles * Fix navigation when served from subpath * Improve group removal handling in Prom consistency check. Fix Delete group button --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>pull/102361/head
parent
fc9e5110d7
commit
321a886b8b
@ -0,0 +1,284 @@ |
||||
import { HttpResponse } from 'msw'; |
||||
import { Route, Routes } from 'react-router-dom-v5-compat'; |
||||
import { Props } from 'react-virtualized-auto-sizer'; |
||||
import { render, screen, waitFor } from 'test/test-utils'; |
||||
import { byRole, byTestId } from 'testing-library-selector'; |
||||
|
||||
import { AccessControlAction } from 'app/types'; |
||||
|
||||
import { setupMswServer } from '../mockApi'; |
||||
import { grantUserPermissions, mockRulerGrafanaRule, mockRulerRuleGroup } from '../mocks'; |
||||
import { |
||||
mimirDataSource, |
||||
setFolderResponse, |
||||
setGrafanaRuleGroupExportResolver, |
||||
setPrometheusRules, |
||||
setRulerRuleGroupHandler, |
||||
setRulerRuleGroupResolver, |
||||
} from '../mocks/server/configure'; |
||||
import { alertingFactory } from '../mocks/server/db'; |
||||
|
||||
import GroupDetailsPage from './GroupDetailsPage'; |
||||
|
||||
jest.mock('react-virtualized-auto-sizer', () => { |
||||
return ({ children }: Props) => |
||||
children({ |
||||
height: 600, |
||||
scaledHeight: 600, |
||||
scaledWidth: 1, |
||||
width: 1, |
||||
}); |
||||
}); |
||||
jest.mock('@grafana/ui', () => ({ |
||||
...jest.requireActual('@grafana/ui'), |
||||
CodeEditor: ({ value }: { value: string }) => <textarea data-testid="code-editor" value={value} readOnly />, |
||||
})); |
||||
|
||||
const ui = { |
||||
header: byRole('heading', { level: 1 }), |
||||
editLink: byRole('link', { name: 'Edit' }), |
||||
exportButton: byRole('button', { name: 'Export' }), |
||||
tableRow: byTestId('row'), |
||||
rowsTable: byTestId('dynamic-table'), |
||||
export: { |
||||
dialog: byRole('dialog', { name: /Drawer title Export .* rules/ }), |
||||
jsonTab: byRole('tab', { name: /JSON/ }), |
||||
yamlTab: byRole('tab', { name: /YAML/ }), |
||||
editor: byTestId('code-editor'), |
||||
copyCodeButton: byRole('button', { name: 'Copy code' }), |
||||
downloadButton: byRole('button', { name: 'Download' }), |
||||
}, |
||||
}; |
||||
|
||||
setupMswServer(); |
||||
|
||||
describe('GroupDetailsPage', () => { |
||||
beforeEach(() => { |
||||
grantUserPermissions([ |
||||
AccessControlAction.AlertingRuleRead, |
||||
AccessControlAction.AlertingRuleUpdate, |
||||
AccessControlAction.AlertingRuleExternalRead, |
||||
AccessControlAction.AlertingRuleExternalWrite, |
||||
]); |
||||
}); |
||||
|
||||
describe('Grafana managed rules', () => { |
||||
const rule1 = mockRulerGrafanaRule({ for: '10m' }, { title: 'High CPU Usage' }); |
||||
const rule2 = mockRulerGrafanaRule({ for: '5m' }, { title: 'Memory Pressure' }); |
||||
const provisionedRule = mockRulerGrafanaRule({ for: '10m' }, { title: 'Provisioned Rule', provenance: 'api' }); |
||||
|
||||
const group = mockRulerRuleGroup({ |
||||
name: 'test-group-cpu', |
||||
interval: '3m', |
||||
rules: [rule1, rule2], |
||||
}); |
||||
|
||||
const provisionedGroup = mockRulerRuleGroup({ |
||||
name: 'provisioned-group-cpu', |
||||
interval: '15m', |
||||
rules: [provisionedRule], |
||||
}); |
||||
|
||||
beforeEach(() => { |
||||
setRulerRuleGroupHandler({ response: HttpResponse.json(group) }); |
||||
setFolderResponse({ uid: 'test-folder-uid', canSave: true, title: 'test-folder-title' }); |
||||
setGrafanaRuleGroupExportResolver(({ request }) => { |
||||
const url = new URL(request.url); |
||||
return HttpResponse.text( |
||||
url.searchParams.get('format') === 'yaml' ? 'Yaml Export Content' : 'Json Export Content' |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
it('should render grafana rules group based on the Ruler API', async () => { |
||||
// Act
|
||||
renderGroupDetailsPage('grafana', 'test-folder-uid', group.name); |
||||
|
||||
const header = await ui.header.find(); |
||||
const editLink = await ui.editLink.find(); |
||||
|
||||
// Assert
|
||||
expect(header).toHaveTextContent('test-group-cpu'); |
||||
expect(await screen.findByRole('link', { name: /test-folder-title/ })).toBeInTheDocument(); |
||||
expect(await screen.findByText(/5m/)).toBeInTheDocument(); |
||||
expect(editLink).toHaveAttribute( |
||||
'href', |
||||
'/alerting/grafana/namespaces/test-folder-uid/groups/test-group-cpu/edit?returnTo=%2Falerting%2Fgrafana%2Fnamespaces%2Ftest-folder-uid%2Fgroups%2Ftest-group-cpu%2Fview' |
||||
); |
||||
|
||||
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find()); |
||||
expect(tableRows).toHaveLength(2); |
||||
|
||||
expect(tableRows[0]).toHaveTextContent('High CPU Usage'); |
||||
expect(tableRows[0]).toHaveTextContent('10m'); |
||||
expect(tableRows[0]).toHaveTextContent('5'); |
||||
|
||||
expect(tableRows[1]).toHaveTextContent('Memory Pressure'); |
||||
expect(tableRows[1]).toHaveTextContent('5m'); |
||||
expect(tableRows[1]).toHaveTextContent('3'); |
||||
}); |
||||
|
||||
it('should render error alert when API returns an error', async () => { |
||||
// Mock an error response from the API
|
||||
setRulerRuleGroupResolver((req) => { |
||||
return HttpResponse.json({ error: 'Failed to fetch rule group' }, { status: 500 }); |
||||
}); |
||||
|
||||
// Act
|
||||
renderGroupDetailsPage('grafana', 'test-folder-uid', group.name); |
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('Error loading the group')).toBeInTheDocument(); |
||||
expect(await screen.findByText('Failed to fetch rule group')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should render "not found" when group does not exist', async () => { |
||||
// Mock a 404 response
|
||||
setRulerRuleGroupResolver((req) => { |
||||
return HttpResponse.json({ error: 'rule group does not exist' }, { status: 404 }); |
||||
}); |
||||
|
||||
// Act
|
||||
renderGroupDetailsPage('grafana', 'test-folder-uid', 'non-existing-group'); |
||||
|
||||
const notFoundAlert = await screen.findByRole('alert', { name: /Error loading the group/ }); |
||||
|
||||
// Assert
|
||||
expect(notFoundAlert).toBeInTheDocument(); |
||||
expect(notFoundAlert).toHaveTextContent(/rule group does not exist/); |
||||
expect(screen.getByTestId('data-testid entity-not-found')).toHaveTextContent( |
||||
'test-folder-uid/non-existing-group' |
||||
); |
||||
}); |
||||
|
||||
it('should not show edit button when user lacks edit permissions', async () => { |
||||
// Remove edit permissions
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]); |
||||
|
||||
// Act
|
||||
renderGroupDetailsPage('grafana', 'test-folder-uid', group.name); |
||||
|
||||
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find()); |
||||
|
||||
// Assert
|
||||
expect(tableRows).toHaveLength(2); |
||||
expect(ui.editLink.query()).not.toBeInTheDocument(); // Edit button should not be present
|
||||
}); |
||||
|
||||
it('should not show edit button when folder cannot be saved', async () => { |
||||
setFolderResponse({ uid: 'test-folder-uid', canSave: false }); |
||||
|
||||
// Act
|
||||
renderGroupDetailsPage('grafana', 'test-folder-uid', group.name); |
||||
|
||||
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find()); |
||||
|
||||
// Assert
|
||||
expect(tableRows).toHaveLength(2); |
||||
expect(ui.editLink.query()).not.toBeInTheDocument(); // Edit button should not be present
|
||||
}); |
||||
|
||||
it('should not allow editing if the group is provisioned', async () => { |
||||
setRulerRuleGroupHandler({ response: HttpResponse.json(provisionedGroup) }); |
||||
|
||||
// Act
|
||||
renderGroupDetailsPage('grafana', 'test-folder-uid', provisionedGroup.name); |
||||
|
||||
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find()); |
||||
|
||||
// Assert
|
||||
expect(tableRows).toHaveLength(1); |
||||
expect(tableRows[0]).toHaveTextContent('Provisioned Rule'); |
||||
expect(ui.editLink.query()).not.toBeInTheDocument(); |
||||
expect(ui.exportButton.query()).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should allow exporting groups', async () => { |
||||
// Act
|
||||
const { user } = renderGroupDetailsPage('grafana', 'test-folder-uid', group.name); |
||||
|
||||
// Assert
|
||||
const exportButton = await ui.exportButton.find(); |
||||
expect(exportButton).toBeInTheDocument(); |
||||
|
||||
await user.click(exportButton); |
||||
|
||||
const drawer = await ui.export.dialog.find(); |
||||
|
||||
expect(ui.export.yamlTab.get(drawer)).toHaveAttribute('aria-selected', 'true'); |
||||
await waitFor(() => { |
||||
expect(ui.export.editor.get(drawer)).toHaveTextContent('Yaml Export Content'); |
||||
}); |
||||
|
||||
await user.click(ui.export.jsonTab.get(drawer)); |
||||
await waitFor(() => { |
||||
expect(ui.export.editor.get(drawer)).toHaveTextContent('Json Export Content'); |
||||
}); |
||||
|
||||
expect(ui.export.copyCodeButton.get(drawer)).toBeInTheDocument(); |
||||
expect(ui.export.downloadButton.get(drawer)).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
describe('Prometheus rules', () => { |
||||
it('should render vanilla prometheus rules group', async () => { |
||||
const promDs = alertingFactory.dataSource.build({ uid: 'prometheus', name: 'Prometheus' }); |
||||
const group = alertingFactory.prometheus.group.build({ name: 'test-group-cpu', interval: 500 }); |
||||
setPrometheusRules({ uid: promDs.uid }, [group]); |
||||
|
||||
// Act
|
||||
renderGroupDetailsPage(promDs.uid, 'test-prom-namespace', 'test-group-cpu'); |
||||
|
||||
// Assert
|
||||
const header = await ui.header.find(); |
||||
|
||||
expect(header).toHaveTextContent('test-group-cpu'); |
||||
expect(await screen.findByText(/test-group-cpu/)).toBeInTheDocument(); |
||||
expect(await screen.findByText(/8m20s/)).toBeInTheDocument(); |
||||
expect(ui.editLink.query()).not.toBeInTheDocument(); |
||||
expect(ui.exportButton.query()).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
describe('Mimir rules', () => { |
||||
it('should render mimir rules group', async () => { |
||||
const { dataSource: mimirDs } = mimirDataSource(); |
||||
|
||||
const group = alertingFactory.ruler.group.build({ name: 'test-group-cpu', interval: '11m40s' }); |
||||
setRulerRuleGroupResolver((req) => { |
||||
if (req.params.namespace === 'test-mimir-namespace' && req.params.groupName === 'test-group-cpu') { |
||||
return HttpResponse.json(group); |
||||
} |
||||
return HttpResponse.json({ error: 'Group not found' }, { status: 404 }); |
||||
}); |
||||
|
||||
renderGroupDetailsPage(mimirDs.uid, 'test-mimir-namespace', 'test-group-cpu'); |
||||
|
||||
const header = await ui.header.find(); |
||||
const editLink = await ui.editLink.find(); |
||||
|
||||
expect(header).toHaveTextContent('test-group-cpu'); |
||||
expect(await screen.findByText(/test-mimir-namespace/)).toBeInTheDocument(); |
||||
expect(await screen.findByText(/11m40s/)).toBeInTheDocument(); |
||||
expect(editLink).toHaveAttribute( |
||||
'href', |
||||
`/alerting/mimir/namespaces/test-mimir-namespace/groups/test-group-cpu/edit?returnTo=%2Falerting%2Fmimir%2Fnamespaces%2Ftest-mimir-namespace%2Fgroups%2Ftest-group-cpu%2Fview` |
||||
); |
||||
expect(ui.exportButton.query()).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
function renderGroupDetailsPage(dsUid: string, namespaceId: string, groupName: string) { |
||||
return render( |
||||
<Routes> |
||||
<Route |
||||
path="/alerting/:dataSourceUid/namespaces/:namespaceId/groups/:groupName/view" |
||||
element={<GroupDetailsPage />} |
||||
/> |
||||
</Routes>, |
||||
{ |
||||
historyOptions: { initialEntries: [`/alerting/${dsUid}/namespaces/${namespaceId}/groups/${groupName}/view`] }, |
||||
} |
||||
); |
||||
} |
@ -0,0 +1,334 @@ |
||||
import { skipToken } from '@reduxjs/toolkit/query'; |
||||
import { useMemo, useState } from 'react'; |
||||
import { useParams } from 'react-router-dom-v5-compat'; |
||||
|
||||
import { Alert, Badge, Button, LinkButton, Text, TextLink, withErrorBoundary } from '@grafana/ui'; |
||||
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; |
||||
import { Trans, t } from 'app/core/internationalization'; |
||||
import { FolderDTO } from 'app/types'; |
||||
import { GrafanaRulesSourceSymbol, RuleGroup } from 'app/types/unified-alerting'; |
||||
import { PromRuleType, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi'; |
||||
import { RulesSourceFeatures, featureDiscoveryApi } from '../api/featureDiscoveryApi'; |
||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; |
||||
import { DynamicTable, DynamicTableColumnProps } from '../components/DynamicTable'; |
||||
import { GrafanaRuleGroupExporter } from '../components/export/GrafanaRuleGroupExporter'; |
||||
import { useFolder } from '../hooks/useFolder'; |
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../rule-editor/formDefaults'; |
||||
import { useRulesAccess } from '../utils/accessControlHooks'; |
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; |
||||
import { makeFolderLink, stringifyErrorLike } from '../utils/misc'; |
||||
import { createListFilterLink, groups } from '../utils/navigation'; |
||||
import { |
||||
calcRuleEvalsToStartAlerting, |
||||
getRuleName, |
||||
isFederatedRuleGroup, |
||||
isProvisionedRuleGroup, |
||||
rulerRuleType, |
||||
} from '../utils/rules'; |
||||
import { formatPrometheusDuration, safeParsePrometheusDuration } from '../utils/time'; |
||||
|
||||
import { Title } from './Title'; |
||||
|
||||
type GroupPageRouteParams = { |
||||
dataSourceUid?: string; |
||||
namespaceId?: string; |
||||
groupName?: string; |
||||
}; |
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; |
||||
const { usePrometheusRuleNamespacesQuery, useGetRuleGroupForNamespaceQuery } = alertRuleApi; |
||||
|
||||
function GroupDetailsPage() { |
||||
const { dataSourceUid = '', namespaceId = '', groupName = '' } = useParams<GroupPageRouteParams>(); |
||||
const isGrafanaRuleGroup = dataSourceUid === GRAFANA_RULES_SOURCE_NAME; |
||||
|
||||
const { folder, loading: isFolderLoading } = useFolder(isGrafanaRuleGroup ? namespaceId : ''); |
||||
const { |
||||
data: dsFeatures, |
||||
isLoading: isDsFeaturesLoading, |
||||
error: dsFeaturesError, |
||||
} = useDiscoverDsFeaturesQuery({ uid: isGrafanaRuleGroup ? GrafanaRulesSourceSymbol : dataSourceUid }); |
||||
|
||||
const { |
||||
data: promGroup, |
||||
isLoading: isRuleNamespacesLoading, |
||||
error: ruleNamespacesError, |
||||
} = usePrometheusRuleNamespacesQuery( |
||||
dsFeatures && !dsFeatures.rulerConfig |
||||
? { ruleSourceName: dsFeatures?.name ?? '', namespace: namespaceId, groupName: groupName } |
||||
: skipToken, |
||||
{ |
||||
selectFromResult: (result) => ({ |
||||
...result, |
||||
data: result.data?.[0]?.groups.find((g) => g.name === groupName), |
||||
}), |
||||
} |
||||
); |
||||
|
||||
const { |
||||
data: rulerGroup, |
||||
isLoading: isRuleGroupLoading, |
||||
error: ruleGroupError, |
||||
} = useGetRuleGroupForNamespaceQuery( |
||||
dsFeatures?.rulerConfig |
||||
? { rulerConfig: dsFeatures?.rulerConfig, namespace: namespaceId, group: groupName } |
||||
: skipToken |
||||
); |
||||
|
||||
const isLoading = isFolderLoading || isDsFeaturesLoading || isRuleNamespacesLoading || isRuleGroupLoading; |
||||
|
||||
const groupInterval = promGroup?.interval |
||||
? formatPrometheusDuration(promGroup.interval * 1000) |
||||
: (rulerGroup?.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL); |
||||
|
||||
const namespaceName = folder?.title ?? namespaceId; |
||||
const namespaceUrl = createListFilterLink([['namespace', namespaceName]]); |
||||
|
||||
const namespaceLabel = isGrafanaRuleGroup |
||||
? t('alerting.group-details.folder', 'Folder') |
||||
: t('alerting.group-details.namespace', 'Namespace'); |
||||
|
||||
const namespaceValue = folder ? ( |
||||
<TextLink href={makeFolderLink(folder.uid)} inline={false}> |
||||
{folder.title} |
||||
</TextLink> |
||||
) : ( |
||||
namespaceId |
||||
); |
||||
|
||||
return ( |
||||
<AlertingPageWrapper |
||||
pageNav={{ |
||||
text: groupName, |
||||
parentItem: { |
||||
text: namespaceName, |
||||
url: namespaceUrl, |
||||
}, |
||||
}} |
||||
renderTitle={(title) => <Title name={title} />} |
||||
info={[ |
||||
{ label: namespaceLabel, value: namespaceValue }, |
||||
{ label: t('alerting.group-details.interval', 'Interval'), value: groupInterval }, |
||||
]} |
||||
navId="alert-list" |
||||
isLoading={isLoading} |
||||
actions={ |
||||
<> |
||||
{dsFeatures && ( |
||||
<GroupActions |
||||
dsFeatures={dsFeatures} |
||||
namespaceId={namespaceId} |
||||
groupName={groupName} |
||||
folder={folder} |
||||
rulerGroup={rulerGroup} |
||||
/> |
||||
)} |
||||
</> |
||||
} |
||||
> |
||||
<> |
||||
{Boolean(dsFeaturesError) && ( |
||||
<Alert |
||||
title={t('alerting.group-details.ds-features-error', 'Error loading data source details')} |
||||
bottomSpacing={0} |
||||
topSpacing={2} |
||||
> |
||||
<div>{stringifyErrorLike(dsFeaturesError)}</div> |
||||
</Alert> |
||||
)} |
||||
{Boolean(ruleNamespacesError || ruleGroupError) && ( |
||||
<Alert |
||||
title={t('alerting.group-details.group-loading-error', 'Error loading the group')} |
||||
bottomSpacing={0} |
||||
topSpacing={2} |
||||
> |
||||
<div>{stringifyErrorLike(ruleNamespacesError || ruleGroupError)}</div> |
||||
</Alert> |
||||
)} |
||||
{promGroup && <GroupDetails group={promRuleGroupToRuleGroupDetails(promGroup)} />} |
||||
{rulerGroup && <GroupDetails group={rulerRuleGroupToRuleGroupDetails(rulerGroup)} />} |
||||
{!promGroup && !rulerGroup && <EntityNotFound entity={`${namespaceId}/${groupName}`} />} |
||||
</> |
||||
</AlertingPageWrapper> |
||||
); |
||||
} |
||||
|
||||
interface GroupActionsProps { |
||||
dsFeatures: RulesSourceFeatures; |
||||
namespaceId: string; |
||||
groupName: string; |
||||
rulerGroup: RulerRuleGroupDTO | undefined; |
||||
folder: FolderDTO | undefined; |
||||
} |
||||
|
||||
function GroupActions({ dsFeatures, namespaceId, groupName, folder, rulerGroup }: GroupActionsProps) { |
||||
const { canEditRules } = useRulesAccess(); |
||||
const [isExporting, setIsExporting] = useState<boolean>(false); |
||||
|
||||
const isGrafanaSource = dsFeatures.uid === GRAFANA_RULES_SOURCE_NAME; |
||||
const canSaveInFolder = isGrafanaSource ? Boolean(folder?.canSave) : true; |
||||
|
||||
const isFederated = rulerGroup ? isFederatedRuleGroup(rulerGroup) : false; |
||||
const isProvisioned = rulerGroup ? isProvisionedRuleGroup(rulerGroup) : false; |
||||
|
||||
const canEdit = |
||||
Boolean(dsFeatures.rulerConfig) && |
||||
canEditRules(dsFeatures.name) && |
||||
canSaveInFolder && |
||||
!isFederated && |
||||
!isProvisioned; |
||||
|
||||
return ( |
||||
<> |
||||
{isGrafanaSource && ( |
||||
<Button onClick={() => setIsExporting(true)} icon="file-download" variant="secondary"> |
||||
<Trans i18nKey="alerting.group-details.export">Export</Trans> |
||||
</Button> |
||||
)} |
||||
{canEdit && ( |
||||
<LinkButton |
||||
icon="pen" |
||||
href={groups.editPageLink(dsFeatures.uid, namespaceId, groupName, { includeReturnTo: true })} |
||||
variant="secondary" |
||||
> |
||||
<Trans i18nKey="alerting.group-details.edit">Edit</Trans> |
||||
</LinkButton> |
||||
)} |
||||
{folder && isExporting && ( |
||||
<GrafanaRuleGroupExporter folderUid={folder.uid} groupName={groupName} onClose={() => setIsExporting(false)} /> |
||||
)} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
/** An common interface for both Prometheus and Ruler rule groups */ |
||||
interface RuleGroupDetails { |
||||
name: string; |
||||
interval: string; |
||||
rules: RuleDetails[]; |
||||
} |
||||
|
||||
interface AlertingRuleDetails { |
||||
name: string; |
||||
type: 'alerting'; |
||||
pendingPeriod: string; |
||||
evaluationsToFire: number; |
||||
} |
||||
interface RecordingRuleDetails { |
||||
name: string; |
||||
type: 'recording'; |
||||
} |
||||
|
||||
type RuleDetails = AlertingRuleDetails | RecordingRuleDetails; |
||||
|
||||
interface GroupDetailsProps { |
||||
group: RuleGroupDetails; |
||||
} |
||||
|
||||
function GroupDetails({ group }: GroupDetailsProps) { |
||||
return ( |
||||
<div> |
||||
<RulesTable rules={group.rules} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function RulesTable({ rules }: { rules: RuleDetails[] }) { |
||||
const rows = rules.map((rule: RuleDetails, index) => ({ |
||||
id: index, |
||||
data: rule, |
||||
})); |
||||
|
||||
const columns: Array<DynamicTableColumnProps<RuleDetails>> = useMemo(() => { |
||||
return [ |
||||
{ |
||||
id: 'alertName', |
||||
label: t('alerting.group-details.rule-name', 'Rule name'), |
||||
renderCell: ({ data }) => { |
||||
return <Text truncate>{data.name}</Text>; |
||||
}, |
||||
size: 0.4, |
||||
}, |
||||
{ |
||||
id: 'for', |
||||
label: t('alerting.group-details.pending-period', 'Pending period'), |
||||
renderCell: ({ data }) => { |
||||
switch (data.type) { |
||||
case 'alerting': |
||||
return <>{data.pendingPeriod}</>; |
||||
case 'recording': |
||||
return <Badge text={t('alerting.group-details.recording', 'Recording')} color="purple" />; |
||||
} |
||||
}, |
||||
size: 0.3, |
||||
}, |
||||
{ |
||||
id: 'numberEvaluations', |
||||
label: t('alerting.group-details.evaluations-to-fire', 'Evaluation cycles to fire'), |
||||
renderCell: ({ data }) => { |
||||
switch (data.type) { |
||||
case 'alerting': |
||||
return <>{data.evaluationsToFire}</>; |
||||
case 'recording': |
||||
return null; |
||||
} |
||||
}, |
||||
size: 0.3, |
||||
}, |
||||
]; |
||||
}, []); |
||||
|
||||
return <DynamicTable items={rows} cols={columns} />; |
||||
} |
||||
|
||||
function promRuleGroupToRuleGroupDetails(group: RuleGroup): RuleGroupDetails { |
||||
const groupIntervalMs = group.interval * 1000; |
||||
|
||||
return { |
||||
name: group.name, |
||||
interval: formatPrometheusDuration(group.interval * 1000), |
||||
rules: group.rules.map<RuleDetails>((rule) => { |
||||
switch (rule.type) { |
||||
case PromRuleType.Alerting: |
||||
return { |
||||
name: rule.name, |
||||
type: 'alerting', |
||||
pendingPeriod: formatPrometheusDuration(rule.duration ? rule.duration * 1000 : 0), |
||||
evaluationsToFire: calcRuleEvalsToStartAlerting(rule.duration ? rule.duration * 1000 : 0, groupIntervalMs), |
||||
}; |
||||
case PromRuleType.Recording: |
||||
return { name: rule.name, type: 'recording' }; |
||||
} |
||||
}), |
||||
}; |
||||
} |
||||
|
||||
function rulerRuleGroupToRuleGroupDetails(group: RulerRuleGroupDTO): RuleGroupDetails { |
||||
const groupIntervalMs = safeParsePrometheusDuration(group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL); |
||||
|
||||
return { |
||||
name: group.name, |
||||
interval: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, |
||||
rules: group.rules.map<RuleDetails>((rule) => { |
||||
const name = getRuleName(rule); |
||||
|
||||
if (rulerRuleType.any.alertingRule(rule)) { |
||||
return { |
||||
name, |
||||
type: 'alerting', |
||||
pendingPeriod: rule.for ?? '0s', |
||||
evaluationsToFire: calcRuleEvalsToStartAlerting( |
||||
rule.for ? safeParsePrometheusDuration(rule.for) : 0, |
||||
groupIntervalMs |
||||
), |
||||
}; |
||||
} |
||||
|
||||
return { name, type: 'recording' }; |
||||
}), |
||||
}; |
||||
} |
||||
|
||||
export default withErrorBoundary(GroupDetailsPage, { style: 'page' }); |
@ -0,0 +1,345 @@ |
||||
import { HttpResponse } from 'msw'; |
||||
import { Route, Routes } from 'react-router-dom-v5-compat'; |
||||
import { render, screen } from 'test/test-utils'; |
||||
import { byRole, byTestId, byText } from 'testing-library-selector'; |
||||
|
||||
import { locationService } from '@grafana/runtime'; |
||||
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; |
||||
import { AccessControlAction } from 'app/types'; |
||||
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { setupMswServer } from '../mockApi'; |
||||
import { grantUserPermissions } from '../mocks'; |
||||
import { |
||||
mimirDataSource, |
||||
setDeleteRulerRuleNamespaceResolver, |
||||
setFolderResponse, |
||||
setGrafanaRulerRuleGroupResolver, |
||||
setRulerRuleGroupResolver, |
||||
setUpdateGrafanaRulerRuleNamespaceResolver, |
||||
setUpdateRulerRuleNamespaceResolver, |
||||
} from '../mocks/server/configure'; |
||||
import { alertingFactory } from '../mocks/server/db'; |
||||
|
||||
import GroupEditPage from './GroupEditPage'; |
||||
|
||||
// Mock the useRuleGroupConsistencyCheck hook
|
||||
jest.mock('../hooks/usePrometheusConsistencyCheck', () => ({ |
||||
...jest.requireActual('../hooks/usePrometheusConsistencyCheck'), |
||||
useRuleGroupConsistencyCheck: () => ({ |
||||
waitForGroupConsistency: jest.fn().mockResolvedValue(undefined), |
||||
}), |
||||
})); |
||||
|
||||
window.performance.mark = jest.fn(); |
||||
window.performance.measure = jest.fn(); |
||||
|
||||
const ui = { |
||||
header: byRole('heading', { level: 1 }), |
||||
folderInput: byRole('textbox', { name: /Folder/ }), |
||||
namespaceInput: byRole('textbox', { name: /Namespace/ }), |
||||
nameInput: byRole('textbox', { name: /Evaluation group name/ }), |
||||
intervalInput: byRole('textbox', { name: /Evaluation interval/ }), |
||||
saveButton: byRole('button', { name: /Save/ }), |
||||
cancelButton: byRole('link', { name: /Cancel/ }), |
||||
deleteButton: byRole('button', { name: /Delete/ }), |
||||
rules: byTestId('reorder-alert-rule'), |
||||
successMessage: byText('Successfully updated the rule group'), |
||||
errorMessage: byText('Failed to update rule group'), |
||||
confirmDeleteModal: { |
||||
dialog: byRole('dialog'), |
||||
header: byRole('heading', { level: 2, name: /Delete rule group/ }), |
||||
confirmButton: byRole('button', { name: /Delete/ }), |
||||
}, |
||||
}; |
||||
|
||||
setupMswServer(); |
||||
grantUserPermissions([ |
||||
AccessControlAction.AlertingRuleRead, |
||||
AccessControlAction.AlertingRuleUpdate, |
||||
AccessControlAction.AlertingRuleExternalRead, |
||||
AccessControlAction.AlertingRuleExternalWrite, |
||||
]); |
||||
|
||||
const { dataSource: mimirDs } = mimirDataSource(); |
||||
|
||||
describe('GroupEditPage', () => { |
||||
const group = alertingFactory.ruler.group.build({ |
||||
name: 'test-group-cpu', |
||||
interval: '4m30s', |
||||
rules: [ |
||||
alertingFactory.ruler.alertingRule.build({ alert: 'first-rule' }), |
||||
alertingFactory.ruler.alertingRule.build({ alert: 'second-rule' }), |
||||
], |
||||
}); |
||||
|
||||
describe('Grafana Managed Rules', () => { |
||||
const groupsByName = new Map<string, RulerRuleGroupDTO>([[group.name, group]]); |
||||
|
||||
beforeEach(() => { |
||||
setGrafanaRulerRuleGroupResolver(async ({ params: { groupName, folderUid } }) => { |
||||
if (groupsByName.has(groupName) && folderUid === 'test-folder-uid') { |
||||
return HttpResponse.json(groupsByName.get(groupName)); |
||||
} |
||||
return HttpResponse.json(null, { status: 404 }); |
||||
}); |
||||
setFolderResponse({ uid: 'test-folder-uid', canSave: true }); |
||||
}); |
||||
|
||||
it('should render grafana rules group with form fields', async () => { |
||||
renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu'); |
||||
|
||||
const header = await ui.header.find(); |
||||
|
||||
const folderInput = await ui.folderInput.find(); |
||||
const nameInput = await ui.nameInput.find(); |
||||
const intervalInput = await ui.intervalInput.find(); |
||||
const saveButton = await ui.saveButton.find(); |
||||
const cancelButton = await ui.cancelButton.find(); |
||||
const rules = await ui.rules.findAll(); |
||||
|
||||
expect(header).toHaveTextContent('Edit rule group'); |
||||
expect(folderInput).toHaveAttribute('readonly', ''); |
||||
expect(nameInput).toHaveValue('test-group-cpu'); |
||||
expect(intervalInput).toHaveValue('4m30s'); |
||||
expect(saveButton).toBeInTheDocument(); |
||||
expect(cancelButton).toBeInTheDocument(); |
||||
expect(cancelButton).toHaveProperty( |
||||
'href', |
||||
'http://localhost/alerting/grafana/namespaces/test-folder-uid/groups/test-group-cpu/view' |
||||
); |
||||
expect(rules).toHaveLength(2); |
||||
expect(rules[0]).toHaveTextContent('first-rule'); |
||||
expect(rules[1]).toHaveTextContent('second-rule'); |
||||
// Changing folder is not supported for Grafana Managed Rules
|
||||
expect(ui.namespaceInput.query()).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should save updated interval', async () => { |
||||
setUpdateGrafanaRulerRuleNamespaceResolver(async ({ request }) => { |
||||
const body = await request.json(); |
||||
if (body.interval === '1m20s') { |
||||
return HttpResponse.json({}, { status: 202 }); |
||||
} |
||||
|
||||
return HttpResponse.json(null, { status: 400 }); |
||||
}); |
||||
|
||||
const { user } = renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu'); |
||||
|
||||
const intervalInput = await ui.intervalInput.find(); |
||||
const saveButton = await ui.saveButton.find(); |
||||
|
||||
await user.clear(intervalInput); |
||||
await user.type(intervalInput, '1m20s'); |
||||
|
||||
await user.click(saveButton); |
||||
|
||||
expect(await ui.successMessage.find()).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should save a new group and remove the old when renaming', async () => { |
||||
setUpdateGrafanaRulerRuleNamespaceResolver(async ({ request }) => { |
||||
const body = await request.json(); |
||||
if (body.name === 'new-group-name') { |
||||
groupsByName.set('new-group-name', body); |
||||
return HttpResponse.json({}, { status: 202 }); |
||||
} |
||||
|
||||
return HttpResponse.json(null, { status: 400 }); |
||||
}); |
||||
|
||||
const { user } = renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu'); |
||||
|
||||
const nameInput = await ui.nameInput.find(); |
||||
const saveButton = await ui.saveButton.find(); |
||||
|
||||
await user.clear(nameInput); |
||||
await user.type(nameInput, 'new-group-name'); |
||||
|
||||
await user.click(saveButton); |
||||
|
||||
expect(await ui.successMessage.find()).toBeInTheDocument(); |
||||
expect(locationService.getLocation().pathname).toBe( |
||||
'/alerting/grafana/namespaces/test-folder-uid/groups/new-group-name/edit' |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('Mimir Rules', () => { |
||||
// Create a map to store groups by name
|
||||
const groupsByName = new Map<string, RulerRuleGroupDTO>([[group.name, group]]); |
||||
|
||||
beforeEach(() => { |
||||
groupsByName.clear(); |
||||
groupsByName.set(group.name, group); |
||||
|
||||
setRulerRuleGroupResolver(async ({ params: { groupName } }) => { |
||||
if (groupsByName.has(groupName)) { |
||||
return HttpResponse.json(groupsByName.get(groupName)); |
||||
} |
||||
return HttpResponse.json(null, { status: 404 }); |
||||
}); |
||||
|
||||
setUpdateRulerRuleNamespaceResolver(async ({ request, params }) => { |
||||
const body = await request.json(); |
||||
groupsByName.set(body.name, body); |
||||
return HttpResponse.json({}, { status: 202 }); |
||||
}); |
||||
|
||||
setDeleteRulerRuleNamespaceResolver(async ({ params: { groupName } }) => { |
||||
if (groupsByName.has(groupName)) { |
||||
groupsByName.delete(groupName); |
||||
} |
||||
return HttpResponse.json({ message: 'group does not exist' }, { status: 404 }); |
||||
}); |
||||
}); |
||||
|
||||
it('should save updated interval', async () => { |
||||
setUpdateRulerRuleNamespaceResolver(async ({ request }) => { |
||||
const body = await request.json(); |
||||
if (body.interval === '2m') { |
||||
return HttpResponse.json({}, { status: 202 }); |
||||
} |
||||
|
||||
return HttpResponse.json(null, { status: 400 }); |
||||
}); |
||||
|
||||
const { user } = renderGroupEditPage(mimirDs.uid, 'test-mimir-namespace', 'test-group-cpu'); |
||||
|
||||
const intervalInput = await ui.intervalInput.find(); |
||||
const saveButton = await ui.saveButton.find(); |
||||
|
||||
await user.clear(intervalInput); |
||||
await user.type(intervalInput, '2m'); |
||||
|
||||
await user.click(saveButton); |
||||
|
||||
expect(await ui.successMessage.find()).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should save a new group and remove the old when changing the group name', async () => { |
||||
const { user } = renderGroupEditPage(mimirDs.uid, 'test-mimir-namespace', 'test-group-cpu'); |
||||
|
||||
const groupNameInput = await ui.nameInput.find(); |
||||
const saveButton = await ui.saveButton.find(); |
||||
|
||||
await user.clear(groupNameInput); |
||||
await user.type(groupNameInput, 'new-group-name'); |
||||
|
||||
await user.click(saveButton); |
||||
|
||||
expect(await ui.successMessage.find()).toBeInTheDocument(); |
||||
expect(locationService.getLocation().pathname).toBe( |
||||
'/alerting/mimir/namespaces/test-mimir-namespace/groups/new-group-name/edit' |
||||
); |
||||
}); |
||||
|
||||
it('should save a new group and delete old one when changing the namespace', async () => { |
||||
const { user } = renderGroupEditPage(mimirDs.uid, 'test-mimir-namespace', 'test-group-cpu'); |
||||
|
||||
const namespaceInput = await ui.namespaceInput.find(); |
||||
const saveButton = await ui.saveButton.find(); |
||||
|
||||
await user.clear(namespaceInput); |
||||
await user.type(namespaceInput, 'new-namespace-name'); |
||||
|
||||
await user.click(saveButton); |
||||
|
||||
expect(await ui.successMessage.find()).toBeInTheDocument(); |
||||
expect(locationService.getLocation().pathname).toBe( |
||||
'/alerting/mimir/namespaces/new-namespace-name/groups/test-group-cpu/edit' |
||||
); |
||||
}); |
||||
|
||||
it('should display confirmation modal before deleting a group', async () => { |
||||
const { user } = renderGroupEditPage(mimirDs.uid, 'test-mimir-namespace', 'test-group-cpu'); |
||||
|
||||
const deleteButton = await ui.deleteButton.find(); |
||||
|
||||
await user.click(deleteButton); |
||||
const confirmDialog = await ui.confirmDeleteModal.dialog.find(); |
||||
|
||||
expect(confirmDialog).toBeInTheDocument(); |
||||
expect(ui.confirmDeleteModal.header.get(confirmDialog)).toBeInTheDocument(); |
||||
expect(ui.confirmDeleteModal.confirmButton.get(confirmDialog)).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
describe('Form error handling', () => { |
||||
const groupsByName = new Map<string, RulerRuleGroupDTO>([[group.name, group]]); |
||||
|
||||
beforeEach(() => { |
||||
setGrafanaRulerRuleGroupResolver(async ({ params: { groupName, folderUid } }) => { |
||||
if (groupsByName.has(groupName) && folderUid === 'test-folder-uid') { |
||||
return HttpResponse.json(groupsByName.get(groupName)); |
||||
} |
||||
return HttpResponse.json(null, { status: 404 }); |
||||
}); |
||||
setFolderResponse({ uid: 'test-folder-uid', canSave: true }); |
||||
}); |
||||
|
||||
it('should show validation error for empty group name', async () => { |
||||
const { user } = renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu'); |
||||
|
||||
const nameInput = await ui.nameInput.find(); |
||||
const saveButton = await ui.saveButton.find(); |
||||
|
||||
await user.clear(nameInput); |
||||
await user.click(saveButton); |
||||
|
||||
// Check for validation error message
|
||||
expect(screen.getByText('Group name is required')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should show validation error for invalid interval', async () => { |
||||
const { user } = renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu'); |
||||
|
||||
const intervalInput = await ui.intervalInput.find(); |
||||
const saveButton = await ui.saveButton.find(); |
||||
|
||||
await user.clear(intervalInput); |
||||
await user.type(intervalInput, 'invalid'); |
||||
await user.click(saveButton); |
||||
|
||||
// The exact error message depends on your validation logic
|
||||
// This is a common pattern for testing validation errors
|
||||
expect(screen.getByText(/must be of format/i)).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should handle API error when saving fails', async () => { |
||||
setUpdateGrafanaRulerRuleNamespaceResolver(async () => { |
||||
return HttpResponse.json({ message: 'Failed to save rule group' }, { status: 500 }); |
||||
}); |
||||
|
||||
const { user } = renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu'); |
||||
|
||||
const intervalInput = await ui.intervalInput.find(); |
||||
const saveButton = await ui.saveButton.find(); |
||||
|
||||
await user.clear(intervalInput); |
||||
await user.type(intervalInput, '1m'); |
||||
await user.click(saveButton); |
||||
|
||||
expect(ui.successMessage.query()).not.toBeInTheDocument(); |
||||
expect(ui.errorMessage.query()).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
function renderGroupEditPage(dsUid: string, namespaceId: string, groupName: string) { |
||||
return render( |
||||
<> |
||||
<AppNotificationList /> |
||||
<Routes> |
||||
<Route |
||||
path="/alerting/:dataSourceUid/namespaces/:namespaceId/groups/:groupName/edit" |
||||
element={<GroupEditPage />} |
||||
/> |
||||
</Routes> |
||||
</>, |
||||
{ |
||||
historyOptions: { initialEntries: [`/alerting/${dsUid}/namespaces/${namespaceId}/groups/${groupName}/edit`] }, |
||||
} |
||||
); |
||||
} |
@ -0,0 +1,369 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { produce } from 'immer'; |
||||
import { useCallback, useEffect, useState } from 'react'; |
||||
import { SubmitHandler, useForm } from 'react-hook-form'; |
||||
import { useParams } from 'react-router-dom-v5-compat'; |
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; |
||||
import { locationService } from '@grafana/runtime'; |
||||
import { |
||||
Alert, |
||||
Button, |
||||
ConfirmModal, |
||||
Field, |
||||
Input, |
||||
LinkButton, |
||||
Stack, |
||||
useStyles2, |
||||
withErrorBoundary, |
||||
} from '@grafana/ui'; |
||||
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; |
||||
import { useAppNotification } from 'app/core/copy/appNotification'; |
||||
import { Trans, t } from 'app/core/internationalization'; |
||||
import { useDispatch } from 'app/types'; |
||||
import { GrafanaRulesSourceSymbol, RuleGroupIdentifierV2, RulerDataSourceConfig } from 'app/types/unified-alerting'; |
||||
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { logError } from '../Analytics'; |
||||
import { alertRuleApi } from '../api/alertRuleApi'; |
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; |
||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; |
||||
import { EvaluationGroupQuickPick } from '../components/rule-editor/EvaluationGroupQuickPick'; |
||||
import { evaluateEveryValidationOptions } from '../components/rules/EditRuleGroupModal'; |
||||
import { useDeleteRuleGroup } from '../hooks/ruleGroup/useDeleteRuleGroup'; |
||||
import { UpdateGroupDelta, useUpdateRuleGroup } from '../hooks/ruleGroup/useUpdateRuleGroup'; |
||||
import { isLoading, useAsync } from '../hooks/useAsync'; |
||||
import { useFolder } from '../hooks/useFolder'; |
||||
import { useRuleGroupConsistencyCheck } from '../hooks/usePrometheusConsistencyCheck'; |
||||
import { useReturnTo } from '../hooks/useReturnTo'; |
||||
import { SwapOperation } from '../reducers/ruler/ruleGroups'; |
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../rule-editor/formDefaults'; |
||||
import { ruleGroupIdentifierV2toV1 } from '../utils/groupIdentifier'; |
||||
import { stringifyErrorLike } from '../utils/misc'; |
||||
import { alertListPageLink, createListFilterLink, groups } from '../utils/navigation'; |
||||
|
||||
import { DraggableRulesTable } from './components/DraggableRulesTable'; |
||||
|
||||
type GroupEditPageRouteParams = { |
||||
dataSourceUid?: string; |
||||
namespaceId?: string; |
||||
groupName?: string; |
||||
}; |
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; |
||||
|
||||
function GroupEditPage() { |
||||
const dispatch = useDispatch(); |
||||
const { dataSourceUid = '', namespaceId = '', groupName = '' } = useParams<GroupEditPageRouteParams>(); |
||||
|
||||
const { folder, loading: isFolderLoading } = useFolder(dataSourceUid === 'grafana' ? namespaceId : ''); |
||||
|
||||
const ruleSourceUid = dataSourceUid === 'grafana' ? GrafanaRulesSourceSymbol : dataSourceUid; |
||||
const { |
||||
data: dsFeatures, |
||||
isLoading: isDsFeaturesLoading, |
||||
error: dsFeaturesError, |
||||
} = useDiscoverDsFeaturesQuery({ uid: ruleSourceUid }); |
||||
|
||||
// We use useAsync instead of RTKQ query to avoid cache invalidation issues when the group is being deleted
|
||||
// RTKQ query would refetch the group after it's deleted and we'd end up with a blinking group not found error
|
||||
const [getGroupAction, groupRequestState] = useAsync(async (rulerConfig: RulerDataSourceConfig) => { |
||||
return dispatch( |
||||
alertRuleApi.endpoints.getRuleGroupForNamespace.initiate({ |
||||
rulerConfig: rulerConfig, |
||||
namespace: namespaceId, |
||||
group: groupName, |
||||
}) |
||||
).unwrap(); |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
if (namespaceId && groupName && dsFeatures?.rulerConfig) { |
||||
getGroupAction.execute(dsFeatures.rulerConfig); |
||||
} |
||||
}, [namespaceId, groupName, dsFeatures?.rulerConfig, getGroupAction]); |
||||
|
||||
const isLoadingGroup = isFolderLoading || isDsFeaturesLoading || isLoading(groupRequestState); |
||||
const { result: rulerGroup, error: ruleGroupError } = groupRequestState; |
||||
|
||||
const pageNav: NavModelItem = { |
||||
text: t('alerting.group-edit.page-title', 'Edit rule group'), |
||||
parentItem: { |
||||
text: folder?.title ?? namespaceId, |
||||
url: createListFilterLink([ |
||||
['namespace', folder?.title ?? namespaceId], |
||||
['group', groupName], |
||||
]), |
||||
}, |
||||
}; |
||||
|
||||
if (!!dsFeatures && !dsFeatures.rulerConfig) { |
||||
return ( |
||||
<AlertingPageWrapper pageNav={pageNav} title={groupName} navId="alert-list" isLoading={isLoadingGroup}> |
||||
<Alert title={t('alerting.group-edit.group-not-editable', 'Selected group cannot be edited')}> |
||||
<Trans i18nKey="alerting.group-edit.group-not-editable-description"> |
||||
This group belongs to a data source that does not support editing. |
||||
</Trans> |
||||
</Alert> |
||||
</AlertingPageWrapper> |
||||
); |
||||
} |
||||
|
||||
const groupIdentifier: RuleGroupIdentifierV2 = |
||||
dataSourceUid === 'grafana' |
||||
? { |
||||
namespace: { uid: namespaceId }, |
||||
groupName: groupName, |
||||
groupOrigin: 'grafana', |
||||
} |
||||
: { |
||||
rulesSource: { uid: dataSourceUid, name: dsFeatures?.name ?? '', ruleSourceType: 'datasource' }, |
||||
namespace: { name: namespaceId }, |
||||
groupName: groupName, |
||||
groupOrigin: 'datasource', |
||||
}; |
||||
|
||||
return ( |
||||
<AlertingPageWrapper |
||||
pageNav={pageNav} |
||||
title={t('alerting.group-edit.title', 'Edit evaluation group')} |
||||
navId="alert-list" |
||||
isLoading={isLoadingGroup} |
||||
> |
||||
<> |
||||
{Boolean(dsFeaturesError) && ( |
||||
<Alert |
||||
title={t('alerting.group-edit.ds-error', 'Error loading data source details')} |
||||
bottomSpacing={0} |
||||
topSpacing={2} |
||||
> |
||||
<div>{stringifyErrorLike(dsFeaturesError)}</div> |
||||
</Alert> |
||||
)} |
||||
{/* If the rule group is being deleted, RTKQ will try to referch it due to cache invalidation */} |
||||
{/* For a few miliseconds before redirecting, the rule group will be missing and 404 error would blink */} |
||||
{Boolean(ruleGroupError) && ( |
||||
<Alert |
||||
title={t('alerting.group-edit.rule-group-error', 'Error loading rule group')} |
||||
bottomSpacing={0} |
||||
topSpacing={2} |
||||
> |
||||
{stringifyErrorLike(ruleGroupError)} |
||||
</Alert> |
||||
)} |
||||
</> |
||||
{rulerGroup && <GroupEditForm rulerGroup={rulerGroup} groupIdentifier={groupIdentifier} />} |
||||
{!rulerGroup && <EntityNotFound entity={`${namespaceId}/${groupName}`} />} |
||||
</AlertingPageWrapper> |
||||
); |
||||
} |
||||
|
||||
export default withErrorBoundary(GroupEditPage, { style: 'page' }); |
||||
|
||||
interface GroupEditFormProps { |
||||
rulerGroup: RulerRuleGroupDTO; |
||||
groupIdentifier: RuleGroupIdentifierV2; |
||||
} |
||||
|
||||
interface GroupEditFormData { |
||||
name: string; |
||||
interval: string; |
||||
namespace?: string; |
||||
} |
||||
|
||||
function GroupEditForm({ rulerGroup, groupIdentifier }: GroupEditFormProps) { |
||||
const styles = useStyles2(getStyles); |
||||
const appInfo = useAppNotification(); |
||||
const { returnTo } = useReturnTo(groups.detailsPageLinkFromGroupIdentifier(groupIdentifier)); |
||||
const { folder } = useFolder(groupIdentifier.groupOrigin === 'grafana' ? groupIdentifier.namespace.uid : ''); |
||||
|
||||
const { waitForGroupConsistency } = useRuleGroupConsistencyCheck(); |
||||
const [updateRuleGroup] = useUpdateRuleGroup(); |
||||
const [deleteRuleGroup] = useDeleteRuleGroup(); |
||||
const [operations, setOperations] = useState<SwapOperation[]>([]); |
||||
const [confirmDeleteOpened, setConfirmDeleteOpened] = useState(false); |
||||
|
||||
const groupIntervalOrDefault = rulerGroup?.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL; |
||||
|
||||
const { |
||||
register, |
||||
handleSubmit, |
||||
getValues, |
||||
setValue, |
||||
formState: { errors, dirtyFields, isSubmitting }, |
||||
} = useForm<GroupEditFormData>({ |
||||
mode: 'onBlur', |
||||
shouldFocusError: true, |
||||
defaultValues: { |
||||
name: rulerGroup.name, |
||||
interval: rulerGroup.interval, |
||||
namespace: groupIdentifier.groupOrigin === 'datasource' ? groupIdentifier.namespace.name : undefined, |
||||
}, |
||||
}); |
||||
|
||||
const onSwap = useCallback((swapOperation: SwapOperation) => { |
||||
setOperations((prevOperations) => { |
||||
return produce(prevOperations, (draft) => { |
||||
draft.push(swapOperation); |
||||
}); |
||||
}); |
||||
}, []); |
||||
|
||||
const onSubmit: SubmitHandler<GroupEditFormData> = async (data) => { |
||||
try { |
||||
const changeDelta: UpdateGroupDelta = { |
||||
namespaceName: dirtyFields.namespace ? data.namespace : undefined, |
||||
groupName: dirtyFields.name ? data.name : undefined, |
||||
interval: dirtyFields.interval ? data.interval : undefined, |
||||
ruleSwaps: operations.length ? operations : undefined, |
||||
}; |
||||
|
||||
const updatedGroupIdentifier = await updateRuleGroup.execute( |
||||
ruleGroupIdentifierV2toV1(groupIdentifier), |
||||
changeDelta |
||||
); |
||||
|
||||
const shouldWaitForPromConsistency = !!changeDelta.namespaceName || !!changeDelta.groupName; |
||||
if (shouldWaitForPromConsistency) { |
||||
await waitForGroupConsistency(updatedGroupIdentifier); |
||||
} |
||||
|
||||
const successMessage = t('alerting.group-edit.form.update-success', 'Successfully updated the rule group'); |
||||
appInfo.success(successMessage); |
||||
|
||||
setMatchingGroupPageUrl(updatedGroupIdentifier); |
||||
} catch (error) { |
||||
logError(error instanceof Error ? error : new Error('Failed to update rule group')); |
||||
appInfo.error( |
||||
t('alerting.group-edit.form.update-error', 'Failed to update rule group'), |
||||
stringifyErrorLike(error) |
||||
); |
||||
} |
||||
}; |
||||
|
||||
const onDelete = async () => { |
||||
await deleteRuleGroup.execute(ruleGroupIdentifierV2toV1(groupIdentifier)); |
||||
await waitForGroupConsistency(groupIdentifier); |
||||
redirectToListPage(); |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
<form onSubmit={handleSubmit(onSubmit)}> |
||||
{groupIdentifier.groupOrigin === 'datasource' && ( |
||||
<Field |
||||
label={t('alerting.group-edit.form.namespace-label', 'Namespace')} |
||||
required |
||||
invalid={!!errors.namespace} |
||||
error={errors.namespace?.message} |
||||
className={styles.input} |
||||
> |
||||
<Input |
||||
id="namespace" |
||||
{...register('namespace', { |
||||
required: t('alerting.group-edit.form.namespace-required', 'Namespace is required'), |
||||
})} |
||||
/> |
||||
</Field> |
||||
)} |
||||
{groupIdentifier.groupOrigin === 'grafana' && ( |
||||
<Field label={t('alerting.group-edit.form.folder-label', 'Folder')} required> |
||||
<Input id="folder" value={folder?.title ?? ''} readOnly /> |
||||
</Field> |
||||
)} |
||||
<Field |
||||
label={t('alerting.group-edit.form.group-name-label', 'Evaluation group name')} |
||||
required |
||||
invalid={!!errors.name} |
||||
error={errors.name?.message} |
||||
className={styles.input} |
||||
> |
||||
<Input |
||||
id="group-name" |
||||
{...register('name', { |
||||
required: t('alerting.group-edit.form.group-name-required', 'Group name is required'), |
||||
})} |
||||
/> |
||||
</Field> |
||||
<Field |
||||
label={t('alerting.group-edit.form.interval-label', 'Evaluation interval')} |
||||
description={t('alerting.group-edit.form.interval-description', 'How often is the group evaluated')} |
||||
invalid={!!errors.interval} |
||||
error={errors.interval?.message} |
||||
className={styles.input} |
||||
htmlFor="interval" |
||||
> |
||||
<> |
||||
<Input |
||||
id="interval" |
||||
{...register('interval', evaluateEveryValidationOptions(rulerGroup.rules))} |
||||
className={styles.intervalInput} |
||||
/> |
||||
<EvaluationGroupQuickPick |
||||
currentInterval={getValues('interval')} |
||||
onSelect={(value) => setValue('interval', value, { shouldValidate: true, shouldDirty: true })} |
||||
/> |
||||
</> |
||||
</Field> |
||||
<Field |
||||
label={t('alerting.group-edit.form.rules-label', 'Alerting and recording rules')} |
||||
description={t('alerting.group-edit.form.rules-description', 'Drag rules to reorder')} |
||||
> |
||||
<DraggableRulesTable rules={rulerGroup.rules} groupInterval={groupIntervalOrDefault} onSwap={onSwap} /> |
||||
</Field> |
||||
|
||||
<Stack> |
||||
<Button type="submit" disabled={isSubmitting} icon={isSubmitting ? 'spinner' : undefined}> |
||||
<Trans i18nKey="alerting.group-edit.form.save">Save</Trans> |
||||
</Button> |
||||
<LinkButton variant="secondary" disabled={isSubmitting} href={returnTo}> |
||||
<Trans i18nKey="alerting.common.cancel">Cancel</Trans> |
||||
</LinkButton> |
||||
</Stack> |
||||
</form> |
||||
{groupIdentifier.groupOrigin === 'datasource' && ( |
||||
<Stack direction="row" justifyContent="flex-end"> |
||||
<Button |
||||
type="button" |
||||
variant="destructive" |
||||
onClick={() => setConfirmDeleteOpened(true)} |
||||
disabled={isSubmitting} |
||||
> |
||||
<Trans i18nKey="alerting.group-edit.form.delete">Delete</Trans> |
||||
</Button> |
||||
<ConfirmModal |
||||
isOpen={confirmDeleteOpened} |
||||
title={t('alerting.group-edit.form.delete-title', 'Delete rule group')} |
||||
body={t('alerting.group-edit.form.delete-body', 'Are you sure you want to delete this rule group?')} |
||||
confirmText={t('alerting.group-edit.form.delete-confirm', 'Delete')} |
||||
onConfirm={onDelete} |
||||
onDismiss={() => setConfirmDeleteOpened(false)} |
||||
/> |
||||
</Stack> |
||||
)} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
intervalInput: css({ |
||||
marginBottom: theme.spacing(0.5), |
||||
}), |
||||
input: css({ |
||||
maxWidth: '600px', |
||||
}), |
||||
}); |
||||
|
||||
function setMatchingGroupPageUrl(groupIdentifier: RuleGroupIdentifierV2) { |
||||
if (groupIdentifier.groupOrigin === 'datasource') { |
||||
const { rulesSource, namespace, groupName } = groupIdentifier; |
||||
locationService.replace(groups.editPageLink(rulesSource.uid, namespace.name, groupName, { skipSubPath: true })); |
||||
} else { |
||||
const { namespace, groupName } = groupIdentifier; |
||||
locationService.replace(groups.editPageLink('grafana', namespace.uid, groupName, { skipSubPath: true })); |
||||
} |
||||
} |
||||
|
||||
function redirectToListPage() { |
||||
locationService.replace(alertListPageLink(undefined, { skipSubPath: true })); |
||||
} |
@ -0,0 +1,16 @@ |
||||
import { LinkButton, Stack, Text } from '@grafana/ui'; |
||||
|
||||
import { useReturnTo } from '../hooks/useReturnTo'; |
||||
|
||||
export const Title = ({ name }: { name: string }) => { |
||||
const { returnTo } = useReturnTo('/alerting/list'); |
||||
|
||||
return ( |
||||
<Stack direction="row" gap={1} minWidth={0} alignItems="center"> |
||||
<LinkButton variant="secondary" icon="angle-left" href={returnTo} /> |
||||
<Text element="h1" truncate> |
||||
{name} |
||||
</Text> |
||||
</Stack> |
||||
); |
||||
}; |
@ -0,0 +1,175 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import { |
||||
DragDropContext, |
||||
Draggable, |
||||
DraggableProvided, |
||||
DropResult, |
||||
Droppable, |
||||
DroppableProvided, |
||||
} from '@hello-pangea/dnd'; |
||||
import { produce } from 'immer'; |
||||
import { forwardRef, useCallback, useMemo, useState } from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Badge, Icon, Stack, useStyles2 } from '@grafana/ui'; |
||||
import { t } from 'app/core/internationalization'; |
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { SwapOperation, swapItems } from '../../reducers/ruler/ruleGroups'; |
||||
import { hashRulerRule } from '../../utils/rule-id'; |
||||
import { getNumberEvaluationsToStartAlerting, getRuleName, rulerRuleType } from '../../utils/rules'; |
||||
|
||||
interface DraggableRulesTableProps { |
||||
rules: RulerRuleDTO[]; |
||||
groupInterval: string; |
||||
onSwap: (swapOperation: SwapOperation) => void; |
||||
} |
||||
|
||||
export function DraggableRulesTable({ rules, groupInterval, onSwap }: DraggableRulesTableProps) { |
||||
const styles = useStyles2(getStyles); |
||||
const [rulesList, setRulesList] = useState<RulerRuleDTO[]>(rules); |
||||
|
||||
const onDragEnd = useCallback( |
||||
(result: DropResult) => { |
||||
// check for no-ops so we don't update the group unless we have changes
|
||||
if (!result.destination) { |
||||
return; |
||||
} |
||||
|
||||
const swapOperation: SwapOperation = [result.source.index, result.destination.index]; |
||||
|
||||
onSwap(swapOperation); |
||||
|
||||
// re-order the rules list for the UI rendering
|
||||
const newOrderedRules = produce(rulesList, (draft) => { |
||||
swapItems(draft, swapOperation); |
||||
}); |
||||
setRulesList(newOrderedRules); |
||||
}, |
||||
[rulesList, onSwap] |
||||
); |
||||
|
||||
const rulesWithUID = useMemo(() => { |
||||
return rulesList.map((rulerRule) => ({ ...rulerRule, uid: hashRulerRule(rulerRule) })); |
||||
}, [rulesList]); |
||||
|
||||
return ( |
||||
<div> |
||||
<ListItem |
||||
ruleName={t('alerting.draggable-rules-table.rule-name', 'Rule name')} |
||||
pendingPeriod={t('alerting.draggable-rules-table.pending-period', 'Pending period')} |
||||
evalsToStartAlerting={t( |
||||
'alerting.draggable-rules-table.evals-to-start-alerting', |
||||
'Evaluations to start alerting' |
||||
)} |
||||
className={styles.listHeader} |
||||
/> |
||||
<DragDropContext onDragEnd={onDragEnd}> |
||||
<Droppable |
||||
droppableId="alert-list" |
||||
mode="standard" |
||||
renderClone={(provided, _snapshot, rubric) => ( |
||||
<DraggableListItem |
||||
provided={provided} |
||||
rule={rulesWithUID[rubric.source.index]} |
||||
isClone |
||||
groupInterval={groupInterval} |
||||
/> |
||||
)} |
||||
> |
||||
{(droppableProvided: DroppableProvided) => ( |
||||
<Stack direction="column" gap={0} ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}> |
||||
{rulesWithUID.map((rule, index) => ( |
||||
<Draggable key={rule.uid} draggableId={rule.uid} index={index} isDragDisabled={false}> |
||||
{(provided: DraggableProvided) => ( |
||||
<DraggableListItem key={rule.uid} provided={provided} rule={rule} groupInterval={groupInterval} /> |
||||
)} |
||||
</Draggable> |
||||
))} |
||||
{droppableProvided.placeholder} |
||||
</Stack> |
||||
)} |
||||
</Droppable> |
||||
</DragDropContext> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
interface DraggableListItemProps extends React.HTMLAttributes<HTMLDivElement> { |
||||
provided: DraggableProvided; |
||||
rule: RulerRuleDTO; |
||||
groupInterval: string; |
||||
isClone?: boolean; |
||||
} |
||||
|
||||
const DraggableListItem = ({ provided, rule, groupInterval, isClone = false }: DraggableListItemProps) => { |
||||
const styles = useStyles2(getStyles); |
||||
const ruleName = getRuleName(rule); |
||||
const pendingPeriod = rulerRuleType.any.alertingRule(rule) ? rule.for : null; |
||||
const numberEvaluationsToStartAlerting = getNumberEvaluationsToStartAlerting(pendingPeriod ?? '0s', groupInterval); |
||||
const isRecordingRule = rulerRuleType.any.recordingRule(rule); |
||||
|
||||
return ( |
||||
<ListItem |
||||
dragHandle={<Icon name="draggabledots" />} |
||||
ruleName={ruleName} |
||||
pendingPeriod={pendingPeriod} |
||||
evalsToStartAlerting={ |
||||
isRecordingRule ? ( |
||||
<Badge text={t('alerting.draggable-rules-table.recording', 'Recording')} color="purple" /> |
||||
) : ( |
||||
numberEvaluationsToStartAlerting |
||||
) |
||||
} |
||||
data-testid="reorder-alert-rule" |
||||
className={cx(styles.listItem, { [styles.listItemClone]: isClone })} |
||||
ref={provided.innerRef} |
||||
{...provided.draggableProps} |
||||
{...provided.dragHandleProps} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
interface ListItemProps extends React.HTMLAttributes<HTMLDivElement> { |
||||
dragHandle?: React.ReactNode; |
||||
ruleName: React.ReactNode; |
||||
pendingPeriod: React.ReactNode; |
||||
evalsToStartAlerting: React.ReactNode; |
||||
} |
||||
|
||||
const ListItem = forwardRef<HTMLDivElement, ListItemProps>( |
||||
({ dragHandle, ruleName, pendingPeriod, evalsToStartAlerting, className, ...props }, ref) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={cx(styles.listItem, className)} ref={ref} {...props}> |
||||
<Stack flex="0 0 24px">{dragHandle}</Stack> |
||||
<Stack flex={1}>{ruleName}</Stack> |
||||
<Stack basis="30%">{pendingPeriod}</Stack> |
||||
<Stack basis="30%">{evalsToStartAlerting}</Stack> |
||||
</div> |
||||
); |
||||
} |
||||
); |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
listItem: css({ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
alignItems: 'center', |
||||
|
||||
gap: theme.spacing(1), |
||||
padding: `${theme.spacing(1)} ${theme.spacing(2)}`, |
||||
|
||||
'&:nth-child(even)': { |
||||
background: theme.colors.background.secondary, |
||||
}, |
||||
}), |
||||
listItemClone: css({ |
||||
border: `solid 1px ${theme.colors.primary.shade}`, |
||||
}), |
||||
listHeader: css({ |
||||
fontWeight: theme.typography.fontWeightBold, |
||||
borderBottom: `1px solid ${theme.colors.border.weak}`, |
||||
}), |
||||
}); |
@ -0,0 +1,45 @@ |
||||
import { RuleGroupIdentifierV2 } from 'app/types/unified-alerting'; |
||||
|
||||
import { createReturnTo } from '../hooks/useReturnTo'; |
||||
|
||||
import { createRelativeUrl } from './url'; |
||||
|
||||
export const createListFilterLink = (values: Array<[string, string]>) => { |
||||
const params = new URLSearchParams([['search', values.map(([key, value]) => `${key}:"${value}"`).join(' ')]]); |
||||
return createRelativeUrl(`/alerting/list`, params); |
||||
}; |
||||
|
||||
export const alertListPageLink = (queryParams: Record<string, string> = {}, options?: { skipSubPath?: boolean }) => |
||||
createRelativeUrl(`/alerting/list`, queryParams, { skipSubPath: options?.skipSubPath }); |
||||
|
||||
export const groups = { |
||||
detailsPageLink: (dsUid: string, namespaceId: string, groupName: string, options?: { includeReturnTo: boolean }) => { |
||||
const params: Record<string, string> = options?.includeReturnTo ? { returnTo: createReturnTo() } : {}; |
||||
|
||||
return createRelativeUrl( |
||||
`/alerting/${dsUid}/namespaces/${encodeURIComponent(namespaceId)}/groups/${encodeURIComponent(groupName)}/view`, |
||||
params |
||||
); |
||||
}, |
||||
detailsPageLinkFromGroupIdentifier: (groupIdentifier: RuleGroupIdentifierV2) => { |
||||
const { groupOrigin, namespace, groupName } = groupIdentifier; |
||||
const isGrafanaOrigin = groupOrigin === 'grafana'; |
||||
|
||||
return isGrafanaOrigin |
||||
? groups.detailsPageLink('grafana', namespace.uid, groupName) |
||||
: groups.detailsPageLink(groupIdentifier.rulesSource.uid, namespace.name, groupName); |
||||
}, |
||||
editPageLink: ( |
||||
dsUid: string, |
||||
namespaceId: string, |
||||
groupName: string, |
||||
options?: { includeReturnTo?: boolean; skipSubPath?: boolean } |
||||
) => { |
||||
const params: Record<string, string> = options?.includeReturnTo ? { returnTo: createReturnTo() } : {}; |
||||
return createRelativeUrl( |
||||
`/alerting/${dsUid}/namespaces/${encodeURIComponent(namespaceId)}/groups/${encodeURIComponent(groupName)}/edit`, |
||||
params, |
||||
{ skipSubPath: options?.skipSubPath } |
||||
); |
||||
}, |
||||
}; |
Loading…
Reference in new issue