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 <gilles.de.mey@gmail.com>
pull/96626/head
Konrad Lalik 6 months ago committed by GitHub
parent 65097d4b54
commit b73ab15878
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 15
      .betterer.results
  2. 1
      public/app/core/icons/cached.json
  3. 542
      public/app/core/icons/iconBundle.ts
  4. 21
      public/app/features/alerting/unified/RuleEditor.tsx
  5. 54
      public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx
  6. 14
      public/app/features/alerting/unified/RuleEditorExisting.test.tsx
  7. 7
      public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx
  8. 18
      public/app/features/alerting/unified/RuleList.test.tsx
  9. 24
      public/app/features/alerting/unified/api/alertRuleApi.ts
  10. 2
      public/app/features/alerting/unified/api/buildInfo.test.ts
  11. 19
      public/app/features/alerting/unified/api/buildInfo.ts
  12. 81
      public/app/features/alerting/unified/api/featureDiscoveryApi.ts
  13. 23
      public/app/features/alerting/unified/components/MenuItemPauseRule.tsx
  14. 13
      public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx
  15. 8
      public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx
  16. 13
      public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx
  17. 9
      public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx
  18. 67
      public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx
  19. 63
      public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx
  20. 23
      public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx
  21. 8
      public/app/features/alerting/unified/components/rules/CloudRules.tsx
  22. 31
      public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx
  23. 48
      public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx
  24. 23
      public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx
  25. 21
      public/app/features/alerting/unified/components/rules/RuleListErrors.tsx
  26. 4
      public/app/features/alerting/unified/components/rules/RuleListStateView.tsx
  27. 10
      public/app/features/alerting/unified/components/rules/RulesGroup.tsx
  28. 34
      public/app/features/alerting/unified/components/rules/RulesTable.test.tsx
  29. 4
      public/app/features/alerting/unified/components/rules/RulesTable.tsx
  30. 102
      public/app/features/alerting/unified/hooks/useAbilities.ts
  31. 6
      public/app/features/alerting/unified/hooks/useCombinedRule.ts
  32. 2
      public/app/features/alerting/unified/hooks/useFilteredRules.ts
  33. 52
      public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx
  34. 7
      public/app/features/alerting/unified/hooks/useIsRuleEditable.ts
  35. 33
      public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts
  36. 13
      public/app/features/alerting/unified/mocks.ts
  37. 30
      public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts
  38. 8
      public/app/features/alerting/unified/rule-list/RuleList.v1.tsx
  39. 526
      public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
  40. 153
      public/app/features/alerting/unified/rule-list/StateView.tsx
  41. 53
      public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx
  42. 11
      public/app/features/alerting/unified/rule-list/components/EvaluationGroup.tsx
  43. 81
      public/app/features/alerting/unified/rule-list/components/EvaluationGroupWithRules.tsx
  44. 28
      public/app/features/alerting/unified/rule-list/components/ListGroup.tsx
  45. 16
      public/app/features/alerting/unified/rule-list/components/ListSection.tsx
  46. 14
      public/app/features/alerting/unified/rule-list/components/Namespace.tsx
  47. 114
      public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx
  48. 12
      public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx
  49. 155
      public/app/features/alerting/unified/state/actions.ts
  50. 6
      public/app/features/alerting/unified/state/reducers.ts
  51. 53
      public/app/features/alerting/unified/utils/datasource.ts
  52. 8
      public/app/features/alerting/unified/utils/labels.ts
  53. 28
      public/app/features/alerting/unified/utils/misc.ts
  54. 9
      public/app/features/alerting/unified/utils/rules.test.ts
  55. 12
      public/app/features/alerting/unified/utils/rules.ts
  56. 4
      public/app/types/unified-alerting-dto.ts
  57. 4
      public/locales/en-US/grafana.json
  58. 4
      public/locales/pseudo-LOCALE/grafana.json

@ -1909,9 +1909,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"]
],
"public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans />", "1"]
],
"public/app/features/alerting/unified/rule-list/RuleList.v2.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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"]

@ -38,6 +38,7 @@
"unicons/chart-line",
"unicons/check",
"unicons/check-circle",
"unicons/times-circle",
"unicons/circle",
"unicons/clipboard-alt",
"unicons/clock-nine",

@ -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
}

