Alerting: Add read-only GMA rules to the new list view (#98116)

* Reuse prom groups generator between GMA, external DS and list view

* Improve generators, add initial support for GMA in grouped view components

* Improve handling of GMA rules

* Split componentes into files

* Improve error handling, simplify groups grouping

* Extract grafana rules component

* Reset yarn.lock

* Reset yarn.lock 2

* Update filters, adjust file names, add folder display name to GMA rules

* Re-enable filtering for cloud rules

* Rename AlertRuleLoader

* Add missing translations, fix lint errors

* Remove unused imports, update translations

* Fix responses in BE tests

* Update backend tests

* Update integration test

* Tidy up group page size constants

* Add error throwing to getGroups endpoint to prevent grafana usage

* Refactor FilterView to remove exhaustive check

* Refactor common props for grafana rule rendering

* Unify identifiers' discriminators, add comments, minor refactor

* Update translations

* Remove unnecessary prev page condition, add a few explanations

---------

Co-authored-by: fayzal-g <fayzal.ghantiwala@grafana.com>
Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Konrad Lalik
2025-01-15 11:36:32 +01:00
committed by GitHub
parent 7f04f66137
commit 5aeaccadff
30 changed files with 986 additions and 511 deletions

View File

@@ -2792,16 +2792,6 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/rule-list/FilterView.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
"public/app/features/alerting/unified/rule-list/GroupedView.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "6"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "7"]
],
"public/app/features/alerting/unified/rule-list/RuleList.v1.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],

View File

