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} - - - )} - -
    -
    - - - - - - - - - - } - > - - - -
    -
  • - ); -}; - -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)} - - - )} - -
    - - - - - - - - } - > - - - - -
  • - ); -}; - -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 - /> - - -
    - - -
    -
    - - -
    -
    - - -
    - {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 && ( -
    - - - options={[ - { label: 'Show', value: undefined }, - { label: 'Hide', value: 'hide' }, - ]} - value={filterState.plugins} - onChange={(value) => updateFilters({ ...filterState, plugins: value })} - /> -
    - )} - + + } + > + + - - -
    { - setSearchQuery(data.searchQuery); - searchQueryRef.current?.blur(); - trackRulesSearchInputInteraction({ oldQuery: searchQuery, newQuery: data.searchQuery }); - })} - > + 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 + /> + + +
    + + +
    +
    + + +
    +
    + + +
    + {canRenderContactPointSelector && ( + + - - Search - }> - - - + } > - { - ref(e); - searchQueryRef.current = e; + { + handleContactPointChange(selectValue?.value?.name!); + }, + isClearable: true, }} - {...rest} - placeholder="Search" - data-testid="search-query-input" /> - - -
    - - -
    -
    - {hasActiveFilters && ( -
    - -
    - )} -
    +
    + + )} + {pluginsFilterEnabled && ( +
    + + + options={[ + { label: 'Show', value: undefined }, + { label: 'Hide', value: 'hide' }, + ]} + value={filterState.plugins} + onChange={(value) => updateFilters({ ...filterState, plugins: value })} + /> +
    + )} - + + + +
    { + setSearchQuery(data.searchQuery); + searchQueryRef.current?.blur(); + trackRulesSearchInputInteraction({ oldQuery: searchQuery, newQuery: data.searchQuery }); + })} + > + + + Search + }> + + + + + } + > + { + ref(e); + searchQueryRef.current = e; + }} + {...rest} + placeholder="Search" + data-testid="search-query-input" + /> + + +
    +
    + + +
    +
    + {hasActiveFilters && ( +
    + +
    + )} +
    + ); }; 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 ? ( - {pluginMeta?.name} + {pluginName} ) : ( ); - 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 && ( - - )} - - - )} - {hasNoAlertRulesCreatedYet && } - {hasAlertRulesCreated && } + + + + {hasAlertRulesCreated && ( + + {view === 'groups' && hasActiveFilters && ( + + )} + + )} + + {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}} - ); - } - 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 && ( + <> +
      + {children} +
    + {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>", "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>", "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": {