@ -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<RuleEditorPathParams>();
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 <AlertWarning title="Cannot create rules">Sorry! You are not allowed to create rules.</AlertWarning>;
}
@ -90,10 +73,10 @@ const RuleEditor = () => {
}
// new alert rule
return <AlertRuleForm />;
}, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier, loading]);
}, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier]);
return (
<AlertingPageWrapper isLoading={loading} navId="alert-list" pageNav={getPageNav(identifier, type)}>
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav(identifier, type)}>
{getContent()}
</AlertingPageWrapper>
);

@ -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

@ -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));

@ -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([
{

@ -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,

@ -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<string, string | undefined> = {
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,

@ -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);

@ -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<PromApiFeatures> {
if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) {
export async function discoverFeaturesByUid(dataSourceUid: string): Promise<PromApiFeatures> {
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,
},

@ -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<AlertmanagerApiFeatures, { amSourceName: string }>({
@ -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<RulesSourceFeatures, { rulesSourceName: string } | { uid: string }>({
queryFn: async (rulesSourceIdentifier) => {
const dataSourceUID = getDataSourceUID(rulesSourceIdentifier);
if (!dataSourceUID) {
return { error: new Error(`Unable to find data source for ${rulesSourceIdentifier}`) };
}
if (isGrafanaRulesSource(dataSourceUID)) {
return {
data: {
name: GRAFANA_RULES_SOURCE_NAME,
uid: GRAFANA_RULES_SOURCE_NAME,
application: 'grafana',
rulerConfig: GRAFANA_RULER_CONFIG,
} satisfies RulesSourceFeatures,
};
}
const dsSettings = getRulesDataSource(rulesSourceName);
if (!dsSettings) {
return { error: new Error(`Missing data source configuration for ${rulesSourceName}`) };
const dataSourceSettings = dataSourceUID ? getRulesDataSourceByUID(dataSourceUID) : undefined;
if (!dataSourceSettings) {
return { error: new Error(`Missing data source configuration for ${rulesSourceIdentifier}`) };
}
const discoverFeaturesWithLogging = withPerformanceLogging(
'unifiedalerting/featureDiscoveryApi/discoverDsFeatures',
discoverFeatures,
{
dataSourceName: rulesSourceName,
endpoint: 'unifiedalerting/featureDiscoveryApi/discoverDsFeatures',
}
);
const dsFeatures = await discoverFeaturesWithLogging(dsSettings.name);
const rulerConfig: RulerDataSourceConfig | undefined = dsFeatures.features.rulerApiEnabled
? {
dataSourceName: dsSettings.name,
apiVersion: dsFeatures.application === PromApplication.Cortex ? 'legacy' : 'config',
}
const features = await discoverFeaturesByUid(dataSourceSettings.uid);
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,
};
},
}),
}),

@ -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;

@ -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 (
<DataSourcePicker
disabled={loading || disabled}
disabled={isLoading || disabled}
noDefault
alerting
filter={dataSourceFilter}

@ -8,13 +8,14 @@ import { byRole } from 'testing-library-selector';
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import RuleEditor from 'app/features/alerting/unified/RuleEditor';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { mockFeatureDiscoveryApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
import { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks';
import { setAlertmanagerChoices } from 'app/features/alerting/unified/mocks/server/configure';
import { captureRequests, serializeRequests } from 'app/features/alerting/unified/mocks/server/events';
import { FOLDER_TITLE_HAPPY_PATH } from 'app/features/alerting/unified/mocks/server/handlers/search';
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { testWithFeatureToggles } from 'app/features/alerting/unified/test/test-utils';
import { buildInfoResponse } from 'app/features/alerting/unified/testSetup/featureDiscovery';
import { DataSourceType, GRAFANA_DATASOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
@ -28,7 +29,7 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
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 () => {

@ -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();

@ -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) => {

@ -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 && <MenuItemPauseRule rule={rule} onPauseChange={onPauseChange} />}
{canPause && rulerRule && (
<MenuItemPauseRule rule={rulerRule} groupIdentifier={groupIdentifier} onPauseChange={onPauseChange} />
)}
{canSilence && <Menu.Item label="Silence notifications" icon="bell-slash" onClick={handleSilence} />}
{shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={rule.name} url={''} />}
{/* TODO Migrate Declare Incident to plugin links extensions */}
{shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={promRule.name} url={''} />}
{canDuplicate && <Menu.Item label="Duplicate" icon="copy" onClick={() => handleDuplicateRule(identifier)} />}
{showDivider && <Menu.Divider />}
{shareUrl && <Menu.Item label="Copy link" icon="share-alt" onClick={() => copyToClipboard(shareUrl)} />}
@ -96,10 +110,15 @@ const AlertRuleMenu = ({
))}
</>
)}
{canDelete && (
{canDelete && rulerRule && (
<>
<Menu.Divider />
<Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => handleDelete(rule)} />
<Menu.Item
label="Delete"
icon="trash-alt"
destructive
onClick={() => handleDelete(rulerRule, groupIdentifier)}
/>
</>
)}
</>

@ -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<CombinedRule | undefined>();
const [ruleToDelete, setRuleToDelete] = useState<DeleteRuleInfo>();
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];

@ -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<RuleIdentifier>();
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={<RuleActionsButtons rule={rule} showCopyLinkButton rulesSource={rule.namespace.rulesSource} />}
actions={<RuleActionsButtons rule={rule} rulesSource={rule.namespace.rulesSource} />}
info={createMetadata(rule)}
subTitle={
<Stack direction="column">
@ -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,

@ -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));

@ -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) => ({

@ -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(<RuleActionsButtons rule={mockRule} rulesSource="grafana" showCopyLinkButton />);
render(<RuleActionsButtons rule={mockRule} rulesSource="grafana" />);
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 defaultState = configureStore().getState();
render(<RuleActionsButtons rule={mockRule} rulesSource={dataSource} />, {
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',
},
},
};
}),
});
const mockRule = getCloudRule(undefined, { rulesSource: mimirDs });
mockFeatureDiscoveryApi(server).discoverDsFeatures(mimirDs, buildInfoResponse.mimir);
render(<RuleActionsButtons rule={mockRule} rulesSource={mimirDs} />);
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(<RuleActionsButtons rule={mockRule} rulesSource="grafana" />);
const mockRule = getCloudRule({ name: 'pod-1-cpu-firing' });
render(<RuleActionsButtons rule={mockRule} rulesSource={promDataSource} />);
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'
);
});
});

