Alerting: Detail view part 3 (#81286)
This commit is contained in:
+6
-11
@@ -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>
|
||||
);
|
||||
}
|
||||
+8
-9
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
-5
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user