mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Integrate alerts with folders (#45763)
* Add Alert rules tab
* Add pagination and a simple name-based filtering
* Add name and label based filtering
* Improve pagination, handle the no results case
* Add tests for alerts folder view
* Add label filtering by clicking a tag
* Add tests for matcher to string method
* Add sorting, improve styles
* Use simple Select component for sorting
* Update default page size
* Remove unused code
* Use existingc thunk
* chore: update swagger spec
* Revert "chore: update swagger spec"
This reverts commit ee79ec7341
.
* Code cleanup
* Fix ts
Co-authored-by: gillesdemey <gilles.de.mey@gmail.com>
This commit is contained in:
parent
e512029afb
commit
2409405c34
@ -46,3 +46,13 @@ function getRefId(num: number): string {
|
||||
return getRefId(Math.floor(num / letters.length) - 1) + letters[num % letters.length];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the input value for non empty string and undefined otherwise
|
||||
*
|
||||
* It is inadvisable to set a query param to an empty string as it will be added to the URL.
|
||||
* It is better to keep it undefined so the param won't be added to the URL at all.
|
||||
*/
|
||||
export function getQueryParamValue(value: string | undefined | null): string | undefined {
|
||||
return value || undefined;
|
||||
}
|
||||
|
189
public/app/features/alerting/unified/AlertsFolderView.test.tsx
Normal file
189
public/app/features/alerting/unified/AlertsFolderView.test.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { FolderState } from 'app/types';
|
||||
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { byTestId } from 'testing-library-selector';
|
||||
import { AlertsFolderView } from './AlertsFolderView';
|
||||
import { mockCombinedRule } from './mocks';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||
|
||||
const ui = {
|
||||
filter: {
|
||||
name: byTestId('name-filter'),
|
||||
label: byTestId('label-filter'),
|
||||
},
|
||||
ruleList: {
|
||||
row: byTestId('alert-card-row'),
|
||||
},
|
||||
};
|
||||
|
||||
const combinedNamespaceMock = jest.fn<CombinedRuleNamespace[], any>();
|
||||
jest.mock('./hooks/useCombinedRuleNamespaces', () => ({
|
||||
useCombinedRuleNamespaces: () => combinedNamespaceMock(),
|
||||
}));
|
||||
|
||||
const mockFolder = (folderOverride: Partial<FolderState> = {}): FolderState => {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'Folder with alerts',
|
||||
uid: 'folder-1',
|
||||
hasChanged: false,
|
||||
canSave: false,
|
||||
url: '/folder-1',
|
||||
version: 1,
|
||||
permissions: [],
|
||||
canViewFolderPermissions: false,
|
||||
canDelete: false,
|
||||
...folderOverride,
|
||||
};
|
||||
};
|
||||
|
||||
describe('AlertsFolderView tests', () => {
|
||||
it('Should display grafana alert rules when the namespace name matches the folder name', () => {
|
||||
// Arrange
|
||||
const store = configureStore();
|
||||
const folder = mockFolder();
|
||||
|
||||
const grafanaNamespace: CombinedRuleNamespace = {
|
||||
name: folder.title,
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [
|
||||
{
|
||||
name: 'default',
|
||||
rules: [
|
||||
mockCombinedRule({ name: 'Test Alert 1' }),
|
||||
mockCombinedRule({ name: 'Test Alert 2' }),
|
||||
mockCombinedRule({ name: 'Test Alert 3' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
|
||||
|
||||
// Act
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<AlertsFolderView folder={folder} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
// Assert
|
||||
const alertRows = ui.ruleList.row.queryAll();
|
||||
expect(alertRows).toHaveLength(3);
|
||||
expect(alertRows[0]).toHaveTextContent('Test Alert 1');
|
||||
expect(alertRows[1]).toHaveTextContent('Test Alert 2');
|
||||
expect(alertRows[2]).toHaveTextContent('Test Alert 3');
|
||||
});
|
||||
|
||||
it('Shold not display alert rules when the namespace name does not match the folder name', () => {
|
||||
// Arrange
|
||||
const store = configureStore();
|
||||
const folder = mockFolder();
|
||||
|
||||
const grafanaNamespace: CombinedRuleNamespace = {
|
||||
name: 'Folder without alerts',
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [
|
||||
{
|
||||
name: 'default',
|
||||
rules: [
|
||||
mockCombinedRule({ name: 'Test Alert from other folder 1' }),
|
||||
mockCombinedRule({ name: 'Test Alert from other folder 2' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
|
||||
|
||||
// Act
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<AlertsFolderView folder={folder} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(ui.ruleList.row.queryAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Should filter alert rules by the name, case insensitive', () => {
|
||||
// Arrange
|
||||
const store = configureStore();
|
||||
const folder = mockFolder();
|
||||
|
||||
const grafanaNamespace: CombinedRuleNamespace = {
|
||||
name: folder.title,
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [
|
||||
{
|
||||
name: 'default',
|
||||
rules: [mockCombinedRule({ name: 'CPU Alert' }), mockCombinedRule({ name: 'RAM usage alert' })],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
|
||||
|
||||
// Act
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<AlertsFolderView folder={folder} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
userEvent.type(ui.filter.name.get(), 'cpu');
|
||||
|
||||
// Assert
|
||||
expect(ui.ruleList.row.queryAll()).toHaveLength(1);
|
||||
expect(ui.ruleList.row.get()).toHaveTextContent('CPU Alert');
|
||||
});
|
||||
|
||||
it('Should filter alert rule by labels', () => {
|
||||
// Arrange
|
||||
const store = configureStore();
|
||||
const folder = mockFolder();
|
||||
|
||||
const grafanaNamespace: CombinedRuleNamespace = {
|
||||
name: folder.title,
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [
|
||||
{
|
||||
name: 'default',
|
||||
rules: [
|
||||
mockCombinedRule({ name: 'CPU Alert', labels: {} }),
|
||||
mockCombinedRule({ name: 'RAM usage alert', labels: { severity: 'critical' } }),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
combinedNamespaceMock.mockReturnValue([grafanaNamespace]);
|
||||
|
||||
// Act
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<AlertsFolderView folder={folder} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
userEvent.type(ui.filter.label.get(), 'severity=critical');
|
||||
|
||||
// Assert
|
||||
expect(ui.ruleList.row.queryAll()).toHaveLength(1);
|
||||
expect(ui.ruleList.row.get()).toHaveTextContent('RAM usage alert');
|
||||
});
|
||||
});
|
202
public/app/features/alerting/unified/AlertsFolderView.tsx
Normal file
202
public/app/features/alerting/unified/AlertsFolderView.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Stack } from '@grafana/experimental';
|
||||
import { Card, FilterInput, Icon, Pagination, Select, TagList, useStyles2 } from '@grafana/ui';
|
||||
import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants';
|
||||
import { getQueryParamValue } from 'app/core/utils/query';
|
||||
import { FolderState } from 'app/types';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { isEqual, orderBy, uniqWith } from 'lodash';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces';
|
||||
import { usePagination } from './hooks/usePagination';
|
||||
import { useURLSearchParams } from './hooks/useURLSearchParams';
|
||||
import { fetchPromRulesAction, fetchRulerRulesAction } from './state/actions';
|
||||
import { labelsMatchMatchers, matchersToString, parseMatcher, parseMatchers } from './utils/alertmanager';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||
import { createViewLink } from './utils/misc';
|
||||
|
||||
interface Props {
|
||||
folder: FolderState;
|
||||
}
|
||||
|
||||
enum SortOrder {
|
||||
Ascending = 'alpha-asc',
|
||||
Descending = 'alpha-desc',
|
||||
}
|
||||
|
||||
const sortOptions: Array<SelectableValue<SortOrder>> = [
|
||||
{ label: 'Alphabetically [A-Z]', value: SortOrder.Ascending },
|
||||
{ label: 'Alphabetically [Z-A]', value: SortOrder.Descending },
|
||||
];
|
||||
|
||||
export const AlertsFolderView = ({ folder }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onTagClick = (tagName: string) => {
|
||||
const matchers = parseMatchers(labelFilter);
|
||||
const tagMatcherField = parseMatcher(tagName);
|
||||
const uniqueMatchers = uniqWith([...matchers, tagMatcherField], isEqual);
|
||||
const matchersString = matchersToString(uniqueMatchers);
|
||||
setLabelFilter(matchersString);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchPromRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
|
||||
dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
|
||||
}, [dispatch]);
|
||||
|
||||
const combinedNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
|
||||
const { nameFilter, labelFilter, sortOrder, setNameFilter, setLabelFilter, setSortOrder } =
|
||||
useAlertsFolderViewParams();
|
||||
|
||||
const matchingNamespace = combinedNamespaces.find((namespace) => namespace.name === folder.title);
|
||||
const alertRules = matchingNamespace?.groups[0]?.rules ?? [];
|
||||
|
||||
const filteredRules = filterAndSortRules(alertRules, nameFilter, labelFilter, sortOrder ?? SortOrder.Ascending);
|
||||
|
||||
const hasNoResults = alertRules.length === 0 || filteredRules.length === 0;
|
||||
const { page, numberOfPages, onPageChange, pageItems } = usePagination(filteredRules, 1, DEFAULT_PER_PAGE_PAGINATION);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Stack direction="column" gap={3}>
|
||||
<FilterInput
|
||||
value={nameFilter}
|
||||
onChange={setNameFilter}
|
||||
placeholder="Search alert rules by name"
|
||||
data-testid="name-filter"
|
||||
/>
|
||||
<Stack direction="row">
|
||||
<Select<SortOrder>
|
||||
value={sortOrder}
|
||||
onChange={({ value }) => value && setSortOrder(value)}
|
||||
options={sortOptions}
|
||||
width={25}
|
||||
aria-label="Sort"
|
||||
placeholder={`Sort (Default A-Z)`}
|
||||
prefix={<Icon name={sortOrder === SortOrder.Ascending ? 'sort-amount-up' : 'sort-amount-down'} />}
|
||||
/>
|
||||
<FilterInput
|
||||
value={labelFilter}
|
||||
onChange={setLabelFilter}
|
||||
placeholder="Search alerts by labels"
|
||||
className={styles.filterLabelsInput}
|
||||
data-testid="label-filter"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={1}>
|
||||
{pageItems.map((currentRule) => (
|
||||
<Card
|
||||
key={currentRule.name}
|
||||
href={createViewLink('grafana', currentRule, '')}
|
||||
className={styles.card}
|
||||
data-testid="alert-card-row"
|
||||
>
|
||||
<Card.Heading>{currentRule.name}</Card.Heading>
|
||||
<Card.Tags>
|
||||
<TagList
|
||||
onClick={onTagClick}
|
||||
tags={Object.entries(currentRule.labels).map(([label, value]) => `${label}=${value}`)}
|
||||
/>
|
||||
</Card.Tags>
|
||||
<Card.Meta>
|
||||
<div>
|
||||
<Icon name="folder" /> {folder.title}
|
||||
</div>
|
||||
</Card.Meta>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
{hasNoResults && <div className={styles.noResults}>No alert rules found</div>}
|
||||
<div className={styles.pagination}>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
numberOfPages={numberOfPages}
|
||||
onNavigate={onPageChange}
|
||||
hideWhenSinglePage={true}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
enum AlertFolderViewParams {
|
||||
nameFilter = 'nameFilter',
|
||||
labelFilter = 'labelFilter',
|
||||
sortOrder = 'sort',
|
||||
}
|
||||
|
||||
function useAlertsFolderViewParams() {
|
||||
const [searchParams, setSearchParams] = useURLSearchParams();
|
||||
|
||||
const [nameFilter, setNameFilter] = useState(searchParams.get(AlertFolderViewParams.nameFilter) ?? '');
|
||||
const [labelFilter, setLabelFilter] = useState(searchParams.get(AlertFolderViewParams.labelFilter) ?? '');
|
||||
|
||||
const sortParam = searchParams.get(AlertFolderViewParams.sortOrder);
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder | undefined>(
|
||||
sortParam === SortOrder.Ascending
|
||||
? SortOrder.Ascending
|
||||
: sortParam === SortOrder.Descending
|
||||
? SortOrder.Descending
|
||||
: undefined
|
||||
);
|
||||
|
||||
useDebounce(
|
||||
() =>
|
||||
setSearchParams(
|
||||
{
|
||||
[AlertFolderViewParams.nameFilter]: getQueryParamValue(nameFilter),
|
||||
[AlertFolderViewParams.labelFilter]: getQueryParamValue(labelFilter),
|
||||
[AlertFolderViewParams.sortOrder]: getQueryParamValue(sortOrder),
|
||||
},
|
||||
true
|
||||
),
|
||||
400,
|
||||
[nameFilter, labelFilter, sortOrder]
|
||||
);
|
||||
|
||||
return { nameFilter, labelFilter, sortOrder, setNameFilter, setLabelFilter, setSortOrder };
|
||||
}
|
||||
|
||||
function filterAndSortRules(
|
||||
originalRules: CombinedRule[],
|
||||
nameFilter: string,
|
||||
labelFilter: string,
|
||||
sortOrder: SortOrder
|
||||
) {
|
||||
const matchers = parseMatchers(labelFilter);
|
||||
let rules = originalRules.filter(
|
||||
(rule) => rule.name.toLowerCase().includes(nameFilter.toLowerCase()) && labelsMatchMatchers(rule.labels, matchers)
|
||||
);
|
||||
|
||||
return orderBy(rules, (x) => x.name, [sortOrder === SortOrder.Ascending ? 'asc' : 'desc']);
|
||||
}
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css`
|
||||
padding: ${theme.spacing(1)};
|
||||
`,
|
||||
card: css`
|
||||
grid-template-columns: auto 1fr 2fr;
|
||||
margin: 0;
|
||||
`,
|
||||
pagination: css`
|
||||
align-self: center;
|
||||
`,
|
||||
filterLabelsInput: css`
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 240px;
|
||||
`,
|
||||
noResults: css`
|
||||
padding: ${theme.spacing(2)};
|
||||
background-color: ${theme.colors.background.secondary};
|
||||
font-style: italic;
|
||||
`,
|
||||
});
|
19
public/app/features/alerting/unified/hooks/usePagination.ts
Normal file
19
public/app/features/alerting/unified/hooks/usePagination.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function usePagination<T>(items: T[], initialPage: number, itemsPerPage: number) {
|
||||
const [page, setPage] = useState(initialPage);
|
||||
|
||||
const numberOfPages = Math.ceil(items.length / itemsPerPage);
|
||||
|
||||
const firstItemOnPageIndex = itemsPerPage * (page - 1);
|
||||
const pageItems = items.slice(firstItemOnPageIndex, firstItemOnPageIndex + itemsPerPage);
|
||||
|
||||
const onPageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
// Reset the current page when number of changes has been changed
|
||||
useEffect(() => setPage(1), [numberOfPages]);
|
||||
|
||||
return { page, onPageChange, numberOfPages, pageItems };
|
||||
}
|
@ -1,6 +1,12 @@
|
||||
import { Matcher, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { Labels } from 'app/types/unified-alerting-dto';
|
||||
import { parseMatcher, parseMatchers, labelsMatchMatchers, removeMuteTimingFromRoute } from './alertmanager';
|
||||
import {
|
||||
parseMatcher,
|
||||
parseMatchers,
|
||||
labelsMatchMatchers,
|
||||
removeMuteTimingFromRoute,
|
||||
matchersToString,
|
||||
} from './alertmanager';
|
||||
|
||||
describe('Alertmanager utils', () => {
|
||||
describe('parseMatcher', () => {
|
||||
@ -162,4 +168,19 @@ describe('Alertmanager utils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchersToString', () => {
|
||||
it('Should create a comma-separated list of labels and values wrapped into curly brackets', () => {
|
||||
const matchers: Matcher[] = [
|
||||
{ name: 'severity', value: 'critical', isEqual: true, isRegex: false },
|
||||
{ name: 'resource', value: 'cpu', isEqual: true, isRegex: true },
|
||||
{ name: 'rule_uid', value: '2Otf8canzz', isEqual: false, isRegex: false },
|
||||
{ name: 'cluster', value: 'prom', isEqual: false, isRegex: true },
|
||||
];
|
||||
|
||||
const matchersString = matchersToString(matchers);
|
||||
|
||||
expect(matchersString).toBe('{severity="critical",resource=~"cpu",rule_uid!="2Otf8canzz",cluster!~"prom"}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -104,6 +104,17 @@ export function matcherFieldToMatcher(field: MatcherFieldValue): Matcher {
|
||||
};
|
||||
}
|
||||
|
||||
export function matchersToString(matchers: Matcher[]) {
|
||||
const matcherFields = matchers.map(matcherToMatcherField);
|
||||
|
||||
const combinedMatchers = matcherFields.reduce((acc, current) => {
|
||||
const currentMatcherString = `${current.name}${current.operator}"${current.value}"`;
|
||||
return acc ? `${acc},${currentMatcherString}` : currentMatcherString;
|
||||
}, '');
|
||||
|
||||
return `{${combinedMatchers}}`;
|
||||
}
|
||||
|
||||
export const matcherFieldOptions: SelectableValue[] = [
|
||||
{ label: MatcherOperator.equal, description: 'Equals', value: MatcherOperator.equal },
|
||||
{ label: MatcherOperator.notEqual, description: 'Does not equal', value: MatcherOperator.notEqual },
|
||||
|
33
public/app/features/folders/FolderAlerting.tsx
Normal file
33
public/app/features/folders/FolderAlerting.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import Page from 'app/core/components/Page/Page';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { StoreState } from 'app/types';
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useAsync } from 'react-use';
|
||||
import { AlertsFolderView } from '../alerting/unified/AlertsFolderView';
|
||||
import { getFolderByUid } from './state/actions';
|
||||
import { getLoadingNav } from './state/navModel';
|
||||
|
||||
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
|
||||
|
||||
const FolderAlerting = ({ match }: OwnProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const navIndex = useSelector((state: StoreState) => state.navIndex);
|
||||
const folder = useSelector((state: StoreState) => state.folder);
|
||||
|
||||
const uid = match.params.uid;
|
||||
const navModel = getNavModel(navIndex, `folder-alerting-${uid}`, getLoadingNav(1));
|
||||
|
||||
const { loading } = useAsync(async () => dispatch(getFolderByUid(uid)), [getFolderByUid, uid]);
|
||||
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents isLoading={loading}>
|
||||
<AlertsFolderView folder={folder} />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderAlerting;
|
@ -1,14 +1,14 @@
|
||||
import { locationUtil } from '@grafana/data';
|
||||
import { getBackendSrv, locationService } from '@grafana/runtime';
|
||||
import { notifyApp, updateNavIndex } from 'app/core/actions';
|
||||
import { createSuccessNotification, createWarningNotification } from 'app/core/copy/appNotification';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { FolderState, ThunkResult } from 'app/types';
|
||||
import { DashboardAcl, DashboardAclUpdateDTO, NewDashboardAclItem, PermissionLevel } from 'app/types/acl';
|
||||
import { notifyApp, updateNavIndex } from 'app/core/actions';
|
||||
import { createSuccessNotification, createWarningNotification } from 'app/core/copy/appNotification';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { buildNavModel } from './navModel';
|
||||
import { loadFolder, loadFolderPermissions, setCanViewFolderPermissions } from './reducers';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
export function getFolderByUid(uid: string): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
|
@ -29,6 +29,14 @@ export function buildNavModel(folder: FolderDTO): NavModelItem {
|
||||
url: `${folder.url}/library-panels`,
|
||||
});
|
||||
|
||||
model.children.push({
|
||||
active: false,
|
||||
icon: 'bell',
|
||||
id: `folder-alerting-${folder.uid}`,
|
||||
text: 'Alert rules',
|
||||
url: `${folder.url}/alerting`,
|
||||
});
|
||||
|
||||
if (folder.canAdmin) {
|
||||
model.children.push({
|
||||
active: false,
|
||||
|
@ -421,6 +421,12 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
() => import(/* webpackChunkName: "FolderLibraryPanelsPage"*/ 'app/features/folders/FolderLibraryPanelsPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/dashboards/f/:uid/:slug/alerting',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "FolderAlerting"*/ 'app/features/folders/FolderAlerting')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/library-panels',
|
||||
component: SafeDynamicImport(
|
||||
|
Loading…
Reference in New Issue
Block a user