@ -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 (
<Stack gap={1} alignItems="center" wrap="nowrap">
{buttons}
<AlertRuleMenu
buttonSize={buttonSize}
rule={rule}
rulerRule={rule.rulerRule}
promRule={rule.promRule}
identifier={identifier}
showCopyLinkButton={showCopyLinkButton}
handleDelete={() => 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 && (

@ -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<Array<{ error: SerializedError; dataSource: DataSourceInstanceSettings }>>(
(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{' '}
<a href={makeDataSourceLink(dataSource.uid)} className={styles.dsLink}>
{dataSource.name}
</a>
: {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 (
<>

@ -102,7 +102,7 @@ const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: C
return null;
}
const originMeta = getRulePluginOrigin(rule);
const originMeta = getRulePluginOrigin(rule.promRule);
return (
<AlertRuleListItem
@ -116,7 +116,7 @@ const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: C
labels={rule.promRule?.labels}
isProvisioned={isProvisioned}
instancesCount={instancesCount}
namespace={rule.namespace}
namespace={rule.namespace.name}
group={rule.group.name}
actions={<RuleActionsButtons compact rule={rule} rulesSource={rule.namespace.rulesSource} />}
origin={originMeta}

@ -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 && (
<ReorderCloudGroupModal
group={group}
folderUid={folderUID}
namespace={namespace}
onClose={() => setIsReorderingGroup(false)}
rulerConfig={dsFeatures.rulerConfig}
/>
)}
<ConfirmModal

@ -4,7 +4,7 @@ import { byRole } from 'testing-library-selector';
import { setPluginLinksHook } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { AlertRuleAction, useAlertRuleAbility, useRulerRuleAbility } from '../../hooks/useAbilities';
import { getCloudRule, getGrafanaRule } from '../../mocks';
import { RulesTable } from './RulesTable';
@ -12,6 +12,10 @@ import { RulesTable } from './RulesTable';
jest.mock('../../hooks/useAbilities');
const mocks = {
// This is a bit unfortunate, but we need to mock both abilities
// RuleActionButtons still needs to use the useAlertRuleAbility hook
// whereas AlertRuleMenu has already been refactored to use useRulerRuleAbility
useRulerRuleAbility: jest.mocked(useRulerRuleAbility),
useAlertRuleAbility: jest.mocked(useAlertRuleAbility),
};
@ -45,6 +49,9 @@ describe('RulesTable RBAC', () => {
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(<RulesTable rules={[grafanaRule]} />);
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];
});

@ -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 <PluginOriginBadge pluginId={originMeta.pluginId} />;
}

@ -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<AlertRul
loading,
} = useIsRuleEditable(rulesSourceName, rule.rulerRule);
const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
const canSilence = useCanSilence(rule);
const canSilence = useCanSilence(rule.rulerRule);
const abilities = useMemo<Abilities<AlertRuleAction>>(() => {
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<AlertRul
return abilities;
}
export function useAllRulerRuleAbilities(
rule: RulerRuleDTO | undefined,
groupIdentifier: RuleGroupIdentifier
): Abilities<AlertRuleAction> {
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<Abilities<AlertRuleAction>>(() => {
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> = {
[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<AlertmanagerAction> {
const {
selectedAlertmanager,
@ -335,23 +406,26 @@ 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);
function useCanSilence(rule?: RulerRuleDTO): [boolean, boolean] {
const folderUID = isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined;
const { loading: folderIsLoading, folder } = useFolder(folderUID);
const { currentData: amConfigStatus, isLoading } =
alertmanagerApi.endpoints.getGrafanaAlertingConfigurationStatus.useQuery(undefined, {
skip: !isGrafanaManagedRule,
});
const isGrafanaManagedRule = rule && isGrafanaRulerRule(rule);
const isGrafanaRecording = rule && isGrafanaRecordingRule(rule);
const folderUID = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.namespace_uid : undefined;
const { loading: folderIsLoading, folder } = useFolder(folderUID);
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

@ -240,7 +240,11 @@ export function useRuleWithLocation({
}): RequestState<RuleWithLocation> {
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,

@ -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) {

@ -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<{}>) => <Provider store={store}>{children}</Provider>;
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' },
},
},
};
}

@ -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,
};
}

@ -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<DataSourceInstanceSettings[]>([]);
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 };
}

@ -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<CombinedRule>, rulerOverride?:
});
}
export function getCloudRule(override?: Partial<CombinedRule>) {
export function getCloudRule(override?: Partial<CombinedRule>, nsOverride?: Partial<CombinedRuleNamespace>) {
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,
});
}

