Alerting: New list view UI – Part 1 (#87907)

This commit is contained in:
Gilles De Mey
2024-05-24 16:40:49 +02:00
committed by GitHub
parent 51e27200a6
commit 99b5259cc1
36 changed files with 1330 additions and 270 deletions

View File

@@ -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"]
],

View File

@@ -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

View File

@@ -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",

View File

@@ -188,6 +188,7 @@ export interface FeatureToggles {
autofixDSUID?: boolean;
logsExploreTableDefaultVisualization?: boolean;
newDashboardSharingComponent?: boolean;
alertingListViewV2?: boolean;
notificationBanner?: boolean;
dashboardRestore?: boolean;
datasourceProxyDisableRBAC?: boolean;

View File

@@ -185,6 +185,7 @@ export const availableIconsIndex = {
paragraph: true,
'pathfinder-unite': true,
pause: true,
'pause-circle': true,
pen: true,
percentage: true,
play: true,

View File

@@ -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",

View File

@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
169 autofixDSUID experimental @grafana/plugins-platform-backend false false false
170 logsExploreTableDefaultVisualization experimental @grafana/observability-logs false false true
171 newDashboardSharingComponent experimental @grafana/sharing-squad false false true
172 alertingListViewV2 experimental @grafana/alerting-squad false false true
173 notificationBanner experimental @grafana/grafana-frontend-platform false false false
174 dashboardRestore experimental @grafana/grafana-frontend-platform false false false
175 datasourceProxyDisableRBAC GA @grafana/identity-access-team false false false

View File

@@ -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"

View File

@@ -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
}
}
]
}

View File

@@ -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 }),

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 };

View File

@@ -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();

View File

@@ -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;

View File

@@ -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,
}),
});

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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',
});
});
});

View File

@@ -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));
}

View File

@@ -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 = () => {

View File

@@ -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' }),

View File

@@ -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]);
}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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();
}

View File

@@ -65,6 +65,8 @@ export enum PromApplication {
Thanos = 'Thanos',
}
export type RulesSourceApplication = PromApplication | 'loki' | 'grafana';
export interface PromBuildInfoResponse {
data: {
application?: string;

View File

@@ -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 {

View File

@@ -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"