Alerting: Detail view part 3 (#81286)

This commit is contained in:
Gilles De Mey
2024-02-07 18:02:20 +01:00
committed by GitHub
parent 3464b6e581
commit 791ead7806
20 changed files with 335 additions and 208 deletions
+6 -11
View File
@@ -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"],
@@ -74,7 +74,9 @@ export const Alert = React.forwardRef<HTMLDivElement, Props>(
</Box>
<Box paddingY={1} grow={1}>
<Text weight="medium">{title}</Text>
<Text color="primary" weight="medium">
{title}
</Text>
{children && <div className={styles.content}>{children}</div>}
</Box>
{/* 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',
@@ -57,6 +57,7 @@ const getStyles = (theme: GrafanaTheme2) => {
title: css({
display: 'flex',
flexDirection: 'row',
maxWidth: '100%',
h1: {
display: 'flex',
marginBottom: 0,
@@ -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 ? <RuleViewerV2Wrapper {...props} /> : <RuleViewerV1Wrapper {...props} />;
@@ -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 <Alert title={'Uh-oh'}>Something went wrong loading the rule</Alert>;
return (
<AlertingPageWrapper pageNav={defaultPageNav} navId="alert-list">
<Alert title={'Something went wrong loading the rule'}>{stringifyErrorLike(error)}</Alert>
</AlertingPageWrapper>
);
}
if (loading) {
@@ -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,
})}
`,
};
};
@@ -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<ComponentPropsWithoutRef<typeof Alert>, 'title' | 'severity'>;
interface ProvisioningAlertProps extends ExtraAlertProps {
resource: ProvisionedResource;
}
export const ProvisioningAlert = ({ resource }: ProvisioningAlertProps) => {
export const ProvisioningAlert = ({ resource, ...rest }: ProvisioningAlertProps) => {
return (
<Alert title={`This ${resource} cannot be edited through the UI`} severity="info">
<Alert title={`This ${resource} cannot be edited through the UI`} severity="info" {...rest}>
This {resource} has been provisioned, that means it was created by config. Please contact your server admin to
update this {resource}.
</Alert>
@@ -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 });
};
@@ -39,13 +39,11 @@ export const AlertGroupFilter = ({ groups }: Props) => {
<div className={styles.wrapper}>
<div className={styles.filterSection}>
<MatcherFilter
className={styles.filterInput}
key={matcherFilterKey}
defaultQueryString={queryString}
onFilterChange={(value) => setQueryParams({ queryString: value ? value : null })}
/>
<GroupBy
className={styles.filterInput}
groups={groups}
groupBy={groupBy}
onGroupingChange={(keys) => 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)};
@@ -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 (
<div className={styles.wrapper}>
<div>
<Label>State</Label>
<RadioButtonGroup options={alertStateOptions} value={stateFilter} onChange={onStateFilterChange} />
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css`
margin-left: ${theme.spacing(1)};
`,
});
@@ -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<SelectableValue>((key) => ({
@@ -21,7 +20,7 @@ export const GroupBy = ({ className, groups, groupBy, onGroupingChange }: Props)
}));
return (
<div data-testid={'group-by-container'} className={className}>
<div data-testid={'group-by-container'}>
<Label>Custom group by</Label>
<MultiSelect
aria-label={'group by label keys'}
@@ -32,6 +31,7 @@ export const GroupBy = ({ className, groups, groupBy, onGroupingChange }: Props)
onGroupingChange(items.map(({ value }) => value as string));
}}
options={labelKeyOptions}
width={34}
/>
</div>
);
@@ -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 (
<div className={className}>
<Field
invalid={inputInvalid || undefined}
error={inputInvalid ? 'Query must use valid matcher syntax. See the examples in the help tooltip.' : null}
label={
<Label>
<Stack gap={0.5}>
<span>Search by label</span>
<Tooltip
content={
<div>
Filter alerts using label querying without spaces, ex:
<pre>{`{severity="critical", instance=~"cluster-us-.+"}`}</pre>
Invalid use of spaces:
<pre>{`{severity= "critical"}`}</pre>
<pre>{`{severity ="critical"}`}</pre>
Valid use of spaces:
<pre>{`{severity=" critical"}`}</pre>
Filter alerts using label querying without braces, ex:
<pre>{`severity="critical", instance=~"cluster-us-.+"`}</pre>
</div>
}
>
<Icon className={styles.icon} name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
>
<Input
placeholder="Search"
defaultValue={defaultQueryString ?? ''}
onChange={onSearchInputChanged}
data-testid="search-query-input"
prefix={searchIcon}
className={styles.inputWidth}
/>
</Field>
</div>
<Field
className={styles.fixMargin}
invalid={inputInvalid || undefined}
error={inputInvalid ? 'Query must use valid matcher syntax. See the examples in the help tooltip.' : null}
label={
<Label>
<Stack gap={0.5}>
<span>Search by label</span>
<Tooltip
content={
<div>
Filter alerts using label querying without spaces, ex:
<pre>{`{severity="critical", instance=~"cluster-us-.+"}`}</pre>
Invalid use of spaces:
<pre>{`{severity= "critical"}`}</pre>
<pre>{`{severity ="critical"}`}</pre>
Valid use of spaces:
<pre>{`{severity=" critical"}`}</pre>
Filter alerts using label querying without braces, ex:
<pre>{`severity="critical", instance=~"cluster-us-.+"`}</pre>
</div>
}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
>
<Input
placeholder="Search"
defaultValue={defaultQueryString ?? ''}
onChange={onSearchInputChanged}
data-testid="search-query-input"
prefix={searchIcon}
className={styles.inputWidth}
/>
</Field>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
icon: css({
marginRight: theme.spacing(0.5),
fixMargin: css({
marginBottom: 0,
}),
inputWidth: css({
width: 340,
@@ -0,0 +1,18 @@
import React from 'react';
import { Alert, Button, Stack } from '@grafana/ui';
export function FederatedRuleWarning() {
return (
<Alert severity="info" title="This rule is part of a federated rule group." bottomSpacing={0} topSpacing={2}>
<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>
</Alert>
);
}
@@ -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 && (
<LinkButton
size="md"
variant="secondary"
icon="compass"
target="_blank"
href={createExploreLink(dsSettings, model)}
>
View in Explore
</LinkButton>
<WithReturnButton
component={
<LinkButton size="md" variant="secondary" icon="compass" href={createExploreLink(dsSettings, model)}>
View in Explore
</LinkButton>
}
/>
)}
</div>
</div>
@@ -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(),
}));
@@ -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 (
<AlertingPageWrapper
pageNav={pageNav}
navId="alert-list"
isLoading={false}
renderTitle={(title) => {
return <Title name={title} state={isAlertType ? promRule.state : undefined} />;
}}
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;
@@ -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>
);
};
@@ -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;
@@ -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}>
@@ -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);
}
+4 -1
View File
@@ -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;