@@ -198,7 +198,6 @@ func (_m *FakeDashboardService) GetAllDashboards(ctx context.Context) ([]*Dashbo
return r0, r1
}
func (_m *FakeDashboardService) GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*Dashboard, error) {
ret := _m.Called(ctx, orgID)

View File

@@ -432,7 +432,7 @@ func (ss *FolderUnifiedStoreImpl) GetDescendants(ctx context.Context, orgID int6
}
func getDescendants(nodes map[string]*folder.Folder, tree map[string]map[string]*folder.Folder, ancestor_uid string, descendantsMap map[string]*folder.Folder) {
for uid, _ := range tree[ancestor_uid] {
for uid := range tree[ancestor_uid] {
descendantsMap[uid] = nodes[uid]
getDescendants(nodes, tree, uid, descendantsMap)
}

View File

@@ -489,7 +489,8 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, sr StatusRe
newGroup := &apimodels.RuleGroup{
Name: groupKey.RuleGroup,
// file is what Prometheus uses for provisioning, we replace it with namespace which is the folder in Grafana.
File: folderFullPath,
File: folderFullPath,
FolderUID: groupKey.NamespaceUID,
}
rulesTotals := make(map[string]int64, len(rules))
@@ -514,7 +515,9 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, sr StatusRe
}
newRule := apimodels.Rule{
UID: rule.UID,
Name: rule.Title,
FolderUID: rule.NamespaceUID,
Labels: apimodels.LabelsFromMap(rule.GetLabels(labelOptions...)),
Health: status.Health,
LastError: errorOrEmpty(status.LastError),

View File

@@ -314,9 +314,12 @@ func TestRouteGetRuleStatuses(t *testing.T) {
"groups": [{
"name": "rule-group",
"file": "%s",
"folderUid": "namespaceUID",
"rules": [{
"state": "inactive",
"name": "AlwaysFiring",
"folderUid": "namespaceUID",
"uid": "RuleUID",
"query": "vector(1)",
"alerts": [{
"labels": {
@@ -377,10 +380,13 @@ func TestRouteGetRuleStatuses(t *testing.T) {
"groups": [{
"name": "rule-group",
"file": "%s",
"folderUid": "namespaceUID",
"rules": [{
"state": "inactive",
"name": "AlwaysFiring",
"query": "vector(1)",
"folderUid": "namespaceUID",
"uid": "RuleUID",
"alerts": [{
"labels": {
"job": "prometheus",
@@ -439,10 +445,13 @@ func TestRouteGetRuleStatuses(t *testing.T) {
"groups": [{
"name": "rule-group",
"file": "%s",
"folderUid": "namespaceUID",
"rules": [{
"state": "inactive",
"name": "AlwaysFiring",
"query": "vector(1) | vector(1)",
"folderUid": "namespaceUID",
"uid": "RuleUID",
"alerts": [{
"labels": {
"job": "prometheus"

View File

@@ -89,6 +89,8 @@ type RuleGroup struct {
Name string `json:"name"`
// required: true
File string `json:"file"`
// required: true
FolderUID string `json:"folderUid"`
// In order to preserve rule ordering, while exposing type (alerting or recording)
// specific properties, both alerting and recording rules are exposed in the
// same array.
@@ -165,9 +167,13 @@ type AlertingRule struct {
// adapted from cortex
// swagger:model
type Rule struct {
// required: true
UID string `json:"uid"`
// required: true
Name string `json:"name"`
// required: true
FolderUID string `json:"folderUid"`
// required: true
Query string `json:"query"`
Labels promlabels.Labels `json:"labels,omitempty"`
// required: true

View File

@@ -27,6 +27,9 @@ import (
"github.com/grafana/grafana/pkg/tests/testinfra"
)
// Declare respModel at the function level
var respModel apimodels.UpdateRuleGroupResponse
func TestIntegrationPrometheusRules(t *testing.T) {
testinfra.SQLiteIntegrationTest(t)
@@ -157,7 +160,6 @@ func TestIntegrationPrometheusRules(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
var respModel apimodels.UpdateRuleGroupResponse
require.NoError(t, json.Unmarshal(b, &respModel))
require.Len(t, respModel.Created, len(rules.Rules))
}
@@ -235,18 +237,21 @@ func TestIntegrationPrometheusRules(t *testing.T) {
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
require.JSONEq(t, `
require.JSONEq(t, fmt.Sprintf(`
{
"status": "success",
"data": {
"groups": [{
"name": "arulegroup",
"file": "default",
"folderUid": "default",
"rules": [{
"state": "inactive",
"name": "AlwaysFiring",
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
"duration": 10,
"folderUid": "default",
"uid": "%s",
"annotations": {
"annotation1": "val1"
},
@@ -261,6 +266,8 @@ func TestIntegrationPrometheusRules(t *testing.T) {
"state": "inactive",
"name": "AlwaysFiringButSilenced",
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
"folderUid": "default",
"uid": "%s",
"health": "ok",
"type": "alerting",
"lastEvaluation": "0001-01-01T00:00:00Z",
@@ -277,7 +284,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
"inactive": 2
}
}
}`, string(b))
}`, respModel.Created[0], respModel.Created[1]), string(b))
}
{
@@ -293,18 +300,21 @@ func TestIntegrationPrometheusRules(t *testing.T) {
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
require.JSONEq(t, `
require.JSONEq(t, fmt.Sprintf(`
{
"status": "success",
"data": {
"groups": [{
"name": "arulegroup",
"file": "default",
"folderUid": "default",
"rules": [{
"state": "inactive",
"name": "AlwaysFiring",
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
"duration": 10,
"folderUid": "default",
"uid": "%s",
"annotations": {
"annotation1": "val1"
},
@@ -319,6 +329,8 @@ func TestIntegrationPrometheusRules(t *testing.T) {
"state": "inactive",
"name": "AlwaysFiringButSilenced",
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
"folderUid": "default",
"uid": "%s",
"health": "ok",
"type": "alerting",
"lastEvaluation": "0001-01-01T00:00:00Z",
@@ -335,7 +347,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
"inactive": 2
}
}
}`, string(b))
}`, respModel.Created[0], respModel.Created[1]), string(b))
return true
}, 18*time.Second, 2*time.Second)
}
@@ -441,7 +453,6 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
var respModel apimodels.UpdateRuleGroupResponse
require.NoError(t, json.Unmarshal(b, &respModel))
require.Len(t, respModel.Created, len(rules.Rules))
}
@@ -453,9 +464,12 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
"groups": [{
"name": "anotherrulegroup",
"file": "default",
"folderUid": "default",
"rules": [{
"state": "inactive",
"name": "AlwaysFiring",
"uid": "%s",
"folderUid": "default",
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
"duration": 10,
"annotations": {
@@ -469,6 +483,8 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
}, {
"state": "inactive",
"name": "AlwaysFiringButSilenced",
"uid": "%s",
"folderUid": "default",
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
"health": "ok",
"type": "alerting",
@@ -486,7 +502,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
"inactive": 2
}
}
}`, dashboardUID)
}`, respModel.Created[0], dashboardUID, respModel.Created[1])
expectedFilteredByJSON := fmt.Sprintf(`
{
"status": "success",
@@ -494,9 +510,12 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
"groups": [{
"name": "anotherrulegroup",
"file": "default",
"folderUid": "default",
"rules": [{
"state": "inactive",
"name": "AlwaysFiring",
"uid": "%s",
"folderUid": "default",
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
"duration": 10,
"annotations": {
@@ -519,7 +538,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
"inactive": 1
}
}
}`, dashboardUID)
}`, respModel.Created[0], dashboardUID)
expectedNoneJSON := `
{
"status": "success",

View File

@@ -1,3 +1,4 @@
import { produce } from 'immer';
import { lastValueFrom } from 'rxjs';
import { getBackendSrv } from '@grafana/runtime';
@@ -89,10 +90,9 @@ export function paramsWithMatcherAndState(
return paramsResult;
}
export const groupRulesByFileName = (groups: PromRuleGroupDTO[], dataSourceName: string) => {
const nsMap: { [key: string]: RuleNamespace } = {};
groups.forEach((group) => {
group.rules.forEach((rule) => {
export function normalizeRuleGroup(group: PromRuleGroupDTO): PromRuleGroupDTO {
return produce(group, (draft) => {
draft.rules.forEach((rule) => {
rule.query = rule.query || '';
if (rule.type === PromRuleType.Alerting) {
// There's a possibility that a custom/unexpected datasource might response with
@@ -100,11 +100,19 @@ export const groupRulesByFileName = (groups: PromRuleGroupDTO[], dataSourceName:
// In this case, we fall back to `Inactive` state so that elsewhere in the UI we don't fail/have to handle the edge case
// and log a message so we can identify how frequently this might be happening
if (!rule.state) {
logInfo('prom rule with type=alerting is missing a state', { dataSourceName, ruleName: rule.name });
logInfo('prom rule with type=alerting is missing a state', { ruleName: rule.name });
rule.state = PromAlertingRuleState.Inactive;
}
}
});
});
}
export const groupRulesByFileName = (groups: PromRuleGroupDTO[], dataSourceName: string) => {
const normalizedGroups = groups.map(normalizeRuleGroup);
const nsMap: { [key: string]: RuleNamespace } = {};
normalizedGroups.forEach((group) => {
if (!nsMap[group.file]) {
nsMap[group.file] = {
dataSourceName,
@@ -118,6 +126,7 @@ export const groupRulesByFileName = (groups: PromRuleGroupDTO[], dataSourceName:
return Object.values(nsMap);
};
export const ungroupRulesByFileName = (namespaces: RuleNamespace[] = []): PromRuleGroupDTO[] => {
return namespaces?.flatMap((namespace) =>
namespace.groups.flatMap((group) => ruleGroupToPromRuleGroupDTO(group, namespace.name))

View File

@@ -1,11 +1,14 @@
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { GrafanaPromRuleGroupDTO, PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { alertingApi } from './alertingApi';
import { normalizeRuleGroup } from './prometheus';
interface PromRulesResponse {
export interface PromRulesResponse<TRuleGroup> {
status: string;
data: {
groups: PromRuleGroupDTO[];
groups: TRuleGroup[];
groupNextToken?: string;
};
errorType?: string;
@@ -22,11 +25,37 @@ interface PromRulesOptions {
groupNextToken?: string;
}
type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource'> & {
dashboardUid?: string;
panelId?: number;
};
export const prometheusApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
groups: build.query<PromRulesResponse, PromRulesOptions>({
query: ({ ruleSource, namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => ({
url: `api/prometheus/${ruleSource.uid}/api/v1/rules`,
getGroups: build.query<PromRulesResponse<PromRuleGroupDTO<PromRuleDTO>>, PromRulesOptions>({
query: ({ ruleSource, namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => {
if (ruleSource.uid === GRAFANA_RULES_SOURCE_NAME) {
throw new Error('Please use getGrafanaGroups endpoint for grafana rules');
}
return {
url: `api/prometheus/${ruleSource.uid}/api/v1/rules`,
params: {
'file[]': namespace,
'group[]': groupName,
'rule[]': ruleName,
exclude_alerts: excludeAlerts?.toString(),
group_limit: groupLimit?.toFixed(0),
group_next_token: groupNextToken,
},
};
},
transformResponse: (response: PromRulesResponse<PromRuleGroupDTO<PromRuleDTO>>) => {
return { ...response, data: { ...response.data, groups: response.data.groups.map(normalizeRuleGroup) } };
},
}),
getGrafanaGroups: build.query<PromRulesResponse<GrafanaPromRuleGroupDTO>, GrafanaPromRulesOptions>({
query: ({ namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => ({
url: `api/prometheus/grafana/api/v1/rules`,
params: {
'file[]': namespace,
'group[]': groupName,

View File

@@ -14,12 +14,15 @@ import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButto
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const { useGetRuleGroupForNamespaceQuery } = alertRuleApi;
interface AlertRuleLoaderProps {
interface DataSourceRuleLoaderProps {
rule: Rule;
groupIdentifier: DataSourceRuleGroupIdentifier;
}
export const AlertRuleLoader = memo(function AlertRuleLoader({ rule, groupIdentifier }: AlertRuleLoaderProps) {
export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
rule,
groupIdentifier,
}: DataSourceRuleLoaderProps) {
const { rulesSource, namespace, groupName } = groupIdentifier;
const ruleIdentifier = fromRule(rulesSource.name, namespace.name, groupName, rule);

View File

@@ -9,12 +9,19 @@ import { isLoading, useAsync } from '../hooks/useAsync';
import { RulesFilter } from '../search/rulesSearchParser';
import { hashRule } from '../utils/rule-id';
import { AlertRuleLoader } from './AlertRuleLoader';
import { DataSourceRuleLoader } from './DataSourceRuleLoader';
import { GrafanaRuleLoader } from './GrafanaRuleLoader';
import LoadMoreHelper from './LoadMoreHelper';
import { UnknownRuleListItem } from './components/AlertRuleListItem';
import { ListItem } from './components/ListItem';
import { ActionsLoader } from './components/RuleActionsButtons.V2';
import { RuleListIcon } from './components/RuleListIcon';
import { RuleWithOrigin, useFilteredRulesIteratorProvider } from './hooks/useFilteredRulesIterator';
import {
GrafanaRuleWithOrigin,
PromRuleWithOrigin,
RuleWithOrigin,
useFilteredRulesIteratorProvider,
} from './hooks/useFilteredRulesIterator';
interface FilterViewProps {
filterState: RulesFilter;
@@ -30,13 +37,13 @@ export function FilterView({ filterState }: FilterViewProps) {
return <FilterViewResults filterState={filterState} key={JSON.stringify(filterState)} />;
}
interface KeyedRuleWithOrigin extends RuleWithOrigin {
type KeyedRuleWithOrigin = RuleWithOrigin & {
/**
* Artificial frontend-only identifier for the rule.
* It's used as a key for the rule in the rule list to prevent key duplication
*/
key: string;
}
};
/**
* Renders the list of rules that match the filter.
@@ -107,9 +114,25 @@ function FilterViewResults({ filterState }: FilterViewProps) {
return (
<Stack direction="column" gap={0}>
<ul aria-label="filtered-rule-list">
{rules.map(({ key, rule, groupIdentifier }) => (
<AlertRuleLoader key={key} rule={rule} groupIdentifier={groupIdentifier} />
))}
{rules.map((ruleWithOrigin) => {
const { key, rule, groupIdentifier, origin } = ruleWithOrigin;
switch (origin) {
case 'grafana':
return (
<GrafanaRuleLoader
key={key}
rule={rule}
groupName={groupIdentifier.groupName}
namespaceName={ruleWithOrigin.namespaceName}
/>
);
case 'datasource':
return <DataSourceRuleLoader key={key} rule={rule} groupIdentifier={groupIdentifier} />;
default:
return <UnknownRuleListItem key={key} rule={rule} groupIdentifier={groupIdentifier} />;
}
})}
{loading && (
<>
<AlertRuleListItemLoader />
@@ -146,7 +169,22 @@ function onFinished<T>(fn: () => void) {
return tap<T>(undefined, undefined, fn);
}
function getRuleKey(ruleWithOrigin: RuleWithOrigin) {
function getRuleKey(ruleWithOrigin: RuleWithOrigin): string {
if (ruleWithOrigin.origin === 'grafana') {
return getGrafanaRuleKey(ruleWithOrigin);
}
return getDataSourceRuleKey(ruleWithOrigin);
}
function getGrafanaRuleKey(ruleWithOrigin: GrafanaRuleWithOrigin) {
const {
groupIdentifier: { namespace, groupName },
rule,
} = ruleWithOrigin;
return `grafana-${namespace.uid}-${groupName}-${rule.uid}}`;
}
function getDataSourceRuleKey(ruleWithOrigin: PromRuleWithOrigin) {
const {
rule,
groupIdentifier: { rulesSource, namespace, groupName },

View File

@@ -0,0 +1,56 @@
import { GrafanaPromRuleDTO, PromRuleType } from 'app/types/unified-alerting-dto';
import { GrafanaRulesSource } from '../utils/datasource';
import { createRelativeUrl } from '../utils/url';
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
interface GrafanaRuleLoaderProps {
rule: GrafanaPromRuleDTO;
groupName: string;
namespaceName: string;
}
export function GrafanaRuleLoader({ rule, groupName, namespaceName }: GrafanaRuleLoaderProps) {
const { folderUid } = rule;
const commonProps = {
name: rule.name,
rulesSource: GrafanaRulesSource,
group: groupName,
namespace: namespaceName,
href: createRelativeUrl(`/alerting/grafana/${rule.uid}/view`),
health: rule.health,
error: rule.lastError,
labels: rule.labels,
};
if (rule.type === PromRuleType.Alerting) {
return (
<AlertRuleListItem
{...commonProps}
application="grafana"
summary={rule.annotations?.summary}
state={rule.state}
isProvisioned={undefined}
instancesCount={rule.alerts?.length}
/>
);
}
if (rule.type === PromRuleType.Recording) {
return <RecordingRuleListItem {...commonProps} application="grafana" isProvisioned={undefined} />;
}
return (
<UnknownRuleListItem
rule={rule}
groupIdentifier={{
rulesSource: GrafanaRulesSource,
groupName,
namespace: { uid: folderUid },
groupOrigin: 'grafana',
}}
/>
);
}

View File

@@ -1,53 +1,46 @@
import { css } from '@emotion/css';
import { PropsWithChildren, ReactNode, useMemo } from 'react';
import { useMemo } from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Dropdown, Icon, IconButton, LinkButton, Menu, Stack, Text, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { DataSourceNamespaceIdentifier, DataSourceRuleGroupIdentifier, RuleGroup } from 'app/types/unified-alerting';
import { RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { Stack } from '@grafana/ui';
import { DataSourceRulesSourceIdentifier } from 'app/types/unified-alerting';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { Spacer } from '../components/Spacer';
import { WithReturnButton } from '../components/WithReturnButton';
import { getDatasourceAPIUid, getExternalRulesSources } from '../utils/datasource';
import { hashRule } from '../utils/rule-id';
import { GrafanaRulesSource, getExternalRulesSources } from '../utils/datasource';
import { AlertRuleLoader } from './AlertRuleLoader';
import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { DataSourceIcon } from './components/Namespace';
import { LoadingIndicator } from './components/RuleGroup';
import { usePaginatedPrometheusRuleNamespaces } from './hooks/usePaginatedPrometheusRuleNamespaces';
import { PaginatedDataSourceLoader } from './PaginatedDataSourceLoader';
import { PaginatedGrafanaLoader } from './PaginatedGrafanaLoader';
import { DataSourceErrorBoundary } from './components/DataSourceErrorBoundary';
import { DataSourceSection } from './components/DataSourceSection';
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const GROUP_PAGE_SIZE = 40;
export function GroupedView() {
const externalRuleSources = useMemo(() => getExternalRulesSources(), []);
return (
<Stack direction="column" gap={1} role="list">
<GrafanaDataSourceLoader />
<DataSourceErrorBoundary rulesSourceIdentifier={GrafanaRulesSource}>
<PaginatedGrafanaLoader />
</DataSourceErrorBoundary>
{externalRuleSources.map((ruleSource) => {
return <DataSourceLoader key={ruleSource.uid} uid={ruleSource.uid} name={ruleSource.name} />;
return <DataSourceLoader key={ruleSource.uid} rulesSourceIdentifier={ruleSource} />;
})}
</Stack>
);
}
interface DataSourceLoaderProps {
name: string;
uid: string;
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
}
export function GrafanaDataSourceLoader() {
return <DataSourceSection name="Grafana" application="grafana" uid="grafana" isLoading={true} />;
}
export function DataSourceLoader({ uid, name }: DataSourceLoaderProps) {
const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid });
function DataSourceLoader({ rulesSourceIdentifier }: DataSourceLoaderProps) {
const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid: rulesSourceIdentifier.uid });
const { uid, name } = rulesSourceIdentifier;
if (isLoading) {
return <DataSourceSection loader={<Skeleton width={250} height={16} />} uid={uid} name={name} />;
@@ -56,223 +49,15 @@ export function DataSourceLoader({ uid, name }: DataSourceLoaderProps) {
// 2. grab prometheus rule groups with max_groups if supported
if (dataSourceInfo) {
return (
<PaginatedDataSourceLoader
ruleSourceName={dataSourceInfo.name}
uid={uid}
name={name}
application={dataSourceInfo.application}
/>
<DataSourceErrorBoundary rulesSourceIdentifier={rulesSourceIdentifier}>
<PaginatedDataSourceLoader
key={rulesSourceIdentifier.uid}
rulesSourceIdentifier={rulesSourceIdentifier}
application={dataSourceInfo.application}
/>
</DataSourceErrorBoundary>
);
}
return null;
}
// TODO Try to use a better rules source identifier
interface PaginatedDataSourceLoaderProps
extends Required<Pick<DataSourceSectionProps, 'application' | 'uid' | 'name'>> {
ruleSourceName: string;
}
function PaginatedDataSourceLoader({ ruleSourceName, name, uid, application }: PaginatedDataSourceLoaderProps) {
const {
page: ruleNamespaces,
nextPage,
previousPage,
canMoveForward,
canMoveBackward,
isLoading,
} = usePaginatedPrometheusRuleNamespaces(ruleSourceName, GROUP_PAGE_SIZE);
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" />{' '}
<Text variant="body" element="h3">
{namespace.name}
</Text>
</Stack>
}
>
{namespace.groups.map((group) => (
<RuleGroupListItem
key={`${ruleSourceName}-${namespace.name}-${group.name}`}
group={group}
ruleSourceName={ruleSourceName}
namespaceId={namespace}
/>
))}
</ListSection>
))}
<LazyPagination
nextPage={nextPage}
previousPage={previousPage}
canMoveForward={canMoveForward}
canMoveBackward={canMoveBackward}
/>
</Stack>
</DataSourceSection>
);
}
interface RuleGroupListItemProps {
group: RuleGroup;
ruleSourceName: string;
namespaceId: DataSourceNamespaceIdentifier;
}
function RuleGroupListItem({ group, ruleSourceName, namespaceId }: RuleGroupListItemProps) {
const rulesWithGroupId = useMemo(
() =>
group.rules.map((rule) => {
const groupIdentifier: DataSourceRuleGroupIdentifier = {
rulesSource: { uid: getDatasourceAPIUid(ruleSourceName), name: ruleSourceName },
namespace: namespaceId,
groupName: group.name,
groupOrigin: 'datasource',
};
return { rule, groupIdentifier };
}),
[group, namespaceId, ruleSourceName]
);
return (
<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>
</>
}
>
{rulesWithGroupId.map(({ rule, groupIdentifier }) => (
<AlertRuleLoader key={hashRule(rule)} rule={rule} groupIdentifier={groupIdentifier} />
))}
</ListGroup>
);
}
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 (
<section aria-labelledby={`datasource-${uid}-heading`} role="listitem">
<Stack direction="column" gap={1}>
<Stack direction="column" gap={0}>
{isLoading && <LoadingIndicator datasourceUid={uid} />}
<div className={styles.dataSourceSectionTitle}>
{loader ?? (
<Stack alignItems="center">
{application && <DataSourceIcon application={application} />}
<Text variant="body" weight="bold" element="h2" id={`datasource-${uid}-heading`}>
{name}
</Text>
{description && (
<>
{'·'}
{description}
</>
)}
<Spacer />
<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>
</section>
);
};
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,
}),
});
interface LazyPaginationProps {
canMoveForward: boolean;
canMoveBackward: boolean;
nextPage: () => void;
previousPage: () => void;
}
function LazyPagination({ canMoveForward, canMoveBackward, nextPage, previousPage }: LazyPaginationProps) {
return (
<Stack direction="row" gap={1}>
<Button
aria-label={`previous page`}
size="sm"
variant="secondary"
onClick={previousPage}
disabled={!canMoveBackward}
>
<Icon name="angle-left" />
</Button>
<Button aria-label={`next page`} size="sm" variant="secondary" onClick={nextPage} disabled={!canMoveForward}>
<Icon name="angle-right" />
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,108 @@
import { groupBy } from 'lodash';
import { useEffect, useMemo, useRef } from 'react';
import { Icon, Stack, Text } from '@grafana/ui';
import { DataSourceRuleGroupIdentifier, DataSourceRulesSourceIdentifier, RuleGroup } from 'app/types/unified-alerting';
import { hashRule } from '../utils/rule-id';
import { DataSourceRuleLoader } from './DataSourceRuleLoader';
import { DataSourceSection, DataSourceSectionProps } from './components/DataSourceSection';
import { LazyPagination } from './components/LazyPagination';
import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { RuleGroupActionsMenu } from './components/RuleGroupActionsMenu';
import { usePrometheusGroupsGenerator } from './hooks/prometheusGroupsGenerator';
import { usePaginatedPrometheusGroups } from './hooks/usePaginatedPrometheusGroups';
const DATA_SOURCE_GROUP_PAGE_SIZE = 40;
interface PaginatedDataSourceLoaderProps extends Required<Pick<DataSourceSectionProps, 'application'>> {
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
}
export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }: PaginatedDataSourceLoaderProps) {
const { uid, name } = rulesSourceIdentifier;
const prometheusGroupsGenerator = usePrometheusGroupsGenerator();
const groupsGenerator = useRef(prometheusGroupsGenerator(rulesSourceIdentifier, DATA_SOURCE_GROUP_PAGE_SIZE));
useEffect(() => {
const currentGenerator = groupsGenerator.current;
return () => {
currentGenerator.return();
};
}, [groupsGenerator]);
const {
page: groupsPage,
nextPage,
previousPage,
canMoveForward,
canMoveBackward,
isLoading,
} = usePaginatedPrometheusGroups(groupsGenerator.current, DATA_SOURCE_GROUP_PAGE_SIZE);
const groupsByNamespace = useMemo(() => groupBy(groupsPage, 'file'), [groupsPage]);
return (
<DataSourceSection name={name} application={application} uid={uid} isLoading={isLoading}>
<Stack direction="column" gap={1}>
{Object.entries(groupsByNamespace).map(([namespace, groups]) => (
<ListSection
key={namespace}
title={
<Stack direction="row" gap={1} alignItems="center">
<Icon name="folder" />{' '}
<Text variant="body" element="h3">
{namespace}
</Text>
</Stack>
}
>
{groups.map((group) => (
<RuleGroupListItem
key={`${rulesSourceIdentifier.uid}-${namespace}-${group.name}`}
group={group}
rulesSourceIdentifier={rulesSourceIdentifier}
namespaceName={namespace}
/>
))}
</ListSection>
))}
<LazyPagination
nextPage={nextPage}
previousPage={previousPage}
canMoveForward={canMoveForward}
canMoveBackward={canMoveBackward}
/>
</Stack>
</DataSourceSection>
);
}
interface RuleGroupListItemProps {
group: RuleGroup;
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
namespaceName: string;
}
function RuleGroupListItem({ rulesSourceIdentifier, group, namespaceName }: RuleGroupListItemProps) {
const rulesWithGroupId = useMemo(() => {
return group.rules.map((rule) => {
const groupIdentifier: DataSourceRuleGroupIdentifier = {
rulesSource: rulesSourceIdentifier,
namespace: { name: namespaceName },
groupName: group.name,
groupOrigin: 'datasource',
};
return { rule, groupIdentifier };
});
}, [group, namespaceName, rulesSourceIdentifier]);
return (
<ListGroup key={group.name} name={group.name} isOpen={false} actions={<RuleGroupActionsMenu />}>
{rulesWithGroupId.map(({ rule, groupIdentifier }) => (
<DataSourceRuleLoader key={hashRule(rule)} rule={rule} groupIdentifier={groupIdentifier} />
))}
</ListGroup>
);
}

View File

@@ -0,0 +1,93 @@
import { groupBy } from 'lodash';
import { useEffect, useMemo, useRef } from 'react';
import { Icon, Stack, Text } from '@grafana/ui';
import { GrafanaRulesSourceSymbol } from 'app/types/unified-alerting';
import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { GrafanaRuleLoader } from './GrafanaRuleLoader';
import { DataSourceSection } from './components/DataSourceSection';
import { LazyPagination } from './components/LazyPagination';
import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { RuleGroupActionsMenu } from './components/RuleGroupActionsMenu';
import { useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator';
import { usePaginatedPrometheusGroups } from './hooks/usePaginatedPrometheusGroups';
const GRAFANA_GROUP_PAGE_SIZE = 40;
export function PaginatedGrafanaLoader() {
const grafanaGroupsGenerator = useGrafanaGroupsGenerator();
const groupsGenerator = useRef(grafanaGroupsGenerator(GRAFANA_GROUP_PAGE_SIZE));
useEffect(() => {
const currentGenerator = groupsGenerator.current;
return () => {
currentGenerator.return();
};
}, []);
const {
page: groupsPage,
nextPage,
previousPage,
canMoveForward,
canMoveBackward,
isLoading,
} = usePaginatedPrometheusGroups(groupsGenerator.current, GRAFANA_GROUP_PAGE_SIZE);
const groupsByFolder = useMemo(() => groupBy(groupsPage, 'folderUid'), [groupsPage]);
return (
<DataSourceSection name="Grafana" application="grafana" uid={GrafanaRulesSourceSymbol} isLoading={isLoading}>
<Stack direction="column" gap={1}>
{Object.entries(groupsByFolder).map(([folderUid, groups]) => {
// Groups are grouped by folder, so we can use the first group to get the folder name
const folderName = groups[0].file;
return (
<ListSection
key={folderUid}
title={
<Stack direction="row" gap={1} alignItems="center">
<Icon name="folder" />{' '}
<Text variant="body" element="h3">
{folderName}
</Text>
</Stack>
}
>
{groups.map((group) => (
<GrafanaRuleGroupListItem
key={`grafana-ns-${folderUid}-${group.name}`}
group={group}
namespaceName={folderName}
/>
))}
</ListSection>
);
})}
<LazyPagination
nextPage={nextPage}
previousPage={previousPage}
canMoveForward={canMoveForward}
canMoveBackward={canMoveBackward}
/>
</Stack>
</DataSourceSection>
);
}
interface GrafanaRuleGroupListItemProps {
group: GrafanaPromRuleGroupDTO;
namespaceName: string;
}
export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGroupListItemProps) {
return (
<ListGroup key={group.name} name={group.name} isOpen={false} actions={<RuleGroupActionsMenu />}>
{group.rules.map((rule) => {
return <GrafanaRuleLoader key={rule.uid} rule={rule} groupName={group.name} namespaceName={namespaceName} />;
})}
</ListGroup>
);
}

View File

@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import pluralize from 'pluralize';
import { ReactNode } from 'react';
import { ReactNode, useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Icon, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui';
@@ -266,15 +266,17 @@ interface UnknownRuleListItemProps {
export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListItemProps) => {
const styles = useStyles2(getStyles);
const { rulesSource, namespace, groupName } = groupIdentifier;
const ruleContext = {
name: rule.name,
groupName,
namespace: JSON.stringify(namespace),
rulesSource: rulesSource.uid === GrafanaRulesSourceSymbol ? GRAFANA_RULES_SOURCE_NAME : rulesSource.uid,
};
logError(new Error('unknown rule type'), ruleContext);
useEffect(() => {
const { rulesSource, namespace, groupName } = groupIdentifier;
const ruleContext = {
name: rule.name,
groupName,
namespace: JSON.stringify(namespace),
rulesSource: rulesSource.uid === GrafanaRulesSourceSymbol ? GRAFANA_RULES_SOURCE_NAME : rulesSource.uid,
};
logError(new Error('unknown rule type'), ruleContext);
}, [rule, groupIdentifier]);
return (
<Alert title={'Unknown rule type'} className={styles.resetMargin}>

View File

@@ -0,0 +1,47 @@
import { Alert, ErrorBoundary, ErrorWithStack, Text } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { RulesSourceIdentifier } from 'app/types/unified-alerting';
import { DataSourceSection } from './DataSourceSection';
/**
* Some more exotic Prometheus data sources might not be 100% compatible with Prometheus API
* We don't want them to break the whole page, so we wrap them in an error boundary
*/
export function DataSourceErrorBoundary({
children,
rulesSourceIdentifier,
}: {
children: React.ReactNode;
rulesSourceIdentifier: RulesSourceIdentifier;
}) {
return (
<ErrorBoundary>
{({ error, errorInfo }) => {
if (error || errorInfo) {
const { uid, name } = rulesSourceIdentifier;
return (
<DataSourceSection uid={uid} name={name}>
<Alert
title={t('alerting.rule-list.ds-error-boundary.title', 'Unable to load rules from this data source')}
>
<Text>
<Trans i18nKey="alerting.rule-list.ds-error-boundary.description">
Check the data source configuration. Does the data source support Prometheus API?
</Trans>
</Text>
<ErrorWithStack
error={error}
errorInfo={errorInfo}
title={t('alerting.rule-list.ds-error-boundary.title', 'Unable to load rules from this data source')}
/>
</Alert>
</DataSourceSection>
);
}
return children;
}}
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,94 @@
import { css } from '@emotion/css';
import { PropsWithChildren, ReactNode } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { LinkButton, Stack, Text, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { RulesSourceIdentifier } from 'app/types/unified-alerting';
import { RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { Spacer } from '../../components/Spacer';
import { WithReturnButton } from '../../components/WithReturnButton';
import { DataSourceIcon } from './Namespace';
import { LoadingIndicator } from './RuleGroup';
export interface DataSourceSectionProps extends PropsWithChildren {
uid: RulesSourceIdentifier['uid'];
name: string;
loader?: ReactNode;
application?: RulesSourceApplication;
isLoading?: boolean;
description?: ReactNode;
}
export const DataSourceSection = ({
uid,
name,
application,
children,
loader,
isLoading = false,
description = null,
}: DataSourceSectionProps) => {
const styles = useStyles2(getStyles);
return (
<section aria-labelledby={`datasource-${String(uid)}-heading`} role="listitem">
<Stack direction="column" gap={1}>
<Stack direction="column" gap={0}>
{isLoading && <LoadingIndicator datasourceUid={String(uid)} />}
<div className={styles.dataSourceSectionTitle}>
{loader ?? (
<Stack alignItems="center">
{application && <DataSourceIcon application={application} />}
<Text variant="body" weight="bold" element="h2" id={`datasource-${String(uid)}-heading`}>
{name}
</Text>
{description && (
<>
{'·'}
{description}
</>
)}
<Spacer />
<WithReturnButton
title={t('alerting.rule-list.return-button.title', 'Alert rules')}
component={
<LinkButton variant="secondary" size="sm" href={`/connections/datasources/edit/${String(uid)}`}>
<Trans i18nKey="alerting.rule-list.configure-datasource">Configure</Trans>
</LinkButton>
}
/>
</Stack>
)}
</div>
</Stack>
<div className={styles.itemsWrapper}>{children}</div>
</Stack>
</section>
);
};
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,
}),
});

View File

@@ -0,0 +1,34 @@
import { Button, Icon, Stack } from '@grafana/ui';
import { t } from 'app/core/internationalization';
interface LazyPaginationProps {
canMoveForward: boolean;
canMoveBackward: boolean;
nextPage: () => void;
previousPage: () => void;
}
export function LazyPagination({ canMoveForward, canMoveBackward, nextPage, previousPage }: LazyPaginationProps) {
return (
<Stack direction="row" gap={1}>
<Button
aria-label={t('alerting.rule-list.pagination.previous-page', 'previous page')}
size="sm"
variant="secondary"
onClick={previousPage}
disabled={!canMoveBackward}
>
<Icon name="angle-left" />
</Button>
<Button
aria-label={t('alerting.rule-list.pagination.next-page', 'next page')}
size="sm"
variant="secondary"
onClick={nextPage}
disabled={!canMoveForward}
>
<Icon name="angle-right" />
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,22 @@
import { Dropdown, IconButton, Menu } from '@grafana/ui';
import { t } from 'app/core/internationalization';
export function RuleGroupActionsMenu() {
return (
<>
<Dropdown
overlay={
<Menu>
<Menu.Item label={t('alerting.group-actions.edit', 'Edit')} icon="pen" data-testid="edit-group-action" />
<Menu.Item label={t('alerting.group-actions.reorder', 'Re-order rules')} icon="flip" />
<Menu.Divider />
<Menu.Item label={t('alerting.group-actions.export', 'Export')} icon="download-alt" />
<Menu.Item label={t('alerting.group-actions.delete', 'Delete')} icon="trash-alt" destructive />
</Menu>
}
>
<IconButton name="ellipsis-h" aria-label={t('alerting.group-actions.actions-trigger', 'Rule group actions')} />
</Dropdown>
</>
);
}

View File

@@ -0,0 +1,81 @@
import { BaseQueryFn } from '@reduxjs/toolkit/query';
import { TypedLazyQueryTrigger } from '@reduxjs/toolkit/query/react';
import { useCallback } from 'react';
import { DataSourceRulesSourceIdentifier } from 'app/types/unified-alerting';
import { BaseQueryFnArgs } from '../../api/alertingApi';
import { PromRulesResponse, prometheusApi } from '../../api/prometheusApi';
const { useLazyGetGroupsQuery, useLazyGetGrafanaGroupsQuery } = prometheusApi;
interface FetchGroupsOptions {
groupLimit?: number;
groupNextToken?: string;
}
export function usePrometheusGroupsGenerator() {
const [getGroups] = useLazyGetGroupsQuery();
return useCallback(
async function* (ruleSource: DataSourceRulesSourceIdentifier, groupLimit: number) {
const getRuleSourceGroups = (options: FetchGroupsOptions) =>
getGroups({ ruleSource: { uid: ruleSource.uid }, ...options });
yield* genericGroupsGenerator(getRuleSourceGroups, groupLimit);
},
[getGroups]
);
}
export function useGrafanaGroupsGenerator() {
const [getGrafanaGroups] = useLazyGetGrafanaGroupsQuery();
return useCallback(
async function* (groupLimit: number) {
yield* genericGroupsGenerator(getGrafanaGroups, groupLimit);
},
[getGrafanaGroups]
);
}
// Generator lazily provides groups one by one only when needed
// This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources
// For unpaginated data sources we fetch everything in one go
// For paginated we fetch the next page when needed
async function* genericGroupsGenerator<TGroup>(
fetchGroups: TypedLazyQueryTrigger<PromRulesResponse<TGroup>, FetchGroupsOptions, BaseQueryFn<BaseQueryFnArgs>>,
groupLimit: number
) {
const response = await fetchGroups({ groupLimit });
if (!response.isSuccess) {
return;
}
if (response.data?.data) {
yield* response.data.data.groups;
}
let lastToken: string | undefined = undefined;
if (response.data?.data?.groupNextToken) {
lastToken = response.data.data.groupNextToken;
}
while (lastToken) {
const response = await fetchGroups({
groupNextToken: lastToken,
groupLimit: groupLimit,
});
if (!response.isSuccess) {
return;
}
if (response.data?.data) {
yield* response.data.data.groups;
}
lastToken = response.data?.data?.groupNextToken;
}
}

View File

@@ -1,97 +1,95 @@
import { AsyncIterableX, from } from 'ix/asynciterable/index';
import { merge } from 'ix/asynciterable/merge';
import { filter, flatMap, map } from 'ix/asynciterable/operators';
import { compact } from 'lodash';
import { useCallback } from 'react';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
import { DataSourceRuleGroupIdentifier, ExternalRulesSourceIdentifier } from 'app/types/unified-alerting';
import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import {
DataSourceRuleGroupIdentifier,
DataSourceRulesSourceIdentifier,
GrafanaRuleGroupIdentifier,
} from 'app/types/unified-alerting';
import {
GrafanaPromRuleDTO,
GrafanaPromRuleGroupDTO,
PromRuleDTO,
PromRuleGroupDTO,
} from 'app/types/unified-alerting-dto';
import { prometheusApi } from '../../api/prometheusApi';
import { RulesFilter } from '../../search/rulesSearchParser';
import { labelsMatchMatchers } from '../../utils/alertmanager';
import { Annotation } from '../../utils/constants';
import { getDatasourceAPIUid, getExternalRulesSources } from '../../utils/datasource';
import { GrafanaRulesSource, getDatasourceAPIUid, getExternalRulesSources } from '../../utils/datasource';
import { parseMatcher } from '../../utils/matchers';
import { isAlertingRule } from '../../utils/rules';
export interface RuleWithOrigin {
rule: PromRuleDTO;
groupIdentifier: DataSourceRuleGroupIdentifier;
import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator';
export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin;
export interface GrafanaRuleWithOrigin {
rule: GrafanaPromRuleDTO;
groupIdentifier: GrafanaRuleGroupIdentifier;
/**
* The name of the namespace that contains the rule group
* groupIdentifier contains the uid of the namespace, but not the user-friendly display name
*/
namespaceName: string;
origin: 'grafana';
}
const { useLazyGroupsQuery } = prometheusApi;
export interface PromRuleWithOrigin {
rule: PromRuleDTO;
groupIdentifier: DataSourceRuleGroupIdentifier;
origin: 'datasource';
}
export function useFilteredRulesIteratorProvider() {
const [fetchGroups] = useLazyGroupsQuery();
const allExternalRulesSources = getExternalRulesSources();
/**
* This async generator will continue to yield rule groups and will keep fetching backend pages as long as the consumer
* is iterating.
*/
const fetchRuleSourceGroups = useCallback(
async function* (ruleSource: ExternalRulesSourceIdentifier, maxGroups: number) {
const response = await fetchGroups({ ruleSource: { uid: ruleSource.uid }, groupLimit: maxGroups });
const prometheusGroupsGenerator = usePrometheusGroupsGenerator();
const grafanaGroupsGenerator = useGrafanaGroupsGenerator();
if (!response.isSuccess) {
return;
}
const getFilteredRulesIterator = (filterState: RulesFilter, groupLimit: number): AsyncIterableX<RuleWithOrigin> => {
const normalizedFilterState = normalizeFilterState(filterState);
if (response.data?.data) {
yield* response.data.data.groups.map((group) => [ruleSource, group] as const);
}
let lastToken: string | undefined = undefined;
if (response.data?.data?.groupNextToken) {
lastToken = response.data.data.groupNextToken;
}
while (lastToken) {
const response = await fetchGroups({
ruleSource: { uid: ruleSource.uid },
groupNextToken: lastToken,
groupLimit: maxGroups,
});
if (!response.isSuccess) {
return;
}
if (response.data?.data) {
yield* response.data.data.groups.map((group) => [ruleSource, group] as const);
}
lastToken = response.data?.data?.groupNextToken;
}
},
[fetchGroups]
);
const getFilteredRulesIterator = (filterState: RulesFilter, groupLimit: number) => {
const ruleSourcesToFetchFrom = filterState.dataSourceNames.length
? filterState.dataSourceNames.map((ds) => ({ name: ds, uid: getDatasourceAPIUid(ds) }))
? filterState.dataSourceNames.map<DataSourceRulesSourceIdentifier>((ds) => ({
name: ds,
uid: getDatasourceAPIUid(ds),
ruleSourceType: 'datasource',
}))
: allExternalRulesSources;
// This split into the first one and the rest is only for compatibility with the merge function from ix
const [source, ...iterables] = ruleSourcesToFetchFrom.map((ds) => fetchRuleSourceGroups(ds, groupLimit));
const grafanaIterator = from(grafanaGroupsGenerator(groupLimit)).pipe(
filter((group) => groupFilter(group, normalizedFilterState)),
flatMap((group) => group.rules.map((rule) => [group, rule] as const)),
filter(([_, rule]) => ruleFilter(rule, normalizedFilterState)),
map(([group, rule]) => mapGrafanaRuleToRuleWithOrigin(group, rule))
);
return merge(source, ...iterables).pipe(
filter(([_, group]) => groupFilter(group, filterState)),
const [source, ...iterables] = ruleSourcesToFetchFrom.map((ds) => {
return from(prometheusGroupsGenerator(ds, groupLimit)).pipe(map((group) => [ds, group] as const));
});
const dataSourcesIterator = merge(source, ...iterables).pipe(
filter(([_, group]) => groupFilter(group, normalizedFilterState)),
flatMap(([rulesSource, group]) => group.rules.map((rule) => [rulesSource, group, rule] as const)),
filter(([_, __, rule]) => ruleFilter(rule, filterState)),
map(([rulesSource, group, rule]) => mapRuleToRuleWithOrigin(rulesSource, group, rule))
);
return merge(grafanaIterator, dataSourcesIterator);
};
return { getFilteredRulesIterator };
}
function mapRuleToRuleWithOrigin(
rulesSource: ExternalRulesSourceIdentifier,
rulesSource: DataSourceRulesSourceIdentifier,
group: PromRuleGroupDTO,
rule: PromRuleDTO
): RuleWithOrigin {
): PromRuleWithOrigin {
return {
rule,
groupIdentifier: {
@@ -100,6 +98,24 @@ function mapRuleToRuleWithOrigin(
groupName: group.name,
groupOrigin: 'datasource',
},
origin: 'datasource',
};
}
function mapGrafanaRuleToRuleWithOrigin(
group: GrafanaPromRuleGroupDTO,
rule: GrafanaPromRuleDTO
): GrafanaRuleWithOrigin {
return {
rule,
groupIdentifier: {
rulesSource: GrafanaRulesSource,
namespace: { uid: group.folderUid },
groupName: group.name,
groupOrigin: 'grafana',
},
namespaceName: group.file,
origin: 'grafana',
};
}
@@ -111,11 +127,11 @@ function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean
const { name, file } = group;
// TODO Add fuzzy filtering or not
if (filterState.namespace && !file.includes(filterState.namespace)) {
if (filterState.namespace && !file.toLowerCase().includes(filterState.namespace)) {
return false;
}
if (filterState.groupName && !name.includes(filterState.groupName)) {
if (filterState.groupName && !name.toLowerCase().includes(filterState.groupName)) {
return false;
}
@@ -125,11 +141,13 @@ function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean
function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) {
const { name, labels = {}, health, type } = rule;
if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => name.includes(word))) {
const nameLower = name.toLowerCase();
if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => nameLower.includes(word))) {
return false;
}
if (filterState.ruleName && !name.includes(filterState.ruleName)) {
if (filterState.ruleName && !nameLower.includes(filterState.ruleName)) {
return false;
}
@@ -165,6 +183,19 @@ function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) {
return true;
}
/**
* Lowercase free form words, rule name, group name and namespace
*/
function normalizeFilterState(filterState: RulesFilter): RulesFilter {
return {
...filterState,
freeFormWords: filterState.freeFormWords.map((word) => word.toLowerCase()),
ruleName: filterState.ruleName?.toLowerCase(),
groupName: filterState.groupName?.toLowerCase(),
namespace: filterState.namespace?.toLowerCase(),
};
}
function looseParseMatcher(matcherQuery: string): Matcher | undefined {
try {
return parseMatcher(matcherQuery);

View File

@@ -0,0 +1,77 @@
import { useCallback, useMemo, useState } from 'react';
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { isLoading, 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 Pagination state and controls for navigating through rule groups
*/
export function usePaginatedPrometheusGroups<TGroup extends PromRuleGroupDTO>(
groupsGenerator: AsyncGenerator<TGroup, void, unknown>,
pageSize: number
) {
const [currentPage, setCurrentPage] = useState(1);
const [groups, setGroups] = useState<TGroup[]>([]);
const [lastPage, setLastPage] = useState<number | undefined>(undefined);
const [{ execute: fetchMoreGroups }, groupsRequestState] = useAsync(async (groupsCount: number) => {
let done = false;
const currentGroups: TGroup[] = [];
while (currentGroups.length < groupsCount) {
const generatorResult = await groupsGenerator.next();
if (generatorResult.done) {
done = true;
break;
}
const group = generatorResult.value;
currentGroups.push(group);
}
if (done) {
const groupsTotal = groups.length + currentGroups.length;
setLastPage(Math.ceil(groupsTotal / pageSize));
}
setGroups((groups) => [...groups, ...currentGroups]);
});
// lastPage could be computed from groups.length and pageSize
const fetchInProgress = isLoading(groupsRequestState);
const canMoveForward = !fetchInProgress && (!lastPage || currentPage < lastPage);
// When going backward we already have the groups loaded, so no need to check if fetchInProgress
const canMoveBackward = currentPage > 1;
const nextPage = useCallback(async () => {
if (canMoveForward) {
setCurrentPage((page) => page + 1);
}
}, [canMoveForward]);
const previousPage = useCallback(async () => {
if (canMoveBackward) {
setCurrentPage((page) => page - 1);
}
}, [canMoveBackward]);
// groups.length - pageSize to have one more page loaded to prevent flickering with loading state
// lastPage === undefined because 0 is falsy but a value which should stop fetching (e.g for broken data sources)
const shouldFetchNextPage = groups.length - pageSize < pageSize * currentPage && lastPage === undefined;
if (shouldFetchNextPage && !fetchInProgress) {
fetchMoreGroups(pageSize);
}
const groupsPage = useMemo(() => {
return groups.slice((currentPage - 1) * pageSize, currentPage * pageSize);
}, [groups, currentPage, pageSize]);
return { isLoading: fetchInProgress, page: groupsPage, nextPage, previousPage, canMoveForward, canMoveBackward };
}

View File

@@ -1,146 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { usePrevious } from 'react-use';
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { groupRulesByFileName } from '../../api/prometheus';
import { prometheusApi } from '../../api/prometheusApi';
import { isLoading, useAsync } from '../../hooks/useAsync';
import { getDatasourceAPIUid } from '../../utils/datasource';
const { useLazyGroupsQuery } = prometheusApi;
export function usePaginatedPrometheusRuleNamespaces(ruleSourceName: string, pageSize: number) {
const [currentPage, setCurrentPage] = useState(1);
const [groups, setGroups] = useState<PromRuleGroupDTO[]>([]);
const [lastPage, setLastPage] = useState<number | undefined>(undefined);
const { groupsGenerator } = usePrometheusGroupsGenerator(ruleSourceName, pageSize);
const [{ execute: fetchMoreGroups }, groupsRequestState] = useAsync(async (groupsCount: number) => {
let done = false;
const currentGroups: PromRuleGroupDTO[] = [];
while (currentGroups.length < groupsCount) {
const group = await groupsGenerator.next();
if (group.done) {
done = true;
break;
}
currentGroups.push(group.value);
}
if (done) {
const groupsTotal = groups.length + currentGroups.length;
setLastPage(Math.ceil(groupsTotal / pageSize));
}
setGroups((groups) => [...groups, ...currentGroups]);
});
const fetchInProgress = isLoading(groupsRequestState);
const canMoveForward = !fetchInProgress && (!lastPage || currentPage < lastPage);
const canMoveBackward = currentPage > 1 && !fetchInProgress;
const nextPage = useCallback(async () => {
if (canMoveForward) {
setCurrentPage((page) => page + 1);
}
}, [canMoveForward]);
const previousPage = useCallback(async () => {
if (canMoveBackward) {
setCurrentPage((page) => page - 1);
}
}, [canMoveBackward]);
// groups.length - pageSize to have one more page loaded to prevent flickering with loading state
// lastPage === undefined because 0 is falsy but a value which should stop fetching (e.g for broken data sources)
const shouldFetchNextPage = groups.length - pageSize < pageSize * currentPage && lastPage === undefined;
if (shouldFetchNextPage && !fetchInProgress) {
fetchMoreGroups(pageSize);
}
const pageNamespaces = useMemo(() => {
const pageGroups = groups.slice((currentPage - 1) * pageSize, currentPage * pageSize);
// groupRulesByFileName mutates the array and RTKQ query freezes the response data
return groupRulesByFileName(structuredClone(pageGroups), ruleSourceName);
}, [groups, ruleSourceName, currentPage, pageSize]);
return { isLoading: fetchInProgress, page: pageNamespaces, nextPage, previousPage, canMoveForward, canMoveBackward };
}
function usePrometheusGroupsGenerator(ruleSourceName: string, pageSize: number) {
const [fetchGroups, { isLoading }] = useLazyGroupsQuery();
const prevRuleSourceName = usePrevious(ruleSourceName);
// Generator lazily provides groups one by one only when needed
// This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources
// For unpaginated data sources we just fetch everything in one go
// For paginated we fetch the next page when needed
const getGroups = useCallback(
async function* (ruleSourceName: string, maxGroups: number) {
const ruleSourceUid = getDatasourceAPIUid(ruleSourceName);
const response = await fetchGroups({
ruleSource: { uid: ruleSourceUid },
groupLimit: maxGroups,
});
if (!response.isSuccess) {
return;
}
if (response.data?.data) {
yield* response.data.data.groups;
}
let lastToken: string | undefined = undefined;
if (response.data?.data?.groupNextToken) {
lastToken = response.data.data.groupNextToken;
}
while (lastToken) {
const response = await fetchGroups({
ruleSource: { uid: ruleSourceUid },
groupNextToken: lastToken,
groupLimit: maxGroups,
});
if (!response.isSuccess) {
return;
}
if (response.data?.data) {
yield* response.data.data.groups;
}
lastToken = response.data?.data?.groupNextToken;
}
},
[fetchGroups]
);
const [groupsGenerator, setGroupsGenerator] = useState<AsyncGenerator<PromRuleGroupDTO, void, unknown>>(
getGroups(ruleSourceName, pageSize)
);
const resetGenerator = useCallback(() => {
setGroupsGenerator(getGroups(ruleSourceName, pageSize));
}, [ruleSourceName, getGroups, pageSize]);
if (prevRuleSourceName && prevRuleSourceName !== ruleSourceName) {
resetGenerator();
}
useEffect(() => {
const currentGenerator = groupsGenerator;
return () => {
currentGenerator.return();
};
}, [groupsGenerator]);
return { groupsGenerator, isLoading };
}

View File

@@ -10,7 +10,8 @@ import {
} from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import {
ExternalRulesSourceIdentifier,
DataSourceRulesSourceIdentifier as DataSourceRulesSourceIdentifier,
GrafanaRulesSourceIdentifier,
GrafanaRulesSourceSymbol,
RulesSource,
RulesSourceUid,
@@ -28,6 +29,12 @@ import { getAllDataSources } from './config';
export const GRAFANA_RULES_SOURCE_NAME = 'grafana';
export const GRAFANA_DATASOURCE_NAME = '-- Grafana --';
export const GrafanaRulesSource: GrafanaRulesSourceIdentifier = {
uid: GrafanaRulesSourceSymbol,
name: GRAFANA_RULES_SOURCE_NAME,
ruleSourceType: 'grafana',
};
export enum DataSourceType {
Alertmanager = 'alertmanager',
Loki = 'loki',
@@ -214,10 +221,11 @@ export function getAllRulesSourceNames(): string[] {
return availableRulesSources;
}
export function getExternalRulesSources(): ExternalRulesSourceIdentifier[] {
export function getExternalRulesSources(): DataSourceRulesSourceIdentifier[] {
return getRulesDataSources().map((ds) => ({
name: ds.name,
uid: ds.uid,
ruleSourceType: 'datasource',
}));
}

View File

@@ -6,7 +6,7 @@ import { isGrafanaRulerRule } from './rules';
function fromCombinedRule(rule: CombinedRule): RuleGroupIdentifierV2 {
if (isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulesSource(rule.namespace.rulesSource)) {
return {
rulesSource: { uid: GrafanaRulesSourceSymbol, name: GRAFANA_RULES_SOURCE_NAME },
rulesSource: { uid: GrafanaRulesSourceSymbol, name: GRAFANA_RULES_SOURCE_NAME, ruleSourceType: 'grafana' },
namespace: { uid: rule.rulerRule.grafana_alert.namespace_uid },
groupName: rule.group.name,
groupOrigin: 'grafana',
@@ -16,7 +16,7 @@ function fromCombinedRule(rule: CombinedRule): RuleGroupIdentifierV2 {
const rulesSourceName = getRulesSourceName(rule.namespace.rulesSource);
const rulesSourceUid = getDatasourceAPIUid(rulesSourceName);
return {
rulesSource: { uid: rulesSourceUid, name: rulesSourceName },
rulesSource: { uid: rulesSourceUid, name: rulesSourceName, ruleSourceType: 'datasource' },
namespace: { name: rule.namespace.name },
groupName: rule.group.name,
groupOrigin: 'datasource',

View File

@@ -149,16 +149,31 @@ export interface PromRecordingRuleDTO extends PromRuleDTOBase {
export type PromRuleDTO = PromAlertingRuleDTO | PromRecordingRuleDTO;
export interface PromRuleGroupDTO {
export interface PromRuleGroupDTO<TRule = PromRuleDTO> {
name: string;
file: string;
rules: PromRuleDTO[];
rules: TRule[];
interval: number;
evaluationTime?: number; // these 2 are not in older prometheus payloads
lastEvaluation?: string;
}
export interface GrafanaPromAlertingRuleDTO extends PromAlertingRuleDTO {
uid: string;
folderUid: string;
}
export interface GrafanaPromRecordingRuleDTO extends PromRecordingRuleDTO {
uid: string;
folderUid: string;
}
export type GrafanaPromRuleDTO = GrafanaPromAlertingRuleDTO | GrafanaPromRecordingRuleDTO;
export interface GrafanaPromRuleGroupDTO extends PromRuleGroupDTO<GrafanaPromRuleDTO> {
folderUid: string;
}
export interface PromResponse<T> {
status: 'success' | 'error' | ''; // mocks return empty string
data: T;

View File

@@ -2,7 +2,6 @@
import { AlertState, DataSourceInstanceSettings } from '@grafana/data';
import { PromOptions } from '@grafana/prometheus';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { LokiOptions } from 'app/plugins/datasource/loki/types';
import {
@@ -71,6 +70,18 @@ export interface RecordingRule extends RuleBase {
export type Rule = AlertingRule | RecordingRule;
export interface GrafanaAlertingRule extends AlertingRule {
uid: string;
folderUid: string;
}
export interface GrafanaRecordingRule extends RecordingRule {
uid: string;
folderUid: string;
}
export type GrafanaRule = GrafanaAlertingRule | GrafanaRecordingRule;
export type BaseRuleGroup = { name: string };
type TotalsWithoutAlerting = Exclude<AlertInstanceTotalState, AlertInstanceTotalState.Alerting>;
@@ -85,6 +96,18 @@ export interface RuleGroup {
totals?: Partial<Record<TotalsWithoutAlerting | FiringTotal, number>>;
}
export interface DataSourceRuleGroup {
id: DataSourceRuleGroupIdentifier;
interval: number;
rules: Rule[];
}
export interface DataSourceRuleNamespace {
rulesSource: DataSourceRulesSourceIdentifier;
id: DataSourceNamespaceIdentifier;
groups: DataSourceRuleGroup[];
}
export interface RuleNamespace {
dataSourceName: string;
name: string;
@@ -154,16 +177,20 @@ export interface RuleWithLocation<T = RulerRuleDTO> {
export const GrafanaRulesSourceSymbol = Symbol('grafana');
export type RulesSourceUid = string | typeof GrafanaRulesSourceSymbol;
export interface ExternalRulesSourceIdentifier {
export interface DataSourceRulesSourceIdentifier {
uid: string;
name: string;
// discriminator
ruleSourceType: 'datasource';
}
export interface GrafanaRulesSourceIdentifier {
uid: typeof GrafanaRulesSourceSymbol;
name: typeof GRAFANA_RULES_SOURCE_NAME;
name: 'grafana';
// discriminator
ruleSourceType: 'grafana';
}
export type RulesSourceIdentifier = ExternalRulesSourceIdentifier | GrafanaRulesSourceIdentifier;
export type RulesSourceIdentifier = DataSourceRulesSourceIdentifier | GrafanaRulesSourceIdentifier;
/** @deprecated use RuleGroupIdentifierV2 instead */
export interface RuleGroupIdentifier {
@@ -189,7 +216,7 @@ export interface GrafanaRuleGroupIdentifier {
}
export interface DataSourceRuleGroupIdentifier {
rulesSource: ExternalRulesSourceIdentifier;
rulesSource: DataSourceRulesSourceIdentifier;
groupName: string;
namespace: DataSourceNamespaceIdentifier;
groupOrigin: 'datasource';

View File

@@ -299,6 +299,13 @@
}
}
},
"group-actions": {
"actions-trigger": "Rule group actions",
"delete": "Delete",
"edit": "Edit",
"export": "Export",
"reorder": "Re-order rules"
},
"list-view": {
"empty": {
"new-alert-rule": "New alert rule",
@@ -484,11 +491,22 @@
},
"rule-list": {
"configure-datasource": "Configure",
"ds-error-boundary": {
"description": "Check the data source configuration. Does the data source support Prometheus API?",
"title": "Unable to load rules from this data source"
},
"filter-view": {
"no-more-results": "No more results showing {{numberOfRules}} rules",
"no-rules-found": "No alert or recording rules matched your current set of filters."
},
"new-alert-rule": "New alert rule"
"new-alert-rule": "New alert rule",
"pagination": {
"next-page": "next page",
"previous-page": "previous page"
},
"return-button": {
"title": "Alert rules"
}
},
"rule-state": {
"creating": "Creating",

View File

@@ -299,6 +299,13 @@
}
}
},
"group-actions": {
"actions-trigger": "Ŗūľę ģřőūp äčŧįőʼnş",
"delete": "Đęľęŧę",
"edit": "Ēđįŧ",
"export": "Ēχpőřŧ",
"reorder": "Ŗę-őřđęř řūľęş"
},
"list-view": {
"empty": {
"new-alert-rule": "Ńęŵ äľęřŧ řūľę",
@@ -484,11 +491,22 @@
},
"rule-list": {
"configure-datasource": "Cőʼnƒįģūřę",
"ds-error-boundary": {
"description": "Cĥęčĸ ŧĥę đäŧä şőūřčę čőʼnƒįģūřäŧįőʼn. Đőęş ŧĥę đäŧä şőūřčę şūppőřŧ Přőmęŧĥęūş ÅPĨ?",
"title": "Ůʼnäþľę ŧő ľőäđ řūľęş ƒřőm ŧĥįş đäŧä şőūřčę"
},
"filter-view": {
"no-more-results": "Ńő mőřę řęşūľŧş şĥőŵįʼnģ {{numberOfRules}} řūľęş",
"no-rules-found": "Ńő äľęřŧ őř řęčőřđįʼnģ řūľęş mäŧčĥęđ yőūř čūřřęʼnŧ şęŧ őƒ ƒįľŧęřş."
},
"new-alert-rule": "Ńęŵ äľęřŧ řūľę"
"new-alert-rule": "Ńęŵ äľęřŧ řūľę",
"pagination": {
"next-page": "ʼnęχŧ päģę",
"previous-page": "přęvįőūş päģę"
},
"return-button": {
"title": "Åľęřŧ řūľęş"
}
},
"rule-state": {
"creating": "Cřęäŧįʼnģ",