mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Details page v2 feature flag (#69326)
This commit is contained in:
parent
6ad9e386ad
commit
441660b7b4
@ -1,338 +1,30 @@
|
|||||||
import { css } from '@emotion/css';
|
import React from 'react';
|
||||||
import produce from 'immer';
|
import { Disable, Enable } from 'react-enable';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useObservable, useToggle } from 'react-use';
|
|
||||||
|
|
||||||
import { GrafanaTheme2, LoadingState, PanelData, RelativeTimeRange } from '@grafana/data';
|
import { withErrorBoundary } from '@grafana/ui';
|
||||||
import { Stack } from '@grafana/experimental';
|
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Collapse,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
LoadingPlaceholder,
|
|
||||||
useStyles2,
|
|
||||||
VerticalGroup,
|
|
||||||
withErrorBoundary,
|
|
||||||
} from '@grafana/ui';
|
|
||||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
|
|
||||||
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../core/constants';
|
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import { AlertQuery, GrafanaRuleDefinition } from '../../../types/unified-alerting-dto';
|
import { AlertingFeature } from './features';
|
||||||
|
|
||||||
import { GrafanaRuleQueryViewer, QueryPreview } from './GrafanaRuleQueryViewer';
|
const DetailViewV1 = SafeDynamicImport(() => import('./components/rule-viewer/RuleViewer.v1'));
|
||||||
import { AlertLabels } from './components/AlertLabels';
|
const DetailViewV2 = SafeDynamicImport(() => import('./components/rule-viewer/v2/RuleViewer.v2'));
|
||||||
import { DetailsField } from './components/DetailsField';
|
|
||||||
import { ProvisionedResource, ProvisioningAlert } from './components/Provisioning';
|
|
||||||
import { RuleViewerLayout, RuleViewerLayoutContent } from './components/rule-viewer/RuleViewerLayout';
|
|
||||||
import { RuleDetailsActionButtons } from './components/rules/RuleDetailsActionButtons';
|
|
||||||
import { RuleDetailsAnnotations } from './components/rules/RuleDetailsAnnotations';
|
|
||||||
import { RuleDetailsDataSources } from './components/rules/RuleDetailsDataSources';
|
|
||||||
import { RuleDetailsExpression } from './components/rules/RuleDetailsExpression';
|
|
||||||
import { RuleDetailsFederatedSources } from './components/rules/RuleDetailsFederatedSources';
|
|
||||||
import { RuleDetailsMatchingInstances } from './components/rules/RuleDetailsMatchingInstances';
|
|
||||||
import { RuleHealth } from './components/rules/RuleHealth';
|
|
||||||
import { RuleState } from './components/rules/RuleState';
|
|
||||||
import { useAlertQueriesStatus } from './hooks/useAlertQueriesStatus';
|
|
||||||
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';
|
|
||||||
|
|
||||||
type RuleViewerProps = GrafanaRouteComponentProps<{ id?: string; sourceName?: string }>;
|
type RuleViewerProps = GrafanaRouteComponentProps<{
|
||||||
|
id: string;
|
||||||
|
sourceName: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
const errorMessage = 'Could not find data source for rule';
|
const RuleViewer = (props: RuleViewerProps): JSX.Element => (
|
||||||
const errorTitle = 'Could not view rule';
|
<AlertingPageWrapper>
|
||||||
const pageTitle = 'View rule';
|
<Enable feature={AlertingFeature.DetailsViewV2}>
|
||||||
|
<DetailViewV2 {...props} />
|
||||||
export function RuleViewer({ match }: RuleViewerProps) {
|
</Enable>
|
||||||
const styles = useStyles2(getStyles);
|
<Disable feature={AlertingFeature.DetailsViewV2}>
|
||||||
const [expandQuery, setExpandQuery] = useToggle(false);
|
<DetailViewV1 {...props} />
|
||||||
|
</Disable>
|
||||||
const { id } = match.params;
|
</AlertingPageWrapper>
|
||||||
const identifier = ruleId.tryParse(id, true);
|
);
|
||||||
|
|
||||||
const { loading, error, result: rule } = useCombinedRule(identifier, identifier?.ruleSourceName);
|
|
||||||
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,
|
|
||||||
}));
|
|
||||||
|
|
||||||
runner.run(evalCustomizedQueries);
|
|
||||||
}
|
|
||||||
}, [queries, evaluationTimeRanges, runner, allDataSourcesAvailable]);
|
|
||||||
|
|
||||||
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}>
|
|
||||||
<Alert title={errorTitle}>
|
|
||||||
<details className={styles.errorMessage}>{errorMessage}</details>
|
|
||||||
</Alert>
|
|
||||||
</RuleViewerLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rulesSource = getRulesSourceByName(identifier.ruleSourceName);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<RuleViewerLayout title={pageTitle}>
|
|
||||||
<LoadingPlaceholder text="Loading rule..." />
|
|
||||||
</RuleViewerLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !rulesSource) {
|
|
||||||
return (
|
|
||||||
<RuleViewerLayout title={pageTitle}>
|
|
||||||
<Alert title={errorTitle}>
|
|
||||||
<details className={styles.errorMessage}>
|
|
||||||
{error?.message ?? errorMessage}
|
|
||||||
<br />
|
|
||||||
{!!error?.stack && error.stack}
|
|
||||||
</details>
|
|
||||||
</Alert>
|
|
||||||
</RuleViewerLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rule) {
|
|
||||||
return (
|
|
||||||
<RuleViewerLayout title={pageTitle}>
|
|
||||||
<span>Rule could not be found.</span>
|
|
||||||
</RuleViewerLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFederatedRule = isFederatedRuleGroup(rule.group);
|
|
||||||
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RuleViewerLayout wrapInContent={false} title={pageTitle}>
|
|
||||||
{isFederatedRule && (
|
|
||||||
<Alert severity="info" title="This rule is part of a federated rule group.">
|
|
||||||
<VerticalGroup>
|
|
||||||
Federated rule groups are currently an experimental feature.
|
|
||||||
<Button fill="text" icon="book">
|
|
||||||
<a href="https://grafana.com/docs/metrics-enterprise/latest/tenant-management/tenant-federation/#cross-tenant-alerting-and-recording-rule-federation">
|
|
||||||
Read documentation
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</VerticalGroup>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{isProvisioned && <ProvisioningAlert resource={ProvisionedResource.AlertRule} />}
|
|
||||||
<RuleViewerLayoutContent>
|
|
||||||
<div>
|
|
||||||
<Stack direction="row" alignItems="center" wrap={false} 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>
|
|
||||||
<div className={styles.details}>
|
|
||||||
<div className={styles.leftSide}>
|
|
||||||
{rule.promRule && (
|
|
||||||
<DetailsField label="Health" horizontal={true}>
|
|
||||||
<RuleHealth rule={rule.promRule} />
|
|
||||||
</DetailsField>
|
|
||||||
)}
|
|
||||||
{!!rule.labels && !!Object.keys(rule.labels).length && (
|
|
||||||
<DetailsField label="Labels" horizontal={true}>
|
|
||||||
<AlertLabels labels={rule.labels} className={styles.labels} />
|
|
||||||
</DetailsField>
|
|
||||||
)}
|
|
||||||
<RuleDetailsExpression rulesSource={rulesSource} rule={rule} annotations={annotations} />
|
|
||||||
<RuleDetailsAnnotations annotations={annotations} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.rightSide}>
|
|
||||||
<RuleDetailsDataSources rule={rule} rulesSource={rulesSource} />
|
|
||||||
{isFederatedRule && <RuleDetailsFederatedSources group={rule.group} />}
|
|
||||||
<DetailsField label="Namespace / Group" className={styles.rightSideDetails}>
|
|
||||||
{rule.namespace.name} / {rule.group.name}
|
|
||||||
</DetailsField>
|
|
||||||
{isGrafanaRulerRule(rule.rulerRule) && <GrafanaRuleUID rule={rule.rulerRule.grafana_alert} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<RuleDetailsMatchingInstances
|
|
||||||
rule={rule}
|
|
||||||
pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }}
|
|
||||||
enableFiltering
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</Collapse>
|
|
||||||
</RuleViewerLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GrafanaRuleUID({ rule }: { rule: GrafanaRuleDefinition }) {
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
const copyUID = () => navigator.clipboard && navigator.clipboard.writeText(rule.uid);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailsField label="Rule UID" childrenWrapperClassName={styles.ruleUid}>
|
|
||||||
{rule.uid} <IconButton name="copy" onClick={copyUID} tooltip="Copy rule" />
|
|
||||||
</DetailsField>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoading(data: Record<string, PanelData>): boolean {
|
|
||||||
return !!Object.values(data).find((d) => d.state === LoadingState.Loading);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
|
||||||
return {
|
|
||||||
errorMessage: css`
|
|
||||||
white-space: pre-wrap;
|
|
||||||
`,
|
|
||||||
queries: css`
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
`,
|
|
||||||
collapse: css`
|
|
||||||
margin-top: ${theme.spacing(2)};
|
|
||||||
border-color: ${theme.colors.border.weak};
|
|
||||||
border-radius: ${theme.shape.borderRadius()};
|
|
||||||
`,
|
|
||||||
queriesTitle: css`
|
|
||||||
padding: ${theme.spacing(2, 0.5)};
|
|
||||||
font-size: ${theme.typography.h5.fontSize};
|
|
||||||
font-weight: ${theme.typography.fontWeightBold};
|
|
||||||
font-family: ${theme.typography.h5.fontFamily};
|
|
||||||
`,
|
|
||||||
query: css`
|
|
||||||
border-bottom: 1px solid ${theme.colors.border.medium};
|
|
||||||
padding: ${theme.spacing(2)};
|
|
||||||
`,
|
|
||||||
queryWarning: css`
|
|
||||||
margin: ${theme.spacing(4, 0)};
|
|
||||||
`,
|
|
||||||
title: css`
|
|
||||||
font-size: ${theme.typography.h4.fontSize};
|
|
||||||
font-weight: ${theme.typography.fontWeightBold};
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
`,
|
|
||||||
details: css`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: ${theme.spacing(4)};
|
|
||||||
`,
|
|
||||||
leftSide: css`
|
|
||||||
flex: 1;
|
|
||||||
`,
|
|
||||||
rightSide: css`
|
|
||||||
padding-right: ${theme.spacing(3)};
|
|
||||||
|
|
||||||
max-width: 360px;
|
|
||||||
word-break: break-all;
|
|
||||||
overflow: hidden;
|
|
||||||
`,
|
|
||||||
rightSideDetails: css`
|
|
||||||
& > div:first-child {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
labels: css`
|
|
||||||
justify-content: flex-start;
|
|
||||||
`,
|
|
||||||
ruleUid: css`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: ${theme.spacing(1)};
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withErrorBoundary(RuleViewer, { style: 'page' });
|
export default withErrorBoundary(RuleViewer, { style: 'page' });
|
||||||
|
@ -0,0 +1,57 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const AlertStateDot = (props: DotStylesProps) => {
|
||||||
|
const styles = useStyles2((theme) => getDotStyles(theme, props));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content={String(props.state)} placement="top">
|
||||||
|
<div className={styles.dot} />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DotStylesProps {
|
||||||
|
state?: GrafanaAlertState;
|
||||||
|
size?: ComponentSize; // TODO support this
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDotStyles = (theme: GrafanaTheme2, props: DotStylesProps) => {
|
||||||
|
const size = theme.spacing(1.25);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dot: css`
|
||||||
|
width: ${size};
|
||||||
|
height: ${size};
|
||||||
|
|
||||||
|
border-radius: 100%;
|
||||||
|
|
||||||
|
background-color: ${theme.colors.secondary.main};
|
||||||
|
outline: solid calc(${size} / 2.5) ${theme.colors.secondary.transparent};
|
||||||
|
|
||||||
|
${props.state === GrafanaAlertState.Normal &&
|
||||||
|
css`
|
||||||
|
background-color: ${theme.colors.success.main};
|
||||||
|
outline-color: ${theme.colors.success.transparent};
|
||||||
|
`}
|
||||||
|
|
||||||
|
${props.state === GrafanaAlertState.Pending &&
|
||||||
|
css`
|
||||||
|
background-color: ${theme.colors.warning.main};
|
||||||
|
outline-color: ${theme.colors.warning.transparent};
|
||||||
|
`}
|
||||||
|
|
||||||
|
${props.state === GrafanaAlertState.Alerting &&
|
||||||
|
css`
|
||||||
|
background-color: ${theme.colors.error.main};
|
||||||
|
outline-color: ${theme.colors.error.transparent};
|
||||||
|
`}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { AlertStateDot };
|
@ -19,7 +19,7 @@ const combokeys = new Mousetrap(document.body);
|
|||||||
* This is the main alerting page wrapper, used by the alertmanager page wrapper and the alert rules list view
|
* This is the main alerting page wrapper, used by the alertmanager page wrapper and the alert rules list view
|
||||||
*/
|
*/
|
||||||
interface AlertingPageWrapperProps extends PropsWithChildren {
|
interface AlertingPageWrapperProps extends PropsWithChildren {
|
||||||
pageId: string;
|
pageId?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
pageNav?: NavModelItem;
|
pageNav?: NavModelItem;
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
|
@ -10,10 +10,11 @@ import { contextSrv } from 'app/core/services/context_srv';
|
|||||||
import { AccessControlAction } from 'app/types';
|
import { AccessControlAction } from 'app/types';
|
||||||
import { CombinedRule } from 'app/types/unified-alerting';
|
import { CombinedRule } from 'app/types/unified-alerting';
|
||||||
|
|
||||||
import { RuleViewer } from './RuleViewer';
|
import { useCombinedRule } from '../../hooks/useCombinedRule';
|
||||||
import { useCombinedRule } from './hooks/useCombinedRule';
|
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
|
||||||
import { useIsRuleEditable } from './hooks/useIsRuleEditable';
|
import { getCloudRule, getGrafanaRule, grantUserPermissions } from '../../mocks';
|
||||||
import { getCloudRule, getGrafanaRule, grantUserPermissions } from './mocks';
|
|
||||||
|
import { RuleViewer } from './RuleViewer.v1';
|
||||||
|
|
||||||
const mockGrafanaRule = getGrafanaRule({ name: 'Test alert' });
|
const mockGrafanaRule = getGrafanaRule({ name: 'Test alert' });
|
||||||
const mockCloudRule = getCloudRule({ name: 'cloud test alert' });
|
const mockCloudRule = getCloudRule({ name: 'cloud test alert' });
|
||||||
@ -29,7 +30,7 @@ const mockRoute: GrafanaRouteComponentProps<{ id?: string; sourceName?: string }
|
|||||||
staticContext: {},
|
staticContext: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('./hooks/useCombinedRule');
|
jest.mock('../../hooks/useCombinedRule');
|
||||||
jest.mock('@grafana/runtime', () => ({
|
jest.mock('@grafana/runtime', () => ({
|
||||||
...jest.requireActual('@grafana/runtime'),
|
...jest.requireActual('@grafana/runtime'),
|
||||||
getDataSourceSrv: () => {
|
getDataSourceSrv: () => {
|
||||||
@ -61,7 +62,7 @@ const ui = {
|
|||||||
silence: byRole('link', { name: 'Silence' }),
|
silence: byRole('link', { name: 'Silence' }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
jest.mock('./hooks/useIsRuleEditable');
|
jest.mock('../../hooks/useIsRuleEditable');
|
||||||
|
|
||||||
const mocks = {
|
const mocks = {
|
||||||
useIsRuleEditable: jest.mocked(useIsRuleEditable),
|
useIsRuleEditable: jest.mocked(useIsRuleEditable),
|
||||||
@ -93,7 +94,6 @@ describe('RuleViewer', () => {
|
|||||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
|
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
|
||||||
await renderRuleViewer();
|
await renderRuleViewer();
|
||||||
|
|
||||||
expect(screen.getByText(/view rule/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/test alert/i)).toBeInTheDocument();
|
expect(screen.getByText(/test alert/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -107,7 +107,7 @@ describe('RuleViewer', () => {
|
|||||||
});
|
});
|
||||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
|
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
|
||||||
await renderRuleViewer();
|
await renderRuleViewer();
|
||||||
expect(screen.getByText(/view rule/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/cloud test alert/i)).toBeInTheDocument();
|
expect(screen.getByText(/cloud test alert/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -0,0 +1,324 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import produce from 'immer';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useObservable, useToggle } from 'react-use';
|
||||||
|
|
||||||
|
import { GrafanaTheme2, LoadingState, PanelData, RelativeTimeRange } from '@grafana/data';
|
||||||
|
import { Stack } from '@grafana/experimental';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { Alert, Button, Collapse, Icon, IconButton, LoadingPlaceholder, useStyles2, VerticalGroup } 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 { 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 { RuleDetailsActionButtons } from '../rules/RuleDetailsActionButtons';
|
||||||
|
import { RuleDetailsAnnotations } from '../rules/RuleDetailsAnnotations';
|
||||||
|
import { RuleDetailsDataSources } from '../rules/RuleDetailsDataSources';
|
||||||
|
import { RuleDetailsExpression } from '../rules/RuleDetailsExpression';
|
||||||
|
import { RuleDetailsFederatedSources } from '../rules/RuleDetailsFederatedSources';
|
||||||
|
import { RuleDetailsMatchingInstances } from '../rules/RuleDetailsMatchingInstances';
|
||||||
|
import { RuleHealth } from '../rules/RuleHealth';
|
||||||
|
import { RuleState } from '../rules/RuleState';
|
||||||
|
|
||||||
|
type RuleViewerProps = GrafanaRouteComponentProps<{ id?: string; sourceName?: string }>;
|
||||||
|
|
||||||
|
const errorMessage = 'Could not find data source for rule';
|
||||||
|
const errorTitle = 'Could not view rule';
|
||||||
|
const pageTitle = 'View rule';
|
||||||
|
|
||||||
|
export function RuleViewer({ match }: RuleViewerProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const [expandQuery, setExpandQuery] = useToggle(false);
|
||||||
|
|
||||||
|
const { id } = match.params;
|
||||||
|
const identifier = ruleId.tryParse(id, true);
|
||||||
|
|
||||||
|
const { loading, error, result: rule } = useCombinedRule(identifier, identifier?.ruleSourceName);
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
runner.run(evalCustomizedQueries);
|
||||||
|
}
|
||||||
|
}, [queries, evaluationTimeRanges, runner, allDataSourcesAvailable]);
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<Alert title={errorTitle}>
|
||||||
|
<details className={styles.errorMessage}>{errorMessage}</details>
|
||||||
|
</Alert>
|
||||||
|
</RuleViewerLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rulesSource = getRulesSourceByName(identifier.ruleSourceName);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<RuleViewerLayout title={pageTitle}>
|
||||||
|
<LoadingPlaceholder text="Loading rule..." />
|
||||||
|
</RuleViewerLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !rulesSource) {
|
||||||
|
return (
|
||||||
|
<Alert title={errorTitle}>
|
||||||
|
<details className={styles.errorMessage}>
|
||||||
|
{error?.message ?? errorMessage}
|
||||||
|
<br />
|
||||||
|
{!!error?.stack && error.stack}
|
||||||
|
</details>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return (
|
||||||
|
<RuleViewerLayout title={pageTitle}>
|
||||||
|
<span>Rule could not be found.</span>
|
||||||
|
</RuleViewerLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFederatedRule = isFederatedRuleGroup(rule.group);
|
||||||
|
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isFederatedRule && (
|
||||||
|
<Alert severity="info" title="This rule is part of a federated rule group.">
|
||||||
|
<VerticalGroup>
|
||||||
|
Federated rule groups are currently an experimental feature.
|
||||||
|
<Button fill="text" icon="book">
|
||||||
|
<a href="https://grafana.com/docs/metrics-enterprise/latest/tenant-management/tenant-federation/#cross-tenant-alerting-and-recording-rule-federation">
|
||||||
|
Read documentation
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</VerticalGroup>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{isProvisioned && <ProvisioningAlert resource={ProvisionedResource.AlertRule} />}
|
||||||
|
<RuleViewerLayoutContent>
|
||||||
|
<div>
|
||||||
|
<Stack direction="row" alignItems="center" wrap={false} 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>
|
||||||
|
<div className={styles.details}>
|
||||||
|
<div className={styles.leftSide}>
|
||||||
|
{rule.promRule && (
|
||||||
|
<DetailsField label="Health" horizontal={true}>
|
||||||
|
<RuleHealth rule={rule.promRule} />
|
||||||
|
</DetailsField>
|
||||||
|
)}
|
||||||
|
{!!rule.labels && !!Object.keys(rule.labels).length && (
|
||||||
|
<DetailsField label="Labels" horizontal={true}>
|
||||||
|
<AlertLabels labels={rule.labels} className={styles.labels} />
|
||||||
|
</DetailsField>
|
||||||
|
)}
|
||||||
|
<RuleDetailsExpression rulesSource={rulesSource} rule={rule} annotations={annotations} />
|
||||||
|
<RuleDetailsAnnotations annotations={annotations} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.rightSide}>
|
||||||
|
<RuleDetailsDataSources rule={rule} rulesSource={rulesSource} />
|
||||||
|
{isFederatedRule && <RuleDetailsFederatedSources group={rule.group} />}
|
||||||
|
<DetailsField label="Namespace / Group" className={styles.rightSideDetails}>
|
||||||
|
{rule.namespace.name} / {rule.group.name}
|
||||||
|
</DetailsField>
|
||||||
|
{isGrafanaRulerRule(rule.rulerRule) && <GrafanaRuleUID rule={rule.rulerRule.grafana_alert} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<RuleDetailsMatchingInstances
|
||||||
|
rule={rule}
|
||||||
|
pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }}
|
||||||
|
enableFiltering
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</Collapse>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GrafanaRuleUID({ rule }: { rule: GrafanaRuleDefinition }) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const copyUID = () => navigator.clipboard && navigator.clipboard.writeText(rule.uid);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailsField label="Rule UID" childrenWrapperClassName={styles.ruleUid}>
|
||||||
|
{rule.uid} <IconButton name="copy" onClick={copyUID} tooltip="Copy rule" />
|
||||||
|
</DetailsField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoading(data: Record<string, PanelData>): boolean {
|
||||||
|
return !!Object.values(data).find((d) => d.state === LoadingState.Loading);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
errorMessage: css`
|
||||||
|
white-space: pre-wrap;
|
||||||
|
`,
|
||||||
|
queries: css`
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
`,
|
||||||
|
collapse: css`
|
||||||
|
margin-top: ${theme.spacing(2)};
|
||||||
|
border-color: ${theme.colors.border.weak};
|
||||||
|
border-radius: ${theme.shape.borderRadius()};
|
||||||
|
`,
|
||||||
|
queriesTitle: css`
|
||||||
|
padding: ${theme.spacing(2, 0.5)};
|
||||||
|
font-size: ${theme.typography.h5.fontSize};
|
||||||
|
font-weight: ${theme.typography.fontWeightBold};
|
||||||
|
font-family: ${theme.typography.h5.fontFamily};
|
||||||
|
`,
|
||||||
|
query: css`
|
||||||
|
border-bottom: 1px solid ${theme.colors.border.medium};
|
||||||
|
padding: ${theme.spacing(2)};
|
||||||
|
`,
|
||||||
|
queryWarning: css`
|
||||||
|
margin: ${theme.spacing(4, 0)};
|
||||||
|
`,
|
||||||
|
title: css`
|
||||||
|
font-size: ${theme.typography.h4.fontSize};
|
||||||
|
font-weight: ${theme.typography.fontWeightBold};
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`,
|
||||||
|
details: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: ${theme.spacing(4)};
|
||||||
|
`,
|
||||||
|
leftSide: css`
|
||||||
|
flex: 1;
|
||||||
|
`,
|
||||||
|
rightSide: css`
|
||||||
|
padding-right: ${theme.spacing(3)};
|
||||||
|
|
||||||
|
max-width: 360px;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow: hidden;
|
||||||
|
`,
|
||||||
|
rightSideDetails: css`
|
||||||
|
& > div:first-child {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
labels: css`
|
||||||
|
justify-content: flex-start;
|
||||||
|
`,
|
||||||
|
ruleUid: css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: ${theme.spacing(1)};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleViewer;
|
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const History = () => <>History</>;
|
||||||
|
|
||||||
|
export { History };
|
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const InstancesList = () => <>Instances</>;
|
||||||
|
|
||||||
|
export { InstancesList };
|
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const QueryResults = () => <>Query results</>;
|
||||||
|
|
||||||
|
export { QueryResults };
|
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Routing = () => <>Routing</>;
|
||||||
|
|
||||||
|
export { Routing };
|
@ -0,0 +1,175 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { Stack } from '@grafana/experimental';
|
||||||
|
import { Alert, Button, Icon, LoadingPlaceholder, Tab, TabContent, TabsBar } from '@grafana/ui';
|
||||||
|
import { H1, Span } from '@grafana/ui/src/unstable';
|
||||||
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
|
import { GrafanaAlertState } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
|
import { useRuleViewerPageTitle } from '../../../hooks/alert-details/useRuleViewerPageTitle';
|
||||||
|
import { useCombinedRule } from '../../../hooks/useCombinedRule';
|
||||||
|
import * as ruleId from '../../../utils/rule-id';
|
||||||
|
import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules';
|
||||||
|
import { AlertStateDot } from '../../AlertStateDot';
|
||||||
|
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
|
||||||
|
import { Spacer } from '../../Spacer';
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
enum Tabs {
|
||||||
|
Instances,
|
||||||
|
Query,
|
||||||
|
Routing,
|
||||||
|
History,
|
||||||
|
}
|
||||||
|
|
||||||
|
// @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 { id } = match.params;
|
||||||
|
const identifier = ruleId.tryParse(id, true);
|
||||||
|
const [activeTab, setActiveTab] = useState<Tabs>(Tabs.Instances);
|
||||||
|
|
||||||
|
const { loading, error, result: rule } = useCombinedRule(identifier, identifier?.ruleSourceName);
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
{/* alerts and notifications and stuff */}
|
||||||
|
{isFederatedRule && (
|
||||||
|
<Alert severity="info" title="This rule is part of a federated rule group.">
|
||||||
|
<Stack direction="column">
|
||||||
|
Federated rule groups are currently an experimental feature.
|
||||||
|
<Button fill="text" icon="book">
|
||||||
|
<a href="https://grafana.com/docs/metrics-enterprise/latest/tenant-management/tenant-federation/#cross-tenant-alerting-and-recording-rule-federation">
|
||||||
|
Read documentation
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{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 />}
|
||||||
|
</TabContent>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BreadcrumbProps {
|
||||||
|
folder: string;
|
||||||
|
evaluationGroup: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BreadCrumb = ({ folder, evaluationGroup }: BreadcrumbProps) => (
|
||||||
|
<Stack alignItems="center" gap={0.5}>
|
||||||
|
<Span color="secondary">
|
||||||
|
<Icon name="folder" />
|
||||||
|
</Span>
|
||||||
|
<Span variant="body" color="primary">
|
||||||
|
{folder}
|
||||||
|
</Span>
|
||||||
|
<Span variant="body" color="secondary">
|
||||||
|
<Icon name="angle-right" />
|
||||||
|
</Span>
|
||||||
|
<Span variant="body" color="primary">
|
||||||
|
{evaluationGroup}
|
||||||
|
</Span>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TitleProps {
|
||||||
|
name: string;
|
||||||
|
state: GrafanaAlertState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Title = ({ name, state }: TitleProps) => (
|
||||||
|
<header>
|
||||||
|
<Stack alignItems={'center'} gap={1}>
|
||||||
|
<AlertStateDot size="md" state={state} />
|
||||||
|
{/* <Button variant="secondary" fill="outline" icon="angle-left" /> */}
|
||||||
|
<H1 variant="h2" weight="bold">
|
||||||
|
{name}
|
||||||
|
</H1>
|
||||||
|
{/* <Badge color="red" text={state} icon="exclamation-circle" /> */}
|
||||||
|
</Stack>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SummaryProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Summary = ({ text }: SummaryProps) => (
|
||||||
|
<Span variant="body" color="secondary">
|
||||||
|
{text}
|
||||||
|
</Span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RuleViewer;
|
@ -4,6 +4,7 @@ import { config } from '@grafana/runtime';
|
|||||||
|
|
||||||
export enum AlertingFeature {
|
export enum AlertingFeature {
|
||||||
NotificationPoliciesV2MatchingInstances = 'notification-policies.v2.matching-instances',
|
NotificationPoliciesV2MatchingInstances = 'notification-policies.v2.matching-instances',
|
||||||
|
DetailsViewV2 = 'details-view.v2',
|
||||||
ContactPointsV2 = 'contact-points.v2',
|
ContactPointsV2 = 'contact-points.v2',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,5 +17,9 @@ const FEATURES: FeatureDescription[] = [
|
|||||||
name: AlertingFeature.ContactPointsV2,
|
name: AlertingFeature.ContactPointsV2,
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: AlertingFeature.DetailsViewV2,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
export default FEATURES;
|
export default FEATURES;
|
||||||
|
@ -0,0 +1,29 @@
|
|||||||
|
import { useLayoutEffect } from 'react';
|
||||||
|
|
||||||
|
import { Branding } from 'app/core/components/Branding/Branding';
|
||||||
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
|
import { CombinedRule } from 'app/types/unified-alerting';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We're definitely doing something odd here, and it all boils down to
|
||||||
|
* 1. we have a page layout that is different from what <Page /> forces us to do with pageNav
|
||||||
|
* 2. because of 1. we don't get to update the pageNav that way and circumvents
|
||||||
|
* the `usePageTitle` hook in the <Page /> component
|
||||||
|
*
|
||||||
|
* Therefore we are manually setting the breadcrumb and the page title.
|
||||||
|
*/
|
||||||
|
export function useRuleViewerPageTitle(rule?: CombinedRule) {
|
||||||
|
const { chrome } = useGrafana();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (rule?.name) {
|
||||||
|
chrome.update({ pageNav: { text: rule.name } });
|
||||||
|
}
|
||||||
|
}, [chrome, rule]);
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.title = `${rule.name} - Alerting - ${Branding.AppTitle}`;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user