mirror of
https://github.com/grafana/grafana.git
synced 2025-08-02 16:42:26 +08:00
Alerting: List v2 empty states (#105616)
* Add empty state handling for GMA rules * Add handing empty states for Grafana and Datasource rules * Update translations, fix lint errors * Add empty state translation * WIP layout update * implement hover styles * update pagination * fix list item indent * clean up actions part 1 * only apply text fill to v2 list view * add missing returnTo for rule viewer * fix list styles for list view * i18n * update bulk actions to regular folder actions for list v2 * fix a few tests * simplify paginated loaders for new list view * i18n * more UI feedback * fix test * comment --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
@ -1,8 +1,13 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, useTranslate } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Dropdown, EmptyState, LinkButton, Menu, MenuItem, Stack, TextLink } from '@grafana/ui';
|
||||
import { Dropdown, EmptyState, LinkButton, Menu, MenuItem, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||
import { useRulesAccess } from '../../utils/accessControlHooks';
|
||||
import { createRelativeUrl } from '../../utils/url';
|
||||
|
||||
const RecordingRulesButtons = () => {
|
||||
const { canCreateGrafanaRules, canCreateCloudRules } = useRulesAccess();
|
||||
@ -21,7 +26,7 @@ const RecordingRulesButtons = () => {
|
||||
<MenuItem
|
||||
url="alerting/new/grafana-recording"
|
||||
icon="plus"
|
||||
label={t('alerting.list-view.empty.new-grafana-recording-rule', 'New Grafana-managed recording rule')}
|
||||
label={t('alerting.list-view.empty.new-grafana-recording-rule', 'New recording rule')}
|
||||
/>
|
||||
<MenuItem
|
||||
url="alerting/new/recording"
|
||||
@ -47,9 +52,7 @@ const RecordingRulesButtons = () => {
|
||||
<>
|
||||
{canCreateGrafanaRules && grafanaRecordingRulesEnabled && (
|
||||
<LinkButton variant="primary" icon="plus" size="lg" href="alerting/new/grafana-recording">
|
||||
<Trans i18nKey="alerting.list-view.empty.new-grafana-recording-rule">
|
||||
New Grafana-managed recording rule
|
||||
</Trans>
|
||||
<Trans i18nKey="alerting.list-view.empty.new-grafana-recording-rule">New recording rule</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
{canCreateCloudRules && (
|
||||
@ -64,13 +67,14 @@ const RecordingRulesButtons = () => {
|
||||
};
|
||||
|
||||
export const NoRulesSplash = () => {
|
||||
const { t } = useTranslate();
|
||||
const { canCreateGrafanaRules, canCreateCloudRules } = useRulesAccess();
|
||||
const canCreateAnything = canCreateGrafanaRules || canCreateCloudRules;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EmptyState
|
||||
message="You haven't created any rules yet"
|
||||
message={t('alerting.list-view.empty.no-rules-created', "You haven't created any rules yet")}
|
||||
variant="call-to-action"
|
||||
button={
|
||||
canCreateAnything ? (
|
||||
@ -86,15 +90,110 @@ export const NoRulesSplash = () => {
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="alerting.list-view.empty.provisioning">
|
||||
You can also define rules through file provisioning or Terraform.{' '}
|
||||
<TextLink
|
||||
href="https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/"
|
||||
external
|
||||
>
|
||||
Learn more
|
||||
</TextLink>
|
||||
You can also define rules through file provisioning or Terraform
|
||||
</Trans>
|
||||
<TextLink href="https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/" external>
|
||||
<Trans i18nKey="alerting.common.learn-more">Learn more</Trans>
|
||||
</TextLink>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function GrafanaNoRulesCTA() {
|
||||
const { canCreateGrafanaRules } = useRulesAccess();
|
||||
const { t } = useTranslate();
|
||||
|
||||
const grafanaRecordingRulesEnabled = config.unifiedAlerting.recordingRulesEnabled && canCreateGrafanaRules;
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
message={t('alerting.list-view.empty.no-rules-created', "You haven't created any rules yet")}
|
||||
variant="call-to-action"
|
||||
>
|
||||
<Stack direction="column" alignItems="center" justifyContent="center" gap={2}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="center">
|
||||
<Trans i18nKey="alerting.list-view.empty.provisioning">
|
||||
You can also define rules through file provisioning or Terraform
|
||||
</Trans>
|
||||
<TextLink
|
||||
href="https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/"
|
||||
external
|
||||
>
|
||||
<Trans i18nKey="alerting.common.learn-more">Learn more</Trans>
|
||||
</TextLink>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" justifyContent="center">
|
||||
{canCreateGrafanaRules && (
|
||||
<LinkButton variant="primary" icon="plus" href="alerting/new/alerting">
|
||||
<Trans i18nKey="alerting.list-view.empty.new-grafana-alerting-rule">New alert rule</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
{canCreateGrafanaRules && grafanaRecordingRulesEnabled && (
|
||||
<LinkButton variant="primary" icon="plus" href="alerting/new/grafana-recording">
|
||||
<Trans i18nKey="alerting.list-view.empty.new-grafana-recording-rule">New recording rule</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloudNoRulesCTA({ dataSourceName }: { dataSourceName: string }) {
|
||||
const styles = useStyles2(getCloudNoRulesStyles);
|
||||
const { canCreateCloudRules } = useRulesAccess();
|
||||
|
||||
const newAlertingRuleUrl = getNewDataSourceRuleUrl(dataSourceName, RuleFormType.cloudAlerting);
|
||||
const newRecordingRuleUrl = getNewDataSourceRuleUrl(dataSourceName, RuleFormType.cloudRecording);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Text variant="h5">
|
||||
<Trans i18nKey="alerting.list-view.empty.ds-no-rules">This data source has no rules configured</Trans>
|
||||
</Text>
|
||||
{canCreateCloudRules && (
|
||||
<Stack direction="row" alignItems="center" justifyContent="center">
|
||||
<LinkButton variant="secondary" size="sm" icon="plus" href={newAlertingRuleUrl}>
|
||||
<Trans i18nKey="alerting.list-view.empty.new-ds-managed-alerting-rule">
|
||||
New data source-managed alerting rule
|
||||
</Trans>
|
||||
</LinkButton>
|
||||
<LinkButton variant="secondary" size="sm" icon="plus" href={newRecordingRuleUrl}>
|
||||
<Trans i18nKey="alerting.list-view.empty.new-ds-managed-recording-rule">
|
||||
New data source-managed recording rule
|
||||
</Trans>
|
||||
</LinkButton>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getNewDataSourceRuleUrl(
|
||||
dataSourceName: string,
|
||||
type: RuleFormType.cloudAlerting | RuleFormType.cloudRecording
|
||||
) {
|
||||
const urlRuleType = type === RuleFormType.cloudAlerting ? 'alerting' : 'recording';
|
||||
const formDefaults: Partial<RuleFormValues> = {
|
||||
dataSourceName,
|
||||
editorSettings: {
|
||||
simplifiedQueryEditor: false,
|
||||
simplifiedNotificationEditor: false,
|
||||
},
|
||||
type,
|
||||
};
|
||||
|
||||
return createRelativeUrl(`/alerting/new/${urlRuleType}`, { defaults: JSON.stringify(formDefaults) });
|
||||
}
|
||||
|
||||
const getCloudNoRulesStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: theme.spacing(2, 1),
|
||||
}),
|
||||
});
|
||||
|
@ -120,7 +120,7 @@ export function formValuesFromQueryParams(ruleDefinition: string, type: RuleForm
|
||||
...ruleFromQueryParams,
|
||||
annotations: normalizeDefaultAnnotations(ruleFromQueryParams.annotations ?? []),
|
||||
queries: ruleFromQueryParams.queries ?? getDefaultQueries(),
|
||||
type: type || RuleFormType.grafana,
|
||||
type: ruleFromQueryParams.type ?? type ?? RuleFormType.grafana,
|
||||
evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
})
|
||||
)
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { groupBy } from 'lodash';
|
||||
import { css } from '@emotion/css';
|
||||
import { groupBy, isEmpty } from 'lodash';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { Icon, Stack, Text } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { Icon, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { DataSourceRuleGroupIdentifier, DataSourceRulesSourceIdentifier, RuleGroup } from 'app/types/unified-alerting';
|
||||
|
||||
import { groups } from '../utils/navigation';
|
||||
@ -21,6 +24,8 @@ interface PaginatedDataSourceLoaderProps extends Required<Pick<DataSourceSection
|
||||
}
|
||||
|
||||
export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }: PaginatedDataSourceLoaderProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { uid, name } = rulesSourceIdentifier;
|
||||
const prometheusGroupsGenerator = usePrometheusGroupsGenerator({ populateCache: true });
|
||||
|
||||
@ -40,6 +45,7 @@ export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }
|
||||
DATA_SOURCE_GROUP_PAGE_SIZE
|
||||
);
|
||||
|
||||
const hasNoRules = isEmpty(groups) && !isLoading;
|
||||
const groupsByNamespace = useMemo(() => groupBy(groups, 'file'), [groups]);
|
||||
|
||||
return (
|
||||
@ -73,6 +79,13 @@ export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }
|
||||
<LoadMoreButton onClick={fetchMoreGroups} />
|
||||
</div>
|
||||
)}
|
||||
{hasNoRules && (
|
||||
<div className={styles.noRules}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="alerting.rule-list.empty-data-source">No rules found</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</DataSourceSection>
|
||||
);
|
||||
@ -106,3 +119,9 @@ function RuleGroupListItem({ rulesSourceIdentifier, group, namespaceName }: Rule
|
||||
</ListGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
noRules: css({
|
||||
margin: theme.spacing(1.5, 0, 0.5, 4),
|
||||
}),
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { groupBy } from 'lodash';
|
||||
import { groupBy, isEmpty } from 'lodash';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { Trans } from '@grafana/i18n';
|
||||
@ -8,6 +8,7 @@ import { GrafanaRuleGroupIdentifier, GrafanaRulesSourceSymbol } from 'app/types/
|
||||
import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { FolderBulkActionsButton } from '../components/folder-actions/FolderActionsButton';
|
||||
import { GrafanaNoRulesCTA } from '../components/rules/NoRulesCTA';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
import { makeFolderLink } from '../utils/misc';
|
||||
import { groups } from '../utils/navigation';
|
||||
@ -40,6 +41,7 @@ export function PaginatedGrafanaLoader() {
|
||||
);
|
||||
|
||||
const groupsByFolder = useMemo(() => groupBy(groups, 'folderUid'), [groups]);
|
||||
const hasNoRules = isEmpty(groups) && !isLoading;
|
||||
|
||||
const isFolderBulkActionsEnabled = config.featureToggles.alertingBulkActionsInUI;
|
||||
|
||||
@ -81,6 +83,7 @@ export function PaginatedGrafanaLoader() {
|
||||
</ListSection>
|
||||
);
|
||||
})}
|
||||
{hasNoRules && <GrafanaNoRulesCTA />}
|
||||
{hasMoreGroups && (
|
||||
// this div will make the button not stretch
|
||||
<div>
|
||||
|
@ -54,6 +54,7 @@ export const DataSourceSection = ({
|
||||
}
|
||||
return `/connections/datasources/edit/${String(uid)}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`datasource-${String(uid)}-heading`} role="listitem">
|
||||
<Stack direction="column" gap={0}>
|
||||
@ -73,10 +74,9 @@ export const DataSourceSection = ({
|
||||
{name}
|
||||
</Text>
|
||||
{description && (
|
||||
<>
|
||||
{'·'}
|
||||
{description}
|
||||
</>
|
||||
<Text color="secondary">
|
||||
{'·'} {description}
|
||||
</Text>
|
||||
)}
|
||||
<Spacer />
|
||||
{showImportLink && (
|
||||
|
@ -0,0 +1,18 @@
|
||||
import { useTranslate } from '@grafana/i18n';
|
||||
import { Button } from '@grafana/ui';
|
||||
|
||||
interface LazyPaginationProps {
|
||||
loadMore: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function LazyPagination({ loadMore, disabled = false }: LazyPaginationProps) {
|
||||
const { t } = useTranslate();
|
||||
const label = t('alerting.rule-list.pagination.next-page', 'Show more…');
|
||||
|
||||
return (
|
||||
<Button aria-label={label} fill="text" size="sm" variant="secondary" onClick={loadMore} disabled={disabled}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
|
||||
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { isLoading as isLoadingState, isUninitialized as isUninitializedState, useAsync } from '../../hooks/useAsync';
|
||||
|
||||
/**
|
||||
* Provides pagination functionality for rule groups with lazy loading.
|
||||
* Instead of loading all groups at once, it uses a generator to fetch them in batches as needed,
|
||||
* which helps with performance when dealing with large numbers of rules.
|
||||
*
|
||||
* @param groupsGenerator - An async generator that yields rule groups in batches
|
||||
* @param pageSize - Number of groups to display per page
|
||||
* @returns Groups loaded so far and controls for navigating through rule groups
|
||||
*/
|
||||
export function useLazyLoadPrometheusGroups<TGroup extends PromRuleGroupDTO>(
|
||||
groupsGenerator: AsyncIterator<TGroup>,
|
||||
pageSize: number
|
||||
) {
|
||||
const [groups, setGroups] = useState<TGroup[]>([]);
|
||||
const [hasMoreGroups, setHasMoreGroups] = useState<boolean>(true);
|
||||
|
||||
const [{ execute: fetchMoreGroups }, groupsRequestState] = useAsync(async () => {
|
||||
let done = false;
|
||||
const currentGroups: TGroup[] = [];
|
||||
|
||||
while (currentGroups.length < pageSize) {
|
||||
const generatorResult = await groupsGenerator.next();
|
||||
if (generatorResult.done) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
const group = generatorResult.value;
|
||||
currentGroups.push(group);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
setHasMoreGroups(false);
|
||||
}
|
||||
|
||||
setGroups((groups) => groups.concat(currentGroups));
|
||||
});
|
||||
|
||||
// make sure we only load the initial group exactly once
|
||||
useEffectOnce(() => {
|
||||
fetchMoreGroups();
|
||||
});
|
||||
|
||||
const isLoading = isLoadingState(groupsRequestState);
|
||||
const isUninitialized = isUninitializedState(groupsRequestState);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
groups,
|
||||
hasMoreGroups: !isUninitialized && hasMoreGroups,
|
||||
fetchMoreGroups,
|
||||
};
|
||||
}
|
@ -731,6 +731,7 @@
|
||||
"edit": "Edit",
|
||||
"export": "Export",
|
||||
"export-all": "Export all",
|
||||
"learn-more": "Learn more",
|
||||
"loading": "Loading...",
|
||||
"search-by-matchers": "Search by matchers",
|
||||
"titles": {
|
||||
@ -1402,11 +1403,15 @@
|
||||
},
|
||||
"list-view": {
|
||||
"empty": {
|
||||
"ds-no-rules": "This data source has no rules configured",
|
||||
"new-alert-rule": "New alert rule",
|
||||
"new-ds-managed-alerting-rule": "New data source-managed alerting rule",
|
||||
"new-ds-managed-recording-rule": "New data source-managed recording rule",
|
||||
"new-grafana-recording-rule": "New Grafana-managed recording rule",
|
||||
"new-grafana-alerting-rule": "New alert rule",
|
||||
"new-grafana-recording-rule": "New recording rule",
|
||||
"new-recording-rule": "New recording rule",
|
||||
"provisioning": "You can also define rules through file provisioning or Terraform. <2>Learn more</2>"
|
||||
"no-rules-created": "You haven't created any rules yet",
|
||||
"provisioning": "You can also define rules through file provisioning or Terraform"
|
||||
},
|
||||
"no-prom-or-loki-rules": "There are no Prometheus or Loki data sources configured",
|
||||
"no-rules": "No rules found.",
|
||||
@ -2048,6 +2053,7 @@
|
||||
"description": "Check the data source configuration. Does the data source support Prometheus API?",
|
||||
"title": "Unable to load rules from this data source"
|
||||
},
|
||||
"empty-data-source": "No rules found",
|
||||
"filter-view": {
|
||||
"cancel-search": "Cancel search",
|
||||
"no-more-results": "No more results – found {{numberOfRules}} rules",
|
||||
|
Reference in New Issue
Block a user