mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: New list view UI – Part 1 (#87907)
This commit is contained in:
@@ -1232,9 +1232,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/RuleList.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
@@ -1299,6 +1296,9 @@ exports[`better eslint`] = {
|
||||
"public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-list/RuleList.v1.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/silences/SilencesEditor.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
|
||||
@@ -188,6 +188,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `autofixDSUID` | Automatically migrates invalid datasource UIDs |
|
||||
| `logsExploreTableDefaultVisualization` | Sets the logs table as default visualisation in logs explore |
|
||||
| `newDashboardSharingComponent` | Enables the new sharing drawer design |
|
||||
| `alertingListViewV2` | Enables the new alert list view design |
|
||||
| `notificationBanner` | Enables the notification banner UI and API |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
@@ -393,6 +393,7 @@
|
||||
"tinycolor2": "1.6.0",
|
||||
"tslib": "2.6.2",
|
||||
"tween-functions": "^1.2.0",
|
||||
"type-fest": "^4.18.2",
|
||||
"uplot": "1.6.30",
|
||||
"uuid": "9.0.1",
|
||||
"visjs-network": "4.25.0",
|
||||
|
||||
@@ -188,6 +188,7 @@ export interface FeatureToggles {
|
||||
autofixDSUID?: boolean;
|
||||
logsExploreTableDefaultVisualization?: boolean;
|
||||
newDashboardSharingComponent?: boolean;
|
||||
alertingListViewV2?: boolean;
|
||||
notificationBanner?: boolean;
|
||||
dashboardRestore?: boolean;
|
||||
datasourceProxyDisableRBAC?: boolean;
|
||||
|
||||
@@ -185,6 +185,7 @@ export const availableIconsIndex = {
|
||||
paragraph: true,
|
||||
'pathfinder-unite': true,
|
||||
pause: true,
|
||||
'pause-circle': true,
|
||||
pen: true,
|
||||
percentage: true,
|
||||
play: true,
|
||||
|
||||
@@ -1267,6 +1267,13 @@ var (
|
||||
Owner: grafanaSharingSquad,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "alertingListViewV2",
|
||||
Description: "Enables the new alert list view design",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaAlertingSquad,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "notificationBanner",
|
||||
Description: "Enables the notification banner UI and API",
|
||||
|
||||
@@ -169,6 +169,7 @@ queryLibrary,experimental,@grafana/explore-squad,false,false,false
|
||||
autofixDSUID,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true
|
||||
newDashboardSharingComponent,experimental,@grafana/sharing-squad,false,false,true
|
||||
alertingListViewV2,experimental,@grafana/alerting-squad,false,false,true
|
||||
notificationBanner,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||
dashboardRestore,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||
datasourceProxyDisableRBAC,GA,@grafana/identity-access-team,false,false,false
|
||||
|
||||
|
@@ -687,6 +687,10 @@ const (
|
||||
// Enables the new sharing drawer design
|
||||
FlagNewDashboardSharingComponent = "newDashboardSharingComponent"
|
||||
|
||||
// FlagAlertingListViewV2
|
||||
// Enables the new alert list view design
|
||||
FlagAlertingListViewV2 = "alertingListViewV2"
|
||||
|
||||
// FlagNotificationBanner
|
||||
// Enables the notification banner UI and API
|
||||
FlagNotificationBanner = "notificationBanner"
|
||||
|
||||
@@ -2237,6 +2237,19 @@
|
||||
"codeowner": "@grafana/grafana-app-platform-squad",
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "alertingListViewV2",
|
||||
"resourceVersion": "1716558084235",
|
||||
"creationTimestamp": "2024-05-24T13:41:24Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the new alert list view design",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"frontend": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -162,7 +162,7 @@ const ui = {
|
||||
paused: byText(/^Paused/),
|
||||
},
|
||||
actionButtons: {
|
||||
more: byRole('button', { name: /more-actions/ }),
|
||||
more: byRole('button', { name: /More/ }),
|
||||
},
|
||||
moreActionItems: {
|
||||
pause: byRole('menuitem', { name: /pause evaluation/i }),
|
||||
|
||||
@@ -1,185 +1,14 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAsyncFn, useInterval } from 'react-use';
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
||||
import { Button, LinkButton, useStyles2, withErrorBoundary } from '@grafana/ui';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { useDispatch } from 'app/types';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { CombinedRuleNamespace } from '../../../types/unified-alerting';
|
||||
import RuleListV1 from './components/rule-list/RuleList.v1';
|
||||
const RuleListV2 = React.lazy(() => import('./components/rule-list/RuleList.v2'));
|
||||
|
||||
import { LogMessages, logInfo, trackRuleListNavigation } from './Analytics';
|
||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { NoRulesSplash } from './components/rules/NoRulesCTA';
|
||||
import { INSTANCES_DISPLAY_LIMIT } from './components/rules/RuleDetails';
|
||||
import { RuleListErrors } from './components/rules/RuleListErrors';
|
||||
import { RuleListGroupView } from './components/rules/RuleListGroupView';
|
||||
import { RuleListStateView } from './components/rules/RuleListStateView';
|
||||
import { RuleStats } from './components/rules/RuleStats';
|
||||
import RulesFilter from './components/rules/RulesFilter';
|
||||
import { AlertingAction, useAlertingAbility } from './hooks/useAbilities';
|
||||
import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces';
|
||||
import { useFilteredRules, useRulesFilter } from './hooks/useFilteredRules';
|
||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||
import { fetchAllPromAndRulerRulesAction } from './state/actions';
|
||||
import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants';
|
||||
import { getAllRulesSourceNames } from './utils/datasource';
|
||||
const RuleList = () => {
|
||||
const newView = config.featureToggles.alertingListViewV2;
|
||||
|
||||
const VIEWS = {
|
||||
groups: RuleListGroupView,
|
||||
state: RuleListStateView,
|
||||
return <Suspense>{newView ? <RuleListV2 /> : <RuleListV1 />}</Suspense>;
|
||||
};
|
||||
|
||||
// make sure we ask for 1 more so we show the "show x more" button
|
||||
const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1;
|
||||
|
||||
const RuleList = withErrorBoundary(
|
||||
() => {
|
||||
const dispatch = useDispatch();
|
||||
const styles = useStyles2(getStyles);
|
||||
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
|
||||
const onFilterCleared = useCallback(() => setExpandAll(false), []);
|
||||
|
||||
const [queryParams] = useQueryParams();
|
||||
const { filterState, hasActiveFilters } = useRulesFilter();
|
||||
|
||||
const queryParamView = queryParams['view'] as keyof typeof VIEWS;
|
||||
const view = VIEWS[queryParamView] ? queryParamView : 'groups';
|
||||
|
||||
const ViewComponent = VIEWS[view];
|
||||
|
||||
const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules);
|
||||
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
|
||||
const loading = rulesDataSourceNames.some(
|
||||
(name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading
|
||||
);
|
||||
|
||||
const promRequests = Object.entries(promRuleRequests);
|
||||
const rulerRequests = Object.entries(rulerRuleRequests);
|
||||
|
||||
const allPromLoaded = promRequests.every(
|
||||
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
|
||||
);
|
||||
const allRulerLoaded = rulerRequests.every(
|
||||
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
|
||||
);
|
||||
|
||||
const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0);
|
||||
|
||||
const allRulerEmpty = rulerRequests.every(([_, state]) => {
|
||||
const rulerRules = Object.entries(state?.result ?? {});
|
||||
const noRules = rulerRules.every(([_, result]) => result?.length === 0);
|
||||
return noRules && state.dispatched;
|
||||
});
|
||||
|
||||
const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS;
|
||||
// Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED
|
||||
const [_, fetchRules] = useAsyncFn(async () => {
|
||||
if (!loading) {
|
||||
await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
|
||||
}
|
||||
}, [loading, limitAlerts, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
trackRuleListNavigation().catch(() => {});
|
||||
}, []);
|
||||
|
||||
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
|
||||
useEffect(() => {
|
||||
dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
|
||||
}, [dispatch, limitAlerts]);
|
||||
useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS);
|
||||
|
||||
// Show splash only when we loaded all of the data sources and none of them has alerts
|
||||
const hasNoAlertRulesCreatedYet =
|
||||
allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded;
|
||||
const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet;
|
||||
|
||||
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
|
||||
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
|
||||
return (
|
||||
// We don't want to show the Loading... indicator for the whole page.
|
||||
// We show separate indicators for Grafana-managed and Cloud rules
|
||||
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={hasAlertRulesCreated && <CreateAlertButton />}>
|
||||
<RuleListErrors />
|
||||
<RulesFilter onFilterCleared={onFilterCleared} />
|
||||
{hasAlertRulesCreated && (
|
||||
<>
|
||||
<div className={styles.break} />
|
||||
<div className={styles.buttonsContainer}>
|
||||
<div className={styles.statsContainer}>
|
||||
{view === 'groups' && hasActiveFilters && (
|
||||
<Button
|
||||
className={styles.expandAllButton}
|
||||
icon={expandAll ? 'angle-double-up' : 'angle-double-down'}
|
||||
variant="secondary"
|
||||
onClick={() => setExpandAll(!expandAll)}
|
||||
>
|
||||
{expandAll ? 'Collapse all' : 'Expand all'}
|
||||
</Button>
|
||||
)}
|
||||
<RuleStats namespaces={filteredNamespaces} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hasNoAlertRulesCreatedYet && <NoRulesSplash />}
|
||||
{hasAlertRulesCreated && <ViewComponent expandAll={expandAll} namespaces={filteredNamespaces} />}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
},
|
||||
{ style: 'page' }
|
||||
);
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
break: css({
|
||||
width: '100%',
|
||||
height: 0,
|
||||
marginBottom: theme.spacing(2),
|
||||
borderBottom: `solid 1px ${theme.colors.border.medium}`,
|
||||
}),
|
||||
buttonsContainer: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}),
|
||||
statsContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
expandAllButton: css({
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
||||
export default RuleList;
|
||||
|
||||
export function CreateAlertButton() {
|
||||
const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule);
|
||||
const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed;
|
||||
|
||||
const canCreateGrafanaRules = createRuleSupported && createRuleAllowed;
|
||||
|
||||
if (canCreateGrafanaRules || canCreateCloudRules) {
|
||||
return (
|
||||
<LinkButton
|
||||
href={urlUtil.renderUrl('alerting/new/alerting', { returnTo: location.pathname + location.search })}
|
||||
icon="plus"
|
||||
onClick={() => logInfo(LogMessages.alertRuleFromScratch)}
|
||||
>
|
||||
New alert rule
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { CSSProperties, ReactNode } from 'react';
|
||||
import tinycolor2 from 'tinycolor2';
|
||||
|
||||
import { GrafanaTheme2, IconName } from '@grafana/data';
|
||||
import { Icon, Stack, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export type LabelSize = 'md' | 'sm';
|
||||
export type LabelSize = 'md' | 'sm' | 'xs';
|
||||
|
||||
interface Props {
|
||||
icon?: IconName;
|
||||
@@ -58,8 +58,18 @@ const getStyles = (theme: GrafanaTheme2, color?: string, size?: string) => {
|
||||
? tinycolor2.mostReadable(backgroundColor, ['#000', '#fff']).toString()
|
||||
: theme.colors.text.primary;
|
||||
|
||||
const padding =
|
||||
size === 'md' ? `${theme.spacing(0.33)} ${theme.spacing(1)}` : `${theme.spacing(0.2)} ${theme.spacing(0.6)}`;
|
||||
let padding: CSSProperties['padding'] = theme.spacing(0.33, 1);
|
||||
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
padding = theme.spacing(0.2, 0.6);
|
||||
break;
|
||||
case 'xs':
|
||||
padding = theme.spacing(0, 0.5);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
wrapper: css({
|
||||
|
||||
@@ -27,7 +27,7 @@ const MetaText = ({ children, icon, color = 'secondary', direction = 'row', ...r
|
||||
>
|
||||
<Text variant="bodySmall" color={color}>
|
||||
<Stack direction={direction} alignItems={alignItems} gap={gap} wrap={'wrap'}>
|
||||
{icon && <Icon size="sm" name={icon} />}
|
||||
{icon && <Icon size="xs" name={icon} />}
|
||||
{children}
|
||||
</Stack>
|
||||
</Text>
|
||||
|
||||
@@ -4,15 +4,7 @@ import { Button, ButtonProps, Icon, Stack } from '@grafana/ui';
|
||||
|
||||
const MoreButton = forwardRef(function MoreButton(props: ButtonProps, ref: Ref<HTMLButtonElement>) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
aria-label="more-actions"
|
||||
data-testid="more-actions"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<Button variant="secondary" size="sm" type="button" aria-label="More" ref={ref} {...props}>
|
||||
<Stack direction="row" alignItems="center" gap={0}>
|
||||
More <Icon name="angle-down" />
|
||||
</Stack>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
import { Text } from '@grafana/ui';
|
||||
|
||||
interface Props {}
|
||||
interface Props {
|
||||
children: NonNullable<ReactNode>;
|
||||
}
|
||||
|
||||
const Strong = ({ children }: React.PropsWithChildren<Props>) => {
|
||||
const theme = useTheme2();
|
||||
return <strong style={{ color: theme.colors.text.primary }}>{children}</strong>;
|
||||
const Strong = ({ children }: Props) => {
|
||||
return <Text weight="bold">{children}</Text>;
|
||||
};
|
||||
|
||||
export { Strong };
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('contact points', () => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const moreActionsButtons = screen.getAllByRole('button', { name: 'more-actions' });
|
||||
const moreActionsButtons = screen.getAllByRole('button', { name: /More/ });
|
||||
expect(moreActionsButtons).toHaveLength(5);
|
||||
moreActionsButtons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled();
|
||||
@@ -158,7 +158,7 @@ describe('contact points', () => {
|
||||
expect(viewButtons).toHaveLength(5);
|
||||
|
||||
// delete should be disabled in the "more" actions
|
||||
const moreButtons = screen.queryAllByRole('button', { name: 'more-actions' });
|
||||
const moreButtons = screen.queryAllByRole('button', { name: /More/ });
|
||||
expect(moreButtons).toHaveLength(5);
|
||||
|
||||
// check if all of the delete buttons are disabled
|
||||
@@ -183,7 +183,7 @@ describe('contact points', () => {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const moreActions = screen.getByRole('button', { name: 'more-actions' });
|
||||
const moreActions = screen.getByRole('button', { name: /More/ });
|
||||
await userEvent.click(moreActions);
|
||||
|
||||
const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
|
||||
@@ -197,7 +197,7 @@ describe('contact points', () => {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const moreActions = screen.getByRole('button', { name: 'more-actions' });
|
||||
const moreActions = screen.getByRole('button', { name: /More/ });
|
||||
expect(moreActions).not.toBeDisabled();
|
||||
|
||||
const editAction = screen.getByTestId('edit-action');
|
||||
@@ -217,7 +217,7 @@ describe('contact points', () => {
|
||||
const viewAction = screen.getByRole('link', { name: /view/i });
|
||||
expect(viewAction).toBeInTheDocument();
|
||||
|
||||
const moreActions = screen.getByRole('button', { name: 'more-actions' });
|
||||
const moreActions = screen.getByRole('button', { name: /More/ });
|
||||
expect(moreActions).not.toBeDisabled();
|
||||
await userEvent.click(moreActions);
|
||||
|
||||
@@ -241,7 +241,7 @@ describe('contact points', () => {
|
||||
|
||||
expect(screen.getByRole('link', { name: 'is used by 1 notification policy' })).toBeInTheDocument();
|
||||
|
||||
const moreActions = screen.getByRole('button', { name: 'more-actions' });
|
||||
const moreActions = screen.getByRole('button', { name: /More/ });
|
||||
await userEvent.click(moreActions);
|
||||
|
||||
const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
|
||||
@@ -262,7 +262,7 @@ describe('contact points', () => {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const moreActions = screen.getByRole('button', { name: 'more-actions' });
|
||||
const moreActions = screen.getByRole('button', { name: /More/ });
|
||||
await userEvent.click(moreActions);
|
||||
|
||||
const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
|
||||
@@ -334,7 +334,7 @@ describe('contact points', () => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const moreActionsButtons = screen.getAllByRole('button', { name: 'more-actions' });
|
||||
const moreActionsButtons = screen.getAllByRole('button', { name: /More/ });
|
||||
expect(moreActionsButtons).toHaveLength(2);
|
||||
moreActionsButtons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled();
|
||||
|
||||
@@ -861,7 +861,10 @@ const routePropertyToLabel = (key: keyof InheritableProperties | string): string
|
||||
}
|
||||
};
|
||||
|
||||
const routePropertyToValue = (key: keyof InheritableProperties | string, value: string | string[]): React.ReactNode => {
|
||||
const routePropertyToValue = (
|
||||
key: keyof InheritableProperties | string,
|
||||
value: string | string[]
|
||||
): NonNullable<ReactNode> => {
|
||||
const isNotGrouping = key === 'group_by' && Array.isArray(value) && value[0] === '...';
|
||||
const isSingleGroup = key === 'group_by' && Array.isArray(value) && value.length === 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { isEmpty } from 'lodash';
|
||||
import pluralize from 'pluralize';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Stack, Text, TextLink, Dropdown, Button, Menu, Alert } from '@grafana/ui';
|
||||
import { CombinedRule, RuleHealth } from 'app/types/unified-alerting';
|
||||
import { Labels, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { logError } from '../../Analytics';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { labelsSize } from '../../utils/labels';
|
||||
import { createContactPointLink } from '../../utils/misc';
|
||||
import { MetaText } from '../MetaText';
|
||||
import MoreButton from '../MoreButton';
|
||||
import { Spacer } from '../Spacer';
|
||||
|
||||
import { RuleListIcon } from './RuleListIcon';
|
||||
import { calculateNextEvaluationEstimate } from './util';
|
||||
|
||||
interface AlertRuleListItemProps {
|
||||
name: string;
|
||||
href: string;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
state?: PromAlertingRuleState;
|
||||
isPaused?: boolean;
|
||||
health?: RuleHealth;
|
||||
isProvisioned?: boolean;
|
||||
lastEvaluation?: string;
|
||||
evaluationInterval?: string;
|
||||
evaluationDuration?: number;
|
||||
labels?: Labels;
|
||||
instancesCount?: number;
|
||||
// used for alert rules that use simplified routing
|
||||
contactPoint?: string;
|
||||
}
|
||||
|
||||
export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
|
||||
const {
|
||||
name,
|
||||
summary,
|
||||
state,
|
||||
health,
|
||||
error,
|
||||
href,
|
||||
isProvisioned,
|
||||
lastEvaluation,
|
||||
evaluationInterval,
|
||||
isPaused = false,
|
||||
instancesCount = 0,
|
||||
contactPoint,
|
||||
labels,
|
||||
} = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<li className={styles.alertListItemContainer} role="treeitem" aria-selected="false">
|
||||
<Stack direction="row" alignItems="start" gap={1} wrap="nowrap">
|
||||
<RuleListIcon state={state} health={health} isPaused={isPaused} />
|
||||
<Stack direction="column" gap={0.5} flex="1">
|
||||
<div>
|
||||
<Stack direction="column" gap={0}>
|
||||
<Stack direction="row" alignItems="start">
|
||||
<TextLink href={href} inline={false}>
|
||||
{name}
|
||||
</TextLink>
|
||||
{/* let's not show labels for now, but maybe users would be interested later? Or maybe show them only in the list view? */}
|
||||
{/* {labels && <AlertLabels labels={labels} size="xs" />} */}
|
||||
</Stack>
|
||||
<Summary content={summary} error={error} />
|
||||
</Stack>
|
||||
</div>
|
||||
<div>
|
||||
<Stack direction="row" gap={1}>
|
||||
{/* show evaluation-related metadata if the rule isn't paused – paused rules don't have instances and shouldn't show evaluation timestamps */}
|
||||
{!isPaused && (
|
||||
<>
|
||||
<EvaluationMetadata
|
||||
lastEvaluation={lastEvaluation}
|
||||
evaluationInterval={evaluationInterval}
|
||||
health={health}
|
||||
state={state}
|
||||
error={error}
|
||||
/>
|
||||
<MetaText icon="layers-alt">
|
||||
<TextLink href={href + '?tab=instances'} variant="bodySmall" color="primary" inline={false}>
|
||||
{pluralize('instance', instancesCount, true)}
|
||||
</TextLink>
|
||||
</MetaText>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* show label count */}
|
||||
{!isEmpty(labels) && (
|
||||
<MetaText icon="tag-alt">
|
||||
<TextLink href={href} variant="bodySmall" color="primary" inline={false}>
|
||||
{pluralize('label', labelsSize(labels), true)}
|
||||
</TextLink>
|
||||
</MetaText>
|
||||
)}
|
||||
|
||||
{/* show if the alert rule is using direct contact point or notification policy routing, not for paused rules or recording rules */}
|
||||
{contactPoint && !isPaused && (
|
||||
<MetaText icon="at">
|
||||
Delivered to{' '}
|
||||
<TextLink
|
||||
href={createContactPointLink(contactPoint, GRAFANA_RULES_SOURCE_NAME)}
|
||||
variant="bodySmall"
|
||||
color="primary"
|
||||
inline={false}
|
||||
>
|
||||
{contactPoint}
|
||||
</TextLink>
|
||||
</MetaText>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" alignItems="center" gap={1} wrap="nowrap">
|
||||
<Button variant="secondary" size="sm" icon="pen" type="button" disabled={isProvisioned}>
|
||||
Edit
|
||||
</Button>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item label="Silence" icon="bell-slash" />
|
||||
<Menu.Divider />
|
||||
<Menu.Item label="Export" disabled={isProvisioned} icon="download-alt" />
|
||||
<Menu.Item label="Delete" disabled={isProvisioned} icon="trash-alt" destructive />
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<MoreButton />
|
||||
</Dropdown>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
interface SummaryProps {
|
||||
content?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function Summary({ content, error }: SummaryProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<Text variant="bodySmall" color="error" weight="light" truncate>
|
||||
{error}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (content) {
|
||||
return (
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// @TODO use Pick<> or Omit<> here
|
||||
interface RecordingRuleListItemProps {
|
||||
name: string;
|
||||
href: string;
|
||||
error?: string;
|
||||
health?: RuleHealth;
|
||||
recording?: boolean;
|
||||
state?: PromAlertingRuleState;
|
||||
labels?: Labels;
|
||||
isProvisioned?: boolean;
|
||||
lastEvaluation?: string;
|
||||
evaluationInterval?: string;
|
||||
evaluationDuration?: number;
|
||||
}
|
||||
|
||||
// @TODO split in to smaller re-usable bits
|
||||
export const RecordingRuleListItem = ({
|
||||
name,
|
||||
error,
|
||||
state,
|
||||
health,
|
||||
isProvisioned,
|
||||
href,
|
||||
labels,
|
||||
lastEvaluation,
|
||||
evaluationInterval,
|
||||
}: RecordingRuleListItemProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<li className={styles.alertListItemContainer} role="treeitem" aria-selected="false">
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
<Stack direction="row" alignItems="start" gap={1} flex="1">
|
||||
<RuleListIcon health={health} recording />
|
||||
<Stack direction="column" gap={0.5}>
|
||||
<Stack direction="column" gap={0}>
|
||||
<Stack direction="row" alignItems="start">
|
||||
<TextLink href={href} variant="body" weight="bold" inline={false}>
|
||||
{name}
|
||||
</TextLink>
|
||||
{/* {labels && <AlertLabels labels={labels} size="xs" />} */}
|
||||
</Stack>
|
||||
<Summary error={error} />
|
||||
</Stack>
|
||||
<div>
|
||||
<Stack direction="row" gap={1}>
|
||||
<EvaluationMetadata
|
||||
lastEvaluation={lastEvaluation}
|
||||
evaluationInterval={evaluationInterval}
|
||||
health={health}
|
||||
state={state}
|
||||
error={error}
|
||||
/>
|
||||
{!isEmpty(labels) && (
|
||||
<MetaText icon="tag-alt">
|
||||
<TextLink variant="bodySmall" color="primary" href={href} inline={false}>
|
||||
{pluralize('label', labelsSize(labels), true)}
|
||||
</TextLink>
|
||||
</MetaText>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
<Spacer />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="pen"
|
||||
type="button"
|
||||
disabled={isProvisioned}
|
||||
data-testid="edit-rule-action"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item label="Export" disabled={isProvisioned} icon="download-alt" />
|
||||
<Menu.Item label="Delete" disabled={isProvisioned} icon="trash-alt" destructive />
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<MoreButton />
|
||||
</Dropdown>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
interface EvaluationMetadataProps {
|
||||
lastEvaluation?: string;
|
||||
evaluationInterval?: string;
|
||||
state?: PromAlertingRuleState;
|
||||
health?: RuleHealth;
|
||||
error?: string; // if health is "error" this should have error details for us
|
||||
}
|
||||
|
||||
function EvaluationMetadata({ lastEvaluation, evaluationInterval, state }: EvaluationMetadataProps) {
|
||||
const nextEvaluation = calculateNextEvaluationEstimate(lastEvaluation, evaluationInterval);
|
||||
|
||||
// @TODO support firing for calculation
|
||||
if (state === PromAlertingRuleState.Firing && nextEvaluation) {
|
||||
const firingFor = '2m 34s';
|
||||
|
||||
return (
|
||||
<MetaText icon="clock-nine">
|
||||
Firing for <Text color="primary">{firingFor}</Text>
|
||||
{nextEvaluation && <>· next evaluation in {nextEvaluation.humanized}</>}
|
||||
</MetaText>
|
||||
);
|
||||
}
|
||||
|
||||
// for recording rules and normal or pending state alert rules we just show when we evaluated last and how long that took
|
||||
if (nextEvaluation) {
|
||||
return <MetaText icon="clock-nine">Next evaluation {nextEvaluation.humanized}</MetaText>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface UnknownRuleListItemProps {
|
||||
rule: CombinedRule;
|
||||
}
|
||||
|
||||
export const UnknownRuleListItem = ({ rule }: UnknownRuleListItemProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const ruleContext = { namespace: rule.namespace.name, group: rule.group.name, name: rule.name };
|
||||
logError(new Error('unknown rule type'), ruleContext);
|
||||
|
||||
return (
|
||||
<Alert title={'Unknown rule type'} className={styles.resetMargin}>
|
||||
<details>
|
||||
<summary>Rule definition</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(rule.rulerRule, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
alertListItemContainer: css({
|
||||
position: 'relative',
|
||||
listStyle: 'none',
|
||||
background: theme.colors.background.primary,
|
||||
|
||||
borderBottom: `solid 1px ${theme.colors.border.weak}`,
|
||||
padding: theme.spacing(1, 1, 1, 1.5),
|
||||
}),
|
||||
resetMargin: css({
|
||||
margin: 0,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Badge, Button, Dropdown, Menu, Stack, Text, Icon } from '@grafana/ui';
|
||||
|
||||
import { MetaText } from '../MetaText';
|
||||
import MoreButton from '../MoreButton';
|
||||
import { Spacer } from '../Spacer';
|
||||
|
||||
interface EvaluationGroupProps extends PropsWithChildren {
|
||||
name: string;
|
||||
interval?: string;
|
||||
provenance?: string;
|
||||
isOpen?: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const EvaluationGroup = ({ name, provenance, interval, onToggle, isOpen = false, children }: EvaluationGroupProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const isProvisioned = Boolean(provenance);
|
||||
|
||||
return (
|
||||
<Stack direction="column" role="treeitem" aria-expanded={isOpen} aria-selected="false" gap={0}>
|
||||
<div className={styles.headerWrapper}>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
<button className={cx(styles.hiddenButton, styles.largerClickTarget)} type="button" onClick={onToggle}>
|
||||
<Stack alignItems="center" gap={0.5}>
|
||||
<Icon name={isOpen ? 'angle-down' : 'angle-right'} />
|
||||
<Text truncate variant="body">
|
||||
{name}
|
||||
</Text>
|
||||
</Stack>
|
||||
</button>
|
||||
{isProvisioned && <Badge color="purple" text="Provisioned" />}
|
||||
<Spacer />
|
||||
{interval && <MetaText icon="history">{interval}</MetaText>}
|
||||
<Button size="sm" icon="pen" variant="secondary" disabled={isProvisioned} data-testid="edit-group-action">
|
||||
Edit
|
||||
</Button>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item label="Re-order rules" icon="flip" disabled={isProvisioned} />
|
||||
<Menu.Divider />
|
||||
<Menu.Item label="Export" icon="download-alt" />
|
||||
<Menu.Item label="Delete" icon="trash-alt" destructive disabled={isProvisioned} />
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<MoreButton size="sm" />
|
||||
</Dropdown>
|
||||
</Stack>
|
||||
</div>
|
||||
{isOpen && <div role="group">{children}</div>}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
headerWrapper: css({
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(1)}`,
|
||||
|
||||
background: theme.colors.background.secondary,
|
||||
|
||||
border: 'none',
|
||||
borderBottom: `solid 1px ${theme.colors.border.weak}`,
|
||||
borderTopLeftRadius: theme.shape.radius.default,
|
||||
borderTopRightRadius: theme.shape.radius.default,
|
||||
}),
|
||||
hiddenButton: css({
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
}),
|
||||
largerClickTarget: css({
|
||||
padding: theme.spacing(0.5),
|
||||
margin: `-${theme.spacing(0.5)}`,
|
||||
}),
|
||||
});
|
||||
|
||||
export default EvaluationGroup;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { size } from 'lodash';
|
||||
import React from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { CombinedRuleGroup, RulesSource } from 'app/types/unified-alerting';
|
||||
|
||||
import { createViewLink } from '../../utils/misc';
|
||||
import { hashRulerRule } from '../../utils/rule-id';
|
||||
import { isAlertingRule, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from '../../utils/rules';
|
||||
|
||||
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './AlertRuleListItem';
|
||||
import EvaluationGroup from './EvaluationGroup';
|
||||
|
||||
export interface EvaluationGroupWithRulesProps {
|
||||
group: CombinedRuleGroup;
|
||||
rulesSource: RulesSource;
|
||||
}
|
||||
|
||||
export const EvaluationGroupWithRules = ({ group, rulesSource }: EvaluationGroupWithRulesProps) => {
|
||||
const [open, toggleOpen] = useToggle(false);
|
||||
|
||||
return (
|
||||
<EvaluationGroup name={group.name} interval={group.interval} isOpen={open} onToggle={toggleOpen}>
|
||||
{group.rules.map((rule, index) => {
|
||||
const { rulerRule, promRule, annotations } = rule;
|
||||
|
||||
// don't render anything if we don't have the rule definition yet
|
||||
if (!rulerRule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// keep in mind that we may not have a promRule for the ruler rule – this happens when the target
|
||||
// rule source is eventually consistent - it may know about the rule definition but not its state
|
||||
const isAlertingPromRule = isAlertingRule(promRule);
|
||||
|
||||
if (isAlertingRulerRule(rulerRule)) {
|
||||
return (
|
||||
<AlertRuleListItem
|
||||
key={hashRulerRule(rulerRule)}
|
||||
state={isAlertingPromRule ? promRule?.state : undefined}
|
||||
health={promRule?.health}
|
||||
error={promRule?.lastError}
|
||||
name={rulerRule.alert}
|
||||
labels={rulerRule.labels}
|
||||
lastEvaluation={promRule?.lastEvaluation}
|
||||
evaluationDuration={promRule?.evaluationTime}
|
||||
evaluationInterval={group.interval}
|
||||
instancesCount={isAlertingPromRule ? size(promRule.alerts) : undefined}
|
||||
href={createViewLink(rulesSource, rule)}
|
||||
summary={annotations?.['summary']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRecordingRulerRule(rulerRule)) {
|
||||
return (
|
||||
<RecordingRuleListItem
|
||||
key={hashRulerRule(rulerRule)}
|
||||
name={rulerRule.record}
|
||||
health={promRule?.health}
|
||||
error={promRule?.lastError}
|
||||
lastEvaluation={promRule?.lastEvaluation}
|
||||
evaluationDuration={promRule?.evaluationTime}
|
||||
evaluationInterval={group.interval}
|
||||
labels={rulerRule.labels}
|
||||
href={createViewLink(rulesSource, rule)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGrafanaRulerRule(rulerRule)) {
|
||||
const contactPoint = rulerRule.grafana_alert.notification_settings?.receiver;
|
||||
|
||||
return (
|
||||
<AlertRuleListItem
|
||||
key={rulerRule.grafana_alert.uid}
|
||||
name={rulerRule.grafana_alert.title}
|
||||
state={isAlertingPromRule ? promRule?.state : undefined}
|
||||
health={promRule?.health}
|
||||
error={promRule?.lastError}
|
||||
labels={rulerRule.labels}
|
||||
isPaused={rulerRule.grafana_alert.is_paused}
|
||||
lastEvaluation={promRule?.lastEvaluation}
|
||||
evaluationDuration={promRule?.evaluationTime}
|
||||
evaluationInterval={group.interval}
|
||||
instancesCount={isAlertingPromRule ? size(promRule.alerts) : undefined}
|
||||
href={createViewLink(rulesSource, rule)}
|
||||
summary={rule.annotations?.['summary']}
|
||||
isProvisioned={Boolean(rulerRule.grafana_alert.provenance)}
|
||||
contactPoint={contactPoint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// if we get here it means we don't really know how to render this rule
|
||||
return <UnknownRuleListItem key={hashRulerRule(rulerRule)} rule={rule} />;
|
||||
})}
|
||||
</EvaluationGroup>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Stack, TextLink, Icon } from '@grafana/ui';
|
||||
import { PromApplication, RulesSourceApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { WithReturnButton } from '../WithReturnButton';
|
||||
|
||||
interface NamespaceProps extends PropsWithChildren {
|
||||
name: string;
|
||||
href?: string;
|
||||
application?: RulesSourceApplication;
|
||||
}
|
||||
|
||||
// @TODO add export rules for namespace back in
|
||||
const Namespace = ({ children, name, href, application }: NamespaceProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<li className={styles.namespaceWrapper} role="treeitem" aria-selected="false">
|
||||
<div className={styles.namespaceTitle}>
|
||||
<Stack alignItems={'center'} gap={1}>
|
||||
<NamespaceIcon application={application} />
|
||||
{href ? (
|
||||
<WithReturnButton
|
||||
title="Alert rules"
|
||||
component={
|
||||
<TextLink href={href} inline={false}>
|
||||
{name}
|
||||
</TextLink>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
name
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
{children && (
|
||||
<ul role="group" className={styles.groupItemsWrapper}>
|
||||
{children}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
interface NamespaceIconProps {
|
||||
application?: RulesSourceApplication;
|
||||
}
|
||||
|
||||
const NamespaceIcon = ({ application }: NamespaceIconProps) => {
|
||||
switch (application) {
|
||||
case PromApplication.Prometheus:
|
||||
return (
|
||||
<img
|
||||
width={16}
|
||||
height={16}
|
||||
src="public/app/plugins/datasource/prometheus/img/prometheus_logo.svg"
|
||||
alt="Prometheus"
|
||||
/>
|
||||
);
|
||||
case PromApplication.Mimir:
|
||||
return (
|
||||
<img width={16} height={16} src="public/app/plugins/datasource/prometheus/img/mimir_logo.svg" alt="Mimir" />
|
||||
);
|
||||
case 'loki':
|
||||
return <img width={16} height={16} src="public/app/plugins/datasource/loki/img/loki_icon.svg" alt="Loki" />;
|
||||
case 'grafana':
|
||||
default:
|
||||
return <Icon name="folder" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
groupItemsWrapper: css({
|
||||
position: 'relative',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
border: `solid 1px ${theme.colors.border.weak}`,
|
||||
borderBottom: 'none',
|
||||
|
||||
marginLeft: theme.spacing(3),
|
||||
|
||||
'&:before': {
|
||||
content: "''",
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
|
||||
borderLeft: `solid 1px ${theme.colors.border.weak}`,
|
||||
|
||||
marginTop: 0,
|
||||
marginLeft: `-${theme.spacing(2.5)}`,
|
||||
},
|
||||
}),
|
||||
namespaceWrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
namespaceTitle: css({
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
|
||||
|
||||
background: theme.colors.background.secondary,
|
||||
|
||||
border: `solid 1px ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
});
|
||||
|
||||
export default Namespace;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAsyncFn, useInterval } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
||||
import { Button, LinkButton, useStyles2, withErrorBoundary } from '@grafana/ui';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { useDispatch } from 'app/types';
|
||||
|
||||
import { CombinedRuleNamespace } from '../../../../../types/unified-alerting';
|
||||
import { LogMessages, logInfo, trackRuleListNavigation } from '../../Analytics';
|
||||
import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities';
|
||||
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
|
||||
import { useFilteredRules, useRulesFilter } from '../../hooks/useFilteredRules';
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { fetchAllPromAndRulerRulesAction } from '../../state/actions';
|
||||
import { RULE_LIST_POLL_INTERVAL_MS } from '../../utils/constants';
|
||||
import { getAllRulesSourceNames } from '../../utils/datasource';
|
||||
import { AlertingPageWrapper } from '../AlertingPageWrapper';
|
||||
import { NoRulesSplash } from '../rules/NoRulesCTA';
|
||||
import { INSTANCES_DISPLAY_LIMIT } from '../rules/RuleDetails';
|
||||
import { RuleListErrors } from '../rules/RuleListErrors';
|
||||
import { RuleListGroupView } from '../rules/RuleListGroupView';
|
||||
import { RuleListStateView } from '../rules/RuleListStateView';
|
||||
import { RuleStats } from '../rules/RuleStats';
|
||||
import RulesFilter from '../rules/RulesFilter';
|
||||
|
||||
const VIEWS = {
|
||||
groups: RuleListGroupView,
|
||||
state: RuleListStateView,
|
||||
};
|
||||
|
||||
// make sure we ask for 1 more so we show the "show x more" button
|
||||
const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1;
|
||||
|
||||
const RuleList = withErrorBoundary(
|
||||
() => {
|
||||
const dispatch = useDispatch();
|
||||
const styles = useStyles2(getStyles);
|
||||
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
|
||||
const onFilterCleared = useCallback(() => setExpandAll(false), []);
|
||||
|
||||
const [queryParams] = useQueryParams();
|
||||
const { filterState, hasActiveFilters } = useRulesFilter();
|
||||
|
||||
const queryParamView = queryParams['view'] as keyof typeof VIEWS;
|
||||
const view = VIEWS[queryParamView] ? queryParamView : 'groups';
|
||||
|
||||
const ViewComponent = VIEWS[view];
|
||||
|
||||
const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules);
|
||||
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
|
||||
const loading = rulesDataSourceNames.some(
|
||||
(name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading
|
||||
);
|
||||
|
||||
const promRequests = Object.entries(promRuleRequests);
|
||||
const rulerRequests = Object.entries(rulerRuleRequests);
|
||||
|
||||
const allPromLoaded = promRequests.every(
|
||||
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
|
||||
);
|
||||
const allRulerLoaded = rulerRequests.every(
|
||||
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
|
||||
);
|
||||
|
||||
const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0);
|
||||
|
||||
const allRulerEmpty = rulerRequests.every(([_, state]) => {
|
||||
const rulerRules = Object.entries(state?.result ?? {});
|
||||
const noRules = rulerRules.every(([_, result]) => result?.length === 0);
|
||||
return noRules && state.dispatched;
|
||||
});
|
||||
|
||||
const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS;
|
||||
// Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED
|
||||
const [_, fetchRules] = useAsyncFn(async () => {
|
||||
if (!loading) {
|
||||
await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
|
||||
}
|
||||
}, [loading, limitAlerts, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
trackRuleListNavigation().catch(() => {});
|
||||
}, []);
|
||||
|
||||
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
|
||||
useEffect(() => {
|
||||
dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
|
||||
}, [dispatch, limitAlerts]);
|
||||
useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS);
|
||||
|
||||
// Show splash only when we loaded all of the data sources and none of them has alerts
|
||||
const hasNoAlertRulesCreatedYet =
|
||||
allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded;
|
||||
const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet;
|
||||
|
||||
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
|
||||
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
|
||||
return (
|
||||
// We don't want to show the Loading... indicator for the whole page.
|
||||
// We show separate indicators for Grafana-managed and Cloud rules
|
||||
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={hasAlertRulesCreated && <CreateAlertButton />}>
|
||||
<RuleListErrors />
|
||||
<RulesFilter onFilterCleared={onFilterCleared} />
|
||||
{hasAlertRulesCreated && (
|
||||
<>
|
||||
<div className={styles.break} />
|
||||
<div className={styles.buttonsContainer}>
|
||||
<div className={styles.statsContainer}>
|
||||
{view === 'groups' && hasActiveFilters && (
|
||||
<Button
|
||||
className={styles.expandAllButton}
|
||||
icon={expandAll ? 'angle-double-up' : 'angle-double-down'}
|
||||
variant="secondary"
|
||||
onClick={() => setExpandAll(!expandAll)}
|
||||
>
|
||||
{expandAll ? 'Collapse all' : 'Expand all'}
|
||||
</Button>
|
||||
)}
|
||||
<RuleStats namespaces={filteredNamespaces} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hasNoAlertRulesCreatedYet && <NoRulesSplash />}
|
||||
{hasAlertRulesCreated && <ViewComponent expandAll={expandAll} namespaces={filteredNamespaces} />}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
},
|
||||
{ style: 'page' }
|
||||
);
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
break: css({
|
||||
width: '100%',
|
||||
height: 0,
|
||||
marginBottom: theme.spacing(2),
|
||||
borderBottom: `solid 1px ${theme.colors.border.medium}`,
|
||||
}),
|
||||
buttonsContainer: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}),
|
||||
statsContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
expandAllButton: css({
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
||||
export default RuleList;
|
||||
|
||||
export function CreateAlertButton() {
|
||||
const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule);
|
||||
const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed;
|
||||
|
||||
const canCreateGrafanaRules = createRuleSupported && createRuleAllowed;
|
||||
|
||||
if (canCreateGrafanaRules || canCreateCloudRules) {
|
||||
return (
|
||||
<LinkButton
|
||||
href={urlUtil.renderUrl('alerting/new/alerting', { returnTo: location.pathname + location.search })}
|
||||
icon="plus"
|
||||
onClick={() => logInfo(LogMessages.alertRuleFromScratch)}
|
||||
>
|
||||
New alert rule
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAsyncFn, useInterval, useMeasure } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
||||
import { Button, LinkButton, LoadingBar, useStyles2, withErrorBoundary } from '@grafana/ui';
|
||||
import { useDispatch } from 'app/types';
|
||||
|
||||
import { CombinedRuleNamespace } from '../../../../../types/unified-alerting';
|
||||
import { LogMessages, logInfo, trackRuleListNavigation } from '../../Analytics';
|
||||
import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities';
|
||||
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
|
||||
import { useFilteredRules, useRulesFilter } from '../../hooks/useFilteredRules';
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { fetchAllPromAndRulerRulesAction } from '../../state/actions';
|
||||
import { RULE_LIST_POLL_INTERVAL_MS } from '../../utils/constants';
|
||||
import { getAllRulesSourceNames, getRulesSourceUniqueKey, getApplicationFromRulesSource } from '../../utils/datasource';
|
||||
import { makeFolderAlertsLink } from '../../utils/misc';
|
||||
import { AlertingPageWrapper } from '../AlertingPageWrapper';
|
||||
import { NoRulesSplash } from '../rules/NoRulesCTA';
|
||||
import { INSTANCES_DISPLAY_LIMIT } from '../rules/RuleDetails';
|
||||
import { RuleListErrors } from '../rules/RuleListErrors';
|
||||
import { RuleStats } from '../rules/RuleStats';
|
||||
import RulesFilter from '../rules/RulesFilter';
|
||||
|
||||
import { EvaluationGroupWithRules } from './EvaluationGroupWithRules';
|
||||
import Namespace from './Namespace';
|
||||
|
||||
// make sure we ask for 1 more so we show the "show x more" button
|
||||
const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1;
|
||||
|
||||
const RuleList = withErrorBoundary(
|
||||
() => {
|
||||
const dispatch = useDispatch();
|
||||
const styles = useStyles2(getStyles);
|
||||
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
|
||||
const onFilterCleared = useCallback(() => setExpandAll(false), []);
|
||||
|
||||
const { filterState, hasActiveFilters } = useRulesFilter();
|
||||
|
||||
const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules);
|
||||
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
|
||||
const loading = rulesDataSourceNames.some(
|
||||
(name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading
|
||||
);
|
||||
|
||||
const promRequests = Object.entries(promRuleRequests);
|
||||
const rulerRequests = Object.entries(rulerRuleRequests);
|
||||
|
||||
const allPromLoaded = promRequests.every(
|
||||
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
|
||||
);
|
||||
const allRulerLoaded = rulerRequests.every(
|
||||
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
|
||||
);
|
||||
|
||||
const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0);
|
||||
|
||||
const allRulerEmpty = rulerRequests.every(([_, state]) => {
|
||||
const rulerRules = Object.entries(state?.result ?? {});
|
||||
const noRules = rulerRules.every(([_, result]) => result?.length === 0);
|
||||
return noRules && state.dispatched;
|
||||
});
|
||||
|
||||
const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS;
|
||||
// Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED
|
||||
const [_, fetchRules] = useAsyncFn(async () => {
|
||||
if (!loading) {
|
||||
await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
|
||||
}
|
||||
}, [loading, limitAlerts, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
trackRuleListNavigation().catch(() => {});
|
||||
}, []);
|
||||
|
||||
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
|
||||
useEffect(() => {
|
||||
dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
|
||||
}, [dispatch, limitAlerts]);
|
||||
useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS);
|
||||
|
||||
// Show splash only when we loaded all of the data sources and none of them has alerts
|
||||
const hasNoAlertRulesCreatedYet =
|
||||
allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded;
|
||||
const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet;
|
||||
|
||||
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
|
||||
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
|
||||
|
||||
const sortedNamespaces = filteredNamespaces.sort((a: CombinedRuleNamespace, b: CombinedRuleNamespace) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
|
||||
return (
|
||||
// We don't want to show the Loading... indicator for the whole page.
|
||||
// We show separate indicators for Grafana-managed and Cloud rules
|
||||
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={hasAlertRulesCreated && <CreateAlertButton />}>
|
||||
<RuleListErrors />
|
||||
<RulesFilter onFilterCleared={onFilterCleared} />
|
||||
{hasAlertRulesCreated && (
|
||||
<>
|
||||
<div className={styles.break} />
|
||||
<div className={styles.buttonsContainer}>
|
||||
<div className={styles.statsContainer}>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
className={styles.expandAllButton}
|
||||
icon={expandAll ? 'angle-double-up' : 'angle-double-down'}
|
||||
variant="secondary"
|
||||
onClick={() => setExpandAll(!expandAll)}
|
||||
>
|
||||
{expandAll ? 'Collapse all' : 'Expand all'}
|
||||
</Button>
|
||||
)}
|
||||
<RuleStats namespaces={filteredNamespaces} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hasNoAlertRulesCreatedYet && <NoRulesSplash />}
|
||||
{hasAlertRulesCreated && (
|
||||
<>
|
||||
<LoadingIndicator visible={loading} />
|
||||
<ul className={styles.rulesTree} role="tree" aria-label="List of alert rules">
|
||||
{sortedNamespaces.map((namespace) => {
|
||||
const { rulesSource, uid } = namespace;
|
||||
|
||||
const application = getApplicationFromRulesSource(rulesSource);
|
||||
const href = application === 'grafana' && uid ? makeFolderAlertsLink(uid, namespace.name) : undefined;
|
||||
|
||||
return (
|
||||
<Namespace
|
||||
key={getRulesSourceUniqueKey(rulesSource) + namespace.name}
|
||||
href={href}
|
||||
name={namespace.name}
|
||||
application={application}
|
||||
>
|
||||
{namespace.groups
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((group) => (
|
||||
<EvaluationGroupWithRules key={group.name} group={group} rulesSource={rulesSource} />
|
||||
))}
|
||||
</Namespace>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
},
|
||||
{ style: 'page' }
|
||||
);
|
||||
|
||||
const LoadingIndicator = ({ visible = false }) => {
|
||||
const [measureRef, { width }] = useMeasure<HTMLDivElement>();
|
||||
return <div ref={measureRef}>{visible && <LoadingBar width={width} />}</div>;
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
rulesTree: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
break: css({
|
||||
width: '100%',
|
||||
height: 0,
|
||||
marginBottom: theme.spacing(2),
|
||||
borderBottom: `solid 1px ${theme.colors.border.medium}`,
|
||||
}),
|
||||
buttonsContainer: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}),
|
||||
statsContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
expandAllButton: css({
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
||||
export default RuleList;
|
||||
|
||||
export function CreateAlertButton() {
|
||||
const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule);
|
||||
const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed;
|
||||
|
||||
const canCreateGrafanaRules = createRuleSupported && createRuleAllowed;
|
||||
|
||||
if (canCreateGrafanaRules || canCreateCloudRules) {
|
||||
return (
|
||||
<LinkButton
|
||||
href={urlUtil.renderUrl('alerting/new/alerting', { returnTo: location.pathname + location.search })}
|
||||
icon="plus"
|
||||
onClick={() => logInfo(LogMessages.alertRuleFromScratch)}
|
||||
>
|
||||
New alert rule
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import type { RequireAtLeastOne } from 'type-fest';
|
||||
|
||||
import { Tooltip, type IconName, Text, Icon } from '@grafana/ui';
|
||||
import type { TextProps } from '@grafana/ui/src/components/Text/Text';
|
||||
import type { RuleHealth } from 'app/types/unified-alerting';
|
||||
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { isErrorHealth } from '../rule-viewer/RuleViewer';
|
||||
|
||||
interface RuleListIconProps {
|
||||
recording?: boolean;
|
||||
state?: PromAlertingRuleState;
|
||||
health?: RuleHealth;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that the order of importance here matches the one we use in the StateBadge component for the detail view
|
||||
*/
|
||||
export function RuleListIcon({
|
||||
state,
|
||||
health,
|
||||
recording = false,
|
||||
isPaused = false,
|
||||
}: RequireAtLeastOne<RuleListIconProps>) {
|
||||
const icons: Record<PromAlertingRuleState, IconName> = {
|
||||
[PromAlertingRuleState.Inactive]: 'check-circle',
|
||||
[PromAlertingRuleState.Pending]: 'circle',
|
||||
[PromAlertingRuleState.Firing]: 'exclamation-circle',
|
||||
};
|
||||
|
||||
const color: Record<PromAlertingRuleState, 'success' | 'error' | 'warning'> = {
|
||||
[PromAlertingRuleState.Inactive]: 'success',
|
||||
[PromAlertingRuleState.Pending]: 'warning',
|
||||
[PromAlertingRuleState.Firing]: 'error',
|
||||
};
|
||||
|
||||
const stateNames: Record<PromAlertingRuleState, string> = {
|
||||
[PromAlertingRuleState.Inactive]: 'Normal',
|
||||
[PromAlertingRuleState.Pending]: 'Pending',
|
||||
[PromAlertingRuleState.Firing]: 'Firing',
|
||||
};
|
||||
|
||||
let iconName: IconName = state ? icons[state] : 'circle';
|
||||
let iconColor: TextProps['color'] = state ? color[state] : 'secondary';
|
||||
let stateName: string = state ? stateNames[state] : 'unknown';
|
||||
|
||||
if (recording) {
|
||||
iconName = 'record-audio';
|
||||
iconColor = 'success';
|
||||
stateName = 'Recording';
|
||||
}
|
||||
|
||||
if (health === 'nodata') {
|
||||
iconName = 'exclamation-triangle';
|
||||
iconColor = 'warning';
|
||||
stateName = 'Insufficient data';
|
||||
}
|
||||
|
||||
if (isErrorHealth(health)) {
|
||||
iconName = 'times-circle';
|
||||
iconColor = 'error';
|
||||
stateName = 'Failed to evaluate rule';
|
||||
}
|
||||
|
||||
if (isPaused) {
|
||||
iconName = 'pause-circle';
|
||||
iconColor = 'warning';
|
||||
stateName = 'Paused';
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={stateName} placement="right">
|
||||
<div>
|
||||
<Text color={iconColor}>
|
||||
<Icon name={iconName} size="lg" />
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { calculateNextEvaluationEstimate } from './util';
|
||||
|
||||
describe('calculateNextEvaluationEstimate', () => {
|
||||
const MOCK_NOW = new Date('2024-05-23T12:00:00');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ now: MOCK_NOW });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('with timestamp of last evaluation', () => {
|
||||
// a minute ago
|
||||
const lastEvaluation = new Date(MOCK_NOW.valueOf() - 60 * 1000).toISOString();
|
||||
const interval = '5m';
|
||||
|
||||
const output = calculateNextEvaluationEstimate(lastEvaluation, interval);
|
||||
expect(output).toStrictEqual({
|
||||
humanized: 'in 4 minutes',
|
||||
fullDate: '2024-05-23 12:04:00',
|
||||
});
|
||||
});
|
||||
|
||||
test('with last evaluation having missed ticks', () => {
|
||||
// 6 minutes ago, so we missed a tick
|
||||
const lastEvaluation = new Date(MOCK_NOW.valueOf() - 6 * 60 * 1000).toISOString();
|
||||
const interval = '5m';
|
||||
|
||||
const output = calculateNextEvaluationEstimate(lastEvaluation, interval);
|
||||
expect(output).toStrictEqual({
|
||||
humanized: 'within 5m',
|
||||
fullDate: 'within 5m',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { addMilliseconds, formatDistanceToNowStrict, isBefore } from 'date-fns';
|
||||
|
||||
import { dateTime, dateTimeFormat, isValidDate } from '@grafana/data';
|
||||
|
||||
import { isNullDate, parsePrometheusDuration } from '../../utils/time';
|
||||
|
||||
type NextEvaluation = {
|
||||
humanized: string;
|
||||
fullDate: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Best effort estimate for when the next evaluation will occur
|
||||
* @TODO write a test for this
|
||||
* @TODO move this somewhere else probably
|
||||
*/
|
||||
export function calculateNextEvaluationEstimate(
|
||||
lastEvaluation?: string,
|
||||
evaluationInterval?: string
|
||||
): NextEvaluation | undefined {
|
||||
if (!lastEvaluation || !evaluationInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidDate(lastEvaluation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let intervalSize: number;
|
||||
try {
|
||||
intervalSize = parsePrometheusDuration(evaluationInterval);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
// paused alert rules will have their lastEvaluation set to a nil date
|
||||
if (isNullDate(lastEvaluation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastEvaluationDate = Date.parse(lastEvaluation || '');
|
||||
const nextEvaluationDate = addMilliseconds(lastEvaluationDate, intervalSize);
|
||||
|
||||
//when `nextEvaluationDate` is a past date it means lastEvaluation was more than one evaluation interval ago.
|
||||
//in this case we use the interval value to show a more generic estimate.
|
||||
//See https://github.com/grafana/grafana/issues/65125
|
||||
const isPastDate = isBefore(nextEvaluationDate, new Date());
|
||||
if (isPastDate) {
|
||||
return {
|
||||
humanized: `within ${evaluationInterval}`,
|
||||
fullDate: `within ${evaluationInterval}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
humanized: `in ${dateTime(nextEvaluationDate).locale('en').fromNow(true)}`,
|
||||
fullDate: dateTimeFormat(nextEvaluationDate, { format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRelativeEvaluationInterval(lastEvaluation?: string) {
|
||||
if (!lastEvaluation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isNullDate(lastEvaluation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return formatDistanceToNowStrict(new Date(lastEvaluation));
|
||||
}
|
||||
@@ -24,7 +24,7 @@ jest.mock('app/core/services/context_srv');
|
||||
const mockContextSrv = jest.mocked(contextSrv);
|
||||
|
||||
const ui = {
|
||||
moreButton: byLabelText('more-actions'),
|
||||
moreButton: byLabelText(/More/),
|
||||
};
|
||||
|
||||
const grantAllPermissions = () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ const ui = {
|
||||
actionButtons: {
|
||||
edit: byRole('link', { name: 'Edit' }),
|
||||
view: byRole('link', { name: 'View' }),
|
||||
more: byRole('button', { name: /more-actions/i }),
|
||||
more: byRole('button', { name: /More/ }),
|
||||
},
|
||||
moreActionItems: {
|
||||
delete: byRole('menuitem', { name: 'Delete' }),
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { isBefore, formatDuration } from 'date-fns';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
GrafanaTheme2,
|
||||
addDurationToDate,
|
||||
isValidDate,
|
||||
isValidDuration,
|
||||
parseDuration,
|
||||
dateTimeFormat,
|
||||
dateTime,
|
||||
} from '@grafana/data';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Tooltip } from '@grafana/ui';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
|
||||
@@ -24,6 +15,7 @@ import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines';
|
||||
import { ProvisioningBadge } from '../Provisioning';
|
||||
import { RuleLocation } from '../RuleLocation';
|
||||
import { Tokenize } from '../Tokenize';
|
||||
import { calculateNextEvaluationEstimate } from '../rule-list/util';
|
||||
|
||||
import { RuleActionsButtons } from './RuleActionsButtons';
|
||||
import { RuleConfigStatus } from './RuleConfigStatus';
|
||||
@@ -116,39 +108,6 @@ export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNextEvaluationColumn: boolean) {
|
||||
const { hasRuler, rulerRulesLoaded } = useHasRuler();
|
||||
|
||||
const calculateNextEvaluationDate = useCallback((rule: CombinedRule) => {
|
||||
const isValidLastEvaluation = rule.promRule?.lastEvaluation && isValidDate(rule.promRule.lastEvaluation);
|
||||
const isValidIntervalDuration = rule.group.interval && isValidDuration(rule.group.interval);
|
||||
|
||||
if (
|
||||
!isValidLastEvaluation ||
|
||||
!isValidIntervalDuration ||
|
||||
(isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalDuration = parseDuration(rule.group.interval!);
|
||||
const lastEvaluationDate = Date.parse(rule.promRule?.lastEvaluation || '');
|
||||
const nextEvaluationDate = addDurationToDate(lastEvaluationDate, intervalDuration);
|
||||
|
||||
//when `nextEvaluationDate` is a past date it means lastEvaluation was more than one evaluation interval ago.
|
||||
//in this case we use the interval value to show a more generic estimate.
|
||||
//See https://github.com/grafana/grafana/issues/65125
|
||||
const isPastDate = isBefore(nextEvaluationDate, new Date());
|
||||
if (isPastDate) {
|
||||
return {
|
||||
humanized: `within ${formatDuration(intervalDuration)}`,
|
||||
fullDate: `within ${formatDuration(intervalDuration)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
humanized: `in ${dateTime(nextEvaluationDate).locale('en').fromNow(true)}`,
|
||||
fullDate: dateTimeFormat(nextEvaluationDate, { format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useMemo((): RuleTableColumnProps[] => {
|
||||
const columns: RuleTableColumnProps[] = [
|
||||
{
|
||||
@@ -228,7 +187,8 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNe
|
||||
id: 'nextEvaluation',
|
||||
label: 'Next evaluation',
|
||||
renderCell: ({ data: rule }) => {
|
||||
const nextEvalInfo = calculateNextEvaluationDate(rule);
|
||||
const nextEvalInfo = calculateNextEvaluationEstimate(rule.promRule?.lastEvaluation, rule.group.interval);
|
||||
|
||||
return (
|
||||
nextEvalInfo && (
|
||||
<Tooltip placement="top" content={`${nextEvalInfo?.fullDate}`} theme="info">
|
||||
@@ -272,12 +232,5 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNe
|
||||
});
|
||||
|
||||
return columns;
|
||||
}, [
|
||||
showSummaryColumn,
|
||||
showGroupColumn,
|
||||
showNextEvaluationColumn,
|
||||
hasRuler,
|
||||
rulerRulesLoaded,
|
||||
calculateNextEvaluationDate,
|
||||
]);
|
||||
}, [showSummaryColumn, showGroupColumn, showNextEvaluationColumn, hasRuler, rulerRulesLoaded]);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { RulesSource } from 'app/types/unified-alerting';
|
||||
import { PromApplication, RulesSourceApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertmanagerApi } from '../api/alertmanagerApi';
|
||||
import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources';
|
||||
@@ -45,6 +46,10 @@ export function getRulesDataSources() {
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function getRulesSourceUniqueKey(rulesSource: RulesSource): string {
|
||||
return isGrafanaRulesSource(rulesSource) ? 'grafana' : rulesSource.uid ?? rulesSource.id;
|
||||
}
|
||||
|
||||
export function getRulesDataSource(rulesSourceName: string) {
|
||||
return getRulesDataSources().find((x) => x.name === rulesSourceName);
|
||||
}
|
||||
@@ -273,3 +278,20 @@ export function getDefaultOrFirstCompatibleDataSource(): DataSourceInstanceSetti
|
||||
export function isDataSourceManagingAlerts(ds: DataSourceInstanceSettings<DataSourceJsonData>) {
|
||||
return ds.jsonData.manageAlerts !== false; //if this prop is undefined it defaults to true
|
||||
}
|
||||
|
||||
export function getApplicationFromRulesSource(rulesSource: RulesSource): RulesSourceApplication {
|
||||
if (isGrafanaRulesSource(rulesSource)) {
|
||||
return 'grafana';
|
||||
}
|
||||
|
||||
// @TODO use buildinfo
|
||||
if ('prometheusType' in rulesSource.jsonData) {
|
||||
return rulesSource.jsonData?.prometheusType ?? PromApplication.Prometheus;
|
||||
}
|
||||
|
||||
if (rulesSource.type === 'loki') {
|
||||
return 'loki';
|
||||
}
|
||||
|
||||
return PromApplication.Prometheus; // assume Prometheus if nothing matches
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ export function arrayKeyValuesToObject(
|
||||
|
||||
export const GRAFANA_ORIGIN_LABEL = '__grafana_origin';
|
||||
|
||||
export function labelsSize(labels: Labels) {
|
||||
return Object.keys(labels).filter((key) => !isPrivateLabelKey(key)).length;
|
||||
}
|
||||
|
||||
export function isPrivateLabelKey(labelKey: string) {
|
||||
return (labelKey.startsWith('__') && labelKey.endsWith('__')) || labelKey === GRAFANA_ORIGIN_LABEL;
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ import { getMatcherQueryParams } from './matchers';
|
||||
import * as ruleId from './rule-id';
|
||||
import { createAbsoluteUrl, createUrl } from './url';
|
||||
|
||||
export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo: string): string {
|
||||
export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo?: string): string {
|
||||
const sourceName = getRulesSourceName(ruleSource);
|
||||
const identifier = ruleId.fromCombinedRule(sourceName, rule);
|
||||
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
|
||||
const paramSource = encodeURIComponent(sourceName);
|
||||
|
||||
return createUrl(`/alerting/${paramSource}/${paramId}/view`, { returnTo });
|
||||
return createUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
|
||||
}
|
||||
|
||||
export function createExploreLink(datasource: DataSourceRef, query: string) {
|
||||
|
||||
@@ -221,7 +221,7 @@ export function hashRulerRule(rule: RulerRuleDTO): string {
|
||||
}
|
||||
}
|
||||
|
||||
function hashRule(rule: Rule): string {
|
||||
export function hashRule(rule: Rule): string {
|
||||
if (isRecordingRule(rule)) {
|
||||
return hash(JSON.stringify([rule.type, rule.query, hashLabelsOrAnnotations(rule.labels)])).toString();
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ export enum PromApplication {
|
||||
Thanos = 'Thanos',
|
||||
}
|
||||
|
||||
export type RulesSourceApplication = PromApplication | 'loki' | 'grafana';
|
||||
|
||||
export interface PromBuildInfoResponse {
|
||||
data: {
|
||||
application?: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* Prometheus internal models */
|
||||
|
||||
import { AlertState, DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { PromOptions } from '@grafana/prometheus';
|
||||
import { LokiOptions } from 'app/plugins/datasource/loki/types';
|
||||
|
||||
import {
|
||||
Annotations,
|
||||
@@ -89,7 +91,7 @@ export interface RulesSourceResult {
|
||||
namespaces?: RuleNamespace[];
|
||||
}
|
||||
|
||||
export type RulesSource = DataSourceInstanceSettings | 'grafana';
|
||||
export type RulesSource = DataSourceInstanceSettings<PromOptions | LokiOptions> | 'grafana';
|
||||
|
||||
// combined prom and ruler result
|
||||
export interface CombinedRule {
|
||||
|
||||
@@ -17019,6 +17019,7 @@ __metadata:
|
||||
ts-node: "npm:10.9.2"
|
||||
tslib: "npm:2.6.2"
|
||||
tween-functions: "npm:^1.2.0"
|
||||
type-fest: "npm:^4.18.2"
|
||||
typescript: "npm:5.4.5"
|
||||
uplot: "npm:1.6.30"
|
||||
uuid: "npm:9.0.1"
|
||||
@@ -28902,6 +28903,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"type-fest@npm:^4.18.2":
|
||||
version: 4.18.2
|
||||
resolution: "type-fest@npm:4.18.2"
|
||||
checksum: 10/2c176de28384a247fac1503165774e874c15ac39434a775f32ecda3aef5a0cefcfa2f5fb670c3da1f81cf773c355999154078c8d9657db19b65de78334b27933
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"type-fest@npm:^4.9.0":
|
||||
version: 4.10.2
|
||||
resolution: "type-fest@npm:4.10.2"
|
||||
|
||||
Reference in New Issue
Block a user