mirror of https://github.com/grafana/grafana
Alerting: New list view (Part 1) (#95039)
* initial commit * update styles * wip * update list view * update translations * abstract components * metadata separator * refactor * cleanup * fix tests * WIP * translations * refactor to use maps and type-safety * WIP * UI updates * Rule action buttons early draft * recording rules * WIP typescript errors * implement action button loading * move section loader etc * add placeholder for group actions * Change files structure, remove CombinedRule from AlertRuleMenu * Refactor fetching data sources with ruler * Fix tests * Unify data source features * move files * make actions column wider * update translations * Update tests to reflect code changes * Remove direct buildinfo usages * Fix useCanSilence hook * Add missing translations, fix lint errors * PR feedback * update test * Remove featureDiscovery mock from a test --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>pull/96626/head
parent
65097d4b54
commit
b73ab15878
@ -1,19 +1,28 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data'; |
||||
import { PromBasedDataSource } from 'app/types/unified-alerting'; |
||||
|
||||
import { getDataSourceByName } from '../utils/datasource'; |
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; |
||||
import { getRulesDataSources } from '../utils/datasource'; |
||||
|
||||
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; |
||||
const { useLazyDiscoverDsFeaturesQuery } = featureDiscoveryApi; |
||||
|
||||
export function useRulesSourcesWithRuler(): DataSourceInstanceSettings[] { |
||||
const dataSources = useUnifiedAlertingSelector((state) => state.dataSources); |
||||
export function useRulesSourcesWithRuler(): { |
||||
rulesSourcesWithRuler: DataSourceInstanceSettings[]; |
||||
isLoading: boolean; |
||||
} { |
||||
const [rulesSourcesWithRuler, setRulesSourcesWithRuler] = useState<DataSourceInstanceSettings[]>([]); |
||||
const [discoverDsFeatures, { isLoading }] = useLazyDiscoverDsFeaturesQuery(); |
||||
|
||||
const dataSourcesWithRuler = Object.values(dataSources) |
||||
.map((ds) => ds.result) |
||||
.filter((ds): ds is PromBasedDataSource => Boolean(ds?.rulerConfig)); |
||||
// try fetching rules for each prometheus to see if it has ruler
|
||||
useEffect(() => { |
||||
const dataSources = getRulesDataSources(); |
||||
dataSources.forEach(async (ds) => { |
||||
const { data: dsFeatures } = await discoverDsFeatures({ uid: ds.uid }, true); |
||||
if (dsFeatures?.rulerConfig) { |
||||
setRulesSourcesWithRuler((prev) => [...prev, ds]); |
||||
} |
||||
}); |
||||
}, [discoverDsFeatures]); |
||||
|
||||
return dataSourcesWithRuler |
||||
.map((ds) => getDataSourceByName(ds.name)) |
||||
.filter((dsConfig): dsConfig is DataSourceInstanceSettings => Boolean(dsConfig)); |
||||
return { rulesSourcesWithRuler, isLoading }; |
||||
} |
||||
|
@ -1,216 +1,370 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { useCallback, useEffect, useMemo, useState } from 'react'; |
||||
import { useLocation } from 'react-router-dom-v5-compat'; |
||||
import { useAsyncFn, useInterval, useMeasure } from 'react-use'; |
||||
import { PropsWithChildren, ReactNode, useMemo } from 'react'; |
||||
import Skeleton from 'react-loading-skeleton'; |
||||
|
||||
import { GrafanaTheme2, urlUtil } from '@grafana/data'; |
||||
import { Button, LinkButton, LoadingBar, useStyles2, withErrorBoundary } from '@grafana/ui'; |
||||
import { useDispatch } from 'app/types'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { |
||||
Dropdown, |
||||
Icon, |
||||
IconButton, |
||||
LinkButton, |
||||
Menu, |
||||
Pagination, |
||||
Stack, |
||||
Text, |
||||
useStyles2, |
||||
withErrorBoundary, |
||||
} from '@grafana/ui'; |
||||
import { Trans } from 'app/core/internationalization'; |
||||
import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting'; |
||||
import { RulesSourceApplication } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { CombinedRuleNamespace } from '../../../../types/unified-alerting'; |
||||
import { logInfo, LogMessages, trackRuleListNavigation } from '../Analytics'; |
||||
import { alertRuleApi } from '../api/alertRuleApi'; |
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; |
||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; |
||||
import RulesFilter from '../components/rules/Filter/RulesFilter.v1'; |
||||
import { NoRulesSplash } from '../components/rules/NoRulesCTA'; |
||||
import { INSTANCES_DISPLAY_LIMIT } from '../components/rules/RuleDetails'; |
||||
import { RuleListErrors } from '../components/rules/RuleListErrors'; |
||||
import { RuleStats } from '../components/rules/RuleStats'; |
||||
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; |
||||
import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces'; |
||||
import { useFilteredRules, useRulesFilter } from '../hooks/useFilteredRules'; |
||||
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector'; |
||||
import { fetchAllPromAndRulerRulesAction } from '../state/actions'; |
||||
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; |
||||
import { getAllRulesSourceNames, getApplicationFromRulesSource, getRulesSourceUniqueKey } from '../utils/datasource'; |
||||
import { makeFolderAlertsLink } from '../utils/misc'; |
||||
|
||||
import { EvaluationGroupWithRules } from './components/EvaluationGroupWithRules'; |
||||
import Namespace from './components/Namespace'; |
||||
|
||||
// make sure we ask for 1 more so we show the "show x more" button
|
||||
const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1; |
||||
import { Spacer } from '../components/Spacer'; |
||||
import { WithReturnButton } from '../components/WithReturnButton'; |
||||
import RulesFilter from '../components/rules/Filter/RulesFilter'; |
||||
import { getAllRulesSources, isGrafanaRulesSource } from '../utils/datasource'; |
||||
import { equal, fromRule, fromRulerRule, hashRule, stringifyIdentifier } from '../utils/rule-id'; |
||||
import { getRulePluginOrigin, isAlertingRule, isRecordingRule } from '../utils/rules'; |
||||
import { createRelativeUrl } from '../utils/url'; |
||||
|
||||
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem'; |
||||
import { ListGroup } from './components/ListGroup'; |
||||
import { ListSection } from './components/ListSection'; |
||||
import { DataSourceIcon } from './components/Namespace'; |
||||
import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2'; |
||||
import { LoadingIndicator } from './components/RuleGroup'; |
||||
|
||||
const noop = () => {}; |
||||
const { usePrometheusRuleNamespacesQuery, useGetRuleGroupForNamespaceQuery } = alertRuleApi; |
||||
|
||||
const RuleList = withErrorBoundary( |
||||
() => { |
||||
const dispatch = useDispatch(); |
||||
const styles = useStyles2(getStyles); |
||||
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []); |
||||
const [expandAll, setExpandAll] = useState(false); |
||||
const ruleSources = getAllRulesSources(); |
||||
|
||||
const onFilterCleared = useCallback(() => setExpandAll(false), []); |
||||
return ( |
||||
// We don't want to show the Loading... indicator for the whole page.
|
||||
// We show separate indicators for Grafana-managed and Cloud rules
|
||||
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={null}> |
||||
<RulesFilter onClear={() => {}} /> |
||||
<Stack direction="column" gap={1}> |
||||
{ruleSources.map((ruleSource) => { |
||||
if (isGrafanaRulesSource(ruleSource)) { |
||||
return <GrafanaDataSourceLoader key={ruleSource} />; |
||||
} else { |
||||
return <DataSourceLoader key={ruleSource.uid} uid={ruleSource.uid} name={ruleSource.name} />; |
||||
} |
||||
})} |
||||
</Stack> |
||||
</AlertingPageWrapper> |
||||
); |
||||
}, |
||||
{ style: 'page' } |
||||
); |
||||
|
||||
const { filterState, hasActiveFilters } = useRulesFilter(); |
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; |
||||
|
||||
const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); |
||||
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); |
||||
interface DataSourceLoaderProps { |
||||
name: string; |
||||
uid: string; |
||||
} |
||||
|
||||
const loading = rulesDataSourceNames.some( |
||||
(name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading |
||||
); |
||||
const GrafanaDataSourceLoader = () => { |
||||
return <DataSourceSection name="Grafana" application="grafana" isLoading={true}></DataSourceSection>; |
||||
}; |
||||
|
||||
const promRequests = Object.entries(promRuleRequests); |
||||
const rulerRequests = Object.entries(rulerRuleRequests); |
||||
const DataSourceLoader = ({ uid, name }: DataSourceLoaderProps) => { |
||||
const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid }); |
||||
|
||||
const allPromLoaded = promRequests.every( |
||||
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined) |
||||
); |
||||
const allRulerLoaded = rulerRequests.every( |
||||
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined) |
||||
); |
||||
if (isLoading) { |
||||
return <DataSourceSection loader={<Skeleton width={250} height={16} />} />; |
||||
} |
||||
|
||||
const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0); |
||||
|
||||
const allRulerEmpty = rulerRequests.every(([_, state]) => { |
||||
const rulerRules = Object.entries(state?.result ?? {}); |
||||
const noRules = rulerRules.every(([_, result]) => result?.length === 0); |
||||
return noRules && state.dispatched; |
||||
}); |
||||
|
||||
const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS; |
||||
// Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED
|
||||
const [_, fetchRules] = useAsyncFn(async () => { |
||||
if (!loading) { |
||||
await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); |
||||
} |
||||
}, [loading, limitAlerts, dispatch]); |
||||
|
||||
useEffect(() => { |
||||
trackRuleListNavigation().catch(() => {}); |
||||
}, []); |
||||
|
||||
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
|
||||
useEffect(() => { |
||||
dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); |
||||
}, [dispatch, limitAlerts]); |
||||
useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS); |
||||
|
||||
// Show splash only when we loaded all of the data sources and none of them has alerts
|
||||
const hasNoAlertRulesCreatedYet = |
||||
allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded; |
||||
const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet; |
||||
|
||||
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces(); |
||||
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState); |
||||
|
||||
const sortedNamespaces = filteredNamespaces.sort((a: CombinedRuleNamespace, b: CombinedRuleNamespace) => |
||||
a.name.localeCompare(b.name) |
||||
); |
||||
// 2. grab prometheus rule groups with max_groups if supported
|
||||
if (dataSourceInfo) { |
||||
const rulerEnabled = Boolean(dataSourceInfo.rulerConfig); |
||||
|
||||
return ( |
||||
// We don't want to show the Loading... indicator for the whole page.
|
||||
// We show separate indicators for Grafana-managed and Cloud rules
|
||||
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={hasAlertRulesCreated && <CreateAlertButton />}> |
||||
<RuleListErrors /> |
||||
<RulesFilter onClear={onFilterCleared} /> |
||||
{hasAlertRulesCreated && ( |
||||
<> |
||||
<div className={styles.break} /> |
||||
<div className={styles.buttonsContainer}> |
||||
<div className={styles.statsContainer}> |
||||
{hasActiveFilters && ( |
||||
<Button |
||||
className={styles.expandAllButton} |
||||
icon={expandAll ? 'angle-double-up' : 'angle-double-down'} |
||||
variant="secondary" |
||||
onClick={() => setExpandAll(!expandAll)} |
||||
> |
||||
{expandAll ? 'Collapse all' : 'Expand all'} |
||||
</Button> |
||||
)} |
||||
<RuleStats namespaces={filteredNamespaces} /> |
||||
</div> |
||||
</div> |
||||
</> |
||||
)} |
||||
{hasNoAlertRulesCreatedYet && <NoRulesSplash />} |
||||
{hasAlertRulesCreated && ( |
||||
<> |
||||
<LoadingIndicator visible={loading} /> |
||||
<ul className={styles.rulesTree} role="tree" aria-label="List of alert rules"> |
||||
{sortedNamespaces.map((namespace) => { |
||||
const { rulesSource, uid } = namespace; |
||||
|
||||
const application = getApplicationFromRulesSource(rulesSource); |
||||
const href = application === 'grafana' && uid ? makeFolderAlertsLink(uid, namespace.name) : undefined; |
||||
|
||||
return ( |
||||
<Namespace |
||||
key={getRulesSourceUniqueKey(rulesSource) + namespace.name} |
||||
href={href} |
||||
name={namespace.name} |
||||
application={application} |
||||
> |
||||
{namespace.groups |
||||
.sort((a, b) => a.name.localeCompare(b.name)) |
||||
.map((group) => ( |
||||
<EvaluationGroupWithRules key={group.name} group={group} rulesSource={rulesSource} /> |
||||
))} |
||||
</Namespace> |
||||
); |
||||
})} |
||||
</ul> |
||||
</> |
||||
)} |
||||
</AlertingPageWrapper> |
||||
<PaginatedDataSourceLoader |
||||
ruleSourceName={dataSourceInfo.name} |
||||
rulerEnabled={rulerEnabled} |
||||
uid={uid} |
||||
name={name} |
||||
application={dataSourceInfo.application} |
||||
/> |
||||
); |
||||
}, |
||||
{ style: 'page' } |
||||
); |
||||
} |
||||
|
||||
const LoadingIndicator = ({ visible = false }) => { |
||||
const [measureRef, { width }] = useMeasure<HTMLDivElement>(); |
||||
return <div ref={measureRef}>{visible && <LoadingBar width={width} />}</div>; |
||||
return null; |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
rulesTree: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
gap: theme.spacing(1), |
||||
}), |
||||
break: css({ |
||||
width: '100%', |
||||
height: 0, |
||||
marginBottom: theme.spacing(2), |
||||
borderBottom: `solid 1px ${theme.colors.border.medium}`, |
||||
}), |
||||
buttonsContainer: css({ |
||||
marginBottom: theme.spacing(2), |
||||
display: 'flex', |
||||
justifyContent: 'space-between', |
||||
}), |
||||
statsContainer: css({ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
alignItems: 'center', |
||||
}), |
||||
expandAllButton: css({ |
||||
marginRight: theme.spacing(1), |
||||
}), |
||||
}); |
||||
interface PaginatedDataSourceLoaderProps extends Pick<DataSourceSectionProps, 'application' | 'uid' | 'name'> { |
||||
ruleSourceName: string; |
||||
rulerEnabled?: boolean; |
||||
} |
||||
|
||||
export default RuleList; |
||||
function PaginatedDataSourceLoader({ |
||||
ruleSourceName, |
||||
rulerEnabled = false, |
||||
name, |
||||
uid, |
||||
application, |
||||
}: PaginatedDataSourceLoaderProps) { |
||||
const { data: ruleNamespaces = [], isLoading } = usePrometheusRuleNamespacesQuery({ |
||||
ruleSourceName, |
||||
maxGroups: 25, |
||||
limitAlerts: 0, |
||||
excludeAlerts: true, |
||||
}); |
||||
|
||||
export function CreateAlertButton() { |
||||
const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); |
||||
const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); |
||||
return ( |
||||
<DataSourceSection name={name} application={application} uid={uid} isLoading={isLoading}> |
||||
<Stack direction="column" gap={1}> |
||||
{ruleNamespaces.map((namespace) => ( |
||||
<ListSection |
||||
key={namespace.name} |
||||
title={ |
||||
<Stack direction="row" gap={1} alignItems="center"> |
||||
<Icon name="folder" /> {namespace.name} |
||||
</Stack> |
||||
} |
||||
> |
||||
{namespace.groups.map((group) => ( |
||||
<ListGroup |
||||
key={group.name} |
||||
name={group.name} |
||||
isOpen={false} |
||||
actions={ |
||||
<> |
||||
<Dropdown |
||||
overlay={ |
||||
<Menu> |
||||
<Menu.Item label="Edit" icon="pen" data-testid="edit-group-action" /> |
||||
<Menu.Item label="Re-order rules" icon="flip" /> |
||||
<Menu.Divider /> |
||||
<Menu.Item label="Export" icon="download-alt" /> |
||||
<Menu.Item label="Delete" icon="trash-alt" destructive /> |
||||
</Menu> |
||||
} |
||||
> |
||||
<IconButton name="ellipsis-h" aria-label="rule group actions" /> |
||||
</Dropdown> |
||||
</> |
||||
} |
||||
> |
||||
{group.rules.map((rule) => { |
||||
const groupIdentifier: RuleGroupIdentifier = { |
||||
dataSourceName: ruleSourceName, |
||||
groupName: group.name, |
||||
namespaceName: namespace.name, |
||||
}; |
||||
|
||||
const location = useLocation(); |
||||
return ( |
||||
<AlertRuleLoader |
||||
key={hashRule(rule)} |
||||
rule={rule} |
||||
groupIdentifier={groupIdentifier} |
||||
rulerEnabled={rulerEnabled} |
||||
/> |
||||
); |
||||
})} |
||||
</ListGroup> |
||||
))} |
||||
</ListSection> |
||||
))} |
||||
{!isLoading && <Pagination currentPage={1} numberOfPages={0} onNavigate={noop} />} |
||||
</Stack> |
||||
</DataSourceSection> |
||||
); |
||||
} |
||||
|
||||
const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; |
||||
interface AlertRuleLoaderProps { |
||||
rule: Rule; |
||||
groupIdentifier: RuleGroupIdentifier; |
||||
rulerEnabled?: boolean; |
||||
} |
||||
|
||||
const canCreateGrafanaRules = createRuleSupported && createRuleAllowed; |
||||
function AlertRuleLoader({ rule, groupIdentifier, rulerEnabled = false }: AlertRuleLoaderProps) { |
||||
const { dataSourceName, namespaceName, groupName } = groupIdentifier; |
||||
|
||||
if (canCreateGrafanaRules || canCreateCloudRules) { |
||||
const ruleIdentifier = fromRule(dataSourceName, namespaceName, groupName, rule); |
||||
const href = createViewLinkFromIdentifier(ruleIdentifier); |
||||
const originMeta = getRulePluginOrigin(rule); |
||||
|
||||
// @TODO work with context API to propagate rulerConfig and such
|
||||
const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ rulesSourceName: dataSourceName }); |
||||
|
||||
// @TODO refactor this to use a separate hook (useRuleWithLocation() and useCombinedRule() seems to introduce infinite loading / recursion)
|
||||
const { |
||||
isLoading, |
||||
data: rulerRuleGroup, |
||||
// error,
|
||||
} = useGetRuleGroupForNamespaceQuery( |
||||
{ |
||||
namespace: namespaceName, |
||||
group: groupName, |
||||
rulerConfig: dataSourceInfo?.rulerConfig!, |
||||
}, |
||||
{ skip: !dataSourceInfo?.rulerConfig } |
||||
); |
||||
|
||||
const rulerRule = useMemo(() => { |
||||
if (!rulerRuleGroup) { |
||||
return; |
||||
} |
||||
|
||||
return rulerRuleGroup.rules.find((rule) => |
||||
equal(fromRulerRule(dataSourceName, namespaceName, groupName, rule), ruleIdentifier) |
||||
); |
||||
}, [dataSourceName, groupName, namespaceName, ruleIdentifier, rulerRuleGroup]); |
||||
|
||||
// 1. get the rule from the ruler API with "ruleWithLocation"
|
||||
// 1.1 skip this if this datasource does not have a ruler
|
||||
//
|
||||
// 2.1 render action buttons
|
||||
// 2.2 render provisioning badge and contact point metadata, etc.
|
||||
|
||||
const actions = useMemo(() => { |
||||
if (!rulerEnabled) { |
||||
return null; |
||||
} |
||||
|
||||
if (isLoading) { |
||||
return <ActionsLoader />; |
||||
} |
||||
|
||||
if (rulerRule) { |
||||
return <RuleActionsButtons rule={rulerRule} promRule={rule} groupIdentifier={groupIdentifier} compact />; |
||||
} |
||||
|
||||
return null; |
||||
}, [groupIdentifier, isLoading, rule, rulerEnabled, rulerRule]); |
||||
|
||||
if (isAlertingRule(rule)) { |
||||
return ( |
||||
<LinkButton |
||||
href={urlUtil.renderUrl('alerting/new/alerting', { returnTo: location.pathname + location.search })} |
||||
icon="plus" |
||||
onClick={() => logInfo(LogMessages.alertRuleFromScratch)} |
||||
> |
||||
New alert rule |
||||
</LinkButton> |
||||
<AlertRuleListItem |
||||
name={rule.name} |
||||
href={href} |
||||
summary={rule.annotations?.summary} |
||||
state={rule.state} |
||||
health={rule.health} |
||||
error={rule.lastError} |
||||
labels={rule.labels} |
||||
isProvisioned={undefined} |
||||
instancesCount={undefined} |
||||
actions={actions} |
||||
origin={originMeta} |
||||
/> |
||||
); |
||||
} |
||||
return null; |
||||
|
||||
if (isRecordingRule(rule)) { |
||||
return ( |
||||
<RecordingRuleListItem |
||||
name={rule.name} |
||||
href={href} |
||||
health={rule.health} |
||||
error={rule.lastError} |
||||
labels={rule.labels} |
||||
isProvisioned={undefined} |
||||
actions={null} |
||||
origin={originMeta} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />; |
||||
} |
||||
|
||||
function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) { |
||||
const paramId = encodeURIComponent(stringifyIdentifier(identifier)); |
||||
const paramSource = encodeURIComponent(identifier.ruleSourceName); |
||||
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {}); |
||||
} |
||||
|
||||
interface DataSourceSectionProps extends PropsWithChildren { |
||||
uid?: string; |
||||
name?: string; |
||||
loader?: ReactNode; |
||||
application?: RulesSourceApplication; |
||||
isLoading?: boolean; |
||||
description?: ReactNode; |
||||
} |
||||
|
||||
const DataSourceSection = ({ |
||||
uid, |
||||
name, |
||||
application, |
||||
children, |
||||
loader, |
||||
isLoading = false, |
||||
description = null, |
||||
}: DataSourceSectionProps) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<Stack direction="column" gap={1}> |
||||
<Stack direction="column" gap={0}> |
||||
{isLoading && <LoadingIndicator />} |
||||
<div className={styles.dataSourceSectionTitle}> |
||||
{loader ?? ( |
||||
<Stack alignItems="center"> |
||||
{application && <DataSourceIcon application={application} />} |
||||
{name && ( |
||||
<Text variant="body" weight="bold"> |
||||
{name} |
||||
</Text> |
||||
)} |
||||
{description && ( |
||||
<> |
||||
{'·'} |
||||
{description} |
||||
</> |
||||
)} |
||||
<Spacer /> |
||||
{uid && ( |
||||
<WithReturnButton |
||||
title="alert rules" |
||||
component={ |
||||
<LinkButton variant="secondary" size="sm" href={`/connections/datasources/edit/${uid}`}> |
||||
<Trans i18nKey="alerting.rule-list.configure-datasource">Configure</Trans> |
||||
</LinkButton> |
||||
} |
||||
/> |
||||
)} |
||||
</Stack> |
||||
)} |
||||
</div> |
||||
</Stack> |
||||
<div className={styles.itemsWrapper}>{children}</div> |
||||
</Stack> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
itemsWrapper: css({ |
||||
position: 'relative', |
||||
marginLeft: theme.spacing(1.5), |
||||
|
||||
'&:before': { |
||||
content: "''", |
||||
position: 'absolute', |
||||
height: '100%', |
||||
|
||||
marginLeft: `-${theme.spacing(1.5)}`, |
||||
borderLeft: `solid 1px ${theme.colors.border.weak}`, |
||||
}, |
||||
}), |
||||
dataSourceSectionTitle: css({ |
||||
background: theme.colors.background.secondary, |
||||
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, |
||||
|
||||
border: `solid 1px ${theme.colors.border.weak}`, |
||||
borderRadius: theme.shape.radius.default, |
||||
}), |
||||
}); |
||||
|
||||
export default RuleList; |
||||
|
@ -0,0 +1,153 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { useMemo } from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Counter, Pagination, Stack, useStyles2 } from '@grafana/ui'; |
||||
import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants'; |
||||
import { CombinedRule, CombinedRuleNamespace } from 'app/types/unified-alerting'; |
||||
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { usePagination } from '..//hooks/usePagination'; |
||||
import { calculateTotalInstances } from '../components/rule-viewer/RuleViewer'; |
||||
import { ListSection } from '../rule-list/components/ListSection'; |
||||
import { createViewLink } from '../utils/misc'; |
||||
import { hashRule } from '../utils/rule-id'; |
||||
import { |
||||
getRuleGroupLocationFromCombinedRule, |
||||
getRulePluginOrigin, |
||||
isAlertingRule, |
||||
isGrafanaRulerRule, |
||||
} from '../utils/rules'; |
||||
|
||||
import { AlertRuleListItem } from './components/AlertRuleListItem'; |
||||
import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2'; |
||||
|
||||
interface Props { |
||||
namespaces: CombinedRuleNamespace[]; |
||||
} |
||||
|
||||
type GroupedRules = Map<PromAlertingRuleState, CombinedRule[]>; |
||||
|
||||
export const StateView = ({ namespaces }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const groupedRules = useMemo(() => { |
||||
const result: GroupedRules = new Map([ |
||||
[PromAlertingRuleState.Firing, []], |
||||
[PromAlertingRuleState.Pending, []], |
||||
[PromAlertingRuleState.Inactive, []], |
||||
]); |
||||
|
||||
namespaces.forEach((namespace) => |
||||
namespace.groups.forEach((group) => |
||||
group.rules.forEach((rule) => { |
||||
// We might hit edge cases where there type = alerting, but there is no state.
|
||||
// In this case, we shouldn't try to group these alerts in the state view
|
||||
// Even though we handle this at the API layer, this is a last catch point for any edge cases
|
||||
if (rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state) { |
||||
result.get(rule.promRule.state)?.push(rule); |
||||
} |
||||
}) |
||||
) |
||||
); |
||||
|
||||
result.forEach((rules) => rules.sort((a, b) => a.name.localeCompare(b.name))); |
||||
|
||||
return result; |
||||
}, [namespaces]); |
||||
|
||||
const entries = groupedRules.entries(); |
||||
|
||||
return ( |
||||
<ul className={styles.columnStack} role="tree"> |
||||
{Array.from(entries).map(([state, rules]) => ( |
||||
<RulesByState key={state} state={state} rules={rules} /> |
||||
))} |
||||
</ul> |
||||
); |
||||
}; |
||||
|
||||
const STATE_TITLES: Record<PromAlertingRuleState, string> = { |
||||
[PromAlertingRuleState.Firing]: 'Firing', |
||||
[PromAlertingRuleState.Pending]: 'Pending', |
||||
[PromAlertingRuleState.Inactive]: 'Normal', |
||||
}; |
||||
|
||||
const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: CombinedRule[] }) => { |
||||
const { page, pageItems, numberOfPages, onPageChange } = usePagination(rules, 1, DEFAULT_PER_PAGE_PAGINATION); |
||||
|
||||
const isFiringState = state !== PromAlertingRuleState.Firing; |
||||
const hasRulesMatchingState = rules.length > 0; |
||||
|
||||
return ( |
||||
<ListSection |
||||
title={ |
||||
<Stack alignItems="center" gap={0}> |
||||
{STATE_TITLES[state] ?? 'Unknown'} |
||||
<Counter value={rules.length} /> |
||||
</Stack> |
||||
} |
||||
collapsed={isFiringState || hasRulesMatchingState} |
||||
pagination={ |
||||
<Pagination |
||||
currentPage={page} |
||||
numberOfPages={numberOfPages} |
||||
onNavigate={onPageChange} |
||||
hideWhenSinglePage={true} |
||||
/> |
||||
} |
||||
> |
||||
{pageItems.map((rule) => { |
||||
const { rulerRule, promRule } = rule; |
||||
|
||||
const isProvisioned = isGrafanaRulerRule(rulerRule) && Boolean(rulerRule.grafana_alert.provenance); |
||||
const instancesCount = isAlertingRule(rule.promRule) ? calculateTotalInstances(rule.instanceTotals) : undefined; |
||||
const groupIdentifier = getRuleGroupLocationFromCombinedRule(rule); |
||||
|
||||
if (!promRule) { |
||||
return null; |
||||
} |
||||
|
||||
const originMeta = getRulePluginOrigin(promRule); |
||||
|
||||
return ( |
||||
<AlertRuleListItem |
||||
key={hashRule(promRule)} |
||||
name={rule.name} |
||||
href={createViewLink(rule.namespace.rulesSource, rule)} |
||||
summary={rule.annotations.summary} |
||||
state={state} |
||||
health={rule.promRule?.health} |
||||
error={rule.promRule?.lastError} |
||||
labels={rule.promRule?.labels} |
||||
isProvisioned={isProvisioned} |
||||
instancesCount={instancesCount} |
||||
namespace={rule.namespace.name} |
||||
group={rule.group.name} |
||||
actions={ |
||||
rule.rulerRule ? ( |
||||
<RuleActionsButtons |
||||
compact |
||||
rule={rule.rulerRule} |
||||
promRule={promRule} |
||||
groupIdentifier={groupIdentifier} |
||||
/> |
||||
) : ( |
||||
<ActionsLoader /> |
||||
) |
||||
} |
||||
origin={originMeta} |
||||
/> |
||||
); |
||||
})} |
||||
</ListSection> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
columnStack: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
gap: theme.spacing(1), |
||||
}), |
||||
}); |
@ -1,81 +0,0 @@ |
||||
import { size } from 'lodash'; |
||||
import { useToggle } from 'react-use'; |
||||
|
||||
import { CombinedRuleGroup, RulesSource } from 'app/types/unified-alerting'; |
||||
|
||||
import { createViewLink } from '../../utils/misc'; |
||||
import { hashRulerRule } from '../../utils/rule-id'; |
||||
import { isAlertingRule, isGrafanaRulerRule, isRecordingRule } from '../../utils/rules'; |
||||
|
||||
import { AlertRuleListItem, UnknownRuleListItem } from './AlertRuleListItem'; |
||||
import EvaluationGroup from './EvaluationGroup'; |
||||
|
||||
export interface EvaluationGroupWithRulesProps { |
||||
group: CombinedRuleGroup; |
||||
rulesSource: RulesSource; |
||||
} |
||||
|
||||
export const EvaluationGroupWithRules = ({ group, rulesSource }: EvaluationGroupWithRulesProps) => { |
||||
const [open, toggleOpen] = useToggle(false); |
||||
|
||||
return ( |
||||
<EvaluationGroup name={group.name} interval={group.interval} isOpen={open} onToggle={toggleOpen}> |
||||
{group.rules.map((rule, index) => { |
||||
const { rulerRule, promRule, annotations } = rule; |
||||
|
||||
// don't render anything if we don't have the rule definition yet
|
||||
if (!rulerRule) { |
||||
return null; |
||||
} |
||||
|
||||
// keep in mind that we may not have a promRule for the ruler rule – this happens when the target
|
||||
// rule source is eventually consistent - it may know about the rule definition but not its state
|
||||
const isAlertingPromRule = isAlertingRule(promRule); |
||||
|
||||
if (isAlertingRule(rule.promRule) || isRecordingRule(rule.promRule)) { |
||||
return ( |
||||
<AlertRuleListItem |
||||
key={hashRulerRule(rulerRule)} |
||||
state={isAlertingPromRule ? promRule?.state : undefined} |
||||
health={promRule?.health} |
||||
error={promRule?.lastError} |
||||
name={rule.name} |
||||
labels={rulerRule.labels} |
||||
lastEvaluation={promRule?.lastEvaluation} |
||||
evaluationInterval={group.interval} |
||||
instancesCount={isAlertingPromRule ? size(promRule.alerts) : undefined} |
||||
href={createViewLink(rulesSource, rule)} |
||||
summary={annotations?.summary} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
if (isGrafanaRulerRule(rulerRule)) { |
||||
const contactPoint = rulerRule.grafana_alert.notification_settings?.receiver; |
||||
|
||||
return ( |
||||
<AlertRuleListItem |
||||
key={rulerRule.grafana_alert.uid} |
||||
name={rulerRule.grafana_alert.title} |
||||
state={isAlertingPromRule ? promRule?.state : undefined} |
||||
health={promRule?.health} |
||||
error={promRule?.lastError} |
||||
labels={rulerRule.labels} |
||||
isPaused={rulerRule.grafana_alert.is_paused} |
||||
lastEvaluation={promRule?.lastEvaluation} |
||||
evaluationInterval={group.interval} |
||||
instancesCount={isAlertingPromRule ? size(promRule.alerts) : undefined} |
||||
href={createViewLink(rulesSource, rule)} |
||||
summary={rule.annotations?.summary} |
||||
isProvisioned={Boolean(rulerRule.grafana_alert.provenance)} |
||||
contactPoint={contactPoint} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
// if we get here it means we don't really know how to render this rule
|
||||
return <UnknownRuleListItem key={hashRulerRule(rulerRule)} rule={rule} />; |
||||
})} |
||||
</EvaluationGroup> |
||||
); |
||||
}; |
@ -0,0 +1,114 @@ |
||||
import { useState } from 'react'; |
||||
import Skeleton from 'react-loading-skeleton'; |
||||
|
||||
import { LinkButton, Stack } from '@grafana/ui'; |
||||
import { Trans } from 'app/core/internationalization'; |
||||
import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu'; |
||||
import { useDeleteModal } from 'app/features/alerting/unified/components/rule-viewer/DeleteModal'; |
||||
import { RedirectToCloneRule } from 'app/features/alerting/unified/components/rules/CloneRule'; |
||||
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails'; |
||||
import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/silences/SilenceGrafanaRuleDrawer'; |
||||
import { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules'; |
||||
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; |
||||
import { useDispatch } from 'app/types'; |
||||
import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting'; |
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; |
||||
|
||||
import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities'; |
||||
import { fetchPromAndRulerRulesAction } from '../../state/actions'; |
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; |
||||
import * as ruleId from '../../utils/rule-id'; |
||||
import { isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules'; |
||||
import { createRelativeUrl } from '../../utils/url'; |
||||
|
||||
interface Props { |
||||
rule: RulerRuleDTO; |
||||
promRule: Rule; |
||||
groupIdentifier: RuleGroupIdentifier; |
||||
/** |
||||
* Should we show the buttons in a "compact" state? |
||||
* i.e. without text and using smaller button sizes |
||||
*/ |
||||
compact?: boolean; |
||||
} |
||||
|
||||
// For now this is just a copy of RuleActionsButtons.tsx but with the View button removed.
|
||||
// This is only done to keep the new list behind a feature flag and limit changes in the existing components
|
||||
export const RuleActionsButtons = ({ compact, rule, promRule, groupIdentifier }: Props) => { |
||||
const dispatch = useDispatch(); |
||||
|
||||
const redirectToListView = compact ? false : true; |
||||
const [deleteModal, showDeleteModal] = useDeleteModal(redirectToListView); |
||||
|
||||
const [showSilenceDrawer, setShowSilenceDrawer] = useState<boolean>(false); |
||||
|
||||
const [redirectToClone, setRedirectToClone] = useState< |
||||
{ identifier: RuleIdentifier; isProvisioned: boolean } | undefined |
||||
>(undefined); |
||||
|
||||
const { namespaceName, groupName, dataSourceName } = groupIdentifier; |
||||
const { hasActiveFilters } = useRulesFilter(); |
||||
|
||||
const isProvisioned = isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance); |
||||
|
||||
const [editRuleSupported, editRuleAllowed] = useRulerRuleAbility(rule, groupIdentifier, AlertRuleAction.Update); |
||||
|
||||
const canEditRule = editRuleSupported && editRuleAllowed; |
||||
|
||||
const buttons: JSX.Element[] = []; |
||||
const buttonSize = compact ? 'sm' : 'md'; |
||||
|
||||
const identifier = ruleId.fromRulerRule(dataSourceName, namespaceName, groupName, rule); |
||||
|
||||
if (canEditRule) { |
||||
const identifier = ruleId.fromRulerRule(dataSourceName, namespaceName, groupName, rule); |
||||
|
||||
const editURL = createRelativeUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`); |
||||
|
||||
buttons.push( |
||||
<LinkButton title="Edit" size={buttonSize} key="edit" variant="secondary" icon="pen" href={editURL}> |
||||
<Trans i18nKey="common.edit">Edit</Trans> |
||||
</LinkButton> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<Stack gap={1} alignItems="center" wrap="nowrap"> |
||||
{buttons} |
||||
<AlertRuleMenu |
||||
buttonSize={buttonSize} |
||||
rulerRule={rule} |
||||
promRule={promRule} |
||||
groupIdentifier={groupIdentifier} |
||||
identifier={identifier} |
||||
handleDelete={() => showDeleteModal(rule, groupIdentifier)} |
||||
handleSilence={() => setShowSilenceDrawer(true)} |
||||
handleDuplicateRule={() => setRedirectToClone({ identifier, isProvisioned })} |
||||
onPauseChange={() => { |
||||
// Uses INSTANCES_DISPLAY_LIMIT + 1 here as exporting LIMIT_ALERTS from RuleList has the side effect
|
||||
// of breaking some unrelated tests in Policy.test.tsx due to mocking approach
|
||||
const limitAlerts = hasActiveFilters ? undefined : INSTANCES_DISPLAY_LIMIT + 1; |
||||
// Trigger a re-fetch of the rules table
|
||||
// TODO: Migrate rules table functionality to RTK Query, so we instead rely
|
||||
// on tag invalidation (or optimistic cache updates) for this
|
||||
dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME, limitAlerts })); |
||||
}} |
||||
/> |
||||
{deleteModal} |
||||
{isGrafanaAlertingRule(rule) && showSilenceDrawer && ( |
||||
<AlertmanagerProvider accessType="instance"> |
||||
<SilenceGrafanaRuleDrawer rulerRule={rule} onClose={() => setShowSilenceDrawer(false)} /> |
||||
</AlertmanagerProvider> |
||||
)} |
||||
{redirectToClone?.identifier && ( |
||||
<RedirectToCloneRule |
||||
identifier={redirectToClone.identifier} |
||||
isProvisioned={redirectToClone.isProvisioned} |
||||
onDismiss={() => setRedirectToClone(undefined)} |
||||
/> |
||||
)} |
||||
</Stack> |
||||
); |
||||
}; |
||||
|
||||
export const ActionsLoader = () => <Skeleton width={50} height={16} />; |
Loading…
Reference in new issue