mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: New alert state list view (#94068)
This commit is contained in:
parent
f671ad51fe
commit
c42f42223a
@ -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"]
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
}),
|
||||
});
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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)}`,
|
||||
}),
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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),
|
||||
}),
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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,
|
||||
}),
|
||||
});
|
@ -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={
|
@ -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;
|
||||
|
@ -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,
|
||||
}),
|
||||
});
|
@ -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',
|
||||
}),
|
||||
});
|
@ -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,
|
||||
}),
|
||||
});
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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':
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
Loading…
Reference in New Issue
Block a user