Alerting: New alert state list view (#94068)

This commit is contained in:
Gilles De Mey 2024-10-24 14:37:42 +02:00 committed by GitHub
parent f671ad51fe
commit c42f42223a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1025 additions and 682 deletions

View File

@ -1860,25 +1860,6 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-list/AlertRuleListItem.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"]
],
"public/app/features/alerting/unified/components/rule-list/EvaluationGroup.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-list/RuleList.v1.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/rule-list/RuleList.v2.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
@ -2135,6 +2116,13 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Do not use any type assertions.", "8"]
],
"public/app/features/alerting/unified/rule-list/RuleList.v1.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/rule-list/RuleList.v2.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/state/actions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]

View File

@ -2,8 +2,8 @@ import { lazy, Suspense } from 'react';
import { config } from '@grafana/runtime';
import RuleListV1 from './components/rule-list/RuleList.v1';
const RuleListV2 = lazy(() => import('./components/rule-list/RuleList.v2'));
import RuleListV1 from './rule-list/RuleList.v1';
const RuleListV2 = lazy(() => import('./rule-list/RuleList.v2'));
const RuleList = () => {
const newView = config.featureToggles.alertingListViewV2;

View File

@ -1,313 +0,0 @@
import { css } from '@emotion/css';
import { isEmpty } from 'lodash';
import pluralize from 'pluralize';
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;
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}
state={state}
/>
<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;
state?: PromAlertingRuleState;
labels?: Labels;
isProvisioned?: boolean;
lastEvaluation?: string;
evaluationInterval?: string;
}
// @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}
state={state}
/>
{!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;
}
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

