From b73ab1587835a38900beb622f0a1edc5e7c3bd40 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Mon, 18 Nov 2024 10:48:15 +0100 Subject: [PATCH] Alerting: New list view (Part 1) (#95039) * initial commit * update styles * wip * update list view * update translations * abstract components * metadata separator * refactor * cleanup * fix tests * WIP * translations * refactor to use maps and type-safety * WIP * UI updates * Rule action buttons early draft * recording rules * WIP typescript errors * implement action button loading * move section loader etc * add placeholder for group actions * Change files structure, remove CombinedRule from AlertRuleMenu * Refactor fetching data sources with ruler * Fix tests * Unify data source features * move files * make actions column wider * update translations * Update tests to reflect code changes * Remove direct buildinfo usages * Fix useCanSilence hook * Add missing translations, fix lint errors * PR feedback * update test * Remove featureDiscovery mock from a test --------- Co-authored-by: Gilles De Mey --- .betterer.results | 15 +- public/app/core/icons/cached.json | 1 + public/app/core/icons/iconBundle.ts | 542 +++++++++--------- .../features/alerting/unified/RuleEditor.tsx | 21 +- .../RuleEditorCloudOnlyAllowed.test.tsx | 54 +- .../unified/RuleEditorExisting.test.tsx | 14 +- .../unified/RuleEditorGrafanaRules.test.tsx | 7 +- .../alerting/unified/RuleList.test.tsx | 18 +- .../alerting/unified/api/alertRuleApi.ts | 24 +- .../alerting/unified/api/buildInfo.test.ts | 2 +- .../alerting/unified/api/buildInfo.ts | 19 +- .../unified/api/featureDiscoveryApi.ts | 77 ++- .../unified/components/MenuItemPauseRule.tsx | 23 +- .../rule-editor/CloudRulesSourcePicker.tsx | 13 +- .../SimplifiedRuleEditor.test.tsx | 8 +- .../QueryAndExpressionsStep.tsx | 13 +- .../rule-editor/rule-types/RuleTypePicker.tsx | 9 +- .../components/rule-viewer/AlertRuleMenu.tsx | 67 ++- .../components/rule-viewer/DeleteModal.tsx | 63 +- .../components/rule-viewer/RuleViewer.tsx | 23 +- .../unified/components/rules/CloudRules.tsx | 8 +- .../rules/ReorderRuleGroupModal.tsx | 31 +- .../rules/RuleActionsButtons.test.tsx | 46 +- .../components/rules/RuleActionsButtons.tsx | 23 +- .../components/rules/RuleListErrors.tsx | 21 +- .../components/rules/RuleListStateView.tsx | 4 +- .../unified/components/rules/RulesGroup.tsx | 10 +- .../components/rules/RulesTable.test.tsx | 34 +- .../unified/components/rules/RulesTable.tsx | 4 +- .../alerting/unified/hooks/useAbilities.ts | 104 +++- .../alerting/unified/hooks/useCombinedRule.ts | 6 +- .../unified/hooks/useFilteredRules.ts | 2 +- .../unified/hooks/useIsRuleEditable.test.tsx | 52 +- .../unified/hooks/useIsRuleEditable.ts | 7 +- .../unified/hooks/useRuleSourcesWithRuler.ts | 33 +- public/app/features/alerting/unified/mocks.ts | 13 +- .../plugins/useRulePluginLinkExtensions.ts | 30 +- .../unified/rule-list/RuleList.v1.tsx | 8 +- .../unified/rule-list/RuleList.v2.tsx | 532 +++++++++++------ .../alerting/unified/rule-list/StateView.tsx | 153 +++++ .../components/AlertRuleListItem.tsx | 53 +- .../rule-list/components/EvaluationGroup.tsx | 11 +- .../components/EvaluationGroupWithRules.tsx | 81 --- .../rule-list/components/ListGroup.tsx | 28 +- .../rule-list/components/ListSection.tsx | 16 +- .../rule-list/components/Namespace.tsx | 14 +- .../components/RuleActionsButtons.V2.tsx | 114 ++++ .../rule-list/components/RuleGroup.tsx | 12 +- .../alerting/unified/state/actions.ts | 155 +---- .../alerting/unified/state/reducers.ts | 6 - .../alerting/unified/utils/datasource.ts | 53 +- .../features/alerting/unified/utils/labels.ts | 8 +- .../features/alerting/unified/utils/misc.ts | 28 +- .../alerting/unified/utils/rules.test.ts | 9 +- .../features/alerting/unified/utils/rules.ts | 12 +- public/app/types/unified-alerting-dto.ts | 4 +- public/locales/en-US/grafana.json | 4 + public/locales/pseudo-LOCALE/grafana.json | 4 + 58 files changed, 1591 insertions(+), 1155 deletions(-) create mode 100644 public/app/features/alerting/unified/rule-list/StateView.tsx delete mode 100644 public/app/features/alerting/unified/rule-list/components/EvaluationGroupWithRules.tsx create mode 100644 public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx diff --git a/.betterer.results b/.betterer.results index a5f9a3f4d3c..2af5d4872d0 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1909,9 +1909,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "5"], [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "8"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "10"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "8"] ], "public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], @@ -2056,17 +2054,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "7"], [0, 0, 0, "Do not use any type assertions.", "8"] ], - "public/app/features/alerting/unified/rule-list/RuleList.v1.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] - ], - "public/app/features/alerting/unified/rule-list/RuleList.v2.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] - ], - "public/app/features/alerting/unified/state/actions.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] - ], "public/app/features/alerting/unified/types/receiver-form.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] diff --git a/public/app/core/icons/cached.json b/public/app/core/icons/cached.json index 9d2af6a7ff5..28cfb2d352c 100644 --- a/public/app/core/icons/cached.json +++ b/public/app/core/icons/cached.json @@ -38,6 +38,7 @@ "unicons/chart-line", "unicons/check", "unicons/check-circle", + "unicons/times-circle", "unicons/circle", "unicons/clipboard-alt", "unicons/clock-nine", diff --git a/public/app/core/icons/iconBundle.ts b/public/app/core/icons/iconBundle.ts index b8ed8ae2cbf..54485041685 100644 --- a/public/app/core/icons/iconBundle.ts +++ b/public/app/core/icons/iconBundle.ts @@ -46,141 +46,142 @@ import u1035 from '../../../img/icons/unicons/channel-add.svg'; import u1036 from '../../../img/icons/unicons/chart-line.svg'; import u1037 from '../../../img/icons/unicons/check.svg'; import u1038 from '../../../img/icons/unicons/check-circle.svg'; -import u1039 from '../../../img/icons/unicons/circle.svg'; -import u1040 from '../../../img/icons/unicons/clipboard-alt.svg'; -import u1041 from '../../../img/icons/unicons/clock-nine.svg'; -import u1042 from '../../../img/icons/unicons/cloud.svg'; -import u1043 from '../../../img/icons/unicons/cloud-download.svg'; -import u1044 from '../../../img/icons/unicons/code-branch.svg'; -import u1045 from '../../../img/icons/unicons/cog.svg'; -import u1046 from '../../../img/icons/unicons/columns.svg'; -import u1047 from '../../../img/icons/unicons/comment-alt.svg'; -import u1048 from '../../../img/icons/unicons/comment-alt-share.svg'; -import u1049 from '../../../img/icons/unicons/comments-alt.svg'; -import u1050 from '../../../img/icons/unicons/compass.svg'; -import u1051 from '../../../img/icons/unicons/copy.svg'; -import u1052 from '../../../img/icons/unicons/corner-down-right-alt.svg'; -import u1053 from '../../../img/icons/unicons/cube.svg'; -import u1054 from '../../../img/icons/unicons/dashboard.svg'; -import u1055 from '../../../img/icons/unicons/database.svg'; -import u1056 from '../../../img/icons/unicons/document-info.svg'; -import u1057 from '../../../img/icons/unicons/download-alt.svg'; -import u1058 from '../../../img/icons/unicons/draggabledots.svg'; -import u1059 from '../../../img/icons/unicons/edit.svg'; -import u1060 from '../../../img/icons/unicons/ellipsis-v.svg'; -import u1061 from '../../../img/icons/unicons/ellipsis-h.svg'; -import u1062 from '../../../img/icons/unicons/envelope.svg'; -import u1063 from '../../../img/icons/unicons/exchange-alt.svg'; -import u1064 from '../../../img/icons/unicons/exclamation-circle.svg'; -import u1065 from '../../../img/icons/unicons/exclamation-triangle.svg'; -import u1066 from '../../../img/icons/unicons/external-link-alt.svg'; -import u1067 from '../../../img/icons/unicons/eye.svg'; -import u1068 from '../../../img/icons/unicons/eye-slash.svg'; -import u1069 from '../../../img/icons/unicons/file-alt.svg'; -import u1070 from '../../../img/icons/unicons/file-blank.svg'; -import u1071 from '../../../img/icons/unicons/filter.svg'; -import u1072 from '../../../img/icons/unicons/folder.svg'; -import u1073 from '../../../img/icons/unicons/folder-open.svg'; -import u1074 from '../../../img/icons/unicons/folder-plus.svg'; -import u1075 from '../../../img/icons/unicons/folder-upload.svg'; -import u1076 from '../../../img/icons/unicons/forward.svg'; -import u1077 from '../../../img/icons/unicons/graph-bar.svg'; -import u1078 from '../../../img/icons/unicons/history.svg'; -import u1079 from '../../../img/icons/unicons/history-alt.svg'; -import u1080 from '../../../img/icons/unicons/home-alt.svg'; -import u1081 from '../../../img/icons/unicons/import.svg'; -import u1082 from '../../../img/icons/unicons/info.svg'; -import u1083 from '../../../img/icons/unicons/info-circle.svg'; -import u1084 from '../../../img/icons/unicons/k6.svg'; -import u1085 from '../../../img/icons/unicons/key-skeleton-alt.svg'; -import u1086 from '../../../img/icons/unicons/keyboard.svg'; -import u1087 from '../../../img/icons/unicons/link.svg'; -import u1088 from '../../../img/icons/unicons/list-ul.svg'; -import u1089 from '../../../img/icons/unicons/lock.svg'; -import u1090 from '../../../img/icons/unicons/minus.svg'; -import u1091 from '../../../img/icons/unicons/minus-circle.svg'; -import u1092 from '../../../img/icons/unicons/mobile-android.svg'; -import u1093 from '../../../img/icons/unicons/monitor.svg'; -import u1094 from '../../../img/icons/unicons/pause.svg'; -import u1095 from '../../../img/icons/unicons/pen.svg'; -import u1096 from '../../../img/icons/unicons/play.svg'; -import u1097 from '../../../img/icons/unicons/plug.svg'; -import u1098 from '../../../img/icons/unicons/plus.svg'; -import u1099 from '../../../img/icons/unicons/plus-circle.svg'; -import u1100 from '../../../img/icons/unicons/power.svg'; -import u1101 from '../../../img/icons/unicons/presentation-play.svg'; -import u1102 from '../../../img/icons/unicons/process.svg'; -import u1103 from '../../../img/icons/unicons/question-circle.svg'; -import u1104 from '../../../img/icons/unicons/repeat.svg'; -import u1105 from '../../../img/icons/unicons/rocket.svg'; -import u1106 from '../../../img/icons/unicons/rss.svg'; -import u1107 from '../../../img/icons/unicons/save.svg'; -import u1108 from '../../../img/icons/unicons/search.svg'; -import u1109 from '../../../img/icons/unicons/search-minus.svg'; -import u1110 from '../../../img/icons/unicons/search-plus.svg'; -import u1111 from '../../../img/icons/unicons/share-alt.svg'; -import u1112 from '../../../img/icons/unicons/shield.svg'; -import u1113 from '../../../img/icons/unicons/signal.svg'; -import u1114 from '../../../img/icons/unicons/signin.svg'; -import u1115 from '../../../img/icons/unicons/signout.svg'; -import u1116 from '../../../img/icons/unicons/sitemap.svg'; -import u1117 from '../../../img/icons/unicons/slack.svg'; -import u1118 from '../../../img/icons/unicons/sliders-v-alt.svg'; -import u1119 from '../../../img/icons/unicons/sort-amount-down.svg'; -import u1120 from '../../../img/icons/unicons/sort-amount-up.svg'; -import u1121 from '../../../img/icons/unicons/square-shape.svg'; -import u1122 from '../../../img/icons/unicons/star.svg'; -import u1123 from '../../../img/icons/unicons/step-backward.svg'; -import u1124 from '../../../img/icons/unicons/sync.svg'; -import u1125 from '../../../img/icons/unicons/stopwatch.svg'; -import u1126 from '../../../img/icons/unicons/table.svg'; -import u1127 from '../../../img/icons/unicons/tag-alt.svg'; -import u1128 from '../../../img/icons/unicons/times.svg'; -import u1129 from '../../../img/icons/unicons/trash-alt.svg'; -import u1130 from '../../../img/icons/unicons/unlock.svg'; -import u1131 from '../../../img/icons/unicons/upload.svg'; -import u1132 from '../../../img/icons/unicons/user.svg'; -import u1133 from '../../../img/icons/unicons/users-alt.svg'; -import u1134 from '../../../img/icons/unicons/wrap-text.svg'; -import u1135 from '../../../img/icons/unicons/cloud-upload.svg'; -import u1136 from '../../../img/icons/unicons/credit-card.svg'; -import u1137 from '../../../img/icons/unicons/file-copy-alt.svg'; -import u1138 from '../../../img/icons/unicons/fire.svg'; -import u1139 from '../../../img/icons/unicons/hourglass.svg'; -import u1140 from '../../../img/icons/unicons/layer-group.svg'; -import u1141 from '../../../img/icons/unicons/layers-alt.svg'; -import u1142 from '../../../img/icons/unicons/line-alt.svg'; -import u1143 from '../../../img/icons/unicons/list-ui-alt.svg'; -import u1144 from '../../../img/icons/unicons/message.svg'; -import u1145 from '../../../img/icons/unicons/palette.svg'; -import u1146 from '../../../img/icons/unicons/percentage.svg'; -import u1147 from '../../../img/icons/unicons/shield-exclamation.svg'; -import u1148 from '../../../img/icons/unicons/plus-square.svg'; -import u1149 from '../../../img/icons/unicons/x.svg'; -import u1150 from '../../../img/icons/unicons/capture.svg'; -import u1151 from '../../../img/icons/custom/gf-grid.svg'; -import u1152 from '../../../img/icons/custom/gf-landscape.svg'; -import u1153 from '../../../img/icons/custom/gf-layout-simple.svg'; -import u1154 from '../../../img/icons/custom/gf-portrait.svg'; -import u1155 from '../../../img/icons/custom/gf-show-context.svg'; -import u1156 from '../../../img/icons/custom/gf-bar-alignment-after.svg'; -import u1157 from '../../../img/icons/custom/gf-bar-alignment-before.svg'; -import u1158 from '../../../img/icons/custom/gf-bar-alignment-center.svg'; -import u1159 from '../../../img/icons/custom/gf-interpolation-linear.svg'; -import u1160 from '../../../img/icons/custom/gf-interpolation-smooth.svg'; -import u1161 from '../../../img/icons/custom/gf-interpolation-step-after.svg'; -import u1162 from '../../../img/icons/custom/gf-interpolation-step-before.svg'; -import u1163 from '../../../img/icons/custom/gf-logs.svg'; -import u1164 from '../../../img/icons/custom/gf-movepane-left.svg'; -import u1165 from '../../../img/icons/custom/gf-movepane-right.svg'; -import u1166 from '../../../img/icons/mono/favorite.svg'; -import u1167 from '../../../img/icons/mono/grafana.svg'; -import u1168 from '../../../img/icons/mono/heart.svg'; -import u1169 from '../../../img/icons/mono/heart-break.svg'; -import u1170 from '../../../img/icons/mono/panel-add.svg'; -import u1171 from '../../../img/icons/mono/library-panel.svg'; -import u1172 from '../../../img/icons/unicons/record-audio.svg'; -import u1173 from '../../../img/icons/solid/bookmark.svg'; +import u1039 from '../../../img/icons/unicons/times-circle.svg'; +import u1040 from '../../../img/icons/unicons/circle.svg'; +import u1041 from '../../../img/icons/unicons/clipboard-alt.svg'; +import u1042 from '../../../img/icons/unicons/clock-nine.svg'; +import u1043 from '../../../img/icons/unicons/cloud.svg'; +import u1044 from '../../../img/icons/unicons/cloud-download.svg'; +import u1045 from '../../../img/icons/unicons/code-branch.svg'; +import u1046 from '../../../img/icons/unicons/cog.svg'; +import u1047 from '../../../img/icons/unicons/columns.svg'; +import u1048 from '../../../img/icons/unicons/comment-alt.svg'; +import u1049 from '../../../img/icons/unicons/comment-alt-share.svg'; +import u1050 from '../../../img/icons/unicons/comments-alt.svg'; +import u1051 from '../../../img/icons/unicons/compass.svg'; +import u1052 from '../../../img/icons/unicons/copy.svg'; +import u1053 from '../../../img/icons/unicons/corner-down-right-alt.svg'; +import u1054 from '../../../img/icons/unicons/cube.svg'; +import u1055 from '../../../img/icons/unicons/dashboard.svg'; +import u1056 from '../../../img/icons/unicons/database.svg'; +import u1057 from '../../../img/icons/unicons/document-info.svg'; +import u1058 from '../../../img/icons/unicons/download-alt.svg'; +import u1059 from '../../../img/icons/unicons/draggabledots.svg'; +import u1060 from '../../../img/icons/unicons/edit.svg'; +import u1061 from '../../../img/icons/unicons/ellipsis-v.svg'; +import u1062 from '../../../img/icons/unicons/ellipsis-h.svg'; +import u1063 from '../../../img/icons/unicons/envelope.svg'; +import u1064 from '../../../img/icons/unicons/exchange-alt.svg'; +import u1065 from '../../../img/icons/unicons/exclamation-circle.svg'; +import u1066 from '../../../img/icons/unicons/exclamation-triangle.svg'; +import u1067 from '../../../img/icons/unicons/external-link-alt.svg'; +import u1068 from '../../../img/icons/unicons/eye.svg'; +import u1069 from '../../../img/icons/unicons/eye-slash.svg'; +import u1070 from '../../../img/icons/unicons/file-alt.svg'; +import u1071 from '../../../img/icons/unicons/file-blank.svg'; +import u1072 from '../../../img/icons/unicons/filter.svg'; +import u1073 from '../../../img/icons/unicons/folder.svg'; +import u1074 from '../../../img/icons/unicons/folder-open.svg'; +import u1075 from '../../../img/icons/unicons/folder-plus.svg'; +import u1076 from '../../../img/icons/unicons/folder-upload.svg'; +import u1077 from '../../../img/icons/unicons/forward.svg'; +import u1078 from '../../../img/icons/unicons/graph-bar.svg'; +import u1079 from '../../../img/icons/unicons/history.svg'; +import u1080 from '../../../img/icons/unicons/history-alt.svg'; +import u1081 from '../../../img/icons/unicons/home-alt.svg'; +import u1082 from '../../../img/icons/unicons/import.svg'; +import u1083 from '../../../img/icons/unicons/info.svg'; +import u1084 from '../../../img/icons/unicons/info-circle.svg'; +import u1085 from '../../../img/icons/unicons/k6.svg'; +import u1086 from '../../../img/icons/unicons/key-skeleton-alt.svg'; +import u1087 from '../../../img/icons/unicons/keyboard.svg'; +import u1088 from '../../../img/icons/unicons/link.svg'; +import u1089 from '../../../img/icons/unicons/list-ul.svg'; +import u1090 from '../../../img/icons/unicons/lock.svg'; +import u1091 from '../../../img/icons/unicons/minus.svg'; +import u1092 from '../../../img/icons/unicons/minus-circle.svg'; +import u1093 from '../../../img/icons/unicons/mobile-android.svg'; +import u1094 from '../../../img/icons/unicons/monitor.svg'; +import u1095 from '../../../img/icons/unicons/pause.svg'; +import u1096 from '../../../img/icons/unicons/pen.svg'; +import u1097 from '../../../img/icons/unicons/play.svg'; +import u1098 from '../../../img/icons/unicons/plug.svg'; +import u1099 from '../../../img/icons/unicons/plus.svg'; +import u1100 from '../../../img/icons/unicons/plus-circle.svg'; +import u1101 from '../../../img/icons/unicons/power.svg'; +import u1102 from '../../../img/icons/unicons/presentation-play.svg'; +import u1103 from '../../../img/icons/unicons/process.svg'; +import u1104 from '../../../img/icons/unicons/question-circle.svg'; +import u1105 from '../../../img/icons/unicons/repeat.svg'; +import u1106 from '../../../img/icons/unicons/rocket.svg'; +import u1107 from '../../../img/icons/unicons/rss.svg'; +import u1108 from '../../../img/icons/unicons/save.svg'; +import u1109 from '../../../img/icons/unicons/search.svg'; +import u1110 from '../../../img/icons/unicons/search-minus.svg'; +import u1111 from '../../../img/icons/unicons/search-plus.svg'; +import u1112 from '../../../img/icons/unicons/share-alt.svg'; +import u1113 from '../../../img/icons/unicons/shield.svg'; +import u1114 from '../../../img/icons/unicons/signal.svg'; +import u1115 from '../../../img/icons/unicons/signin.svg'; +import u1116 from '../../../img/icons/unicons/signout.svg'; +import u1117 from '../../../img/icons/unicons/sitemap.svg'; +import u1118 from '../../../img/icons/unicons/slack.svg'; +import u1119 from '../../../img/icons/unicons/sliders-v-alt.svg'; +import u1120 from '../../../img/icons/unicons/sort-amount-down.svg'; +import u1121 from '../../../img/icons/unicons/sort-amount-up.svg'; +import u1122 from '../../../img/icons/unicons/square-shape.svg'; +import u1123 from '../../../img/icons/unicons/star.svg'; +import u1124 from '../../../img/icons/unicons/step-backward.svg'; +import u1125 from '../../../img/icons/unicons/sync.svg'; +import u1126 from '../../../img/icons/unicons/stopwatch.svg'; +import u1127 from '../../../img/icons/unicons/table.svg'; +import u1128 from '../../../img/icons/unicons/tag-alt.svg'; +import u1129 from '../../../img/icons/unicons/times.svg'; +import u1130 from '../../../img/icons/unicons/trash-alt.svg'; +import u1131 from '../../../img/icons/unicons/unlock.svg'; +import u1132 from '../../../img/icons/unicons/upload.svg'; +import u1133 from '../../../img/icons/unicons/user.svg'; +import u1134 from '../../../img/icons/unicons/users-alt.svg'; +import u1135 from '../../../img/icons/unicons/wrap-text.svg'; +import u1136 from '../../../img/icons/unicons/cloud-upload.svg'; +import u1137 from '../../../img/icons/unicons/credit-card.svg'; +import u1138 from '../../../img/icons/unicons/file-copy-alt.svg'; +import u1139 from '../../../img/icons/unicons/fire.svg'; +import u1140 from '../../../img/icons/unicons/hourglass.svg'; +import u1141 from '../../../img/icons/unicons/layer-group.svg'; +import u1142 from '../../../img/icons/unicons/layers-alt.svg'; +import u1143 from '../../../img/icons/unicons/line-alt.svg'; +import u1144 from '../../../img/icons/unicons/list-ui-alt.svg'; +import u1145 from '../../../img/icons/unicons/message.svg'; +import u1146 from '../../../img/icons/unicons/palette.svg'; +import u1147 from '../../../img/icons/unicons/percentage.svg'; +import u1148 from '../../../img/icons/unicons/shield-exclamation.svg'; +import u1149 from '../../../img/icons/unicons/plus-square.svg'; +import u1150 from '../../../img/icons/unicons/x.svg'; +import u1151 from '../../../img/icons/unicons/capture.svg'; +import u1152 from '../../../img/icons/custom/gf-grid.svg'; +import u1153 from '../../../img/icons/custom/gf-landscape.svg'; +import u1154 from '../../../img/icons/custom/gf-layout-simple.svg'; +import u1155 from '../../../img/icons/custom/gf-portrait.svg'; +import u1156 from '../../../img/icons/custom/gf-show-context.svg'; +import u1157 from '../../../img/icons/custom/gf-bar-alignment-after.svg'; +import u1158 from '../../../img/icons/custom/gf-bar-alignment-before.svg'; +import u1159 from '../../../img/icons/custom/gf-bar-alignment-center.svg'; +import u1160 from '../../../img/icons/custom/gf-interpolation-linear.svg'; +import u1161 from '../../../img/icons/custom/gf-interpolation-smooth.svg'; +import u1162 from '../../../img/icons/custom/gf-interpolation-step-after.svg'; +import u1163 from '../../../img/icons/custom/gf-interpolation-step-before.svg'; +import u1164 from '../../../img/icons/custom/gf-logs.svg'; +import u1165 from '../../../img/icons/custom/gf-movepane-left.svg'; +import u1166 from '../../../img/icons/custom/gf-movepane-right.svg'; +import u1167 from '../../../img/icons/mono/favorite.svg'; +import u1168 from '../../../img/icons/mono/grafana.svg'; +import u1169 from '../../../img/icons/mono/heart.svg'; +import u1170 from '../../../img/icons/mono/heart-break.svg'; +import u1171 from '../../../img/icons/mono/panel-add.svg'; +import u1172 from '../../../img/icons/mono/library-panel.svg'; +import u1173 from '../../../img/icons/unicons/record-audio.svg'; +import u1174 from '../../../img/icons/solid/bookmark.svg'; // do not edit this list directly // the list of icons live here: @grafana/ui/components/Icon/cached.json @@ -253,141 +254,142 @@ export function initIconCache() { cacheItem(u1036, resolvePath('unicons/chart-line.svg')); cacheItem(u1037, resolvePath('unicons/check.svg')); cacheItem(u1038, resolvePath('unicons/check-circle.svg')); - cacheItem(u1039, resolvePath('unicons/circle.svg')); - cacheItem(u1040, resolvePath('unicons/clipboard-alt.svg')); - cacheItem(u1041, resolvePath('unicons/clock-nine.svg')); - cacheItem(u1042, resolvePath('unicons/cloud.svg')); - cacheItem(u1043, resolvePath('unicons/cloud-download.svg')); - cacheItem(u1044, resolvePath('unicons/code-branch.svg')); - cacheItem(u1045, resolvePath('unicons/cog.svg')); - cacheItem(u1046, resolvePath('unicons/columns.svg')); - cacheItem(u1047, resolvePath('unicons/comment-alt.svg')); - cacheItem(u1048, resolvePath('unicons/comment-alt-share.svg')); - cacheItem(u1049, resolvePath('unicons/comments-alt.svg')); - cacheItem(u1050, resolvePath('unicons/compass.svg')); - cacheItem(u1051, resolvePath('unicons/copy.svg')); - cacheItem(u1052, resolvePath('unicons/corner-down-right-alt.svg')); - cacheItem(u1053, resolvePath('unicons/cube.svg')); - cacheItem(u1054, resolvePath('unicons/dashboard.svg')); - cacheItem(u1055, resolvePath('unicons/database.svg')); - cacheItem(u1056, resolvePath('unicons/document-info.svg')); - cacheItem(u1057, resolvePath('unicons/download-alt.svg')); - cacheItem(u1058, resolvePath('unicons/draggabledots.svg')); - cacheItem(u1059, resolvePath('unicons/edit.svg')); - cacheItem(u1060, resolvePath('unicons/ellipsis-v.svg')); - cacheItem(u1061, resolvePath('unicons/ellipsis-h.svg')); - cacheItem(u1062, resolvePath('unicons/envelope.svg')); - cacheItem(u1063, resolvePath('unicons/exchange-alt.svg')); - cacheItem(u1064, resolvePath('unicons/exclamation-circle.svg')); - cacheItem(u1065, resolvePath('unicons/exclamation-triangle.svg')); - cacheItem(u1066, resolvePath('unicons/external-link-alt.svg')); - cacheItem(u1067, resolvePath('unicons/eye.svg')); - cacheItem(u1068, resolvePath('unicons/eye-slash.svg')); - cacheItem(u1069, resolvePath('unicons/file-alt.svg')); - cacheItem(u1070, resolvePath('unicons/file-blank.svg')); - cacheItem(u1071, resolvePath('unicons/filter.svg')); - cacheItem(u1072, resolvePath('unicons/folder.svg')); - cacheItem(u1073, resolvePath('unicons/folder-open.svg')); - cacheItem(u1074, resolvePath('unicons/folder-plus.svg')); - cacheItem(u1075, resolvePath('unicons/folder-upload.svg')); - cacheItem(u1076, resolvePath('unicons/forward.svg')); - cacheItem(u1077, resolvePath('unicons/graph-bar.svg')); - cacheItem(u1078, resolvePath('unicons/history.svg')); - cacheItem(u1079, resolvePath('unicons/history-alt.svg')); - cacheItem(u1080, resolvePath('unicons/home-alt.svg')); - cacheItem(u1081, resolvePath('unicons/import.svg')); - cacheItem(u1082, resolvePath('unicons/info.svg')); - cacheItem(u1083, resolvePath('unicons/info-circle.svg')); - cacheItem(u1084, resolvePath('unicons/k6.svg')); - cacheItem(u1085, resolvePath('unicons/key-skeleton-alt.svg')); - cacheItem(u1086, resolvePath('unicons/keyboard.svg')); - cacheItem(u1087, resolvePath('unicons/link.svg')); - cacheItem(u1088, resolvePath('unicons/list-ul.svg')); - cacheItem(u1089, resolvePath('unicons/lock.svg')); - cacheItem(u1090, resolvePath('unicons/minus.svg')); - cacheItem(u1091, resolvePath('unicons/minus-circle.svg')); - cacheItem(u1092, resolvePath('unicons/mobile-android.svg')); - cacheItem(u1093, resolvePath('unicons/monitor.svg')); - cacheItem(u1094, resolvePath('unicons/pause.svg')); - cacheItem(u1095, resolvePath('unicons/pen.svg')); - cacheItem(u1096, resolvePath('unicons/play.svg')); - cacheItem(u1097, resolvePath('unicons/plug.svg')); - cacheItem(u1098, resolvePath('unicons/plus.svg')); - cacheItem(u1099, resolvePath('unicons/plus-circle.svg')); - cacheItem(u1100, resolvePath('unicons/power.svg')); - cacheItem(u1101, resolvePath('unicons/presentation-play.svg')); - cacheItem(u1102, resolvePath('unicons/process.svg')); - cacheItem(u1103, resolvePath('unicons/question-circle.svg')); - cacheItem(u1104, resolvePath('unicons/repeat.svg')); - cacheItem(u1105, resolvePath('unicons/rocket.svg')); - cacheItem(u1106, resolvePath('unicons/rss.svg')); - cacheItem(u1107, resolvePath('unicons/save.svg')); - cacheItem(u1108, resolvePath('unicons/search.svg')); - cacheItem(u1109, resolvePath('unicons/search-minus.svg')); - cacheItem(u1110, resolvePath('unicons/search-plus.svg')); - cacheItem(u1111, resolvePath('unicons/share-alt.svg')); - cacheItem(u1112, resolvePath('unicons/shield.svg')); - cacheItem(u1113, resolvePath('unicons/signal.svg')); - cacheItem(u1114, resolvePath('unicons/signin.svg')); - cacheItem(u1115, resolvePath('unicons/signout.svg')); - cacheItem(u1116, resolvePath('unicons/sitemap.svg')); - cacheItem(u1117, resolvePath('unicons/slack.svg')); - cacheItem(u1118, resolvePath('unicons/sliders-v-alt.svg')); - cacheItem(u1119, resolvePath('unicons/sort-amount-down.svg')); - cacheItem(u1120, resolvePath('unicons/sort-amount-up.svg')); - cacheItem(u1121, resolvePath('unicons/square-shape.svg')); - cacheItem(u1122, resolvePath('unicons/star.svg')); - cacheItem(u1123, resolvePath('unicons/step-backward.svg')); - cacheItem(u1124, resolvePath('unicons/sync.svg')); - cacheItem(u1125, resolvePath('unicons/stopwatch.svg')); - cacheItem(u1126, resolvePath('unicons/table.svg')); - cacheItem(u1127, resolvePath('unicons/tag-alt.svg')); - cacheItem(u1128, resolvePath('unicons/times.svg')); - cacheItem(u1129, resolvePath('unicons/trash-alt.svg')); - cacheItem(u1130, resolvePath('unicons/unlock.svg')); - cacheItem(u1131, resolvePath('unicons/upload.svg')); - cacheItem(u1132, resolvePath('unicons/user.svg')); - cacheItem(u1133, resolvePath('unicons/users-alt.svg')); - cacheItem(u1134, resolvePath('unicons/wrap-text.svg')); - cacheItem(u1135, resolvePath('unicons/cloud-upload.svg')); - cacheItem(u1136, resolvePath('unicons/credit-card.svg')); - cacheItem(u1137, resolvePath('unicons/file-copy-alt.svg')); - cacheItem(u1138, resolvePath('unicons/fire.svg')); - cacheItem(u1139, resolvePath('unicons/hourglass.svg')); - cacheItem(u1140, resolvePath('unicons/layer-group.svg')); - cacheItem(u1141, resolvePath('unicons/layers-alt.svg')); - cacheItem(u1142, resolvePath('unicons/line-alt.svg')); - cacheItem(u1143, resolvePath('unicons/list-ui-alt.svg')); - cacheItem(u1144, resolvePath('unicons/message.svg')); - cacheItem(u1145, resolvePath('unicons/palette.svg')); - cacheItem(u1146, resolvePath('unicons/percentage.svg')); - cacheItem(u1147, resolvePath('unicons/shield-exclamation.svg')); - cacheItem(u1148, resolvePath('unicons/plus-square.svg')); - cacheItem(u1149, resolvePath('unicons/x.svg')); - cacheItem(u1150, resolvePath('unicons/capture.svg')); - cacheItem(u1151, resolvePath('custom/gf-grid.svg')); - cacheItem(u1152, resolvePath('custom/gf-landscape.svg')); - cacheItem(u1153, resolvePath('custom/gf-layout-simple.svg')); - cacheItem(u1154, resolvePath('custom/gf-portrait.svg')); - cacheItem(u1155, resolvePath('custom/gf-show-context.svg')); - cacheItem(u1156, resolvePath('custom/gf-bar-alignment-after.svg')); - cacheItem(u1157, resolvePath('custom/gf-bar-alignment-before.svg')); - cacheItem(u1158, resolvePath('custom/gf-bar-alignment-center.svg')); - cacheItem(u1159, resolvePath('custom/gf-interpolation-linear.svg')); - cacheItem(u1160, resolvePath('custom/gf-interpolation-smooth.svg')); - cacheItem(u1161, resolvePath('custom/gf-interpolation-step-after.svg')); - cacheItem(u1162, resolvePath('custom/gf-interpolation-step-before.svg')); - cacheItem(u1163, resolvePath('custom/gf-logs.svg')); - cacheItem(u1164, resolvePath('custom/gf-movepane-left.svg')); - cacheItem(u1165, resolvePath('custom/gf-movepane-right.svg')); - cacheItem(u1166, resolvePath('mono/favorite.svg')); - cacheItem(u1167, resolvePath('mono/grafana.svg')); - cacheItem(u1168, resolvePath('mono/heart.svg')); - cacheItem(u1169, resolvePath('mono/heart-break.svg')); - cacheItem(u1170, resolvePath('mono/panel-add.svg')); - cacheItem(u1171, resolvePath('mono/library-panel.svg')); - cacheItem(u1172, resolvePath('unicons/record-audio.svg')); - cacheItem(u1173, resolvePath('solid/bookmark.svg')); + cacheItem(u1039, resolvePath('unicons/times-circle.svg')); + cacheItem(u1040, resolvePath('unicons/circle.svg')); + cacheItem(u1041, resolvePath('unicons/clipboard-alt.svg')); + cacheItem(u1042, resolvePath('unicons/clock-nine.svg')); + cacheItem(u1043, resolvePath('unicons/cloud.svg')); + cacheItem(u1044, resolvePath('unicons/cloud-download.svg')); + cacheItem(u1045, resolvePath('unicons/code-branch.svg')); + cacheItem(u1046, resolvePath('unicons/cog.svg')); + cacheItem(u1047, resolvePath('unicons/columns.svg')); + cacheItem(u1048, resolvePath('unicons/comment-alt.svg')); + cacheItem(u1049, resolvePath('unicons/comment-alt-share.svg')); + cacheItem(u1050, resolvePath('unicons/comments-alt.svg')); + cacheItem(u1051, resolvePath('unicons/compass.svg')); + cacheItem(u1052, resolvePath('unicons/copy.svg')); + cacheItem(u1053, resolvePath('unicons/corner-down-right-alt.svg')); + cacheItem(u1054, resolvePath('unicons/cube.svg')); + cacheItem(u1055, resolvePath('unicons/dashboard.svg')); + cacheItem(u1056, resolvePath('unicons/database.svg')); + cacheItem(u1057, resolvePath('unicons/document-info.svg')); + cacheItem(u1058, resolvePath('unicons/download-alt.svg')); + cacheItem(u1059, resolvePath('unicons/draggabledots.svg')); + cacheItem(u1060, resolvePath('unicons/edit.svg')); + cacheItem(u1061, resolvePath('unicons/ellipsis-v.svg')); + cacheItem(u1062, resolvePath('unicons/ellipsis-h.svg')); + cacheItem(u1063, resolvePath('unicons/envelope.svg')); + cacheItem(u1064, resolvePath('unicons/exchange-alt.svg')); + cacheItem(u1065, resolvePath('unicons/exclamation-circle.svg')); + cacheItem(u1066, resolvePath('unicons/exclamation-triangle.svg')); + cacheItem(u1067, resolvePath('unicons/external-link-alt.svg')); + cacheItem(u1068, resolvePath('unicons/eye.svg')); + cacheItem(u1069, resolvePath('unicons/eye-slash.svg')); + cacheItem(u1070, resolvePath('unicons/file-alt.svg')); + cacheItem(u1071, resolvePath('unicons/file-blank.svg')); + cacheItem(u1072, resolvePath('unicons/filter.svg')); + cacheItem(u1073, resolvePath('unicons/folder.svg')); + cacheItem(u1074, resolvePath('unicons/folder-open.svg')); + cacheItem(u1075, resolvePath('unicons/folder-plus.svg')); + cacheItem(u1076, resolvePath('unicons/folder-upload.svg')); + cacheItem(u1077, resolvePath('unicons/forward.svg')); + cacheItem(u1078, resolvePath('unicons/graph-bar.svg')); + cacheItem(u1079, resolvePath('unicons/history.svg')); + cacheItem(u1080, resolvePath('unicons/history-alt.svg')); + cacheItem(u1081, resolvePath('unicons/home-alt.svg')); + cacheItem(u1082, resolvePath('unicons/import.svg')); + cacheItem(u1083, resolvePath('unicons/info.svg')); + cacheItem(u1084, resolvePath('unicons/info-circle.svg')); + cacheItem(u1085, resolvePath('unicons/k6.svg')); + cacheItem(u1086, resolvePath('unicons/key-skeleton-alt.svg')); + cacheItem(u1087, resolvePath('unicons/keyboard.svg')); + cacheItem(u1088, resolvePath('unicons/link.svg')); + cacheItem(u1089, resolvePath('unicons/list-ul.svg')); + cacheItem(u1090, resolvePath('unicons/lock.svg')); + cacheItem(u1091, resolvePath('unicons/minus.svg')); + cacheItem(u1092, resolvePath('unicons/minus-circle.svg')); + cacheItem(u1093, resolvePath('unicons/mobile-android.svg')); + cacheItem(u1094, resolvePath('unicons/monitor.svg')); + cacheItem(u1095, resolvePath('unicons/pause.svg')); + cacheItem(u1096, resolvePath('unicons/pen.svg')); + cacheItem(u1097, resolvePath('unicons/play.svg')); + cacheItem(u1098, resolvePath('unicons/plug.svg')); + cacheItem(u1099, resolvePath('unicons/plus.svg')); + cacheItem(u1100, resolvePath('unicons/plus-circle.svg')); + cacheItem(u1101, resolvePath('unicons/power.svg')); + cacheItem(u1102, resolvePath('unicons/presentation-play.svg')); + cacheItem(u1103, resolvePath('unicons/process.svg')); + cacheItem(u1104, resolvePath('unicons/question-circle.svg')); + cacheItem(u1105, resolvePath('unicons/repeat.svg')); + cacheItem(u1106, resolvePath('unicons/rocket.svg')); + cacheItem(u1107, resolvePath('unicons/rss.svg')); + cacheItem(u1108, resolvePath('unicons/save.svg')); + cacheItem(u1109, resolvePath('unicons/search.svg')); + cacheItem(u1110, resolvePath('unicons/search-minus.svg')); + cacheItem(u1111, resolvePath('unicons/search-plus.svg')); + cacheItem(u1112, resolvePath('unicons/share-alt.svg')); + cacheItem(u1113, resolvePath('unicons/shield.svg')); + cacheItem(u1114, resolvePath('unicons/signal.svg')); + cacheItem(u1115, resolvePath('unicons/signin.svg')); + cacheItem(u1116, resolvePath('unicons/signout.svg')); + cacheItem(u1117, resolvePath('unicons/sitemap.svg')); + cacheItem(u1118, resolvePath('unicons/slack.svg')); + cacheItem(u1119, resolvePath('unicons/sliders-v-alt.svg')); + cacheItem(u1120, resolvePath('unicons/sort-amount-down.svg')); + cacheItem(u1121, resolvePath('unicons/sort-amount-up.svg')); + cacheItem(u1122, resolvePath('unicons/square-shape.svg')); + cacheItem(u1123, resolvePath('unicons/star.svg')); + cacheItem(u1124, resolvePath('unicons/step-backward.svg')); + cacheItem(u1125, resolvePath('unicons/sync.svg')); + cacheItem(u1126, resolvePath('unicons/stopwatch.svg')); + cacheItem(u1127, resolvePath('unicons/table.svg')); + cacheItem(u1128, resolvePath('unicons/tag-alt.svg')); + cacheItem(u1129, resolvePath('unicons/times.svg')); + cacheItem(u1130, resolvePath('unicons/trash-alt.svg')); + cacheItem(u1131, resolvePath('unicons/unlock.svg')); + cacheItem(u1132, resolvePath('unicons/upload.svg')); + cacheItem(u1133, resolvePath('unicons/user.svg')); + cacheItem(u1134, resolvePath('unicons/users-alt.svg')); + cacheItem(u1135, resolvePath('unicons/wrap-text.svg')); + cacheItem(u1136, resolvePath('unicons/cloud-upload.svg')); + cacheItem(u1137, resolvePath('unicons/credit-card.svg')); + cacheItem(u1138, resolvePath('unicons/file-copy-alt.svg')); + cacheItem(u1139, resolvePath('unicons/fire.svg')); + cacheItem(u1140, resolvePath('unicons/hourglass.svg')); + cacheItem(u1141, resolvePath('unicons/layer-group.svg')); + cacheItem(u1142, resolvePath('unicons/layers-alt.svg')); + cacheItem(u1143, resolvePath('unicons/line-alt.svg')); + cacheItem(u1144, resolvePath('unicons/list-ui-alt.svg')); + cacheItem(u1145, resolvePath('unicons/message.svg')); + cacheItem(u1146, resolvePath('unicons/palette.svg')); + cacheItem(u1147, resolvePath('unicons/percentage.svg')); + cacheItem(u1148, resolvePath('unicons/shield-exclamation.svg')); + cacheItem(u1149, resolvePath('unicons/plus-square.svg')); + cacheItem(u1150, resolvePath('unicons/x.svg')); + cacheItem(u1151, resolvePath('unicons/capture.svg')); + cacheItem(u1152, resolvePath('custom/gf-grid.svg')); + cacheItem(u1153, resolvePath('custom/gf-landscape.svg')); + cacheItem(u1154, resolvePath('custom/gf-layout-simple.svg')); + cacheItem(u1155, resolvePath('custom/gf-portrait.svg')); + cacheItem(u1156, resolvePath('custom/gf-show-context.svg')); + cacheItem(u1157, resolvePath('custom/gf-bar-alignment-after.svg')); + cacheItem(u1158, resolvePath('custom/gf-bar-alignment-before.svg')); + cacheItem(u1159, resolvePath('custom/gf-bar-alignment-center.svg')); + cacheItem(u1160, resolvePath('custom/gf-interpolation-linear.svg')); + cacheItem(u1161, resolvePath('custom/gf-interpolation-smooth.svg')); + cacheItem(u1162, resolvePath('custom/gf-interpolation-step-after.svg')); + cacheItem(u1163, resolvePath('custom/gf-interpolation-step-before.svg')); + cacheItem(u1164, resolvePath('custom/gf-logs.svg')); + cacheItem(u1165, resolvePath('custom/gf-movepane-left.svg')); + cacheItem(u1166, resolvePath('custom/gf-movepane-right.svg')); + cacheItem(u1167, resolvePath('mono/favorite.svg')); + cacheItem(u1168, resolvePath('mono/grafana.svg')); + cacheItem(u1169, resolvePath('mono/heart.svg')); + cacheItem(u1170, resolvePath('mono/heart-break.svg')); + cacheItem(u1171, resolvePath('mono/panel-add.svg')); + cacheItem(u1172, resolvePath('mono/library-panel.svg')); + cacheItem(u1173, resolvePath('unicons/record-audio.svg')); + cacheItem(u1174, resolvePath('solid/bookmark.svg')); // do not edit this list directly // the list of icons live here: @grafana/ui/components/Icon/cached.json } diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index 9b482e07c04..bdda3e5d28b 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -1,10 +1,8 @@ import { useCallback } from 'react'; import { useParams } from 'react-router-dom-v5-compat'; -import { useAsync } from 'react-use'; import { NavModelItem } from '@grafana/data'; import { withErrorBoundary } from '@grafana/ui'; -import { useDispatch } from 'app/types'; import { RuleIdentifier } from 'app/types/unified-alerting'; import { AlertWarning } from './AlertWarning'; @@ -13,7 +11,6 @@ import { ExistingRuleEditor } from './ExistingRuleEditor'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm'; import { useURLSearchParams } from './hooks/useURLSearchParams'; -import { fetchRulesSourceBuildInfoAction } from './state/actions'; import { useRulesAccess } from './utils/accessControlHooks'; import * as ruleId from './utils/rule-id'; @@ -47,7 +44,6 @@ const getPageNav = (identifier?: RuleIdentifier, type?: RuleEditorPathParams['ty }; const RuleEditor = () => { - const dispatch = useDispatch(); const [searchParams] = useURLSearchParams(); const params = useParams(); const { type } = params; @@ -57,22 +53,9 @@ const RuleEditor = () => { const copyFromId = searchParams.get('copyFrom') ?? undefined; const copyFromIdentifier = ruleId.tryParse(copyFromId); - const { loading = true } = useAsync(async () => { - if (identifier) { - await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName: identifier.ruleSourceName })); - } - if (copyFromIdentifier) { - await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName: copyFromIdentifier.ruleSourceName })); - } - }, [dispatch]); - const { canCreateGrafanaRules, canCreateCloudRules, canEditRules } = useRulesAccess(); const getContent = useCallback(() => { - if (loading) { - return; - } - if (!identifier && !canCreateGrafanaRules && !canCreateCloudRules) { return Sorry! You are not allowed to create rules.; } @@ -90,10 +73,10 @@ const RuleEditor = () => { } // new alert rule return ; - }, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier, loading]); + }, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier]); return ( - + {getContent()} ); diff --git a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx index 37437f4b91e..cfc05cee73d 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx @@ -2,20 +2,19 @@ import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { screen, waitForElementToBeRemoved } from 'test/test-utils'; import { byText } from 'testing-library-selector'; -import { setDataSourceSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types'; import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto'; import { searchFolders } from '../../manage-dashboards/state/actions'; -import { discoverFeatures } from './api/buildInfo'; +import { discoverFeaturesByUid } from './api/buildInfo'; import { fetchRulerRulesGroup } from './api/ruler'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { setupMswServer } from './mockApi'; -import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks'; -import * as config from './utils/config'; -import { DataSourceType } from './utils/datasource'; +import { grantUserPermissions, mockDataSource } from './mocks'; +import { setupDataSources } from './testSetup/datasources'; +import { DataSourceType, GRAFANA_DATASOURCE_NAME, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; jest.mock('./components/rule-editor/ExpressionEditor', () => ({ // eslint-disable-next-line react/display-name @@ -49,10 +48,19 @@ jest.mock('./components/rule-editor/util', () => { }); const dataSources = { + grafana: mockDataSource( + { + type: 'datasource', + uid: GRAFANA_RULES_SOURCE_NAME, + name: GRAFANA_DATASOURCE_NAME, + }, + { alerting: true } + ), // can edit rules loki: mockDataSource( { type: DataSourceType.Loki, + uid: 'loki-with-ruler', name: 'loki with ruler', }, { alerting: true } @@ -61,9 +69,8 @@ const dataSources = { { type: DataSourceType.Loki, name: 'loki disabled for alerting', - jsonData: { - manageAlerts: false, - }, + uid: 'loki-without-alerting', + jsonData: { manageAlerts: false }, }, { alerting: true } ), @@ -72,6 +79,7 @@ const dataSources = { { type: DataSourceType.Prometheus, name: 'cortex with ruler', + uid: 'cortex-with-ruler', isDefault: true, }, { alerting: true } @@ -81,6 +89,7 @@ const dataSources = { { type: DataSourceType.Loki, name: 'loki with local rule store', + uid: 'loki-with-local-rule-store', }, { alerting: true } ), @@ -89,6 +98,7 @@ const dataSources = { { type: DataSourceType.Loki, name: 'cortex without ruler api', + uid: 'cortex-without-ruler-api', }, { alerting: true } ), @@ -97,27 +107,19 @@ const dataSources = { { type: 'splunk', name: 'splunk', + uid: 'splunk', }, { alerting: true } ), }; -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getDataSourceSrv: jest.fn(() => ({ - getInstanceSettings: () => dataSources.prom, - get: () => dataSources.prom, - getList: () => Object.values(dataSources), - })), -})); - -jest.spyOn(config, 'getAllDataSources'); +setupDataSources(...Object.values(dataSources)); const mocks = { - getAllDataSources: jest.mocked(config.getAllDataSources), + // getAllDataSources: jest.mocked(config.getAllDataSources), searchFolders: jest.mocked(searchFolders), api: { - discoverFeatures: jest.mocked(discoverFeatures), + discoverFeaturesByUid: jest.mocked(discoverFeaturesByUid), fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), }, }; @@ -147,18 +149,18 @@ describe('RuleEditor cloud: checking editable data sources', () => { }); it('for cloud alerts, should only allow to select editable rules sources', async () => { - mocks.api.discoverFeatures.mockImplementation(async (dataSourceName) => { - if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { + mocks.api.discoverFeaturesByUid.mockImplementation(async (dataSourceUid) => { + if (dataSourceUid === dataSources.loki.uid || dataSourceUid === dataSources.prom.uid) { return getDiscoverFeaturesMock(PromApplication.Cortex, { rulerApiEnabled: true }); } - if (dataSourceName === 'loki with local rule store') { + if (dataSourceUid === dataSources.loki_local_rule_store.uid) { return getDiscoverFeaturesMock(PromApplication.Cortex); } - if (dataSourceName === 'cortex without ruler api') { + if (dataSourceUid === dataSources.prom_no_ruler_api.uid) { return getDiscoverFeaturesMock(PromApplication.Cortex); } - throw new Error(`${dataSourceName} not handled`); + throw new Error(`${dataSourceUid} not handled`); }); mocks.api.fetchRulerRulesGroup.mockImplementation(async ({ dataSourceName }) => { @@ -179,8 +181,6 @@ describe('RuleEditor cloud: checking editable data sources', () => { return null; }); - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.searchFolders.mockResolvedValue([]); // render rule editor, select mimir/loki managed alerts diff --git a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx index 6f46fdb4ab1..d885a9c5688 100644 --- a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx @@ -74,14 +74,12 @@ describe('RuleEditor grafana managed rules', () => { ]); const dataSources = { - default: mockDataSource( - { - type: 'prometheus', - name: 'Prom', - isDefault: true, - }, - { alerting: false } - ), + default: mockDataSource({ + uid: 'mimir', + type: 'prometheus', + name: 'Mimir', + isDefault: true, + }), }; setupDataSources(dataSources.default); setFolderResponse(mockFolder(folder)); diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx index 9c4ea00134d..d290b8f58e3 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -6,7 +6,7 @@ import { clickSelectOption } from 'test/helpers/selectOptionInTest'; import { byRole } from 'testing-library-selector'; import { contextSrv } from 'app/core/services/context_srv'; -import { setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { mockFeatureDiscoveryApi, setupMswServer } from 'app/features/alerting/unified/mockApi'; import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { AccessControlAction } from 'app/types'; @@ -16,6 +16,7 @@ import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor import { grantUserPermissions, mockDataSource } from './mocks'; import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi'; import { setupDataSources } from './testSetup/datasources'; +import { buildInfoResponse } from './testSetup/featureDiscovery'; import * as config from './utils/config'; jest.mock('./components/rule-editor/ExpressionEditor', () => ({ @@ -45,7 +46,7 @@ const mocks = { searchFolders: jest.mocked(searchFolders), }; -setupMswServer(); +const server = setupMswServer(); describe('RuleEditor grafana managed rules', () => { beforeEach(() => { @@ -80,6 +81,8 @@ describe('RuleEditor grafana managed rules', () => { }; setupDataSources(dataSources.default); + mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.searchFolders.mockResolvedValue([ { diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index f42767739e4..b7fd41728b3 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -20,7 +20,7 @@ import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerti import * as analytics from './Analytics'; import RuleList from './RuleList'; -import { discoverFeatures } from './api/buildInfo'; +import { discoverFeaturesByUid } from './api/buildInfo'; import { fetchRules } from './api/prometheus'; import * as apiRuler from './api/ruler'; import { fetchRulerRules } from './api/ruler'; @@ -68,7 +68,7 @@ const mocks = { rulesInSameGroupHaveInvalidForMock: jest.mocked(actions.rulesInSameGroupHaveInvalidFor), api: { - discoverFeatures: jest.mocked(discoverFeatures), + discoverFeaturesByUid: jest.mocked(discoverFeaturesByUid), fetchRules: jest.mocked(fetchRules), fetchRulerRules: jest.mocked(fetchRulerRules), rulerBuilderMock: jest.mocked(apiRuler.rulerUrlBuilder), @@ -185,7 +185,7 @@ describe('RuleList', () => { setDataSourceSrv(new MockDataSourceSrv(dataSources)); - mocks.api.discoverFeatures.mockResolvedValue({ + mocks.api.discoverFeaturesByUid.mockResolvedValue({ application: PromApplication.Prometheus, features: { rulerApiEnabled: true, @@ -279,7 +279,7 @@ describe('RuleList', () => { mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); - mocks.api.discoverFeatures.mockResolvedValue({ + mocks.api.discoverFeaturesByUid.mockResolvedValue({ application: PromApplication.Cortex, features: { rulerApiEnabled: true, @@ -430,7 +430,7 @@ describe('RuleList', () => { mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); - mocks.api.discoverFeatures.mockResolvedValue({ + mocks.api.discoverFeaturesByUid.mockResolvedValue({ application: PromApplication.Cortex, features: { rulerApiEnabled: true, @@ -577,7 +577,7 @@ describe('RuleList', () => { setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); - mocks.api.discoverFeatures.mockResolvedValue({ + mocks.api.discoverFeaturesByUid.mockResolvedValue({ application: PromApplication.Cortex, features: { rulerApiEnabled: true, @@ -688,7 +688,7 @@ describe('RuleList', () => { mocks.getAllDataSourcesMock.mockReturnValue(Object.values(testDatasources)); setDataSourceSrv(new MockDataSourceSrv(testDatasources)); - mocks.api.discoverFeatures.mockResolvedValue({ + mocks.api.discoverFeaturesByUid.mockResolvedValue({ application: PromApplication.Cortex, features: { rulerApiEnabled: true, @@ -855,7 +855,7 @@ describe('RuleList', () => { mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); - mocks.api.discoverFeatures.mockResolvedValue({ + mocks.api.discoverFeaturesByUid.mockResolvedValue({ application: PromApplication.Cortex, features: { rulerApiEnabled: true, @@ -880,7 +880,7 @@ describe('RuleList', () => { mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); - mocks.api.discoverFeatures.mockResolvedValue({ + mocks.api.discoverFeaturesByUid.mockResolvedValue({ application: PromApplication.Cortex, features: { rulerApiEnabled: true, diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index d33f2f5152c..84d6d15bad9 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -25,9 +25,9 @@ import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rule import { alertingApi, WithNotificationOptions } from './alertingApi'; import { FetchPromRulesFilter, + getRulesFilterSearchParams, groupRulesByFileName, paramsWithMatcherAndState, - getRulesFilterSearchParams, } from './prometheus'; import { FetchRulerRulesFilter, rulerUrlBuilder } from './ruler'; @@ -173,9 +173,21 @@ export const alertRuleApi = alertingApi.injectEndpoints({ dashboardUid?: string; panelId?: number; limitAlerts?: number; + maxGroups?: number; + excludeAlerts?: boolean; } >({ - query: ({ ruleSourceName, namespace, groupName, ruleName, dashboardUid, panelId, limitAlerts }) => { + query: ({ + ruleSourceName, + namespace, + groupName, + ruleName, + dashboardUid, + panelId, + limitAlerts, + maxGroups, + excludeAlerts, + }) => { const queryParams: Record = { rule_group: groupName, rule_name: ruleName, @@ -195,6 +207,14 @@ export const alertRuleApi = alertingApi.injectEndpoints({ set(queryParams, PrometheusAPIFilters.LimitAlerts, String(limitAlerts)); } + if (maxGroups) { + set(queryParams, 'max_groups', maxGroups); + } + + if (excludeAlerts) { + set(queryParams, 'exclude_alerts', 'true'); + } + return { url: `api/prometheus/${getDatasourceAPIUid(ruleSourceName)}/api/v1/rules`, params: queryParams, diff --git a/public/app/features/alerting/unified/api/buildInfo.test.ts b/public/app/features/alerting/unified/api/buildInfo.test.ts index 53f9319364a..9ac5f2a6b20 100644 --- a/public/app/features/alerting/unified/api/buildInfo.test.ts +++ b/public/app/features/alerting/unified/api/buildInfo.test.ts @@ -95,7 +95,7 @@ describe('discoverDataSourceFeatures', () => { const response = await discoverDataSourceFeatures({ url: '/datasource/proxy', name: 'Loki', type: 'loki' }); - expect(response.application).toBe(PromApplication.Cortex); + expect(response.application).toBe('Loki'); expect(response.features.rulerApiEnabled).toBe(true); expect(mocks.fetchTestRulerRulesGroup).toHaveBeenCalledTimes(1); diff --git a/public/app/features/alerting/unified/api/buildInfo.ts b/public/app/features/alerting/unified/api/buildInfo.ts index 3504a5d67a1..172bb3309f2 100644 --- a/public/app/features/alerting/unified/api/buildInfo.ts +++ b/public/app/features/alerting/unified/api/buildInfo.ts @@ -9,26 +9,24 @@ import { } from 'app/types/unified-alerting-dto'; import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; -import { getDataSourceByName, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import { getDataSourceByName, getRulesDataSourceByUID, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { fetchRules } from './prometheus'; import { fetchTestRulerRulesGroup } from './ruler'; -/** - * Attempt to fetch buildinfo from our component - */ -export async function discoverFeatures(dataSourceName: string): Promise { - if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { +export async function discoverFeaturesByUid(dataSourceUid: string): Promise { + if (dataSourceUid === GRAFANA_RULES_SOURCE_NAME) { return { + application: 'grafana', features: { rulerApiEnabled: true, }, - }; + } satisfies PromApiFeatures; } - const dsConfig = getDataSourceByName(dataSourceName); + const dsConfig = getRulesDataSourceByUID(dataSourceUid); if (!dsConfig) { - throw new Error(`Cannot find data source configuration for ${dataSourceName}`); + throw new Error(`Cannot find data source configuration for ${dataSourceUid}`); } const { url, name, type } = dsConfig; @@ -78,7 +76,8 @@ export async function discoverDataSourceFeatures(dsSettings: { const rulerSupported = await hasRulerSupport(name); return { - application: PromApplication.Cortex, + // if we were not trying to discover ruler support for a "loki" type data source then assume it's Cortex. + application: type === 'loki' ? 'Loki' : PromApplication.Cortex, features: { rulerApiEnabled: rulerSupported, }, diff --git a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts index 844683dcef4..6c7141211fa 100644 --- a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts +++ b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts @@ -1,17 +1,32 @@ import { RulerDataSourceConfig } from 'app/types/unified-alerting'; -import { AlertmanagerApiFeatures, PromApplication } from '../../../../types/unified-alerting-dto'; -import { withPerformanceLogging } from '../Analytics'; -import { getRulesDataSource, isGrafanaRulesSource } from '../utils/datasource'; +import { + AlertmanagerApiFeatures, + PromApplication, + RulesSourceApplication, +} from '../../../../types/unified-alerting-dto'; +import { + getDataSourceUID, + getRulesDataSourceByUID, + GRAFANA_RULES_SOURCE_NAME, + isGrafanaRulesSource, +} from '../utils/datasource'; import { alertingApi } from './alertingApi'; -import { discoverAlertmanagerFeatures, discoverFeatures } from './buildInfo'; +import { discoverAlertmanagerFeatures, discoverFeaturesByUid } from './buildInfo'; export const GRAFANA_RULER_CONFIG: RulerDataSourceConfig = { dataSourceName: 'grafana', apiVersion: 'legacy', }; +interface RulesSourceFeatures { + name: string; + uid: string; + application: RulesSourceApplication; + rulerConfig?: RulerDataSourceConfig; +} + export const featureDiscoveryApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ discoverAmFeatures: build.query({ @@ -25,36 +40,46 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({ }, }), - discoverDsFeatures: build.query<{ rulerConfig?: RulerDataSourceConfig }, { rulesSourceName: string }>({ - queryFn: async ({ rulesSourceName }) => { - if (isGrafanaRulesSource(rulesSourceName)) { - return { data: { rulerConfig: GRAFANA_RULER_CONFIG } }; + discoverDsFeatures: build.query({ + queryFn: async (rulesSourceIdentifier) => { + const dataSourceUID = getDataSourceUID(rulesSourceIdentifier); + if (!dataSourceUID) { + return { error: new Error(`Unable to find data source for ${rulesSourceIdentifier}`) }; } - const dsSettings = getRulesDataSource(rulesSourceName); - if (!dsSettings) { - return { error: new Error(`Missing data source configuration for ${rulesSourceName}`) }; + if (isGrafanaRulesSource(dataSourceUID)) { + return { + data: { + name: GRAFANA_RULES_SOURCE_NAME, + uid: GRAFANA_RULES_SOURCE_NAME, + application: 'grafana', + rulerConfig: GRAFANA_RULER_CONFIG, + } satisfies RulesSourceFeatures, + }; } - const discoverFeaturesWithLogging = withPerformanceLogging( - 'unifiedalerting/featureDiscoveryApi/discoverDsFeatures', - discoverFeatures, - { - dataSourceName: rulesSourceName, - endpoint: 'unifiedalerting/featureDiscoveryApi/discoverDsFeatures', - } - ); + const dataSourceSettings = dataSourceUID ? getRulesDataSourceByUID(dataSourceUID) : undefined; + if (!dataSourceSettings) { + return { error: new Error(`Missing data source configuration for ${rulesSourceIdentifier}`) }; + } - const dsFeatures = await discoverFeaturesWithLogging(dsSettings.name); + const features = await discoverFeaturesByUid(dataSourceSettings.uid); - const rulerConfig: RulerDataSourceConfig | undefined = dsFeatures.features.rulerApiEnabled - ? { - dataSourceName: dsSettings.name, - apiVersion: dsFeatures.application === PromApplication.Cortex ? 'legacy' : 'config', - } + const rulerConfig = features.features.rulerApiEnabled + ? ({ + dataSourceName: dataSourceSettings.name, + apiVersion: features.application === PromApplication.Cortex ? 'legacy' : 'config', + } satisfies RulerDataSourceConfig) : undefined; - return { data: { rulerConfig } }; + return { + data: { + name: dataSourceSettings.name, + uid: dataSourceSettings.uid, + application: features.application, + rulerConfig, + } satisfies RulesSourceFeatures, + }; }, }), }), diff --git a/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx b/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx index c034123d0dc..8b85b20266f 100644 --- a/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx +++ b/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx @@ -1,18 +1,16 @@ import { Menu } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { - isGrafanaRulerRule, - isGrafanaRulerRulePaused, - getRuleGroupLocationFromCombinedRule, -} from 'app/features/alerting/unified/utils/rules'; -import { CombinedRule } from 'app/types/unified-alerting'; +import { isGrafanaRulerRule, isGrafanaRulerRulePaused } from 'app/features/alerting/unified/utils/rules'; +import { RuleGroupIdentifier } from 'app/types/unified-alerting'; +import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { usePauseRuleInGroup } from '../hooks/ruleGroup/usePauseAlertRule'; import { isLoading } from '../hooks/useAsync'; import { stringifyErrorLike } from '../utils/misc'; interface Props { - rule: CombinedRule; + rule: RulerRuleDTO; + groupIdentifier: RuleGroupIdentifier; /** * Method invoked after the request to change the paused state has completed */ @@ -23,11 +21,11 @@ interface Props { * Menu item to display correct text for pausing/resuming an alert, * and triggering API call to do so */ -const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => { +const MenuItemPauseRule = ({ rule, groupIdentifier, onPauseChange }: Props) => { const notifyApp = useAppNotification(); const [pauseRule, updateState] = usePauseRuleInGroup(); - const isPaused = isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule); + const isPaused = isGrafanaRulerRule(rule) && isGrafanaRulerRulePaused(rule); const icon = isPaused ? 'play' : 'pause'; const title = isPaused ? 'Resume evaluation' : 'Pause evaluation'; @@ -35,15 +33,14 @@ const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => { * Triggers API call to update the current rule to the new `is_paused` state */ const setRulePause = async (newIsPaused: boolean) => { - if (!isGrafanaRulerRule(rule.rulerRule)) { + if (!isGrafanaRulerRule(rule)) { return; } try { - const ruleGroupId = getRuleGroupLocationFromCombinedRule(rule); - const ruleUID = rule.rulerRule.grafana_alert.uid; + const ruleUID = rule.grafana_alert.uid; - await pauseRule.execute(ruleGroupId, ruleUID, newIsPaused); + await pauseRule.execute(groupIdentifier, ruleUID, newIsPaused); } catch (error) { notifyApp.error(`Failed to ${newIsPaused ? 'pause' : 'resume'} the rule: ${stringifyErrorLike(error)}`); return; diff --git a/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx b/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx index 4155d51a3f4..8c18b146926 100644 --- a/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx @@ -1,12 +1,9 @@ import { useCallback } from 'react'; -import { useAsync } from 'react-use'; import { DataSourceInstanceSettings } from '@grafana/data'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; -import { dispatch } from 'app/store/store'; import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler'; -import { fetchAllPromBuildInfoAction } from '../../state/actions'; interface Props { disabled?: boolean; @@ -17,20 +14,18 @@ interface Props { } export function CloudRulesSourcePicker({ value, disabled, ...props }: Props): JSX.Element { - const rulesSourcesWithRuler = useRulesSourcesWithRuler(); - - const { loading = true } = useAsync(() => dispatch(fetchAllPromBuildInfoAction()), [dispatch]); + const { rulesSourcesWithRuler: dataSourcesWithRuler, isLoading } = useRulesSourcesWithRuler(); const dataSourceFilter = useCallback( (ds: DataSourceInstanceSettings): boolean => { - return !!rulesSourcesWithRuler.find(({ id }) => id === ds.id); + return dataSourcesWithRuler.some(({ uid }) => uid === ds.uid); }, - [rulesSourcesWithRuler] + [dataSourcesWithRuler] ); return ( ({ jest.setTimeout(60 * 1000); -setupMswServer(); +const server = setupMswServer(); const dataSources = { default: mockDataSource( @@ -44,6 +45,7 @@ const dataSources = { type: DataSourceType.Alertmanager, }), }; + setupDataSources(dataSources.default, dataSources.am); const selectFolderAndGroup = async () => { @@ -76,6 +78,8 @@ describe('Can create a new grafana managed alert using simplified routing', () = AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite, ]); + + mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir); }); it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => { diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index 768d0152928..074ed6365e8 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -29,12 +29,10 @@ import { expressionTypes, ReducerMode, } from 'app/features/expressions/types'; -import { useDispatch } from 'app/types'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler'; import { useURLSearchParams } from '../../../hooks/useURLSearchParams'; -import { fetchAllPromBuildInfoAction } from '../../../state/actions'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource'; import { isPromOrLokiQuery, PromOrLokiQuery } from '../../../utils/rule-form'; @@ -184,12 +182,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P } }, [isAdvancedMode, expressionQueries, isGrafanaAlertingType, setSimpleCondition]); - const dispatchReduxAction = useDispatch(); - useEffect(() => { - dispatchReduxAction(fetchAllPromBuildInfoAction()); - }, [dispatchReduxAction]); - - const rulesSourcesWithRuler = useRulesSourcesWithRuler(); + const { rulesSourcesWithRuler } = useRulesSourcesWithRuler(); const runQueriesPreview = useCallback( (condition?: string) => { @@ -322,7 +315,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P [runQueriesPreview, setValue, updateExpressionAndDatasource] ); - const recordingRuleDefaultDatasource = rulesSourcesWithRuler[0]; + // Using dataSourcesWithRuler[0] gives incorrect types - no undefined + // Using at(0) provides a safe type with undefined + const recordingRuleDefaultDatasource = rulesSourcesWithRuler.at(0); useEffect(() => { clearPreviewData(); diff --git a/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx b/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx index 82c777ae2ed..97eb2af8be2 100644 --- a/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx @@ -1,13 +1,10 @@ import { css } from '@emotion/css'; import { isEmpty } from 'lodash'; -import { useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data/src'; import { useStyles2, Stack } from '@grafana/ui'; -import { dispatch } from 'app/store/store'; import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler'; -import { fetchAllPromBuildInfoAction } from '../../../state/actions'; import { RuleFormType } from '../../../types/rule-form'; import { GrafanaManagedRuleType } from './GrafanaManagedAlert'; @@ -19,13 +16,9 @@ interface RuleTypePickerProps { } const RuleTypePicker = ({ selected, onChange, enabledTypes }: RuleTypePickerProps) => { - const rulesSourcesWithRuler = useRulesSourcesWithRuler(); + const { rulesSourcesWithRuler } = useRulesSourcesWithRuler(); const hasLotexDatasources = !isEmpty(rulesSourcesWithRuler); - useEffect(() => { - dispatch(fetchAllPromBuildInfoAction()); - }, []); - const styles = useStyles2(getStyles); const handleChange = (type: RuleFormType) => { diff --git a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx index 832973c83f3..283a68c2b8c 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx @@ -4,22 +4,23 @@ import appEvents from 'app/core/app_events'; import MenuItemPauseRule from 'app/features/alerting/unified/components/MenuItemPauseRule'; import MoreButton from 'app/features/alerting/unified/components/MoreButton'; import { useRulePluginLinkExtension } from 'app/features/alerting/unified/plugins/useRulePluginLinkExtensions'; -import { isAlertingRule } from 'app/features/alerting/unified/utils/rules'; -import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; -import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting'; +import { PromAlertingRuleState, RulerRuleDTO } from 'app/types/unified-alerting-dto'; -import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities'; +import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities'; import { createShareLink, isLocalDevEnv, isOpenSourceEdition } from '../../utils/misc'; import * as ruleId from '../../utils/rule-id'; +import { isAlertingRule } from '../../utils/rules'; import { createRelativeUrl } from '../../utils/url'; import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton'; interface Props { - rule: CombinedRule; + promRule: Rule; + rulerRule?: RulerRuleDTO; identifier: RuleIdentifier; - showCopyLinkButton?: boolean; + groupIdentifier: RuleGroupIdentifier; handleSilence: () => void; - handleDelete: (rule: CombinedRule) => void; + handleDelete: (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifier) => void; handleDuplicateRule: (identifier: RuleIdentifier) => void; onPauseChange?: () => void; buttonSize?: ComponentSize; @@ -30,9 +31,10 @@ interface Props { * dropdown menu */ const AlertRuleMenu = ({ - rule, + promRule, + rulerRule, identifier, - showCopyLinkButton, + groupIdentifier, handleSilence, handleDelete, handleDuplicateRule, @@ -40,22 +42,30 @@ const AlertRuleMenu = ({ buttonSize, }: Props) => { // check all abilities and permissions - const [pauseSupported, pauseAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Pause); + const [pauseSupported, pauseAllowed] = useRulerRuleAbility(rulerRule, groupIdentifier, AlertRuleAction.Pause); const canPause = pauseSupported && pauseAllowed; - const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete); + const [deleteSupported, deleteAllowed] = useRulerRuleAbility(rulerRule, groupIdentifier, AlertRuleAction.Delete); const canDelete = deleteSupported && deleteAllowed; - const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate); + const [duplicateSupported, duplicateAllowed] = useRulerRuleAbility( + rulerRule, + groupIdentifier, + AlertRuleAction.Duplicate + ); const canDuplicate = duplicateSupported && duplicateAllowed; - const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence); + const [silenceSupported, silenceAllowed] = useRulerRuleAbility(rulerRule, groupIdentifier, AlertRuleAction.Silence); const canSilence = silenceSupported && silenceAllowed; - const [exportSupported, exportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport); + const [exportSupported, exportAllowed] = useRulerRuleAbility( + rulerRule, + groupIdentifier, + AlertRuleAction.ModifyExport + ); const canExport = exportSupported && exportAllowed; - const ruleExtensionLinks = useRulePluginLinkExtension(rule); + const ruleExtensionLinks = useRulePluginLinkExtension(promRule, groupIdentifier); const extensionsAvailable = ruleExtensionLinks.length > 0; @@ -63,21 +73,25 @@ const AlertRuleMenu = ({ * Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana. * We should show it in development mode */ + // @TODO Migrate "declare incident button" to plugin links extensions const shouldShowDeclareIncidentButton = (!isOpenSourceEdition() || isLocalDevEnv()) && - isAlertingRule(rule.promRule) && - rule.promRule.state === PromAlertingRuleState.Firing; - const shareUrl = createShareLink(rule.namespace.rulesSource, rule); + isAlertingRule(promRule) && + promRule.state === PromAlertingRuleState.Firing; + + const shareUrl = createShareLink(identifier); const showDivider = - [canPause, canSilence, shouldShowDeclareIncidentButton, canDuplicate].some(Boolean) && - [showCopyLinkButton, canExport].some(Boolean); + [canPause, canSilence, shouldShowDeclareIncidentButton, canDuplicate].some(Boolean) && [canExport].some(Boolean); const menuItems = ( <> - {canPause && } + {canPause && rulerRule && ( + + )} {canSilence && } - {shouldShowDeclareIncidentButton && } + {/* TODO Migrate Declare Incident to plugin links extensions */} + {shouldShowDeclareIncidentButton && } {canDuplicate && handleDuplicateRule(identifier)} />} {showDivider && } {shareUrl && copyToClipboard(shareUrl)} />} @@ -96,10 +110,15 @@ const AlertRuleMenu = ({ ))} )} - {canDelete && ( + {canDelete && rulerRule && ( <> - handleDelete(rule)} /> + handleDelete(rulerRule, groupIdentifier)} + /> )} diff --git a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx index 2aca5f867f7..d8834ecc4e7 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx @@ -3,21 +3,23 @@ import { useState, useCallback, useMemo } from 'react'; import { locationService } from '@grafana/runtime'; import { ConfirmModal } from '@grafana/ui'; import { dispatch } from 'app/store/store'; -import { CombinedRule } from 'app/types/unified-alerting'; +import { RuleGroupIdentifier } from 'app/types/unified-alerting'; +import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { shouldUsePrometheusRulesPrimary } from '../../featureToggles'; import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup'; import { usePrometheusConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck'; import { fetchPromAndRulerRulesAction, fetchRulerRulesAction } from '../../state/actions'; import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id'; -import { getRuleGroupLocationFromCombinedRule, isCloudRuleIdentifier } from '../../utils/rules'; +import { isCloudRuleIdentifier } from '../../utils/rules'; -type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void]; +type DeleteModalHook = [JSX.Element, (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifier) => void, () => void]; +type DeleteRuleInfo = { rule: RulerRuleDTO; groupIdentifier: RuleGroupIdentifier } | undefined; const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); export const useDeleteModal = (redirectToListView = false): DeleteModalHook => { - const [ruleToDelete, setRuleToDelete] = useState(); + const [ruleToDelete, setRuleToDelete] = useState(); const [deleteRuleFromGroup] = useDeleteRuleFromGroup(); const { waitForRemoval } = usePrometheusConsistencyCheck(); @@ -25,40 +27,37 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => { setRuleToDelete(undefined); }, []); - const showModal = useCallback((rule: CombinedRule) => { - setRuleToDelete(rule); + const showModal = useCallback((rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifier) => { + setRuleToDelete({ rule, groupIdentifier }); }, []); - const deleteRule = useCallback( - async (rule?: CombinedRule) => { - if (!rule?.rulerRule) { - return; - } + const deleteRule = useCallback(async () => { + if (!ruleToDelete) { + return; + } - const ruleGroupIdentifier = getRuleGroupLocationFromCombinedRule(rule); - const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, rule.rulerRule); + const { rule, groupIdentifier } = ruleToDelete; - await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier); + const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(groupIdentifier, rule); + await deleteRuleFromGroup.execute(groupIdentifier, ruleIdentifier); - // refetch rules for this rules source - // @TODO remove this when we moved everything to RTKQ – then the endpoint will simply invalidate the tags - dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: ruleGroupIdentifier.dataSourceName })); + // refetch rules for this rules source + // @TODO remove this when we moved everything to RTKQ – then the endpoint will simply invalidate the tags + dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: groupIdentifier.dataSourceName })); - if (prometheusRulesPrimary && isCloudRuleIdentifier(ruleIdentifier)) { - await waitForRemoval(ruleIdentifier); - } else { - // Without this the delete popup will close and the user will still see the deleted rule - await dispatch(fetchRulerRulesAction({ rulesSourceName: ruleGroupIdentifier.dataSourceName })); - } + if (prometheusRulesPrimary && isCloudRuleIdentifier(ruleIdentifier)) { + await waitForRemoval(ruleIdentifier); + } else { + // Without this the delete popup will close and the user will still see the deleted rule + await dispatch(fetchRulerRulesAction({ rulesSourceName: groupIdentifier.dataSourceName })); + } - dismissModal(); + dismissModal(); - if (redirectToListView) { - locationService.replace('/alerting/list'); - } - }, - [deleteRuleFromGroup, dismissModal, redirectToListView, waitForRemoval] - ); + if (redirectToListView) { + locationService.replace('/alerting/list'); + } + }, [deleteRuleFromGroup, dismissModal, ruleToDelete, redirectToListView, waitForRemoval]); const modal = useMemo( () => ( @@ -68,11 +67,11 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => { body="Deleting this rule will permanently remove it from your alert rule list. Are you sure you want to delete this rule?" confirmText="Yes, delete" icon="exclamation-triangle" - onConfirm={() => deleteRule(ruleToDelete)} + onConfirm={deleteRule} onDismiss={dismissModal} /> ), - [deleteRule, dismissModal, ruleToDelete] + [ruleToDelete, deleteRule, dismissModal] ); return [modal, showModal, dismissModal]; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx index 1ef1f681913..157b723fd1c 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -65,18 +65,17 @@ const RuleViewer = () => { // we want to be able to show a modal if the rule has been provisioned explain the limitations // of duplicating provisioned alert rules const [duplicateRuleIdentifier, setDuplicateRuleIdentifier] = useState(); + const { annotations, promRule, rulerRule } = rule; - const { annotations, promRule } = rule; - const hasError = isErrorHealth(rule.promRule?.health); - + const hasError = isErrorHealth(promRule?.health); const isAlertType = isAlertingRule(promRule); const isFederatedRule = isFederatedRuleGroup(rule.group); - const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); - const isPaused = isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule); + const isProvisioned = isGrafanaRulerRule(rulerRule) && Boolean(rulerRule.grafana_alert.provenance); + const isPaused = isGrafanaRulerRule(rulerRule) && isGrafanaRulerRulePaused(rulerRule); const showError = hasError && !isPaused; - const ruleOrigin = getRulePluginOrigin(rule); + const ruleOrigin = rulerRule ? getRulePluginOrigin(rulerRule) : getRulePluginOrigin(promRule); const summary = annotations[Annotation.summary]; @@ -90,12 +89,12 @@ const RuleViewer = () => { name={title} paused={isPaused} state={isAlertType ? promRule.state : undefined} - health={rule.promRule?.health} - ruleType={rule.promRule?.type} + health={promRule?.health} + ruleType={promRule?.type} ruleOrigin={ruleOrigin} /> )} - actions={} + actions={} info={createMetadata(rule)} subTitle={ @@ -327,7 +326,7 @@ function isValidTab(tab: UrlQueryValue): tab is ActiveTab { function usePageNav(rule: CombinedRule) { const [activeTab, setActiveTab] = useActiveTab(); - const { annotations, promRule } = rule; + const { annotations, promRule, rulerRule } = rule; const summary = annotations[Annotation.summary]; const isAlertType = isAlertingRule(promRule); @@ -336,8 +335,8 @@ function usePageNav(rule: CombinedRule) { const namespaceName = decodeGrafanaNamespace(rule.namespace).name; const groupName = rule.group.name; - const isGrafanaAlertRule = isGrafanaRulerRule(rule.rulerRule) && isAlertType; - const isRecordingRuleType = isRecordingRule(rule.promRule); + const isGrafanaAlertRule = isGrafanaRulerRule(rulerRule) && isAlertType; + const isRecordingRuleType = isRecordingRule(promRule); const pageNav: NavModelItem = { ...defaultPageNav, diff --git a/public/app/features/alerting/unified/components/rules/CloudRules.tsx b/public/app/features/alerting/unified/components/rules/CloudRules.tsx index 0c624958b3e..f775ee4bed2 100644 --- a/public/app/features/alerting/unified/components/rules/CloudRules.tsx +++ b/public/app/features/alerting/unified/components/rules/CloudRules.tsx @@ -27,17 +27,13 @@ interface Props { export const CloudRules = ({ namespaces, expandAll }: Props) => { const styles = useStyles2(getStyles); - const dsConfigs = useUnifiedAlertingSelector((state) => state.dataSources); const promRules = useUnifiedAlertingSelector((state) => state.promRules); const rulesDataSources = useMemo(getRulesDataSources, []); const groupsWithNamespaces = useCombinedGroupNamespace(namespaces); const dataSourcesLoading = useMemo( - () => - rulesDataSources.filter( - (ds) => isAsyncRequestStatePending(promRules[ds.name]) || isAsyncRequestStatePending(dsConfigs[ds.name]) - ), - [promRules, dsConfigs, rulesDataSources] + () => rulesDataSources.filter((ds) => isAsyncRequestStatePending(promRules[ds.name])), + [promRules, rulesDataSources] ); const hasSomeResults = rulesDataSources.some((ds) => Boolean(promRules[ds.name]?.result?.length)); diff --git a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx index 3a40868f20e..1899f7be274 100644 --- a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx @@ -15,15 +15,20 @@ import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Badge, Button, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; -import { dispatch, getState } from 'app/store/store'; -import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting'; +import { dispatch } from 'app/store/store'; +import { + CombinedRuleGroup, + CombinedRuleNamespace, + RuleGroupIdentifier, + RulerDataSourceConfig, +} from 'app/types/unified-alerting'; import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { alertRuleApi } from '../../api/alertRuleApi'; import { useReorderRuleForRuleGroup } from '../../hooks/ruleGroup/useUpdateRuleGroup'; import { isLoading } from '../../hooks/useAsync'; import { swapItems, SwapOperation } from '../../reducers/ruler/ruleGroups'; -import { fetchRulerRulesAction, getDataSourceRulerConfig } from '../../state/actions'; +import { fetchRulerRulesAction } from '../../state/actions'; import { isCloudRulesSource } from '../../utils/datasource'; import { hashRulerRule } from '../../utils/rule-id'; import { @@ -38,6 +43,7 @@ interface ModalProps { group: CombinedRuleGroup; onClose: () => void; folderUid?: string; + rulerConfig: RulerDataSourceConfig; } type RulerRuleWithUID = { uid: string } & RulerRuleDTO; @@ -52,11 +58,9 @@ export const ReorderCloudGroupModal = (props: ModalProps) => { // The list of rules might have been filtered before we get to this reordering modal // We need to grab the full (unfiltered) list - const dataSourceName = rulesSourceToDataSourceName(namespace.rulesSource); - const rulerConfig = getDataSourceRulerConfig(getState, dataSourceName); const { currentData: ruleGroup, isLoading: loadingRules } = alertRuleApi.endpoints.getRuleGroupForNamespace.useQuery( { - rulerConfig, + rulerConfig: props.rulerConfig, namespace: folderUid ?? namespace.name, group: group.name, }, @@ -97,8 +101,10 @@ export const ReorderCloudGroupModal = (props: ModalProps) => { ); const updateRulesOrder = useCallback(async () => { + const dataSourceName = rulesSourceToDataSourceName(namespace.rulesSource); + const ruleGroupIdentifier: RuleGroupIdentifier = { - dataSourceName: rulesSourceToDataSourceName(namespace.rulesSource), + dataSourceName, groupName: group.name, namespaceName: folderUid ?? namespace.name, }; @@ -107,16 +113,7 @@ export const ReorderCloudGroupModal = (props: ModalProps) => { // TODO: Remove once RTKQ is more prevalently used await dispatch(fetchRulerRulesAction({ rulesSourceName: dataSourceName })); onClose(); - }, [ - namespace.rulesSource, - namespace.name, - group.name, - folderUid, - reorderRulesInGroup, - operations, - dataSourceName, - onClose, - ]); + }, [namespace.rulesSource, namespace.name, group.name, folderUid, reorderRulesInGroup, operations, onClose]); // assign unique but stable identifiers to each (alerting / recording) rule const rulesWithUID: RulerRuleWithUID[] = rulesList.map((rulerRule) => ({ diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx index 4418745fbd7..bddce82d705 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx @@ -1,11 +1,10 @@ -import { produce } from 'immer'; import { render, screen, userEvent } from 'test/test-utils'; import { byLabelText, byRole } from 'testing-library-selector'; import { config, setPluginLinksHook } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { RuleActionsButtons } from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; -import { setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { mockFeatureDiscoveryApi, setupMswServer } from 'app/features/alerting/unified/mockApi'; import { getCloudRule, getGrafanaRule, @@ -14,11 +13,13 @@ import { mockGrafanaRulerRule, mockPromAlertingRule, } from 'app/features/alerting/unified/mocks'; -import { configureStore } from 'app/store/configureStore'; import { AccessControlAction } from 'app/types'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; -setupMswServer(); +import { setupDataSources } from '../../testSetup/datasources'; +import { buildInfoResponse } from '../../testSetup/featureDiscovery'; + +const server = setupMswServer(); jest.mock('app/core/services/context_srv'); const mockContextSrv = jest.mocked(contextSrv); @@ -35,6 +36,8 @@ const grantAllPermissions = () => { AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleDelete, AccessControlAction.AlertingInstanceCreate, + AccessControlAction.AlertingRuleExternalRead, + AccessControlAction.AlertingRuleExternalWrite, ]); mockContextSrv.hasPermissionInMetadata.mockImplementation(() => true); mockContextSrv.hasPermission.mockImplementation(() => true); @@ -58,6 +61,9 @@ setPluginLinksHook(() => ({ isLoading: false, })); +const mimirDs = mockDataSource({ uid: 'mimir', name: 'Mimir' }); +setupDataSources(mimirDs); + const clickCopyLink = async () => { const user = userEvent.setup(); await user.click(await ui.moreButton.find()); @@ -70,7 +76,7 @@ describe('RuleActionsButtons', () => { grantAllPermissions(); const mockRule = getGrafanaRule(); - render(); + render(); await user.click(await ui.moreButton.find()); @@ -93,26 +99,10 @@ describe('RuleActionsButtons', () => { it('renders correct options for Cloud rule', async () => { const user = userEvent.setup(); grantAllPermissions(); - const mockRule = getCloudRule(); - const dataSource = mockDataSource({ id: 1 }); + const mockRule = getCloudRule(undefined, { rulesSource: mimirDs }); + mockFeatureDiscoveryApi(server).discoverDsFeatures(mimirDs, buildInfoResponse.mimir); - const defaultState = configureStore().getState(); - render(, { - preloadedState: produce(defaultState, (store) => { - store.unifiedAlerting.dataSources[dataSource.name] = { - loading: false, - dispatched: true, - result: { - id: 'test-ds', - name: dataSource.name, - rulerConfig: { - dataSourceName: dataSource.name, - apiVersion: 'config', - }, - }, - }; - }), - }); + render(); await user.click(await ui.moreButton.find()); @@ -162,14 +152,16 @@ describe('RuleActionsButtons', () => { }); it('copies correct URL for cloud rule', async () => { - const mockRule = getCloudRule(); + const promDataSource = mockDataSource({ name: 'Prometheus-2' }); - render(); + const mockRule = getCloudRule({ name: 'pod-1-cpu-firing' }); + + render(); await clickCopyLink(); expect(await navigator.clipboard.readText()).toBe( - 'http://localhost:3000/sub/alerting/Prometheus-2/mockRule/find' + 'http://localhost:3000/sub/alerting/Prometheus-2/pod-1-cpu-firing/find' ); }); }); diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index eb0cededcba..04040a39161 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -16,7 +16,7 @@ import { fetchPromAndRulerRulesAction } from '../../state/actions'; import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource'; import { createViewLink } from '../../utils/misc'; import * as ruleId from '../../utils/rule-id'; -import { isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules'; +import { getRuleGroupLocationFromCombinedRule, isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules'; import { createRelativeUrl } from '../../utils/url'; import { RedirectToCloneRule } from './CloneRule'; @@ -32,13 +32,12 @@ interface Props { */ compact?: boolean; showViewButton?: boolean; - showCopyLinkButton?: boolean; } /** * **Action** buttons to show for an alert rule - e.g. "View", "Edit", "More..." */ -export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton, rule, rulesSource }: Props) => { +export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource }: Props) => { const dispatch = useDispatch(); const redirectToListView = compact ? false : true; @@ -65,6 +64,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton const sourceName = getRulesSourceName(rulesSource); const identifier = ruleId.fromCombinedRule(sourceName, rule); + const groupIdentifier = getRuleGroupLocationFromCombinedRule(rule); if (showViewButton) { buttons.push( @@ -93,15 +93,23 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton ); } + if (!rule.promRule) { + return null; + } + return ( {buttons} showDeleteModal(rule)} + groupIdentifier={groupIdentifier} + handleDelete={() => { + if (rule.rulerRule) { + showDeleteModal(rule.rulerRule, groupIdentifier); + } + }} handleSilence={() => setShowSilenceDrawer(true)} handleDuplicateRule={() => setRedirectToClone({ identifier, isProvisioned })} onPauseChange={() => { @@ -113,6 +121,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton // on tag invalidation (or optimistic cache updates) for this dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME, limitAlerts })); }} + buttonSize={buttonSize} /> {deleteModal} {isGrafanaAlertingRule(rule.rulerRule) && showSilenceDrawer && ( diff --git a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx index 39148ab5415..26f2da575ad 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx @@ -15,17 +15,12 @@ import { isRulerNotSupportedResponse } from '../../utils/rules'; export function RuleListErrors(): ReactElement { const [expanded, setExpanded] = useState(false); const [closed, setClosed] = useLocalStorage('grafana.unifiedalerting.hideErrors', false); - const dataSourceConfigRequests = useUnifiedAlertingSelector((state) => state.dataSources); const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const styles = useStyles2(getStyles); const errors = useMemo((): JSX.Element[] => { - const [dataSourceConfigErrors, promRequestErrors, rulerRequestErrors] = [ - dataSourceConfigRequests, - promRuleRequests, - rulerRuleRequests, - ].map((requests) => + const [promRequestErrors, rulerRequestErrors] = [promRuleRequests, rulerRuleRequests].map((requests) => getRulesDataSources().reduce>( (result, dataSource) => { const error = requests[dataSource.name]?.error; @@ -49,18 +44,6 @@ export function RuleListErrors(): ReactElement { result.push(<>Failed to load Grafana rules config: {grafanaRulerError.message || 'Unknown error.'}); } - dataSourceConfigErrors.forEach(({ dataSource, error }) => { - result.push( - <> - Failed to load the data source configuration for{' '} - - {dataSource.name} - - : {error.message || 'Unknown error.'} - - ); - }); - promRequestErrors.forEach(({ dataSource, error }) => result.push( <> @@ -86,7 +69,7 @@ export function RuleListErrors(): ReactElement { ); return result; - }, [dataSourceConfigRequests, promRuleRequests, rulerRuleRequests, styles.dsLink]); + }, [promRuleRequests, rulerRuleRequests, styles.dsLink]); return ( <> diff --git a/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx b/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx index 01199866c97..69faaec1d5c 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx @@ -102,7 +102,7 @@ const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: C return null; } - const originMeta = getRulePluginOrigin(rule); + const originMeta = getRulePluginOrigin(rule.promRule); return ( } origin={originMeta} diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index 3cee6fead7d..f8a1f6d7431 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -8,6 +8,7 @@ import { Badge, ConfirmModal, Icon, Spinner, Stack, Tooltip, useStyles2 } from ' import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier, RulesSource } from 'app/types/unified-alerting'; import { LogMessages, logInfo } from '../../Analytics'; +import { featureDiscoveryApi } from '../../api/featureDiscoveryApi'; import { useDeleteRuleGroup } from '../../hooks/ruleGroup/useDeleteRuleGroup'; import { useFolder } from '../../hooks/useFolder'; import { useHasRuler } from '../../hooks/useHasRuler'; @@ -36,8 +37,12 @@ interface Props { viewMode: ViewMode; } +const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; + export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => { const { rulesSource } = namespace; + const rulesSourceName = getRulesSourceName(rulesSource); + const [deleteRuleGroup] = useDeleteRuleGroup(); const styles = useStyles2(getStyles); @@ -54,6 +59,8 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: }, [expandAll]); const { hasRuler, rulerRulesLoaded } = useHasRuler(namespace.rulesSource); + const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ rulesSourceName }); + const rulerRule = group.rules[0]?.rulerRule; const folderUID = (rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid) || undefined; const { folder } = useFolder(folderUID); @@ -276,12 +283,13 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: folderUid={folderUID} /> )} - {isReorderingGroup && ( + {isReorderingGroup && dsFeatures?.rulerConfig && ( setIsReorderingGroup(false)} + rulerConfig={dsFeatures.rulerConfig} /> )} { const grafanaRule = getGrafanaRule({ name: 'Grafana' }); it('Should not render Edit button for users without the update permission', async () => { + mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => { + return action === AlertRuleAction.Update ? [true, false] : [true, true]; + }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { return action === AlertRuleAction.Update ? [true, false] : [true, true]; }); @@ -55,6 +62,9 @@ describe('RulesTable RBAC', () => { }); it('Should not render Delete button for users without the delete permission', async () => { + mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => { + return action === AlertRuleAction.Delete ? [true, false] : [true, true]; + }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { return action === AlertRuleAction.Delete ? [true, false] : [true, true]; }); @@ -67,15 +77,22 @@ describe('RulesTable RBAC', () => { }); it('Should render Edit button for users with the update permission', async () => { + mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => { + return action === AlertRuleAction.Update ? [true, true] : [false, false]; + }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { return action === AlertRuleAction.Update ? [true, true] : [false, false]; }); + render(); expect(await ui.actionButtons.edit.find()).toBeInTheDocument(); }); it('Should render Delete button for users with the delete permission', async () => { + mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => { + return action === AlertRuleAction.Delete ? [true, true] : [false, false]; + }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { return action === AlertRuleAction.Delete ? [true, true] : [false, false]; }); @@ -103,6 +120,9 @@ describe('RulesTable RBAC', () => { }; beforeEach(() => { + mocks.useRulerRuleAbility.mockImplementation(() => { + return [true, true]; + }); mocks.useAlertRuleAbility.mockImplementation(() => { return [true, true]; }); @@ -135,6 +155,9 @@ describe('RulesTable RBAC', () => { const cloudRule = getCloudRule({ name: 'Cloud' }); it('Should not render Edit button for users without the update permission', async () => { + mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => { + return action === AlertRuleAction.Update ? [true, false] : [true, true]; + }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { return action === AlertRuleAction.Update ? [true, false] : [true, true]; }); @@ -145,6 +168,9 @@ describe('RulesTable RBAC', () => { }); it('Should not render Delete button for users without the delete permission', async () => { + mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => { + return action === AlertRuleAction.Delete ? [true, false] : [true, true]; + }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { return action === AlertRuleAction.Delete ? [true, false] : [true, true]; }); @@ -156,6 +182,9 @@ describe('RulesTable RBAC', () => { }); it('Should render Edit button for users with the update permission', async () => { + mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => { + return action === AlertRuleAction.Update ? [true, true] : [false, false]; + }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { return action === AlertRuleAction.Update ? [true, true] : [false, false]; }); @@ -166,6 +195,9 @@ describe('RulesTable RBAC', () => { }); it('Should render Delete button for users with the delete permission', async () => { + mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => { + return action === AlertRuleAction.Delete ? [true, true] : [false, false]; + }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => { return action === AlertRuleAction.Delete ? [true, true] : [false, false]; }); diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index 25523ea81b1..15f5bffb04b 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -214,9 +214,9 @@ function useColumns( label: '', // eslint-disable-next-line react/display-name renderCell: ({ data: rule }) => { - const rulerRule = rule.rulerRule; + const { promRule, rulerRule } = rule; - const originMeta = getRulePluginOrigin(rule); + const originMeta = getRulePluginOrigin(promRule ?? rulerRule); if (originMeta) { return ; } diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index b2df0f55f52..1ff6b578acf 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -9,12 +9,13 @@ import { import { useFolder } from 'app/features/alerting/unified/hooks/useFolder'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; -import { CombinedRule } from 'app/types/unified-alerting'; +import { CombinedRule, RuleGroupIdentifier } from 'app/types/unified-alerting'; +import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { alertmanagerApi } from '../api/alertmanagerApi'; import { useAlertmanager } from '../state/AlertmanagerContext'; import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control'; -import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import { getRulesSourceName } from '../utils/datasource'; import { isAdmin } from '../utils/misc'; import { isFederatedRuleGroup, isGrafanaRecordingRule, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules'; @@ -155,6 +156,30 @@ export function useAlertRuleAbilities(rule: CombinedRule, actions: AlertRuleActi }, [abilities, actions]); } +export function useRulerRuleAbility( + rule: RulerRuleDTO | undefined, + groupIdentifier: RuleGroupIdentifier, + action: AlertRuleAction +): Ability { + const abilities = useAllRulerRuleAbilities(rule, groupIdentifier); + + return useMemo(() => { + return abilities[action]; + }, [abilities, action]); +} + +export function useRulerRuleAbilities( + rule: RulerRuleDTO, + groupIdentifier: RuleGroupIdentifier, + actions: AlertRuleAction[] +): Ability[] { + const abilities = useAllRulerRuleAbilities(rule, groupIdentifier); + + return useMemo(() => { + return actions.map((action) => abilities[action]); + }, [abilities, actions]); +} + // This hook is being called a lot in different places // In some cases multiple times for ~80 rules (e.g. on the list page) // We need to investigate further if some of these calls are redundant @@ -169,13 +194,13 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities>(() => { const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); const isFederated = isFederatedRuleGroup(rule.group); const isGrafanaManagedAlertRule = isGrafanaRulerRule(rule.rulerRule); - const isPluginProvided = isPluginProvidedRule(rule); + const isPluginProvided = isPluginProvidedRule(rule.rulerRule); // if a rule is either provisioned, federated or provided by a plugin rule, we don't allow it to be removed or edited const immutableRule = isProvisioned || isFederated || isPluginProvided; @@ -206,6 +231,52 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities { + const rulesSourceName = groupIdentifier.dataSourceName; + + const { isEditable, isRemovable, isRulerAvailable = false, loading } = useIsRuleEditable(rulesSourceName, rule); + const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules); + const canSilence = useCanSilence(rule); + + const abilities = useMemo>(() => { + const isProvisioned = isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance); + // const isFederated = isFederatedRuleGroup(); + const isFederated = false; + const isGrafanaManagedAlertRule = isGrafanaRulerRule(rule); + const isPluginProvided = isPluginProvidedRule(rule); + + // if a rule is either provisioned, federated or provided by a plugin rule, we don't allow it to be removed or edited + const immutableRule = isProvisioned || isFederated || isPluginProvided; + + // while we gather info, pretend it's not supported + const MaybeSupported = loading ? NotSupported : isRulerAvailable; + const MaybeSupportedUnlessImmutable = immutableRule ? NotSupported : MaybeSupported; + + // Creating duplicates of plugin-provided rules does not seem to make a lot of sense + const duplicateSupported = isPluginProvided ? NotSupported : MaybeSupported; + + const rulesPermissions = getRulesPermissions(rulesSourceName); + + const abilities: Abilities = { + [AlertRuleAction.Duplicate]: toAbility(duplicateSupported, rulesPermissions.create), + [AlertRuleAction.View]: toAbility(AlwaysSupported, rulesPermissions.read), + [AlertRuleAction.Update]: [MaybeSupportedUnlessImmutable, isEditable ?? false], + [AlertRuleAction.Delete]: [MaybeSupportedUnlessImmutable, isRemovable ?? false], + [AlertRuleAction.Explore]: toAbility(AlwaysSupported, AccessControlAction.DataSourcesExplore), + [AlertRuleAction.Silence]: canSilence, + [AlertRuleAction.ModifyExport]: [isGrafanaManagedAlertRule, exportAllowed], + [AlertRuleAction.Pause]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false], + }; + + return abilities; + }, [rule, loading, isRulerAvailable, rulesSourceName, isEditable, isRemovable, canSilence, exportAllowed]); + + return abilities; +} + export function useAllAlertmanagerAbilities(): Abilities { const { selectedAlertmanager, @@ -335,24 +406,27 @@ export function useAlertmanagerAbilities(actions: AlertmanagerAction[]): Ability }, [abilities, actions]); } +const { useGetGrafanaAlertingConfigurationStatusQuery } = alertmanagerApi; /** * We don't want to show the silence button if either * 1. the user has no permissions to create silences * 2. the admin has configured to only send instances to external AMs */ -function useCanSilence(rule: CombinedRule): [boolean, boolean] { - const rulesSource = rule.namespace.rulesSource; - const isGrafanaManagedRule = rulesSource === GRAFANA_RULES_SOURCE_NAME; - const isGrafanaRecording = isGrafanaRecordingRule(rule.rulerRule); - - const { currentData: amConfigStatus, isLoading } = - alertmanagerApi.endpoints.getGrafanaAlertingConfigurationStatus.useQuery(undefined, { - skip: !isGrafanaManagedRule, - }); - - const folderUID = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.namespace_uid : undefined; +function useCanSilence(rule?: RulerRuleDTO): [boolean, boolean] { + const folderUID = isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined; const { loading: folderIsLoading, folder } = useFolder(folderUID); + const isGrafanaManagedRule = rule && isGrafanaRulerRule(rule); + const isGrafanaRecording = rule && isGrafanaRecordingRule(rule); + + const { currentData: amConfigStatus, isLoading } = useGetGrafanaAlertingConfigurationStatusQuery(undefined, { + skip: !isGrafanaManagedRule || !rule, + }); + + if (!rule) { + return [false, false]; + } + // we don't support silencing when the rule is not a Grafana managed alerting rule // we simply don't know what Alertmanager the ruler is sending alerts to if (!isGrafanaManagedRule || isGrafanaRecording || isLoading || folderIsLoading || !folder) { diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index a01cce491b8..8a12bb04e5a 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -240,7 +240,11 @@ export function useRuleWithLocation({ }): RequestState { const ruleSource = getRulesSourceFromIdentifier(ruleIdentifier); - const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleIdentifier.ruleSourceName); + const { data: dsFeatures, isLoading: isLoadingDsFeatures } = + featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({ + rulesSourceName: ruleIdentifier.ruleSourceName, + }); + const { loading: isLoadingRuleLocation, error: ruleLocationError, diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index be82f26a3b3..0c756bb45a8 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -272,7 +272,7 @@ const reduceGroups = (filterState: RulesFilter) => { } if ('plugins' in matchesFilterFor && filterState.plugins === 'hide') { - matchesFilterFor.plugins = !isPluginProvidedRule(rule); + matchesFilterFor.plugins = rule.rulerRule && !isPluginProvidedRule(rule.rulerRule); } if ('contactPoint' in matchesFilterFor) { diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx b/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx index ad3be5526f6..beb802cb88e 100644 --- a/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx +++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx @@ -3,9 +3,13 @@ import * as React from 'react'; import { Provider } from 'react-redux'; import { contextSrv } from 'app/core/services/context_srv'; -import { AccessControlAction, FolderDTO, StoreState } from 'app/types'; +import { configureStore } from 'app/store/configureStore'; +import { AccessControlAction, FolderDTO } from 'app/types'; -import { mockFolder, mockRulerAlertingRule, mockRulerGrafanaRule, mockUnifiedAlertingStore } from '../mocks'; +import { mockFeatureDiscoveryApi, setupMswServer } from '../mockApi'; +import { mockDataSource, mockFolder, mockRulerAlertingRule, mockRulerGrafanaRule } from '../mocks'; +import { setupDataSources } from '../testSetup/datasources'; +import { buildInfoResponse } from '../testSetup/featureDiscovery'; import { useFolder } from './useFolder'; import { useIsRuleEditable } from './useIsRuleEditable'; @@ -16,6 +20,14 @@ const mocks = { useFolder: jest.mocked(useFolder), }; +const server = setupMswServer(); + +const dataSources = { + mimir: mockDataSource({ uid: 'mimir', name: 'Mimir' }), +}; + +setupDataSources(dataSources.mimir); + describe('useIsRuleEditable', () => { describe('RBAC enabled', () => { describe('Grafana rules', () => { @@ -95,13 +107,17 @@ describe('useIsRuleEditable', () => { beforeEach(() => { mocks.useFolder.mockReturnValue({ loading: false }); contextSrv.isEditor = true; + + mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.mimir, buildInfoResponse.mimir); }); it('Should allow editing and deleting when the user has alert rule external write permission', async () => { mockPermissions([AccessControlAction.AlertingRuleExternalWrite]); const wrapper = getProviderWrapper(); - const { result } = renderHook(() => useIsRuleEditable('cortex', mockRulerAlertingRule()), { wrapper }); + const { result } = renderHook(() => useIsRuleEditable(dataSources.mimir.name, mockRulerAlertingRule()), { + wrapper, + }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.isEditable).toBe(true); @@ -112,7 +128,9 @@ describe('useIsRuleEditable', () => { mockPermissions([]); const wrapper = getProviderWrapper(); - const { result } = renderHook(() => useIsRuleEditable('cortex', mockRulerAlertingRule()), { wrapper }); + const { result } = renderHook(() => useIsRuleEditable(dataSources.mimir.name, mockRulerAlertingRule()), { + wrapper, + }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.isEditable).toBe(false); @@ -133,31 +151,7 @@ function mockPermissions(grantedPermissions: AccessControlAction[]) { } function getProviderWrapper() { - const dataSources = getMockedDataSources(); - const store = mockUnifiedAlertingStore({ dataSources }); + const store = configureStore(); const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; return wrapper; } - -function getMockedDataSources(): StoreState['unifiedAlerting']['dataSources'] { - return { - grafana: { - loading: false, - dispatched: false, - result: { - id: 'grafana', - name: 'grafana', - rulerConfig: { dataSourceName: 'grafana', apiVersion: 'legacy' }, - }, - }, - cortex: { - loading: false, - dispatched: false, - result: { - id: 'cortex', - name: 'Cortex', - rulerConfig: { dataSourceName: 'cortex', apiVersion: 'legacy' }, - }, - }, - }; -} diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts index 40637f50845..4e712668f09 100644 --- a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts +++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts @@ -6,7 +6,6 @@ import { getRulesPermissions } from '../utils/access-control'; import { isGrafanaRulerRule } from '../utils/rules'; import { useFolder } from './useFolder'; -import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; interface ResultBag { isRulerAvailable?: boolean; @@ -16,7 +15,6 @@ interface ResultBag { } export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag { - const dataSources = useUnifiedAlertingSelector((state) => state.dataSources); const { currentData: dsFeatures, isLoading } = featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({ rulesSourceName, }); @@ -62,8 +60,7 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): } // prom rules are only editable by users with Editor role and only if rules source supports editing - const isRulerAvailable = - Boolean(dataSources[rulesSourceName]?.result?.rulerConfig) || Boolean(dsFeatures?.rulerConfig); + const isRulerAvailable = Boolean(dsFeatures?.rulerConfig); const canEditCloudRules = contextSrv.hasPermission(rulePermission.update); const canRemoveCloudRules = contextSrv.hasPermission(rulePermission.delete); @@ -71,6 +68,6 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): isRulerAvailable, isEditable: canEditCloudRules && isRulerAvailable, isRemovable: canRemoveCloudRules && isRulerAvailable, - loading: isLoading || dataSources[rulesSourceName]?.loading, + loading: isLoading, }; } diff --git a/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts b/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts index da9baf2d197..7f9cbf400a6 100644 --- a/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts +++ b/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts @@ -1,19 +1,28 @@ +import { useEffect, useState } from 'react'; + import { DataSourceInstanceSettings } from '@grafana/data'; -import { PromBasedDataSource } from 'app/types/unified-alerting'; -import { getDataSourceByName } from '../utils/datasource'; +import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; +import { getRulesDataSources } from '../utils/datasource'; -import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; +const { useLazyDiscoverDsFeaturesQuery } = featureDiscoveryApi; -export function useRulesSourcesWithRuler(): DataSourceInstanceSettings[] { - const dataSources = useUnifiedAlertingSelector((state) => state.dataSources); +export function useRulesSourcesWithRuler(): { + rulesSourcesWithRuler: DataSourceInstanceSettings[]; + isLoading: boolean; +} { + const [rulesSourcesWithRuler, setRulesSourcesWithRuler] = useState([]); + const [discoverDsFeatures, { isLoading }] = useLazyDiscoverDsFeaturesQuery(); - const dataSourcesWithRuler = Object.values(dataSources) - .map((ds) => ds.result) - .filter((ds): ds is PromBasedDataSource => Boolean(ds?.rulerConfig)); - // try fetching rules for each prometheus to see if it has ruler + useEffect(() => { + const dataSources = getRulesDataSources(); + dataSources.forEach(async (ds) => { + const { data: dsFeatures } = await discoverDsFeatures({ uid: ds.uid }, true); + if (dsFeatures?.rulerConfig) { + setRulesSourcesWithRuler((prev) => [...prev, ds]); + } + }); + }, [discoverDsFeatures]); - return dataSourcesWithRuler - .map((ds) => getDataSourceByName(ds.name)) - .filter((dsConfig): dsConfig is DataSourceInstanceSettings => Boolean(dsConfig)); + return { rulesSourcesWithRuler, isLoading }; } diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 806e13b9a01..cf716feee59 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -1,4 +1,5 @@ import { produce } from 'immer'; +import { isEmpty, pick } from 'lodash'; import { Observable } from 'rxjs'; import { @@ -778,15 +779,21 @@ export function getGrafanaRule(override?: Partial, rulerOverride?: }); } -export function getCloudRule(override?: Partial) { +export function getCloudRule(override?: Partial, nsOverride?: Partial) { + const promOverride = pick(override, ['name', 'labels', 'annotations']); + const rulerOverride = pick(override, ['name', 'labels', 'annotations']); + return mockCombinedRule({ namespace: { groups: [], name: 'Cortex', rulesSource: mockDataSource(), + ...nsOverride, }, - promRule: mockPromAlertingRule(), - rulerRule: mockRulerAlertingRule(), + promRule: mockPromAlertingRule(isEmpty(promOverride) ? undefined : promOverride), + rulerRule: mockRulerAlertingRule( + isEmpty(rulerOverride) ? undefined : { ...rulerOverride, alert: rulerOverride.name } + ), ...override, }); } diff --git a/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts index 874bba6f8be..f7cd81c56bf 100644 --- a/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts +++ b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { PluginExtensionPoints } from '@grafana/data'; import { usePluginLinks } from '@grafana/runtime'; -import { CombinedRule } from 'app/types/unified-alerting'; +import { CombinedRule, Rule, RuleGroupIdentifier } from 'app/types/unified-alerting'; import { PromRuleType } from 'app/types/unified-alerting-dto'; import { getRulePluginOrigin } from '../utils/rules'; @@ -21,12 +21,12 @@ export interface AlertingRuleExtensionContext extends BaseRuleExtensionContext { export interface RecordingRuleExtensionContext extends BaseRuleExtensionContext {} -export function useRulePluginLinkExtension(rule: CombinedRule) { - const ruleExtensionPoint = useRuleExtensionPoint(rule); +export function useRulePluginLinkExtension(rule: Rule, groupIdentifier: RuleGroupIdentifier) { + const ruleExtensionPoint = useRuleExtensionPoint(rule, groupIdentifier); const { links } = usePluginLinks(ruleExtensionPoint); const ruleOrigin = getRulePluginOrigin(rule); - const ruleType = rule.promRule?.type; + const ruleType = rule.type; if (!ruleOrigin || !ruleType) { return []; } @@ -57,9 +57,9 @@ interface EmptyExtensionPoint { type RuleExtensionPoint = AlertingRuleExtensionPoint | RecordingRuleExtensionPoint | EmptyExtensionPoint; -function useRuleExtensionPoint(rule: CombinedRule): RuleExtensionPoint { - return useMemo(() => { - const ruleType = rule.promRule?.type; +function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifier): RuleExtensionPoint { + return useMemo(() => { + const ruleType = rule.type; switch (ruleType) { case PromRuleType.Alerting: @@ -67,11 +67,11 @@ function useRuleExtensionPoint(rule: CombinedRule): RuleExtensionPoint { extensionPointId: PluginExtensionPoints.AlertingAlertingRuleAction, context: { name: rule.name, - namespace: rule.namespace.name, - group: rule.group.name, + namespace: groupIdentifier.namespaceName, + group: groupIdentifier.groupName, expression: rule.query, - labels: rule.labels, - annotations: rule.annotations, + labels: rule.labels ?? {}, + annotations: rule.annotations ?? {}, }, }; case PromRuleType.Recording: @@ -79,14 +79,14 @@ function useRuleExtensionPoint(rule: CombinedRule): RuleExtensionPoint { extensionPointId: PluginExtensionPoints.AlertingRecordingRuleAction, context: { name: rule.name, - namespace: rule.namespace.name, - group: rule.group.name, + namespace: groupIdentifier.namespaceName, + group: groupIdentifier.groupName, expression: rule.query, - labels: rule.labels, + labels: rule.labels ?? {}, }, }; default: return { extensionPointId: '' }; } - }, [rule]); + }, [groupIdentifier, rule]); } diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx index 88f3cf848b8..fbd357bb354 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx @@ -6,6 +6,7 @@ import { urlUtil } from '@grafana/data'; import { logInfo } from '@grafana/runtime'; import { Button, LinkButton, Stack, withErrorBoundary } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { Trans } from 'app/core/internationalization'; import { useDispatch } from 'app/types'; import { CombinedRuleNamespace } from 'app/types/unified-alerting'; @@ -49,8 +50,9 @@ const RuleListV1 = () => { const hasActiveLabelsFilter = filterState.labels.length > 0; - const queryParamView = queryParams.view as keyof typeof VIEWS; - const view = VIEWS[queryParamView] ? queryParamView : 'groups'; + const queryParamView = queryParams.view; + const viewType = queryParamView === 'state' || queryParamView === 'groups' ? queryParamView : 'groups'; + const view = VIEWS[viewType] ? viewType : 'groups'; const ViewComponent = VIEWS[view]; @@ -161,7 +163,7 @@ export function CreateAlertButton() { icon="plus" onClick={() => logInfo(LogMessages.alertRuleFromScratch)} > - New alert rule + New alert rule ); } diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index f414245413f..6db9f149f18 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -1,216 +1,370 @@ import { css } from '@emotion/css'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useLocation } from 'react-router-dom-v5-compat'; -import { useAsyncFn, useInterval, useMeasure } from 'react-use'; +import { PropsWithChildren, ReactNode, useMemo } from 'react'; +import Skeleton from 'react-loading-skeleton'; -import { GrafanaTheme2, urlUtil } from '@grafana/data'; -import { Button, LinkButton, LoadingBar, useStyles2, withErrorBoundary } from '@grafana/ui'; -import { useDispatch } from 'app/types'; +import { GrafanaTheme2 } from '@grafana/data'; +import { + Dropdown, + Icon, + IconButton, + LinkButton, + Menu, + Pagination, + Stack, + Text, + useStyles2, + withErrorBoundary, +} from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; +import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting'; +import { RulesSourceApplication } from 'app/types/unified-alerting-dto'; -import { CombinedRuleNamespace } from '../../../../types/unified-alerting'; -import { logInfo, LogMessages, trackRuleListNavigation } from '../Analytics'; +import { alertRuleApi } from '../api/alertRuleApi'; +import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; -import RulesFilter from '../components/rules/Filter/RulesFilter.v1'; -import { NoRulesSplash } from '../components/rules/NoRulesCTA'; -import { INSTANCES_DISPLAY_LIMIT } from '../components/rules/RuleDetails'; -import { RuleListErrors } from '../components/rules/RuleListErrors'; -import { RuleStats } from '../components/rules/RuleStats'; -import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; -import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces'; -import { useFilteredRules, useRulesFilter } from '../hooks/useFilteredRules'; -import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector'; -import { fetchAllPromAndRulerRulesAction } from '../state/actions'; -import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; -import { getAllRulesSourceNames, getApplicationFromRulesSource, getRulesSourceUniqueKey } from '../utils/datasource'; -import { makeFolderAlertsLink } from '../utils/misc'; +import { Spacer } from '../components/Spacer'; +import { WithReturnButton } from '../components/WithReturnButton'; +import RulesFilter from '../components/rules/Filter/RulesFilter'; +import { getAllRulesSources, isGrafanaRulesSource } from '../utils/datasource'; +import { equal, fromRule, fromRulerRule, hashRule, stringifyIdentifier } from '../utils/rule-id'; +import { getRulePluginOrigin, isAlertingRule, isRecordingRule } from '../utils/rules'; +import { createRelativeUrl } from '../utils/url'; -import { EvaluationGroupWithRules } from './components/EvaluationGroupWithRules'; -import Namespace from './components/Namespace'; +import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem'; +import { ListGroup } from './components/ListGroup'; +import { ListSection } from './components/ListSection'; +import { DataSourceIcon } from './components/Namespace'; +import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2'; +import { LoadingIndicator } from './components/RuleGroup'; -// make sure we ask for 1 more so we show the "show x more" button -const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1; +const noop = () => {}; +const { usePrometheusRuleNamespacesQuery, useGetRuleGroupForNamespaceQuery } = alertRuleApi; const RuleList = withErrorBoundary( () => { - const dispatch = useDispatch(); - const styles = useStyles2(getStyles); - const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []); - const [expandAll, setExpandAll] = useState(false); - - const onFilterCleared = useCallback(() => setExpandAll(false), []); - - const { filterState, hasActiveFilters } = useRulesFilter(); - - const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); - const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); - - const loading = rulesDataSourceNames.some( - (name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading - ); - - const promRequests = Object.entries(promRuleRequests); - const rulerRequests = Object.entries(rulerRuleRequests); - - const allPromLoaded = promRequests.every( - ([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined) - ); - const allRulerLoaded = rulerRequests.every( - ([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined) - ); - - const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0); - - const allRulerEmpty = rulerRequests.every(([_, state]) => { - const rulerRules = Object.entries(state?.result ?? {}); - const noRules = rulerRules.every(([_, result]) => result?.length === 0); - return noRules && state.dispatched; - }); - - const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS; - // Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED - const [_, fetchRules] = useAsyncFn(async () => { - if (!loading) { - await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); - } - }, [loading, limitAlerts, dispatch]); - - useEffect(() => { - trackRuleListNavigation().catch(() => {}); - }, []); - - // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS - useEffect(() => { - dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); - }, [dispatch, limitAlerts]); - useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS); - - // Show splash only when we loaded all of the data sources and none of them has alerts - const hasNoAlertRulesCreatedYet = - allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded; - const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet; - - const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces(); - const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState); - - const sortedNamespaces = filteredNamespaces.sort((a: CombinedRuleNamespace, b: CombinedRuleNamespace) => - a.name.localeCompare(b.name) - ); + const ruleSources = getAllRulesSources(); return ( // We don't want to show the Loading... indicator for the whole page. // We show separate indicators for Grafana-managed and Cloud rules - }> - - - {hasAlertRulesCreated && ( - <> -
-
-
- {hasActiveFilters && ( - - )} - -
-
- - )} - {hasNoAlertRulesCreatedYet && } - {hasAlertRulesCreated && ( - <> - -
    - {sortedNamespaces.map((namespace) => { - const { rulesSource, uid } = namespace; - - const application = getApplicationFromRulesSource(rulesSource); - const href = application === 'grafana' && uid ? makeFolderAlertsLink(uid, namespace.name) : undefined; - - return ( - - {namespace.groups - .sort((a, b) => a.name.localeCompare(b.name)) - .map((group) => ( - - ))} - - ); - })} -
- - )} + + {}} /> + + {ruleSources.map((ruleSource) => { + if (isGrafanaRulesSource(ruleSource)) { + return ; + } else { + return ; + } + })} + ); }, { style: 'page' } ); -const LoadingIndicator = ({ visible = false }) => { - const [measureRef, { width }] = useMeasure(); - return
{visible && }
; +const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; + +interface DataSourceLoaderProps { + name: string; + uid: string; +} + +const GrafanaDataSourceLoader = () => { + return ; +}; + +const DataSourceLoader = ({ uid, name }: DataSourceLoaderProps) => { + const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid }); + + if (isLoading) { + return } />; + } + + // 2. grab prometheus rule groups with max_groups if supported + if (dataSourceInfo) { + const rulerEnabled = Boolean(dataSourceInfo.rulerConfig); + + return ( + + ); + } + + return null; +}; + +interface PaginatedDataSourceLoaderProps extends Pick { + ruleSourceName: string; + rulerEnabled?: boolean; +} + +function PaginatedDataSourceLoader({ + ruleSourceName, + rulerEnabled = false, + name, + uid, + application, +}: PaginatedDataSourceLoaderProps) { + const { data: ruleNamespaces = [], isLoading } = usePrometheusRuleNamespacesQuery({ + ruleSourceName, + maxGroups: 25, + limitAlerts: 0, + excludeAlerts: true, + }); + + return ( + + + {ruleNamespaces.map((namespace) => ( + + {namespace.name} + + } + > + {namespace.groups.map((group) => ( + + + + + + + + + } + > + + + + } + > + {group.rules.map((rule) => { + const groupIdentifier: RuleGroupIdentifier = { + dataSourceName: ruleSourceName, + groupName: group.name, + namespaceName: namespace.name, + }; + + return ( + + ); + })} + + ))} + + ))} + {!isLoading && } + + + ); +} + +interface AlertRuleLoaderProps { + rule: Rule; + groupIdentifier: RuleGroupIdentifier; + rulerEnabled?: boolean; +} + +function AlertRuleLoader({ rule, groupIdentifier, rulerEnabled = false }: AlertRuleLoaderProps) { + const { dataSourceName, namespaceName, groupName } = groupIdentifier; + + const ruleIdentifier = fromRule(dataSourceName, namespaceName, groupName, rule); + const href = createViewLinkFromIdentifier(ruleIdentifier); + const originMeta = getRulePluginOrigin(rule); + + // @TODO work with context API to propagate rulerConfig and such + const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ rulesSourceName: dataSourceName }); + + // @TODO refactor this to use a separate hook (useRuleWithLocation() and useCombinedRule() seems to introduce infinite loading / recursion) + const { + isLoading, + data: rulerRuleGroup, + // error, + } = useGetRuleGroupForNamespaceQuery( + { + namespace: namespaceName, + group: groupName, + rulerConfig: dataSourceInfo?.rulerConfig!, + }, + { skip: !dataSourceInfo?.rulerConfig } + ); + + const rulerRule = useMemo(() => { + if (!rulerRuleGroup) { + return; + } + + return rulerRuleGroup.rules.find((rule) => + equal(fromRulerRule(dataSourceName, namespaceName, groupName, rule), ruleIdentifier) + ); + }, [dataSourceName, groupName, namespaceName, ruleIdentifier, rulerRuleGroup]); + + // 1. get the rule from the ruler API with "ruleWithLocation" + // 1.1 skip this if this datasource does not have a ruler + // + // 2.1 render action buttons + // 2.2 render provisioning badge and contact point metadata, etc. + + const actions = useMemo(() => { + if (!rulerEnabled) { + return null; + } + + if (isLoading) { + return ; + } + + if (rulerRule) { + return ; + } + + return null; + }, [groupIdentifier, isLoading, rule, rulerEnabled, rulerRule]); + + if (isAlertingRule(rule)) { + return ( + + ); + } + + if (isRecordingRule(rule)) { + return ( + + ); + } + + return ; +} + +function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) { + const paramId = encodeURIComponent(stringifyIdentifier(identifier)); + const paramSource = encodeURIComponent(identifier.ruleSourceName); + + return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {}); +} + +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 ( + + + {isLoading && } +
+ {loader ?? ( + + {application && } + {name && ( + + {name} + + )} + {description && ( + <> + {'·'} + {description} + + )} + + {uid && ( + + Configure + + } + /> + )} + + )} +
+
+
{children}
+
+ ); }; const getStyles = (theme: GrafanaTheme2) => ({ - rulesTree: css({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(1), + 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}`, + }, }), - break: css({ - width: '100%', - height: 0, - marginBottom: theme.spacing(2), - borderBottom: `solid 1px ${theme.colors.border.medium}`, - }), - buttonsContainer: css({ - marginBottom: theme.spacing(2), - display: 'flex', - justifyContent: 'space-between', - }), - statsContainer: css({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - }), - expandAllButton: css({ - marginRight: theme.spacing(1), + 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, }), }); export default RuleList; - -export function CreateAlertButton() { - const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); - const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); - - const location = useLocation(); - - const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; - - const canCreateGrafanaRules = createRuleSupported && createRuleAllowed; - - if (canCreateGrafanaRules || canCreateCloudRules) { - return ( - logInfo(LogMessages.alertRuleFromScratch)} - > - New alert rule - - ); - } - return null; -} diff --git a/public/app/features/alerting/unified/rule-list/StateView.tsx b/public/app/features/alerting/unified/rule-list/StateView.tsx new file mode 100644 index 00000000000..248d83f43a2 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/StateView.tsx @@ -0,0 +1,153 @@ +import { css } from '@emotion/css'; +import { useMemo } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Counter, Pagination, Stack, useStyles2 } from '@grafana/ui'; +import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants'; +import { CombinedRule, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; + +import { usePagination } from '..//hooks/usePagination'; +import { calculateTotalInstances } from '../components/rule-viewer/RuleViewer'; +import { ListSection } from '../rule-list/components/ListSection'; +import { createViewLink } from '../utils/misc'; +import { hashRule } from '../utils/rule-id'; +import { + getRuleGroupLocationFromCombinedRule, + getRulePluginOrigin, + isAlertingRule, + isGrafanaRulerRule, +} from '../utils/rules'; + +import { AlertRuleListItem } from './components/AlertRuleListItem'; +import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2'; + +interface Props { + namespaces: CombinedRuleNamespace[]; +} + +type GroupedRules = Map; + +export const StateView = ({ namespaces }: Props) => { + const styles = useStyles2(getStyles); + + const groupedRules = useMemo(() => { + const result: GroupedRules = new Map([ + [PromAlertingRuleState.Firing, []], + [PromAlertingRuleState.Pending, []], + [PromAlertingRuleState.Inactive, []], + ]); + + namespaces.forEach((namespace) => + namespace.groups.forEach((group) => + group.rules.forEach((rule) => { + // We might hit edge cases where there type = alerting, but there is no state. + // In this case, we shouldn't try to group these alerts in the state view + // Even though we handle this at the API layer, this is a last catch point for any edge cases + if (rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state) { + result.get(rule.promRule.state)?.push(rule); + } + }) + ) + ); + + result.forEach((rules) => rules.sort((a, b) => a.name.localeCompare(b.name))); + + return result; + }, [namespaces]); + + const entries = groupedRules.entries(); + + return ( +
    + {Array.from(entries).map(([state, rules]) => ( + + ))} +
+ ); +}; + +const STATE_TITLES: Record = { + [PromAlertingRuleState.Firing]: 'Firing', + [PromAlertingRuleState.Pending]: 'Pending', + [PromAlertingRuleState.Inactive]: 'Normal', +}; + +const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: CombinedRule[] }) => { + const { page, pageItems, numberOfPages, onPageChange } = usePagination(rules, 1, DEFAULT_PER_PAGE_PAGINATION); + + const isFiringState = state !== PromAlertingRuleState.Firing; + const hasRulesMatchingState = rules.length > 0; + + return ( + + {STATE_TITLES[state] ?? 'Unknown'} + + + } + collapsed={isFiringState || hasRulesMatchingState} + pagination={ + + } + > + {pageItems.map((rule) => { + const { rulerRule, promRule } = rule; + + const isProvisioned = isGrafanaRulerRule(rulerRule) && Boolean(rulerRule.grafana_alert.provenance); + const instancesCount = isAlertingRule(rule.promRule) ? calculateTotalInstances(rule.instanceTotals) : undefined; + const groupIdentifier = getRuleGroupLocationFromCombinedRule(rule); + + if (!promRule) { + return null; + } + + const originMeta = getRulePluginOrigin(promRule); + + return ( + + ) : ( + + ) + } + origin={originMeta} + /> + ); + })} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + columnStack: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }), +}); diff --git a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx index 741a62fbe35..d4ab558ba1f 100644 --- a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx @@ -1,12 +1,11 @@ import { css } from '@emotion/css'; -import { isEmpty } from 'lodash'; import pluralize from 'pluralize'; import { ReactNode } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Alert, Icon, Stack, Text, TextLink, useStyles2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; -import { CombinedRule, CombinedRuleNamespace, RuleHealth } from 'app/types/unified-alerting'; +import { Rule, RuleGroupIdentifier, RuleHealth } from 'app/types/unified-alerting'; import { Labels, PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { logError } from '../../Analytics'; @@ -35,7 +34,7 @@ interface AlertRuleListItemProps { evaluationInterval?: string; labels?: Labels; instancesCount?: number; - namespace?: CombinedRuleNamespace; + namespace?: string; group?: string; // used for alert rules that use simplified routing contactPoint?: string; @@ -91,7 +90,7 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => { } } - if (!isEmpty(labels)) { + if (labelsSize(labels) > 0) { metadata.push( @@ -139,6 +138,39 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => { ); }; +type RecordingRuleListItemProps = Omit; + +export function RecordingRuleListItem({ + name, + href, + health, + isProvisioned, + error, + isPaused, + origin, +}: RecordingRuleListItemProps) { + return ( + + + {name} + + {origin && } + {/* show provisioned badge only when it also doesn't have plugin origin */} + {isProvisioned && !origin && } + {/* let's not show labels for now, but maybe users would be interested later? Or maybe show them only in the list view? */} + {/* {labels && } */} + + } + description={} + icon={} + actions={null} + meta={[]} + /> + ); +} + interface SummaryProps { content?: string; error?: string; @@ -203,13 +235,14 @@ function EvaluationMetadata({ lastEvaluation, evaluationInterval, state }: Evalu } interface UnknownRuleListItemProps { - rule: CombinedRule; + rule: Rule; + groupIdentifier: RuleGroupIdentifier; } -export const UnknownRuleListItem = ({ rule }: UnknownRuleListItemProps) => { +export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListItemProps) => { const styles = useStyles2(getStyles); - const ruleContext = { namespace: rule.namespace.name, group: rule.group.name, name: rule.name }; + const ruleContext = { ...groupIdentifier, name: rule.name }; logError(new Error('unknown rule type'), ruleContext); return ( @@ -219,7 +252,7 @@ export const UnknownRuleListItem = ({ rule }: UnknownRuleListItemProps) => { Rule definition
-          {JSON.stringify(rule.rulerRule, null, 2)}
+          {JSON.stringify(rule, null, 2)}
         
@@ -227,7 +260,7 @@ export const UnknownRuleListItem = ({ rule }: UnknownRuleListItemProps) => { }; interface RuleLocationProps { - namespace: CombinedRuleNamespace; + namespace: string; group: string; } @@ -235,7 +268,7 @@ export const RuleLocation = ({ namespace, group }: RuleLocationProps) => ( - {namespace.name} + {namespace} {group} diff --git a/public/app/features/alerting/unified/rule-list/components/EvaluationGroup.tsx b/public/app/features/alerting/unified/rule-list/components/EvaluationGroup.tsx index 49903bf261b..bf406d4c81a 100644 --- a/public/app/features/alerting/unified/rule-list/components/EvaluationGroup.tsx +++ b/public/app/features/alerting/unified/rule-list/components/EvaluationGroup.tsx @@ -17,7 +17,14 @@ interface EvaluationGroupProps extends PropsWithChildren { onToggle: () => void; } -const EvaluationGroup = ({ name, provenance, interval, onToggle, isOpen = false, children }: EvaluationGroupProps) => { +export const EvaluationGroup = ({ + name, + provenance, + interval, + onToggle, + isOpen = false, + children, +}: EvaluationGroupProps) => { const styles = useStyles2(getStyles); const isProvisioned = Boolean(provenance); @@ -78,5 +85,3 @@ const getStyles = (theme: GrafanaTheme2) => ({ margin: `-${theme.spacing(0.5)}`, }), }); - -export default EvaluationGroup; diff --git a/public/app/features/alerting/unified/rule-list/components/EvaluationGroupWithRules.tsx b/public/app/features/alerting/unified/rule-list/components/EvaluationGroupWithRules.tsx deleted file mode 100644 index 24de30c94b9..00000000000 --- a/public/app/features/alerting/unified/rule-list/components/EvaluationGroupWithRules.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { size } from 'lodash'; -import { useToggle } from 'react-use'; - -import { CombinedRuleGroup, RulesSource } from 'app/types/unified-alerting'; - -import { createViewLink } from '../../utils/misc'; -import { hashRulerRule } from '../../utils/rule-id'; -import { isAlertingRule, isGrafanaRulerRule, isRecordingRule } from '../../utils/rules'; - -import { AlertRuleListItem, UnknownRuleListItem } from './AlertRuleListItem'; -import EvaluationGroup from './EvaluationGroup'; - -export interface EvaluationGroupWithRulesProps { - group: CombinedRuleGroup; - rulesSource: RulesSource; -} - -export const EvaluationGroupWithRules = ({ group, rulesSource }: EvaluationGroupWithRulesProps) => { - const [open, toggleOpen] = useToggle(false); - - return ( - - {group.rules.map((rule, index) => { - const { rulerRule, promRule, annotations } = rule; - - // don't render anything if we don't have the rule definition yet - if (!rulerRule) { - return null; - } - - // keep in mind that we may not have a promRule for the ruler rule – this happens when the target - // rule source is eventually consistent - it may know about the rule definition but not its state - const isAlertingPromRule = isAlertingRule(promRule); - - if (isAlertingRule(rule.promRule) || isRecordingRule(rule.promRule)) { - return ( - - ); - } - - if (isGrafanaRulerRule(rulerRule)) { - const contactPoint = rulerRule.grafana_alert.notification_settings?.receiver; - - return ( - - ); - } - - // if we get here it means we don't really know how to render this rule - return ; - })} - - ); -}; diff --git a/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx b/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx index 76db0878f5d..721d8002908 100644 --- a/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx +++ b/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/css'; import { PropsWithChildren, ReactNode } from 'react'; +import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { IconButton, Stack, Text, useStyles2 } from '@grafana/ui'; @@ -13,36 +14,39 @@ interface GroupProps extends PropsWithChildren { metaRight?: ReactNode; actions?: ReactNode; isOpen?: boolean; - onToggle: () => void; } -export const Group = ({ +export const ListGroup = ({ name, description, - onToggle, - isOpen = false, + isOpen = true, metaRight = null, actions = null, children, }: GroupProps) => { const styles = useStyles2(getStyles); + const [open, toggle] = useToggle(isOpen); return ( -
+
toggle()} + isOpen={open} description={description} name={name} metaRight={metaRight} actions={actions} /> - {isOpen &&
{children}
} + {open &&
{children}
}
); }; -const GroupHeader = (props: GroupProps) => { +type GroupHeaderProps = GroupProps & { + onToggle: () => void; +}; + +const GroupHeader = (props: GroupHeaderProps) => { const { name, description, metaRight = null, actions = null, isOpen = false, onToggle } = props; const styles = useStyles2(getStyles); @@ -50,9 +54,9 @@ const GroupHeader = (props: GroupProps) => { return (
- + @@ -76,7 +80,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ flexDirection: 'column', }), headerWrapper: css({ - padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, + padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`, background: theme.colors.background.secondary, diff --git a/public/app/features/alerting/unified/rule-list/components/ListSection.tsx b/public/app/features/alerting/unified/rule-list/components/ListSection.tsx index 1e9739f81b6..6a2cd961d69 100644 --- a/public/app/features/alerting/unified/rule-list/components/ListSection.tsx +++ b/public/app/features/alerting/unified/rule-list/components/ListSection.tsx @@ -30,7 +30,7 @@ export const ListSection = ({
  • - + ({ border: `solid 1px ${theme.colors.border.weak}`, borderBottom: 'none', - marginLeft: theme.spacing(3), - - '&:before': { - content: "''", - position: 'absolute', - height: '100%', - - borderLeft: `solid 1px ${theme.colors.border.weak}`, - - marginTop: 0, - marginLeft: `-${theme.spacing(2.5)}`, - }, + marginLeft: theme.spacing(1.5), }), wrapper: css({ display: 'flex', @@ -88,7 +77,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`, background: theme.colors.background.secondary, - border: `solid 1px ${theme.colors.border.weak}`, borderRadius: theme.shape.radius.default, }), diff --git a/public/app/features/alerting/unified/rule-list/components/Namespace.tsx b/public/app/features/alerting/unified/rule-list/components/Namespace.tsx index ff620eeee93..8d4f325e3b8 100644 --- a/public/app/features/alerting/unified/rule-list/components/Namespace.tsx +++ b/public/app/features/alerting/unified/rule-list/components/Namespace.tsx @@ -21,7 +21,7 @@ const Namespace = ({ children, name, href, application }: NamespaceProps) => {
  • - + {href ? ( { +export const DataSourceIcon = ({ application }: NamespaceIconProps) => { switch (application) { case PromApplication.Prometheus: return ( @@ -64,11 +64,11 @@ const NamespaceIcon = ({ application }: NamespaceIconProps) => { return ( Mimir ); - case 'loki': + case 'Loki': return Loki; case 'grafana': default: - return ; + return ; } }; @@ -101,10 +101,10 @@ const getStyles = (theme: GrafanaTheme2) => ({ namespaceTitle: css({ padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, - background: theme.colors.background.secondary, + // background: theme.colors.background.secondary, - border: `solid 1px ${theme.colors.border.weak}`, - borderRadius: theme.shape.radius.default, + // border: `solid 1px ${theme.colors.border.weak}`, + // borderRadius: theme.shape.radius.default, }), }); diff --git a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx new file mode 100644 index 00000000000..e560b464a09 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import Skeleton from 'react-loading-skeleton'; + +import { LinkButton, Stack } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; +import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu'; +import { useDeleteModal } from 'app/features/alerting/unified/components/rule-viewer/DeleteModal'; +import { RedirectToCloneRule } from 'app/features/alerting/unified/components/rules/CloneRule'; +import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails'; +import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/silences/SilenceGrafanaRuleDrawer'; +import { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules'; +import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; +import { useDispatch } from 'app/types'; +import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting'; +import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; + +import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities'; +import { fetchPromAndRulerRulesAction } from '../../state/actions'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import * as ruleId from '../../utils/rule-id'; +import { isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules'; +import { createRelativeUrl } from '../../utils/url'; + +interface Props { + rule: RulerRuleDTO; + promRule: Rule; + groupIdentifier: RuleGroupIdentifier; + /** + * Should we show the buttons in a "compact" state? + * i.e. without text and using smaller button sizes + */ + compact?: boolean; +} + +// For now this is just a copy of RuleActionsButtons.tsx but with the View button removed. +// This is only done to keep the new list behind a feature flag and limit changes in the existing components +export const RuleActionsButtons = ({ compact, rule, promRule, groupIdentifier }: Props) => { + const dispatch = useDispatch(); + + const redirectToListView = compact ? false : true; + const [deleteModal, showDeleteModal] = useDeleteModal(redirectToListView); + + const [showSilenceDrawer, setShowSilenceDrawer] = useState(false); + + const [redirectToClone, setRedirectToClone] = useState< + { identifier: RuleIdentifier; isProvisioned: boolean } | undefined + >(undefined); + + const { namespaceName, groupName, dataSourceName } = groupIdentifier; + const { hasActiveFilters } = useRulesFilter(); + + const isProvisioned = isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance); + + const [editRuleSupported, editRuleAllowed] = useRulerRuleAbility(rule, groupIdentifier, AlertRuleAction.Update); + + const canEditRule = editRuleSupported && editRuleAllowed; + + const buttons: JSX.Element[] = []; + const buttonSize = compact ? 'sm' : 'md'; + + const identifier = ruleId.fromRulerRule(dataSourceName, namespaceName, groupName, rule); + + if (canEditRule) { + const identifier = ruleId.fromRulerRule(dataSourceName, namespaceName, groupName, rule); + + const editURL = createRelativeUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`); + + buttons.push( + + Edit + + ); + } + + return ( + + {buttons} + showDeleteModal(rule, groupIdentifier)} + handleSilence={() => setShowSilenceDrawer(true)} + handleDuplicateRule={() => setRedirectToClone({ identifier, isProvisioned })} + onPauseChange={() => { + // Uses INSTANCES_DISPLAY_LIMIT + 1 here as exporting LIMIT_ALERTS from RuleList has the side effect + // of breaking some unrelated tests in Policy.test.tsx due to mocking approach + const limitAlerts = hasActiveFilters ? undefined : INSTANCES_DISPLAY_LIMIT + 1; + // Trigger a re-fetch of the rules table + // TODO: Migrate rules table functionality to RTK Query, so we instead rely + // on tag invalidation (or optimistic cache updates) for this + dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME, limitAlerts })); + }} + /> + {deleteModal} + {isGrafanaAlertingRule(rule) && showSilenceDrawer && ( + + setShowSilenceDrawer(false)} /> + + )} + {redirectToClone?.identifier && ( + setRedirectToClone(undefined)} + /> + )} + + ); +}; + +export const ActionsLoader = () => ; diff --git a/public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx b/public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx index 00a1108eba7..fb8a036128f 100644 --- a/public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx +++ b/public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx @@ -10,7 +10,7 @@ import { usePagination } from '../../hooks/usePagination'; import { isAlertingRule } from '../../utils/rules'; import { AlertRuleListItem } from './AlertRuleListItem'; -import EvaluationGroup from './EvaluationGroup'; +import { EvaluationGroup } from './EvaluationGroup'; import { SkeletonListItem } from './ListItem'; interface EvaluationGroupLoaderProps { @@ -75,6 +75,16 @@ export const EvaluationGroupLoader = ({ ); }; +export const LoadingIndicator = () => { + const [ref, { width }] = useMeasure(); + + return ( +
    + +
    + ); +}; + const GroupLoadingIndicator = () => { const [ref, { width }] = useMeasure(); diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 78ad5a54e2f..7c05181fd53 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -10,18 +10,12 @@ import { Receiver, TestReceiversAlert, } from 'app/plugins/datasource/alertmanager/types'; -import { FolderDTO, StoreState, ThunkResult } from 'app/types'; -import { - PromBasedDataSource, - RuleIdentifier, - RuleNamespace, - RulerDataSourceConfig, - StateHistoryItem, -} from 'app/types/unified-alerting'; -import { PromApplication, RulerRuleDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { FolderDTO, ThunkResult } from 'app/types'; +import { RuleIdentifier, RuleNamespace, StateHistoryItem } from 'app/types/unified-alerting'; +import { RulerRuleDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { backendSrv } from '../../../../core/services/backend_srv'; -import { withPerformanceLogging, withPromRulesMetadataLogging, withRulerRulesMetadataLogging } from '../Analytics'; +import { withPromRulesMetadataLogging, withRulerRulesMetadataLogging } from '../Analytics'; import { deleteAlertManagerConfig, fetchAlertGroups, @@ -30,43 +24,16 @@ import { } from '../api/alertmanager'; import { alertmanagerApi } from '../api/alertmanagerApi'; import { fetchAnnotations } from '../api/annotations'; -import { discoverFeatures } from '../api/buildInfo'; +import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; import { FetchPromRulesFilter, fetchRules } from '../api/prometheus'; import { FetchRulerRulesFilter, fetchRulerRules } from '../api/ruler'; import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager'; -import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames, getRulesDataSource } from '../utils/datasource'; +import { getAllRulesSourceNames } from '../utils/datasource'; import { makeAMLink } from '../utils/misc'; -import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux'; +import { withAppEvents, withSerializedError } from '../utils/redux'; import { getAlertInfo } from '../utils/rules'; import { safeParsePrometheusDuration } from '../utils/time'; -function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) { - const dataSources = (getState() as StoreState).unifiedAlerting.dataSources; - const dsConfig = dataSources[rulesSourceName]?.result; - const dsError = dataSources[rulesSourceName]?.error; - - // @TODO use aggregateError but add support for it in "stringifyErrorLike" - if (!dsConfig) { - const error = new Error(`Data source configuration is not available for "${rulesSourceName}" data source`); - if (dsError) { - error.cause = dsError; - } - - throw error; - } - - return dsConfig; -} - -export function getDataSourceRulerConfig(getState: () => unknown, rulesSourceName: string) { - const dsConfig = getDataSourceConfig(getState, rulesSourceName); - if (!dsConfig.rulerConfig) { - throw new Error(`Ruler API is not available for ${rulesSourceName}`); - } - - return dsConfig.rulerConfig; -} - export const fetchPromRulesAction = createAsyncThunk( 'unifiedalerting/fetchPromRules', async ( @@ -87,8 +54,6 @@ export const fetchPromRulesAction = createAsyncThunk( }, thunkAPI ): Promise => { - await thunkAPI.dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); - const fetchRulesWithLogging = withPromRulesMetadataLogging('unifiedalerting/fetchPromRules', fetchRules, { dataSourceName: rulesSourceName, thunk: 'unifiedalerting/fetchPromRules', @@ -112,8 +77,13 @@ export const fetchRulerRulesAction = createAsyncThunk( }, { dispatch, getState } ): Promise => { - await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); - const rulerConfig = getDataSourceRulerConfig(getState, rulesSourceName); + const { data: dsFeatures } = await dispatch( + featureDiscoveryApi.endpoints.discoverDsFeatures.initiate({ rulesSourceName }) + ); + + if (!dsFeatures?.rulerConfig) { + return null; + } const fetchRulerRulesWithLogging = withRulerRulesMetadataLogging( 'unifiedalerting/fetchRulerRules', @@ -124,7 +94,7 @@ export const fetchRulerRulesAction = createAsyncThunk( } ); - return await withSerializedError(fetchRulerRulesWithLogging(rulerConfig, filter)); + return await withSerializedError(fetchRulerRulesWithLogging(dsFeatures.rulerConfig, filter)); } ); @@ -143,89 +113,18 @@ export function fetchPromAndRulerRulesAction({ matcher?: Matcher[]; state?: string[]; }): ThunkResult> { - return async (dispatch, getState) => { - await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); - const dsConfig = getDataSourceConfig(getState, rulesSourceName); - - await dispatch(fetchPromRulesAction({ rulesSourceName, identifier, filter, limitAlerts, matcher, state })); - if (dsConfig.rulerConfig) { - await dispatch(fetchRulerRulesAction({ rulesSourceName })); - } - }; -} - -// TODO: memoize this or move to RTK Query so we can cache results! -export function fetchAllPromBuildInfoAction(): ThunkResult> { return async (dispatch) => { - const allRequests = getAllRulesSourceNames().map((rulesSourceName) => - dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })) + const { data: dsFeatures } = await dispatch( + featureDiscoveryApi.endpoints.discoverDsFeatures.initiate({ rulesSourceName }) ); - await Promise.allSettled(allRequests); + await Promise.all([ + dispatch(fetchPromRulesAction({ rulesSourceName, identifier, filter, limitAlerts, matcher, state })), + dsFeatures?.rulerConfig ? dispatch(fetchRulerRulesAction({ rulesSourceName })) : Promise.resolve(), + ]); }; } -export const fetchRulesSourceBuildInfoAction = createAsyncThunk( - 'unifiedalerting/fetchPromBuildinfo', - async ({ rulesSourceName }: { rulesSourceName: string }): Promise => { - return withSerializedError( - (async (): Promise => { - if (rulesSourceName === GRAFANA_RULES_SOURCE_NAME) { - return { - name: GRAFANA_RULES_SOURCE_NAME, - id: GRAFANA_RULES_SOURCE_NAME, - rulerConfig: { - dataSourceName: GRAFANA_RULES_SOURCE_NAME, - apiVersion: 'legacy', - }, - }; - } - - const ds = getRulesDataSource(rulesSourceName); - if (!ds) { - throw new Error(`Missing data source configuration for ${rulesSourceName}`); - } - - const { id, name } = ds; - - const discoverFeaturesWithLogging = withPerformanceLogging( - 'unifiedalerting/fetchPromBuildinfo', - discoverFeatures, - { - dataSourceName: rulesSourceName, - thunk: 'unifiedalerting/fetchPromBuildinfo', - } - ); - - const buildInfo = await discoverFeaturesWithLogging(name); - - const rulerConfig: RulerDataSourceConfig | undefined = buildInfo.features.rulerApiEnabled - ? { - dataSourceName: name, - apiVersion: buildInfo.application === PromApplication.Cortex ? 'legacy' : 'config', - } - : undefined; - - return { - name: name, - id: id, - rulerConfig, - }; - })() - ); - }, - { - condition: ({ rulesSourceName }, { getState }) => { - const dataSources: AsyncRequestMapSlice = (getState() as StoreState).unifiedAlerting - .dataSources; - const hasLoaded = Boolean(dataSources[rulesSourceName]?.result); - const hasError = Boolean(dataSources[rulesSourceName]?.error); - - return !(hasLoaded || hasError); - }, - } -); - interface FetchPromRulesRulesActionProps { filter?: FetchPromRulesFilter; limitAlerts?: number; @@ -242,18 +141,18 @@ export function fetchAllPromAndRulerRulesAction( await Promise.allSettled( getAllRulesSourceNames().map(async (rulesSourceName) => { - await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); + const { data: dsFeatures } = await dispatch( + featureDiscoveryApi.endpoints.discoverDsFeatures.initiate({ rulesSourceName }) + ); - const { promRules, rulerRules, dataSources } = getStore().unifiedAlerting; - const dataSourceConfig = dataSources[rulesSourceName].result; + const { promRules, rulerRules } = getStore().unifiedAlerting; - if (!dataSourceConfig) { + if (!dsFeatures) { return; } const shouldLoadProm = force || !promRules[rulesSourceName]?.loading; - const shouldLoadRuler = - (force || !rulerRules[rulesSourceName]?.loading) && Boolean(dataSourceConfig.rulerConfig); + const shouldLoadRuler = (force || !rulerRules[rulesSourceName]?.loading) && Boolean(dsFeatures?.rulerConfig); await Promise.allSettled([ shouldLoadProm && dispatch(fetchPromRulesAction({ rulesSourceName, ...options })), diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index d12154135d1..19153a3bee1 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -9,17 +9,11 @@ import { fetchGrafanaAnnotationsAction, fetchPromRulesAction, fetchRulerRulesAction, - fetchRulesSourceBuildInfoAction, testReceiversAction, updateAlertManagerConfigAction, } from './actions'; export const reducer = combineReducers({ - dataSources: createAsyncMapSlice( - 'dataSources', - fetchRulesSourceBuildInfoAction, - ({ rulesSourceName }) => rulesSourceName - ).reducer, promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, ({ rulesSourceName }) => rulesSourceName).reducer, rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, ({ rulesSourceName }) => rulesSourceName) .reducer, diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts index 64275f04169..7ad7aa6aadc 100644 --- a/public/app/features/alerting/unified/utils/datasource.ts +++ b/public/app/features/alerting/unified/utils/datasource.ts @@ -8,7 +8,6 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; import { RulesSource } from 'app/types/unified-alerting'; -import { PromApplication, RulesSourceApplication } from 'app/types/unified-alerting-dto'; import { alertmanagerApi } from '../api/alertmanagerApi'; import { PERMISSIONS_CONTACT_POINTS } from '../components/contact-points/permissions'; @@ -22,6 +21,8 @@ import { getAllDataSources } from './config'; export const GRAFANA_RULES_SOURCE_NAME = 'grafana'; export const GRAFANA_DATASOURCE_NAME = '-- Grafana --'; +export type RulesSourceIdentifier = { rulesSourceName: string } | { uid: string }; + export enum DataSourceType { Alertmanager = 'alertmanager', Loki = 'loki', @@ -39,12 +40,15 @@ export interface AlertManagerDataSource { export const RulesDataSourceTypes: string[] = [DataSourceType.Loki, DataSourceType.Prometheus]; export function getRulesDataSources() { - if (!contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalRead)) { + const hasReadPermission = contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalRead); + const hasWritePermission = contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalWrite); + if (!hasReadPermission && !hasWritePermission) { return []; } return getAllDataSources() - .filter((ds) => RulesDataSourceTypes.includes(ds.type) && ds.jsonData.manageAlerts !== false) + .filter((ds) => RulesDataSourceTypes.includes(ds.type)) + .filter((ds) => isDataSourceManagingAlerts(ds)) .sort((a, b) => a.name.localeCompare(b.name)); } @@ -56,6 +60,10 @@ export function getRulesDataSource(rulesSourceName: string) { return getRulesDataSources().find((x) => x.name === rulesSourceName); } +export function getRulesDataSourceByUID(uid: string) { + return getRulesDataSources().find((x) => x.uid === uid); +} + export function getAlertManagerDataSources() { return getAllDataSources() .filter(isAlertmanagerDataSourceInstance) @@ -203,7 +211,7 @@ export function getAllRulesSources(): RulesSource[] { const availableRulesSources: RulesSource[] = getRulesDataSources(); if (contextSrv.hasPermission(AccessControlAction.AlertingRuleRead)) { - availableRulesSources.push(GRAFANA_RULES_SOURCE_NAME); + availableRulesSources.unshift(GRAFANA_RULES_SOURCE_NAME); } return availableRulesSources; @@ -242,6 +250,10 @@ export function getDataSourceByName(name: string): DataSourceInstanceSettings source.name === name); } +export function getDataSourceByUid(dsUid: string): DataSourceInstanceSettings | undefined { + return getAllDataSources().find((source) => source.uid === dsUid); +} + export function getAlertmanagerDataSourceByName(name: string) { return getAllDataSources() .filter(isAlertmanagerDataSourceInstance) @@ -277,6 +289,22 @@ export function getDatasourceAPIUid(dataSourceName: string) { return ds.uid; } +export function getDataSourceUID(rulesSourceIdentifier: RulesSourceIdentifier) { + if ('uid' in rulesSourceIdentifier) { + return rulesSourceIdentifier.uid; + } + + if (rulesSourceIdentifier.rulesSourceName === GRAFANA_RULES_SOURCE_NAME) { + return GRAFANA_RULES_SOURCE_NAME; + } + + const ds = getRulesDataSource(rulesSourceIdentifier.rulesSourceName); + if (!ds) { + return undefined; + } + return ds.uid; +} + export function getFirstCompatibleDataSource(): DataSourceInstanceSettings | undefined { return getDataSourceSrv().getList({ alerting: true })[0]; } @@ -291,20 +319,3 @@ export function getDefaultOrFirstCompatibleDataSource(): DataSourceInstanceSetti export function isDataSourceManagingAlerts(ds: DataSourceInstanceSettings) { return ds.jsonData.manageAlerts !== false; //if this prop is undefined it defaults to true } - -export function getApplicationFromRulesSource(rulesSource: RulesSource): RulesSourceApplication { - if (isGrafanaRulesSource(rulesSource)) { - return 'grafana'; - } - - // @TODO use buildinfo - if ('prometheusType' in rulesSource.jsonData) { - return rulesSource.jsonData?.prometheusType ?? PromApplication.Prometheus; - } - - if (rulesSource.type === 'loki') { - return 'loki'; - } - - return PromApplication.Prometheus; // assume Prometheus if nothing matches -} diff --git a/public/app/features/alerting/unified/utils/labels.ts b/public/app/features/alerting/unified/utils/labels.ts index e76d600c400..492f25d83f0 100644 --- a/public/app/features/alerting/unified/utils/labels.ts +++ b/public/app/features/alerting/unified/utils/labels.ts @@ -1,3 +1,5 @@ +import { isEmpty } from 'lodash'; + import { Labels } from '../../../../types/unified-alerting-dto'; import { Label } from '../components/rules/state-history/common'; @@ -35,7 +37,11 @@ export function arrayKeyValuesToObject( export const GRAFANA_ORIGIN_LABEL = '__grafana_origin'; -export function labelsSize(labels: Labels) { +export function labelsSize(labels?: Labels) { + if (isEmpty(labels)) { + return 0; + } + return Object.keys(labels).filter((key) => !isPrivateLabelKey(key)).length; } diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 04998ff66c8..e9479df2405 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -6,9 +6,21 @@ import { config, isFetchError } from '@grafana/runtime'; import { DataSourceRef } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { escapePathSeparators } from 'app/features/alerting/unified/utils/rule-id'; -import { alertInstanceKey, isGrafanaRulerRule } from 'app/features/alerting/unified/utils/rules'; +import { + alertInstanceKey, + isCloudRuleIdentifier, + isGrafanaRuleIdentifier, + isPrometheusRuleIdentifier, +} from 'app/features/alerting/unified/utils/rules'; import { SortOrder } from 'app/plugins/panel/alertlist/types'; -import { Alert, CombinedRule, FilterState, RulesSource, SilenceFilterState } from 'app/types/unified-alerting'; +import { + Alert, + CombinedRule, + FilterState, + RuleIdentifier, + RulesSource, + SilenceFilterState, +} from 'app/types/unified-alerting'; import { GrafanaAlertState, PromAlertingRuleState, @@ -16,7 +28,7 @@ import { } from 'app/types/unified-alerting-dto'; import { ALERTMANAGER_NAME_QUERY_KEY } from './constants'; -import { getRulesSourceName, isCloudRulesSource } from './datasource'; +import { getRulesSourceName } from './datasource'; import { getMatcherQueryParams } from './matchers'; import * as ruleId from './rule-id'; import { createAbsoluteUrl, createRelativeUrl } from './url'; @@ -67,13 +79,13 @@ export function createMuteTimingLink(muteTimingName: string, alertManagerSourceN }); } -export function createShareLink(ruleSource: RulesSource, rule: CombinedRule): string | undefined { - if (isCloudRulesSource(ruleSource)) { +export function createShareLink(ruleIdentifier: RuleIdentifier): string | undefined { + if (isCloudRuleIdentifier(ruleIdentifier) || isPrometheusRuleIdentifier(ruleIdentifier)) { return createAbsoluteUrl( - `/alerting/${encodeURIComponent(ruleSource.name)}/${encodeURIComponent(escapePathSeparators(rule.name))}/find` + `/alerting/${encodeURIComponent(ruleIdentifier.ruleSourceName)}/${encodeURIComponent(escapePathSeparators(ruleIdentifier.ruleName))}/find` ); - } else if (isGrafanaRulerRule(rule.rulerRule)) { - return createAbsoluteUrl(`/alerting/grafana/${rule.rulerRule.grafana_alert.uid}/view`); + } else if (isGrafanaRuleIdentifier(ruleIdentifier)) { + return createAbsoluteUrl(`/alerting/grafana/${ruleIdentifier.uid}/view`); } return; diff --git a/public/app/features/alerting/unified/utils/rules.test.ts b/public/app/features/alerting/unified/utils/rules.test.ts index fb1477d038c..4b4036c40d2 100644 --- a/public/app/features/alerting/unified/utils/rules.test.ts +++ b/public/app/features/alerting/unified/utils/rules.test.ts @@ -7,6 +7,7 @@ import { mockCombinedRule, mockCombinedRuleGroup, mockGrafanaRulerRule, + mockPromAlertingRule, mockRuleWithLocation, mockRulerAlertingRule, } from '../mocks'; @@ -20,21 +21,21 @@ import { describe('getRuleOrigin', () => { it('returns undefined when no origin label is present', () => { - const rule = mockCombinedRule({ + const rule = mockPromAlertingRule({ labels: {}, }); expect(getRulePluginOrigin(rule)).toBeUndefined(); }); it('returns undefined when origin label does not match expected format', () => { - const rule = mockCombinedRule({ + const rule = mockPromAlertingRule({ labels: { [GRAFANA_ORIGIN_LABEL]: 'invalid_format' }, }); expect(getRulePluginOrigin(rule)).toBeUndefined(); }); it('returns undefined when plugin is not installed', () => { - const rule = mockCombinedRule({ + const rule = mockPromAlertingRule({ labels: { [GRAFANA_ORIGIN_LABEL]: 'plugin/uninstalled_plugin' }, }); expect(getRulePluginOrigin(rule)).toBeUndefined(); @@ -64,7 +65,7 @@ describe('getRuleOrigin', () => { }, }, }; - const rule = mockCombinedRule({ + const rule = mockPromAlertingRule({ labels: { [GRAFANA_ORIGIN_LABEL]: 'plugin/installed_plugin' }, }); expect(getRulePluginOrigin(rule)).toEqual({ pluginId: 'installed_plugin' }); diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 6e18da1826a..fd372acdda4 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -176,10 +176,12 @@ export interface RulePluginOrigin { pluginId: string; } -export function getRulePluginOrigin(rule: CombinedRule): RulePluginOrigin | undefined { - // com.grafana.origin=plugin/ - // Prom and Mimir do not support dots in label names 😔 - const origin = rule.labels[GRAFANA_ORIGIN_LABEL]; +export function getRulePluginOrigin(rule?: Rule | RulerRuleDTO): RulePluginOrigin | undefined { + if (!rule) { + return undefined; + } + + const origin = rule.labels?.[GRAFANA_ORIGIN_LABEL]; if (!origin) { return undefined; } @@ -203,7 +205,7 @@ function isPluginInstalled(pluginId: string) { return Boolean(config.apps[pluginId]); } -export function isPluginProvidedRule(rule: CombinedRule): boolean { +export function isPluginProvidedRule(rule?: Rule | RulerRuleDTO): boolean { return Boolean(getRulePluginOrigin(rule)); } diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index bf149896c12..3edbb586d21 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -77,7 +77,7 @@ export enum PromApplication { Thanos = 'Thanos', } -export type RulesSourceApplication = PromApplication | 'loki' | 'grafana'; +export type RulesSourceApplication = PromApplication | 'Loki' | 'grafana'; export interface PromBuildInfoResponse { data: { @@ -96,7 +96,7 @@ export interface PromBuildInfoResponse { } export interface PromApiFeatures { - application?: PromApplication; + application: RulesSourceApplication; features: { rulerApiEnabled: boolean; }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2d0005e46dc..eb90b340ad5 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -280,6 +280,10 @@ "success": "Successfully updated rule group" } }, + "rule-list": { + "configure-datasource": "Configure", + "new-alert-rule": "New alert rule" + }, "rule-state": { "creating": "Creating", "deleting": "Deleting", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 4be0ea99904..8950fe5a584 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -280,6 +280,10 @@ "success": "Ŝūččęşşƒūľľy ūpđäŧęđ řūľę ģřőūp" } }, + "rule-list": { + "configure-datasource": "Cőʼnƒįģūřę", + "new-alert-rule": "Ńęŵ äľęřŧ řūľę" + }, "rule-state": { "creating": "Cřęäŧįʼnģ", "deleting": "Đęľęŧįʼnģ",