mirror of https://github.com/grafana/grafana
Alerting: Details page v2 feature flag (#69326)
parent
6ad9e386ad
commit
441660b7b4
@ -1,338 +1,30 @@ |
||||
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 from 'react'; |
||||
import { Disable, Enable } from 'react-enable'; |
||||
|
||||
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, |
||||
withErrorBoundary, |
||||
} from '@grafana/ui'; |
||||
import { withErrorBoundary } from '@grafana/ui'; |
||||
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; |
||||
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 { AlertLabels } from './components/AlertLabels'; |
||||
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 }>; |
||||
|
||||
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 ( |
||||
<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)}; |
||||
`,
|
||||
}; |
||||
}; |
||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper'; |
||||
import { AlertingFeature } from './features'; |
||||
|
||||
const DetailViewV1 = SafeDynamicImport(() => import('./components/rule-viewer/RuleViewer.v1')); |
||||
const DetailViewV2 = SafeDynamicImport(() => import('./components/rule-viewer/v2/RuleViewer.v2')); |
||||
|
||||
type RuleViewerProps = GrafanaRouteComponentProps<{ |
||||
id: string; |
||||
sourceName: string; |
||||
}>; |
||||
|
||||
const RuleViewer = (props: RuleViewerProps): JSX.Element => ( |
||||
<AlertingPageWrapper> |
||||
<Enable feature={AlertingFeature.DetailsViewV2}> |
||||
<DetailViewV2 {...props} /> |
||||
</Enable> |
||||
<Disable feature={AlertingFeature.DetailsViewV2}> |
||||
<DetailViewV1 {...props} /> |
||||
</Disable> |
||||
</AlertingPageWrapper> |
||||
); |
||||
|
||||
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 }; |
@ -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; |
@ -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