@ -1,10 +1,9 @@
import { css } from '@emotion/css';
import { chain, isEmpty, truncate } from 'lodash';
import { useState } from 'react';
import { useMeasure } from 'react-use';
import { NavModelItem, UrlQueryValue } from '@grafana/data';
import { Alert, LinkButton, LoadingBar, Stack, TabContent, Text, TextLink, useStyles2 } from '@grafana/ui';
import { Alert, LinkButton, LoadingBar, Stack, TabContent, Text, TextLink } from '@grafana/ui';
import { t, Trans } from '@grafana/ui/src/utils/i18n';
import { PageInfoItem } from 'app/core/components/Page/types';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
@ -243,15 +242,14 @@ interface TitleProps {
}
export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin }: TitleProps) => {
const styles = useStyles2(getStyles);
const [queryParams] = useQueryParams();
const isRecordingRule = ruleType === PromRuleType.Recording;
const returnTo = queryParams.returnTo ? String(queryParams.returnTo) : '/alerting/list';
return (
<div className={styles.title}>
<Stack direction="row" gap={1} minWidth={0} alignItems="center">
<LinkButton variant="secondary" icon="angle-left" href={returnTo} />
{ruleOrigin && <PluginOriginBadge pluginId={ruleOrigin.pluginId} />}
{ruleOrigin && <PluginOriginBadge pluginId={ruleOrigin.pluginId} size="lg" />}
<Text variant="h1" truncate>
{name}
</Text>
@ -264,7 +262,7 @@ export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigi
{isRecordingRule && <RecordingBadge health={health} />}
</>
)}
</div>
</Stack>
);
};
@ -398,23 +396,20 @@ function usePageNav(rule: CombinedRule) {
};
}
const calculateTotalInstances = (stats: CombinedRule['instanceTotals']) => {
export const calculateTotalInstances = (stats: CombinedRule['instanceTotals']) => {
return chain(stats)
.pick([AlertInstanceTotalState.Alerting, AlertInstanceTotalState.Pending, AlertInstanceTotalState.Normal])
.pick([
AlertInstanceTotalState.Alerting,
AlertInstanceTotalState.Pending,
AlertInstanceTotalState.Normal,
AlertInstanceTotalState.NoData,
AlertInstanceTotalState.Error,
])
.values()
.sum()
.value();
};
const getStyles = () => ({
title: css({
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
}),
});
function isValidRunbookURL(url: string) {
const isRelative = url.startsWith('/');
let isAbsolute = false;

View File

@ -159,191 +159,186 @@ const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => {
const searchIcon = <Icon name={'search'} />;
return (
<div className={styles.container}>
<Stack direction="column" gap={1}>
<Stack direction="row" gap={1} wrap="wrap">
<Field
className={styles.dsPickerContainer}
label={
<Label htmlFor="data-source-picker">
<Stack gap={0.5} alignItems="center">
<span>Search by data sources</span>
<Tooltip
content={
<div>
<p>
Data sources containing configured alert rules are Mimir or Loki data sources where alert
rules are stored and evaluated in the data source itself.
</p>
<p>
In these data sources, you can select Manage alerts via Alerting UI to be able to manage these
alert rules in the Grafana UI as well as in the data source where they were configured.
</p>
</div>
}
>
<Icon
id="data-source-picker-inline-help"
name="info-circle"
size="sm"
title="Search by data sources help"
/>
</Tooltip>
</Stack>
</Label>
}
>
<MultipleDataSourcePicker
key={dataSourceKey}
alerting
noDefault
placeholder="All data sources"
current={filterState.dataSourceNames}
onChange={handleDataSourceChange}
onClear={clearDataSource}
/>
</Field>
<Field
className={styles.dashboardPickerContainer}
label={<Label htmlFor="filters-dashboard-picker">Dashboard</Label>}
>
{/* The key prop is to clear the picker value */}
{/* DashboardPicker doesn't do that itself when value is undefined */}
<DashboardPicker
inputId="filters-dashboard-picker"
key={filterState.dashboardUid ? 'dashboard-defined' : 'dashboard-not-defined'}
value={filterState.dashboardUid}
onChange={(value) => handleDashboardChange(value?.uid)}
isClearable
cacheOptions
/>
</Field>
<div>
<Label>State</Label>
<RadioButtonGroup
options={RuleStateOptions}
value={filterState.ruleState}
onChange={handleAlertStateChange}
/>
</div>
<div>
<Label>Rule type</Label>
<RadioButtonGroup options={RuleTypeOptions} value={filterState.ruleType} onChange={handleRuleTypeChange} />
</div>
<div>
<Label>Health</Label>
<RadioButtonGroup
options={RuleHealthOptions}
value={filterState.ruleHealth}
onChange={handleRuleHealthChange}
/>
</div>
{canRenderContactPointSelector && (
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={GRAFANA_RULES_SOURCE_NAME}>
<Stack direction="column" gap={0}>
<Field
label={
<Label htmlFor="contactPointFilter">
<Trans i18nKey="alerting.contactPointFilter.label">Contact point</Trans>
</Label>
<Stack direction="column" gap={0}>
<Stack direction="row" gap={1} wrap="wrap">
<Field
className={styles.dsPickerContainer}
label={
<Label htmlFor="data-source-picker">
<Stack gap={0.5} alignItems="center">
<span>Search by data sources</span>
<Tooltip
content={
<div>
<p>
Data sources containing configured alert rules are Mimir or Loki data sources where alert rules
are stored and evaluated in the data source itself.
</p>
<p>
In these data sources, you can select Manage alerts via Alerting UI to be able to manage these
alert rules in the Grafana UI as well as in the data source where they were configured.
</p>
</div>
}
>
<ContactPointSelector
selectedContactPointName={filterState.contactPoint}
selectProps={{
inputId: 'contactPointFilter',
width: 40,
onChange: (selectValue) => {
handleContactPointChange(selectValue?.value?.name!);
},
isClearable: true,
}}
<Icon
id="data-source-picker-inline-help"
name="info-circle"
size="sm"
title="Search by data sources help"
/>
</Field>
</Tooltip>
</Stack>
</AlertmanagerProvider>
)}
{pluginsFilterEnabled && (
<div>
<Label>Plugin rules</Label>
<RadioButtonGroup<'hide'>
options={[
{ label: 'Show', value: undefined },
{ label: 'Hide', value: 'hide' },
]}
value={filterState.plugins}
onChange={(value) => updateFilters({ ...filterState, plugins: value })}
/>
</div>
)}
</Stack>
</Label>
}
>
<MultipleDataSourcePicker
key={dataSourceKey}
alerting
noDefault
placeholder="All data sources"
current={filterState.dataSourceNames}
onChange={handleDataSourceChange}
onClear={clearDataSource}
/>
</Field>
<Stack direction="column" gap={1}>
<Stack direction="row" gap={1}>
<form
className={styles.searchInput}
onSubmit={handleSubmit((data) => {
setSearchQuery(data.searchQuery);
searchQueryRef.current?.blur();
trackRulesSearchInputInteraction({ oldQuery: searchQuery, newQuery: data.searchQuery });
})}
>
<Field
className={styles.dashboardPickerContainer}
label={<Label htmlFor="filters-dashboard-picker">Dashboard</Label>}
>
{/* The key prop is to clear the picker value */}
{/* DashboardPicker doesn't do that itself when value is undefined */}
<DashboardPicker
inputId="filters-dashboard-picker"
key={filterState.dashboardUid ? 'dashboard-defined' : 'dashboard-not-defined'}
value={filterState.dashboardUid}
onChange={(value) => handleDashboardChange(value?.uid)}
isClearable
cacheOptions
/>
</Field>
<div>
<Label>State</Label>
<RadioButtonGroup
options={RuleStateOptions}
value={filterState.ruleState}
onChange={handleAlertStateChange}
/>
</div>
<div>
<Label>Rule type</Label>
<RadioButtonGroup options={RuleTypeOptions} value={filterState.ruleType} onChange={handleRuleTypeChange} />
</div>
<div>
<Label>Health</Label>
<RadioButtonGroup
options={RuleHealthOptions}
value={filterState.ruleHealth}
onChange={handleRuleHealthChange}
/>
</div>
{canRenderContactPointSelector && (
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={GRAFANA_RULES_SOURCE_NAME}>
<Stack direction="column" gap={0}>
<Field
label={
<Label htmlFor="rulesSearchInput">
<Stack gap={0.5} alignItems="center">
<span>Search</span>
<PopupCard content={<SearchQueryHelp />}>
<Icon name="info-circle" size="sm" tabIndex={0} title="Search help" />
</PopupCard>
</Stack>
<Label htmlFor="contactPointFilter">
<Trans i18nKey="alerting.contactPointFilter.label">Contact point</Trans>
</Label>
}
>
<Input
id="rulesSearchInput"
key={queryStringKey}
prefix={searchIcon}
ref={(e) => {
ref(e);
searchQueryRef.current = e;
<ContactPointSelector
selectedContactPointName={filterState.contactPoint}
selectProps={{
inputId: 'contactPointFilter',
width: 40,
onChange: (selectValue) => {
handleContactPointChange(selectValue?.value?.name!);
},
isClearable: true,
}}
{...rest}
placeholder="Search"
data-testid="search-query-input"
/>
</Field>
<input type="submit" hidden />
</form>
<div>
<Label>View as</Label>
<RadioButtonGroup
options={ViewOptions}
value={queryParams.get('view') ?? ViewOptions[0].value}
onChange={handleViewChange}
/>
</div>
</Stack>
{hasActiveFilters && (
<div>
<Button fullWidth={false} icon="times" variant="secondary" onClick={handleClearFiltersClick}>
Clear filters
</Button>
</div>
)}
</Stack>
</Stack>
</AlertmanagerProvider>
)}
{pluginsFilterEnabled && (
<div>
<Label>Plugin rules</Label>
<RadioButtonGroup<'hide'>
options={[
{ label: 'Show', value: undefined },
{ label: 'Hide', value: 'hide' },
]}
value={filterState.plugins}
onChange={(value) => updateFilters({ ...filterState, plugins: value })}
/>
</div>
)}
</Stack>
</div>
<Stack direction="column" gap={0}>
<Stack direction="row" gap={1}>
<form
className={styles.searchInput}
onSubmit={handleSubmit((data) => {
setSearchQuery(data.searchQuery);
searchQueryRef.current?.blur();
trackRulesSearchInputInteraction({ oldQuery: searchQuery, newQuery: data.searchQuery });
})}
>
<Field
label={
<Label htmlFor="rulesSearchInput">
<Stack gap={0.5} alignItems="center">
<span>Search</span>
<PopupCard content={<SearchQueryHelp />}>
<Icon name="info-circle" size="sm" tabIndex={0} title="Search help" />
</PopupCard>
</Stack>
</Label>
}
>
<Input
id="rulesSearchInput"
key={queryStringKey}
prefix={searchIcon}
ref={(e) => {
ref(e);
searchQueryRef.current = e;
}}
{...rest}
placeholder="Search"
data-testid="search-query-input"
/>
</Field>
<input type="submit" hidden />
</form>
<div>
<Label>View as</Label>
<RadioButtonGroup
options={ViewOptions}
value={queryParams.get('view') ?? ViewOptions[0].value}
onChange={handleViewChange}
/>
</div>
</Stack>
{hasActiveFilters && (
<div>
<Button fullWidth={false} icon="times" variant="secondary" onClick={handleClearFiltersClick}>
Clear filters
</Button>
</div>
)}
</Stack>
</Stack>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
marginBottom: theme.spacing(1),
}),
dsPickerContainer: css({
width: theme.spacing(60),
flexGrow: 0,

View File

@ -1,8 +1,7 @@
import { css, cx } from '@emotion/css';
import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { LinkButton, Stack, useStyles2 } from '@grafana/ui';
import { LinkButton, Stack } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu';
import { useDeleteModal } from 'app/features/alerting/unified/components/rule-viewer/DeleteModal';
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';
@ -41,7 +40,6 @@ interface Props {
*/
export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton, rule, rulesSource }: Props) => {
const dispatch = useDispatch();
const style = useStyles2(getStyles);
const redirectToListView = compact ? false : true;
const [deleteModal, showDeleteModal] = useDeleteModal(redirectToListView);
@ -62,8 +60,6 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton
const canEditRule = editRuleSupported && editRuleAllowed;
const buttons: JSX.Element[] = [];
const buttonClasses = cx({ [style.compactButton]: compact });
const buttonSize = compact ? 'sm' : 'md';
const sourceName = getRulesSourceName(rulesSource);
@ -73,17 +69,14 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton
if (showViewButton) {
buttons.push(
<LinkButton
tooltip={compact ? 'View' : undefined}
tooltipPlacement="top"
className={buttonClasses}
title={'View'}
title="View"
size={buttonSize}
key="view"
variant="secondary"
icon="eye"
href={createViewLink(rulesSource, rule)}
>
{!compact && 'View'}
<Trans i18nKey="common.view">View</Trans>
</LinkButton>
);
}
@ -94,24 +87,14 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton
const editURL = createRelativeUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`);
buttons.push(
<LinkButton
tooltip={compact ? 'Edit' : undefined}
tooltipPlacement="top"
title={'Edit'}
className={buttonClasses}
size={buttonSize}
key="edit"
variant="secondary"
icon="pen"
href={editURL}
>
{!compact && 'Edit'}
<LinkButton title="Edit" size={buttonSize} key="edit" variant="secondary" icon="pen" href={editURL}>
<Trans i18nKey="common.edit">Edit</Trans>
</LinkButton>
);
}
return (
<Stack gap={1}>
<Stack gap={1} alignItems="center" wrap="nowrap">
{buttons}
<AlertRuleMenu
buttonSize={buttonSize}
@ -147,9 +130,3 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton
</Stack>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
compactButton: css({
padding: `0 ${theme.spacing(2)}`,
}),
});

View File

@ -52,8 +52,8 @@ describe('RuleListStateView', () => {
it('renders differing prom rule states correctly and does not crash with missing state', () => {
render(<RuleListStateView namespaces={namespaces} />);
expect(screen.getByText(/firing \(1\)/i)).toBeInTheDocument();
expect(screen.getByText(/pending \(1\)/i)).toBeInTheDocument();
expect(screen.getByText(/normal \(1\)/i)).toBeInTheDocument();
expect(screen.getByRole('treeitem', { name: 'Firing 1' })).toBeInTheDocument();
expect(screen.getByRole('treeitem', { name: 'Pending 1' })).toBeInTheDocument();
expect(screen.getByRole('treeitem', { name: 'Normal 1' })).toBeInTheDocument();
});
});

View File

@ -1,29 +1,37 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { GrafanaTheme2 } from '@grafana/data';
import { Counter, Pagination, Stack, useStyles2 } from '@grafana/ui';
import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants';
import { CombinedRule, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { getFiltersFromUrlParams } from '../../utils/misc';
import { isAlertingRule } from '../../utils/rules';
import { usePagination } from '../../hooks/usePagination';
import { AlertRuleListItem } from '../../rule-list/components/AlertRuleListItem';
import { ListSection } from '../../rule-list/components/ListSection';
import { createViewLink } from '../../utils/misc';
import { hashRule } from '../../utils/rule-id';
import { getRulePluginOrigin, isAlertingRule, isProvisionedRule } from '../../utils/rules';
import { calculateTotalInstances } from '../rule-viewer/RuleViewer';
import { RuleListStateSection } from './RuleListStateSection';
import { RuleActionsButtons } from './RuleActionsButtons';
interface Props {
namespaces: CombinedRuleNamespace[];
}
type GroupedRules = Record<PromAlertingRuleState, CombinedRule[]>;
type GroupedRules = Map<PromAlertingRuleState, CombinedRule[]>;
export const RuleListStateView = ({ namespaces }: Props) => {
const filters = getFiltersFromUrlParams(useQueryParams()[0]);
const styles = useStyles2(getStyles);
const groupedRules = useMemo(() => {
const result: GroupedRules = {
[PromAlertingRuleState.Firing]: [],
[PromAlertingRuleState.Inactive]: [],
[PromAlertingRuleState.Pending]: [],
};
const result: GroupedRules = new Map([
[PromAlertingRuleState.Firing, []],
[PromAlertingRuleState.Pending, []],
[PromAlertingRuleState.Inactive, []],
]);
namespaces.forEach((namespace) =>
namespace.groups.forEach((group) =>
@ -32,34 +40,97 @@ export const RuleListStateView = ({ namespaces }: Props) => {
// In this case, we shouldn't try to group these alerts in the state view
// Even though we handle this at the API layer, this is a last catch point for any edge cases
if (rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state) {
result[rule.promRule.state].push(rule);
result.get(rule.promRule.state)?.push(rule);
}
})
)
);
Object.values(result).forEach((rules) => rules.sort((a, b) => a.name.localeCompare(b.name)));
result.forEach((rules) => rules.sort((a, b) => a.name.localeCompare(b.name)));
return result;
}, [namespaces]);
const entries = groupedRules.entries();
return (
<>
{(!filters.alertState || filters.alertState === PromAlertingRuleState.Firing) && (
<RuleListStateSection state={PromAlertingRuleState.Firing} rules={groupedRules[PromAlertingRuleState.Firing]} />
)}
{(!filters.alertState || filters.alertState === PromAlertingRuleState.Pending) && (
<RuleListStateSection
state={PromAlertingRuleState.Pending}
rules={groupedRules[PromAlertingRuleState.Pending]}
/>
)}
{(!filters.alertState || filters.alertState === PromAlertingRuleState.Inactive) && (
<RuleListStateSection
defaultCollapsed={filters.alertState !== PromAlertingRuleState.Inactive}
state={PromAlertingRuleState.Inactive}
rules={groupedRules[PromAlertingRuleState.Inactive]}
/>
)}
</>
<ul className={styles.columnStack} role="tree">
{Array.from(entries).map(([state, rules]) => (
<RulesByState key={state} state={state} rules={rules} />
))}
</ul>
);
};
const STATE_TITLES: Record<PromAlertingRuleState, string> = {
[PromAlertingRuleState.Firing]: 'Firing',
[PromAlertingRuleState.Pending]: 'Pending',
[PromAlertingRuleState.Inactive]: 'Normal',
};
const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: CombinedRule[] }) => {
const { page, pageItems, numberOfPages, onPageChange } = usePagination(rules, 1, DEFAULT_PER_PAGE_PAGINATION);
const isFiringState = state !== PromAlertingRuleState.Firing;
const hasRulesMatchingState = rules.length > 0;
return (
<ListSection
title={
<Stack alignItems="center" gap={0}>
{STATE_TITLES[state] ?? 'Unknown'}
<Counter value={rules.length} />
</Stack>
}
collapsed={isFiringState || hasRulesMatchingState}
pagination={
<Pagination
currentPage={page}
numberOfPages={numberOfPages}
onNavigate={onPageChange}
hideWhenSinglePage={true}
/>
}
>
{pageItems.map((rule) => {
const { rulerRule, promRule } = rule;
const isProvisioned = rulerRule ? isProvisionedRule(rulerRule) : false;
const instancesCount = isAlertingRule(rule.promRule) ? calculateTotalInstances(rule.instanceTotals) : undefined;
if (!promRule) {
return null;
}
const originMeta = getRulePluginOrigin(rule);
return (
<AlertRuleListItem
key={hashRule(promRule)}
name={rule.name}
href={createViewLink(rule.namespace.rulesSource, rule)}
summary={rule.annotations.summary}
state={state}
health={rule.promRule?.health}
error={rule.promRule?.lastError}
labels={rule.promRule?.labels}
isProvisioned={isProvisioned}
instancesCount={instancesCount}
namespace={rule.namespace}
group={rule.group.name}
actions={<RuleActionsButtons compact rule={rule} rulesSource={rule.namespace.rulesSource} />}
origin={originMeta}
/>
);
})}
</ListSection>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
columnStack: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
});

View File

@ -3,7 +3,7 @@ import { useEffect, useMemo } from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Tooltip, Pagination } from '@grafana/ui';
import { Pagination, Tooltip, useStyles2 } from '@grafana/ui';
import { CombinedRule } from 'app/types/unified-alerting';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
@ -15,6 +15,7 @@ import { attachRulerRuleToCombinedRule } from '../../hooks/useCombinedRuleNamesp
import { useHasRuler } from '../../hooks/useHasRuler';
import { usePagination } from '../../hooks/usePagination';
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
import { calculateNextEvaluationEstimate } from '../../rule-list/components/util';
import { Annotation } from '../../utils/constants';
import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { getRulePluginOrigin, isGrafanaRulerRule, isGrafanaRulerRulePaused } from '../../utils/rules';
@ -23,7 +24,6 @@ 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';
@ -298,7 +298,7 @@ function useColumns(
label: 'Actions',
// eslint-disable-next-line react/display-name
renderCell: ({ data: rule }) => <RuleActionsCell rule={rule} isLoadingRuler={isRulerLoading} />,
size: '200px',
size: '215px',
});
return columns;

View File

@ -1,31 +1,35 @@
import { useAsync } from 'react-use';
import { Badge, Tooltip } from '@grafana/ui';
import { Badge, IconSize, Tooltip } from '@grafana/ui';
import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
import { getPluginSettings } from '../../../plugins/pluginSettings';
interface PluginOriginBadgeProps {
pluginId: string;
size?: IconSize;
}
export function PluginOriginBadge({ pluginId }: PluginOriginBadgeProps) {
const { value: pluginMeta } = useAsync(() => getPluginSettings(pluginId));
export function PluginOriginBadge({ pluginId, size = 'md' }: PluginOriginBadgeProps) {
const { value: pluginMeta, loading } = useAsync(() => getPluginSettings(pluginId));
const logo = pluginMeta?.info.logos?.small;
if (loading) {
return null;
}
if (!pluginMeta) {
return null;
}
const logo = pluginMeta.info.logos?.small;
const pluginName = pluginMeta.name;
const imageSize = getSvgSize(size);
const badgeIcon = logo ? (
<img src={logo} alt={pluginMeta?.name} style={{ width: '20px', height: '20px' }} />
<img src={logo} alt={pluginName} height={imageSize} />
) : (
<Badge text={pluginId} color="orange" />
);
const tooltipContent = pluginMeta
? `This rule is managed by the ${pluginMeta?.name} plugin`
: `This rule is managed by a plugin`;
return (
<Tooltip content={tooltipContent}>
<div>{badgeIcon}</div>
</Tooltip>
);
return <Tooltip content={`This rule is managed by the ${pluginName} plugin`}>{badgeIcon}</Tooltip>;
}

View File

@ -3,28 +3,29 @@ import { useLocation } from 'react-router-dom-v5-compat';
import { useAsyncFn, useInterval } from 'react-use';
import { urlUtil } from '@grafana/data';
import { logInfo } from '@grafana/runtime';
import { Button, LinkButton, Stack, withErrorBoundary } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { useDispatch } from 'app/types';
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import { CombinedRuleNamespace } from '../../../../../types/unified-alerting';
import { LogMessages, logInfo, trackRuleListNavigation } from '../../Analytics';
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { useFilteredRules, useRulesFilter } from '../../hooks/useFilteredRules';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../../state/actions';
import { RULE_LIST_POLL_INTERVAL_MS } from '../../utils/constants';
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { AlertingPageWrapper } from '../AlertingPageWrapper';
import RulesFilter from '../rules/Filter/RulesFilter';
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 { LogMessages, trackRuleListNavigation } from '../Analytics';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import RulesFilter from '../components/rules/Filter/RulesFilter.v1';
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 { shouldUsePrometheusRulesPrimary } from '../featureToggles';
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces';
import { useFilteredRules, useRulesFilter } from '../hooks/useFilteredRules';
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector';
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions';
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
const VIEWS = {
groups: RuleListGroupView,
@ -115,24 +116,26 @@ const RuleListV1 = () => {
// 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 onClear={onFilterCleared} />
{hasAlertRulesCreated && (
<Stack direction="row" alignItems="center">
{view === 'groups' && hasActiveFilters && (
<Button
icon={expandAll ? 'angle-double-up' : 'angle-double-down'}
variant="secondary"
onClick={() => setExpandAll(!expandAll)}
>
{expandAll ? 'Collapse all' : 'Expand all'}
</Button>
)}
<RuleStats namespaces={filteredNamespaces} />
</Stack>
)}
{hasNoAlertRulesCreatedYet && <NoRulesSplash />}
{hasAlertRulesCreated && <ViewComponent expandAll={expandAll} namespaces={filteredNamespaces} />}
<Stack direction="column">
<RuleListErrors />
<RulesFilter onClear={onFilterCleared} />
{hasAlertRulesCreated && (
<Stack direction="row" alignItems="center">
{view === 'groups' && hasActiveFilters && (
<Button
icon={expandAll ? 'angle-double-up' : 'angle-double-down'}
variant="secondary"
onClick={() => setExpandAll(!expandAll)}
>
{expandAll ? 'Collapse all' : 'Expand all'}
</Button>
)}
</Stack>
)}
<RuleStats namespaces={filteredNamespaces} />
{hasNoAlertRulesCreatedYet && <NoRulesSplash />}
{hasAlertRulesCreated && <ViewComponent expandAll={expandAll} namespaces={filteredNamespaces} />}
</Stack>
</AlertingPageWrapper>
);
};

View File

@ -7,25 +7,25 @@ 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 RulesFilter from '../rules/Filter/RulesFilter';
import { NoRulesSplash } from '../rules/NoRulesCTA';
import { INSTANCES_DISPLAY_LIMIT } from '../rules/RuleDetails';
import { RuleListErrors } from '../rules/RuleListErrors';
import { RuleStats } from '../rules/RuleStats';
import { CombinedRuleNamespace } from '../../../../types/unified-alerting';
import { logInfo, LogMessages, trackRuleListNavigation } from '../Analytics';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import RulesFilter from '../components/rules/Filter/RulesFilter.v1';
import { NoRulesSplash } from '../components/rules/NoRulesCTA';
import { INSTANCES_DISPLAY_LIMIT } from '../components/rules/RuleDetails';
import { RuleListErrors } from '../components/rules/RuleListErrors';
import { RuleStats } from '../components/rules/RuleStats';
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, getApplicationFromRulesSource, getRulesSourceUniqueKey } from '../utils/datasource';
import { makeFolderAlertsLink } from '../utils/misc';
import { EvaluationGroupWithRules } from './EvaluationGroupWithRules';
import Namespace from './Namespace';
import { EvaluationGroupWithRules } from './components/EvaluationGroupWithRules';
import Namespace from './components/Namespace';
// make sure we ask for 1 more so we show the "show x more" button
const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1;

View File

@ -0,0 +1,257 @@
import { css } from '@emotion/css';
import { isEmpty } from 'lodash';
import pluralize from 'pluralize';
import { ReactNode } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Icon, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { CombinedRule, CombinedRuleNamespace, RuleHealth } from 'app/types/unified-alerting';
import { Labels, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { logError } from '../../Analytics';
import { MetaText } from '../../components/MetaText';
import { ProvisioningBadge } from '../../components/Provisioning';
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { labelsSize } from '../../utils/labels';
import { createContactPointLink } from '../../utils/misc';
import { RulePluginOrigin } from '../../utils/rules';
import { ListItem } from './ListItem';
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;
labels?: Labels;
instancesCount?: number;
namespace?: CombinedRuleNamespace;
group?: string;
// used for alert rules that use simplified routing
contactPoint?: string;
actions?: ReactNode;
origin?: RulePluginOrigin;
}
export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
const {
name,
summary,
state,
health,
error,
href,
isProvisioned,
lastEvaluation,
evaluationInterval,
isPaused = false,
instancesCount = 0,
namespace,
group,
contactPoint,
labels,
origin,
actions = null,
} = props;
const metadata: ReactNode[] = [];
if (namespace && group) {
metadata.push(
<Text color="secondary" variant="bodySmall">
<RuleLocation namespace={namespace} group={group} />
</Text>
);
}
if (!isPaused) {
if (lastEvaluation && evaluationInterval) {
metadata.push(
<EvaluationMetadata lastEvaluation={lastEvaluation} evaluationInterval={evaluationInterval} state={state} />
);
}
if (instancesCount) {
metadata.push(
<MetaText icon="layers-alt">
<TextLink href={href + '?tab=instances'} variant="bodySmall" color="primary" inline={false}>
{pluralize('instance', instancesCount, true)}
</TextLink>
</MetaText>
);
}
}
if (!isEmpty(labels)) {
metadata.push(
<MetaText icon="tag-alt">
<TextLink href={href} variant="bodySmall" color="primary" inline={false}>
{pluralize('label', labelsSize(labels), true)}
</TextLink>
</MetaText>
);
}
if (!isPaused && contactPoint) {
metadata.push(
<MetaText icon="at">
<Trans i18nKey="alerting.contact-points.delivered-to">Delivered to</Trans>{' '}
<TextLink
href={createContactPointLink(contactPoint, GRAFANA_RULES_SOURCE_NAME)}
variant="bodySmall"
color="primary"
inline={false}
>
{contactPoint}
</TextLink>
</MetaText>
);
}
return (
<ListItem
title={
<Stack direction="row" alignItems="center">
<TextLink href={href} inline={false}>
{name}
</TextLink>
{origin && <PluginOriginBadge pluginId={origin.pluginId} size="sm" />}
{/* show provisioned badge only when it also doesn't have plugin origin */}
{isProvisioned && !origin && <ProvisioningBadge />}
{/* 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>
}
description={<Summary content={summary} error={error} />}
icon={<RuleListIcon state={state} health={health} isPaused={isPaused} />}
actions={actions}
meta={metadata}
/>
);
};
interface SummaryProps {
content?: string;
error?: string;
}
function Summary({ content, error }: SummaryProps) {
if (error) {
return (
<Text variant="bodySmall" color="error" weight="light" truncate element="p">
{error}
</Text>
);
}
if (content) {
return (
<Text variant="bodySmall" color="secondary">
{content}
</Text>
);
}
return null;
}
interface EvaluationMetadataProps {
lastEvaluation?: string;
evaluationInterval?: string;
state?: PromAlertingRuleState;
}
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">
<Trans i18nKey="alerting.alert-rules.firing-for">Firing for</Trans> <Text color="primary">{firingFor}</Text>
{nextEvaluation && (
<>
{'· '}
<Trans i18nKey="alerting.alert-rules.next-evaluation-in">next evaluation in</Trans>{' '}
{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">
<Trans i18nKey="alerting.alert-rules.next-evaluation">Next evaluation</Trans> {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>
<Trans i18nKey="alerting.alert-rules.rule-definition">Rule definition</Trans>
</summary>
<pre>
<code>{JSON.stringify(rule.rulerRule, null, 2)}</code>
</pre>
</details>
</Alert>
);
};
interface RuleLocationProps {
namespace: CombinedRuleNamespace;
group: string;
}
export const RuleLocation = ({ namespace, group }: RuleLocationProps) => (
<Stack direction="row" alignItems="center" gap={0.5}>
<Icon size="xs" name="folder" />
<Stack direction="row" alignItems="center" gap={0}>
{namespace.name}
<Icon size="sm" name="angle-right" />
{group}
</Stack>
</Stack>
);
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

@ -2,11 +2,12 @@ import { css, cx } from '@emotion/css';
import { PropsWithChildren } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Badge, Button, Dropdown, Menu, Stack, Text, Icon } from '@grafana/ui';
import { Badge, Button, Dropdown, Icon, Menu, Stack, Text, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { MetaText } from '../MetaText';
import MoreButton from '../MoreButton';
import { Spacer } from '../Spacer';
import { MetaText } from '../../components/MetaText';
import MoreButton from '../../components/MoreButton';
import { Spacer } from '../../components/Spacer';
interface EvaluationGroupProps extends PropsWithChildren {
name: string;
@ -36,7 +37,7 @@ const EvaluationGroup = ({ name, provenance, interval, onToggle, isOpen = false,
<Spacer />
{interval && <MetaText icon="history">{interval}</MetaText>}
<Button size="sm" icon="pen" variant="secondary" disabled={isProvisioned} data-testid="edit-group-action">
Edit
<Trans i18nKey="common.edit">Edit</Trans>
</Button>
<Dropdown
overlay={

View File

@ -5,9 +5,9 @@ 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 { isAlertingRule, isGrafanaRulerRule, isRecordingRule } from '../../utils/rules';
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './AlertRuleListItem';
import { AlertRuleListItem, UnknownRuleListItem } from './AlertRuleListItem';
import EvaluationGroup from './EvaluationGroup';
export interface EvaluationGroupWithRulesProps {
@ -32,14 +32,14 @@ export const EvaluationGroupWithRules = ({ group, rulesSource }: EvaluationGroup
// rule source is eventually consistent - it may know about the rule definition but not its state
const isAlertingPromRule = isAlertingRule(promRule);
if (isAlertingRulerRule(rulerRule)) {
if (isAlertingRule(rule.promRule) || isRecordingRule(rule.promRule)) {
return (
<AlertRuleListItem
key={hashRulerRule(rulerRule)}
state={isAlertingPromRule ? promRule?.state : undefined}
health={promRule?.health}
error={promRule?.lastError}
name={rulerRule.alert}
name={rule.name}
labels={rulerRule.labels}
lastEvaluation={promRule?.lastEvaluation}
evaluationInterval={group.interval}
@ -50,21 +50,6 @@ export const EvaluationGroupWithRules = ({ group, rulesSource }: EvaluationGroup
);
}
if (isRecordingRulerRule(rulerRule)) {
return (
<RecordingRuleListItem
key={hashRulerRule(rulerRule)}
name={rulerRule.record}
health={promRule?.health}
error={promRule?.lastError}
lastEvaluation={promRule?.lastEvaluation}
evaluationInterval={group.interval}
labels={rulerRule.labels}
href={createViewLink(rulesSource, rule)}
/>
);
}
if (isGrafanaRulerRule(rulerRule)) {
const contactPoint = rulerRule.grafana_alert.notification_settings?.receiver;

View File

@ -0,0 +1,88 @@
import { css } from '@emotion/css';
import { PropsWithChildren, ReactNode } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, Stack, Text, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { Spacer } from '../../components/Spacer';
interface GroupProps extends PropsWithChildren {
name: string;
description?: ReactNode;
metaRight?: ReactNode;
actions?: ReactNode;
isOpen?: boolean;
onToggle: () => void;
}
export const Group = ({
name,
description,
onToggle,
isOpen = false,
metaRight = null,
actions = null,
children,
}: GroupProps) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.groupWrapper} role="treeitem" aria-expanded={isOpen} aria-selected="false">
<GroupHeader
onToggle={onToggle}
isOpen={isOpen}
description={description}
name={name}
metaRight={metaRight}
actions={actions}
/>
{isOpen && <div role="group">{children}</div>}
</div>
);
};
const GroupHeader = (props: GroupProps) => {
const { name, description, metaRight = null, actions = null, isOpen = false, onToggle } = props;
const styles = useStyles2(getStyles);
return (
<div className={styles.headerWrapper}>
<Stack direction="row" alignItems="center" gap={1}>
<Stack alignItems="center" gap={1}>
<IconButton
name={isOpen ? 'angle-right' : 'angle-down'}
onClick={onToggle}
aria-label={t('common.collapse', 'Collapse')}
/>
<Text truncate variant="body">
{name}
</Text>
</Stack>
{description}
<Spacer />
{metaRight}
{actions}
</Stack>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
groupWrapper: css({
display: 'flex',
flexDirection: 'column',
}),
headerWrapper: css({
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
background: theme.colors.background.secondary,
border: 'none',
borderBottom: `solid 1px ${theme.colors.border.weak}`,
borderTopLeftRadius: theme.shape.radius.default,
borderTopRightRadius: theme.shape.radius.default,
}),
});

View File

@ -0,0 +1,86 @@
import { css } from '@emotion/css';
import React, { ReactNode } from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack, Text, useStyles2 } from '@grafana/ui';
interface ListItemProps {
icon?: ReactNode;
title: ReactNode;
description?: ReactNode;
meta?: ReactNode[];
metaRight?: ReactNode[];
actions?: ReactNode;
}
export const ListItem = (props: ListItemProps) => {
const styles = useStyles2(getStyles);
const { icon = null, title, description, meta, metaRight, actions } = props;
return (
<li className={styles.alertListItemContainer} role="treeitem" aria-selected="false">
<Stack direction="row" alignItems="start" gap={1} wrap={false}>
{/* icon */}
{icon}
<Stack direction="column" gap={0} flex="1" minWidth={0}>
{/* title */}
<Stack direction="column" gap={0}>
<div className={styles.textOverflow}>{title}</div>
<div className={styles.textOverflow}>{description}</div>
</Stack>
{/* metadata */}
<Stack direction="row" gap={0.5} alignItems="center">
{meta?.map((item, index) => (
<React.Fragment key={index}>
{index > 0 && <Separator />}
{item}
</React.Fragment>
))}
</Stack>
</Stack>
{/* actions & meta right */}
<Stack direction="row" alignItems="center" gap={1} wrap={false}>
{/* @TODO move this so the metadata row can extend beyond the width of this column */}
{metaRight}
{actions}
</Stack>
</Stack>
</li>
);
};
export const SkeletonListItem = () => {
return (
<ListItem
icon={<Skeleton width={16} height={16} circle />}
title={<Skeleton height={16} width={350} />}
actions={<Skeleton height={10} width={200} />}
/>
);
};
const Separator = () => (
<Text color="secondary" variant="bodySmall">
{'·'}
</Text>
);
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)} ${theme.spacing(1)}`,
}),
textOverflow: css({
overflow: 'hidden',
textOverflow: 'ellipsis',
color: 'inherit',
}),
});

View File

@ -0,0 +1,95 @@
import { css } from '@emotion/css';
import { isEmpty } from 'lodash';
import { PropsWithChildren, ReactNode } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, Stack, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { Spacer } from '../../components/Spacer';
interface ListSectionProps extends PropsWithChildren {
title: ReactNode;
collapsed?: boolean;
actions?: ReactNode;
pagination?: ReactNode;
}
export const ListSection = ({
children,
title,
collapsed = false,
actions = null,
pagination = null,
}: ListSectionProps) => {
const styles = useStyles2(getStyles);
const [isCollapsed, toggleCollapsed] = useToggle(collapsed);
return (
<li className={styles.wrapper} role="treeitem" aria-selected="false">
<div className={styles.sectionTitle}>
<Stack alignItems="center">
<Stack alignItems="center" gap={1}>
<IconButton
name={isCollapsed ? 'angle-right' : 'angle-down'}
onClick={toggleCollapsed}
aria-label={t('common.collapse', 'Collapse')}
/>
{title}
</Stack>
{actions && (
<>
<Spacer />
{actions}
</>
)}
</Stack>
</div>
{!isEmpty(children) && !isCollapsed && (
<>
<ul role="group" className={styles.groupItemsWrapper}>
{children}
</ul>
{pagination}
</>
)}
</li>
);
};
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)}`,
},
}),
wrapper: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
sectionTitle: css({
padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`,
background: theme.colors.background.secondary,
border: `solid 1px ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
}),
});

View File

@ -2,10 +2,10 @@ import { css } from '@emotion/css';
import { PropsWithChildren } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Stack, TextLink, Icon } from '@grafana/ui';
import { Icon, Stack, TextLink, useStyles2 } from '@grafana/ui';
import { PromApplication, RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { WithReturnButton } from '../WithReturnButton';
import { WithReturnButton } from '../../components/WithReturnButton';
interface NamespaceProps extends PropsWithChildren {
name: string;

View File

@ -0,0 +1,87 @@
import { useEffect } from 'react';
import { useMeasure, useToggle } from 'react-use';
import { Alert, LoadingBar, Pagination } from '@grafana/ui';
import { RulerDataSourceConfig } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi';
import { usePagination } from '../../hooks/usePagination';
import { isAlertingRule } from '../../utils/rules';
import { AlertRuleListItem } from './AlertRuleListItem';
import EvaluationGroup from './EvaluationGroup';
import { SkeletonListItem } from './ListItem';
interface EvaluationGroupLoaderProps {
name: string;
interval?: string;
provenance?: string;
namespace: string;
rulerConfig?: RulerDataSourceConfig;
}
const ALERT_RULE_PAGE_SIZE = 15;
export const EvaluationGroupLoader = ({
name,
provenance,
interval,
namespace,
rulerConfig,
}: EvaluationGroupLoaderProps) => {
const [isOpen, toggle] = useToggle(false);
// TODO use Prometheus endpoint here?
const [fetchRulerRuleGroup, { currentData: promNamespace, isLoading, error }] =
alertRuleApi.endpoints.prometheusRuleNamespaces.useLazyQuery();
const promRules = promNamespace?.flatMap((namespace) => namespace.groups).flatMap((groups) => groups.rules);
const { page, pageItems, onPageChange, numberOfPages } = usePagination(promRules ?? [], 1, ALERT_RULE_PAGE_SIZE);
useEffect(() => {
if (isOpen && rulerConfig) {
fetchRulerRuleGroup({
namespace,
groupName: name,
ruleSourceName: rulerConfig.dataSourceName,
});
}
}, [fetchRulerRuleGroup, isOpen, name, namespace, rulerConfig]);
return (
<EvaluationGroup name={name} interval={interval} provenance={provenance} isOpen={isOpen} onToggle={toggle}>
<>
{/* @TODO nicer error handling */}
{error && <Alert title="Something went wrong when trying to fetch group details">{String(error)}</Alert>}
{isLoading ? (
<GroupLoadingIndicator />
) : (
pageItems.map((rule, index) => {
<AlertRuleListItem
key={index}
state={PromAlertingRuleState.Inactive}
name={rule.name}
href={'/'}
summary={isAlertingRule(rule) ? rule.annotations?.summary : undefined}
/>;
return null;
})
)}
{numberOfPages > 1 && <Pagination currentPage={page} numberOfPages={numberOfPages} onNavigate={onPageChange} />}
</>
</EvaluationGroup>
);
};
const GroupLoadingIndicator = () => {
const [ref, { width }] = useMeasure<HTMLDivElement>();
return (
<div ref={ref}>
<LoadingBar width={width} />
<SkeletonListItem />
</div>
);
};

View File

@ -1,11 +1,11 @@
import type { RequireAtLeastOne } from 'type-fest';
import { Tooltip, type IconName, Text, Icon } from '@grafana/ui';
import { Icon, Text, Tooltip, type IconName } 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';
import { isErrorHealth } from '../../components/rule-viewer/RuleViewer';
interface RuleListIconProps {
recording?: boolean;

View File

@ -118,6 +118,10 @@ export function isEditableRuleIdentifier(identifier: RuleIdentifier): identifier
return isGrafanaRuleIdentifier(identifier) || isCloudRuleIdentifier(identifier);
}
export function isProvisionedRule(rulerRule: RulerRuleDTO): boolean {
return isGrafanaRulerRule(rulerRule) && Boolean(rulerRule.grafana_alert.provenance);
}
export function getRuleHealth(health: string): RuleHealth | undefined {
switch (health) {
case 'ok':

View File

@ -71,6 +71,12 @@
}
}
},
"alert-rules": {
"firing-for": "Firing for",
"next-evaluation": "Next evaluation",
"next-evaluation-in": "next evaluation in",
"rule-definition": "Rule definition"
},
"alertform": {
"labels": {
"alerting": "Add labels to your rule for searching, silencing, or routing to a notification policy.",
@ -141,6 +147,7 @@
"provisioned": "Contact point is provisioned and cannot be deleted via the UI",
"rules": "Contact point is referenced by one or more alert rules"
},
"delivered-to": "Delivered to",
"delivery-duration": "Last delivery took <1></1>",
"empty-state": {
"title": "You don't have any contact points yet"
@ -473,11 +480,14 @@
"cancel": "Cancel",
"clear": "Clear",
"close": "Close",
"collapse": "Collapse",
"edit": "Edit",
"locale": {
"default": "Default"
},
"save": "Save",
"search": "Search"
"search": "Search",
"view": "View"
},
"configuration-tracker": {
"config-card": {

View File

@ -71,6 +71,12 @@
}
}
},
"alert-rules": {
"firing-for": "Fįřįʼnģ ƒőř",
"next-evaluation": "Ńęχŧ ęväľūäŧįőʼn",
"next-evaluation-in": "ʼnęχŧ ęväľūäŧįőʼn įʼn",
"rule-definition": "Ŗūľę đęƒįʼnįŧįőʼn"
},
"alertform": {
"labels": {
"alerting": "Åđđ ľäþęľş ŧő yőūř řūľę ƒőř şęäřčĥįʼnģ, şįľęʼnčįʼnģ, őř řőūŧįʼnģ ŧő ä ʼnőŧįƒįčäŧįőʼn pőľįčy.",
@ -141,6 +147,7 @@
"provisioned": "Cőʼnŧäčŧ pőįʼnŧ įş přővįşįőʼnęđ äʼnđ čäʼnʼnőŧ þę đęľęŧęđ vįä ŧĥę ŮĨ",
"rules": "Cőʼnŧäčŧ pőįʼnŧ įş řęƒęřęʼnčęđ þy őʼnę őř mőřę äľęřŧ řūľęş"
},
"delivered-to": "Đęľįvęřęđ ŧő",
"delivery-duration": "Ŀäşŧ đęľįvęřy ŧőőĸ <1></1>",
"empty-state": {
"title": "Ÿőū đőʼn'ŧ ĥävę äʼny čőʼnŧäčŧ pőįʼnŧş yęŧ"
@ -473,11 +480,14 @@
"cancel": "Cäʼnčęľ",
"clear": "Cľęäř",
"close": "Cľőşę",
"collapse": "Cőľľäpşę",
"edit": "Ēđįŧ",
"locale": {
"default": "Đęƒäūľŧ"
},
"save": "Ŝävę",
"search": "Ŝęäřčĥ"
"search": "Ŝęäřčĥ",
"view": "Vįęŵ"
},
"configuration-tracker": {
"config-card": {