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>
pull/105711/head
Konrad Lalik 2 days ago committed by GitHub
parent e38e07ec60
commit 0c9ca20bc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 121
      public/app/features/alerting/unified/components/rules/NoRulesCTA.tsx
  2. 2
      public/app/features/alerting/unified/rule-editor/formDefaults.ts
  3. 23
      public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx
  4. 5
      public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx
  5. 8
      public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx
  6. 18
      public/app/features/alerting/unified/rule-list/components/LazyPagination.tsx
  7. 59
      public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx
  8. 10
      public/locales/en-US/grafana.json

@ -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.{' '}
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
>
Learn more
<Trans i18nKey="alerting.common.learn-more">Learn more</Trans>
</TextLink>
</Trans>
</EmptyState>
</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",

Loading…
Cancel
Save