diff --git a/.betterer.results b/.betterer.results index b044889ad27..23c6330def8 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1632,10 +1632,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"] ], "public/app/features/alerting/unified/components/AlertStateDot.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"] + [0, 0, 0, "Styles should be written using objects.", "0"] ], "public/app/features/alerting/unified/components/AnnotationDetailsField.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -1758,16 +1755,12 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"] + [0, 0, 0, "Styles should be written using objects.", "3"] ], "public/app/features/alerting/unified/components/alert-groups/AlertGroupHeader.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -2272,8 +2265,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "Styles should be written using objects.", "2"], [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"] + [0, 0, 0, "Styles should be written using objects.", "4"] ], "public/app/features/alerting/unified/components/rules/RuleHealth.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] @@ -2449,6 +2441,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], + "public/app/features/alerting/unified/utils/misc.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/alerting/unified/utils/receiver-form.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/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx index fb0cf2c080c..98bbf23669e 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.tsx @@ -74,7 +74,9 @@ export const Alert = React.forwardRef( - {title} + + {title} + {children &&
{children}
}
{/* If onRemove is specified, giving preference to onRemove */} @@ -151,6 +153,7 @@ const getStyles = ( color: color.text, }), content: css({ + color: theme.colors.text.primary, paddingTop: hasTitle ? theme.spacing(0.5) : 0, maxHeight: '50vh', overflowY: 'auto', diff --git a/public/app/core/components/Page/PageHeader.tsx b/public/app/core/components/Page/PageHeader.tsx index d6f8aba8505..23a7796c2c1 100644 --- a/public/app/core/components/Page/PageHeader.tsx +++ b/public/app/core/components/Page/PageHeader.tsx @@ -57,6 +57,7 @@ const getStyles = (theme: GrafanaTheme2) => { title: css({ display: 'flex', flexDirection: 'row', + maxWidth: '100%', h1: { display: 'flex', marginBottom: 0, diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx index 772c0936dff..da62e5d4c3e 100644 --- a/public/app/features/alerting/unified/RuleViewer.tsx +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -9,6 +9,7 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { AlertRuleProvider } from './components/rule-viewer/v2/RuleContext'; import { useCombinedRule } from './hooks/useCombinedRule'; +import { stringifyErrorLike } from './utils/misc'; import { getRuleIdFromPathname, parse as parseRuleId } from './utils/rule-id'; const DetailViewV1 = SafeDynamicImport(() => import('./components/rule-viewer/RuleViewer.v1')); @@ -19,7 +20,7 @@ type RuleViewerProps = GrafanaRouteComponentProps<{ sourceName: string; }>; -const newAlertDetailView = Boolean(config.featureToggles.alertingDetailsViewV2) === true; +const newAlertDetailView = Boolean(config.featureToggles?.alertingDetailsViewV2) === true; const RuleViewer = (props: RuleViewerProps): JSX.Element => { return newAlertDetailView ? : ; @@ -48,13 +49,12 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => { // we then fetch the rule from the correct API endpoint(s) const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier }); - // TODO improve error handling here if (error) { - if (typeof error === 'string') { - return error; - } - - return Something went wrong loading the rule; + return ( + + {stringifyErrorLike(error)} + + ); } if (loading) { diff --git a/public/app/features/alerting/unified/components/AlertStateDot.tsx b/public/app/features/alerting/unified/components/AlertStateDot.tsx index 01a991d6450..c48efda4af5 100644 --- a/public/app/features/alerting/unified/components/AlertStateDot.tsx +++ b/public/app/features/alerting/unified/components/AlertStateDot.tsx @@ -2,8 +2,12 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { ComponentSize, Stack, useStyles2 } from '@grafana/ui'; -import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import { Stack, useStyles2 } from '@grafana/ui'; + +interface DotStylesProps { + color: 'success' | 'error' | 'warning' | 'info'; + includeState?: boolean; +} const AlertStateDot = (props: DotStylesProps) => { const styles = useStyles2(getDotStyles, props); @@ -15,16 +19,14 @@ const AlertStateDot = (props: DotStylesProps) => { ); }; -interface DotStylesProps { - state: PromAlertingRuleState; - includeState?: boolean; - size?: ComponentSize; // TODO support this -} - const getDotStyles = (theme: GrafanaTheme2, props: DotStylesProps) => { const size = theme.spacing(1.25); const outlineSize = `calc(${size} / 2.5)`; + const errorStyle = props.color === 'error'; + const successStyle = props.color === 'success'; + const warningStyle = props.color === 'warning'; + return { dot: css` width: ${size}; @@ -36,23 +38,23 @@ const getDotStyles = (theme: GrafanaTheme2, props: DotStylesProps) => { outline: solid ${outlineSize} ${theme.colors.secondary.transparent}; margin: ${outlineSize}; - ${props.state === PromAlertingRuleState.Inactive && - css` - background-color: ${theme.colors.success.main}; - outline-color: ${theme.colors.success.transparent}; - `} + ${successStyle && + css({ + backgroundColor: theme.colors.success.main, + outlineColor: theme.colors.success.transparent, + })} - ${props.state === PromAlertingRuleState.Pending && - css` - background-color: ${theme.colors.warning.main}; - outline-color: ${theme.colors.warning.transparent}; - `} + ${warningStyle && + css({ + backgroundColor: theme.colors.warning.main, + outlineColor: theme.colors.warning.transparent, + })} - ${props.state === PromAlertingRuleState.Firing && - css` - background-color: ${theme.colors.error.main}; - outline-color: ${theme.colors.error.transparent}; - `} + ${errorStyle && + css({ + backgroundColor: theme.colors.error.main, + outlineColor: theme.colors.error.transparent, + })} `, }; }; diff --git a/public/app/features/alerting/unified/components/Provisioning.tsx b/public/app/features/alerting/unified/components/Provisioning.tsx index a5b66f73bc6..78a7c28ac08 100644 --- a/public/app/features/alerting/unified/components/Provisioning.tsx +++ b/public/app/features/alerting/unified/components/Provisioning.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ComponentPropsWithoutRef } from 'react'; import { Alert, Badge } from '@grafana/ui'; @@ -10,13 +10,16 @@ export enum ProvisionedResource { RootNotificationPolicy = 'root notification policy', } -interface ProvisioningAlertProps { +// we'll omit the props we don't want consumers to overwrite and forward the others to the alert component +type ExtraAlertProps = Omit, 'title' | 'severity'>; + +interface ProvisioningAlertProps extends ExtraAlertProps { resource: ProvisionedResource; } -export const ProvisioningAlert = ({ resource }: ProvisioningAlertProps) => { +export const ProvisioningAlert = ({ resource, ...rest }: ProvisioningAlertProps) => { return ( - + This {resource} has been provisioned, that means it was created by config. Please contact your server admin to update this {resource}. diff --git a/public/app/features/alerting/unified/components/WithReturnButton.tsx b/public/app/features/alerting/unified/components/WithReturnButton.tsx new file mode 100644 index 00000000000..5d8ef8c8e34 --- /dev/null +++ b/public/app/features/alerting/unified/components/WithReturnButton.tsx @@ -0,0 +1,19 @@ +import React, { useCallback } from 'react'; + +import { useReturnToPrevious } from '@grafana/runtime'; + +interface WithReturnButtonProps { + component: JSX.Element; + title?: string; +} + +// @TODO translations? +export const WithReturnButton = ({ component, title = 'previous page' }: WithReturnButtonProps) => { + const returnToPrevious = useReturnToPrevious(); + + const returnToThisURL = useCallback(() => { + returnToPrevious(title); + }, [returnToPrevious, title]); + + return React.cloneElement(component, { onClick: returnToThisURL }); +}; diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx index 4b7afe05247..aad3722a343 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx @@ -39,13 +39,11 @@ export const AlertGroupFilter = ({ groups }: Props) => {
setQueryParams({ queryString: value ? value : null })} /> setQueryParams({ groupBy: keys.length ? keys.join(',') : null })} @@ -73,12 +71,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: flex; flex-direction: row; margin-bottom: ${theme.spacing(3)}; - `, - filterInput: css` - width: 340px; - & + & { - margin-left: ${theme.spacing(1)}; - } + gap: ${theme.spacing(1)}; `, clearButton: css` margin-left: ${theme.spacing(1)}; diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx index 507a4d693c6..d94e2c36b0e 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx @@ -1,8 +1,7 @@ -import { css } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { RadioButtonGroup, Label, useStyles2 } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; +import { RadioButtonGroup, Label } from '@grafana/ui'; import { AlertState } from 'app/plugins/datasource/alertmanager/types'; interface Props { @@ -11,7 +10,6 @@ interface Props { } export const AlertStateFilter = ({ onStateFilterChange, stateFilter }: Props) => { - const styles = useStyles2(getStyles); const alertStateOptions: SelectableValue[] = Object.entries(AlertState) .sort(([labelA], [labelB]) => (labelA < labelB ? -1 : 1)) .map(([label, state]) => ({ @@ -20,15 +18,9 @@ export const AlertStateFilter = ({ onStateFilterChange, stateFilter }: Props) => })); return ( -
+
); }; - -const getStyles = (theme: GrafanaTheme2) => ({ - wrapper: css` - margin-left: ${theme.spacing(1)}; - `, -}); diff --git a/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx b/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx index 7d60a1631fc..a35dba726d1 100644 --- a/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx @@ -6,13 +6,12 @@ import { Icon, Label, MultiSelect } from '@grafana/ui'; import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; interface Props { - className?: string; groups: AlertmanagerGroup[]; groupBy: string[]; onGroupingChange: (keys: string[]) => void; } -export const GroupBy = ({ className, groups, groupBy, onGroupingChange }: Props) => { +export const GroupBy = ({ groups, groupBy, onGroupingChange }: Props) => { const labelKeyOptions = uniq(groups.flatMap((group) => group.alerts).flatMap(({ labels }) => Object.keys(labels))) .filter((label) => !(label.startsWith('__') && label.endsWith('__'))) // Filter out private labels .map((key) => ({ @@ -21,7 +20,7 @@ export const GroupBy = ({ className, groups, groupBy, onGroupingChange }: Props) })); return ( -
+
value as string)); }} options={labelKeyOptions} + width={34} />
); diff --git a/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx index 3246fcbc6d2..02dab1c9eea 100644 --- a/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx @@ -9,12 +9,11 @@ import { logInfo, LogMessages } from '../../Analytics'; import { parseMatchers } from '../../utils/alertmanager'; interface Props { - className?: string; defaultQueryString?: string; onFilterChange: (filterString: string) => void; } -export const MatcherFilter = ({ className, onFilterChange, defaultQueryString }: Props) => { +export const MatcherFilter = ({ onFilterChange, defaultQueryString }: Props) => { const styles = useStyles2(getStyles); const onSearchInputChanged = useMemo( @@ -33,51 +32,50 @@ export const MatcherFilter = ({ className, onFilterChange, defaultQueryString }: const inputInvalid = defaultQueryString ? parseMatchers(defaultQueryString).length === 0 : false; return ( -
- - - Search by label - - Filter alerts using label querying without spaces, ex: -
{`{severity="critical", instance=~"cluster-us-.+"}`}
- Invalid use of spaces: -
{`{severity= "critical"}`}
-
{`{severity ="critical"}`}
- Valid use of spaces: -
{`{severity=" critical"}`}
- Filter alerts using label querying without braces, ex: -
{`severity="critical", instance=~"cluster-us-.+"`}
-
- } - > - - - - - } - > - - -
+ + + Search by label + + Filter alerts using label querying without spaces, ex: +
{`{severity="critical", instance=~"cluster-us-.+"}`}
+ Invalid use of spaces: +
{`{severity= "critical"}`}
+
{`{severity ="critical"}`}
+ Valid use of spaces: +
{`{severity=" critical"}`}
+ Filter alerts using label querying without braces, ex: +
{`severity="critical", instance=~"cluster-us-.+"`}
+
+ } + > + + + + + } + > + + ); }; const getStyles = (theme: GrafanaTheme2) => ({ - icon: css({ - marginRight: theme.spacing(0.5), + fixMargin: css({ + marginBottom: 0, }), inputWidth: css({ width: 340, diff --git a/public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx b/public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx new file mode 100644 index 00000000000..76bf9317abf --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { Alert, Button, Stack } from '@grafana/ui'; + +export function FederatedRuleWarning() { + return ( + + + Federated rule groups are currently an experimental feature. + + + + ); +} diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx index ead6e0cf27f..aa9b8614177 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx @@ -18,6 +18,7 @@ import { contextSrv } from 'app/core/core'; import { isExpressionQuery } from 'app/features/expressions/guards'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; +import { WithReturnButton } from '../WithReturnButton'; import { VizWrapper } from '../rule-editor/VizWrapper'; import { ThresholdDefinition } from '../rule-editor/util'; @@ -74,15 +75,13 @@ export function RuleViewerVisualization({ ) : null} {allowedToExploreDataSources && !isExpression && ( - - View in Explore - + + View in Explore + + } + /> )}
diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.test.tsx index 2787c4ae2ee..4d2c2392e84 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.test.tsx @@ -167,3 +167,8 @@ const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier) await waitFor(() => expect(ELEMENTS.loading.query()).not.toBeInTheDocument()); }; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + useReturnToPrevious: jest.fn(), +})); diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx index 55d956e7b97..29d4c0607c4 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx @@ -1,24 +1,26 @@ +import { css } from '@emotion/css'; import { isEmpty, truncate } from 'lodash'; import React, { useState } from 'react'; import { NavModelItem, UrlQueryValue } from '@grafana/data'; -import { Alert, Button, LinkButton, Stack, TabContent, Text, TextLink } from '@grafana/ui'; +import { Alert, LinkButton, Stack, TabContent, Text, TextLink, useStyles2 } from '@grafana/ui'; import { PageInfoItem } from 'app/core/components/Page/types'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; -import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; -import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import { CombinedRule, RuleHealth, RuleIdentifier } from 'app/types/unified-alerting'; +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; import { defaultPageNav } from '../../../RuleViewer'; import { Annotation } from '../../../utils/constants'; import { makeDashboardLink, makePanelLink } from '../../../utils/misc'; -import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules'; +import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule, isRecordingRule } from '../../../utils/rules'; import { createUrl } from '../../../utils/url'; import { AlertLabels } from '../../AlertLabels'; -import { AlertStateDot } from '../../AlertStateDot'; import { AlertingPageWrapper } from '../../AlertingPageWrapper'; import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; +import { WithReturnButton } from '../../WithReturnButton'; import { decodeGrafanaNamespace } from '../../expressions/util'; import { RedirectToCloneRule } from '../../rules/CloneRule'; +import { FederatedRuleWarning } from '../FederatedRuleWarning'; import { Details } from '../tabs/Details'; import { History } from '../tabs/History'; import { InstancesList } from '../tabs/Instances'; @@ -28,6 +30,7 @@ import { Routing } from '../tabs/Routing'; import { useAlertRulePageActions } from './Actions'; import { useDeleteModal } from './DeleteModal'; import { useAlertRule } from './RuleContext'; +import { RecordingBadge, StateBadge } from './StateBadges'; enum ActiveTab { Query = 'query', @@ -52,51 +55,62 @@ const RuleViewer = () => { handleDelete: showDeleteModal, }); - const promRule = rule.promRule; + const { annotations, promRule } = rule; + const hasError = isErrorHealth(rule.promRule?.health); const isAlertType = isAlertingRule(promRule); const isFederatedRule = isFederatedRuleGroup(rule.group); const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); + const summary = annotations[Annotation.summary]; + return ( { - return ; - }} + renderTitle={(title) => ( + <Title + name={title} + state={isAlertType ? promRule.state : undefined} + health={rule.promRule?.health} + ruleType={rule.promRule?.type} + /> + )} actions={actions} info={createMetadata(rule)} - > - <Stack direction="column" gap={2}> - {/* actions */} - <Stack direction="column" gap={2}> + subTitle={ + <Stack direction="column"> + {summary} {/* alerts and notifications and stuff */} - {isFederatedRule && ( - <Alert severity="info" title="This rule is part of a federated rule group."> - <Stack direction="column"> - Federated rule groups are currently an experimental feature. - <Button fill="text" icon="book"> - <a href="https://grafana.com/docs/metrics-enterprise/latest/tenant-management/tenant-federation/#cross-tenant-alerting-and-recording-rule-federation"> - Read documentation - </a> - </Button> - </Stack> + {isFederatedRule && <FederatedRuleWarning />} + {/* indicator for rules in a provisioned group */} + {isProvisioned && ( + <ProvisioningAlert resource={ProvisionedResource.AlertRule} bottomSpacing={0} topSpacing={2} /> + )} + {/* error state */} + {hasError && ( + <Alert title="Something went wrong when evaluating this alert rule" bottomSpacing={0} topSpacing={2}> + <pre style={{ marginBottom: 0 }}> + <code>{rule.promRule?.lastError ?? 'No error message'}</code> + </pre> </Alert> )} - {isProvisioned && <ProvisioningAlert resource={ProvisionedResource.AlertRule} />} - {/* tabs and tab content */} - <TabContent> - {activeTab === ActiveTab.Query && <QueryResults rule={rule} />} - {activeTab === ActiveTab.Instances && <InstancesList rule={rule} />} - {activeTab === ActiveTab.History && isGrafanaRulerRule(rule.rulerRule) && <History rule={rule.rulerRule} />} - {activeTab === ActiveTab.Routing && <Routing />} - {activeTab === ActiveTab.Details && <Details rule={rule} />} - </TabContent> </Stack> + } + > + <Stack direction="column" gap={2}> + {/* tabs and tab content */} + <TabContent> + {activeTab === ActiveTab.Query && <QueryResults rule={rule} />} + {activeTab === ActiveTab.Instances && <InstancesList rule={rule} />} + {activeTab === ActiveTab.History && isGrafanaRulerRule(rule.rulerRule) && <History rule={rule.rulerRule} />} + {activeTab === ActiveTab.Routing && <Routing />} + {activeTab === ActiveTab.Details && <Details rule={rule} />} + </TabContent> </Stack> + {deleteModal} {duplicateRuleIdentifier && ( <RedirectToCloneRule @@ -118,8 +132,8 @@ const createMetadata = (rule: CombinedRule): PageInfoItem[] => { const dashboardUID = annotations[Annotation.dashboardUID]; const panelID = annotations[Annotation.panelID]; - const hasPanel = dashboardUID && panelID; - const hasDashboardWithoutPanel = dashboardUID && !panelID; + const hasDashboardAndPanel = dashboardUID && panelID; + const hasDashboard = dashboardUID; const hasLabels = !isEmpty(labels); const interval = group.interval; @@ -136,22 +150,32 @@ const createMetadata = (rule: CombinedRule): PageInfoItem[] => { }); } - if (hasPanel) { + if (hasDashboardAndPanel) { metadata.push({ label: 'Dashboard and panel', value: ( - <TextLink variant="bodySmall" href={makePanelLink(dashboardUID, panelID)} external> - View panel - </TextLink> + <WithReturnButton + title={rule.name} + component={ + <TextLink variant="bodySmall" href={makePanelLink(dashboardUID, panelID)}> + View panel + </TextLink> + } + /> ), }); - } else if (hasDashboardWithoutPanel) { + } else if (hasDashboard) { metadata.push({ label: 'Dashboard', value: ( - <TextLink variant="bodySmall" href={makeDashboardLink(dashboardUID)} external> - View dashboard - </TextLink> + <WithReturnButton + title={rule.name} + component={ + <TextLink title={rule.name} variant="bodySmall" href={makeDashboardLink(dashboardUID)}> + View dashboard + </TextLink> + } + /> ), }); } @@ -184,53 +208,29 @@ interface TitleProps { name: string; // recording rules don't have a state state?: PromAlertingRuleState; + health?: RuleHealth; + ruleType?: PromRuleType; } -export const Title = ({ name, state }: TitleProps) => ( - <div style={{ display: 'flex', alignItems: 'center', gap: 8, maxWidth: '100%' }}> - <LinkButton variant="secondary" icon="angle-left" href="/alerting/list" /> - <Text element="h1" truncate> - {name} - </Text> - {/* recording rules won't have a state */} - {state && <StateBadge state={state} />} - </div> -); - -interface StateBadgeProps { - state: PromAlertingRuleState; -} - -// TODO move to separate component -const StateBadge = ({ state }: StateBadgeProps) => { - let stateLabel: string; - let textColor: 'success' | 'error' | 'warning'; - - switch (state) { - case PromAlertingRuleState.Inactive: - textColor = 'success'; - stateLabel = 'Normal'; - break; - case PromAlertingRuleState.Firing: - textColor = 'error'; - stateLabel = 'Firing'; - break; - case PromAlertingRuleState.Pending: - textColor = 'warning'; - stateLabel = 'Pending'; - break; - } +export const Title = ({ name, state, health, ruleType }: TitleProps) => { + const styles = useStyles2(getStyles); + const isRecordingRule = ruleType === PromRuleType.Recording; return ( - <Stack direction="row" gap={0.5}> - <AlertStateDot size="md" state={state} /> - <Text variant="bodySmall" color={textColor}> - {stateLabel} + <div className={styles.title}> + <LinkButton variant="secondary" icon="angle-left" href="/alerting/list" /> + <Text variant="h1" truncate> + {name} </Text> - </Stack> + {/* recording rules won't have a state */} + {state && <StateBadge state={state} health={health} />} + {isRecordingRule && <RecordingBadge health={health} />} + </div> ); }; +export const isErrorHealth = (health?: RuleHealth) => health === 'error' || health === 'err'; + function useActiveTab(): [ActiveTab, (tab: ActiveTab) => void] { const [queryParams, setQueryParams] = useQueryParams(); const tabFromQuery = queryParams['tab']; @@ -262,6 +262,9 @@ function usePageNav(rule: CombinedRule) { const namespaceName = decodeGrafanaNamespace(rule.namespace); const groupName = rule.group.name; + const isGrafanaAlertRule = isGrafanaRulerRule(rule.rulerRule) && isAlertType; + const isRecordingRuleType = isRecordingRule(rule.promRule); + const pageNav: NavModelItem = { ...defaultPageNav, text: rule.name, @@ -281,6 +284,7 @@ function usePageNav(rule: CombinedRule) { setActiveTab(ActiveTab.Instances); }, tabCounter: numberOfInstance, + hideFromTabs: isRecordingRuleType, }, { text: 'History', @@ -288,6 +292,8 @@ function usePageNav(rule: CombinedRule) { onClick: () => { setActiveTab(ActiveTab.History); }, + // alert state history is only available for Grafana managed alert rules + hideFromTabs: !isGrafanaAlertRule, }, { text: 'Details', @@ -316,4 +322,13 @@ function usePageNav(rule: CombinedRule) { }; } +const getStyles = () => ({ + title: css({ + display: 'flex', + alignItems: 'center', + gap: 8, + minWidth: 0, + }), +}); + export default RuleViewer; diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/StateBadges.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/StateBadges.tsx new file mode 100644 index 00000000000..94ac7edb466 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/StateBadges.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import { Stack, Text } from '@grafana/ui'; +import { RuleHealth } from 'app/types/unified-alerting'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; + +import { AlertStateDot } from '../../AlertStateDot'; + +import { isErrorHealth } from './RuleViewer.v2'; + +interface RecordingBadgeProps { + health?: RuleHealth; +} + +export const RecordingBadge = ({ health }: RecordingBadgeProps) => { + const hasError = isErrorHealth(health); + + const color = hasError ? 'error' : 'success'; + const text = hasError ? 'Recording error' : 'Recording'; + + return ( + <Stack direction="row" gap={0.5}> + <AlertStateDot color={color} /> + <Text variant="bodySmall" color={color}> + {text} + </Text> + </Stack> + ); +}; + +// we're making a distinction here between the "state" of the rule and its "health". +interface StateBadgeProps { + state: PromAlertingRuleState; + health?: RuleHealth; +} + +export const StateBadge = ({ state, health }: StateBadgeProps) => { + let stateLabel: string; + let color: 'success' | 'error' | 'warning'; + + switch (state) { + case PromAlertingRuleState.Inactive: + color = 'success'; + stateLabel = 'Normal'; + break; + case PromAlertingRuleState.Firing: + color = 'error'; + stateLabel = 'Firing'; + break; + case PromAlertingRuleState.Pending: + color = 'warning'; + stateLabel = 'Pending'; + break; + } + + // if the rule is in "error" health we don't really care about the state + if (isErrorHealth(health)) { + color = 'error'; + stateLabel = 'Error'; + } + + if (health === 'nodata') { + color = 'warning'; + stateLabel = 'No data'; + } + + return ( + <Stack direction="row" gap={0.5}> + <AlertStateDot color={color} /> + <Text variant="bodySmall" color={color}> + {stateLabel} + </Text> + </Stack> + ); +}; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx index b2af9f91508..fcc1a0ac7fb 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx @@ -115,13 +115,11 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null { <div className={cx(styles.flexRow, styles.spaceBetween)}> <div className={styles.flexRow}> <MatcherFilter - className={styles.rowChild} key={queryStringKey} defaultQueryString={queryString} onFilterChange={(value) => setQueryString(value)} /> <AlertInstanceStateFilter - className={styles.rowChild} filterType={stateFilterType} stateFilter={alertState} onStateFilterChange={setAlertState} @@ -164,13 +162,11 @@ const getStyles = (theme: GrafanaTheme2) => { width: 100%; flex-wrap: wrap; margin-bottom: ${theme.spacing(1)}; + gap: ${theme.spacing(1)}; `, spaceBetween: css` justify-content: space-between; `, - rowChild: css` - margin-right: ${theme.spacing(1)}; - `, footerRow: css` display: flex; flex-direction: column; diff --git a/public/app/features/alerting/unified/components/rules/RuleHealth.tsx b/public/app/features/alerting/unified/components/rules/RuleHealth.tsx index 8016e5f255c..1b0a2625b5f 100644 --- a/public/app/features/alerting/unified/components/rules/RuleHealth.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleHealth.tsx @@ -5,6 +5,8 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; import { Rule } from 'app/types/unified-alerting'; +import { isErrorHealth } from '../rule-viewer/v2/RuleViewer.v2'; + interface Prom { rule: Rule; } @@ -12,7 +14,7 @@ interface Prom { export const RuleHealth = ({ rule }: Prom) => { const style = useStyles2(getStyle); - if (rule.health === 'err' || rule.health === 'error') { + if (isErrorHealth(rule.health)) { return ( <Tooltip theme="error" content={rule.lastError || 'No error message provided.'}> <div className={style.warn}> diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index c6e4739353d..f8deef67b00 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -221,3 +221,11 @@ export function isLocalDevEnv() { const buildInfo = config.buildInfo; return buildInfo.env === 'development'; } + +export function isErrorLike(error: unknown): error is Error { + return 'message' in (error as Error); +} + +export function stringifyErrorLike(error: unknown): string { + return isErrorLike(error) ? error.message : String(error); +} diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 1ad18e753bf..e641ab27848 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -26,8 +26,11 @@ export function hasAlertState(alert: Alert, state: PromAlertingRuleState | Grafa return mapStateWithReasonToBaseState(alert.state) === state; } +// Prometheus API uses "err" but grafana API uses "error" *sigh* +export type RuleHealth = 'nodata' | 'error' | 'err' | string; + interface RuleBase { - health: string; + health: RuleHealth; name: string; query: string; lastEvaluation?: string;