diff --git a/.betterer.results b/.betterer.results
index d5e479c07ba..2b1bffc5496 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -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 ", "0"]
],
- "public/app/features/alerting/unified/components/rule-list/AlertRuleListItem.tsx:5381": [
- [0, 0, 0, "No untranslated strings. Wrap text with ", "0"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "1"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "2"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "3"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "4"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "5"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "6"]
- ],
- "public/app/features/alerting/unified/components/rule-list/EvaluationGroup.tsx:5381": [
- [0, 0, 0, "No untranslated strings. Wrap text with ", "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 ", "1"]
- ],
- "public/app/features/alerting/unified/components/rule-list/RuleList.v2.tsx:5381": [
- [0, 0, 0, "No untranslated strings. Wrap text with ", "0"]
- ],
"public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with ", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "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 ", "1"]
+ ],
+ "public/app/features/alerting/unified/rule-list/RuleList.v2.tsx:5381": [
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "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"]
diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx
index 54ed2487908..4c6a1cae7ba 100644
--- a/public/app/features/alerting/unified/RuleList.tsx
+++ b/public/app/features/alerting/unified/RuleList.tsx
@@ -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;
diff --git a/public/app/features/alerting/unified/components/rule-list/AlertRuleListItem.tsx b/public/app/features/alerting/unified/components/rule-list/AlertRuleListItem.tsx
deleted file mode 100644
index b1cdbd46253..00000000000
--- a/public/app/features/alerting/unified/components/rule-list/AlertRuleListItem.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
-
- {name}
-
- {/* let's not show labels for now, but maybe users would be interested later? Or maybe show them only in the list view? */}
- {/* {labels && } */}
-
-
-
-
-
-
- {/* show evaluation-related metadata if the rule isn't paused – paused rules don't have instances and shouldn't show evaluation timestamps */}
- {!isPaused && (
- <>
-
-
-
- {pluralize('instance', instancesCount, true)}
-
-
- >
- )}
-
- {/* show label count */}
- {!isEmpty(labels) && (
-
-
- {pluralize('label', labelsSize(labels), true)}
-
-
- )}
-
- {/* show if the alert rule is using direct contact point or notification policy routing, not for paused rules or recording rules */}
- {contactPoint && !isPaused && (
-
- Delivered to{' '}
-
- {contactPoint}
-
-
- )}
-
-
-
-
-
-
- Edit
-
-
-
-
-
-
-
- }
- >
-
-
-
-
-
- );
-};
-
-interface SummaryProps {
- content?: string;
- error?: string;
-}
-
-function Summary({ content, error }: SummaryProps) {
- if (error) {
- return (
-
- {error}
-
- );
- }
- if (content) {
- return (
-
- {content}
-
- );
- }
-
- 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 (
-
-
-
-
-
-
-
-
- {name}
-
- {/* {labels && } */}
-
-
-
-
-
-
- {!isEmpty(labels) && (
-
-
- {pluralize('label', labelsSize(labels), true)}
-
-
- )}
-
-
-
-
-
- Edit
-
-
-
-
-
- }
- >
-
-
-
-
-
- );
-};
-
-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 (
-
- Firing for {firingFor}
- {nextEvaluation && <>· next evaluation in {nextEvaluation.humanized}>}
-
- );
- }
-
- // 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 Next evaluation {nextEvaluation.humanized} ;
- }
-
- 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 (
-
-
- Rule definition
-
- {JSON.stringify(rule.rulerRule, null, 2)}
-
-
-
- );
-};
-
-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,
- }),
-});
diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx
index 4c2efd64e58..f317be6043a 100644
--- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx
+++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx
@@ -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 (
-
+
- {ruleOrigin && }
+ {ruleOrigin && }
{name}
@@ -264,7 +262,7 @@ export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigi
{isRecordingRule && }
>
)}
-
+
);
};
@@ -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;
diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx
index 452ec78496e..0345383789e 100644
--- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx
+++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx
@@ -159,191 +159,186 @@ const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => {
const searchIcon = ;
return (
-
-
-
-
-
- Search by data sources
-
-
- Data sources containing configured alert rules are Mimir or Loki data sources where alert
- rules are stored and evaluated in the data source itself.
-
-
- 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.
-
-
- }
- >
-
-
-
-
- }
- >
-
-
-
- Dashboard}
- >
- {/* The key prop is to clear the picker value */}
- {/* DashboardPicker doesn't do that itself when value is undefined */}
- handleDashboardChange(value?.uid)}
- isClearable
- cacheOptions
- />
-
-
-
- State
-
-
-
- Rule type
-
-
-
- Health
-
-
- {canRenderContactPointSelector && (
-
-
-
- Contact point
-
+
+
+
+
+ Search by data sources
+
+
+ Data sources containing configured alert rules are Mimir or Loki data sources where alert rules
+ are stored and evaluated in the data source itself.
+
+
+ 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.
+
+
}
>
- {
- handleContactPointChange(selectValue?.value?.name!);
- },
- isClearable: true,
- }}
+
-
+
-
- )}
- {pluginsFilterEnabled && (
-
- Plugin rules
-
- options={[
- { label: 'Show', value: undefined },
- { label: 'Hide', value: 'hide' },
- ]}
- value={filterState.plugins}
- onChange={(value) => updateFilters({ ...filterState, plugins: value })}
- />
-
- )}
-
+
+ }
+ >
+
+
-
-
-
-
- View as
-
-
-
- {hasActiveFilters && (
-
-
- Clear filters
-
-
- )}
-
+
+
+ )}
+ {pluginsFilterEnabled && (
+
+ Plugin rules
+
+ options={[
+ { label: 'Show', value: undefined },
+ { label: 'Hide', value: 'hide' },
+ ]}
+ value={filterState.plugins}
+ onChange={(value) => updateFilters({ ...filterState, plugins: value })}
+ />
+
+ )}
-
+
+
+
+
+
+ View as
+
+
+
+ {hasActiveFilters && (
+
+
+ Clear filters
+
+
+ )}
+
+
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
- container: css({
- marginBottom: theme.spacing(1),
- }),
dsPickerContainer: css({
width: theme.spacing(60),
flexGrow: 0,
diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx
index 3658ce385c9..eb0cededcba 100644
--- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx
@@ -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(
- {!compact && 'View'}
+ View
);
}
@@ -94,24 +87,14 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton
const editURL = createRelativeUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`);
buttons.push(
-
- {!compact && 'Edit'}
+
+ Edit
);
}
return (
-
+
{buttons}
);
};
-
-const getStyles = (theme: GrafanaTheme2) => ({
- compactButton: css({
- padding: `0 ${theme.spacing(2)}`,
- }),
-});
diff --git a/public/app/features/alerting/unified/components/rules/RuleListStateView.test.tsx b/public/app/features/alerting/unified/components/rules/RuleListStateView.test.tsx
index b14286d76a2..260c7436a18 100644
--- a/public/app/features/alerting/unified/components/rules/RuleListStateView.test.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleListStateView.test.tsx
@@ -52,8 +52,8 @@ describe('RuleListStateView', () => {
it('renders differing prom rule states correctly and does not crash with missing state', () => {
render( );
- 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();
});
});
diff --git a/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx b/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx
index d09f998d1a7..03db5349c7e 100644
--- a/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx
@@ -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;
+type GroupedRules = Map;
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) && (
-
- )}
- {(!filters.alertState || filters.alertState === PromAlertingRuleState.Pending) && (
-
- )}
- {(!filters.alertState || filters.alertState === PromAlertingRuleState.Inactive) && (
-
- )}
- >
+
+ {Array.from(entries).map(([state, rules]) => (
+
+ ))}
+
);
};
+
+const STATE_TITLES: Record = {
+ [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 (
+
+ {STATE_TITLES[state] ?? 'Unknown'}
+
+
+ }
+ collapsed={isFiringState || hasRulesMatchingState}
+ pagination={
+
+ }
+ >
+ {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 (
+ }
+ origin={originMeta}
+ />
+ );
+ })}
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ columnStack: css({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(1),
+ }),
+});
diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx
index eb6f2b68ba0..418afa0801b 100644
--- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx
+++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx
@@ -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 }) => ,
- size: '200px',
+ size: '215px',
});
return columns;
diff --git a/public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx b/public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx
index 64967c68adf..4f7a853fc95 100644
--- a/public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx
+++ b/public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx
@@ -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 ? (
-
+
) : (
);
- const tooltipContent = pluginMeta
- ? `This rule is managed by the ${pluginMeta?.name} plugin`
- : `This rule is managed by a plugin`;
-
- return (
-
- {badgeIcon}
-
- );
+ return {badgeIcon} ;
}
diff --git a/public/app/features/alerting/unified/components/rule-list/RuleList.v1.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx
similarity index 71%
rename from public/app/features/alerting/unified/components/rule-list/RuleList.v1.tsx
rename to public/app/features/alerting/unified/rule-list/RuleList.v1.tsx
index 57c56c3642d..2d565d3f259 100644
--- a/public/app/features/alerting/unified/components/rule-list/RuleList.v1.tsx
+++ b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx
@@ -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
}>
-
-
- {hasAlertRulesCreated && (
-
- {view === 'groups' && hasActiveFilters && (
- setExpandAll(!expandAll)}
- >
- {expandAll ? 'Collapse all' : 'Expand all'}
-
- )}
-
-
- )}
- {hasNoAlertRulesCreatedYet && }
- {hasAlertRulesCreated && }
+
+
+
+ {hasAlertRulesCreated && (
+
+ {view === 'groups' && hasActiveFilters && (
+ setExpandAll(!expandAll)}
+ >
+ {expandAll ? 'Collapse all' : 'Expand all'}
+
+ )}
+
+ )}
+
+ {hasNoAlertRulesCreatedYet && }
+ {hasAlertRulesCreated && }
+
);
};
diff --git a/public/app/features/alerting/unified/components/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
similarity index 84%
rename from public/app/features/alerting/unified/components/rule-list/RuleList.v2.tsx
rename to public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
index de62ae914ae..f414245413f 100644
--- a/public/app/features/alerting/unified/components/rule-list/RuleList.v2.tsx
+++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
@@ -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;
diff --git a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx
new file mode 100644
index 00000000000..3e89460e872
--- /dev/null
+++ b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx
@@ -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(
+
+
+
+ );
+ }
+
+ if (!isPaused) {
+ if (lastEvaluation && evaluationInterval) {
+ metadata.push(
+
+ );
+ }
+
+ if (instancesCount) {
+ metadata.push(
+
+
+ {pluralize('instance', instancesCount, true)}
+
+
+ );
+ }
+ }
+
+ if (!isEmpty(labels)) {
+ metadata.push(
+
+
+ {pluralize('label', labelsSize(labels), true)}
+
+
+ );
+ }
+
+ if (!isPaused && contactPoint) {
+ metadata.push(
+
+ Delivered to {' '}
+
+ {contactPoint}
+
+
+ );
+ }
+
+ return (
+
+
+ {name}
+
+ {origin && }
+ {/* show provisioned badge only when it also doesn't have plugin origin */}
+ {isProvisioned && !origin && }
+ {/* let's not show labels for now, but maybe users would be interested later? Or maybe show them only in the list view? */}
+ {/* {labels && } */}
+
+ }
+ description={ }
+ icon={ }
+ actions={actions}
+ meta={metadata}
+ />
+ );
+};
+
+interface SummaryProps {
+ content?: string;
+ error?: string;
+}
+
+function Summary({ content, error }: SummaryProps) {
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+ if (content) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ 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 (
+
+ Firing for {firingFor}
+ {nextEvaluation && (
+ <>
+ {'· '}
+ next evaluation in {' '}
+ {nextEvaluation.humanized}
+ >
+ )}
+
+ );
+ }
+
+ // 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 (
+
+ Next evaluation {nextEvaluation.humanized}
+
+ );
+ }
+
+ 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 (
+
+
+
+ Rule definition
+
+
+ {JSON.stringify(rule.rulerRule, null, 2)}
+
+
+
+ );
+};
+
+interface RuleLocationProps {
+ namespace: CombinedRuleNamespace;
+ group: string;
+}
+
+export const RuleLocation = ({ namespace, group }: RuleLocationProps) => (
+
+
+
+ {namespace.name}
+
+ {group}
+
+
+);
+
+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,
+ }),
+});
diff --git a/public/app/features/alerting/unified/components/rule-list/EvaluationGroup.tsx b/public/app/features/alerting/unified/rule-list/components/EvaluationGroup.tsx
similarity index 87%
rename from public/app/features/alerting/unified/components/rule-list/EvaluationGroup.tsx
rename to public/app/features/alerting/unified/rule-list/components/EvaluationGroup.tsx
index 15969398870..49903bf261b 100644
--- a/public/app/features/alerting/unified/components/rule-list/EvaluationGroup.tsx
+++ b/public/app/features/alerting/unified/rule-list/components/EvaluationGroup.tsx
@@ -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,
{interval && {interval} }
- Edit
+ Edit
- );
- }
-
if (isGrafanaRulerRule(rulerRule)) {
const contactPoint = rulerRule.grafana_alert.notification_settings?.receiver;
diff --git a/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx b/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx
new file mode 100644
index 00000000000..76db0878f5d
--- /dev/null
+++ b/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx
@@ -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 (
+
+
+ {isOpen &&
{children}
}
+
+ );
+};
+
+const GroupHeader = (props: GroupProps) => {
+ const { name, description, metaRight = null, actions = null, isOpen = false, onToggle } = props;
+
+ const styles = useStyles2(getStyles);
+
+ return (
+
+
+
+
+
+ {name}
+
+
+
+ {description}
+
+ {metaRight}
+ {actions}
+
+
+ );
+};
+
+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,
+ }),
+});
diff --git a/public/app/features/alerting/unified/rule-list/components/ListItem.tsx b/public/app/features/alerting/unified/rule-list/components/ListItem.tsx
new file mode 100644
index 00000000000..5340ff57ede
--- /dev/null
+++ b/public/app/features/alerting/unified/rule-list/components/ListItem.tsx
@@ -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 (
+
+
+ {/* icon */}
+ {icon}
+
+
+ {/* title */}
+
+ {title}
+ {description}
+
+
+ {/* metadata */}
+
+ {meta?.map((item, index) => (
+
+ {index > 0 && }
+ {item}
+
+ ))}
+
+
+
+ {/* actions & meta right */}
+
+ {/* @TODO move this so the metadata row can extend beyond the width of this column */}
+ {metaRight}
+ {actions}
+
+
+
+ );
+};
+
+export const SkeletonListItem = () => {
+ return (
+ }
+ title={ }
+ actions={ }
+ />
+ );
+};
+
+const Separator = () => (
+
+ {'·'}
+
+);
+
+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',
+ }),
+});
diff --git a/public/app/features/alerting/unified/rule-list/components/ListSection.tsx b/public/app/features/alerting/unified/rule-list/components/ListSection.tsx
new file mode 100644
index 00000000000..1e9739f81b6
--- /dev/null
+++ b/public/app/features/alerting/unified/rule-list/components/ListSection.tsx
@@ -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 (
+
+
+
+
+
+ {title}
+
+ {actions && (
+ <>
+
+ {actions}
+ >
+ )}
+
+
+ {!isEmpty(children) && !isCollapsed && (
+ <>
+
+ {pagination}
+ >
+ )}
+
+ );
+};
+
+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,
+ }),
+});
diff --git a/public/app/features/alerting/unified/components/rule-list/Namespace.tsx b/public/app/features/alerting/unified/rule-list/components/Namespace.tsx
similarity index 95%
rename from public/app/features/alerting/unified/components/rule-list/Namespace.tsx
rename to public/app/features/alerting/unified/rule-list/components/Namespace.tsx
index a84ebe9c05e..ff620eeee93 100644
--- a/public/app/features/alerting/unified/components/rule-list/Namespace.tsx
+++ b/public/app/features/alerting/unified/rule-list/components/Namespace.tsx
@@ -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;
diff --git a/public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx b/public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx
new file mode 100644
index 00000000000..00a1108eba7
--- /dev/null
+++ b/public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx
@@ -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 (
+
+ <>
+ {/* @TODO nicer error handling */}
+ {error && {String(error)} }
+ {isLoading ? (
+
+ ) : (
+ pageItems.map((rule, index) => {
+ ;
+
+ return null;
+ })
+ )}
+ {numberOfPages > 1 && }
+ >
+
+ );
+};
+
+const GroupLoadingIndicator = () => {
+ const [ref, { width }] = useMeasure();
+
+ return (
+
+
+
+
+ );
+};
diff --git a/public/app/features/alerting/unified/components/rule-list/RuleListIcon.tsx b/public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx
similarity index 94%
rename from public/app/features/alerting/unified/components/rule-list/RuleListIcon.tsx
rename to public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx
index bbecda09ebe..66601fd1923 100644
--- a/public/app/features/alerting/unified/components/rule-list/RuleListIcon.tsx
+++ b/public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx
@@ -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;
diff --git a/public/app/features/alerting/unified/components/rule-list/util.test.tsx b/public/app/features/alerting/unified/rule-list/components/util.test.tsx
similarity index 100%
rename from public/app/features/alerting/unified/components/rule-list/util.test.tsx
rename to public/app/features/alerting/unified/rule-list/components/util.test.tsx
diff --git a/public/app/features/alerting/unified/components/rule-list/util.ts b/public/app/features/alerting/unified/rule-list/components/util.ts
similarity index 100%
rename from public/app/features/alerting/unified/components/rule-list/util.ts
rename to public/app/features/alerting/unified/rule-list/components/util.ts
diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts
index 0a716dfe64f..6e18da1826a 100644
--- a/public/app/features/alerting/unified/utils/rules.ts
+++ b/public/app/features/alerting/unified/utils/rules.ts
@@ -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':
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 460320276da..75f25db860a 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -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": {
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index 44c71ecee91..8ba13075e66 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -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": {