@ -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<RuleExtensionPoint>(() => {
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]);
}

@ -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
<Trans i18nKey="alerting.rule-list.new-alert-rule">New alert rule</Trans>
</LinkButton>
);
}

@ -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 { EvaluationGroupWithRules } from './components/EvaluationGroupWithRules';
import Namespace from './components/Namespace';
// make sure we ask for 1 more so we show the "show x more" button
const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1;
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 { 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';
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 ruleSources = getAllRulesSources();
const onFilterCleared = useCallback(() => setExpandAll(false), []);
return (
// We don't want to show the Loading... indicator for the whole page.
// We show separate indicators for Grafana-managed and Cloud rules
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={null}>
<RulesFilter onClear={() => {}} />
<Stack direction="column" gap={1}>
{ruleSources.map((ruleSource) => {
if (isGrafanaRulesSource(ruleSource)) {
return <GrafanaDataSourceLoader key={ruleSource} />;
} else {
return <DataSourceLoader key={ruleSource.uid} uid={ruleSource.uid} name={ruleSource.name} />;
}
})}
</Stack>
</AlertingPageWrapper>
);
},
{ style: 'page' }
);
const { filterState, hasActiveFilters } = useRulesFilter();
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
interface DataSourceLoaderProps {
name: string;
uid: string;
}
const loading = rulesDataSourceNames.some(
(name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading
);
const GrafanaDataSourceLoader = () => {
return <DataSourceSection name="Grafana" application="grafana" isLoading={true}></DataSourceSection>;
};
const promRequests = Object.entries(promRuleRequests);
const rulerRequests = Object.entries(rulerRuleRequests);
const DataSourceLoader = ({ uid, name }: DataSourceLoaderProps) => {
const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid });
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)
);
if (isLoading) {
return <DataSourceSection loader={<Skeleton width={250} height={16} />} />;
}
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)
);
// 2. grab prometheus rule groups with max_groups if supported
if (dataSourceInfo) {
const rulerEnabled = Boolean(dataSourceInfo.rulerConfig);
return (
// We don't want to show the Loading... indicator for the whole page.
// We show separate indicators for Grafana-managed and Cloud rules
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={hasAlertRulesCreated && <CreateAlertButton />}>
<RuleListErrors />
<RulesFilter onClear={onFilterCleared} />
{hasAlertRulesCreated && (
<>
<div className={styles.break} />
<div className={styles.buttonsContainer}>
<div className={styles.statsContainer}>
{hasActiveFilters && (
<Button
className={styles.expandAllButton}
icon={expandAll ? 'angle-double-up' : 'angle-double-down'}
variant="secondary"
onClick={() => setExpandAll(!expandAll)}
>
{expandAll ? 'Collapse all' : 'Expand all'}
</Button>
)}
<RuleStats namespaces={filteredNamespaces} />
</div>
</div>
</>
)}
{hasNoAlertRulesCreatedYet && <NoRulesSplash />}
{hasAlertRulesCreated && (
<>
<LoadingIndicator visible={loading} />
<ul className={styles.rulesTree} role="tree" aria-label="List of alert rules">
{sortedNamespaces.map((namespace) => {
const { rulesSource, uid } = namespace;
const application = getApplicationFromRulesSource(rulesSource);
const href = application === 'grafana' && uid ? makeFolderAlertsLink(uid, namespace.name) : undefined;
return (
<Namespace
key={getRulesSourceUniqueKey(rulesSource) + namespace.name}
href={href}
name={namespace.name}
application={application}
>
{namespace.groups
.sort((a, b) => a.name.localeCompare(b.name))
.map((group) => (
<EvaluationGroupWithRules key={group.name} group={group} rulesSource={rulesSource} />
))}
</Namespace>
);
})}
</ul>
</>
)}
</AlertingPageWrapper>
<PaginatedDataSourceLoader
ruleSourceName={dataSourceInfo.name}
rulerEnabled={rulerEnabled}
uid={uid}
name={name}
application={dataSourceInfo.application}
/>
);
},
{ style: 'page' }
);
}
const LoadingIndicator = ({ visible = false }) => {
const [measureRef, { width }] = useMeasure<HTMLDivElement>();
return <div ref={measureRef}>{visible && <LoadingBar width={width} />}</div>;
return null;
};
const getStyles = (theme: GrafanaTheme2) => ({
rulesTree: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
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),
}),
});
interface PaginatedDataSourceLoaderProps extends Pick<DataSourceSectionProps, 'application' | 'uid' | 'name'> {
ruleSourceName: string;
rulerEnabled?: boolean;
}
export default RuleList;
function PaginatedDataSourceLoader({
ruleSourceName,
rulerEnabled = false,
name,
uid,
application,
}: PaginatedDataSourceLoaderProps) {
const { data: ruleNamespaces = [], isLoading } = usePrometheusRuleNamespacesQuery({
ruleSourceName,
maxGroups: 25,
limitAlerts: 0,
excludeAlerts: true,
});
export function CreateAlertButton() {
const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule);
const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule);
return (
<DataSourceSection name={name} application={application} uid={uid} isLoading={isLoading}>
<Stack direction="column" gap={1}>
{ruleNamespaces.map((namespace) => (
<ListSection
key={namespace.name}
title={
<Stack direction="row" gap={1} alignItems="center">
<Icon name="folder" /> {namespace.name}
</Stack>
}
>
{namespace.groups.map((group) => (
<ListGroup
key={group.name}
name={group.name}
isOpen={false}
actions={
<>
<Dropdown
overlay={
<Menu>
<Menu.Item label="Edit" icon="pen" data-testid="edit-group-action" />
<Menu.Item label="Re-order rules" icon="flip" />
<Menu.Divider />
<Menu.Item label="Export" icon="download-alt" />
<Menu.Item label="Delete" icon="trash-alt" destructive />
</Menu>
}
>
<IconButton name="ellipsis-h" aria-label="rule group actions" />
</Dropdown>
</>
}
>
{group.rules.map((rule) => {
const groupIdentifier: RuleGroupIdentifier = {
dataSourceName: ruleSourceName,
groupName: group.name,
namespaceName: namespace.name,
};
const location = useLocation();
return (
<AlertRuleLoader
key={hashRule(rule)}
rule={rule}
groupIdentifier={groupIdentifier}
rulerEnabled={rulerEnabled}
/>
);
})}
</ListGroup>
))}
</ListSection>
))}
{!isLoading && <Pagination currentPage={1} numberOfPages={0} onNavigate={noop} />}
</Stack>
</DataSourceSection>
);
}
const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed;
interface AlertRuleLoaderProps {
rule: Rule;
groupIdentifier: RuleGroupIdentifier;
rulerEnabled?: boolean;
}
const canCreateGrafanaRules = createRuleSupported && createRuleAllowed;
function AlertRuleLoader({ rule, groupIdentifier, rulerEnabled = false }: AlertRuleLoaderProps) {
const { dataSourceName, namespaceName, groupName } = groupIdentifier;
if (canCreateGrafanaRules || canCreateCloudRules) {
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 <ActionsLoader />;
}
if (rulerRule) {
return <RuleActionsButtons rule={rulerRule} promRule={rule} groupIdentifier={groupIdentifier} compact />;
}
return null;
}, [groupIdentifier, isLoading, rule, rulerEnabled, rulerRule]);
if (isAlertingRule(rule)) {
return (
<LinkButton
href={urlUtil.renderUrl('alerting/new/alerting', { returnTo: location.pathname + location.search })}
icon="plus"
onClick={() => logInfo(LogMessages.alertRuleFromScratch)}
>
New alert rule
</LinkButton>
<AlertRuleListItem
name={rule.name}
href={href}
summary={rule.annotations?.summary}
state={rule.state}
health={rule.health}
error={rule.lastError}
labels={rule.labels}
isProvisioned={undefined}
instancesCount={undefined}
actions={actions}
origin={originMeta}
/>
);
}
return null;
if (isRecordingRule(rule)) {
return (
<RecordingRuleListItem
name={rule.name}
href={href}
health={rule.health}
error={rule.lastError}
labels={rule.labels}
isProvisioned={undefined}
actions={null}
origin={originMeta}
/>
);
}
return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />;
}
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 (
<Stack direction="column" gap={1}>
<Stack direction="column" gap={0}>
{isLoading && <LoadingIndicator />}
<div className={styles.dataSourceSectionTitle}>
{loader ?? (
<Stack alignItems="center">
{application && <DataSourceIcon application={application} />}
{name && (
<Text variant="body" weight="bold">
{name}
</Text>
)}
{description && (
<>
{'·'}
{description}
</>
)}
<Spacer />
{uid && (
<WithReturnButton
title="alert rules"
component={
<LinkButton variant="secondary" size="sm" href={`/connections/datasources/edit/${uid}`}>
<Trans i18nKey="alerting.rule-list.configure-datasource">Configure</Trans>
</LinkButton>
}
/>
)}
</Stack>
)}
</div>
</Stack>
<div className={styles.itemsWrapper}>{children}</div>
</Stack>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
itemsWrapper: css({
position: 'relative',
marginLeft: theme.spacing(1.5),
'&:before': {
content: "''",
position: 'absolute',
height: '100%',
marginLeft: `-${theme.spacing(1.5)}`,
borderLeft: `solid 1px ${theme.colors.border.weak}`,
},
}),
dataSourceSectionTitle: css({
background: theme.colors.background.secondary,
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
border: `solid 1px ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
}),
});
export default RuleList;

@ -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<PromAlertingRuleState, CombinedRule[]>;
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 (
<ul className={styles.columnStack} role="tree">
{Array.from(entries).map(([state, rules]) => (
<RulesByState key={state} state={state} rules={rules} />
))}
</ul>
);
};
const STATE_TITLES: Record<PromAlertingRuleState, string> = {
[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 (
<ListSection
title={
<Stack alignItems="center" gap={0}>
{STATE_TITLES[state] ?? 'Unknown'}
<Counter value={rules.length} />
</Stack>
}
collapsed={isFiringState || hasRulesMatchingState}
pagination={
<Pagination
currentPage={page}
numberOfPages={numberOfPages}
onNavigate={onPageChange}
hideWhenSinglePage={true}
/>
}
>
{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 (
<AlertRuleListItem
key={hashRule(promRule)}
name={rule.name}
href={createViewLink(rule.namespace.rulesSource, rule)}
summary={rule.annotations.summary}
state={state}
health={rule.promRule?.health}
error={rule.promRule?.lastError}
labels={rule.promRule?.labels}
isProvisioned={isProvisioned}
instancesCount={instancesCount}
namespace={rule.namespace.name}
group={rule.group.name}
actions={
rule.rulerRule ? (
<RuleActionsButtons
compact
rule={rule.rulerRule}
promRule={promRule}
groupIdentifier={groupIdentifier}
/>
) : (
<ActionsLoader />
)
}
origin={originMeta}
/>
);
})}
</ListSection>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
columnStack: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
});

@ -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(
<MetaText icon="tag-alt">
<TextLink href={href} variant="bodySmall" color="primary" inline={false}>
@ -139,6 +138,39 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
);
};
type RecordingRuleListItemProps = Omit<AlertRuleListItemProps, 'summary' | 'state' | 'instancesCount' | 'contactPoint'>;
export function RecordingRuleListItem({
name,
href,
health,
isProvisioned,
error,
isPaused,
origin,
}: RecordingRuleListItemProps) {
return (
<ListItem
title={
<Stack direction="row" alignItems="center">
<TextLink href={href} inline={false}>
{name}
</TextLink>
{origin && <PluginOriginBadge pluginId={origin.pluginId} size="sm" />}
{/* show provisioned badge only when it also doesn't have plugin origin */}
{isProvisioned && !origin && <ProvisioningBadge />}
{/* let's not show labels for now, but maybe users would be interested later? Or maybe show them only in the list view? */}
{/* {labels && <AlertLabels labels={labels} size="xs" />} */}
</Stack>
}
description={<Summary error={error} />}
icon={<RuleListIcon recording={true} health={health} isPaused={isPaused} />}
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) => {
<Trans i18nKey="alerting.alert-rules.rule-definition">Rule definition</Trans>
</summary>
<pre>
<code>{JSON.stringify(rule.rulerRule, null, 2)}</code>
<code>{JSON.stringify(rule, null, 2)}</code>
</pre>
</details>
</Alert>
@ -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) => (
<Stack direction="row" alignItems="center" gap={0.5}>
<Icon size="xs" name="folder" />
<Stack direction="row" alignItems="center" gap={0}>
{namespace.name}
{namespace}
<Icon size="sm" name="angle-right" />
{group}
</Stack>

@ -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;

@ -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 (
<EvaluationGroup name={group.name} interval={group.interval} isOpen={open} onToggle={toggleOpen}>
{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 (
<AlertRuleListItem
key={hashRulerRule(rulerRule)}
state={isAlertingPromRule ? promRule?.state : undefined}
health={promRule?.health}
error={promRule?.lastError}
name={rule.name}
labels={rulerRule.labels}
lastEvaluation={promRule?.lastEvaluation}
evaluationInterval={group.interval}
instancesCount={isAlertingPromRule ? size(promRule.alerts) : undefined}
href={createViewLink(rulesSource, rule)}
summary={annotations?.summary}
/>
);
}
if (isGrafanaRulerRule(rulerRule)) {
const contactPoint = rulerRule.grafana_alert.notification_settings?.receiver;
return (
<AlertRuleListItem
key={rulerRule.grafana_alert.uid}
name={rulerRule.grafana_alert.title}
state={isAlertingPromRule ? promRule?.state : undefined}
health={promRule?.health}
error={promRule?.lastError}
labels={rulerRule.labels}
isPaused={rulerRule.grafana_alert.is_paused}
lastEvaluation={promRule?.lastEvaluation}
evaluationInterval={group.interval}
instancesCount={isAlertingPromRule ? size(promRule.alerts) : undefined}
href={createViewLink(rulesSource, rule)}
summary={rule.annotations?.summary}
isProvisioned={Boolean(rulerRule.grafana_alert.provenance)}
contactPoint={contactPoint}
/>
);
}
// if we get here it means we don't really know how to render this rule
return <UnknownRuleListItem key={hashRulerRule(rulerRule)} rule={rule} />;
})}
</EvaluationGroup>
);
};

@ -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 (
<div className={styles.groupWrapper} role="treeitem" aria-expanded={isOpen} aria-selected="false">
<div className={styles.groupWrapper} role="treeitem" aria-expanded={open} aria-selected="false">
<GroupHeader
onToggle={onToggle}
isOpen={isOpen}
onToggle={() => toggle()}
isOpen={open}
description={description}
name={name}
metaRight={metaRight}
actions={actions}
/>
{isOpen && <div role="group">{children}</div>}
{open && <div role="group">{children}</div>}
</div>
);
};
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 (
<div className={styles.headerWrapper}>
<Stack direction="row" alignItems="center" gap={1}>
<Stack alignItems="center" gap={1}>
<Stack alignItems="center" gap={0}>
<IconButton
name={isOpen ? 'angle-right' : 'angle-down'}
name={isOpen ? 'angle-down' : 'angle-right'}
onClick={onToggle}
aria-label={t('common.collapse', 'Collapse')}
/>
@ -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,

@ -30,7 +30,7 @@ export const ListSection = ({
<li className={styles.wrapper} role="treeitem" aria-selected="false">
<div className={styles.sectionTitle}>
<Stack alignItems="center">
<Stack alignItems="center" gap={1}>
<Stack alignItems="center" gap={0}>
<IconButton
name={isCollapsed ? 'angle-right' : 'angle-down'}
onClick={toggleCollapsed}
@ -65,18 +65,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
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,
}),

@ -21,7 +21,7 @@ const Namespace = ({ children, name, href, application }: NamespaceProps) => {
<li className={styles.namespaceWrapper} role="treeitem" aria-selected="false">
<div className={styles.namespaceTitle}>
<Stack alignItems={'center'} gap={1}>
<NamespaceIcon application={application} />
<DataSourceIcon application={application} />
{href ? (
<WithReturnButton
title="Alert rules"
@ -49,7 +49,7 @@ interface NamespaceIconProps {
application?: RulesSourceApplication;
}
const NamespaceIcon = ({ application }: NamespaceIconProps) => {
export const DataSourceIcon = ({ application }: NamespaceIconProps) => {
switch (application) {
case PromApplication.Prometheus:
return (
@ -64,11 +64,11 @@ const NamespaceIcon = ({ application }: NamespaceIconProps) => {
return (
<img width={16} height={16} src="public/app/plugins/datasource/prometheus/img/mimir_logo.svg" alt="Mimir" />
);
case 'loki':
case 'Loki':
return <img width={16} height={16} src="public/app/plugins/datasource/loki/img/loki_icon.svg" alt="Loki" />;
case 'grafana':
default:
return <Icon name="folder" />;
return <Icon name="grafana" />;
}
};
@ -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,
}),
});

@ -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<boolean>(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(
<LinkButton title="Edit" size={buttonSize} key="edit" variant="secondary" icon="pen" href={editURL}>
<Trans i18nKey="common.edit">Edit</Trans>
</LinkButton>
);
}
return (
<Stack gap={1} alignItems="center" wrap="nowrap">
{buttons}
<AlertRuleMenu
buttonSize={buttonSize}
rulerRule={rule}
promRule={promRule}
groupIdentifier={groupIdentifier}
identifier={identifier}
handleDelete={() => 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 && (
<AlertmanagerProvider accessType="instance">
<SilenceGrafanaRuleDrawer rulerRule={rule} onClose={() => setShowSilenceDrawer(false)} />
</AlertmanagerProvider>
)}
{redirectToClone?.identifier && (
<RedirectToCloneRule
identifier={redirectToClone.identifier}
isProvisioned={redirectToClone.isProvisioned}
onDismiss={() => setRedirectToClone(undefined)}
/>
)}
</Stack>
);
};
export const ActionsLoader = () => <Skeleton width={50} height={16} />;

@ -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<HTMLDivElement>();
return (
<div ref={ref}>
<LoadingBar width={width} />
</div>
);
};
const GroupLoadingIndicator = () => {
const [ref, { width }] = useMeasure<HTMLDivElement>();

@ -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<RuleNamespace[]> => {
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<RulerRulesConfigDTO | null> => {
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<Promise<void>> {
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<Promise<void>> {
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<PromBasedDataSource> => {
return withSerializedError<PromBasedDataSource>(
(async (): Promise<PromBasedDataSource> => {
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<PromBasedDataSource> = (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 })),

@ -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,

@ -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<Da
return getAllDataSources().find((source) => source.name === name);
}
export function getDataSourceByUid(dsUid: string): DataSourceInstanceSettings<DataSourceJsonData> | 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<DataSourceJsonData> | undefined {
return getDataSourceSrv().getList({ alerting: true })[0];
}
@ -291,20 +319,3 @@ export function getDefaultOrFirstCompatibleDataSource(): DataSourceInstanceSetti
export function isDataSourceManagingAlerts(ds: DataSourceInstanceSettings<DataSourceJsonData>) {
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
}

@ -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;
}

@ -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;

@ -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' });

@ -176,10 +176,12 @@ export interface RulePluginOrigin {
pluginId: string;
}
export function getRulePluginOrigin(rule: CombinedRule): RulePluginOrigin | undefined {
// com.grafana.origin=plugin/<plugin-identifier>
// 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));
}

@ -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;
};

@ -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",

@ -280,6 +280,10 @@
"success": "Ŝūččęşşƒūľľy ūpđäŧęđ řūľę ģřőūp"
}
},
"rule-list": {
"configure-datasource": "Cőʼnƒįģūřę",
"new-alert-rule": "Ńęŵ äľęřŧ řūľę"
},
"rule-state": {
"creating": "Cřęäŧįʼnģ",
"deleting": "Đęľęŧįʼnģ",

Loading…
Cancel
Save