diff --git a/.betterer.results b/.betterer.results index 694b989fa69..15b1090b3fe 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1232,9 +1232,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/features/alerting/unified/RuleList.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/alerting/unified/components/AnnotationDetailsField.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -1299,6 +1296,9 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/alerting/unified/components/rule-list/RuleList.v1.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/alerting/unified/components/silences/SilencesEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 127edc25de1..dcc598e8845 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -188,6 +188,7 @@ Experimental features might be changed or removed without prior notice. | `autofixDSUID` | Automatically migrates invalid datasource UIDs | | `logsExploreTableDefaultVisualization` | Sets the logs table as default visualisation in logs explore | | `newDashboardSharingComponent` | Enables the new sharing drawer design | +| `alertingListViewV2` | Enables the new alert list view design | | `notificationBanner` | Enables the notification banner UI and API | ## Development feature toggles diff --git a/package.json b/package.json index d81c473269b..44d225eec44 100644 --- a/package.json +++ b/package.json @@ -393,6 +393,7 @@ "tinycolor2": "1.6.0", "tslib": "2.6.2", "tween-functions": "^1.2.0", + "type-fest": "^4.18.2", "uplot": "1.6.30", "uuid": "9.0.1", "visjs-network": "4.25.0", diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 8792e5b55b9..3e450d7ca88 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -188,6 +188,7 @@ export interface FeatureToggles { autofixDSUID?: boolean; logsExploreTableDefaultVisualization?: boolean; newDashboardSharingComponent?: boolean; + alertingListViewV2?: boolean; notificationBanner?: boolean; dashboardRestore?: boolean; datasourceProxyDisableRBAC?: boolean; diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index f88699833d8..ca8f4bb9bbd 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -185,6 +185,7 @@ export const availableIconsIndex = { paragraph: true, 'pathfinder-unite': true, pause: true, + 'pause-circle': true, pen: true, percentage: true, play: true, diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d62367f9821..f4dd16ad9ea 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1267,6 +1267,13 @@ var ( Owner: grafanaSharingSquad, FrontendOnly: true, }, + { + Name: "alertingListViewV2", + Description: "Enables the new alert list view design", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + FrontendOnly: true, + }, { Name: "notificationBanner", Description: "Enables the notification banner UI and API", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index a2a8db91b7c..8cee18735f7 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -169,6 +169,7 @@ queryLibrary,experimental,@grafana/explore-squad,false,false,false autofixDSUID,experimental,@grafana/plugins-platform-backend,false,false,false logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true newDashboardSharingComponent,experimental,@grafana/sharing-squad,false,false,true +alertingListViewV2,experimental,@grafana/alerting-squad,false,false,true notificationBanner,experimental,@grafana/grafana-frontend-platform,false,false,false dashboardRestore,experimental,@grafana/grafana-frontend-platform,false,false,false datasourceProxyDisableRBAC,GA,@grafana/identity-access-team,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 128f6d145cd..a49679849d1 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -687,6 +687,10 @@ const ( // Enables the new sharing drawer design FlagNewDashboardSharingComponent = "newDashboardSharingComponent" + // FlagAlertingListViewV2 + // Enables the new alert list view design + FlagAlertingListViewV2 = "alertingListViewV2" + // FlagNotificationBanner // Enables the notification banner UI and API FlagNotificationBanner = "notificationBanner" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index de752352db1..f8fdb103952 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2237,6 +2237,19 @@ "codeowner": "@grafana/grafana-app-platform-squad", "requiresRestart": true } + }, + { + "metadata": { + "name": "alertingListViewV2", + "resourceVersion": "1716558084235", + "creationTimestamp": "2024-05-24T13:41:24Z" + }, + "spec": { + "description": "Enables the new alert list view design", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "frontend": true + } } ] } \ No newline at end of file diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 528febf8ebb..97bb8b7eece 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -162,7 +162,7 @@ const ui = { paused: byText(/^Paused/), }, actionButtons: { - more: byRole('button', { name: /more-actions/ }), + more: byRole('button', { name: /More/ }), }, moreActionItems: { pause: byRole('menuitem', { name: /pause evaluation/i }), diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index 3788ecb63f8..94dd35e71b5 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -1,185 +1,14 @@ -import { css } from '@emotion/css'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useLocation } from 'react-router-dom'; -import { useAsyncFn, useInterval } from 'react-use'; +import React, { Suspense } from 'react'; -import { GrafanaTheme2, urlUtil } from '@grafana/data'; -import { Button, LinkButton, useStyles2, withErrorBoundary } from '@grafana/ui'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; -import { useDispatch } from 'app/types'; +import { config } from '@grafana/runtime'; -import { CombinedRuleNamespace } from '../../../types/unified-alerting'; +import RuleListV1 from './components/rule-list/RuleList.v1'; +const RuleListV2 = React.lazy(() => import('./components/rule-list/RuleList.v2')); -import { LogMessages, logInfo, trackRuleListNavigation } from './Analytics'; -import { AlertingPageWrapper } from './components/AlertingPageWrapper'; -import { NoRulesSplash } from './components/rules/NoRulesCTA'; -import { INSTANCES_DISPLAY_LIMIT } from './components/rules/RuleDetails'; -import { RuleListErrors } from './components/rules/RuleListErrors'; -import { RuleListGroupView } from './components/rules/RuleListGroupView'; -import { RuleListStateView } from './components/rules/RuleListStateView'; -import { RuleStats } from './components/rules/RuleStats'; -import RulesFilter from './components/rules/RulesFilter'; -import { AlertingAction, useAlertingAbility } from './hooks/useAbilities'; -import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; -import { useFilteredRules, useRulesFilter } from './hooks/useFilteredRules'; -import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchAllPromAndRulerRulesAction } from './state/actions'; -import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants'; -import { getAllRulesSourceNames } from './utils/datasource'; +const RuleList = () => { + const newView = config.featureToggles.alertingListViewV2; -const VIEWS = { - groups: RuleListGroupView, - state: RuleListStateView, + return {newView ? : }; }; -// make sure we ask for 1 more so we show the "show x more" button -const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1; - -const RuleList = withErrorBoundary( - () => { - const dispatch = useDispatch(); - const styles = useStyles2(getStyles); - const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []); - const [expandAll, setExpandAll] = useState(false); - - const onFilterCleared = useCallback(() => setExpandAll(false), []); - - const [queryParams] = useQueryParams(); - const { filterState, hasActiveFilters } = useRulesFilter(); - - const queryParamView = queryParams['view'] as keyof typeof VIEWS; - const view = VIEWS[queryParamView] ? queryParamView : 'groups'; - - const ViewComponent = VIEWS[view]; - - const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); - const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); - - const loading = rulesDataSourceNames.some( - (name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading - ); - - const promRequests = Object.entries(promRuleRequests); - const rulerRequests = Object.entries(rulerRuleRequests); - - const allPromLoaded = promRequests.every( - ([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined) - ); - const allRulerLoaded = rulerRequests.every( - ([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined) - ); - - const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0); - - const allRulerEmpty = rulerRequests.every(([_, state]) => { - const rulerRules = Object.entries(state?.result ?? {}); - const noRules = rulerRules.every(([_, result]) => result?.length === 0); - return noRules && state.dispatched; - }); - - const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS; - // Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED - const [_, fetchRules] = useAsyncFn(async () => { - if (!loading) { - await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); - } - }, [loading, limitAlerts, dispatch]); - - useEffect(() => { - trackRuleListNavigation().catch(() => {}); - }, []); - - // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS - useEffect(() => { - dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); - }, [dispatch, limitAlerts]); - useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS); - - // Show splash only when we loaded all of the data sources and none of them has alerts - const hasNoAlertRulesCreatedYet = - allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded; - const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet; - - const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces(); - const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState); - return ( - // We don't want to show the Loading... indicator for the whole page. - // We show separate indicators for Grafana-managed and Cloud rules - }> - - - {hasAlertRulesCreated && ( - <> -
-
-
- {view === 'groups' && hasActiveFilters && ( - - )} - -
-
- - )} - {hasNoAlertRulesCreatedYet && } - {hasAlertRulesCreated && } - - ); - }, - { style: 'page' } -); - -const getStyles = (theme: GrafanaTheme2) => ({ - break: css({ - width: '100%', - height: 0, - marginBottom: theme.spacing(2), - borderBottom: `solid 1px ${theme.colors.border.medium}`, - }), - buttonsContainer: css({ - marginBottom: theme.spacing(2), - display: 'flex', - justifyContent: 'space-between', - }), - statsContainer: css({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - }), - expandAllButton: css({ - marginRight: theme.spacing(1), - }), -}); - export default RuleList; - -export function CreateAlertButton() { - const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); - const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); - - const location = useLocation(); - - const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; - - const canCreateGrafanaRules = createRuleSupported && createRuleAllowed; - - if (canCreateGrafanaRules || canCreateCloudRules) { - return ( - logInfo(LogMessages.alertRuleFromScratch)} - > - New alert rule - - ); - } - return null; -} diff --git a/public/app/features/alerting/unified/components/Label.tsx b/public/app/features/alerting/unified/components/Label.tsx index 7df0187a9ca..3d1b4d11d39 100644 --- a/public/app/features/alerting/unified/components/Label.tsx +++ b/public/app/features/alerting/unified/components/Label.tsx @@ -1,11 +1,11 @@ import { css } from '@emotion/css'; -import React, { ReactNode } from 'react'; +import React, { CSSProperties, ReactNode } from 'react'; import tinycolor2 from 'tinycolor2'; import { GrafanaTheme2, IconName } from '@grafana/data'; import { Icon, Stack, useStyles2 } from '@grafana/ui'; -export type LabelSize = 'md' | 'sm'; +export type LabelSize = 'md' | 'sm' | 'xs'; interface Props { icon?: IconName; @@ -58,8 +58,18 @@ const getStyles = (theme: GrafanaTheme2, color?: string, size?: string) => { ? tinycolor2.mostReadable(backgroundColor, ['#000', '#fff']).toString() : theme.colors.text.primary; - const padding = - size === 'md' ? `${theme.spacing(0.33)} ${theme.spacing(1)}` : `${theme.spacing(0.2)} ${theme.spacing(0.6)}`; + let padding: CSSProperties['padding'] = theme.spacing(0.33, 1); + + switch (size) { + case 'sm': + padding = theme.spacing(0.2, 0.6); + break; + case 'xs': + padding = theme.spacing(0, 0.5); + break; + default: + break; + } return { wrapper: css({ diff --git a/public/app/features/alerting/unified/components/MetaText.tsx b/public/app/features/alerting/unified/components/MetaText.tsx index a1d564a5afa..4615d5ca2ac 100644 --- a/public/app/features/alerting/unified/components/MetaText.tsx +++ b/public/app/features/alerting/unified/components/MetaText.tsx @@ -27,7 +27,7 @@ const MetaText = ({ children, icon, color = 'secondary', direction = 'row', ...r > - {icon && } + {icon && } {children} diff --git a/public/app/features/alerting/unified/components/MoreButton.tsx b/public/app/features/alerting/unified/components/MoreButton.tsx index 2fea06edbc3..7b41fe61fda 100644 --- a/public/app/features/alerting/unified/components/MoreButton.tsx +++ b/public/app/features/alerting/unified/components/MoreButton.tsx @@ -4,15 +4,7 @@ import { Button, ButtonProps, Icon, Stack } from '@grafana/ui'; const MoreButton = forwardRef(function MoreButton(props: ButtonProps, ref: Ref) { return ( - + + + + + + + } + > + + + + + + ); +}; + +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; + recording?: boolean; + state?: PromAlertingRuleState; + labels?: Labels; + isProvisioned?: boolean; + lastEvaluation?: string; + evaluationInterval?: string; + evaluationDuration?: number; +} + +// @TODO split in to smaller re-usable bits +export const RecordingRuleListItem = ({ + name, + error, + state, + health, + isProvisioned, + href, + labels, + lastEvaluation, + evaluationInterval, +}: RecordingRuleListItemProps) => { + const styles = useStyles2(getStyles); + + return ( +
  • + + + + + + + + {name} + + {/* {labels && } */} + + + +
    + + + {!isEmpty(labels) && ( + + + {pluralize('label', labelsSize(labels), true)} + + + )} + +
    + + + + + + + + } + > + + + + +
  • + ); +}; + +interface EvaluationMetadataProps { + lastEvaluation?: string; + evaluationInterval?: string; + state?: PromAlertingRuleState; + health?: RuleHealth; + error?: string; // if health is "error" this should have error details for us +} + +function EvaluationMetadata({ lastEvaluation, evaluationInterval, state }: EvaluationMetadataProps) { + const nextEvaluation = calculateNextEvaluationEstimate(lastEvaluation, evaluationInterval); + + // @TODO support firing for calculation + if (state === PromAlertingRuleState.Firing && nextEvaluation) { + const firingFor = '2m 34s'; + + return ( + + 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-list/EvaluationGroup.tsx b/public/app/features/alerting/unified/components/rule-list/EvaluationGroup.tsx new file mode 100644 index 00000000000..68419674e94 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-list/EvaluationGroup.tsx @@ -0,0 +1,81 @@ +import { css, cx } from '@emotion/css'; +import React, { PropsWithChildren } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, Badge, Button, Dropdown, Menu, Stack, Text, Icon } from '@grafana/ui'; + +import { MetaText } from '../MetaText'; +import MoreButton from '../MoreButton'; +import { Spacer } from '../Spacer'; + +interface EvaluationGroupProps extends PropsWithChildren { + name: string; + interval?: string; + provenance?: string; + isOpen?: boolean; + onToggle: () => void; +} + +const EvaluationGroup = ({ name, provenance, interval, onToggle, isOpen = false, children }: EvaluationGroupProps) => { + const styles = useStyles2(getStyles); + const isProvisioned = Boolean(provenance); + + return ( + +
    + + + {isProvisioned && } + + {interval && {interval}} + + + + + + + + } + > + + + +
    + {isOpen &&
    {children}
    } +
    + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + headerWrapper: css({ + padding: `${theme.spacing(1)} ${theme.spacing(1)}`, + + background: theme.colors.background.secondary, + + border: 'none', + borderBottom: `solid 1px ${theme.colors.border.weak}`, + borderTopLeftRadius: theme.shape.radius.default, + borderTopRightRadius: theme.shape.radius.default, + }), + hiddenButton: css({ + border: 'none', + background: 'transparent', + }), + largerClickTarget: css({ + padding: theme.spacing(0.5), + margin: `-${theme.spacing(0.5)}`, + }), +}); + +export default EvaluationGroup; diff --git a/public/app/features/alerting/unified/components/rule-list/EvaluationGroupWithRules.tsx b/public/app/features/alerting/unified/components/rule-list/EvaluationGroupWithRules.tsx new file mode 100644 index 00000000000..0b00e839e94 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-list/EvaluationGroupWithRules.tsx @@ -0,0 +1,100 @@ +import { size } from 'lodash'; +import React from 'react'; +import { useToggle } from 'react-use'; + +import { CombinedRuleGroup, RulesSource } from 'app/types/unified-alerting'; + +import { createViewLink } from '../../utils/misc'; +import { hashRulerRule } from '../../utils/rule-id'; +import { isAlertingRule, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from '../../utils/rules'; + +import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './AlertRuleListItem'; +import EvaluationGroup from './EvaluationGroup'; + +export interface EvaluationGroupWithRulesProps { + group: CombinedRuleGroup; + rulesSource: RulesSource; +} + +export const EvaluationGroupWithRules = ({ group, rulesSource }: EvaluationGroupWithRulesProps) => { + const [open, toggleOpen] = useToggle(false); + + return ( + + {group.rules.map((rule, index) => { + const { rulerRule, promRule, annotations } = rule; + + // don't render anything if we don't have the rule definition yet + if (!rulerRule) { + return null; + } + + // keep in mind that we may not have a promRule for the ruler rule – this happens when the target + // rule source is eventually consistent - it may know about the rule definition but not its state + const isAlertingPromRule = isAlertingRule(promRule); + + if (isAlertingRulerRule(rulerRule)) { + return ( + + ); + } + + if (isRecordingRulerRule(rulerRule)) { + return ( + + ); + } + + if (isGrafanaRulerRule(rulerRule)) { + const contactPoint = rulerRule.grafana_alert.notification_settings?.receiver; + + return ( + + ); + } + + // if we get here it means we don't really know how to render this rule + return ; + })} + + ); +}; diff --git a/public/app/features/alerting/unified/components/rule-list/Namespace.tsx b/public/app/features/alerting/unified/components/rule-list/Namespace.tsx new file mode 100644 index 00000000000..71ba016ff41 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-list/Namespace.tsx @@ -0,0 +1,111 @@ +import { css } from '@emotion/css'; +import React, { PropsWithChildren } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, Stack, TextLink, Icon } from '@grafana/ui'; +import { PromApplication, RulesSourceApplication } from 'app/types/unified-alerting-dto'; + +import { WithReturnButton } from '../WithReturnButton'; + +interface NamespaceProps extends PropsWithChildren { + name: string; + href?: string; + application?: RulesSourceApplication; +} + +// @TODO add export rules for namespace back in +const Namespace = ({ children, name, href, application }: NamespaceProps) => { + const styles = useStyles2(getStyles); + + return ( +
  • +
    + + + {href ? ( + + {name} + + } + /> + ) : ( + name + )} + +
    + {children && ( +
      + {children} +
    + )} +
  • + ); +}; + +interface NamespaceIconProps { + application?: RulesSourceApplication; +} + +const NamespaceIcon = ({ application }: NamespaceIconProps) => { + switch (application) { + case PromApplication.Prometheus: + return ( + Prometheus + ); + case PromApplication.Mimir: + return ( + Mimir + ); + case 'loki': + return Loki; + case 'grafana': + default: + return ; + } +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + groupItemsWrapper: css({ + position: 'relative', + borderRadius: theme.shape.radius.default, + border: `solid 1px ${theme.colors.border.weak}`, + borderBottom: 'none', + + marginLeft: theme.spacing(3), + + '&:before': { + content: "''", + position: 'absolute', + height: '100%', + + borderLeft: `solid 1px ${theme.colors.border.weak}`, + + marginTop: 0, + marginLeft: `-${theme.spacing(2.5)}`, + }, + }), + namespaceWrapper: css({ + display: 'flex', + flexDirection: 'column', + + gap: theme.spacing(1), + }), + namespaceTitle: css({ + padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, + + background: theme.colors.background.secondary, + + border: `solid 1px ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + }), +}); + +export default Namespace; diff --git a/public/app/features/alerting/unified/components/rule-list/RuleList.v1.tsx b/public/app/features/alerting/unified/components/rule-list/RuleList.v1.tsx new file mode 100644 index 00000000000..f2a0409de11 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-list/RuleList.v1.tsx @@ -0,0 +1,184 @@ +import { css } from '@emotion/css'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useAsyncFn, useInterval } from 'react-use'; + +import { GrafanaTheme2, urlUtil } from '@grafana/data'; +import { Button, LinkButton, useStyles2, withErrorBoundary } from '@grafana/ui'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { useDispatch } from 'app/types'; + +import { CombinedRuleNamespace } from '../../../../../types/unified-alerting'; +import { LogMessages, logInfo, trackRuleListNavigation } from '../../Analytics'; +import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities'; +import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; +import { useFilteredRules, useRulesFilter } from '../../hooks/useFilteredRules'; +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { fetchAllPromAndRulerRulesAction } from '../../state/actions'; +import { RULE_LIST_POLL_INTERVAL_MS } from '../../utils/constants'; +import { getAllRulesSourceNames } from '../../utils/datasource'; +import { AlertingPageWrapper } from '../AlertingPageWrapper'; +import { NoRulesSplash } from '../rules/NoRulesCTA'; +import { INSTANCES_DISPLAY_LIMIT } from '../rules/RuleDetails'; +import { RuleListErrors } from '../rules/RuleListErrors'; +import { RuleListGroupView } from '../rules/RuleListGroupView'; +import { RuleListStateView } from '../rules/RuleListStateView'; +import { RuleStats } from '../rules/RuleStats'; +import RulesFilter from '../rules/RulesFilter'; + +const VIEWS = { + groups: RuleListGroupView, + state: RuleListStateView, +}; + +// make sure we ask for 1 more so we show the "show x more" button +const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1; + +const RuleList = withErrorBoundary( + () => { + const dispatch = useDispatch(); + const styles = useStyles2(getStyles); + const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []); + const [expandAll, setExpandAll] = useState(false); + + const onFilterCleared = useCallback(() => setExpandAll(false), []); + + const [queryParams] = useQueryParams(); + const { filterState, hasActiveFilters } = useRulesFilter(); + + const queryParamView = queryParams['view'] as keyof typeof VIEWS; + const view = VIEWS[queryParamView] ? queryParamView : 'groups'; + + const ViewComponent = VIEWS[view]; + + const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); + const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); + + const loading = rulesDataSourceNames.some( + (name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading + ); + + const promRequests = Object.entries(promRuleRequests); + const rulerRequests = Object.entries(rulerRuleRequests); + + const allPromLoaded = promRequests.every( + ([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined) + ); + const allRulerLoaded = rulerRequests.every( + ([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined) + ); + + const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0); + + const allRulerEmpty = rulerRequests.every(([_, state]) => { + const rulerRules = Object.entries(state?.result ?? {}); + const noRules = rulerRules.every(([_, result]) => result?.length === 0); + return noRules && state.dispatched; + }); + + const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS; + // Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED + const [_, fetchRules] = useAsyncFn(async () => { + if (!loading) { + await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); + } + }, [loading, limitAlerts, dispatch]); + + useEffect(() => { + trackRuleListNavigation().catch(() => {}); + }, []); + + // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS + useEffect(() => { + dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); + }, [dispatch, limitAlerts]); + useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS); + + // Show splash only when we loaded all of the data sources and none of them has alerts + const hasNoAlertRulesCreatedYet = + allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded; + const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet; + + const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces(); + const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState); + return ( + // We don't want to show the Loading... indicator for the whole page. + // We show separate indicators for Grafana-managed and Cloud rules + }> + + + {hasAlertRulesCreated && ( + <> +
    +
    +
    + {view === 'groups' && hasActiveFilters && ( + + )} + +
    +
    + + )} + {hasNoAlertRulesCreatedYet && } + {hasAlertRulesCreated && } + + ); + }, + { style: 'page' } +); + +const getStyles = (theme: GrafanaTheme2) => ({ + break: css({ + width: '100%', + height: 0, + marginBottom: theme.spacing(2), + borderBottom: `solid 1px ${theme.colors.border.medium}`, + }), + buttonsContainer: css({ + marginBottom: theme.spacing(2), + display: 'flex', + justifyContent: 'space-between', + }), + statsContainer: css({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }), + expandAllButton: css({ + marginRight: theme.spacing(1), + }), +}); + +export default RuleList; + +export function CreateAlertButton() { + const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); + const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); + + const location = useLocation(); + + const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; + + const canCreateGrafanaRules = createRuleSupported && createRuleAllowed; + + if (canCreateGrafanaRules || canCreateCloudRules) { + return ( + logInfo(LogMessages.alertRuleFromScratch)} + > + New alert rule + + ); + } + return null; +} diff --git a/public/app/features/alerting/unified/components/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/components/rule-list/RuleList.v2.tsx new file mode 100644 index 00000000000..be189d2ede5 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-list/RuleList.v2.tsx @@ -0,0 +1,216 @@ +import { css } from '@emotion/css'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useAsyncFn, useInterval, useMeasure } from 'react-use'; + +import { GrafanaTheme2, urlUtil } from '@grafana/data'; +import { Button, LinkButton, LoadingBar, useStyles2, withErrorBoundary } from '@grafana/ui'; +import { useDispatch } from 'app/types'; + +import { CombinedRuleNamespace } from '../../../../../types/unified-alerting'; +import { LogMessages, logInfo, trackRuleListNavigation } from '../../Analytics'; +import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities'; +import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; +import { useFilteredRules, useRulesFilter } from '../../hooks/useFilteredRules'; +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { fetchAllPromAndRulerRulesAction } from '../../state/actions'; +import { RULE_LIST_POLL_INTERVAL_MS } from '../../utils/constants'; +import { getAllRulesSourceNames, getRulesSourceUniqueKey, getApplicationFromRulesSource } from '../../utils/datasource'; +import { makeFolderAlertsLink } from '../../utils/misc'; +import { AlertingPageWrapper } from '../AlertingPageWrapper'; +import { NoRulesSplash } from '../rules/NoRulesCTA'; +import { INSTANCES_DISPLAY_LIMIT } from '../rules/RuleDetails'; +import { RuleListErrors } from '../rules/RuleListErrors'; +import { RuleStats } from '../rules/RuleStats'; +import RulesFilter from '../rules/RulesFilter'; + +import { EvaluationGroupWithRules } from './EvaluationGroupWithRules'; +import Namespace from './Namespace'; + +// make sure we ask for 1 more so we show the "show x more" button +const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1; + +const RuleList = withErrorBoundary( + () => { + const dispatch = useDispatch(); + const styles = useStyles2(getStyles); + const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []); + const [expandAll, setExpandAll] = useState(false); + + const onFilterCleared = useCallback(() => setExpandAll(false), []); + + const { filterState, hasActiveFilters } = useRulesFilter(); + + const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); + const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); + + const loading = rulesDataSourceNames.some( + (name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading + ); + + const promRequests = Object.entries(promRuleRequests); + const rulerRequests = Object.entries(rulerRuleRequests); + + const allPromLoaded = promRequests.every( + ([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined) + ); + const allRulerLoaded = rulerRequests.every( + ([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined) + ); + + const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0); + + const allRulerEmpty = rulerRequests.every(([_, state]) => { + const rulerRules = Object.entries(state?.result ?? {}); + const noRules = rulerRules.every(([_, result]) => result?.length === 0); + return noRules && state.dispatched; + }); + + const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS; + // Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED + const [_, fetchRules] = useAsyncFn(async () => { + if (!loading) { + await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); + } + }, [loading, limitAlerts, dispatch]); + + useEffect(() => { + trackRuleListNavigation().catch(() => {}); + }, []); + + // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS + useEffect(() => { + dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); + }, [dispatch, limitAlerts]); + useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS); + + // Show splash only when we loaded all of the data sources and none of them has alerts + const hasNoAlertRulesCreatedYet = + allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded; + const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet; + + const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces(); + const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState); + + const sortedNamespaces = filteredNamespaces.sort((a: CombinedRuleNamespace, b: CombinedRuleNamespace) => + a.name.localeCompare(b.name) + ); + + return ( + // We don't want to show the Loading... indicator for the whole page. + // We show separate indicators for Grafana-managed and Cloud rules + }> + + + {hasAlertRulesCreated && ( + <> +
    +
    +
    + {hasActiveFilters && ( + + )} + +
    +
    + + )} + {hasNoAlertRulesCreatedYet && } + {hasAlertRulesCreated && ( + <> + +
      + {sortedNamespaces.map((namespace) => { + const { rulesSource, uid } = namespace; + + const application = getApplicationFromRulesSource(rulesSource); + const href = application === 'grafana' && uid ? makeFolderAlertsLink(uid, namespace.name) : undefined; + + return ( + + {namespace.groups + .sort((a, b) => a.name.localeCompare(b.name)) + .map((group) => ( + + ))} + + ); + })} +
    + + )} + + ); + }, + { style: 'page' } +); + +const LoadingIndicator = ({ visible = false }) => { + const [measureRef, { width }] = useMeasure(); + return
    {visible && }
    ; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + rulesTree: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }), + break: css({ + width: '100%', + height: 0, + marginBottom: theme.spacing(2), + borderBottom: `solid 1px ${theme.colors.border.medium}`, + }), + buttonsContainer: css({ + marginBottom: theme.spacing(2), + display: 'flex', + justifyContent: 'space-between', + }), + statsContainer: css({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }), + expandAllButton: css({ + marginRight: theme.spacing(1), + }), +}); + +export default RuleList; + +export function CreateAlertButton() { + const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); + const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); + + const location = useLocation(); + + const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; + + const canCreateGrafanaRules = createRuleSupported && createRuleAllowed; + + if (canCreateGrafanaRules || canCreateCloudRules) { + return ( + logInfo(LogMessages.alertRuleFromScratch)} + > + New alert rule + + ); + } + return null; +} diff --git a/public/app/features/alerting/unified/components/rule-list/RuleListIcon.tsx b/public/app/features/alerting/unified/components/rule-list/RuleListIcon.tsx new file mode 100644 index 00000000000..9de3d974638 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-list/RuleListIcon.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import type { RequireAtLeastOne } from 'type-fest'; + +import { Tooltip, type IconName, Text, Icon } from '@grafana/ui'; +import type { TextProps } from '@grafana/ui/src/components/Text/Text'; +import type { RuleHealth } from 'app/types/unified-alerting'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; + +import { isErrorHealth } from '../rule-viewer/RuleViewer'; + +interface RuleListIconProps { + recording?: boolean; + state?: PromAlertingRuleState; + health?: RuleHealth; + isPaused?: boolean; +} + +/** + * Make sure that the order of importance here matches the one we use in the StateBadge component for the detail view + */ +export function RuleListIcon({ + state, + health, + recording = false, + isPaused = false, +}: RequireAtLeastOne) { + const icons: Record = { + [PromAlertingRuleState.Inactive]: 'check-circle', + [PromAlertingRuleState.Pending]: 'circle', + [PromAlertingRuleState.Firing]: 'exclamation-circle', + }; + + const color: Record = { + [PromAlertingRuleState.Inactive]: 'success', + [PromAlertingRuleState.Pending]: 'warning', + [PromAlertingRuleState.Firing]: 'error', + }; + + const stateNames: Record = { + [PromAlertingRuleState.Inactive]: 'Normal', + [PromAlertingRuleState.Pending]: 'Pending', + [PromAlertingRuleState.Firing]: 'Firing', + }; + + let iconName: IconName = state ? icons[state] : 'circle'; + let iconColor: TextProps['color'] = state ? color[state] : 'secondary'; + let stateName: string = state ? stateNames[state] : 'unknown'; + + if (recording) { + iconName = 'record-audio'; + iconColor = 'success'; + stateName = 'Recording'; + } + + if (health === 'nodata') { + iconName = 'exclamation-triangle'; + iconColor = 'warning'; + stateName = 'Insufficient data'; + } + + if (isErrorHealth(health)) { + iconName = 'times-circle'; + iconColor = 'error'; + stateName = 'Failed to evaluate rule'; + } + + if (isPaused) { + iconName = 'pause-circle'; + iconColor = 'warning'; + stateName = 'Paused'; + } + + return ( + +
    + + + +
    +
    + ); +} diff --git a/public/app/features/alerting/unified/components/rule-list/util.test.tsx b/public/app/features/alerting/unified/components/rule-list/util.test.tsx new file mode 100644 index 00000000000..981090df823 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-list/util.test.tsx @@ -0,0 +1,37 @@ +import { calculateNextEvaluationEstimate } from './util'; + +describe('calculateNextEvaluationEstimate', () => { + const MOCK_NOW = new Date('2024-05-23T12:00:00'); + + beforeEach(() => { + jest.useFakeTimers({ now: MOCK_NOW }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('with timestamp of last evaluation', () => { + // a minute ago + const lastEvaluation = new Date(MOCK_NOW.valueOf() - 60 * 1000).toISOString(); + const interval = '5m'; + + const output = calculateNextEvaluationEstimate(lastEvaluation, interval); + expect(output).toStrictEqual({ + humanized: 'in 4 minutes', + fullDate: '2024-05-23 12:04:00', + }); + }); + + test('with last evaluation having missed ticks', () => { + // 6 minutes ago, so we missed a tick + const lastEvaluation = new Date(MOCK_NOW.valueOf() - 6 * 60 * 1000).toISOString(); + const interval = '5m'; + + const output = calculateNextEvaluationEstimate(lastEvaluation, interval); + expect(output).toStrictEqual({ + humanized: 'within 5m', + fullDate: 'within 5m', + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rule-list/util.ts b/public/app/features/alerting/unified/components/rule-list/util.ts new file mode 100644 index 00000000000..f7dfcbfa8ab --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-list/util.ts @@ -0,0 +1,71 @@ +import { addMilliseconds, formatDistanceToNowStrict, isBefore } from 'date-fns'; + +import { dateTime, dateTimeFormat, isValidDate } from '@grafana/data'; + +import { isNullDate, parsePrometheusDuration } from '../../utils/time'; + +type NextEvaluation = { + humanized: string; + fullDate: string; +}; + +/** + * Best effort estimate for when the next evaluation will occur + * @TODO write a test for this + * @TODO move this somewhere else probably + */ +export function calculateNextEvaluationEstimate( + lastEvaluation?: string, + evaluationInterval?: string +): NextEvaluation | undefined { + if (!lastEvaluation || !evaluationInterval) { + return; + } + + if (!isValidDate(lastEvaluation)) { + return; + } + + let intervalSize: number; + try { + intervalSize = parsePrometheusDuration(evaluationInterval); + } catch (error) { + return; + } + + // paused alert rules will have their lastEvaluation set to a nil date + if (isNullDate(lastEvaluation)) { + return; + } + + const lastEvaluationDate = Date.parse(lastEvaluation || ''); + const nextEvaluationDate = addMilliseconds(lastEvaluationDate, intervalSize); + + //when `nextEvaluationDate` is a past date it means lastEvaluation was more than one evaluation interval ago. + //in this case we use the interval value to show a more generic estimate. + //See https://github.com/grafana/grafana/issues/65125 + const isPastDate = isBefore(nextEvaluationDate, new Date()); + if (isPastDate) { + return { + humanized: `within ${evaluationInterval}`, + fullDate: `within ${evaluationInterval}`, + }; + } + + return { + humanized: `in ${dateTime(nextEvaluationDate).locale('en').fromNow(true)}`, + fullDate: dateTimeFormat(nextEvaluationDate, { format: 'YYYY-MM-DD HH:mm:ss' }), + }; +} + +export function getRelativeEvaluationInterval(lastEvaluation?: string) { + if (!lastEvaluation) { + return null; + } + + if (isNullDate(lastEvaluation)) { + return; + } + + return formatDistanceToNowStrict(new Date(lastEvaluation)); +} diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx index 04f1e9cc3ac..766a258f4e2 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx @@ -24,7 +24,7 @@ jest.mock('app/core/services/context_srv'); const mockContextSrv = jest.mocked(contextSrv); const ui = { - moreButton: byLabelText('more-actions'), + moreButton: byLabelText(/More/), }; const grantAllPermissions = () => { diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx index d6489633255..4f25c55f8e6 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx @@ -30,7 +30,7 @@ const ui = { actionButtons: { edit: byRole('link', { name: 'Edit' }), view: byRole('link', { name: 'View' }), - more: byRole('button', { name: /more-actions/i }), + more: byRole('button', { name: /More/ }), }, moreActionItems: { delete: byRole('menuitem', { name: 'Delete' }), diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index 33e34f4663b..bf94dcfaa7a 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -1,16 +1,7 @@ import { css, cx } from '@emotion/css'; -import { isBefore, formatDuration } from 'date-fns'; -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; -import { - GrafanaTheme2, - addDurationToDate, - isValidDate, - isValidDuration, - parseDuration, - dateTimeFormat, - dateTime, -} from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2, Tooltip } from '@grafana/ui'; import { CombinedRule } from 'app/types/unified-alerting'; @@ -24,6 +15,7 @@ import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines'; import { ProvisioningBadge } from '../Provisioning'; import { RuleLocation } from '../RuleLocation'; import { Tokenize } from '../Tokenize'; +import { calculateNextEvaluationEstimate } from '../rule-list/util'; import { RuleActionsButtons } from './RuleActionsButtons'; import { RuleConfigStatus } from './RuleConfigStatus'; @@ -116,39 +108,6 @@ export const getStyles = (theme: GrafanaTheme2) => ({ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNextEvaluationColumn: boolean) { const { hasRuler, rulerRulesLoaded } = useHasRuler(); - const calculateNextEvaluationDate = useCallback((rule: CombinedRule) => { - const isValidLastEvaluation = rule.promRule?.lastEvaluation && isValidDate(rule.promRule.lastEvaluation); - const isValidIntervalDuration = rule.group.interval && isValidDuration(rule.group.interval); - - if ( - !isValidLastEvaluation || - !isValidIntervalDuration || - (isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule)) - ) { - return; - } - - const intervalDuration = parseDuration(rule.group.interval!); - const lastEvaluationDate = Date.parse(rule.promRule?.lastEvaluation || ''); - const nextEvaluationDate = addDurationToDate(lastEvaluationDate, intervalDuration); - - //when `nextEvaluationDate` is a past date it means lastEvaluation was more than one evaluation interval ago. - //in this case we use the interval value to show a more generic estimate. - //See https://github.com/grafana/grafana/issues/65125 - const isPastDate = isBefore(nextEvaluationDate, new Date()); - if (isPastDate) { - return { - humanized: `within ${formatDuration(intervalDuration)}`, - fullDate: `within ${formatDuration(intervalDuration)}`, - }; - } - - return { - humanized: `in ${dateTime(nextEvaluationDate).locale('en').fromNow(true)}`, - fullDate: dateTimeFormat(nextEvaluationDate, { format: 'YYYY-MM-DD HH:mm:ss' }), - }; - }, []); - return useMemo((): RuleTableColumnProps[] => { const columns: RuleTableColumnProps[] = [ { @@ -228,7 +187,8 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNe id: 'nextEvaluation', label: 'Next evaluation', renderCell: ({ data: rule }) => { - const nextEvalInfo = calculateNextEvaluationDate(rule); + const nextEvalInfo = calculateNextEvaluationEstimate(rule.promRule?.lastEvaluation, rule.group.interval); + return ( nextEvalInfo && ( @@ -272,12 +232,5 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNe }); return columns; - }, [ - showSummaryColumn, - showGroupColumn, - showNextEvaluationColumn, - hasRuler, - rulerRulesLoaded, - calculateNextEvaluationDate, - ]); + }, [showSummaryColumn, showGroupColumn, showNextEvaluationColumn, hasRuler, rulerRulesLoaded]); } diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts index 4a74e122e2e..600d72d9245 100644 --- a/public/app/features/alerting/unified/utils/datasource.ts +++ b/public/app/features/alerting/unified/utils/datasource.ts @@ -8,6 +8,7 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; import { RulesSource } from 'app/types/unified-alerting'; +import { PromApplication, RulesSourceApplication } from 'app/types/unified-alerting-dto'; import { alertmanagerApi } from '../api/alertmanagerApi'; import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources'; @@ -45,6 +46,10 @@ export function getRulesDataSources() { .sort((a, b) => a.name.localeCompare(b.name)); } +export function getRulesSourceUniqueKey(rulesSource: RulesSource): string { + return isGrafanaRulesSource(rulesSource) ? 'grafana' : rulesSource.uid ?? rulesSource.id; +} + export function getRulesDataSource(rulesSourceName: string) { return getRulesDataSources().find((x) => x.name === rulesSourceName); } @@ -273,3 +278,20 @@ export function getDefaultOrFirstCompatibleDataSource(): DataSourceInstanceSetti export function isDataSourceManagingAlerts(ds: DataSourceInstanceSettings) { return ds.jsonData.manageAlerts !== false; //if this prop is undefined it defaults to true } + +export function getApplicationFromRulesSource(rulesSource: RulesSource): RulesSourceApplication { + if (isGrafanaRulesSource(rulesSource)) { + return 'grafana'; + } + + // @TODO use buildinfo + if ('prometheusType' in rulesSource.jsonData) { + return rulesSource.jsonData?.prometheusType ?? PromApplication.Prometheus; + } + + if (rulesSource.type === 'loki') { + return 'loki'; + } + + return PromApplication.Prometheus; // assume Prometheus if nothing matches +} diff --git a/public/app/features/alerting/unified/utils/labels.ts b/public/app/features/alerting/unified/utils/labels.ts index 7c89af0ea13..1d8fab6ec89 100644 --- a/public/app/features/alerting/unified/utils/labels.ts +++ b/public/app/features/alerting/unified/utils/labels.ts @@ -35,6 +35,10 @@ export function arrayKeyValuesToObject( export const GRAFANA_ORIGIN_LABEL = '__grafana_origin'; +export function labelsSize(labels: Labels) { + return Object.keys(labels).filter((key) => !isPrivateLabelKey(key)).length; +} + export function isPrivateLabelKey(labelKey: string) { return (labelKey.startsWith('__') && labelKey.endsWith('__')) || labelKey === GRAFANA_ORIGIN_LABEL; } diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index af0801f8ea3..9436041eace 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -21,13 +21,13 @@ import { getMatcherQueryParams } from './matchers'; import * as ruleId from './rule-id'; import { createAbsoluteUrl, createUrl } from './url'; -export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo: string): string { +export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo?: string): string { const sourceName = getRulesSourceName(ruleSource); const identifier = ruleId.fromCombinedRule(sourceName, rule); const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier)); const paramSource = encodeURIComponent(sourceName); - return createUrl(`/alerting/${paramSource}/${paramId}/view`, { returnTo }); + return createUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {}); } export function createExploreLink(datasource: DataSourceRef, query: string) { diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts index 316a1b3bae1..3e6ebad8c64 100644 --- a/public/app/features/alerting/unified/utils/rule-id.ts +++ b/public/app/features/alerting/unified/utils/rule-id.ts @@ -221,7 +221,7 @@ export function hashRulerRule(rule: RulerRuleDTO): string { } } -function hashRule(rule: Rule): string { +export function hashRule(rule: Rule): string { if (isRecordingRule(rule)) { return hash(JSON.stringify([rule.type, rule.query, hashLabelsOrAnnotations(rule.labels)])).toString(); } diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index c7d8251c10c..5094b9daa06 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -65,6 +65,8 @@ export enum PromApplication { Thanos = 'Thanos', } +export type RulesSourceApplication = PromApplication | 'loki' | 'grafana'; + export interface PromBuildInfoResponse { data: { application?: string; diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index e641ab27848..a381b21e546 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -1,6 +1,8 @@ /* Prometheus internal models */ import { AlertState, DataSourceInstanceSettings } from '@grafana/data'; +import { PromOptions } from '@grafana/prometheus'; +import { LokiOptions } from 'app/plugins/datasource/loki/types'; import { Annotations, @@ -89,7 +91,7 @@ export interface RulesSourceResult { namespaces?: RuleNamespace[]; } -export type RulesSource = DataSourceInstanceSettings | 'grafana'; +export type RulesSource = DataSourceInstanceSettings | 'grafana'; // combined prom and ruler result export interface CombinedRule { diff --git a/yarn.lock b/yarn.lock index 7b982840a48..f0b041ec89c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17019,6 +17019,7 @@ __metadata: ts-node: "npm:10.9.2" tslib: "npm:2.6.2" tween-functions: "npm:^1.2.0" + type-fest: "npm:^4.18.2" typescript: "npm:5.4.5" uplot: "npm:1.6.30" uuid: "npm:9.0.1" @@ -28902,6 +28903,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^4.18.2": + version: 4.18.2 + resolution: "type-fest@npm:4.18.2" + checksum: 10/2c176de28384a247fac1503165774e874c15ac39434a775f32ecda3aef5a0cefcfa2f5fb670c3da1f81cf773c355999154078c8d9657db19b65de78334b27933 + languageName: node + linkType: hard + "type-fest@npm:^4.9.0": version: 4.10.2 resolution: "type-fest@npm:4.10.2"