mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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"]
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ģ",
|
||||
|
||||
Reference in New Issue
Block a user