diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx index aae84a79973..b27e204afbf 100644 --- a/public/app/features/alerting/unified/RuleViewer.tsx +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -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 { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertingFeature } from './features'; -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'; +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 }>; +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>({}); - - const { allDataSourcesAvailable } = useAlertQueriesStatus(queries); - - const onRunQueries = useCallback(() => { - if (queries.length > 0 && allDataSourcesAvailable) { - const evalCustomizedQueries = queries.map((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 ( - - -
{errorMessage}
-
-
- ); - } - - const rulesSource = getRulesSourceByName(identifier.ruleSourceName); - - if (loading) { - return ( - - - - ); - } - - if (error || !rulesSource) { - return ( - - -
- {error?.message ?? errorMessage} -
- {!!error?.stack && error.stack} -
-
-
- ); - } - - if (!rule) { - return ( - - Rule could not be found. - - ); - } - - const isFederatedRule = isFederatedRuleGroup(rule.group); - const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); - - return ( - - {isFederatedRule && ( - - - Federated rule groups are currently an experimental feature. - - - - )} - {isProvisioned && } - -
- - {rule.name} - - - -
-
-
- {rule.promRule && ( - - - - )} - {!!rule.labels && !!Object.keys(rule.labels).length && ( - - - - )} - - -
-
- - {isFederatedRule && } - - {rule.namespace.name} / {rule.group.name} - - {isGrafanaRulerRule(rule.rulerRule) && } -
-
-
- -
-
- - {isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && ( - - )} - - {!isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && data && Object.keys(data).length > 0 && ( -
- {queries.map((query) => { - return ( - ds.uid === query.datasourceUid)} - queryData={data[query.refId]} - relativeTimeRange={query.relativeTimeRange} - evalTimeRange={evaluationTimeRanges[query.refId]} - onEvalTimeRangeChange={(timeRange) => onQueryTimeRangeChange(query.refId, timeRange)} - isAlertCondition={false} - /> - ); - })} -
- )} - {!isFederatedRule && !allDataSourcesAvailable && ( - - Cannot display the query preview. Some of the data sources used in the queries are not available. - - )} -
-
- ); -} - -function GrafanaRuleUID({ rule }: { rule: GrafanaRuleDefinition }) { - const styles = useStyles2(getStyles); - const copyUID = () => navigator.clipboard && navigator.clipboard.writeText(rule.uid); - - return ( - - {rule.uid} - - ); -} - -function isLoading(data: Record): 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)}; - `, - }; -}; +const RuleViewer = (props: RuleViewerProps): JSX.Element => ( + + + + + + + + +); export default withErrorBoundary(RuleViewer, { style: 'page' }); diff --git a/public/app/features/alerting/unified/components/AlertStateDot.tsx b/public/app/features/alerting/unified/components/AlertStateDot.tsx new file mode 100644 index 00000000000..d52787db4c3 --- /dev/null +++ b/public/app/features/alerting/unified/components/AlertStateDot.tsx @@ -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 ( + +
+ + ); +}; + +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 }; diff --git a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx index 7c80f6717ba..99e245c6f6b 100644 --- a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx +++ b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx @@ -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 */ interface AlertingPageWrapperProps extends PropsWithChildren { - pageId: string; + pageId?: string; isLoading?: boolean; pageNav?: NavModelItem; actions?: React.ReactNode; diff --git a/public/app/features/alerting/unified/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx similarity index 95% rename from public/app/features/alerting/unified/RuleViewer.test.tsx rename to public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx index 11bd03bb774..64e2c978259 100644 --- a/public/app/features/alerting/unified/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx @@ -10,10 +10,11 @@ import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types'; import { CombinedRule } from 'app/types/unified-alerting'; -import { RuleViewer } from './RuleViewer'; -import { useCombinedRule } from './hooks/useCombinedRule'; -import { useIsRuleEditable } from './hooks/useIsRuleEditable'; -import { getCloudRule, getGrafanaRule, grantUserPermissions } from './mocks'; +import { useCombinedRule } from '../../hooks/useCombinedRule'; +import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; +import { getCloudRule, getGrafanaRule, grantUserPermissions } from '../../mocks'; + +import { RuleViewer } from './RuleViewer.v1'; const mockGrafanaRule = getGrafanaRule({ name: 'Test alert' }); const mockCloudRule = getCloudRule({ name: 'cloud test alert' }); @@ -29,7 +30,7 @@ const mockRoute: GrafanaRouteComponentProps<{ id?: string; sourceName?: string } staticContext: {}, }; -jest.mock('./hooks/useCombinedRule'); +jest.mock('../../hooks/useCombinedRule'); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getDataSourceSrv: () => { @@ -61,7 +62,7 @@ const ui = { silence: byRole('link', { name: 'Silence' }), }, }; -jest.mock('./hooks/useIsRuleEditable'); +jest.mock('../../hooks/useIsRuleEditable'); const mocks = { useIsRuleEditable: jest.mocked(useIsRuleEditable), @@ -93,7 +94,6 @@ describe('RuleViewer', () => { mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false }); await renderRuleViewer(); - expect(screen.getByText(/view rule/i)).toBeInTheDocument(); expect(screen.getByText(/test alert/i)).toBeInTheDocument(); }); @@ -107,7 +107,7 @@ describe('RuleViewer', () => { }); mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false }); await renderRuleViewer(); - expect(screen.getByText(/view rule/i)).toBeInTheDocument(); + expect(screen.getByText(/cloud test alert/i)).toBeInTheDocument(); }); }); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx new file mode 100644 index 00000000000..d190e5101d9 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx @@ -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>({}); + + const { allDataSourcesAvailable } = useAlertQueriesStatus(queries); + + const onRunQueries = useCallback(() => { + if (queries.length > 0 && allDataSourcesAvailable) { + const evalCustomizedQueries = queries.map((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 ( + + +
{errorMessage}
+
+
+ ); + } + + const rulesSource = getRulesSourceByName(identifier.ruleSourceName); + + if (loading) { + return ( + + + + ); + } + + if (error || !rulesSource) { + return ( + +
+ {error?.message ?? errorMessage} +
+ {!!error?.stack && error.stack} +
+
+ ); + } + + if (!rule) { + return ( + + Rule could not be found. + + ); + } + + const isFederatedRule = isFederatedRuleGroup(rule.group); + const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); + + return ( + <> + {isFederatedRule && ( + + + Federated rule groups are currently an experimental feature. + + + + )} + {isProvisioned && } + +
+ + {rule.name} + + + +
+
+
+ {rule.promRule && ( + + + + )} + {!!rule.labels && !!Object.keys(rule.labels).length && ( + + + + )} + + +
+
+ + {isFederatedRule && } + + {rule.namespace.name} / {rule.group.name} + + {isGrafanaRulerRule(rule.rulerRule) && } +
+
+
+ +
+
+ + {isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && ( + + )} + + {!isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && data && Object.keys(data).length > 0 && ( +
+ {queries.map((query) => { + return ( + ds.uid === query.datasourceUid)} + queryData={data[query.refId]} + relativeTimeRange={query.relativeTimeRange} + evalTimeRange={evaluationTimeRanges[query.refId]} + onEvalTimeRangeChange={(timeRange) => onQueryTimeRangeChange(query.refId, timeRange)} + isAlertCondition={false} + /> + ); + })} +
+ )} + {!isFederatedRule && !allDataSourcesAvailable && ( + + Cannot display the query preview. Some of the data sources used in the queries are not available. + + )} +
+ + ); +} + +function GrafanaRuleUID({ rule }: { rule: GrafanaRuleDefinition }) { + const styles = useStyles2(getStyles); + const copyUID = () => navigator.clipboard && navigator.clipboard.writeText(rule.uid); + + return ( + + {rule.uid} + + ); +} + +function isLoading(data: Record): 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; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/History.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/History.tsx new file mode 100644 index 00000000000..7d805f13cb1 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/History.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +const History = () => <>History; + +export { History }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Instances.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Instances.tsx new file mode 100644 index 00000000000..3504f6e28ca --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Instances.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +const InstancesList = () => <>Instances; + +export { InstancesList }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx new file mode 100644 index 00000000000..8ec527a5bec --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +const QueryResults = () => <>Query results; + +export { QueryResults }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Routing.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Routing.tsx new file mode 100644 index 00000000000..799d95d43b7 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Routing.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +const Routing = () => <>Routing; + +export { Routing }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx new file mode 100644 index 00000000000..33436b25688 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx @@ -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 +// 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.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 ; + } + + 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 ( + <> + + {/* breadcrumb and actions */} + + + + + + + + + {/* header */} + + + + </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; diff --git a/public/app/features/alerting/unified/features.ts b/public/app/features/alerting/unified/features.ts index faa7b8d4d3d..76447761dea 100644 --- a/public/app/features/alerting/unified/features.ts +++ b/public/app/features/alerting/unified/features.ts @@ -4,6 +4,7 @@ import { config } from '@grafana/runtime'; export enum AlertingFeature { NotificationPoliciesV2MatchingInstances = 'notification-policies.v2.matching-instances', + DetailsViewV2 = 'details-view.v2', ContactPointsV2 = 'contact-points.v2', } @@ -16,5 +17,9 @@ const FEATURES: FeatureDescription[] = [ name: AlertingFeature.ContactPointsV2, defaultValue: false, }, + { + name: AlertingFeature.DetailsViewV2, + defaultValue: false, + }, ]; export default FEATURES; diff --git a/public/app/features/alerting/unified/hooks/alert-details/useRuleViewerPageTitle.tsx b/public/app/features/alerting/unified/hooks/alert-details/useRuleViewerPageTitle.tsx new file mode 100644 index 00000000000..96495963872 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/alert-details/useRuleViewerPageTitle.tsx @@ -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}`; +}