diff --git a/public/app/features/alerting/unified/components/rules/NoRulesCTA.tsx b/public/app/features/alerting/unified/components/rules/NoRulesCTA.tsx index 8acad7bfb0b..682ae645877 100644 --- a/public/app/features/alerting/unified/components/rules/NoRulesCTA.tsx +++ b/public/app/features/alerting/unified/components/rules/NoRulesCTA.tsx @@ -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 = () => { { <> {canCreateGrafanaRules && grafanaRecordingRulesEnabled && ( - - New Grafana-managed recording rule - + New recording rule )} {canCreateCloudRules && ( @@ -64,13 +67,14 @@ const RecordingRulesButtons = () => { }; export const NoRulesSplash = () => { + const { t } = useTranslate(); const { canCreateGrafanaRules, canCreateCloudRules } = useRulesAccess(); const canCreateAnything = canCreateGrafanaRules || canCreateCloudRules; return (
{ } > - You can also define rules through file provisioning or Terraform.{' '} - - Learn more - + You can also define rules through file provisioning or Terraform + + Learn more +
); }; + +export function GrafanaNoRulesCTA() { + const { canCreateGrafanaRules } = useRulesAccess(); + const { t } = useTranslate(); + + const grafanaRecordingRulesEnabled = config.unifiedAlerting.recordingRulesEnabled && canCreateGrafanaRules; + + return ( + + + + + You can also define rules through file provisioning or Terraform + + + Learn more + + + + {canCreateGrafanaRules && ( + + New alert rule + + )} + {canCreateGrafanaRules && grafanaRecordingRulesEnabled && ( + + New recording rule + + )} + + + + ); +} + +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 ( +
+ + This data source has no rules configured + + {canCreateCloudRules && ( + + + + New data source-managed alerting rule + + + + + New data source-managed recording rule + + + + )} +
+ ); +} + +function getNewDataSourceRuleUrl( + dataSourceName: string, + type: RuleFormType.cloudAlerting | RuleFormType.cloudRecording +) { + const urlRuleType = type === RuleFormType.cloudAlerting ? 'alerting' : 'recording'; + const formDefaults: Partial = { + 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), + }), +}); diff --git a/public/app/features/alerting/unified/rule-editor/formDefaults.ts b/public/app/features/alerting/unified/rule-editor/formDefaults.ts index 04458563787..3c72744397a 100644 --- a/public/app/features/alerting/unified/rule-editor/formDefaults.ts +++ b/public/app/features/alerting/unified/rule-editor/formDefaults.ts @@ -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, }) ) diff --git a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx index ddd82a1eb56..2be1e91a3a3 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx @@ -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 groupBy(groups, 'file'), [groups]); return ( @@ -73,6 +79,13 @@ export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application } )} + {hasNoRules && ( +
+ + No rules found + +
+ )} ); @@ -106,3 +119,9 @@ function RuleGroupListItem({ rulesSourceIdentifier, group, namespaceName }: Rule ); } + +const getStyles = (theme: GrafanaTheme2) => ({ + noRules: css({ + margin: theme.spacing(1.5, 0, 0.5, 4), + }), +}); diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index 4896cf5675d..67110157ea8 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -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() { ); })} + {hasNoRules && } {hasMoreGroups && ( // this div will make the button not stretch
diff --git a/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx b/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx index 1579cc236d5..6fad0fa1d7f 100644 --- a/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx +++ b/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx @@ -54,6 +54,7 @@ export const DataSourceSection = ({ } return `/connections/datasources/edit/${String(uid)}`; })(); + return (
@@ -73,10 +74,9 @@ export const DataSourceSection = ({ {name} {description && ( - <> - {'·'} - {description} - + + {'·'} {description} + )} {showImportLink && ( diff --git a/public/app/features/alerting/unified/rule-list/components/LazyPagination.tsx b/public/app/features/alerting/unified/rule-list/components/LazyPagination.tsx new file mode 100644 index 00000000000..fc61542eca7 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/components/LazyPagination.tsx @@ -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 ( + + ); +} diff --git a/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx b/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx new file mode 100644 index 00000000000..8110a871d3a --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx @@ -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( + groupsGenerator: AsyncIterator, + pageSize: number +) { + const [groups, setGroups] = useState([]); + const [hasMoreGroups, setHasMoreGroups] = useState(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, + }; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 88dcc32fb55..a23feb9ab67 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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" + "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",