Alerting: Detail view v2 (#77795)

This commit is contained in:
Gilles De Mey 2024-01-02 10:05:58 +01:00 committed by GitHub
parent f6f259f0b5
commit e5227550c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 934 additions and 324 deletions

View File

@ -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"],

View File

@ -26,6 +26,7 @@ export function PageTabs({ navItem }: Props) {
counter={child.tabCounter}
href={child.url}
suffix={child.tabSuffix}
onChangeTab={child.onClick}
/>
)
);

View File

@ -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>
);

View File

@ -91,7 +91,7 @@ const AlertGroups = () => {
};
const AlertGroupsPage = () => (
<AlertmanagerPageWrapper pageId="groups" accessType="instance">
<AlertmanagerPageWrapper navId="groups" accessType="instance">
<AlertGroups />
</AlertmanagerPageWrapper>
);

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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} />

View File

@ -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>
);

View File

@ -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 && (

View File

@ -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' });

View File

@ -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>
);

View File

@ -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};

View File

@ -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

View File

@ -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};
`,
});

View File

@ -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>

View File

@ -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" />}
</>
);
};

View File

@ -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:

View File

@ -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`

View File

@ -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>

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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];
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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>
</>
);
}

View File

@ -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) ? (

View File

@ -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;

View File

@ -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',
}