mirror of
https://github.com/grafana/grafana.git
synced 2024-11-22 08:56:43 -06:00
Alerting: Detail view v2 (#77795)
This commit is contained in:
parent
f6f259f0b5
commit
e5227550c4
@ -1464,8 +1464,7 @@ exports[`better eslint`] = {
|
||||
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "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.", "2"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/Authorize.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
|
@ -26,6 +26,7 @@ export function PageTabs({ navItem }: Props) {
|
||||
counter={child.tabCounter}
|
||||
href={child.url}
|
||||
suffix={child.tabSuffix}
|
||||
onChangeTab={child.onClick}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
@ -8,7 +8,7 @@ import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||
|
||||
export default function Admin(): JSX.Element {
|
||||
return (
|
||||
<AlertmanagerPageWrapper pageId="alerting-admin" accessType="notification">
|
||||
<AlertmanagerPageWrapper navId="alerting-admin" accessType="notification">
|
||||
<AdminPageContents />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
|
@ -91,7 +91,7 @@ const AlertGroups = () => {
|
||||
};
|
||||
|
||||
const AlertGroupsPage = () => (
|
||||
<AlertmanagerPageWrapper pageId="groups" accessType="instance">
|
||||
<AlertmanagerPageWrapper navId="groups" accessType="instance">
|
||||
<AlertGroups />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
|
@ -78,7 +78,7 @@ const MuteTimingsPage = () => {
|
||||
const pageNav = useMuteTimingNavData();
|
||||
|
||||
return (
|
||||
<AlertmanagerPageWrapper pageId="am-routes" pageNav={pageNav} accessType="notification">
|
||||
<AlertmanagerPageWrapper navId="am-routes" pageNav={pageNav} accessType="notification">
|
||||
<MuteTimings />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
|
@ -329,7 +329,7 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap): QueryParamValues {
|
||||
}
|
||||
|
||||
const NotificationPoliciesPage = () => (
|
||||
<AlertmanagerPageWrapper pageId="am-routes" accessType="notification">
|
||||
<AlertmanagerPageWrapper navId="am-routes" accessType="notification">
|
||||
<AmRoutes />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
|
@ -17,7 +17,7 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||
|
||||
const ContactPoints = (_props: GrafanaRouteComponentProps): JSX.Element => (
|
||||
<AlertmanagerPageWrapper pageId="receivers" accessType="notification">
|
||||
<AlertmanagerPageWrapper navId="receivers" accessType="notification">
|
||||
<Switch>
|
||||
<Route exact={true} path="/alerting/notifications" component={ContactPointsV2} />
|
||||
<Route exact={true} path="/alerting/notifications/receivers/new" component={NewContactPoint} />
|
||||
|
@ -90,7 +90,7 @@ const RuleEditor = ({ match }: RuleEditorProps) => {
|
||||
}, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier, loading]);
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper isLoading={loading} pageId="alert-list" pageNav={getPageNav(identifier, type)}>
|
||||
<AlertingPageWrapper isLoading={loading} navId="alert-list" pageNav={getPageNav(identifier, type)}>
|
||||
{getContent()}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
|
@ -105,7 +105,7 @@ const RuleList = withErrorBoundary(
|
||||
return (
|
||||
// We don't want to show the Loading... indicator for the whole page.
|
||||
// We show separate indicators for Grafana-managed and Cloud rules
|
||||
<AlertingPageWrapper pageId="alert-list" isLoading={false}>
|
||||
<AlertingPageWrapper navId="alert-list" isLoading={false}>
|
||||
<RuleListErrors />
|
||||
<RulesFilter onFilterCleared={onFilterCleared} />
|
||||
{!hasNoAlertRulesCreatedYet && (
|
||||
|
@ -1,14 +1,17 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { withErrorBoundary } from '@grafana/ui';
|
||||
import { Alert, withErrorBoundary } from '@grafana/ui';
|
||||
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
|
||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { useCombinedRule } from './hooks/useCombinedRule';
|
||||
import { getRuleIdFromPathname, parse as parseRuleId } from './utils/rule-id';
|
||||
|
||||
const DetailViewV1 = SafeDynamicImport(() => import('./components/rule-viewer/RuleViewer.v1'));
|
||||
const DetailViewV2 = SafeDynamicImport(() => import('./components/rule-viewer/v2/RuleViewer.v2'));
|
||||
const DetailViewV2 = React.lazy(() => import('./components/rule-viewer/v2/RuleViewer.v2'));
|
||||
|
||||
type RuleViewerProps = GrafanaRouteComponentProps<{
|
||||
id: string;
|
||||
@ -17,10 +20,51 @@ type RuleViewerProps = GrafanaRouteComponentProps<{
|
||||
|
||||
const newAlertDetailView = Boolean(config.featureToggles.alertingDetailsViewV2) === true;
|
||||
|
||||
const RuleViewer = (props: RuleViewerProps): JSX.Element => (
|
||||
<AlertingPageWrapper>
|
||||
{newAlertDetailView ? <DetailViewV2 {...props} /> : <DetailViewV1 {...props} />}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
const RuleViewer = (props: RuleViewerProps): JSX.Element => {
|
||||
return newAlertDetailView ? <RuleViewerV2Wrapper {...props} /> : <RuleViewerV1Wrapper {...props} />;
|
||||
};
|
||||
|
||||
export const defaultPageNav: NavModelItem = {
|
||||
id: 'alert-rule-view',
|
||||
text: '',
|
||||
};
|
||||
|
||||
const RuleViewerV1Wrapper = (props: RuleViewerProps) => <DetailViewV1 {...props} />;
|
||||
|
||||
const RuleViewerV2Wrapper = (props: RuleViewerProps) => {
|
||||
const id = getRuleIdFromPathname(props.match.params);
|
||||
const identifier = useMemo(() => {
|
||||
if (!id) {
|
||||
throw new Error('Rule ID is required');
|
||||
}
|
||||
|
||||
return parseRuleId(id, true);
|
||||
}, [id]);
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AlertingPageWrapper pageNav={defaultPageNav} navId="alert-list" isLoading={true}>
|
||||
<></>
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (rule) {
|
||||
return <DetailViewV2 rule={rule} identifier={identifier} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default withErrorBoundary(RuleViewer, { style: 'page' });
|
||||
|
@ -112,7 +112,7 @@ function SilencesPage() {
|
||||
const pageNav = useSilenceNavData();
|
||||
|
||||
return (
|
||||
<AlertmanagerPageWrapper pageId="silences" pageNav={pageNav} accessType="instance">
|
||||
<AlertmanagerPageWrapper navId="silences" pageNav={pageNav} accessType="instance">
|
||||
<Silences />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
|
@ -2,26 +2,28 @@ import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { ComponentSize, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaAlertState } from 'app/types/unified-alerting-dto';
|
||||
import { ComponentSize, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
const AlertStateDot = (props: DotStylesProps) => {
|
||||
const styles = useStyles2(getDotStyles, props);
|
||||
|
||||
return (
|
||||
<Tooltip content={String(props.state)} placement="top">
|
||||
<Stack direction="row" gap={0.5}>
|
||||
<div className={styles.dot} />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface DotStylesProps {
|
||||
state?: GrafanaAlertState;
|
||||
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)`;
|
||||
|
||||
return {
|
||||
dot: css`
|
||||
@ -31,21 +33,22 @@ const getDotStyles = (theme: GrafanaTheme2, props: DotStylesProps) => {
|
||||
border-radius: 100%;
|
||||
|
||||
background-color: ${theme.colors.secondary.main};
|
||||
outline: solid calc(${size} / 2.5) ${theme.colors.secondary.transparent};
|
||||
outline: solid ${outlineSize} ${theme.colors.secondary.transparent};
|
||||
margin: ${outlineSize};
|
||||
|
||||
${props.state === GrafanaAlertState.Normal &&
|
||||
${props.state === PromAlertingRuleState.Inactive &&
|
||||
css`
|
||||
background-color: ${theme.colors.success.main};
|
||||
outline-color: ${theme.colors.success.transparent};
|
||||
`}
|
||||
|
||||
${props.state === GrafanaAlertState.Pending &&
|
||||
${props.state === PromAlertingRuleState.Pending &&
|
||||
css`
|
||||
background-color: ${theme.colors.warning.main};
|
||||
outline-color: ${theme.colors.warning.transparent};
|
||||
`}
|
||||
|
||||
${props.state === GrafanaAlertState.Alerting &&
|
||||
${props.state === PromAlertingRuleState.Firing &&
|
||||
css`
|
||||
background-color: ${theme.colors.error.main};
|
||||
outline-color: ${theme.colors.error.transparent};
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { useLocation } from 'react-use';
|
||||
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { PageProps } from 'app/core/components/Page/types';
|
||||
|
||||
import { AlertmanagerProvider, useAlertmanager } from '../state/AlertmanagerContext';
|
||||
|
||||
@ -12,19 +12,15 @@ import { NoAlertManagerWarning } from './NoAlertManagerWarning';
|
||||
/**
|
||||
* This is the main alerting page wrapper, used by the alertmanager page wrapper and the alert rules list view
|
||||
*/
|
||||
interface AlertingPageWrapperProps extends PropsWithChildren {
|
||||
pageId?: string;
|
||||
interface AlertingPageWrapperProps extends PageProps {
|
||||
isLoading?: boolean;
|
||||
pageNav?: NavModelItem;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
export const AlertingPageWrapper = ({ children, pageId, pageNav, actions, isLoading }: AlertingPageWrapperProps) => {
|
||||
return (
|
||||
<Page pageNav={pageNav} navId={pageId} actions={actions}>
|
||||
<Page.Contents isLoading={isLoading}>{children}</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertingPageWrapper = ({ children, isLoading, ...rest }: AlertingPageWrapperProps) => (
|
||||
<Page {...rest}>
|
||||
<Page.Contents isLoading={isLoading}>{children}</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
|
||||
/**
|
||||
* This wrapper is for pages that use the Alertmanager API
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2, textUtil } from '@grafana/data';
|
||||
import { Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { TextLink, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { Annotation, annotationLabels } from '../utils/constants';
|
||||
|
||||
@ -44,9 +44,9 @@ const AnnotationValue = ({ annotationKey, value, valueLink }: Props) => {
|
||||
|
||||
if (valueLink) {
|
||||
return (
|
||||
<a href={textUtil.sanitizeUrl(valueLink)} className={styles.link}>
|
||||
<TextLink href={valueLink} external>
|
||||
{value}
|
||||
</a>
|
||||
</TextLink>
|
||||
);
|
||||
}
|
||||
|
||||
@ -56,9 +56,9 @@ const AnnotationValue = ({ annotationKey, value, valueLink }: Props) => {
|
||||
|
||||
if (needsExternalLink) {
|
||||
return (
|
||||
<a href={textUtil.sanitizeUrl(value)} target="__blank" className={styles.link}>
|
||||
<TextLink href={value} external>
|
||||
{value}
|
||||
</a>
|
||||
</TextLink>
|
||||
);
|
||||
}
|
||||
|
||||
@ -69,8 +69,4 @@ export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
well: css`
|
||||
word-break: break-word;
|
||||
`,
|
||||
link: css`
|
||||
word-break: break-all;
|
||||
color: ${theme.colors.primary.text};
|
||||
`,
|
||||
});
|
||||
|
@ -5,13 +5,18 @@ import { Icon, IconName, useStyles2, Text, Stack } from '@grafana/ui';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
icon?: IconName;
|
||||
direction?: 'row' | 'column';
|
||||
color?: ComponentProps<typeof Text>['color'];
|
||||
}
|
||||
|
||||
const MetaText = ({ children, icon, color = 'secondary', ...rest }: Props) => {
|
||||
const MetaText = ({ children, icon, color = 'secondary', direction = 'row', ...rest }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const interactive = typeof rest.onClick === 'function';
|
||||
|
||||
const rowDirection = direction === 'row';
|
||||
const alignItems = rowDirection ? 'center' : 'flex-start';
|
||||
const gap = rowDirection ? 0.5 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx({
|
||||
@ -21,7 +26,7 @@ const MetaText = ({ children, icon, color = 'secondary', ...rest }: Props) => {
|
||||
{...rest}
|
||||
>
|
||||
<Text variant="bodySmall" color={color}>
|
||||
<Stack direction="row" alignItems="center" gap={0.5} wrap={'wrap'}>
|
||||
<Stack direction={direction} alignItems={alignItems} gap={gap} wrap={'wrap'}>
|
||||
{icon && <Icon size="sm" name={icon} />}
|
||||
{children}
|
||||
</Stack>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Button, LinkButton, Tooltip } from '@grafana/ui';
|
||||
import { Button, LinkButton, Menu, Tooltip } from '@grafana/ui';
|
||||
|
||||
import { usePluginBridge } from '../../hooks/usePluginBridge';
|
||||
import { SupportedPlugin } from '../../types/pluginBridges';
|
||||
@ -8,11 +8,11 @@ import { createBridgeURL } from '../PluginBridge';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
severity?: 'minor' | 'major' | 'critical';
|
||||
severity?: 'minor' | 'major' | 'critical' | '';
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export const DeclareIncident: FC<Props> = ({ title = '', severity = '', url = '' }) => {
|
||||
export const DeclareIncidentButton = ({ title = '', severity = '', url = '' }: Props) => {
|
||||
const bridgeURL = createBridgeURL(SupportedPlugin.Incident, '/incidents/declare', { title, severity, url });
|
||||
|
||||
const { loading, installed, settings } = usePluginBridge(SupportedPlugin.Incident);
|
||||
@ -39,3 +39,21 @@ export const DeclareIncident: FC<Props> = ({ title = '', severity = '', url = ''
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeclareIncidentMenuItem = ({ title = '', severity = '', url = '' }: Props) => {
|
||||
const bridgeURL = createBridgeURL(SupportedPlugin.Incident, '/incidents/declare', { title, severity, url });
|
||||
|
||||
const { loading, installed, settings } = usePluginBridge(SupportedPlugin.Incident);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading === true && <Menu.Item label="Declare incident" icon="fire" disabled />}
|
||||
{installed === false && (
|
||||
<Tooltip content={'Grafana Incident is not installed or is not configured correctly'}>
|
||||
<Menu.Item label="Declare incident" icon="fire" disabled />
|
||||
</Tooltip>
|
||||
)}
|
||||
{settings && <Menu.Item label="Declare incident" url={bridgeURL} icon="fire" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -66,7 +66,7 @@ export default function GrafanaModifyExport({ match }: GrafanaModifyExportProps)
|
||||
if (!alertRule && !loading && !loadingBuildInfo) {
|
||||
// alert rule does not exist
|
||||
return (
|
||||
<AlertingPageWrapper isLoading={loading} pageId="alert-list" pageNav={{ text: 'Modify export' }}>
|
||||
<AlertingPageWrapper isLoading={loading} navId="alert-list" pageNav={{ text: 'Modify export' }}>
|
||||
<Alert
|
||||
title="Cannot load the rule. The rule does not exist"
|
||||
buttonContent="Go back to alert list"
|
||||
@ -79,7 +79,7 @@ export default function GrafanaModifyExport({ match }: GrafanaModifyExportProps)
|
||||
if (alertRule && !isGrafanaRulerRule(alertRule.rule)) {
|
||||
// alert rule exists but is not a grafana-managed rule
|
||||
return (
|
||||
<AlertingPageWrapper isLoading={loading} pageId="alert-list" pageNav={{ text: 'Modify export' }}>
|
||||
<AlertingPageWrapper isLoading={loading} navId="alert-list" pageNav={{ text: 'Modify export' }}>
|
||||
<Alert
|
||||
title="This rule is not a Grafana-managed alert rule"
|
||||
buttonContent="Go back to alert list"
|
||||
@ -92,7 +92,7 @@ export default function GrafanaModifyExport({ match }: GrafanaModifyExportProps)
|
||||
return (
|
||||
<AlertingPageWrapper
|
||||
isLoading={loading}
|
||||
pageId="alert-list"
|
||||
navId="alert-list"
|
||||
pageNav={{
|
||||
text: 'Modify export',
|
||||
subTitle:
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { produce } from 'immer';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useObservable, useToggle } from 'react-use';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, LoadingState, PanelData, RelativeTimeRange } from '@grafana/data';
|
||||
import { config, isFetchError } from '@grafana/runtime';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { isFetchError } from '@grafana/runtime';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@ -12,27 +11,24 @@ import {
|
||||
Icon,
|
||||
IconButton,
|
||||
LoadingPlaceholder,
|
||||
Stack,
|
||||
VerticalGroup,
|
||||
useStyles2,
|
||||
VerticalGroup,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@grafana/ui';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
|
||||
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
|
||||
import { AlertQuery, GrafanaRuleDefinition } from '../../../../../types/unified-alerting-dto';
|
||||
import { GrafanaRuleQueryViewer, QueryPreview } from '../../GrafanaRuleQueryViewer';
|
||||
import { useAlertQueriesStatus } from '../../hooks/useAlertQueriesStatus';
|
||||
import { GrafanaRuleDefinition } from '../../../../../types/unified-alerting-dto';
|
||||
import { useCombinedRule } from '../../hooks/useCombinedRule';
|
||||
import { AlertingQueryRunner } from '../../state/AlertingQueryRunner';
|
||||
import { useCleanAnnotations } from '../../utils/annotations';
|
||||
import { getRulesSourceByName } from '../../utils/datasource';
|
||||
import { alertRuleToQueries } from '../../utils/query';
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { AlertLabels } from '../AlertLabels';
|
||||
import { DetailsField } from '../DetailsField';
|
||||
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
|
||||
import { RuleViewerLayout, RuleViewerLayoutContent } from '../rule-viewer/RuleViewerLayout';
|
||||
import { RuleViewerLayout } from '../rule-viewer/RuleViewerLayout';
|
||||
import { RuleDetailsActionButtons } from '../rules/RuleDetailsActionButtons';
|
||||
import { RuleDetailsAnnotations } from '../rules/RuleDetailsAnnotations';
|
||||
import { RuleDetailsDataSources } from '../rules/RuleDetailsDataSources';
|
||||
@ -42,6 +38,8 @@ import { RuleDetailsMatchingInstances } from '../rules/RuleDetailsMatchingInstan
|
||||
import { RuleHealth } from '../rules/RuleHealth';
|
||||
import { RuleState } from '../rules/RuleState';
|
||||
|
||||
import { QueryResults } from './tabs/Query';
|
||||
|
||||
type RuleViewerProps = GrafanaRouteComponentProps<{ id?: string; sourceName?: string }>;
|
||||
|
||||
const errorMessage = 'Could not find data source for rule';
|
||||
@ -63,58 +61,8 @@ export function RuleViewer({ match }: RuleViewerProps) {
|
||||
|
||||
const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier });
|
||||
|
||||
const runner = useMemo(() => new AlertingQueryRunner(), []);
|
||||
const data = useObservable(runner.get());
|
||||
const queries = useMemo(() => alertRuleToQueries(rule), [rule]);
|
||||
const annotations = useCleanAnnotations(rule?.annotations || {});
|
||||
|
||||
const [evaluationTimeRanges, setEvaluationTimeRanges] = useState<Record<string, RelativeTimeRange>>({});
|
||||
|
||||
const { allDataSourcesAvailable } = useAlertQueriesStatus(queries);
|
||||
|
||||
const onRunQueries = useCallback(() => {
|
||||
if (queries.length > 0 && allDataSourcesAvailable) {
|
||||
const evalCustomizedQueries = queries.map<AlertQuery>((q) => ({
|
||||
...q,
|
||||
relativeTimeRange: evaluationTimeRanges[q.refId] ?? q.relativeTimeRange,
|
||||
}));
|
||||
let condition;
|
||||
if (rule && isGrafanaRulerRule(rule.rulerRule)) {
|
||||
condition = rule.rulerRule.grafana_alert.condition;
|
||||
}
|
||||
runner.run(evalCustomizedQueries, condition ?? 'A');
|
||||
}
|
||||
}, [queries, evaluationTimeRanges, runner, allDataSourcesAvailable, rule]);
|
||||
|
||||
useEffect(() => {
|
||||
const alertQueries = alertRuleToQueries(rule);
|
||||
const defaultEvalTimeRanges = Object.fromEntries(
|
||||
alertQueries.map((q) => [q.refId, q.relativeTimeRange ?? { from: 0, to: 0 }])
|
||||
);
|
||||
|
||||
setEvaluationTimeRanges(defaultEvalTimeRanges);
|
||||
}, [rule]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allDataSourcesAvailable && expandQuery) {
|
||||
onRunQueries();
|
||||
}
|
||||
}, [onRunQueries, allDataSourcesAvailable, expandQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => runner.destroy();
|
||||
}, [runner]);
|
||||
|
||||
const onQueryTimeRangeChange = useCallback(
|
||||
(refId: string, timeRange: RelativeTimeRange) => {
|
||||
const newEvalTimeRanges = produce(evaluationTimeRanges, (draft) => {
|
||||
draft[refId] = timeRange;
|
||||
});
|
||||
setEvaluationTimeRanges(newEvalTimeRanges);
|
||||
},
|
||||
[evaluationTimeRanges, setEvaluationTimeRanges]
|
||||
);
|
||||
|
||||
if (!identifier?.ruleSourceName) {
|
||||
return (
|
||||
<RuleViewerLayout title={pageTitle}>
|
||||
@ -160,7 +108,17 @@ export function RuleViewer({ match }: RuleViewerProps) {
|
||||
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RuleViewerLayout
|
||||
wrapInContent={false}
|
||||
title={pageTitle}
|
||||
renderTitle={() => (
|
||||
<Stack direction="row" alignItems="flex-start" gap={1}>
|
||||
<Icon name="bell" size="xl" />
|
||||
<Text variant="h3">{rule.name}</Text>
|
||||
<RuleState rule={rule} isCreating={false} isDeleting={false} />
|
||||
</Stack>
|
||||
)}
|
||||
>
|
||||
{isFederatedRule && (
|
||||
<Alert severity="info" title="This rule is part of a federated rule group.">
|
||||
<VerticalGroup>
|
||||
@ -174,14 +132,8 @@ export function RuleViewer({ match }: RuleViewerProps) {
|
||||
</Alert>
|
||||
)}
|
||||
{isProvisioned && <ProvisioningAlert resource={ProvisionedResource.AlertRule} />}
|
||||
<RuleViewerLayoutContent>
|
||||
<div>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
<Icon name="bell" size="lg" /> <span className={styles.title}>{rule.name}</span>
|
||||
</Stack>
|
||||
<RuleState rule={rule} isCreating={false} isDeleting={false} />
|
||||
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} isViewMode={true} />
|
||||
</div>
|
||||
<>
|
||||
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} isViewMode={true} />
|
||||
<div className={styles.details}>
|
||||
<div className={styles.leftSide}>
|
||||
{rule.promRule && (
|
||||
@ -207,57 +159,25 @@ export function RuleViewer({ match }: RuleViewerProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<RuleDetailsMatchingInstances
|
||||
rule={rule}
|
||||
pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }}
|
||||
enableFiltering
|
||||
/>
|
||||
<DetailsField label="Matching instances" horizontal={true}>
|
||||
<RuleDetailsMatchingInstances
|
||||
rule={rule}
|
||||
pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }}
|
||||
enableFiltering
|
||||
/>
|
||||
</DetailsField>
|
||||
</div>
|
||||
</RuleViewerLayoutContent>
|
||||
</>
|
||||
<Collapse
|
||||
label="Query & Results"
|
||||
isOpen={expandQuery}
|
||||
onToggle={setExpandQuery}
|
||||
loading={data && isLoading(data)}
|
||||
collapsible={true}
|
||||
className={styles.collapse}
|
||||
>
|
||||
{isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && (
|
||||
<GrafanaRuleQueryViewer
|
||||
condition={rule.rulerRule.grafana_alert.condition}
|
||||
queries={queries}
|
||||
evalDataByQuery={data}
|
||||
evalTimeRanges={evaluationTimeRanges}
|
||||
onTimeRangeChange={onQueryTimeRangeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && data && Object.keys(data).length > 0 && (
|
||||
<div className={styles.queries}>
|
||||
{queries.map((query) => {
|
||||
return (
|
||||
<QueryPreview
|
||||
key={query.refId}
|
||||
refId={query.refId}
|
||||
model={query.model}
|
||||
dataSource={Object.values(config.datasources).find((ds) => ds.uid === query.datasourceUid)}
|
||||
queryData={data[query.refId]}
|
||||
relativeTimeRange={query.relativeTimeRange}
|
||||
evalTimeRange={evaluationTimeRanges[query.refId]}
|
||||
onEvalTimeRangeChange={(timeRange) => onQueryTimeRangeChange(query.refId, timeRange)}
|
||||
isAlertCondition={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isFederatedRule && !allDataSourcesAvailable && (
|
||||
<Alert title="Query not available" severity="warning" className={styles.queryWarning}>
|
||||
Cannot display the query preview. Some of the data sources used in the queries are not available.
|
||||
</Alert>
|
||||
)}
|
||||
{expandQuery && <QueryResults rule={rule} />}
|
||||
</Collapse>
|
||||
</>
|
||||
</RuleViewerLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -272,10 +192,6 @@ function GrafanaRuleUID({ rule }: { rule: GrafanaRuleDefinition }) {
|
||||
);
|
||||
}
|
||||
|
||||
function isLoading(data: Record<string, PanelData>): boolean {
|
||||
return !!Object.values(data).find((d) => d.state === LoadingState.Loading);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
errorMessage: css`
|
||||
|
@ -4,10 +4,12 @@ import React from 'react';
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { PageProps } from 'app/core/components/Page/types';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode | React.ReactNode[];
|
||||
title: string;
|
||||
renderTitle?: PageProps['renderTitle'];
|
||||
wrapInContent?: boolean;
|
||||
};
|
||||
|
||||
@ -17,11 +19,11 @@ const defaultPageNav: Partial<NavModelItem> = {
|
||||
};
|
||||
|
||||
export function RuleViewerLayout(props: Props): JSX.Element | null {
|
||||
const { wrapInContent = true, children, title } = props;
|
||||
const { wrapInContent = true, children, title, renderTitle } = props;
|
||||
const styles = useStyles2(getPageStyles);
|
||||
|
||||
return (
|
||||
<Page pageNav={{ ...defaultPageNav, text: title }} navId="alert-list">
|
||||
<Page pageNav={{ ...defaultPageNav, text: title }} renderTitle={renderTitle} navId="alert-list">
|
||||
<Page.Contents>
|
||||
<div className={styles.content}>{wrapInContent ? <RuleViewerLayoutContent {...props} /> : children}</div>
|
||||
</Page.Contents>
|
||||
|
@ -0,0 +1,165 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Text, Stack, useStyles2, ClipboardButton, TextLink } from '@grafana/ui';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { Annotations } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { isGrafanaRulerRule, isRecordingRulerRule } from '../../../utils/rules';
|
||||
import { MetaText } from '../../MetaText';
|
||||
import { Tokenize } from '../../Tokenize';
|
||||
|
||||
interface DetailsProps {
|
||||
rule: CombinedRule;
|
||||
}
|
||||
|
||||
enum RuleType {
|
||||
GrafanaManagedAlertRule = 'Grafana-managed alert rule',
|
||||
CloudAlertRule = 'Cloud alert rule',
|
||||
CloudRecordingRule = 'Cloud recording rule',
|
||||
}
|
||||
|
||||
const Details = ({ rule }: DetailsProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
let ruleType: RuleType;
|
||||
|
||||
if (isGrafanaRulerRule(rule.rulerRule)) {
|
||||
ruleType = RuleType.GrafanaManagedAlertRule;
|
||||
} else if (isRecordingRulerRule(rule.rulerRule)) {
|
||||
ruleType = RuleType.CloudRecordingRule;
|
||||
} else {
|
||||
// probably not the greatest assumption
|
||||
ruleType = RuleType.CloudAlertRule;
|
||||
}
|
||||
|
||||
const evaluationDuration = rule.promRule?.evaluationTime;
|
||||
const evaluationTimestamp = rule.promRule?.lastEvaluation;
|
||||
|
||||
const copyRuleUID = useCallback(() => {
|
||||
if (isGrafanaRulerRule(rule.rulerRule)) {
|
||||
return rule.rulerRule.grafana_alert.uid;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}, [rule.rulerRule]);
|
||||
|
||||
const annotations: Annotations | undefined = !isRecordingRulerRule(rule.rulerRule)
|
||||
? rule.annotations ?? []
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={3}>
|
||||
<div className={styles.metadataWrapper}>
|
||||
{/* type and identifier (optional) */}
|
||||
<MetaText direction="column">
|
||||
Rule type
|
||||
<Text color="primary">{ruleType}</Text>
|
||||
</MetaText>
|
||||
<MetaText direction="column">
|
||||
{isGrafanaRulerRule(rule.rulerRule) && (
|
||||
<>
|
||||
Rule Identifier
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<Text color="primary">
|
||||
{rule.rulerRule.grafana_alert.uid}
|
||||
<ClipboardButton fill="text" variant="secondary" icon="copy" size="sm" getText={copyRuleUID} />
|
||||
</Text>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</MetaText>
|
||||
|
||||
{/* evaluation duration and pending period */}
|
||||
<MetaText direction="column">
|
||||
{evaluationDuration && (
|
||||
<>
|
||||
Last evaluation
|
||||
{evaluationTimestamp && evaluationDuration && (
|
||||
<span>
|
||||
<Text color="primary">{formatDistanceToNowStrict(new Date(evaluationTimestamp))} ago</Text>, took{' '}
|
||||
<Text color="primary">{evaluationDuration}ms</Text>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MetaText>
|
||||
<MetaText direction="column">
|
||||
{!isRecordingRulerRule(rule.rulerRule) && (
|
||||
<>
|
||||
Pending period
|
||||
<Text color="primary">{rule.rulerRule?.for ?? '0s'}</Text>
|
||||
</>
|
||||
)}
|
||||
</MetaText>
|
||||
|
||||
{/* nodata and execution error state mapping */}
|
||||
{isGrafanaRulerRule(rule.rulerRule) && (
|
||||
<>
|
||||
<MetaText direction="column">
|
||||
Alert state if no data or all values are null
|
||||
<Text color="primary">{rule.rulerRule.grafana_alert.no_data_state}</Text>
|
||||
</MetaText>
|
||||
<MetaText direction="column">
|
||||
Alert state if execution error or timeout
|
||||
<Text color="primary">{rule.rulerRule.grafana_alert.exec_err_state}</Text>
|
||||
</MetaText>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* annotations go here */}
|
||||
{annotations && (
|
||||
<>
|
||||
<Text variant="h4">Annotations</Text>
|
||||
{Object.keys(annotations).length === 0 ? (
|
||||
<Text variant="bodySmall" color="secondary" italic>
|
||||
No annotations
|
||||
</Text>
|
||||
) : (
|
||||
<div className={styles.metadataWrapper}>
|
||||
{Object.entries(annotations).map(([name, value]) => (
|
||||
<MetaText direction="column" key={name}>
|
||||
{name}
|
||||
<AnnotationValue value={value} />
|
||||
</MetaText>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface AnnotationValueProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
function AnnotationValue({ value }: AnnotationValueProps) {
|
||||
const needsExternalLink = value && value.startsWith('http');
|
||||
const tokenizeValue = <Tokenize input={value} delimiter={['{{', '}}']} />;
|
||||
|
||||
if (needsExternalLink) {
|
||||
return (
|
||||
<TextLink variant="bodySmall" href={value} external>
|
||||
{value}
|
||||
</TextLink>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text color="primary">{tokenizeValue}</Text>;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
metadataWrapper: css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto auto',
|
||||
rowGap: theme.spacing(3),
|
||||
columnGap: theme.spacing(12),
|
||||
}),
|
||||
});
|
||||
|
||||
export { Details };
|
@ -1,5 +1,40 @@
|
||||
import React from 'react';
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
|
||||
const History = () => <>History</>;
|
||||
import { config } from '@grafana/runtime';
|
||||
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { StateHistoryImplementation } from '../../../hooks/useStateHistoryModal';
|
||||
|
||||
const AnnotationsStateHistory = lazy(() => import('../../../components/rules/state-history/StateHistory'));
|
||||
const LokiStateHistory = lazy(() => import('../../../components/rules/state-history/LokiStateHistory'));
|
||||
|
||||
interface HistoryProps {
|
||||
rule: RulerGrafanaRuleDTO;
|
||||
}
|
||||
|
||||
const History = ({ rule }: HistoryProps) => {
|
||||
// can be "loki", "multiple" or "annotations"
|
||||
const stateHistoryBackend = config.unifiedAlerting.alertStateHistoryBackend;
|
||||
// can be "loki" or "annotations"
|
||||
const stateHistoryPrimary = config.unifiedAlerting.alertStateHistoryPrimary;
|
||||
|
||||
// if "loki" is either the backend or the primary, show the new state history implementation
|
||||
const usingNewAlertStateHistory = [stateHistoryBackend, stateHistoryPrimary].some(
|
||||
(implementation) => implementation === StateHistoryImplementation.Loki
|
||||
);
|
||||
const implementation = usingNewAlertStateHistory
|
||||
? StateHistoryImplementation.Loki
|
||||
: StateHistoryImplementation.Annotations;
|
||||
|
||||
const ruleID = rule.grafana_alert.id ?? '';
|
||||
const ruleUID = rule.grafana_alert.uid;
|
||||
|
||||
return (
|
||||
<Suspense fallback={'Loading...'}>
|
||||
{implementation === StateHistoryImplementation.Loki && <LokiStateHistory ruleUID={ruleUID} />}
|
||||
{implementation === StateHistoryImplementation.Annotations && <AnnotationsStateHistory alertId={ruleID} />}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export { History };
|
||||
|
@ -1,5 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const InstancesList = () => <>Instances</>;
|
||||
import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
|
||||
import { RuleDetailsMatchingInstances } from '../../rules/RuleDetailsMatchingInstances';
|
||||
|
||||
interface Props {
|
||||
rule: CombinedRule;
|
||||
}
|
||||
|
||||
const InstancesList = ({ rule }: Props) => (
|
||||
<RuleDetailsMatchingInstances
|
||||
rule={rule}
|
||||
pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }}
|
||||
enableFiltering
|
||||
/>
|
||||
);
|
||||
|
||||
export { InstancesList };
|
||||
|
@ -1,5 +1,132 @@
|
||||
import React from 'react';
|
||||
import { produce } from 'immer';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
const QueryResults = () => <>Query results</>;
|
||||
import { LoadingState, PanelData, RelativeTimeRange } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Alert } from '@grafana/ui';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { GrafanaRuleQueryViewer, QueryPreview } from '../../../GrafanaRuleQueryViewer';
|
||||
import { useAlertQueriesStatus } from '../../../hooks/useAlertQueriesStatus';
|
||||
import { AlertingQueryRunner } from '../../../state/AlertingQueryRunner';
|
||||
import { alertRuleToQueries } from '../../../utils/query';
|
||||
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules';
|
||||
|
||||
interface Props {
|
||||
rule: CombinedRule;
|
||||
}
|
||||
|
||||
const QueryResults = ({ rule }: Props) => {
|
||||
const [evaluationTimeRanges, setEvaluationTimeRanges] = useState<Record<string, RelativeTimeRange>>({});
|
||||
|
||||
const runner = useMemo(() => new AlertingQueryRunner(), []);
|
||||
const data = useObservable(runner.get());
|
||||
const loadingData = isLoading(data);
|
||||
|
||||
const queries = useMemo(() => alertRuleToQueries(rule), [rule]);
|
||||
|
||||
const { allDataSourcesAvailable } = useAlertQueriesStatus(queries);
|
||||
|
||||
const onRunQueries = useCallback(() => {
|
||||
if (queries.length > 0 && allDataSourcesAvailable) {
|
||||
const evalCustomizedQueries = queries.map<AlertQuery>((q) => ({
|
||||
...q,
|
||||
relativeTimeRange: evaluationTimeRanges[q.refId] ?? q.relativeTimeRange,
|
||||
}));
|
||||
|
||||
let condition;
|
||||
if (rule && isGrafanaRulerRule(rule.rulerRule)) {
|
||||
condition = rule.rulerRule.grafana_alert.condition;
|
||||
}
|
||||
runner.run(evalCustomizedQueries, condition ?? 'A');
|
||||
}
|
||||
}, [queries, allDataSourcesAvailable, rule, runner, evaluationTimeRanges]);
|
||||
|
||||
useEffect(() => {
|
||||
const alertQueries = alertRuleToQueries(rule);
|
||||
const defaultEvalTimeRanges = Object.fromEntries(
|
||||
alertQueries.map((q) => [q.refId, q.relativeTimeRange ?? { from: 0, to: 0 }])
|
||||
);
|
||||
|
||||
setEvaluationTimeRanges(defaultEvalTimeRanges);
|
||||
}, [rule]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allDataSourcesAvailable) {
|
||||
onRunQueries();
|
||||
}
|
||||
}, [allDataSourcesAvailable, onRunQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => runner.destroy();
|
||||
}, [runner]);
|
||||
|
||||
const onQueryTimeRangeChange = useCallback(
|
||||
(refId: string, timeRange: RelativeTimeRange) => {
|
||||
const newEvalTimeRanges = produce(evaluationTimeRanges, (draft) => {
|
||||
draft[refId] = timeRange;
|
||||
});
|
||||
setEvaluationTimeRanges(newEvalTimeRanges);
|
||||
},
|
||||
[evaluationTimeRanges, setEvaluationTimeRanges]
|
||||
);
|
||||
|
||||
const isFederatedRule = isFederatedRuleGroup(rule.group);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loadingData ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
<>
|
||||
{isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && (
|
||||
<GrafanaRuleQueryViewer
|
||||
condition={rule.rulerRule.grafana_alert.condition}
|
||||
queries={queries}
|
||||
evalDataByQuery={data}
|
||||
evalTimeRanges={evaluationTimeRanges}
|
||||
onTimeRangeChange={onQueryTimeRangeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && data && Object.keys(data).length > 0 && (
|
||||
<div>
|
||||
{queries.map((query) => {
|
||||
return (
|
||||
<QueryPreview
|
||||
key={query.refId}
|
||||
refId={query.refId}
|
||||
model={query.model}
|
||||
dataSource={Object.values(config.datasources).find((ds) => ds.uid === query.datasourceUid)}
|
||||
queryData={data[query.refId]}
|
||||
relativeTimeRange={query.relativeTimeRange}
|
||||
evalTimeRange={evaluationTimeRanges[query.refId]}
|
||||
onEvalTimeRangeChange={(timeRange) => onQueryTimeRangeChange(query.refId, timeRange)}
|
||||
isAlertCondition={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isFederatedRule && !allDataSourcesAvailable && (
|
||||
<Alert title="Query not available" severity="warning">
|
||||
Cannot display the query preview. Some of the data sources used in the queries are not available.
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function isLoading(data?: Record<string, PanelData>): boolean {
|
||||
if (!data) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!Object.values(data).find((d) => d.state === LoadingState.Loading);
|
||||
}
|
||||
|
||||
export { QueryResults };
|
||||
|
@ -0,0 +1,57 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { ConfirmModal } from '@grafana/ui';
|
||||
import { dispatch } from 'app/store/store';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
|
||||
import { deleteRuleAction } from '../../../state/actions';
|
||||
import { getRulesSourceName } from '../../../utils/datasource';
|
||||
import { fromRulerRule } from '../../../utils/rule-id';
|
||||
|
||||
type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void];
|
||||
|
||||
export const useDeleteModal = (): DeleteModalHook => {
|
||||
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule | undefined>();
|
||||
|
||||
const dismissModal = useCallback(() => {
|
||||
setRuleToDelete(undefined);
|
||||
}, []);
|
||||
|
||||
const showModal = useCallback((rule: CombinedRule) => {
|
||||
setRuleToDelete(rule);
|
||||
}, []);
|
||||
|
||||
const deleteRule = useCallback(
|
||||
(ruleToDelete?: CombinedRule) => {
|
||||
if (ruleToDelete && ruleToDelete.rulerRule) {
|
||||
const identifier = fromRulerRule(
|
||||
getRulesSourceName(ruleToDelete.namespace.rulesSource),
|
||||
ruleToDelete.namespace.name,
|
||||
ruleToDelete.group.name,
|
||||
ruleToDelete.rulerRule
|
||||
);
|
||||
|
||||
dispatch(deleteRuleAction(identifier, { navigateTo: '/alerting/list' }));
|
||||
dismissModal();
|
||||
}
|
||||
},
|
||||
[dismissModal]
|
||||
);
|
||||
|
||||
const modal = useMemo(
|
||||
() => (
|
||||
<ConfirmModal
|
||||
isOpen={Boolean(ruleToDelete)}
|
||||
title="Delete rule"
|
||||
body="Deleting this rule will permanently remove it from your alert rule list. Are you sure you want to delete this rule?"
|
||||
confirmText="Yes, delete"
|
||||
icon="exclamation-triangle"
|
||||
onConfirm={() => deleteRule(ruleToDelete)}
|
||||
onDismiss={dismissModal}
|
||||
/>
|
||||
),
|
||||
[deleteRule, dismissModal, ruleToDelete]
|
||||
);
|
||||
|
||||
return [modal, showModal, dismissModal];
|
||||
};
|
@ -1,97 +1,147 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { isEmpty, truncate } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { Alert, Button, Icon, LoadingPlaceholder, Tab, TabContent, TabsBar, Text, Stack } from '@grafana/ui';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
import { GrafanaAlertState } from 'app/types/unified-alerting-dto';
|
||||
import { AppEvents, NavModelItem, UrlQueryValue } from '@grafana/data';
|
||||
import { Alert, Button, Dropdown, LinkButton, Menu, Stack, TabContent, Text, TextLink } from '@grafana/ui';
|
||||
import { PageInfoItem } from 'app/core/components/Page/types';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { useRuleViewerPageTitle } from '../../../hooks/alert-details/useRuleViewerPageTitle';
|
||||
import { useCombinedRule } from '../../../hooks/useCombinedRule';
|
||||
import { defaultPageNav } from '../../../RuleViewer';
|
||||
import { AlertRuleAction, useAlertRuleAbility } from '../../../hooks/useAbilities';
|
||||
import { Annotation } from '../../../utils/constants';
|
||||
import {
|
||||
createShareLink,
|
||||
isLocalDevEnv,
|
||||
isOpenSourceEdition,
|
||||
makeDashboardLink,
|
||||
makePanelLink,
|
||||
makeRuleBasedSilenceLink,
|
||||
} from '../../../utils/misc';
|
||||
import * as ruleId from '../../../utils/rule-id';
|
||||
import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules';
|
||||
import { createUrl } from '../../../utils/url';
|
||||
import { AlertLabels } from '../../AlertLabels';
|
||||
import { AlertStateDot } from '../../AlertStateDot';
|
||||
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
|
||||
import MoreButton from '../../MoreButton';
|
||||
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
|
||||
import { Spacer } from '../../Spacer';
|
||||
import { DeclareIncidentMenuItem } from '../../bridges/DeclareIncidentButton';
|
||||
import { Details } from '../tabs/Details';
|
||||
import { History } from '../tabs/History';
|
||||
import { InstancesList } from '../tabs/Instances';
|
||||
import { QueryResults } from '../tabs/Query';
|
||||
import { Routing } from '../tabs/Routing';
|
||||
|
||||
type RuleViewerProps = GrafanaRouteComponentProps<{
|
||||
id: string;
|
||||
sourceName: string;
|
||||
}>;
|
||||
import { useDeleteModal } from './DeleteModal';
|
||||
|
||||
enum Tabs {
|
||||
Instances,
|
||||
Query,
|
||||
Routing,
|
||||
History,
|
||||
type RuleViewerProps = {
|
||||
rule: CombinedRule;
|
||||
identifier: RuleIdentifier;
|
||||
};
|
||||
|
||||
enum ActiveTab {
|
||||
Query = 'query',
|
||||
Instances = 'instances',
|
||||
History = 'history',
|
||||
Routing = 'routing',
|
||||
Details = 'details',
|
||||
}
|
||||
|
||||
// @TODO
|
||||
// hook up tabs to query params or path segment
|
||||
// figure out why we needed <AlertingPageWrapper>
|
||||
// add provisioning and federation stuff back in
|
||||
const RuleViewer = ({ match }: RuleViewerProps) => {
|
||||
const [activeTab, setActiveTab] = useState<Tabs>(Tabs.Instances);
|
||||
const RuleViewer = ({ rule, identifier }: RuleViewerProps) => {
|
||||
const { pageNav, activeTab } = usePageNav(rule);
|
||||
const [deleteModal, showDeleteModal] = useDeleteModal();
|
||||
|
||||
const id = ruleId.getRuleIdFromPathname(match.params);
|
||||
const identifier = useMemo(() => {
|
||||
if (!id) {
|
||||
throw new Error('Rule ID is required');
|
||||
const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update);
|
||||
const canEdit = editSupported && editAllowed;
|
||||
|
||||
const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete);
|
||||
const canDelete = deleteSupported && deleteAllowed;
|
||||
|
||||
const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate);
|
||||
const canDuplicate = duplicateSupported && duplicateAllowed;
|
||||
|
||||
const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence);
|
||||
const canSilence = silenceSupported && silenceAllowed;
|
||||
|
||||
const [exportSupported, exportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport);
|
||||
const canExport = exportSupported && exportAllowed;
|
||||
|
||||
const promRule = rule.promRule;
|
||||
|
||||
const isAlertType = isAlertingRule(promRule);
|
||||
|
||||
const isFederatedRule = isFederatedRuleGroup(rule.group);
|
||||
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
|
||||
|
||||
/**
|
||||
* Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana.
|
||||
* We should show it in development mode
|
||||
*/
|
||||
const shouldShowDeclareIncidentButton = !isOpenSourceEdition() || isLocalDevEnv();
|
||||
const shareUrl = createShareLink(rule.namespace.rulesSource, rule);
|
||||
|
||||
const copyShareUrl = () => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']);
|
||||
}
|
||||
};
|
||||
|
||||
return ruleId.parse(id, true);
|
||||
}, [id]);
|
||||
|
||||
const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier });
|
||||
|
||||
// we're setting the document title and the breadcrumb manually
|
||||
useRuleViewerPageTitle(rule);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingPlaceholder text={'Loading...'} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
if (rule) {
|
||||
const summary = rule.annotations['summary'];
|
||||
const promRule = rule.promRule;
|
||||
|
||||
const isAlertType = isAlertingRule(promRule);
|
||||
const numberOfInstance = isAlertType ? promRule.alerts?.length : undefined;
|
||||
|
||||
const isFederatedRule = isFederatedRuleGroup(rule.group);
|
||||
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="column" gap={3}>
|
||||
{/* breadcrumb and actions */}
|
||||
<Stack>
|
||||
<BreadCrumb folder={rule.namespace.name} evaluationGroup={rule.group.name} />
|
||||
<Spacer />
|
||||
<Stack gap={1}>
|
||||
<Button variant="secondary" icon="pen">
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="secondary">
|
||||
<Stack alignItems="center" gap={1}>
|
||||
More <Icon name="angle-down" />
|
||||
</Stack>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{/* header */}
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack alignItems="center">
|
||||
<Title name={rule.name} state={GrafanaAlertState.Alerting} />
|
||||
</Stack>
|
||||
{summary && <Summary text={summary} />}
|
||||
</Stack>
|
||||
return (
|
||||
<AlertingPageWrapper
|
||||
pageNav={pageNav}
|
||||
navId="alert-list"
|
||||
isLoading={false}
|
||||
renderTitle={(title) => {
|
||||
return <Title name={title} state={isAlertType ? promRule.state : undefined} />;
|
||||
}}
|
||||
actions={[
|
||||
canEdit && <EditButton key="edit-action" identifier={identifier} />,
|
||||
<Dropdown
|
||||
key="more-actions"
|
||||
overlay={
|
||||
<Menu>
|
||||
{canSilence && (
|
||||
<Menu.Item
|
||||
label="Silence"
|
||||
icon="bell-slash"
|
||||
url={makeRuleBasedSilenceLink(identifier.ruleSourceName, rule)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={rule.name} url={''} />}
|
||||
{canDuplicate && <Menu.Item label="Duplicate" icon="copy" />}
|
||||
<Menu.Divider />
|
||||
<Menu.Item label="Copy link" icon="share-alt" onClick={copyShareUrl} />
|
||||
{canExport && (
|
||||
<Menu.Item
|
||||
label="Export"
|
||||
icon="download-alt"
|
||||
childItems={[
|
||||
<Menu.Item key="no-modifications" label="Without modifications" icon="file-blank" />,
|
||||
<Menu.Item key="with-modifications" label="With modifications" icon="file-alt" />,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => showDeleteModal(rule)} />
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<MoreButton size="md" />
|
||||
</Dropdown>,
|
||||
]}
|
||||
info={createMetadata(rule)}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
{/* actions */}
|
||||
<Stack direction="column" gap={2}>
|
||||
{/* alerts and notifications and stuff */}
|
||||
{isFederatedRule && (
|
||||
<Alert severity="info" title="This rule is part of a federated rule group.">
|
||||
@ -107,74 +157,237 @@ const RuleViewer = ({ match }: RuleViewerProps) => {
|
||||
)}
|
||||
{isProvisioned && <ProvisioningAlert resource={ProvisionedResource.AlertRule} />}
|
||||
{/* tabs and tab content */}
|
||||
<TabsBar>
|
||||
<Tab label="Instances" active counter={numberOfInstance} onChangeTab={() => setActiveTab(Tabs.Instances)} />
|
||||
<Tab label="Query" onChangeTab={() => setActiveTab(Tabs.Query)} />
|
||||
<Tab label="Routing" onChangeTab={() => setActiveTab(Tabs.Routing)} />
|
||||
<Tab label="History" onChangeTab={() => setActiveTab(Tabs.History)} />
|
||||
</TabsBar>
|
||||
<TabContent>
|
||||
{activeTab === Tabs.Instances && <InstancesList />}
|
||||
{activeTab === Tabs.Query && <QueryResults />}
|
||||
{activeTab === Tabs.Routing && <Routing />}
|
||||
{activeTab === Tabs.History && <History />}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
</Stack>
|
||||
{deleteModal}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
interface BreadcrumbProps {
|
||||
folder: string;
|
||||
evaluationGroup: string;
|
||||
interface EditButtonProps {
|
||||
identifier: RuleIdentifier;
|
||||
}
|
||||
|
||||
const BreadCrumb = ({ folder, evaluationGroup }: BreadcrumbProps) => (
|
||||
<Stack alignItems="center" gap={0.5}>
|
||||
<Text color="secondary">
|
||||
<Icon name="folder" />
|
||||
</Text>
|
||||
<Text variant="body" color="primary">
|
||||
{folder}
|
||||
</Text>
|
||||
<Text variant="body" color="secondary">
|
||||
<Icon name="angle-right" />
|
||||
</Text>
|
||||
<Text variant="body" color="primary">
|
||||
{evaluationGroup}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
export const EditButton = ({ identifier }: EditButtonProps) => {
|
||||
const returnTo = location.pathname + location.search;
|
||||
const ruleIdentifier = ruleId.stringifyIdentifier(identifier);
|
||||
const editURL = createUrl(`/alerting/${encodeURIComponent(ruleIdentifier)}/edit`, { returnTo });
|
||||
|
||||
return (
|
||||
<LinkButton variant="secondary" icon="pen" href={editURL}>
|
||||
Edit
|
||||
</LinkButton>
|
||||
);
|
||||
};
|
||||
|
||||
const createMetadata = (rule: CombinedRule): PageInfoItem[] => {
|
||||
const { labels, annotations, group } = rule;
|
||||
const metadata: PageInfoItem[] = [];
|
||||
|
||||
const runbookUrl = annotations[Annotation.runbookURL];
|
||||
const dashboardUID = annotations[Annotation.dashboardUID];
|
||||
const panelID = annotations[Annotation.panelID];
|
||||
|
||||
const hasPanel = dashboardUID && panelID;
|
||||
const hasDashboardWithoutPanel = dashboardUID && !panelID;
|
||||
const hasLabels = !isEmpty(labels);
|
||||
|
||||
const interval = group.interval;
|
||||
|
||||
if (runbookUrl) {
|
||||
metadata.push({
|
||||
label: 'Runbook',
|
||||
value: (
|
||||
<TextLink variant="bodySmall" href={runbookUrl} external>
|
||||
{/* TODO instead of truncating the string, we should use flex and text overflow properly to allow it to take up all of the horizontal space available */}
|
||||
{truncate(runbookUrl, { length: 42 })}
|
||||
</TextLink>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPanel) {
|
||||
metadata.push({
|
||||
label: 'Dashboard and panel',
|
||||
value: (
|
||||
<TextLink variant="bodySmall" href={makePanelLink(dashboardUID, panelID)} external>
|
||||
View panel
|
||||
</TextLink>
|
||||
),
|
||||
});
|
||||
} else if (hasDashboardWithoutPanel) {
|
||||
metadata.push({
|
||||
label: 'Dashboard',
|
||||
value: (
|
||||
<TextLink variant="bodySmall" href={makeDashboardLink(dashboardUID)} external>
|
||||
View dashboard
|
||||
</TextLink>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (interval) {
|
||||
metadata.push({
|
||||
label: 'Evaluation interval',
|
||||
value: <Text color="primary">Every {interval}</Text>,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasLabels) {
|
||||
metadata.push({
|
||||
label: 'Labels',
|
||||
/* TODO truncate number of labels, maybe build in to component? */
|
||||
value: <AlertLabels labels={labels} size="sm" />,
|
||||
});
|
||||
}
|
||||
|
||||
return metadata;
|
||||
};
|
||||
|
||||
// TODO move somewhere else
|
||||
export const createListFilterLink = (values: Array<[string, string]>) => {
|
||||
const params = new URLSearchParams([['search', values.map(([key, value]) => `${key}:"${value}"`).join(' ')]]);
|
||||
return createUrl(`/alerting/list?` + params.toString());
|
||||
};
|
||||
|
||||
interface TitleProps {
|
||||
name: string;
|
||||
state: GrafanaAlertState;
|
||||
// recording rules don't have a state
|
||||
state?: PromAlertingRuleState;
|
||||
}
|
||||
|
||||
const Title = ({ name, state }: TitleProps) => (
|
||||
<header>
|
||||
<Stack alignItems={'center'} gap={1}>
|
||||
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;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="row" gap={0.5}>
|
||||
<AlertStateDot size="md" state={state} />
|
||||
{/* <Button variant="secondary" fill="outline" icon="angle-left" /> */}
|
||||
<Text element="h1" variant="h2" weight="bold">
|
||||
{name}
|
||||
<Text variant="bodySmall" color={textColor}>
|
||||
{stateLabel}
|
||||
</Text>
|
||||
{/* <Badge color="red" text={state} icon="exclamation-circle" /> */}
|
||||
</Stack>
|
||||
</header>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
interface SummaryProps {
|
||||
text: string;
|
||||
function useActiveTab(): [ActiveTab, (tab: ActiveTab) => void] {
|
||||
const [queryParams, setQueryParams] = useQueryParams();
|
||||
const tabFromQuery = queryParams['tab'];
|
||||
|
||||
const activeTab = isValidTab(tabFromQuery) ? tabFromQuery : ActiveTab.Query;
|
||||
|
||||
const setActiveTab = (tab: ActiveTab) => {
|
||||
setQueryParams({ tab });
|
||||
};
|
||||
|
||||
return [activeTab, setActiveTab];
|
||||
}
|
||||
|
||||
const Summary = ({ text }: SummaryProps) => (
|
||||
<Text variant="body" color="secondary">
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
function isValidTab(tab: UrlQueryValue): tab is ActiveTab {
|
||||
const isString = typeof tab === 'string';
|
||||
// @ts-ignore
|
||||
return isString && Object.values(ActiveTab).includes(tab);
|
||||
}
|
||||
|
||||
function usePageNav(rule: CombinedRule) {
|
||||
const [activeTab, setActiveTab] = useActiveTab();
|
||||
|
||||
const { annotations, promRule } = rule;
|
||||
|
||||
const summary = annotations[Annotation.summary];
|
||||
const isAlertType = isAlertingRule(promRule);
|
||||
const numberOfInstance = isAlertType ? (promRule.alerts ?? []).length : undefined;
|
||||
|
||||
const pageNav: NavModelItem = {
|
||||
...defaultPageNav,
|
||||
text: rule.name,
|
||||
subTitle: summary,
|
||||
children: [
|
||||
{
|
||||
text: 'Query and conditions',
|
||||
active: activeTab === ActiveTab.Query,
|
||||
onClick: () => {
|
||||
setActiveTab(ActiveTab.Query);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Instances',
|
||||
active: activeTab === ActiveTab.Instances,
|
||||
onClick: () => {
|
||||
setActiveTab(ActiveTab.Instances);
|
||||
},
|
||||
tabCounter: numberOfInstance,
|
||||
},
|
||||
{
|
||||
text: 'History',
|
||||
active: activeTab === ActiveTab.History,
|
||||
onClick: () => {
|
||||
setActiveTab(ActiveTab.History);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Details',
|
||||
active: activeTab === ActiveTab.Details,
|
||||
onClick: () => {
|
||||
setActiveTab(ActiveTab.Details);
|
||||
},
|
||||
},
|
||||
],
|
||||
parentItem: {
|
||||
text: rule.group.name,
|
||||
url: createListFilterLink([
|
||||
['namespace', rule.namespace.name],
|
||||
['group', rule.group.name],
|
||||
]),
|
||||
parentItem: {
|
||||
text: rule.namespace.name,
|
||||
url: createListFilterLink([['namespace', rule.namespace.name]]),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
pageNav,
|
||||
activeTab,
|
||||
};
|
||||
}
|
||||
|
||||
export default RuleViewer;
|
||||
|
@ -53,7 +53,9 @@ export const RuleDetails = ({ rule }: Props) => {
|
||||
<RuleDetailsDataSources rulesSource={rulesSource} rule={rule} />
|
||||
</div>
|
||||
</div>
|
||||
<RuleDetailsMatchingInstances rule={rule} itemsDisplayLimit={INSTANCES_DISPLAY_LIMIT} />
|
||||
<DetailsField label="Matching instances" horizontal={true}>
|
||||
<RuleDetailsMatchingInstances rule={rule} itemsDisplayLimit={INSTANCES_DISPLAY_LIMIT} />
|
||||
</DetailsField>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -37,7 +37,7 @@ import {
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { createUrl } from '../../utils/url';
|
||||
import { DeclareIncident } from '../bridges/DeclareIncidentButton';
|
||||
import { DeclareIncidentButton } from '../bridges/DeclareIncidentButton';
|
||||
|
||||
import { RedirectToCloneRule } from './CloneRule';
|
||||
|
||||
@ -196,7 +196,7 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop
|
||||
if (isFiringRule && shouldShowDeclareIncidentButton()) {
|
||||
buttons.push(
|
||||
<Fragment key="declare-incident">
|
||||
<DeclareIncident title={rule.name} url={buildShareUrl()} />
|
||||
<DeclareIncidentButton title={rule.name} url={buildShareUrl()} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@ -318,7 +318,8 @@ function shouldShowDeclareIncidentButton() {
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
wrapper: css`
|
||||
padding: ${theme.spacing(2)} 0;
|
||||
padding: 0 0 ${theme.spacing(2)} 0;
|
||||
gap: ${theme.spacing(1)};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
@ -18,7 +18,6 @@ import { mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../../utils/datasource';
|
||||
import { isAlertingRule } from '../../utils/rules';
|
||||
import { DetailsField } from '../DetailsField';
|
||||
|
||||
import { AlertInstancesTable } from './AlertInstancesTable';
|
||||
import { getComponentsFromStats } from './RuleStats';
|
||||
@ -83,7 +82,14 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null {
|
||||
|
||||
// Count All By State is used only when filtering is enabled and we have access to all instances
|
||||
const countAllByState = countBy(promRule.alerts, (alert) => mapStateWithReasonToBaseState(alert.state));
|
||||
const totalInstancesCount = sum(Object.values(instanceTotals));
|
||||
|
||||
// error state is not a separate state
|
||||
const totalInstancesCount = sum([
|
||||
instanceTotals.alerting,
|
||||
instanceTotals.inactive,
|
||||
instanceTotals.pending,
|
||||
instanceTotals.nodata,
|
||||
]);
|
||||
const hiddenInstancesCount = totalInstancesCount - visibleInstances.length;
|
||||
|
||||
const stats: ShowMoreStats = {
|
||||
@ -104,7 +110,7 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null {
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailsField label="Matching instances" horizontal={true}>
|
||||
<>
|
||||
{enableFiltering && (
|
||||
<div className={cx(styles.flexRow, styles.spaceBetween)}>
|
||||
<div className={styles.flexRow}>
|
||||
@ -126,7 +132,7 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null {
|
||||
)}
|
||||
{!enableFiltering && <div className={styles.stats}>{statsComponents}</div>}
|
||||
<AlertInstancesTable rule={rule} instances={visibleInstances} pagination={pagination} footerRow={footerRow} />
|
||||
</DetailsField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { isEmpty, sortBy, take, uniq } from 'lodash';
|
||||
import { fromPairs, isEmpty, sortBy, take, uniq } from 'lodash';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { DataFrame, dateTime, GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||
import { Alert, Button, Field, Icon, Input, Label, TagList, Tooltip, useStyles2, Stack } from '@grafana/ui';
|
||||
import { Alert, Button, Field, Icon, Input, Label, Tooltip, useStyles2, Stack } from '@grafana/ui';
|
||||
|
||||
import { stateHistoryApi } from '../../../api/stateHistoryApi';
|
||||
import { combineMatcherStrings } from '../../../utils/alertmanager';
|
||||
import { AlertLabels } from '../../AlertLabels';
|
||||
import { HoverCard } from '../../HoverCard';
|
||||
|
||||
import { LogRecordViewerByTimestamp } from './LogRecordViewer';
|
||||
@ -119,8 +120,8 @@ const LokiStateHistory = ({ ruleUID }: Props) => {
|
||||
<Tooltip content="Common labels are the ones attached to all of the alert instances">
|
||||
<Icon name="info-circle" />
|
||||
</Tooltip>
|
||||
<AlertLabels labels={fromPairs(commonLabels)} size="sm" />
|
||||
</Stack>
|
||||
<TagList tags={commonLabels.map((label) => label.join('='))} />
|
||||
</div>
|
||||
)}
|
||||
{isEmpty(frameSubset) ? (
|
||||
|
@ -67,7 +67,9 @@ const StateHistory = ({ alertId }: Props) => {
|
||||
return (
|
||||
<div key={groupKey}>
|
||||
<header className={styles.tableGroupKey}>
|
||||
<code>{groupKey}</code>
|
||||
<code className={styles.goupKeyText} aria-label={groupKey}>
|
||||
{groupKey}
|
||||
</code>
|
||||
</header>
|
||||
<DynamicTable cols={columns} items={tableItems} pagination={{ itemsPerPage: 25 }} />
|
||||
</div>
|
||||
@ -198,6 +200,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
margin-top: ${theme.spacing(2)};
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
`,
|
||||
goupKeyText: css({
|
||||
overflowX: 'auto',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}),
|
||||
});
|
||||
|
||||
export default StateHistory;
|
||||
|
@ -9,7 +9,7 @@ import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
const AnnotationsStateHistory = lazy(() => import('../components/rules/state-history/StateHistory'));
|
||||
const LokiStateHistory = lazy(() => import('../components/rules/state-history/LokiStateHistory'));
|
||||
|
||||
enum StateHistoryImplementation {
|
||||
export enum StateHistoryImplementation {
|
||||
Loki = 'loki',
|
||||
Annotations = 'annotations',
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user