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>
This commit is contained in:
Konrad Lalik 2024-11-18 10:48:15 +01:00 committed by GitHub
parent 65097d4b54
commit b73ab15878
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 1591 additions and 1155 deletions

View File

@ -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 />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"], [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 />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"], [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"]
], ],
"public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx:5381": [ "public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"], [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, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Do not use any type assertions.", "8"] [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": [ "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.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"] [0, 0, 0, "Unexpected any. Specify a different type.", "1"]

View File

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

View File

@ -46,141 +46,142 @@ import u1035 from '../../../img/icons/unicons/channel-add.svg';
import u1036 from '../../../img/icons/unicons/chart-line.svg'; import u1036 from '../../../img/icons/unicons/chart-line.svg';
import u1037 from '../../../img/icons/unicons/check.svg'; import u1037 from '../../../img/icons/unicons/check.svg';
import u1038 from '../../../img/icons/unicons/check-circle.svg'; import u1038 from '../../../img/icons/unicons/check-circle.svg';
import u1039 from '../../../img/icons/unicons/circle.svg'; import u1039 from '../../../img/icons/unicons/times-circle.svg';
import u1040 from '../../../img/icons/unicons/clipboard-alt.svg'; import u1040 from '../../../img/icons/unicons/circle.svg';
import u1041 from '../../../img/icons/unicons/clock-nine.svg'; import u1041 from '../../../img/icons/unicons/clipboard-alt.svg';
import u1042 from '../../../img/icons/unicons/cloud.svg'; import u1042 from '../../../img/icons/unicons/clock-nine.svg';
import u1043 from '../../../img/icons/unicons/cloud-download.svg'; import u1043 from '../../../img/icons/unicons/cloud.svg';
import u1044 from '../../../img/icons/unicons/code-branch.svg'; import u1044 from '../../../img/icons/unicons/cloud-download.svg';
import u1045 from '../../../img/icons/unicons/cog.svg'; import u1045 from '../../../img/icons/unicons/code-branch.svg';
import u1046 from '../../../img/icons/unicons/columns.svg'; import u1046 from '../../../img/icons/unicons/cog.svg';
import u1047 from '../../../img/icons/unicons/comment-alt.svg'; import u1047 from '../../../img/icons/unicons/columns.svg';
import u1048 from '../../../img/icons/unicons/comment-alt-share.svg'; import u1048 from '../../../img/icons/unicons/comment-alt.svg';
import u1049 from '../../../img/icons/unicons/comments-alt.svg'; import u1049 from '../../../img/icons/unicons/comment-alt-share.svg';
import u1050 from '../../../img/icons/unicons/compass.svg'; import u1050 from '../../../img/icons/unicons/comments-alt.svg';
import u1051 from '../../../img/icons/unicons/copy.svg'; import u1051 from '../../../img/icons/unicons/compass.svg';
import u1052 from '../../../img/icons/unicons/corner-down-right-alt.svg'; import u1052 from '../../../img/icons/unicons/copy.svg';
import u1053 from '../../../img/icons/unicons/cube.svg'; import u1053 from '../../../img/icons/unicons/corner-down-right-alt.svg';
import u1054 from '../../../img/icons/unicons/dashboard.svg'; import u1054 from '../../../img/icons/unicons/cube.svg';
import u1055 from '../../../img/icons/unicons/database.svg'; import u1055 from '../../../img/icons/unicons/dashboard.svg';
import u1056 from '../../../img/icons/unicons/document-info.svg'; import u1056 from '../../../img/icons/unicons/database.svg';
import u1057 from '../../../img/icons/unicons/download-alt.svg'; import u1057 from '../../../img/icons/unicons/document-info.svg';
import u1058 from '../../../img/icons/unicons/draggabledots.svg'; import u1058 from '../../../img/icons/unicons/download-alt.svg';
import u1059 from '../../../img/icons/unicons/edit.svg'; import u1059 from '../../../img/icons/unicons/draggabledots.svg';
import u1060 from '../../../img/icons/unicons/ellipsis-v.svg'; import u1060 from '../../../img/icons/unicons/edit.svg';
import u1061 from '../../../img/icons/unicons/ellipsis-h.svg'; import u1061 from '../../../img/icons/unicons/ellipsis-v.svg';
import u1062 from '../../../img/icons/unicons/envelope.svg'; import u1062 from '../../../img/icons/unicons/ellipsis-h.svg';
import u1063 from '../../../img/icons/unicons/exchange-alt.svg'; import u1063 from '../../../img/icons/unicons/envelope.svg';
import u1064 from '../../../img/icons/unicons/exclamation-circle.svg'; import u1064 from '../../../img/icons/unicons/exchange-alt.svg';
import u1065 from '../../../img/icons/unicons/exclamation-triangle.svg'; import u1065 from '../../../img/icons/unicons/exclamation-circle.svg';
import u1066 from '../../../img/icons/unicons/external-link-alt.svg'; import u1066 from '../../../img/icons/unicons/exclamation-triangle.svg';
import u1067 from '../../../img/icons/unicons/eye.svg'; import u1067 from '../../../img/icons/unicons/external-link-alt.svg';
import u1068 from '../../../img/icons/unicons/eye-slash.svg'; import u1068 from '../../../img/icons/unicons/eye.svg';
import u1069 from '../../../img/icons/unicons/file-alt.svg'; import u1069 from '../../../img/icons/unicons/eye-slash.svg';
import u1070 from '../../../img/icons/unicons/file-blank.svg'; import u1070 from '../../../img/icons/unicons/file-alt.svg';
import u1071 from '../../../img/icons/unicons/filter.svg'; import u1071 from '../../../img/icons/unicons/file-blank.svg';
import u1072 from '../../../img/icons/unicons/folder.svg'; import u1072 from '../../../img/icons/unicons/filter.svg';
import u1073 from '../../../img/icons/unicons/folder-open.svg'; import u1073 from '../../../img/icons/unicons/folder.svg';
import u1074 from '../../../img/icons/unicons/folder-plus.svg'; import u1074 from '../../../img/icons/unicons/folder-open.svg';
import u1075 from '../../../img/icons/unicons/folder-upload.svg'; import u1075 from '../../../img/icons/unicons/folder-plus.svg';
import u1076 from '../../../img/icons/unicons/forward.svg'; import u1076 from '../../../img/icons/unicons/folder-upload.svg';
import u1077 from '../../../img/icons/unicons/graph-bar.svg'; import u1077 from '../../../img/icons/unicons/forward.svg';
import u1078 from '../../../img/icons/unicons/history.svg'; import u1078 from '../../../img/icons/unicons/graph-bar.svg';
import u1079 from '../../../img/icons/unicons/history-alt.svg'; import u1079 from '../../../img/icons/unicons/history.svg';
import u1080 from '../../../img/icons/unicons/home-alt.svg'; import u1080 from '../../../img/icons/unicons/history-alt.svg';
import u1081 from '../../../img/icons/unicons/import.svg'; import u1081 from '../../../img/icons/unicons/home-alt.svg';
import u1082 from '../../../img/icons/unicons/info.svg'; import u1082 from '../../../img/icons/unicons/import.svg';
import u1083 from '../../../img/icons/unicons/info-circle.svg'; import u1083 from '../../../img/icons/unicons/info.svg';
import u1084 from '../../../img/icons/unicons/k6.svg'; import u1084 from '../../../img/icons/unicons/info-circle.svg';
import u1085 from '../../../img/icons/unicons/key-skeleton-alt.svg'; import u1085 from '../../../img/icons/unicons/k6.svg';
import u1086 from '../../../img/icons/unicons/keyboard.svg'; import u1086 from '../../../img/icons/unicons/key-skeleton-alt.svg';
import u1087 from '../../../img/icons/unicons/link.svg'; import u1087 from '../../../img/icons/unicons/keyboard.svg';
import u1088 from '../../../img/icons/unicons/list-ul.svg'; import u1088 from '../../../img/icons/unicons/link.svg';
import u1089 from '../../../img/icons/unicons/lock.svg'; import u1089 from '../../../img/icons/unicons/list-ul.svg';
import u1090 from '../../../img/icons/unicons/minus.svg'; import u1090 from '../../../img/icons/unicons/lock.svg';
import u1091 from '../../../img/icons/unicons/minus-circle.svg'; import u1091 from '../../../img/icons/unicons/minus.svg';
import u1092 from '../../../img/icons/unicons/mobile-android.svg'; import u1092 from '../../../img/icons/unicons/minus-circle.svg';
import u1093 from '../../../img/icons/unicons/monitor.svg'; import u1093 from '../../../img/icons/unicons/mobile-android.svg';
import u1094 from '../../../img/icons/unicons/pause.svg'; import u1094 from '../../../img/icons/unicons/monitor.svg';
import u1095 from '../../../img/icons/unicons/pen.svg'; import u1095 from '../../../img/icons/unicons/pause.svg';
import u1096 from '../../../img/icons/unicons/play.svg'; import u1096 from '../../../img/icons/unicons/pen.svg';
import u1097 from '../../../img/icons/unicons/plug.svg'; import u1097 from '../../../img/icons/unicons/play.svg';
import u1098 from '../../../img/icons/unicons/plus.svg'; import u1098 from '../../../img/icons/unicons/plug.svg';
import u1099 from '../../../img/icons/unicons/plus-circle.svg'; import u1099 from '../../../img/icons/unicons/plus.svg';
import u1100 from '../../../img/icons/unicons/power.svg'; import u1100 from '../../../img/icons/unicons/plus-circle.svg';
import u1101 from '../../../img/icons/unicons/presentation-play.svg'; import u1101 from '../../../img/icons/unicons/power.svg';
import u1102 from '../../../img/icons/unicons/process.svg'; import u1102 from '../../../img/icons/unicons/presentation-play.svg';
import u1103 from '../../../img/icons/unicons/question-circle.svg'; import u1103 from '../../../img/icons/unicons/process.svg';
import u1104 from '../../../img/icons/unicons/repeat.svg'; import u1104 from '../../../img/icons/unicons/question-circle.svg';
import u1105 from '../../../img/icons/unicons/rocket.svg'; import u1105 from '../../../img/icons/unicons/repeat.svg';
import u1106 from '../../../img/icons/unicons/rss.svg'; import u1106 from '../../../img/icons/unicons/rocket.svg';
import u1107 from '../../../img/icons/unicons/save.svg'; import u1107 from '../../../img/icons/unicons/rss.svg';
import u1108 from '../../../img/icons/unicons/search.svg'; import u1108 from '../../../img/icons/unicons/save.svg';
import u1109 from '../../../img/icons/unicons/search-minus.svg'; import u1109 from '../../../img/icons/unicons/search.svg';
import u1110 from '../../../img/icons/unicons/search-plus.svg'; import u1110 from '../../../img/icons/unicons/search-minus.svg';
import u1111 from '../../../img/icons/unicons/share-alt.svg'; import u1111 from '../../../img/icons/unicons/search-plus.svg';
import u1112 from '../../../img/icons/unicons/shield.svg'; import u1112 from '../../../img/icons/unicons/share-alt.svg';
import u1113 from '../../../img/icons/unicons/signal.svg'; import u1113 from '../../../img/icons/unicons/shield.svg';
import u1114 from '../../../img/icons/unicons/signin.svg'; import u1114 from '../../../img/icons/unicons/signal.svg';
import u1115 from '../../../img/icons/unicons/signout.svg'; import u1115 from '../../../img/icons/unicons/signin.svg';
import u1116 from '../../../img/icons/unicons/sitemap.svg'; import u1116 from '../../../img/icons/unicons/signout.svg';
import u1117 from '../../../img/icons/unicons/slack.svg'; import u1117 from '../../../img/icons/unicons/sitemap.svg';
import u1118 from '../../../img/icons/unicons/sliders-v-alt.svg'; import u1118 from '../../../img/icons/unicons/slack.svg';
import u1119 from '../../../img/icons/unicons/sort-amount-down.svg'; import u1119 from '../../../img/icons/unicons/sliders-v-alt.svg';
import u1120 from '../../../img/icons/unicons/sort-amount-up.svg'; import u1120 from '../../../img/icons/unicons/sort-amount-down.svg';
import u1121 from '../../../img/icons/unicons/square-shape.svg'; import u1121 from '../../../img/icons/unicons/sort-amount-up.svg';
import u1122 from '../../../img/icons/unicons/star.svg'; import u1122 from '../../../img/icons/unicons/square-shape.svg';
import u1123 from '../../../img/icons/unicons/step-backward.svg'; import u1123 from '../../../img/icons/unicons/star.svg';
import u1124 from '../../../img/icons/unicons/sync.svg'; import u1124 from '../../../img/icons/unicons/step-backward.svg';
import u1125 from '../../../img/icons/unicons/stopwatch.svg'; import u1125 from '../../../img/icons/unicons/sync.svg';
import u1126 from '../../../img/icons/unicons/table.svg'; import u1126 from '../../../img/icons/unicons/stopwatch.svg';
import u1127 from '../../../img/icons/unicons/tag-alt.svg'; import u1127 from '../../../img/icons/unicons/table.svg';
import u1128 from '../../../img/icons/unicons/times.svg'; import u1128 from '../../../img/icons/unicons/tag-alt.svg';
import u1129 from '../../../img/icons/unicons/trash-alt.svg'; import u1129 from '../../../img/icons/unicons/times.svg';
import u1130 from '../../../img/icons/unicons/unlock.svg'; import u1130 from '../../../img/icons/unicons/trash-alt.svg';
import u1131 from '../../../img/icons/unicons/upload.svg'; import u1131 from '../../../img/icons/unicons/unlock.svg';
import u1132 from '../../../img/icons/unicons/user.svg'; import u1132 from '../../../img/icons/unicons/upload.svg';
import u1133 from '../../../img/icons/unicons/users-alt.svg'; import u1133 from '../../../img/icons/unicons/user.svg';
import u1134 from '../../../img/icons/unicons/wrap-text.svg'; import u1134 from '../../../img/icons/unicons/users-alt.svg';
import u1135 from '../../../img/icons/unicons/cloud-upload.svg'; import u1135 from '../../../img/icons/unicons/wrap-text.svg';
import u1136 from '../../../img/icons/unicons/credit-card.svg'; import u1136 from '../../../img/icons/unicons/cloud-upload.svg';
import u1137 from '../../../img/icons/unicons/file-copy-alt.svg'; import u1137 from '../../../img/icons/unicons/credit-card.svg';
import u1138 from '../../../img/icons/unicons/fire.svg'; import u1138 from '../../../img/icons/unicons/file-copy-alt.svg';
import u1139 from '../../../img/icons/unicons/hourglass.svg'; import u1139 from '../../../img/icons/unicons/fire.svg';
import u1140 from '../../../img/icons/unicons/layer-group.svg'; import u1140 from '../../../img/icons/unicons/hourglass.svg';
import u1141 from '../../../img/icons/unicons/layers-alt.svg'; import u1141 from '../../../img/icons/unicons/layer-group.svg';
import u1142 from '../../../img/icons/unicons/line-alt.svg'; import u1142 from '../../../img/icons/unicons/layers-alt.svg';
import u1143 from '../../../img/icons/unicons/list-ui-alt.svg'; import u1143 from '../../../img/icons/unicons/line-alt.svg';
import u1144 from '../../../img/icons/unicons/message.svg'; import u1144 from '../../../img/icons/unicons/list-ui-alt.svg';
import u1145 from '../../../img/icons/unicons/palette.svg'; import u1145 from '../../../img/icons/unicons/message.svg';
import u1146 from '../../../img/icons/unicons/percentage.svg'; import u1146 from '../../../img/icons/unicons/palette.svg';
import u1147 from '../../../img/icons/unicons/shield-exclamation.svg'; import u1147 from '../../../img/icons/unicons/percentage.svg';
import u1148 from '../../../img/icons/unicons/plus-square.svg'; import u1148 from '../../../img/icons/unicons/shield-exclamation.svg';
import u1149 from '../../../img/icons/unicons/x.svg'; import u1149 from '../../../img/icons/unicons/plus-square.svg';
import u1150 from '../../../img/icons/unicons/capture.svg'; import u1150 from '../../../img/icons/unicons/x.svg';
import u1151 from '../../../img/icons/custom/gf-grid.svg'; import u1151 from '../../../img/icons/unicons/capture.svg';
import u1152 from '../../../img/icons/custom/gf-landscape.svg'; import u1152 from '../../../img/icons/custom/gf-grid.svg';
import u1153 from '../../../img/icons/custom/gf-layout-simple.svg'; import u1153 from '../../../img/icons/custom/gf-landscape.svg';
import u1154 from '../../../img/icons/custom/gf-portrait.svg'; import u1154 from '../../../img/icons/custom/gf-layout-simple.svg';
import u1155 from '../../../img/icons/custom/gf-show-context.svg'; import u1155 from '../../../img/icons/custom/gf-portrait.svg';
import u1156 from '../../../img/icons/custom/gf-bar-alignment-after.svg'; import u1156 from '../../../img/icons/custom/gf-show-context.svg';
import u1157 from '../../../img/icons/custom/gf-bar-alignment-before.svg'; import u1157 from '../../../img/icons/custom/gf-bar-alignment-after.svg';
import u1158 from '../../../img/icons/custom/gf-bar-alignment-center.svg'; import u1158 from '../../../img/icons/custom/gf-bar-alignment-before.svg';
import u1159 from '../../../img/icons/custom/gf-interpolation-linear.svg'; import u1159 from '../../../img/icons/custom/gf-bar-alignment-center.svg';
import u1160 from '../../../img/icons/custom/gf-interpolation-smooth.svg'; import u1160 from '../../../img/icons/custom/gf-interpolation-linear.svg';
import u1161 from '../../../img/icons/custom/gf-interpolation-step-after.svg'; import u1161 from '../../../img/icons/custom/gf-interpolation-smooth.svg';
import u1162 from '../../../img/icons/custom/gf-interpolation-step-before.svg'; import u1162 from '../../../img/icons/custom/gf-interpolation-step-after.svg';
import u1163 from '../../../img/icons/custom/gf-logs.svg'; import u1163 from '../../../img/icons/custom/gf-interpolation-step-before.svg';
import u1164 from '../../../img/icons/custom/gf-movepane-left.svg'; import u1164 from '../../../img/icons/custom/gf-logs.svg';
import u1165 from '../../../img/icons/custom/gf-movepane-right.svg'; import u1165 from '../../../img/icons/custom/gf-movepane-left.svg';
import u1166 from '../../../img/icons/mono/favorite.svg'; import u1166 from '../../../img/icons/custom/gf-movepane-right.svg';
import u1167 from '../../../img/icons/mono/grafana.svg'; import u1167 from '../../../img/icons/mono/favorite.svg';
import u1168 from '../../../img/icons/mono/heart.svg'; import u1168 from '../../../img/icons/mono/grafana.svg';
import u1169 from '../../../img/icons/mono/heart-break.svg'; import u1169 from '../../../img/icons/mono/heart.svg';
import u1170 from '../../../img/icons/mono/panel-add.svg'; import u1170 from '../../../img/icons/mono/heart-break.svg';
import u1171 from '../../../img/icons/mono/library-panel.svg'; import u1171 from '../../../img/icons/mono/panel-add.svg';
import u1172 from '../../../img/icons/unicons/record-audio.svg'; import u1172 from '../../../img/icons/mono/library-panel.svg';
import u1173 from '../../../img/icons/solid/bookmark.svg'; import u1173 from '../../../img/icons/unicons/record-audio.svg';
import u1174 from '../../../img/icons/solid/bookmark.svg';
// do not edit this list directly // do not edit this list directly
// the list of icons live here: @grafana/ui/components/Icon/cached.json // 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(u1036, resolvePath('unicons/chart-line.svg'));
cacheItem(u1037, resolvePath('unicons/check.svg')); cacheItem(u1037, resolvePath('unicons/check.svg'));
cacheItem(u1038, resolvePath('unicons/check-circle.svg')); cacheItem(u1038, resolvePath('unicons/check-circle.svg'));
cacheItem(u1039, resolvePath('unicons/circle.svg')); cacheItem(u1039, resolvePath('unicons/times-circle.svg'));
cacheItem(u1040, resolvePath('unicons/clipboard-alt.svg')); cacheItem(u1040, resolvePath('unicons/circle.svg'));
cacheItem(u1041, resolvePath('unicons/clock-nine.svg')); cacheItem(u1041, resolvePath('unicons/clipboard-alt.svg'));
cacheItem(u1042, resolvePath('unicons/cloud.svg')); cacheItem(u1042, resolvePath('unicons/clock-nine.svg'));
cacheItem(u1043, resolvePath('unicons/cloud-download.svg')); cacheItem(u1043, resolvePath('unicons/cloud.svg'));
cacheItem(u1044, resolvePath('unicons/code-branch.svg')); cacheItem(u1044, resolvePath('unicons/cloud-download.svg'));
cacheItem(u1045, resolvePath('unicons/cog.svg')); cacheItem(u1045, resolvePath('unicons/code-branch.svg'));
cacheItem(u1046, resolvePath('unicons/columns.svg')); cacheItem(u1046, resolvePath('unicons/cog.svg'));
cacheItem(u1047, resolvePath('unicons/comment-alt.svg')); cacheItem(u1047, resolvePath('unicons/columns.svg'));
cacheItem(u1048, resolvePath('unicons/comment-alt-share.svg')); cacheItem(u1048, resolvePath('unicons/comment-alt.svg'));
cacheItem(u1049, resolvePath('unicons/comments-alt.svg')); cacheItem(u1049, resolvePath('unicons/comment-alt-share.svg'));
cacheItem(u1050, resolvePath('unicons/compass.svg')); cacheItem(u1050, resolvePath('unicons/comments-alt.svg'));
cacheItem(u1051, resolvePath('unicons/copy.svg')); cacheItem(u1051, resolvePath('unicons/compass.svg'));
cacheItem(u1052, resolvePath('unicons/corner-down-right-alt.svg')); cacheItem(u1052, resolvePath('unicons/copy.svg'));
cacheItem(u1053, resolvePath('unicons/cube.svg')); cacheItem(u1053, resolvePath('unicons/corner-down-right-alt.svg'));
cacheItem(u1054, resolvePath('unicons/dashboard.svg')); cacheItem(u1054, resolvePath('unicons/cube.svg'));
cacheItem(u1055, resolvePath('unicons/database.svg')); cacheItem(u1055, resolvePath('unicons/dashboard.svg'));
cacheItem(u1056, resolvePath('unicons/document-info.svg')); cacheItem(u1056, resolvePath('unicons/database.svg'));
cacheItem(u1057, resolvePath('unicons/download-alt.svg')); cacheItem(u1057, resolvePath('unicons/document-info.svg'));
cacheItem(u1058, resolvePath('unicons/draggabledots.svg')); cacheItem(u1058, resolvePath('unicons/download-alt.svg'));
cacheItem(u1059, resolvePath('unicons/edit.svg')); cacheItem(u1059, resolvePath('unicons/draggabledots.svg'));
cacheItem(u1060, resolvePath('unicons/ellipsis-v.svg')); cacheItem(u1060, resolvePath('unicons/edit.svg'));
cacheItem(u1061, resolvePath('unicons/ellipsis-h.svg')); cacheItem(u1061, resolvePath('unicons/ellipsis-v.svg'));
cacheItem(u1062, resolvePath('unicons/envelope.svg')); cacheItem(u1062, resolvePath('unicons/ellipsis-h.svg'));
cacheItem(u1063, resolvePath('unicons/exchange-alt.svg')); cacheItem(u1063, resolvePath('unicons/envelope.svg'));
cacheItem(u1064, resolvePath('unicons/exclamation-circle.svg')); cacheItem(u1064, resolvePath('unicons/exchange-alt.svg'));
cacheItem(u1065, resolvePath('unicons/exclamation-triangle.svg')); cacheItem(u1065, resolvePath('unicons/exclamation-circle.svg'));
cacheItem(u1066, resolvePath('unicons/external-link-alt.svg')); cacheItem(u1066, resolvePath('unicons/exclamation-triangle.svg'));
cacheItem(u1067, resolvePath('unicons/eye.svg')); cacheItem(u1067, resolvePath('unicons/external-link-alt.svg'));
cacheItem(u1068, resolvePath('unicons/eye-slash.svg')); cacheItem(u1068, resolvePath('unicons/eye.svg'));
cacheItem(u1069, resolvePath('unicons/file-alt.svg')); cacheItem(u1069, resolvePath('unicons/eye-slash.svg'));
cacheItem(u1070, resolvePath('unicons/file-blank.svg')); cacheItem(u1070, resolvePath('unicons/file-alt.svg'));
cacheItem(u1071, resolvePath('unicons/filter.svg')); cacheItem(u1071, resolvePath('unicons/file-blank.svg'));
cacheItem(u1072, resolvePath('unicons/folder.svg')); cacheItem(u1072, resolvePath('unicons/filter.svg'));
cacheItem(u1073, resolvePath('unicons/folder-open.svg')); cacheItem(u1073, resolvePath('unicons/folder.svg'));
cacheItem(u1074, resolvePath('unicons/folder-plus.svg')); cacheItem(u1074, resolvePath('unicons/folder-open.svg'));
cacheItem(u1075, resolvePath('unicons/folder-upload.svg')); cacheItem(u1075, resolvePath('unicons/folder-plus.svg'));
cacheItem(u1076, resolvePath('unicons/forward.svg')); cacheItem(u1076, resolvePath('unicons/folder-upload.svg'));
cacheItem(u1077, resolvePath('unicons/graph-bar.svg')); cacheItem(u1077, resolvePath('unicons/forward.svg'));
cacheItem(u1078, resolvePath('unicons/history.svg')); cacheItem(u1078, resolvePath('unicons/graph-bar.svg'));
cacheItem(u1079, resolvePath('unicons/history-alt.svg')); cacheItem(u1079, resolvePath('unicons/history.svg'));
cacheItem(u1080, resolvePath('unicons/home-alt.svg')); cacheItem(u1080, resolvePath('unicons/history-alt.svg'));
cacheItem(u1081, resolvePath('unicons/import.svg')); cacheItem(u1081, resolvePath('unicons/home-alt.svg'));
cacheItem(u1082, resolvePath('unicons/info.svg')); cacheItem(u1082, resolvePath('unicons/import.svg'));
cacheItem(u1083, resolvePath('unicons/info-circle.svg')); cacheItem(u1083, resolvePath('unicons/info.svg'));
cacheItem(u1084, resolvePath('unicons/k6.svg')); cacheItem(u1084, resolvePath('unicons/info-circle.svg'));
cacheItem(u1085, resolvePath('unicons/key-skeleton-alt.svg')); cacheItem(u1085, resolvePath('unicons/k6.svg'));
cacheItem(u1086, resolvePath('unicons/keyboard.svg')); cacheItem(u1086, resolvePath('unicons/key-skeleton-alt.svg'));
cacheItem(u1087, resolvePath('unicons/link.svg')); cacheItem(u1087, resolvePath('unicons/keyboard.svg'));
cacheItem(u1088, resolvePath('unicons/list-ul.svg')); cacheItem(u1088, resolvePath('unicons/link.svg'));
cacheItem(u1089, resolvePath('unicons/lock.svg')); cacheItem(u1089, resolvePath('unicons/list-ul.svg'));
cacheItem(u1090, resolvePath('unicons/minus.svg')); cacheItem(u1090, resolvePath('unicons/lock.svg'));
cacheItem(u1091, resolvePath('unicons/minus-circle.svg')); cacheItem(u1091, resolvePath('unicons/minus.svg'));
cacheItem(u1092, resolvePath('unicons/mobile-android.svg')); cacheItem(u1092, resolvePath('unicons/minus-circle.svg'));
cacheItem(u1093, resolvePath('unicons/monitor.svg')); cacheItem(u1093, resolvePath('unicons/mobile-android.svg'));
cacheItem(u1094, resolvePath('unicons/pause.svg')); cacheItem(u1094, resolvePath('unicons/monitor.svg'));
cacheItem(u1095, resolvePath('unicons/pen.svg')); cacheItem(u1095, resolvePath('unicons/pause.svg'));
cacheItem(u1096, resolvePath('unicons/play.svg')); cacheItem(u1096, resolvePath('unicons/pen.svg'));
cacheItem(u1097, resolvePath('unicons/plug.svg')); cacheItem(u1097, resolvePath('unicons/play.svg'));
cacheItem(u1098, resolvePath('unicons/plus.svg')); cacheItem(u1098, resolvePath('unicons/plug.svg'));
cacheItem(u1099, resolvePath('unicons/plus-circle.svg')); cacheItem(u1099, resolvePath('unicons/plus.svg'));
cacheItem(u1100, resolvePath('unicons/power.svg')); cacheItem(u1100, resolvePath('unicons/plus-circle.svg'));
cacheItem(u1101, resolvePath('unicons/presentation-play.svg')); cacheItem(u1101, resolvePath('unicons/power.svg'));
cacheItem(u1102, resolvePath('unicons/process.svg')); cacheItem(u1102, resolvePath('unicons/presentation-play.svg'));
cacheItem(u1103, resolvePath('unicons/question-circle.svg')); cacheItem(u1103, resolvePath('unicons/process.svg'));
cacheItem(u1104, resolvePath('unicons/repeat.svg')); cacheItem(u1104, resolvePath('unicons/question-circle.svg'));
cacheItem(u1105, resolvePath('unicons/rocket.svg')); cacheItem(u1105, resolvePath('unicons/repeat.svg'));
cacheItem(u1106, resolvePath('unicons/rss.svg')); cacheItem(u1106, resolvePath('unicons/rocket.svg'));
cacheItem(u1107, resolvePath('unicons/save.svg')); cacheItem(u1107, resolvePath('unicons/rss.svg'));
cacheItem(u1108, resolvePath('unicons/search.svg')); cacheItem(u1108, resolvePath('unicons/save.svg'));
cacheItem(u1109, resolvePath('unicons/search-minus.svg')); cacheItem(u1109, resolvePath('unicons/search.svg'));
cacheItem(u1110, resolvePath('unicons/search-plus.svg')); cacheItem(u1110, resolvePath('unicons/search-minus.svg'));
cacheItem(u1111, resolvePath('unicons/share-alt.svg')); cacheItem(u1111, resolvePath('unicons/search-plus.svg'));
cacheItem(u1112, resolvePath('unicons/shield.svg')); cacheItem(u1112, resolvePath('unicons/share-alt.svg'));
cacheItem(u1113, resolvePath('unicons/signal.svg')); cacheItem(u1113, resolvePath('unicons/shield.svg'));
cacheItem(u1114, resolvePath('unicons/signin.svg')); cacheItem(u1114, resolvePath('unicons/signal.svg'));
cacheItem(u1115, resolvePath('unicons/signout.svg')); cacheItem(u1115, resolvePath('unicons/signin.svg'));
cacheItem(u1116, resolvePath('unicons/sitemap.svg')); cacheItem(u1116, resolvePath('unicons/signout.svg'));
cacheItem(u1117, resolvePath('unicons/slack.svg')); cacheItem(u1117, resolvePath('unicons/sitemap.svg'));
cacheItem(u1118, resolvePath('unicons/sliders-v-alt.svg')); cacheItem(u1118, resolvePath('unicons/slack.svg'));
cacheItem(u1119, resolvePath('unicons/sort-amount-down.svg')); cacheItem(u1119, resolvePath('unicons/sliders-v-alt.svg'));
cacheItem(u1120, resolvePath('unicons/sort-amount-up.svg')); cacheItem(u1120, resolvePath('unicons/sort-amount-down.svg'));
cacheItem(u1121, resolvePath('unicons/square-shape.svg')); cacheItem(u1121, resolvePath('unicons/sort-amount-up.svg'));
cacheItem(u1122, resolvePath('unicons/star.svg')); cacheItem(u1122, resolvePath('unicons/square-shape.svg'));
cacheItem(u1123, resolvePath('unicons/step-backward.svg')); cacheItem(u1123, resolvePath('unicons/star.svg'));
cacheItem(u1124, resolvePath('unicons/sync.svg')); cacheItem(u1124, resolvePath('unicons/step-backward.svg'));
cacheItem(u1125, resolvePath('unicons/stopwatch.svg')); cacheItem(u1125, resolvePath('unicons/sync.svg'));
cacheItem(u1126, resolvePath('unicons/table.svg')); cacheItem(u1126, resolvePath('unicons/stopwatch.svg'));
cacheItem(u1127, resolvePath('unicons/tag-alt.svg')); cacheItem(u1127, resolvePath('unicons/table.svg'));
cacheItem(u1128, resolvePath('unicons/times.svg')); cacheItem(u1128, resolvePath('unicons/tag-alt.svg'));
cacheItem(u1129, resolvePath('unicons/trash-alt.svg')); cacheItem(u1129, resolvePath('unicons/times.svg'));
cacheItem(u1130, resolvePath('unicons/unlock.svg')); cacheItem(u1130, resolvePath('unicons/trash-alt.svg'));
cacheItem(u1131, resolvePath('unicons/upload.svg')); cacheItem(u1131, resolvePath('unicons/unlock.svg'));
cacheItem(u1132, resolvePath('unicons/user.svg')); cacheItem(u1132, resolvePath('unicons/upload.svg'));
cacheItem(u1133, resolvePath('unicons/users-alt.svg')); cacheItem(u1133, resolvePath('unicons/user.svg'));
cacheItem(u1134, resolvePath('unicons/wrap-text.svg')); cacheItem(u1134, resolvePath('unicons/users-alt.svg'));
cacheItem(u1135, resolvePath('unicons/cloud-upload.svg')); cacheItem(u1135, resolvePath('unicons/wrap-text.svg'));
cacheItem(u1136, resolvePath('unicons/credit-card.svg')); cacheItem(u1136, resolvePath('unicons/cloud-upload.svg'));
cacheItem(u1137, resolvePath('unicons/file-copy-alt.svg')); cacheItem(u1137, resolvePath('unicons/credit-card.svg'));
cacheItem(u1138, resolvePath('unicons/fire.svg')); cacheItem(u1138, resolvePath('unicons/file-copy-alt.svg'));
cacheItem(u1139, resolvePath('unicons/hourglass.svg')); cacheItem(u1139, resolvePath('unicons/fire.svg'));
cacheItem(u1140, resolvePath('unicons/layer-group.svg')); cacheItem(u1140, resolvePath('unicons/hourglass.svg'));
cacheItem(u1141, resolvePath('unicons/layers-alt.svg')); cacheItem(u1141, resolvePath('unicons/layer-group.svg'));
cacheItem(u1142, resolvePath('unicons/line-alt.svg')); cacheItem(u1142, resolvePath('unicons/layers-alt.svg'));
cacheItem(u1143, resolvePath('unicons/list-ui-alt.svg')); cacheItem(u1143, resolvePath('unicons/line-alt.svg'));
cacheItem(u1144, resolvePath('unicons/message.svg')); cacheItem(u1144, resolvePath('unicons/list-ui-alt.svg'));
cacheItem(u1145, resolvePath('unicons/palette.svg')); cacheItem(u1145, resolvePath('unicons/message.svg'));
cacheItem(u1146, resolvePath('unicons/percentage.svg')); cacheItem(u1146, resolvePath('unicons/palette.svg'));
cacheItem(u1147, resolvePath('unicons/shield-exclamation.svg')); cacheItem(u1147, resolvePath('unicons/percentage.svg'));
cacheItem(u1148, resolvePath('unicons/plus-square.svg')); cacheItem(u1148, resolvePath('unicons/shield-exclamation.svg'));
cacheItem(u1149, resolvePath('unicons/x.svg')); cacheItem(u1149, resolvePath('unicons/plus-square.svg'));
cacheItem(u1150, resolvePath('unicons/capture.svg')); cacheItem(u1150, resolvePath('unicons/x.svg'));
cacheItem(u1151, resolvePath('custom/gf-grid.svg')); cacheItem(u1151, resolvePath('unicons/capture.svg'));
cacheItem(u1152, resolvePath('custom/gf-landscape.svg')); cacheItem(u1152, resolvePath('custom/gf-grid.svg'));
cacheItem(u1153, resolvePath('custom/gf-layout-simple.svg')); cacheItem(u1153, resolvePath('custom/gf-landscape.svg'));
cacheItem(u1154, resolvePath('custom/gf-portrait.svg')); cacheItem(u1154, resolvePath('custom/gf-layout-simple.svg'));
cacheItem(u1155, resolvePath('custom/gf-show-context.svg')); cacheItem(u1155, resolvePath('custom/gf-portrait.svg'));
cacheItem(u1156, resolvePath('custom/gf-bar-alignment-after.svg')); cacheItem(u1156, resolvePath('custom/gf-show-context.svg'));
cacheItem(u1157, resolvePath('custom/gf-bar-alignment-before.svg')); cacheItem(u1157, resolvePath('custom/gf-bar-alignment-after.svg'));
cacheItem(u1158, resolvePath('custom/gf-bar-alignment-center.svg')); cacheItem(u1158, resolvePath('custom/gf-bar-alignment-before.svg'));
cacheItem(u1159, resolvePath('custom/gf-interpolation-linear.svg')); cacheItem(u1159, resolvePath('custom/gf-bar-alignment-center.svg'));
cacheItem(u1160, resolvePath('custom/gf-interpolation-smooth.svg')); cacheItem(u1160, resolvePath('custom/gf-interpolation-linear.svg'));
cacheItem(u1161, resolvePath('custom/gf-interpolation-step-after.svg')); cacheItem(u1161, resolvePath('custom/gf-interpolation-smooth.svg'));
cacheItem(u1162, resolvePath('custom/gf-interpolation-step-before.svg')); cacheItem(u1162, resolvePath('custom/gf-interpolation-step-after.svg'));
cacheItem(u1163, resolvePath('custom/gf-logs.svg')); cacheItem(u1163, resolvePath('custom/gf-interpolation-step-before.svg'));
cacheItem(u1164, resolvePath('custom/gf-movepane-left.svg')); cacheItem(u1164, resolvePath('custom/gf-logs.svg'));
cacheItem(u1165, resolvePath('custom/gf-movepane-right.svg')); cacheItem(u1165, resolvePath('custom/gf-movepane-left.svg'));
cacheItem(u1166, resolvePath('mono/favorite.svg')); cacheItem(u1166, resolvePath('custom/gf-movepane-right.svg'));
cacheItem(u1167, resolvePath('mono/grafana.svg')); cacheItem(u1167, resolvePath('mono/favorite.svg'));
cacheItem(u1168, resolvePath('mono/heart.svg')); cacheItem(u1168, resolvePath('mono/grafana.svg'));
cacheItem(u1169, resolvePath('mono/heart-break.svg')); cacheItem(u1169, resolvePath('mono/heart.svg'));
cacheItem(u1170, resolvePath('mono/panel-add.svg')); cacheItem(u1170, resolvePath('mono/heart-break.svg'));
cacheItem(u1171, resolvePath('mono/library-panel.svg')); cacheItem(u1171, resolvePath('mono/panel-add.svg'));
cacheItem(u1172, resolvePath('unicons/record-audio.svg')); cacheItem(u1172, resolvePath('mono/library-panel.svg'));
cacheItem(u1173, resolvePath('solid/bookmark.svg')); cacheItem(u1173, resolvePath('unicons/record-audio.svg'));
cacheItem(u1174, resolvePath('solid/bookmark.svg'));
// do not edit this list directly // do not edit this list directly
// the list of icons live here: @grafana/ui/components/Icon/cached.json // the list of icons live here: @grafana/ui/components/Icon/cached.json
} }

View File

@ -1,10 +1,8 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useParams } from 'react-router-dom-v5-compat'; import { useParams } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
import { NavModelItem } from '@grafana/data'; import { NavModelItem } from '@grafana/data';
import { withErrorBoundary } from '@grafana/ui'; import { withErrorBoundary } from '@grafana/ui';
import { useDispatch } from 'app/types';
import { RuleIdentifier } from 'app/types/unified-alerting'; import { RuleIdentifier } from 'app/types/unified-alerting';
import { AlertWarning } from './AlertWarning'; import { AlertWarning } from './AlertWarning';
@ -13,7 +11,6 @@ import { ExistingRuleEditor } from './ExistingRuleEditor';
import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm'; import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm';
import { useURLSearchParams } from './hooks/useURLSearchParams'; import { useURLSearchParams } from './hooks/useURLSearchParams';
import { fetchRulesSourceBuildInfoAction } from './state/actions';
import { useRulesAccess } from './utils/accessControlHooks'; import { useRulesAccess } from './utils/accessControlHooks';
import * as ruleId from './utils/rule-id'; import * as ruleId from './utils/rule-id';
@ -47,7 +44,6 @@ const getPageNav = (identifier?: RuleIdentifier, type?: RuleEditorPathParams['ty
}; };
const RuleEditor = () => { const RuleEditor = () => {
const dispatch = useDispatch();
const [searchParams] = useURLSearchParams(); const [searchParams] = useURLSearchParams();
const params = useParams<RuleEditorPathParams>(); const params = useParams<RuleEditorPathParams>();
const { type } = params; const { type } = params;
@ -57,22 +53,9 @@ const RuleEditor = () => {
const copyFromId = searchParams.get('copyFrom') ?? undefined; const copyFromId = searchParams.get('copyFrom') ?? undefined;
const copyFromIdentifier = ruleId.tryParse(copyFromId); 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 { canCreateGrafanaRules, canCreateCloudRules, canEditRules } = useRulesAccess();
const getContent = useCallback(() => { const getContent = useCallback(() => {
if (loading) {
return;
}
if (!identifier && !canCreateGrafanaRules && !canCreateCloudRules) { if (!identifier && !canCreateGrafanaRules && !canCreateCloudRules) {
return <AlertWarning title="Cannot create rules">Sorry! You are not allowed to create rules.</AlertWarning>; return <AlertWarning title="Cannot create rules">Sorry! You are not allowed to create rules.</AlertWarning>;
} }
@ -90,10 +73,10 @@ const RuleEditor = () => {
} }
// new alert rule // new alert rule
return <AlertRuleForm />; return <AlertRuleForm />;
}, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier, loading]); }, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier]);
return ( return (
<AlertingPageWrapper isLoading={loading} navId="alert-list" pageNav={getPageNav(identifier, type)}> <AlertingPageWrapper navId="alert-list" pageNav={getPageNav(identifier, type)}>
{getContent()} {getContent()}
</AlertingPageWrapper> </AlertingPageWrapper>
); );

View File

@ -2,20 +2,19 @@ import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { screen, waitForElementToBeRemoved } from 'test/test-utils'; import { screen, waitForElementToBeRemoved } from 'test/test-utils';
import { byText } from 'testing-library-selector'; import { byText } from 'testing-library-selector';
import { setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto'; import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto';
import { searchFolders } from '../../manage-dashboards/state/actions'; import { searchFolders } from '../../manage-dashboards/state/actions';
import { discoverFeatures } from './api/buildInfo'; import { discoverFeaturesByUid } from './api/buildInfo';
import { fetchRulerRulesGroup } from './api/ruler'; import { fetchRulerRulesGroup } from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { setupMswServer } from './mockApi'; import { setupMswServer } from './mockApi';
import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks'; import { grantUserPermissions, mockDataSource } from './mocks';
import * as config from './utils/config'; import { setupDataSources } from './testSetup/datasources';
import { DataSourceType } from './utils/datasource'; import { DataSourceType, GRAFANA_DATASOURCE_NAME, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
@ -49,10 +48,19 @@ jest.mock('./components/rule-editor/util', () => {
}); });
const dataSources = { const dataSources = {
grafana: mockDataSource(
{
type: 'datasource',
uid: GRAFANA_RULES_SOURCE_NAME,
name: GRAFANA_DATASOURCE_NAME,
},
{ alerting: true }
),
// can edit rules // can edit rules
loki: mockDataSource( loki: mockDataSource(
{ {
type: DataSourceType.Loki, type: DataSourceType.Loki,
uid: 'loki-with-ruler',
name: 'loki with ruler', name: 'loki with ruler',
}, },
{ alerting: true } { alerting: true }
@ -61,9 +69,8 @@ const dataSources = {
{ {
type: DataSourceType.Loki, type: DataSourceType.Loki,
name: 'loki disabled for alerting', name: 'loki disabled for alerting',
jsonData: { uid: 'loki-without-alerting',
manageAlerts: false, jsonData: { manageAlerts: false },
},
}, },
{ alerting: true } { alerting: true }
), ),
@ -72,6 +79,7 @@ const dataSources = {
{ {
type: DataSourceType.Prometheus, type: DataSourceType.Prometheus,
name: 'cortex with ruler', name: 'cortex with ruler',
uid: 'cortex-with-ruler',
isDefault: true, isDefault: true,
}, },
{ alerting: true } { alerting: true }
@ -81,6 +89,7 @@ const dataSources = {
{ {
type: DataSourceType.Loki, type: DataSourceType.Loki,
name: 'loki with local rule store', name: 'loki with local rule store',
uid: 'loki-with-local-rule-store',
}, },
{ alerting: true } { alerting: true }
), ),
@ -89,6 +98,7 @@ const dataSources = {
{ {
type: DataSourceType.Loki, type: DataSourceType.Loki,
name: 'cortex without ruler api', name: 'cortex without ruler api',
uid: 'cortex-without-ruler-api',
}, },
{ alerting: true } { alerting: true }
), ),
@ -97,27 +107,19 @@ const dataSources = {
{ {
type: 'splunk', type: 'splunk',
name: 'splunk', name: 'splunk',
uid: 'splunk',
}, },
{ alerting: true } { alerting: true }
), ),
}; };
jest.mock('@grafana/runtime', () => ({ setupDataSources(...Object.values(dataSources));
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: jest.fn(() => ({
getInstanceSettings: () => dataSources.prom,
get: () => dataSources.prom,
getList: () => Object.values(dataSources),
})),
}));
jest.spyOn(config, 'getAllDataSources');
const mocks = { const mocks = {
getAllDataSources: jest.mocked(config.getAllDataSources), // getAllDataSources: jest.mocked(config.getAllDataSources),
searchFolders: jest.mocked(searchFolders), searchFolders: jest.mocked(searchFolders),
api: { api: {
discoverFeatures: jest.mocked(discoverFeatures), discoverFeaturesByUid: jest.mocked(discoverFeaturesByUid),
fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), 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 () => { it('for cloud alerts, should only allow to select editable rules sources', async () => {
mocks.api.discoverFeatures.mockImplementation(async (dataSourceName) => { mocks.api.discoverFeaturesByUid.mockImplementation(async (dataSourceUid) => {
if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { if (dataSourceUid === dataSources.loki.uid || dataSourceUid === dataSources.prom.uid) {
return getDiscoverFeaturesMock(PromApplication.Cortex, { rulerApiEnabled: true }); 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); return getDiscoverFeaturesMock(PromApplication.Cortex);
} }
if (dataSourceName === 'cortex without ruler api') { if (dataSourceUid === dataSources.prom_no_ruler_api.uid) {
return getDiscoverFeaturesMock(PromApplication.Cortex); return getDiscoverFeaturesMock(PromApplication.Cortex);
} }
throw new Error(`${dataSourceName} not handled`); throw new Error(`${dataSourceUid} not handled`);
}); });
mocks.api.fetchRulerRulesGroup.mockImplementation(async ({ dataSourceName }) => { mocks.api.fetchRulerRulesGroup.mockImplementation(async ({ dataSourceName }) => {
@ -179,8 +181,6 @@ describe('RuleEditor cloud: checking editable data sources', () => {
return null; return null;
}); });
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.searchFolders.mockResolvedValue([]); mocks.searchFolders.mockResolvedValue([]);
// render rule editor, select mimir/loki managed alerts // render rule editor, select mimir/loki managed alerts

View File

@ -74,14 +74,12 @@ describe('RuleEditor grafana managed rules', () => {
]); ]);
const dataSources = { const dataSources = {
default: mockDataSource( default: mockDataSource({
{ uid: 'mimir',
type: 'prometheus', type: 'prometheus',
name: 'Prom', name: 'Mimir',
isDefault: true, isDefault: true,
}, }),
{ alerting: false }
),
}; };
setupDataSources(dataSources.default); setupDataSources(dataSources.default);
setFolderResponse(mockFolder(folder)); setFolderResponse(mockFolder(folder));

View File

@ -6,7 +6,7 @@ import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { byRole } from 'testing-library-selector'; import { byRole } from 'testing-library-selector';
import { contextSrv } from 'app/core/services/context_srv'; 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 { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
@ -16,6 +16,7 @@ import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor
import { grantUserPermissions, mockDataSource } from './mocks'; import { grantUserPermissions, mockDataSource } from './mocks';
import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi'; import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi';
import { setupDataSources } from './testSetup/datasources'; import { setupDataSources } from './testSetup/datasources';
import { buildInfoResponse } from './testSetup/featureDiscovery';
import * as config from './utils/config'; import * as config from './utils/config';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
@ -45,7 +46,7 @@ const mocks = {
searchFolders: jest.mocked(searchFolders), searchFolders: jest.mocked(searchFolders),
}; };
setupMswServer(); const server = setupMswServer();
describe('RuleEditor grafana managed rules', () => { describe('RuleEditor grafana managed rules', () => {
beforeEach(() => { beforeEach(() => {
@ -80,6 +81,8 @@ describe('RuleEditor grafana managed rules', () => {
}; };
setupDataSources(dataSources.default); setupDataSources(dataSources.default);
mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir);
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.searchFolders.mockResolvedValue([ mocks.searchFolders.mockResolvedValue([
{ {

View File

@ -20,7 +20,7 @@ import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerti
import * as analytics from './Analytics'; import * as analytics from './Analytics';
import RuleList from './RuleList'; import RuleList from './RuleList';
import { discoverFeatures } from './api/buildInfo'; import { discoverFeaturesByUid } from './api/buildInfo';
import { fetchRules } from './api/prometheus'; import { fetchRules } from './api/prometheus';
import * as apiRuler from './api/ruler'; import * as apiRuler from './api/ruler';
import { fetchRulerRules } from './api/ruler'; import { fetchRulerRules } from './api/ruler';
@ -68,7 +68,7 @@ const mocks = {
rulesInSameGroupHaveInvalidForMock: jest.mocked(actions.rulesInSameGroupHaveInvalidFor), rulesInSameGroupHaveInvalidForMock: jest.mocked(actions.rulesInSameGroupHaveInvalidFor),
api: { api: {
discoverFeatures: jest.mocked(discoverFeatures), discoverFeaturesByUid: jest.mocked(discoverFeaturesByUid),
fetchRules: jest.mocked(fetchRules), fetchRules: jest.mocked(fetchRules),
fetchRulerRules: jest.mocked(fetchRulerRules), fetchRulerRules: jest.mocked(fetchRulerRules),
rulerBuilderMock: jest.mocked(apiRuler.rulerUrlBuilder), rulerBuilderMock: jest.mocked(apiRuler.rulerUrlBuilder),
@ -185,7 +185,7 @@ describe('RuleList', () => {
setDataSourceSrv(new MockDataSourceSrv(dataSources)); setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.api.discoverFeatures.mockResolvedValue({ mocks.api.discoverFeaturesByUid.mockResolvedValue({
application: PromApplication.Prometheus, application: PromApplication.Prometheus,
features: { features: {
rulerApiEnabled: true, rulerApiEnabled: true,
@ -279,7 +279,7 @@ describe('RuleList', () => {
mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]);
setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom }));
mocks.api.discoverFeatures.mockResolvedValue({ mocks.api.discoverFeaturesByUid.mockResolvedValue({
application: PromApplication.Cortex, application: PromApplication.Cortex,
features: { features: {
rulerApiEnabled: true, rulerApiEnabled: true,
@ -430,7 +430,7 @@ describe('RuleList', () => {
mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]);
setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom }));
mocks.api.discoverFeatures.mockResolvedValue({ mocks.api.discoverFeaturesByUid.mockResolvedValue({
application: PromApplication.Cortex, application: PromApplication.Cortex,
features: { features: {
rulerApiEnabled: true, rulerApiEnabled: true,
@ -577,7 +577,7 @@ describe('RuleList', () => {
setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom }));
mocks.api.discoverFeatures.mockResolvedValue({ mocks.api.discoverFeaturesByUid.mockResolvedValue({
application: PromApplication.Cortex, application: PromApplication.Cortex,
features: { features: {
rulerApiEnabled: true, rulerApiEnabled: true,
@ -688,7 +688,7 @@ describe('RuleList', () => {
mocks.getAllDataSourcesMock.mockReturnValue(Object.values(testDatasources)); mocks.getAllDataSourcesMock.mockReturnValue(Object.values(testDatasources));
setDataSourceSrv(new MockDataSourceSrv(testDatasources)); setDataSourceSrv(new MockDataSourceSrv(testDatasources));
mocks.api.discoverFeatures.mockResolvedValue({ mocks.api.discoverFeaturesByUid.mockResolvedValue({
application: PromApplication.Cortex, application: PromApplication.Cortex,
features: { features: {
rulerApiEnabled: true, rulerApiEnabled: true,
@ -855,7 +855,7 @@ describe('RuleList', () => {
mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]);
setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom }));
mocks.api.discoverFeatures.mockResolvedValue({ mocks.api.discoverFeaturesByUid.mockResolvedValue({
application: PromApplication.Cortex, application: PromApplication.Cortex,
features: { features: {
rulerApiEnabled: true, rulerApiEnabled: true,
@ -880,7 +880,7 @@ describe('RuleList', () => {
mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]);
setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom }));
mocks.api.discoverFeatures.mockResolvedValue({ mocks.api.discoverFeaturesByUid.mockResolvedValue({
application: PromApplication.Cortex, application: PromApplication.Cortex,
features: { features: {
rulerApiEnabled: true, rulerApiEnabled: true,

View File

@ -25,9 +25,9 @@ import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rule
import { alertingApi, WithNotificationOptions } from './alertingApi'; import { alertingApi, WithNotificationOptions } from './alertingApi';
import { import {
FetchPromRulesFilter, FetchPromRulesFilter,
getRulesFilterSearchParams,
groupRulesByFileName, groupRulesByFileName,
paramsWithMatcherAndState, paramsWithMatcherAndState,
getRulesFilterSearchParams,
} from './prometheus'; } from './prometheus';
import { FetchRulerRulesFilter, rulerUrlBuilder } from './ruler'; import { FetchRulerRulesFilter, rulerUrlBuilder } from './ruler';
@ -173,9 +173,21 @@ export const alertRuleApi = alertingApi.injectEndpoints({
dashboardUid?: string; dashboardUid?: string;
panelId?: number; panelId?: number;
limitAlerts?: 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> = { const queryParams: Record<string, string | undefined> = {
rule_group: groupName, rule_group: groupName,
rule_name: ruleName, rule_name: ruleName,
@ -195,6 +207,14 @@ export const alertRuleApi = alertingApi.injectEndpoints({
set(queryParams, PrometheusAPIFilters.LimitAlerts, String(limitAlerts)); set(queryParams, PrometheusAPIFilters.LimitAlerts, String(limitAlerts));
} }
if (maxGroups) {
set(queryParams, 'max_groups', maxGroups);
}
if (excludeAlerts) {
set(queryParams, 'exclude_alerts', 'true');
}
return { return {
url: `api/prometheus/${getDatasourceAPIUid(ruleSourceName)}/api/v1/rules`, url: `api/prometheus/${getDatasourceAPIUid(ruleSourceName)}/api/v1/rules`,
params: queryParams, params: queryParams,

View File

@ -95,7 +95,7 @@ describe('discoverDataSourceFeatures', () => {
const response = await discoverDataSourceFeatures({ url: '/datasource/proxy', name: 'Loki', type: 'loki' }); 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(response.features.rulerApiEnabled).toBe(true);
expect(mocks.fetchTestRulerRulesGroup).toHaveBeenCalledTimes(1); expect(mocks.fetchTestRulerRulesGroup).toHaveBeenCalledTimes(1);

View File

@ -9,26 +9,24 @@ import {
} from 'app/types/unified-alerting-dto'; } from 'app/types/unified-alerting-dto';
import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; 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 { fetchRules } from './prometheus';
import { fetchTestRulerRulesGroup } from './ruler'; import { fetchTestRulerRulesGroup } from './ruler';
/** export async function discoverFeaturesByUid(dataSourceUid: string): Promise<PromApiFeatures> {
* Attempt to fetch buildinfo from our component if (dataSourceUid === GRAFANA_RULES_SOURCE_NAME) {
*/
export async function discoverFeatures(dataSourceName: string): Promise<PromApiFeatures> {
if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) {
return { return {
application: 'grafana',
features: { features: {
rulerApiEnabled: true, rulerApiEnabled: true,
}, },
}; } satisfies PromApiFeatures;
} }
const dsConfig = getDataSourceByName(dataSourceName); const dsConfig = getRulesDataSourceByUID(dataSourceUid);
if (!dsConfig) { 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; const { url, name, type } = dsConfig;
@ -78,7 +76,8 @@ export async function discoverDataSourceFeatures(dsSettings: {
const rulerSupported = await hasRulerSupport(name); const rulerSupported = await hasRulerSupport(name);
return { 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: { features: {
rulerApiEnabled: rulerSupported, rulerApiEnabled: rulerSupported,
}, },

View File

@ -1,17 +1,32 @@
import { RulerDataSourceConfig } from 'app/types/unified-alerting'; import { RulerDataSourceConfig } from 'app/types/unified-alerting';
import { AlertmanagerApiFeatures, PromApplication } from '../../../../types/unified-alerting-dto'; import {
import { withPerformanceLogging } from '../Analytics'; AlertmanagerApiFeatures,
import { getRulesDataSource, isGrafanaRulesSource } from '../utils/datasource'; PromApplication,
RulesSourceApplication,
} from '../../../../types/unified-alerting-dto';
import {
getDataSourceUID,
getRulesDataSourceByUID,
GRAFANA_RULES_SOURCE_NAME,
isGrafanaRulesSource,
} from '../utils/datasource';
import { alertingApi } from './alertingApi'; import { alertingApi } from './alertingApi';
import { discoverAlertmanagerFeatures, discoverFeatures } from './buildInfo'; import { discoverAlertmanagerFeatures, discoverFeaturesByUid } from './buildInfo';
export const GRAFANA_RULER_CONFIG: RulerDataSourceConfig = { export const GRAFANA_RULER_CONFIG: RulerDataSourceConfig = {
dataSourceName: 'grafana', dataSourceName: 'grafana',
apiVersion: 'legacy', apiVersion: 'legacy',
}; };
interface RulesSourceFeatures {
name: string;
uid: string;
application: RulesSourceApplication;
rulerConfig?: RulerDataSourceConfig;
}
export const featureDiscoveryApi = alertingApi.injectEndpoints({ export const featureDiscoveryApi = alertingApi.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
discoverAmFeatures: build.query<AlertmanagerApiFeatures, { amSourceName: string }>({ discoverAmFeatures: build.query<AlertmanagerApiFeatures, { amSourceName: string }>({
@ -25,36 +40,46 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({
}, },
}), }),
discoverDsFeatures: build.query<{ rulerConfig?: RulerDataSourceConfig }, { rulesSourceName: string }>({ discoverDsFeatures: build.query<RulesSourceFeatures, { rulesSourceName: string } | { uid: string }>({
queryFn: async ({ rulesSourceName }) => { queryFn: async (rulesSourceIdentifier) => {
if (isGrafanaRulesSource(rulesSourceName)) { const dataSourceUID = getDataSourceUID(rulesSourceIdentifier);
return { data: { rulerConfig: GRAFANA_RULER_CONFIG } }; if (!dataSourceUID) {
return { error: new Error(`Unable to find data source for ${rulesSourceIdentifier}`) };
} }
const dsSettings = getRulesDataSource(rulesSourceName); if (isGrafanaRulesSource(dataSourceUID)) {
if (!dsSettings) { return {
return { error: new Error(`Missing data source configuration for ${rulesSourceName}`) }; data: {
name: GRAFANA_RULES_SOURCE_NAME,
uid: GRAFANA_RULES_SOURCE_NAME,
application: 'grafana',
rulerConfig: GRAFANA_RULER_CONFIG,
} satisfies RulesSourceFeatures,
};
} }
const discoverFeaturesWithLogging = withPerformanceLogging( const dataSourceSettings = dataSourceUID ? getRulesDataSourceByUID(dataSourceUID) : undefined;
'unifiedalerting/featureDiscoveryApi/discoverDsFeatures', if (!dataSourceSettings) {
discoverFeatures, return { error: new Error(`Missing data source configuration for ${rulesSourceIdentifier}`) };
{ }
dataSourceName: rulesSourceName,
endpoint: 'unifiedalerting/featureDiscoveryApi/discoverDsFeatures',
}
);
const dsFeatures = await discoverFeaturesWithLogging(dsSettings.name); const features = await discoverFeaturesByUid(dataSourceSettings.uid);
const rulerConfig: RulerDataSourceConfig | undefined = dsFeatures.features.rulerApiEnabled const rulerConfig = features.features.rulerApiEnabled
? { ? ({
dataSourceName: dsSettings.name, dataSourceName: dataSourceSettings.name,
apiVersion: dsFeatures.application === PromApplication.Cortex ? 'legacy' : 'config', apiVersion: features.application === PromApplication.Cortex ? 'legacy' : 'config',
} } satisfies RulerDataSourceConfig)
: undefined; : undefined;
return { data: { rulerConfig } }; return {
data: {
name: dataSourceSettings.name,
uid: dataSourceSettings.uid,
application: features.application,
rulerConfig,
} satisfies RulesSourceFeatures,
};
}, },
}), }),
}), }),

View File

@ -1,18 +1,16 @@
import { Menu } from '@grafana/ui'; import { Menu } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification'; import { useAppNotification } from 'app/core/copy/appNotification';
import { import { isGrafanaRulerRule, isGrafanaRulerRulePaused } from 'app/features/alerting/unified/utils/rules';
isGrafanaRulerRule, import { RuleGroupIdentifier } from 'app/types/unified-alerting';
isGrafanaRulerRulePaused, import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
getRuleGroupLocationFromCombinedRule,
} from 'app/features/alerting/unified/utils/rules';
import { CombinedRule } from 'app/types/unified-alerting';
import { usePauseRuleInGroup } from '../hooks/ruleGroup/usePauseAlertRule'; import { usePauseRuleInGroup } from '../hooks/ruleGroup/usePauseAlertRule';
import { isLoading } from '../hooks/useAsync'; import { isLoading } from '../hooks/useAsync';
import { stringifyErrorLike } from '../utils/misc'; import { stringifyErrorLike } from '../utils/misc';
interface Props { interface Props {
rule: CombinedRule; rule: RulerRuleDTO;
groupIdentifier: RuleGroupIdentifier;
/** /**
* Method invoked after the request to change the paused state has completed * 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, * Menu item to display correct text for pausing/resuming an alert,
* and triggering API call to do so * and triggering API call to do so
*/ */
const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => { const MenuItemPauseRule = ({ rule, groupIdentifier, onPauseChange }: Props) => {
const notifyApp = useAppNotification(); const notifyApp = useAppNotification();
const [pauseRule, updateState] = usePauseRuleInGroup(); const [pauseRule, updateState] = usePauseRuleInGroup();
const isPaused = isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule); const isPaused = isGrafanaRulerRule(rule) && isGrafanaRulerRulePaused(rule);
const icon = isPaused ? 'play' : 'pause'; const icon = isPaused ? 'play' : 'pause';
const title = isPaused ? 'Resume evaluation' : 'Pause evaluation'; 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 * Triggers API call to update the current rule to the new `is_paused` state
*/ */
const setRulePause = async (newIsPaused: boolean) => { const setRulePause = async (newIsPaused: boolean) => {
if (!isGrafanaRulerRule(rule.rulerRule)) { if (!isGrafanaRulerRule(rule)) {
return; return;
} }
try { try {
const ruleGroupId = getRuleGroupLocationFromCombinedRule(rule); const ruleUID = rule.grafana_alert.uid;
const ruleUID = rule.rulerRule.grafana_alert.uid;
await pauseRule.execute(ruleGroupId, ruleUID, newIsPaused); await pauseRule.execute(groupIdentifier, ruleUID, newIsPaused);
} catch (error) { } catch (error) {
notifyApp.error(`Failed to ${newIsPaused ? 'pause' : 'resume'} the rule: ${stringifyErrorLike(error)}`); notifyApp.error(`Failed to ${newIsPaused ? 'pause' : 'resume'} the rule: ${stringifyErrorLike(error)}`);
return; return;

View File

@ -1,12 +1,9 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useAsync } from 'react-use';
import { DataSourceInstanceSettings } from '@grafana/data'; import { DataSourceInstanceSettings } from '@grafana/data';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { dispatch } from 'app/store/store';
import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler'; import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler';
import { fetchAllPromBuildInfoAction } from '../../state/actions';
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
@ -17,20 +14,18 @@ interface Props {
} }
export function CloudRulesSourcePicker({ value, disabled, ...props }: Props): JSX.Element { export function CloudRulesSourcePicker({ value, disabled, ...props }: Props): JSX.Element {
const rulesSourcesWithRuler = useRulesSourcesWithRuler(); const { rulesSourcesWithRuler: dataSourcesWithRuler, isLoading } = useRulesSourcesWithRuler();
const { loading = true } = useAsync(() => dispatch(fetchAllPromBuildInfoAction()), [dispatch]);
const dataSourceFilter = useCallback( const dataSourceFilter = useCallback(
(ds: DataSourceInstanceSettings): boolean => { (ds: DataSourceInstanceSettings): boolean => {
return !!rulesSourcesWithRuler.find(({ id }) => id === ds.id); return dataSourcesWithRuler.some(({ uid }) => uid === ds.uid);
}, },
[rulesSourcesWithRuler] [dataSourcesWithRuler]
); );
return ( return (
<DataSourcePicker <DataSourcePicker
disabled={loading || disabled} disabled={isLoading || disabled}
noDefault noDefault
alerting alerting
filter={dataSourceFilter} filter={dataSourceFilter}

View File

@ -8,13 +8,14 @@ import { byRole } from 'testing-library-selector';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import RuleEditor from 'app/features/alerting/unified/RuleEditor'; 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 { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks';
import { setAlertmanagerChoices } from 'app/features/alerting/unified/mocks/server/configure'; import { setAlertmanagerChoices } from 'app/features/alerting/unified/mocks/server/configure';
import { captureRequests, serializeRequests } from 'app/features/alerting/unified/mocks/server/events'; 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 { FOLDER_TITLE_HAPPY_PATH } from 'app/features/alerting/unified/mocks/server/handlers/search';
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { testWithFeatureToggles } from 'app/features/alerting/unified/test/test-utils'; 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 { DataSourceType, GRAFANA_DATASOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
@ -28,7 +29,7 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
jest.setTimeout(60 * 1000); jest.setTimeout(60 * 1000);
setupMswServer(); const server = setupMswServer();
const dataSources = { const dataSources = {
default: mockDataSource( default: mockDataSource(
@ -44,6 +45,7 @@ const dataSources = {
type: DataSourceType.Alertmanager, type: DataSourceType.Alertmanager,
}), }),
}; };
setupDataSources(dataSources.default, dataSources.am); setupDataSources(dataSources.default, dataSources.am);
const selectFolderAndGroup = async () => { const selectFolderAndGroup = async () => {
@ -76,6 +78,8 @@ describe('Can create a new grafana managed alert using simplified routing', () =
AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite, 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 () => { it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => {

View File

@ -29,12 +29,10 @@ import {
expressionTypes, expressionTypes,
ReducerMode, ReducerMode,
} from 'app/features/expressions/types'; } from 'app/features/expressions/types';
import { useDispatch } from 'app/types';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler'; import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler';
import { useURLSearchParams } from '../../../hooks/useURLSearchParams'; import { useURLSearchParams } from '../../../hooks/useURLSearchParams';
import { fetchAllPromBuildInfoAction } from '../../../state/actions';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource'; import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
import { isPromOrLokiQuery, PromOrLokiQuery } from '../../../utils/rule-form'; import { isPromOrLokiQuery, PromOrLokiQuery } from '../../../utils/rule-form';
@ -184,12 +182,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
} }
}, [isAdvancedMode, expressionQueries, isGrafanaAlertingType, setSimpleCondition]); }, [isAdvancedMode, expressionQueries, isGrafanaAlertingType, setSimpleCondition]);
const dispatchReduxAction = useDispatch(); const { rulesSourcesWithRuler } = useRulesSourcesWithRuler();
useEffect(() => {
dispatchReduxAction(fetchAllPromBuildInfoAction());
}, [dispatchReduxAction]);
const rulesSourcesWithRuler = useRulesSourcesWithRuler();
const runQueriesPreview = useCallback( const runQueriesPreview = useCallback(
(condition?: string) => { (condition?: string) => {
@ -322,7 +315,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
[runQueriesPreview, setValue, updateExpressionAndDatasource] [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(() => { useEffect(() => {
clearPreviewData(); clearPreviewData();

View File

@ -1,13 +1,10 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data/src'; import { GrafanaTheme2 } from '@grafana/data/src';
import { useStyles2, Stack } from '@grafana/ui'; import { useStyles2, Stack } from '@grafana/ui';
import { dispatch } from 'app/store/store';
import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler'; import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler';
import { fetchAllPromBuildInfoAction } from '../../../state/actions';
import { RuleFormType } from '../../../types/rule-form'; import { RuleFormType } from '../../../types/rule-form';
import { GrafanaManagedRuleType } from './GrafanaManagedAlert'; import { GrafanaManagedRuleType } from './GrafanaManagedAlert';
@ -19,13 +16,9 @@ interface RuleTypePickerProps {
} }
const RuleTypePicker = ({ selected, onChange, enabledTypes }: RuleTypePickerProps) => { const RuleTypePicker = ({ selected, onChange, enabledTypes }: RuleTypePickerProps) => {
const rulesSourcesWithRuler = useRulesSourcesWithRuler(); const { rulesSourcesWithRuler } = useRulesSourcesWithRuler();
const hasLotexDatasources = !isEmpty(rulesSourcesWithRuler); const hasLotexDatasources = !isEmpty(rulesSourcesWithRuler);
useEffect(() => {
dispatch(fetchAllPromBuildInfoAction());
}, []);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const handleChange = (type: RuleFormType) => { const handleChange = (type: RuleFormType) => {

View File

@ -4,22 +4,23 @@ import appEvents from 'app/core/app_events';
import MenuItemPauseRule from 'app/features/alerting/unified/components/MenuItemPauseRule'; import MenuItemPauseRule from 'app/features/alerting/unified/components/MenuItemPauseRule';
import MoreButton from 'app/features/alerting/unified/components/MoreButton'; import MoreButton from 'app/features/alerting/unified/components/MoreButton';
import { useRulePluginLinkExtension } from 'app/features/alerting/unified/plugins/useRulePluginLinkExtensions'; import { useRulePluginLinkExtension } from 'app/features/alerting/unified/plugins/useRulePluginLinkExtensions';
import { isAlertingRule } from 'app/features/alerting/unified/utils/rules'; import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting';
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; import { PromAlertingRuleState, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { PromAlertingRuleState } 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 { createShareLink, isLocalDevEnv, isOpenSourceEdition } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id'; import * as ruleId from '../../utils/rule-id';
import { isAlertingRule } from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url'; import { createRelativeUrl } from '../../utils/url';
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton'; import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
interface Props { interface Props {
rule: CombinedRule; promRule: Rule;
rulerRule?: RulerRuleDTO;
identifier: RuleIdentifier; identifier: RuleIdentifier;
showCopyLinkButton?: boolean; groupIdentifier: RuleGroupIdentifier;
handleSilence: () => void; handleSilence: () => void;
handleDelete: (rule: CombinedRule) => void; handleDelete: (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifier) => void;
handleDuplicateRule: (identifier: RuleIdentifier) => void; handleDuplicateRule: (identifier: RuleIdentifier) => void;
onPauseChange?: () => void; onPauseChange?: () => void;
buttonSize?: ComponentSize; buttonSize?: ComponentSize;
@ -30,9 +31,10 @@ interface Props {
* dropdown menu * dropdown menu
*/ */
const AlertRuleMenu = ({ const AlertRuleMenu = ({
rule, promRule,
rulerRule,
identifier, identifier,
showCopyLinkButton, groupIdentifier,
handleSilence, handleSilence,
handleDelete, handleDelete,
handleDuplicateRule, handleDuplicateRule,
@ -40,22 +42,30 @@ const AlertRuleMenu = ({
buttonSize, buttonSize,
}: Props) => { }: Props) => {
// check all abilities and permissions // 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 canPause = pauseSupported && pauseAllowed;
const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete); const [deleteSupported, deleteAllowed] = useRulerRuleAbility(rulerRule, groupIdentifier, AlertRuleAction.Delete);
const canDelete = deleteSupported && deleteAllowed; const canDelete = deleteSupported && deleteAllowed;
const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate); const [duplicateSupported, duplicateAllowed] = useRulerRuleAbility(
rulerRule,
groupIdentifier,
AlertRuleAction.Duplicate
);
const canDuplicate = duplicateSupported && duplicateAllowed; const canDuplicate = duplicateSupported && duplicateAllowed;
const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence); const [silenceSupported, silenceAllowed] = useRulerRuleAbility(rulerRule, groupIdentifier, AlertRuleAction.Silence);
const canSilence = silenceSupported && silenceAllowed; const canSilence = silenceSupported && silenceAllowed;
const [exportSupported, exportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport); const [exportSupported, exportAllowed] = useRulerRuleAbility(
rulerRule,
groupIdentifier,
AlertRuleAction.ModifyExport
);
const canExport = exportSupported && exportAllowed; const canExport = exportSupported && exportAllowed;
const ruleExtensionLinks = useRulePluginLinkExtension(rule); const ruleExtensionLinks = useRulePluginLinkExtension(promRule, groupIdentifier);
const extensionsAvailable = ruleExtensionLinks.length > 0; 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. * 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 * We should show it in development mode
*/ */
// @TODO Migrate "declare incident button" to plugin links extensions
const shouldShowDeclareIncidentButton = const shouldShowDeclareIncidentButton =
(!isOpenSourceEdition() || isLocalDevEnv()) && (!isOpenSourceEdition() || isLocalDevEnv()) &&
isAlertingRule(rule.promRule) && isAlertingRule(promRule) &&
rule.promRule.state === PromAlertingRuleState.Firing; promRule.state === PromAlertingRuleState.Firing;
const shareUrl = createShareLink(rule.namespace.rulesSource, rule);
const shareUrl = createShareLink(identifier);
const showDivider = const showDivider =
[canPause, canSilence, shouldShowDeclareIncidentButton, canDuplicate].some(Boolean) && [canPause, canSilence, shouldShowDeclareIncidentButton, canDuplicate].some(Boolean) && [canExport].some(Boolean);
[showCopyLinkButton, canExport].some(Boolean);
const menuItems = ( 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} />} {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)} />} {canDuplicate && <Menu.Item label="Duplicate" icon="copy" onClick={() => handleDuplicateRule(identifier)} />}
{showDivider && <Menu.Divider />} {showDivider && <Menu.Divider />}
{shareUrl && <Menu.Item label="Copy link" icon="share-alt" onClick={() => copyToClipboard(shareUrl)} />} {shareUrl && <Menu.Item label="Copy link" icon="share-alt" onClick={() => copyToClipboard(shareUrl)} />}
@ -96,10 +110,15 @@ const AlertRuleMenu = ({
))} ))}
</> </>
)} )}
{canDelete && ( {canDelete && rulerRule && (
<> <>
<Menu.Divider /> <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)}
/>
</> </>
)} )}
</> </>

View File

@ -3,21 +3,23 @@ import { useState, useCallback, useMemo } from 'react';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { ConfirmModal } from '@grafana/ui'; import { ConfirmModal } from '@grafana/ui';
import { dispatch } from 'app/store/store'; 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 { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup'; import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup';
import { usePrometheusConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck'; import { usePrometheusConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck';
import { fetchPromAndRulerRulesAction, fetchRulerRulesAction } from '../../state/actions'; import { fetchPromAndRulerRulesAction, fetchRulerRulesAction } from '../../state/actions';
import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id'; 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(); const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
export const useDeleteModal = (redirectToListView = false): DeleteModalHook => { export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule | undefined>(); const [ruleToDelete, setRuleToDelete] = useState<DeleteRuleInfo>();
const [deleteRuleFromGroup] = useDeleteRuleFromGroup(); const [deleteRuleFromGroup] = useDeleteRuleFromGroup();
const { waitForRemoval } = usePrometheusConsistencyCheck(); const { waitForRemoval } = usePrometheusConsistencyCheck();
@ -25,40 +27,37 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
setRuleToDelete(undefined); setRuleToDelete(undefined);
}, []); }, []);
const showModal = useCallback((rule: CombinedRule) => { const showModal = useCallback((rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifier) => {
setRuleToDelete(rule); setRuleToDelete({ rule, groupIdentifier });
}, []); }, []);
const deleteRule = useCallback( const deleteRule = useCallback(async () => {
async (rule?: CombinedRule) => { if (!ruleToDelete) {
if (!rule?.rulerRule) { return;
return; }
}
const ruleGroupIdentifier = getRuleGroupLocationFromCombinedRule(rule); const { rule, groupIdentifier } = ruleToDelete;
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, rule.rulerRule);
await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier); const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(groupIdentifier, rule);
await deleteRuleFromGroup.execute(groupIdentifier, ruleIdentifier);
// refetch rules for this rules source // refetch rules for this rules source
// @TODO remove this when we moved everything to RTKQ then the endpoint will simply invalidate the tags // @TODO remove this when we moved everything to RTKQ then the endpoint will simply invalidate the tags
dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: ruleGroupIdentifier.dataSourceName })); dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: groupIdentifier.dataSourceName }));
if (prometheusRulesPrimary && isCloudRuleIdentifier(ruleIdentifier)) { if (prometheusRulesPrimary && isCloudRuleIdentifier(ruleIdentifier)) {
await waitForRemoval(ruleIdentifier); await waitForRemoval(ruleIdentifier);
} else { } else {
// Without this the delete popup will close and the user will still see the deleted rule // Without this the delete popup will close and the user will still see the deleted rule
await dispatch(fetchRulerRulesAction({ rulesSourceName: ruleGroupIdentifier.dataSourceName })); await dispatch(fetchRulerRulesAction({ rulesSourceName: groupIdentifier.dataSourceName }));
} }
dismissModal(); dismissModal();
if (redirectToListView) { if (redirectToListView) {
locationService.replace('/alerting/list'); locationService.replace('/alerting/list');
} }
}, }, [deleteRuleFromGroup, dismissModal, ruleToDelete, redirectToListView, waitForRemoval]);
[deleteRuleFromGroup, dismissModal, redirectToListView, waitForRemoval]
);
const modal = useMemo( 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?" 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" confirmText="Yes, delete"
icon="exclamation-triangle" icon="exclamation-triangle"
onConfirm={() => deleteRule(ruleToDelete)} onConfirm={deleteRule}
onDismiss={dismissModal} onDismiss={dismissModal}
/> />
), ),
[deleteRule, dismissModal, ruleToDelete] [ruleToDelete, deleteRule, dismissModal]
); );
return [modal, showModal, dismissModal]; return [modal, showModal, dismissModal];

View File

@ -65,18 +65,17 @@ const RuleViewer = () => {
// we want to be able to show a modal if the rule has been provisioned explain the limitations // we want to be able to show a modal if the rule has been provisioned explain the limitations
// of duplicating provisioned alert rules // of duplicating provisioned alert rules
const [duplicateRuleIdentifier, setDuplicateRuleIdentifier] = useState<RuleIdentifier>(); const [duplicateRuleIdentifier, setDuplicateRuleIdentifier] = useState<RuleIdentifier>();
const { annotations, promRule, rulerRule } = rule;
const { annotations, promRule } = rule; const hasError = isErrorHealth(promRule?.health);
const hasError = isErrorHealth(rule.promRule?.health);
const isAlertType = isAlertingRule(promRule); const isAlertType = isAlertingRule(promRule);
const isFederatedRule = isFederatedRuleGroup(rule.group); const isFederatedRule = isFederatedRuleGroup(rule.group);
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); const isProvisioned = isGrafanaRulerRule(rulerRule) && Boolean(rulerRule.grafana_alert.provenance);
const isPaused = isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule); const isPaused = isGrafanaRulerRule(rulerRule) && isGrafanaRulerRulePaused(rulerRule);
const showError = hasError && !isPaused; const showError = hasError && !isPaused;
const ruleOrigin = getRulePluginOrigin(rule); const ruleOrigin = rulerRule ? getRulePluginOrigin(rulerRule) : getRulePluginOrigin(promRule);
const summary = annotations[Annotation.summary]; const summary = annotations[Annotation.summary];
@ -90,12 +89,12 @@ const RuleViewer = () => {
name={title} name={title}
paused={isPaused} paused={isPaused}
state={isAlertType ? promRule.state : undefined} state={isAlertType ? promRule.state : undefined}
health={rule.promRule?.health} health={promRule?.health}
ruleType={rule.promRule?.type} ruleType={promRule?.type}
ruleOrigin={ruleOrigin} ruleOrigin={ruleOrigin}
/> />
)} )}
actions={<RuleActionsButtons rule={rule} showCopyLinkButton rulesSource={rule.namespace.rulesSource} />} actions={<RuleActionsButtons rule={rule} rulesSource={rule.namespace.rulesSource} />}
info={createMetadata(rule)} info={createMetadata(rule)}
subTitle={ subTitle={
<Stack direction="column"> <Stack direction="column">
@ -327,7 +326,7 @@ function isValidTab(tab: UrlQueryValue): tab is ActiveTab {
function usePageNav(rule: CombinedRule) { function usePageNav(rule: CombinedRule) {
const [activeTab, setActiveTab] = useActiveTab(); const [activeTab, setActiveTab] = useActiveTab();
const { annotations, promRule } = rule; const { annotations, promRule, rulerRule } = rule;
const summary = annotations[Annotation.summary]; const summary = annotations[Annotation.summary];
const isAlertType = isAlertingRule(promRule); const isAlertType = isAlertingRule(promRule);
@ -336,8 +335,8 @@ function usePageNav(rule: CombinedRule) {
const namespaceName = decodeGrafanaNamespace(rule.namespace).name; const namespaceName = decodeGrafanaNamespace(rule.namespace).name;
const groupName = rule.group.name; const groupName = rule.group.name;
const isGrafanaAlertRule = isGrafanaRulerRule(rule.rulerRule) && isAlertType; const isGrafanaAlertRule = isGrafanaRulerRule(rulerRule) && isAlertType;
const isRecordingRuleType = isRecordingRule(rule.promRule); const isRecordingRuleType = isRecordingRule(promRule);
const pageNav: NavModelItem = { const pageNav: NavModelItem = {
...defaultPageNav, ...defaultPageNav,

View File

@ -27,17 +27,13 @@ interface Props {
export const CloudRules = ({ namespaces, expandAll }: Props) => { export const CloudRules = ({ namespaces, expandAll }: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const dsConfigs = useUnifiedAlertingSelector((state) => state.dataSources);
const promRules = useUnifiedAlertingSelector((state) => state.promRules); const promRules = useUnifiedAlertingSelector((state) => state.promRules);
const rulesDataSources = useMemo(getRulesDataSources, []); const rulesDataSources = useMemo(getRulesDataSources, []);
const groupsWithNamespaces = useCombinedGroupNamespace(namespaces); const groupsWithNamespaces = useCombinedGroupNamespace(namespaces);
const dataSourcesLoading = useMemo( const dataSourcesLoading = useMemo(
() => () => rulesDataSources.filter((ds) => isAsyncRequestStatePending(promRules[ds.name])),
rulesDataSources.filter( [promRules, rulesDataSources]
(ds) => isAsyncRequestStatePending(promRules[ds.name]) || isAsyncRequestStatePending(dsConfigs[ds.name])
),
[promRules, dsConfigs, rulesDataSources]
); );
const hasSomeResults = rulesDataSources.some((ds) => Boolean(promRules[ds.name]?.result?.length)); const hasSomeResults = rulesDataSources.some((ds) => Boolean(promRules[ds.name]?.result?.length));

View File

@ -15,15 +15,20 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Badge, Button, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui'; import { Badge, Button, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization'; import { Trans } from 'app/core/internationalization';
import { dispatch, getState } from 'app/store/store'; import { dispatch } from 'app/store/store';
import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting'; import {
CombinedRuleGroup,
CombinedRuleNamespace,
RuleGroupIdentifier,
RulerDataSourceConfig,
} from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi'; import { alertRuleApi } from '../../api/alertRuleApi';
import { useReorderRuleForRuleGroup } from '../../hooks/ruleGroup/useUpdateRuleGroup'; import { useReorderRuleForRuleGroup } from '../../hooks/ruleGroup/useUpdateRuleGroup';
import { isLoading } from '../../hooks/useAsync'; import { isLoading } from '../../hooks/useAsync';
import { swapItems, SwapOperation } from '../../reducers/ruler/ruleGroups'; import { swapItems, SwapOperation } from '../../reducers/ruler/ruleGroups';
import { fetchRulerRulesAction, getDataSourceRulerConfig } from '../../state/actions'; import { fetchRulerRulesAction } from '../../state/actions';
import { isCloudRulesSource } from '../../utils/datasource'; import { isCloudRulesSource } from '../../utils/datasource';
import { hashRulerRule } from '../../utils/rule-id'; import { hashRulerRule } from '../../utils/rule-id';
import { import {
@ -38,6 +43,7 @@ interface ModalProps {
group: CombinedRuleGroup; group: CombinedRuleGroup;
onClose: () => void; onClose: () => void;
folderUid?: string; folderUid?: string;
rulerConfig: RulerDataSourceConfig;
} }
type RulerRuleWithUID = { uid: string } & RulerRuleDTO; 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 // The list of rules might have been filtered before we get to this reordering modal
// We need to grab the full (unfiltered) list // 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( const { currentData: ruleGroup, isLoading: loadingRules } = alertRuleApi.endpoints.getRuleGroupForNamespace.useQuery(
{ {
rulerConfig, rulerConfig: props.rulerConfig,
namespace: folderUid ?? namespace.name, namespace: folderUid ?? namespace.name,
group: group.name, group: group.name,
}, },
@ -97,8 +101,10 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
); );
const updateRulesOrder = useCallback(async () => { const updateRulesOrder = useCallback(async () => {
const dataSourceName = rulesSourceToDataSourceName(namespace.rulesSource);
const ruleGroupIdentifier: RuleGroupIdentifier = { const ruleGroupIdentifier: RuleGroupIdentifier = {
dataSourceName: rulesSourceToDataSourceName(namespace.rulesSource), dataSourceName,
groupName: group.name, groupName: group.name,
namespaceName: folderUid ?? namespace.name, namespaceName: folderUid ?? namespace.name,
}; };
@ -107,16 +113,7 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
// TODO: Remove once RTKQ is more prevalently used // TODO: Remove once RTKQ is more prevalently used
await dispatch(fetchRulerRulesAction({ rulesSourceName: dataSourceName })); await dispatch(fetchRulerRulesAction({ rulesSourceName: dataSourceName }));
onClose(); onClose();
}, [ }, [namespace.rulesSource, namespace.name, group.name, folderUid, reorderRulesInGroup, operations, onClose]);
namespace.rulesSource,
namespace.name,
group.name,
folderUid,
reorderRulesInGroup,
operations,
dataSourceName,
onClose,
]);
// assign unique but stable identifiers to each (alerting / recording) rule // assign unique but stable identifiers to each (alerting / recording) rule
const rulesWithUID: RulerRuleWithUID[] = rulesList.map((rulerRule) => ({ const rulesWithUID: RulerRuleWithUID[] = rulesList.map((rulerRule) => ({

View File

@ -1,11 +1,10 @@
import { produce } from 'immer';
import { render, screen, userEvent } from 'test/test-utils'; import { render, screen, userEvent } from 'test/test-utils';
import { byLabelText, byRole } from 'testing-library-selector'; import { byLabelText, byRole } from 'testing-library-selector';
import { config, setPluginLinksHook } from '@grafana/runtime'; import { config, setPluginLinksHook } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { RuleActionsButtons } from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; 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 { import {
getCloudRule, getCloudRule,
getGrafanaRule, getGrafanaRule,
@ -14,11 +13,13 @@ import {
mockGrafanaRulerRule, mockGrafanaRulerRule,
mockPromAlertingRule, mockPromAlertingRule,
} from 'app/features/alerting/unified/mocks'; } from 'app/features/alerting/unified/mocks';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; 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'); jest.mock('app/core/services/context_srv');
const mockContextSrv = jest.mocked(contextSrv); const mockContextSrv = jest.mocked(contextSrv);
@ -35,6 +36,8 @@ const grantAllPermissions = () => {
AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleUpdate,
AccessControlAction.AlertingRuleDelete, AccessControlAction.AlertingRuleDelete,
AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingInstanceCreate,
AccessControlAction.AlertingRuleExternalRead,
AccessControlAction.AlertingRuleExternalWrite,
]); ]);
mockContextSrv.hasPermissionInMetadata.mockImplementation(() => true); mockContextSrv.hasPermissionInMetadata.mockImplementation(() => true);
mockContextSrv.hasPermission.mockImplementation(() => true); mockContextSrv.hasPermission.mockImplementation(() => true);
@ -58,6 +61,9 @@ setPluginLinksHook(() => ({
isLoading: false, isLoading: false,
})); }));
const mimirDs = mockDataSource({ uid: 'mimir', name: 'Mimir' });
setupDataSources(mimirDs);
const clickCopyLink = async () => { const clickCopyLink = async () => {
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(await ui.moreButton.find()); await user.click(await ui.moreButton.find());
@ -70,7 +76,7 @@ describe('RuleActionsButtons', () => {
grantAllPermissions(); grantAllPermissions();
const mockRule = getGrafanaRule(); const mockRule = getGrafanaRule();
render(<RuleActionsButtons rule={mockRule} rulesSource="grafana" showCopyLinkButton />); render(<RuleActionsButtons rule={mockRule} rulesSource="grafana" />);
await user.click(await ui.moreButton.find()); await user.click(await ui.moreButton.find());
@ -93,26 +99,10 @@ describe('RuleActionsButtons', () => {
it('renders correct options for Cloud rule', async () => { it('renders correct options for Cloud rule', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
grantAllPermissions(); grantAllPermissions();
const mockRule = getCloudRule(); const mockRule = getCloudRule(undefined, { rulesSource: mimirDs });
const dataSource = mockDataSource({ id: 1 }); mockFeatureDiscoveryApi(server).discoverDsFeatures(mimirDs, buildInfoResponse.mimir);
const defaultState = configureStore().getState(); render(<RuleActionsButtons rule={mockRule} rulesSource={mimirDs} />);
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',
},
},
};
}),
});
await user.click(await ui.moreButton.find()); await user.click(await ui.moreButton.find());
@ -162,14 +152,16 @@ describe('RuleActionsButtons', () => {
}); });
it('copies correct URL for cloud rule', async () => { 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(); await clickCopyLink();
expect(await navigator.clipboard.readText()).toBe( 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'
); );
}); });
}); });

View File

@ -16,7 +16,7 @@ import { fetchPromAndRulerRulesAction } from '../../state/actions';
import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource';
import { createViewLink } from '../../utils/misc'; import { createViewLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id'; 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 { createRelativeUrl } from '../../utils/url';
import { RedirectToCloneRule } from './CloneRule'; import { RedirectToCloneRule } from './CloneRule';
@ -32,13 +32,12 @@ interface Props {
*/ */
compact?: boolean; compact?: boolean;
showViewButton?: boolean; showViewButton?: boolean;
showCopyLinkButton?: boolean;
} }
/** /**
* **Action** buttons to show for an alert rule - e.g. "View", "Edit", "More..." * **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 dispatch = useDispatch();
const redirectToListView = compact ? false : true; const redirectToListView = compact ? false : true;
@ -65,6 +64,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton
const sourceName = getRulesSourceName(rulesSource); const sourceName = getRulesSourceName(rulesSource);
const identifier = ruleId.fromCombinedRule(sourceName, rule); const identifier = ruleId.fromCombinedRule(sourceName, rule);
const groupIdentifier = getRuleGroupLocationFromCombinedRule(rule);
if (showViewButton) { if (showViewButton) {
buttons.push( buttons.push(
@ -93,15 +93,23 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton
); );
} }
if (!rule.promRule) {
return null;
}
return ( return (
<Stack gap={1} alignItems="center" wrap="nowrap"> <Stack gap={1} alignItems="center" wrap="nowrap">
{buttons} {buttons}
<AlertRuleMenu <AlertRuleMenu
buttonSize={buttonSize} rulerRule={rule.rulerRule}
rule={rule} promRule={rule.promRule}
identifier={identifier} identifier={identifier}
showCopyLinkButton={showCopyLinkButton} groupIdentifier={groupIdentifier}
handleDelete={() => showDeleteModal(rule)} handleDelete={() => {
if (rule.rulerRule) {
showDeleteModal(rule.rulerRule, groupIdentifier);
}
}}
handleSilence={() => setShowSilenceDrawer(true)} handleSilence={() => setShowSilenceDrawer(true)}
handleDuplicateRule={() => setRedirectToClone({ identifier, isProvisioned })} handleDuplicateRule={() => setRedirectToClone({ identifier, isProvisioned })}
onPauseChange={() => { onPauseChange={() => {
@ -113,6 +121,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton
// on tag invalidation (or optimistic cache updates) for this // on tag invalidation (or optimistic cache updates) for this
dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME, limitAlerts })); dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME, limitAlerts }));
}} }}
buttonSize={buttonSize}
/> />
{deleteModal} {deleteModal}
{isGrafanaAlertingRule(rule.rulerRule) && showSilenceDrawer && ( {isGrafanaAlertingRule(rule.rulerRule) && showSilenceDrawer && (

View File

@ -15,17 +15,12 @@ import { isRulerNotSupportedResponse } from '../../utils/rules';
export function RuleListErrors(): ReactElement { export function RuleListErrors(): ReactElement {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [closed, setClosed] = useLocalStorage('grafana.unifiedalerting.hideErrors', false); const [closed, setClosed] = useLocalStorage('grafana.unifiedalerting.hideErrors', false);
const dataSourceConfigRequests = useUnifiedAlertingSelector((state) => state.dataSources);
const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const errors = useMemo((): JSX.Element[] => { const errors = useMemo((): JSX.Element[] => {
const [dataSourceConfigErrors, promRequestErrors, rulerRequestErrors] = [ const [promRequestErrors, rulerRequestErrors] = [promRuleRequests, rulerRuleRequests].map((requests) =>
dataSourceConfigRequests,
promRuleRequests,
rulerRuleRequests,
].map((requests) =>
getRulesDataSources().reduce<Array<{ error: SerializedError; dataSource: DataSourceInstanceSettings }>>( getRulesDataSources().reduce<Array<{ error: SerializedError; dataSource: DataSourceInstanceSettings }>>(
(result, dataSource) => { (result, dataSource) => {
const error = requests[dataSource.name]?.error; 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.'}</>); 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 }) => promRequestErrors.forEach(({ dataSource, error }) =>
result.push( result.push(
<> <>
@ -86,7 +69,7 @@ export function RuleListErrors(): ReactElement {
); );
return result; return result;
}, [dataSourceConfigRequests, promRuleRequests, rulerRuleRequests, styles.dsLink]); }, [promRuleRequests, rulerRuleRequests, styles.dsLink]);
return ( return (
<> <>

View File

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

View File

@ -8,6 +8,7 @@ import { Badge, ConfirmModal, Icon, Spinner, Stack, Tooltip, useStyles2 } from '
import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier, RulesSource } from 'app/types/unified-alerting'; import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier, RulesSource } from 'app/types/unified-alerting';
import { LogMessages, logInfo } from '../../Analytics'; import { LogMessages, logInfo } from '../../Analytics';
import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
import { useDeleteRuleGroup } from '../../hooks/ruleGroup/useDeleteRuleGroup'; import { useDeleteRuleGroup } from '../../hooks/ruleGroup/useDeleteRuleGroup';
import { useFolder } from '../../hooks/useFolder'; import { useFolder } from '../../hooks/useFolder';
import { useHasRuler } from '../../hooks/useHasRuler'; import { useHasRuler } from '../../hooks/useHasRuler';
@ -36,8 +37,12 @@ interface Props {
viewMode: ViewMode; viewMode: ViewMode;
} }
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => { export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => {
const { rulesSource } = namespace; const { rulesSource } = namespace;
const rulesSourceName = getRulesSourceName(rulesSource);
const [deleteRuleGroup] = useDeleteRuleGroup(); const [deleteRuleGroup] = useDeleteRuleGroup();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@ -54,6 +59,8 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
}, [expandAll]); }, [expandAll]);
const { hasRuler, rulerRulesLoaded } = useHasRuler(namespace.rulesSource); const { hasRuler, rulerRulesLoaded } = useHasRuler(namespace.rulesSource);
const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ rulesSourceName });
const rulerRule = group.rules[0]?.rulerRule; const rulerRule = group.rules[0]?.rulerRule;
const folderUID = (rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid) || undefined; const folderUID = (rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid) || undefined;
const { folder } = useFolder(folderUID); const { folder } = useFolder(folderUID);
@ -276,12 +283,13 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
folderUid={folderUID} folderUid={folderUID}
/> />
)} )}
{isReorderingGroup && ( {isReorderingGroup && dsFeatures?.rulerConfig && (
<ReorderCloudGroupModal <ReorderCloudGroupModal
group={group} group={group}
folderUid={folderUID} folderUid={folderUID}
namespace={namespace} namespace={namespace}
onClose={() => setIsReorderingGroup(false)} onClose={() => setIsReorderingGroup(false)}
rulerConfig={dsFeatures.rulerConfig}
/> />
)} )}
<ConfirmModal <ConfirmModal

View File

@ -4,7 +4,7 @@ import { byRole } from 'testing-library-selector';
import { setPluginLinksHook } from '@grafana/runtime'; import { setPluginLinksHook } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi'; 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 { getCloudRule, getGrafanaRule } from '../../mocks';
import { RulesTable } from './RulesTable'; import { RulesTable } from './RulesTable';
@ -12,6 +12,10 @@ import { RulesTable } from './RulesTable';
jest.mock('../../hooks/useAbilities'); jest.mock('../../hooks/useAbilities');
const mocks = { 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), useAlertRuleAbility: jest.mocked(useAlertRuleAbility),
}; };
@ -45,6 +49,9 @@ describe('RulesTable RBAC', () => {
const grafanaRule = getGrafanaRule({ name: 'Grafana' }); const grafanaRule = getGrafanaRule({ name: 'Grafana' });
it('Should not render Edit button for users without the update permission', async () => { 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) => { mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, false] : [true, true]; 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 () => { 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) => { mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Delete ? [true, false] : [true, true]; 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 () => { 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) => { mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, true] : [false, false]; return action === AlertRuleAction.Update ? [true, true] : [false, false];
}); });
render(<RulesTable rules={[grafanaRule]} />); render(<RulesTable rules={[grafanaRule]} />);
expect(await ui.actionButtons.edit.find()).toBeInTheDocument(); expect(await ui.actionButtons.edit.find()).toBeInTheDocument();
}); });
it('Should render Delete button for users with the delete permission', async () => { 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) => { mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Delete ? [true, true] : [false, false]; return action === AlertRuleAction.Delete ? [true, true] : [false, false];
}); });
@ -103,6 +120,9 @@ describe('RulesTable RBAC', () => {
}; };
beforeEach(() => { beforeEach(() => {
mocks.useRulerRuleAbility.mockImplementation(() => {
return [true, true];
});
mocks.useAlertRuleAbility.mockImplementation(() => { mocks.useAlertRuleAbility.mockImplementation(() => {
return [true, true]; return [true, true];
}); });
@ -135,6 +155,9 @@ describe('RulesTable RBAC', () => {
const cloudRule = getCloudRule({ name: 'Cloud' }); const cloudRule = getCloudRule({ name: 'Cloud' });
it('Should not render Edit button for users without the update permission', async () => { 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) => { mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, false] : [true, true]; 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 () => { 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) => { mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Delete ? [true, false] : [true, true]; 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 () => { 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) => { mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, true] : [false, false]; 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 () => { 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) => { mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Delete ? [true, true] : [false, false]; return action === AlertRuleAction.Delete ? [true, true] : [false, false];
}); });

View File

@ -214,9 +214,9 @@ function useColumns(
label: '', label: '',
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
renderCell: ({ data: rule }) => { renderCell: ({ data: rule }) => {
const rulerRule = rule.rulerRule; const { promRule, rulerRule } = rule;
const originMeta = getRulePluginOrigin(rule); const originMeta = getRulePluginOrigin(promRule ?? rulerRule);
if (originMeta) { if (originMeta) {
return <PluginOriginBadge pluginId={originMeta.pluginId} />; return <PluginOriginBadge pluginId={originMeta.pluginId} />;
} }

View File

@ -9,12 +9,13 @@ import {
import { useFolder } from 'app/features/alerting/unified/hooks/useFolder'; import { useFolder } from 'app/features/alerting/unified/hooks/useFolder';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/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 { alertmanagerApi } from '../api/alertmanagerApi';
import { useAlertmanager } from '../state/AlertmanagerContext'; import { useAlertmanager } from '../state/AlertmanagerContext';
import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control'; 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 { isAdmin } from '../utils/misc';
import { isFederatedRuleGroup, isGrafanaRecordingRule, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules'; import { isFederatedRuleGroup, isGrafanaRecordingRule, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules';
@ -155,6 +156,30 @@ export function useAlertRuleAbilities(rule: CombinedRule, actions: AlertRuleActi
}, [abilities, actions]); }, [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 // This hook is being called a lot in different places
// In some cases multiple times for ~80 rules (e.g. on the list page) // 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 // We need to investigate further if some of these calls are redundant
@ -169,13 +194,13 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
loading, loading,
} = useIsRuleEditable(rulesSourceName, rule.rulerRule); } = useIsRuleEditable(rulesSourceName, rule.rulerRule);
const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules); const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
const canSilence = useCanSilence(rule); const canSilence = useCanSilence(rule.rulerRule);
const abilities = useMemo<Abilities<AlertRuleAction>>(() => { const abilities = useMemo<Abilities<AlertRuleAction>>(() => {
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
const isFederated = isFederatedRuleGroup(rule.group); const isFederated = isFederatedRuleGroup(rule.group);
const isGrafanaManagedAlertRule = isGrafanaRulerRule(rule.rulerRule); 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 // 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; const immutableRule = isProvisioned || isFederated || isPluginProvided;
@ -206,6 +231,52 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
return abilities; 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> { export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> {
const { const {
selectedAlertmanager, selectedAlertmanager,
@ -335,24 +406,27 @@ export function useAlertmanagerAbilities(actions: AlertmanagerAction[]): Ability
}, [abilities, actions]); }, [abilities, actions]);
} }
const { useGetGrafanaAlertingConfigurationStatusQuery } = alertmanagerApi;
/** /**
* We don't want to show the silence button if either * We don't want to show the silence button if either
* 1. the user has no permissions to create silences * 1. the user has no permissions to create silences
* 2. the admin has configured to only send instances to external AMs * 2. the admin has configured to only send instances to external AMs
*/ */
function useCanSilence(rule: CombinedRule): [boolean, boolean] { function useCanSilence(rule?: RulerRuleDTO): [boolean, boolean] {
const rulesSource = rule.namespace.rulesSource; const folderUID = isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined;
const isGrafanaManagedRule = rulesSource === GRAFANA_RULES_SOURCE_NAME;
const isGrafanaRecording = isGrafanaRecordingRule(rule.rulerRule);
const { currentData: amConfigStatus, isLoading } =
alertmanagerApi.endpoints.getGrafanaAlertingConfigurationStatus.useQuery(undefined, {
skip: !isGrafanaManagedRule,
});
const folderUID = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.namespace_uid : undefined;
const { loading: folderIsLoading, folder } = useFolder(folderUID); const { loading: folderIsLoading, folder } = useFolder(folderUID);
const isGrafanaManagedRule = rule && isGrafanaRulerRule(rule);
const isGrafanaRecording = rule && isGrafanaRecordingRule(rule);
const { currentData: amConfigStatus, isLoading } = useGetGrafanaAlertingConfigurationStatusQuery(undefined, {
skip: !isGrafanaManagedRule || !rule,
});
if (!rule) {
return [false, false];
}
// we don't support silencing when the rule is not a Grafana managed alerting rule // we 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 // we simply don't know what Alertmanager the ruler is sending alerts to
if (!isGrafanaManagedRule || isGrafanaRecording || isLoading || folderIsLoading || !folder) { if (!isGrafanaManagedRule || isGrafanaRecording || isLoading || folderIsLoading || !folder) {

View File

@ -240,7 +240,11 @@ export function useRuleWithLocation({
}): RequestState<RuleWithLocation> { }): RequestState<RuleWithLocation> {
const ruleSource = getRulesSourceFromIdentifier(ruleIdentifier); const ruleSource = getRulesSourceFromIdentifier(ruleIdentifier);
const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleIdentifier.ruleSourceName); const { data: dsFeatures, isLoading: isLoadingDsFeatures } =
featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({
rulesSourceName: ruleIdentifier.ruleSourceName,
});
const { const {
loading: isLoadingRuleLocation, loading: isLoadingRuleLocation,
error: ruleLocationError, error: ruleLocationError,

View File

@ -272,7 +272,7 @@ const reduceGroups = (filterState: RulesFilter) => {
} }
if ('plugins' in matchesFilterFor && filterState.plugins === 'hide') { if ('plugins' in matchesFilterFor && filterState.plugins === 'hide') {
matchesFilterFor.plugins = !isPluginProvidedRule(rule); matchesFilterFor.plugins = rule.rulerRule && !isPluginProvidedRule(rule.rulerRule);
} }
if ('contactPoint' in matchesFilterFor) { if ('contactPoint' in matchesFilterFor) {

View File

@ -3,9 +3,13 @@ import * as React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { contextSrv } from 'app/core/services/context_srv'; 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 { useFolder } from './useFolder';
import { useIsRuleEditable } from './useIsRuleEditable'; import { useIsRuleEditable } from './useIsRuleEditable';
@ -16,6 +20,14 @@ const mocks = {
useFolder: jest.mocked(useFolder), useFolder: jest.mocked(useFolder),
}; };
const server = setupMswServer();
const dataSources = {
mimir: mockDataSource({ uid: 'mimir', name: 'Mimir' }),
};
setupDataSources(dataSources.mimir);
describe('useIsRuleEditable', () => { describe('useIsRuleEditable', () => {
describe('RBAC enabled', () => { describe('RBAC enabled', () => {
describe('Grafana rules', () => { describe('Grafana rules', () => {
@ -95,13 +107,17 @@ describe('useIsRuleEditable', () => {
beforeEach(() => { beforeEach(() => {
mocks.useFolder.mockReturnValue({ loading: false }); mocks.useFolder.mockReturnValue({ loading: false });
contextSrv.isEditor = true; 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 () => { it('Should allow editing and deleting when the user has alert rule external write permission', async () => {
mockPermissions([AccessControlAction.AlertingRuleExternalWrite]); mockPermissions([AccessControlAction.AlertingRuleExternalWrite]);
const wrapper = getProviderWrapper(); 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)); await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isEditable).toBe(true); expect(result.current.isEditable).toBe(true);
@ -112,7 +128,9 @@ describe('useIsRuleEditable', () => {
mockPermissions([]); mockPermissions([]);
const wrapper = getProviderWrapper(); 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)); await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isEditable).toBe(false); expect(result.current.isEditable).toBe(false);
@ -133,31 +151,7 @@ function mockPermissions(grantedPermissions: AccessControlAction[]) {
} }
function getProviderWrapper() { function getProviderWrapper() {
const dataSources = getMockedDataSources(); const store = configureStore();
const store = mockUnifiedAlertingStore({ dataSources });
const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>; const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>;
return wrapper; 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' },
},
},
};
}

View File

@ -6,7 +6,6 @@ import { getRulesPermissions } from '../utils/access-control';
import { isGrafanaRulerRule } from '../utils/rules'; import { isGrafanaRulerRule } from '../utils/rules';
import { useFolder } from './useFolder'; import { useFolder } from './useFolder';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
interface ResultBag { interface ResultBag {
isRulerAvailable?: boolean; isRulerAvailable?: boolean;
@ -16,7 +15,6 @@ interface ResultBag {
} }
export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag { export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag {
const dataSources = useUnifiedAlertingSelector((state) => state.dataSources);
const { currentData: dsFeatures, isLoading } = featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({ const { currentData: dsFeatures, isLoading } = featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({
rulesSourceName, 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 // prom rules are only editable by users with Editor role and only if rules source supports editing
const isRulerAvailable = const isRulerAvailable = Boolean(dsFeatures?.rulerConfig);
Boolean(dataSources[rulesSourceName]?.result?.rulerConfig) || Boolean(dsFeatures?.rulerConfig);
const canEditCloudRules = contextSrv.hasPermission(rulePermission.update); const canEditCloudRules = contextSrv.hasPermission(rulePermission.update);
const canRemoveCloudRules = contextSrv.hasPermission(rulePermission.delete); const canRemoveCloudRules = contextSrv.hasPermission(rulePermission.delete);
@ -71,6 +68,6 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
isRulerAvailable, isRulerAvailable,
isEditable: canEditCloudRules && isRulerAvailable, isEditable: canEditCloudRules && isRulerAvailable,
isRemovable: canRemoveCloudRules && isRulerAvailable, isRemovable: canRemoveCloudRules && isRulerAvailable,
loading: isLoading || dataSources[rulesSourceName]?.loading, loading: isLoading,
}; };
} }

View File

@ -1,19 +1,28 @@
import { useEffect, useState } from 'react';
import { DataSourceInstanceSettings } from '@grafana/data'; 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[] { export function useRulesSourcesWithRuler(): {
const dataSources = useUnifiedAlertingSelector((state) => state.dataSources); rulesSourcesWithRuler: DataSourceInstanceSettings[];
isLoading: boolean;
} {
const [rulesSourcesWithRuler, setRulesSourcesWithRuler] = useState<DataSourceInstanceSettings[]>([]);
const [discoverDsFeatures, { isLoading }] = useLazyDiscoverDsFeaturesQuery();
const dataSourcesWithRuler = Object.values(dataSources) useEffect(() => {
.map((ds) => ds.result) const dataSources = getRulesDataSources();
.filter((ds): ds is PromBasedDataSource => Boolean(ds?.rulerConfig)); dataSources.forEach(async (ds) => {
// try fetching rules for each prometheus to see if it has ruler const { data: dsFeatures } = await discoverDsFeatures({ uid: ds.uid }, true);
if (dsFeatures?.rulerConfig) {
setRulesSourcesWithRuler((prev) => [...prev, ds]);
}
});
}, [discoverDsFeatures]);
return dataSourcesWithRuler return { rulesSourcesWithRuler, isLoading };
.map((ds) => getDataSourceByName(ds.name))
.filter((dsConfig): dsConfig is DataSourceInstanceSettings => Boolean(dsConfig));
} }

View File

@ -1,4 +1,5 @@
import { produce } from 'immer'; import { produce } from 'immer';
import { isEmpty, pick } from 'lodash';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { 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({ return mockCombinedRule({
namespace: { namespace: {
groups: [], groups: [],
name: 'Cortex', name: 'Cortex',
rulesSource: mockDataSource(), rulesSource: mockDataSource(),
...nsOverride,
}, },
promRule: mockPromAlertingRule(), promRule: mockPromAlertingRule(isEmpty(promOverride) ? undefined : promOverride),
rulerRule: mockRulerAlertingRule(), rulerRule: mockRulerAlertingRule(
isEmpty(rulerOverride) ? undefined : { ...rulerOverride, alert: rulerOverride.name }
),
...override, ...override,
}); });
} }

View File

@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { PluginExtensionPoints } from '@grafana/data'; import { PluginExtensionPoints } from '@grafana/data';
import { usePluginLinks } from '@grafana/runtime'; 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 { PromRuleType } from 'app/types/unified-alerting-dto';
import { getRulePluginOrigin } from '../utils/rules'; import { getRulePluginOrigin } from '../utils/rules';
@ -21,12 +21,12 @@ export interface AlertingRuleExtensionContext extends BaseRuleExtensionContext {
export interface RecordingRuleExtensionContext extends BaseRuleExtensionContext {} export interface RecordingRuleExtensionContext extends BaseRuleExtensionContext {}
export function useRulePluginLinkExtension(rule: CombinedRule) { export function useRulePluginLinkExtension(rule: Rule, groupIdentifier: RuleGroupIdentifier) {
const ruleExtensionPoint = useRuleExtensionPoint(rule); const ruleExtensionPoint = useRuleExtensionPoint(rule, groupIdentifier);
const { links } = usePluginLinks(ruleExtensionPoint); const { links } = usePluginLinks(ruleExtensionPoint);
const ruleOrigin = getRulePluginOrigin(rule); const ruleOrigin = getRulePluginOrigin(rule);
const ruleType = rule.promRule?.type; const ruleType = rule.type;
if (!ruleOrigin || !ruleType) { if (!ruleOrigin || !ruleType) {
return []; return [];
} }
@ -57,9 +57,9 @@ interface EmptyExtensionPoint {
type RuleExtensionPoint = AlertingRuleExtensionPoint | RecordingRuleExtensionPoint | EmptyExtensionPoint; type RuleExtensionPoint = AlertingRuleExtensionPoint | RecordingRuleExtensionPoint | EmptyExtensionPoint;
function useRuleExtensionPoint(rule: CombinedRule): RuleExtensionPoint { function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifier): RuleExtensionPoint {
return useMemo(() => { return useMemo<RuleExtensionPoint>(() => {
const ruleType = rule.promRule?.type; const ruleType = rule.type;
switch (ruleType) { switch (ruleType) {
case PromRuleType.Alerting: case PromRuleType.Alerting:
@ -67,11 +67,11 @@ function useRuleExtensionPoint(rule: CombinedRule): RuleExtensionPoint {
extensionPointId: PluginExtensionPoints.AlertingAlertingRuleAction, extensionPointId: PluginExtensionPoints.AlertingAlertingRuleAction,
context: { context: {
name: rule.name, name: rule.name,
namespace: rule.namespace.name, namespace: groupIdentifier.namespaceName,
group: rule.group.name, group: groupIdentifier.groupName,
expression: rule.query, expression: rule.query,
labels: rule.labels, labels: rule.labels ?? {},
annotations: rule.annotations, annotations: rule.annotations ?? {},
}, },
}; };
case PromRuleType.Recording: case PromRuleType.Recording:
@ -79,14 +79,14 @@ function useRuleExtensionPoint(rule: CombinedRule): RuleExtensionPoint {
extensionPointId: PluginExtensionPoints.AlertingRecordingRuleAction, extensionPointId: PluginExtensionPoints.AlertingRecordingRuleAction,
context: { context: {
name: rule.name, name: rule.name,
namespace: rule.namespace.name, namespace: groupIdentifier.namespaceName,
group: rule.group.name, group: groupIdentifier.groupName,
expression: rule.query, expression: rule.query,
labels: rule.labels, labels: rule.labels ?? {},
}, },
}; };
default: default:
return { extensionPointId: '' }; return { extensionPointId: '' };
} }
}, [rule]); }, [groupIdentifier, rule]);
} }

View File

@ -6,6 +6,7 @@ import { urlUtil } from '@grafana/data';
import { logInfo } from '@grafana/runtime'; import { logInfo } from '@grafana/runtime';
import { Button, LinkButton, Stack, withErrorBoundary } from '@grafana/ui'; import { Button, LinkButton, Stack, withErrorBoundary } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { Trans } from 'app/core/internationalization';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
import { CombinedRuleNamespace } from 'app/types/unified-alerting'; import { CombinedRuleNamespace } from 'app/types/unified-alerting';
@ -49,8 +50,9 @@ const RuleListV1 = () => {
const hasActiveLabelsFilter = filterState.labels.length > 0; const hasActiveLabelsFilter = filterState.labels.length > 0;
const queryParamView = queryParams.view as keyof typeof VIEWS; const queryParamView = queryParams.view;
const view = VIEWS[queryParamView] ? queryParamView : 'groups'; const viewType = queryParamView === 'state' || queryParamView === 'groups' ? queryParamView : 'groups';
const view = VIEWS[viewType] ? viewType : 'groups';
const ViewComponent = VIEWS[view]; const ViewComponent = VIEWS[view];
@ -161,7 +163,7 @@ export function CreateAlertButton() {
icon="plus" icon="plus"
onClick={() => logInfo(LogMessages.alertRuleFromScratch)} onClick={() => logInfo(LogMessages.alertRuleFromScratch)}
> >
New alert rule <Trans i18nKey="alerting.rule-list.new-alert-rule">New alert rule</Trans>
</LinkButton> </LinkButton>
); );
} }

View File

@ -1,216 +1,370 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { PropsWithChildren, ReactNode, useMemo } from 'react';
import { useLocation } from 'react-router-dom-v5-compat'; import Skeleton from 'react-loading-skeleton';
import { useAsyncFn, useInterval, useMeasure } from 'react-use';
import { GrafanaTheme2, urlUtil } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Button, LinkButton, LoadingBar, useStyles2, withErrorBoundary } from '@grafana/ui'; import {
import { useDispatch } from 'app/types'; 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 { alertRuleApi } from '../api/alertRuleApi';
import { logInfo, LogMessages, trackRuleListNavigation } from '../Analytics'; import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import RulesFilter from '../components/rules/Filter/RulesFilter.v1'; import { Spacer } from '../components/Spacer';
import { NoRulesSplash } from '../components/rules/NoRulesCTA'; import { WithReturnButton } from '../components/WithReturnButton';
import { INSTANCES_DISPLAY_LIMIT } from '../components/rules/RuleDetails'; import RulesFilter from '../components/rules/Filter/RulesFilter';
import { RuleListErrors } from '../components/rules/RuleListErrors'; import { getAllRulesSources, isGrafanaRulesSource } from '../utils/datasource';
import { RuleStats } from '../components/rules/RuleStats'; import { equal, fromRule, fromRulerRule, hashRule, stringifyIdentifier } from '../utils/rule-id';
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; import { getRulePluginOrigin, isAlertingRule, isRecordingRule } from '../utils/rules';
import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces'; import { createRelativeUrl } from '../utils/url';
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 { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
import Namespace from './components/Namespace'; import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { DataSourceIcon } from './components/Namespace';
import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2';
import { LoadingIndicator } from './components/RuleGroup';
// make sure we ask for 1 more so we show the "show x more" button const noop = () => {};
const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1; const { usePrometheusRuleNamespacesQuery, useGetRuleGroupForNamespaceQuery } = alertRuleApi;
const RuleList = withErrorBoundary( const RuleList = withErrorBoundary(
() => { () => {
const dispatch = useDispatch(); const ruleSources = getAllRulesSources();
const styles = useStyles2(getStyles);
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
const [expandAll, setExpandAll] = useState(false);
const onFilterCleared = useCallback(() => setExpandAll(false), []);
const { filterState, hasActiveFilters } = useRulesFilter();
const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const loading = rulesDataSourceNames.some(
(name) => promRuleRequests[name]?.loading || rulerRuleRequests[name]?.loading
);
const promRequests = Object.entries(promRuleRequests);
const rulerRequests = Object.entries(rulerRuleRequests);
const allPromLoaded = promRequests.every(
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
);
const allRulerLoaded = rulerRequests.every(
([_, state]) => state.dispatched && (state?.result !== undefined || state?.error !== undefined)
);
const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0);
const allRulerEmpty = rulerRequests.every(([_, state]) => {
const rulerRules = Object.entries(state?.result ?? {});
const noRules = rulerRules.every(([_, result]) => result?.length === 0);
return noRules && state.dispatched;
});
const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS;
// Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED
const [_, fetchRules] = useAsyncFn(async () => {
if (!loading) {
await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
}
}, [loading, limitAlerts, dispatch]);
useEffect(() => {
trackRuleListNavigation().catch(() => {});
}, []);
// fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS
useEffect(() => {
dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
}, [dispatch, limitAlerts]);
useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS);
// Show splash only when we loaded all of the data sources and none of them has alerts
const hasNoAlertRulesCreatedYet =
allPromLoaded && allPromEmpty && promRequests.length > 0 && allRulerEmpty && allRulerLoaded;
const hasAlertRulesCreated = !hasNoAlertRulesCreatedYet;
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
const sortedNamespaces = filteredNamespaces.sort((a: CombinedRuleNamespace, b: CombinedRuleNamespace) =>
a.name.localeCompare(b.name)
);
return ( return (
// We don't want to show the Loading... indicator for the whole page. // We don't want to show the Loading... indicator for the whole page.
// We show separate indicators for Grafana-managed and Cloud rules // We show separate indicators for Grafana-managed and Cloud rules
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={hasAlertRulesCreated && <CreateAlertButton />}> <AlertingPageWrapper navId="alert-list" isLoading={false} actions={null}>
<RuleListErrors /> <RulesFilter onClear={() => {}} />
<RulesFilter onClear={onFilterCleared} /> <Stack direction="column" gap={1}>
{hasAlertRulesCreated && ( {ruleSources.map((ruleSource) => {
<> if (isGrafanaRulesSource(ruleSource)) {
<div className={styles.break} /> return <GrafanaDataSourceLoader key={ruleSource} />;
<div className={styles.buttonsContainer}> } else {
<div className={styles.statsContainer}> return <DataSourceLoader key={ruleSource.uid} uid={ruleSource.uid} name={ruleSource.name} />;
{hasActiveFilters && ( }
<Button })}
className={styles.expandAllButton} </Stack>
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> </AlertingPageWrapper>
); );
}, },
{ style: 'page' } { style: 'page' }
); );
const LoadingIndicator = ({ visible = false }) => { const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const [measureRef, { width }] = useMeasure<HTMLDivElement>();
return <div ref={measureRef}>{visible && <LoadingBar width={width} />}</div>; interface DataSourceLoaderProps {
name: string;
uid: string;
}
const GrafanaDataSourceLoader = () => {
return <DataSourceSection name="Grafana" application="grafana" isLoading={true}></DataSourceSection>;
};
const DataSourceLoader = ({ uid, name }: DataSourceLoaderProps) => {
const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid });
if (isLoading) {
return <DataSourceSection loader={<Skeleton width={250} height={16} />} />;
}
// 2. grab prometheus rule groups with max_groups if supported
if (dataSourceInfo) {
const rulerEnabled = Boolean(dataSourceInfo.rulerConfig);
return (
<PaginatedDataSourceLoader
ruleSourceName={dataSourceInfo.name}
rulerEnabled={rulerEnabled}
uid={uid}
name={name}
application={dataSourceInfo.application}
/>
);
}
return null;
};
interface PaginatedDataSourceLoaderProps extends Pick<DataSourceSectionProps, 'application' | 'uid' | 'name'> {
ruleSourceName: string;
rulerEnabled?: boolean;
}
function PaginatedDataSourceLoader({
ruleSourceName,
rulerEnabled = false,
name,
uid,
application,
}: PaginatedDataSourceLoaderProps) {
const { data: ruleNamespaces = [], isLoading } = usePrometheusRuleNamespacesQuery({
ruleSourceName,
maxGroups: 25,
limitAlerts: 0,
excludeAlerts: true,
});
return (
<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,
};
return (
<AlertRuleLoader
key={hashRule(rule)}
rule={rule}
groupIdentifier={groupIdentifier}
rulerEnabled={rulerEnabled}
/>
);
})}
</ListGroup>
))}
</ListSection>
))}
{!isLoading && <Pagination currentPage={1} numberOfPages={0} onNavigate={noop} />}
</Stack>
</DataSourceSection>
);
}
interface AlertRuleLoaderProps {
rule: Rule;
groupIdentifier: RuleGroupIdentifier;
rulerEnabled?: boolean;
}
function AlertRuleLoader({ rule, groupIdentifier, rulerEnabled = false }: AlertRuleLoaderProps) {
const { dataSourceName, namespaceName, groupName } = groupIdentifier;
const ruleIdentifier = fromRule(dataSourceName, namespaceName, groupName, rule);
const href = createViewLinkFromIdentifier(ruleIdentifier);
const originMeta = getRulePluginOrigin(rule);
// @TODO work with context API to propagate rulerConfig and such
const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ rulesSourceName: dataSourceName });
// @TODO refactor this to use a separate hook (useRuleWithLocation() and useCombinedRule() seems to introduce infinite loading / recursion)
const {
isLoading,
data: rulerRuleGroup,
// error,
} = useGetRuleGroupForNamespaceQuery(
{
namespace: namespaceName,
group: groupName,
rulerConfig: dataSourceInfo?.rulerConfig!,
},
{ skip: !dataSourceInfo?.rulerConfig }
);
const rulerRule = useMemo(() => {
if (!rulerRuleGroup) {
return;
}
return rulerRuleGroup.rules.find((rule) =>
equal(fromRulerRule(dataSourceName, namespaceName, groupName, rule), ruleIdentifier)
);
}, [dataSourceName, groupName, namespaceName, ruleIdentifier, rulerRuleGroup]);
// 1. get the rule from the ruler API with "ruleWithLocation"
// 1.1 skip this if this datasource does not have a ruler
//
// 2.1 render action buttons
// 2.2 render provisioning badge and contact point metadata, etc.
const actions = useMemo(() => {
if (!rulerEnabled) {
return null;
}
if (isLoading) {
return <ActionsLoader />;
}
if (rulerRule) {
return <RuleActionsButtons rule={rulerRule} promRule={rule} groupIdentifier={groupIdentifier} compact />;
}
return null;
}, [groupIdentifier, isLoading, rule, rulerEnabled, rulerRule]);
if (isAlertingRule(rule)) {
return (
<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}
/>
);
}
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) => ({ const getStyles = (theme: GrafanaTheme2) => ({
rulesTree: css({ itemsWrapper: css({
display: 'flex', position: 'relative',
flexDirection: 'column', marginLeft: theme.spacing(1.5),
gap: theme.spacing(1),
'&:before': {
content: "''",
position: 'absolute',
height: '100%',
marginLeft: `-${theme.spacing(1.5)}`,
borderLeft: `solid 1px ${theme.colors.border.weak}`,
},
}), }),
break: css({ dataSourceSectionTitle: css({
width: '100%', background: theme.colors.background.secondary,
height: 0, padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
marginBottom: theme.spacing(2),
borderBottom: `solid 1px ${theme.colors.border.medium}`, border: `solid 1px ${theme.colors.border.weak}`,
}), borderRadius: theme.shape.radius.default,
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),
}), }),
}); });
export default RuleList; export default RuleList;
export function CreateAlertButton() {
const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule);
const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule);
const location = useLocation();
const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed;
const canCreateGrafanaRules = createRuleSupported && createRuleAllowed;
if (canCreateGrafanaRules || canCreateCloudRules) {
return (
<LinkButton
href={urlUtil.renderUrl('alerting/new/alerting', { returnTo: location.pathname + location.search })}
icon="plus"
onClick={() => logInfo(LogMessages.alertRuleFromScratch)}
>
New alert rule
</LinkButton>
);
}
return null;
}

View File

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

View File

@ -1,12 +1,11 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { isEmpty } from 'lodash';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Icon, Stack, Text, TextLink, useStyles2 } from '@grafana/ui'; import { Alert, Icon, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization'; 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 { Labels, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { logError } from '../../Analytics'; import { logError } from '../../Analytics';
@ -35,7 +34,7 @@ interface AlertRuleListItemProps {
evaluationInterval?: string; evaluationInterval?: string;
labels?: Labels; labels?: Labels;
instancesCount?: number; instancesCount?: number;
namespace?: CombinedRuleNamespace; namespace?: string;
group?: string; group?: string;
// used for alert rules that use simplified routing // used for alert rules that use simplified routing
contactPoint?: string; contactPoint?: string;
@ -91,7 +90,7 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
} }
} }
if (!isEmpty(labels)) { if (labelsSize(labels) > 0) {
metadata.push( metadata.push(
<MetaText icon="tag-alt"> <MetaText icon="tag-alt">
<TextLink href={href} variant="bodySmall" color="primary" inline={false}> <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 { interface SummaryProps {
content?: string; content?: string;
error?: string; error?: string;
@ -203,13 +235,14 @@ function EvaluationMetadata({ lastEvaluation, evaluationInterval, state }: Evalu
} }
interface UnknownRuleListItemProps { interface UnknownRuleListItemProps {
rule: CombinedRule; rule: Rule;
groupIdentifier: RuleGroupIdentifier;
} }
export const UnknownRuleListItem = ({ rule }: UnknownRuleListItemProps) => { export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListItemProps) => {
const styles = useStyles2(getStyles); 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); logError(new Error('unknown rule type'), ruleContext);
return ( return (
@ -219,7 +252,7 @@ export const UnknownRuleListItem = ({ rule }: UnknownRuleListItemProps) => {
<Trans i18nKey="alerting.alert-rules.rule-definition">Rule definition</Trans> <Trans i18nKey="alerting.alert-rules.rule-definition">Rule definition</Trans>
</summary> </summary>
<pre> <pre>
<code>{JSON.stringify(rule.rulerRule, null, 2)}</code> <code>{JSON.stringify(rule, null, 2)}</code>
</pre> </pre>
</details> </details>
</Alert> </Alert>
@ -227,7 +260,7 @@ export const UnknownRuleListItem = ({ rule }: UnknownRuleListItemProps) => {
}; };
interface RuleLocationProps { interface RuleLocationProps {
namespace: CombinedRuleNamespace; namespace: string;
group: string; group: string;
} }
@ -235,7 +268,7 @@ export const RuleLocation = ({ namespace, group }: RuleLocationProps) => (
<Stack direction="row" alignItems="center" gap={0.5}> <Stack direction="row" alignItems="center" gap={0.5}>
<Icon size="xs" name="folder" /> <Icon size="xs" name="folder" />
<Stack direction="row" alignItems="center" gap={0}> <Stack direction="row" alignItems="center" gap={0}>
{namespace.name} {namespace}
<Icon size="sm" name="angle-right" /> <Icon size="sm" name="angle-right" />
{group} {group}
</Stack> </Stack>

View File

@ -17,7 +17,14 @@ interface EvaluationGroupProps extends PropsWithChildren {
onToggle: () => void; 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 styles = useStyles2(getStyles);
const isProvisioned = Boolean(provenance); const isProvisioned = Boolean(provenance);
@ -78,5 +85,3 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin: `-${theme.spacing(0.5)}`, margin: `-${theme.spacing(0.5)}`,
}), }),
}); });
export default EvaluationGroup;

View File

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

View File

@ -1,5 +1,6 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { PropsWithChildren, ReactNode } from 'react'; import { PropsWithChildren, ReactNode } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, Stack, Text, useStyles2 } from '@grafana/ui'; import { IconButton, Stack, Text, useStyles2 } from '@grafana/ui';
@ -13,36 +14,39 @@ interface GroupProps extends PropsWithChildren {
metaRight?: ReactNode; metaRight?: ReactNode;
actions?: ReactNode; actions?: ReactNode;
isOpen?: boolean; isOpen?: boolean;
onToggle: () => void;
} }
export const Group = ({ export const ListGroup = ({
name, name,
description, description,
onToggle, isOpen = true,
isOpen = false,
metaRight = null, metaRight = null,
actions = null, actions = null,
children, children,
}: GroupProps) => { }: GroupProps) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [open, toggle] = useToggle(isOpen);
return ( 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 <GroupHeader
onToggle={onToggle} onToggle={() => toggle()}
isOpen={isOpen} isOpen={open}
description={description} description={description}
name={name} name={name}
metaRight={metaRight} metaRight={metaRight}
actions={actions} actions={actions}
/> />
{isOpen && <div role="group">{children}</div>} {open && <div role="group">{children}</div>}
</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 { name, description, metaRight = null, actions = null, isOpen = false, onToggle } = props;
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@ -50,9 +54,9 @@ const GroupHeader = (props: GroupProps) => {
return ( return (
<div className={styles.headerWrapper}> <div className={styles.headerWrapper}>
<Stack direction="row" alignItems="center" gap={1}> <Stack direction="row" alignItems="center" gap={1}>
<Stack alignItems="center" gap={1}> <Stack alignItems="center" gap={0}>
<IconButton <IconButton
name={isOpen ? 'angle-right' : 'angle-down'} name={isOpen ? 'angle-down' : 'angle-right'}
onClick={onToggle} onClick={onToggle}
aria-label={t('common.collapse', 'Collapse')} aria-label={t('common.collapse', 'Collapse')}
/> />
@ -76,7 +80,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
flexDirection: 'column', flexDirection: 'column',
}), }),
headerWrapper: css({ headerWrapper: css({
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`,
background: theme.colors.background.secondary, background: theme.colors.background.secondary,

View File

@ -30,7 +30,7 @@ export const ListSection = ({
<li className={styles.wrapper} role="treeitem" aria-selected="false"> <li className={styles.wrapper} role="treeitem" aria-selected="false">
<div className={styles.sectionTitle}> <div className={styles.sectionTitle}>
<Stack alignItems="center"> <Stack alignItems="center">
<Stack alignItems="center" gap={1}> <Stack alignItems="center" gap={0}>
<IconButton <IconButton
name={isCollapsed ? 'angle-right' : 'angle-down'} name={isCollapsed ? 'angle-right' : 'angle-down'}
onClick={toggleCollapsed} onClick={toggleCollapsed}
@ -65,18 +65,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
border: `solid 1px ${theme.colors.border.weak}`, border: `solid 1px ${theme.colors.border.weak}`,
borderBottom: 'none', borderBottom: 'none',
marginLeft: theme.spacing(3), marginLeft: theme.spacing(1.5),
'&:before': {
content: "''",
position: 'absolute',
height: '100%',
borderLeft: `solid 1px ${theme.colors.border.weak}`,
marginTop: 0,
marginLeft: `-${theme.spacing(2.5)}`,
},
}), }),
wrapper: css({ wrapper: css({
display: 'flex', display: 'flex',
@ -88,7 +77,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`, padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`,
background: theme.colors.background.secondary, background: theme.colors.background.secondary,
border: `solid 1px ${theme.colors.border.weak}`, border: `solid 1px ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default, borderRadius: theme.shape.radius.default,
}), }),

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import { usePagination } from '../../hooks/usePagination';
import { isAlertingRule } from '../../utils/rules'; import { isAlertingRule } from '../../utils/rules';
import { AlertRuleListItem } from './AlertRuleListItem'; import { AlertRuleListItem } from './AlertRuleListItem';
import EvaluationGroup from './EvaluationGroup'; import { EvaluationGroup } from './EvaluationGroup';
import { SkeletonListItem } from './ListItem'; import { SkeletonListItem } from './ListItem';
interface EvaluationGroupLoaderProps { 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 GroupLoadingIndicator = () => {
const [ref, { width }] = useMeasure<HTMLDivElement>(); const [ref, { width }] = useMeasure<HTMLDivElement>();

View File

@ -10,18 +10,12 @@ import {
Receiver, Receiver,
TestReceiversAlert, TestReceiversAlert,
} from 'app/plugins/datasource/alertmanager/types'; } from 'app/plugins/datasource/alertmanager/types';
import { FolderDTO, StoreState, ThunkResult } from 'app/types'; import { FolderDTO, ThunkResult } from 'app/types';
import { import { RuleIdentifier, RuleNamespace, StateHistoryItem } from 'app/types/unified-alerting';
PromBasedDataSource, import { RulerRuleDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
RuleIdentifier,
RuleNamespace,
RulerDataSourceConfig,
StateHistoryItem,
} from 'app/types/unified-alerting';
import { PromApplication, RulerRuleDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { backendSrv } from '../../../../core/services/backend_srv'; import { backendSrv } from '../../../../core/services/backend_srv';
import { withPerformanceLogging, withPromRulesMetadataLogging, withRulerRulesMetadataLogging } from '../Analytics'; import { withPromRulesMetadataLogging, withRulerRulesMetadataLogging } from '../Analytics';
import { import {
deleteAlertManagerConfig, deleteAlertManagerConfig,
fetchAlertGroups, fetchAlertGroups,
@ -30,43 +24,16 @@ import {
} from '../api/alertmanager'; } from '../api/alertmanager';
import { alertmanagerApi } from '../api/alertmanagerApi'; import { alertmanagerApi } from '../api/alertmanagerApi';
import { fetchAnnotations } from '../api/annotations'; import { fetchAnnotations } from '../api/annotations';
import { discoverFeatures } from '../api/buildInfo'; import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { FetchPromRulesFilter, fetchRules } from '../api/prometheus'; import { FetchPromRulesFilter, fetchRules } from '../api/prometheus';
import { FetchRulerRulesFilter, fetchRulerRules } from '../api/ruler'; import { FetchRulerRulesFilter, fetchRulerRules } from '../api/ruler';
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager'; 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 { makeAMLink } from '../utils/misc';
import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux'; import { withAppEvents, withSerializedError } from '../utils/redux';
import { getAlertInfo } from '../utils/rules'; import { getAlertInfo } from '../utils/rules';
import { safeParsePrometheusDuration } from '../utils/time'; 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( export const fetchPromRulesAction = createAsyncThunk(
'unifiedalerting/fetchPromRules', 'unifiedalerting/fetchPromRules',
async ( async (
@ -87,8 +54,6 @@ export const fetchPromRulesAction = createAsyncThunk(
}, },
thunkAPI thunkAPI
): Promise<RuleNamespace[]> => { ): Promise<RuleNamespace[]> => {
await thunkAPI.dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName }));
const fetchRulesWithLogging = withPromRulesMetadataLogging('unifiedalerting/fetchPromRules', fetchRules, { const fetchRulesWithLogging = withPromRulesMetadataLogging('unifiedalerting/fetchPromRules', fetchRules, {
dataSourceName: rulesSourceName, dataSourceName: rulesSourceName,
thunk: 'unifiedalerting/fetchPromRules', thunk: 'unifiedalerting/fetchPromRules',
@ -112,8 +77,13 @@ export const fetchRulerRulesAction = createAsyncThunk(
}, },
{ dispatch, getState } { dispatch, getState }
): Promise<RulerRulesConfigDTO | null> => { ): Promise<RulerRulesConfigDTO | null> => {
await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); const { data: dsFeatures } = await dispatch(
const rulerConfig = getDataSourceRulerConfig(getState, rulesSourceName); featureDiscoveryApi.endpoints.discoverDsFeatures.initiate({ rulesSourceName })
);
if (!dsFeatures?.rulerConfig) {
return null;
}
const fetchRulerRulesWithLogging = withRulerRulesMetadataLogging( const fetchRulerRulesWithLogging = withRulerRulesMetadataLogging(
'unifiedalerting/fetchRulerRules', '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[]; matcher?: Matcher[];
state?: string[]; state?: string[];
}): ThunkResult<Promise<void>> { }): 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) => { return async (dispatch) => {
const allRequests = getAllRulesSourceNames().map((rulesSourceName) => const { data: dsFeatures } = await dispatch(
dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })) 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 { interface FetchPromRulesRulesActionProps {
filter?: FetchPromRulesFilter; filter?: FetchPromRulesFilter;
limitAlerts?: number; limitAlerts?: number;
@ -242,18 +141,18 @@ export function fetchAllPromAndRulerRulesAction(
await Promise.allSettled( await Promise.allSettled(
getAllRulesSourceNames().map(async (rulesSourceName) => { 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 { promRules, rulerRules } = getStore().unifiedAlerting;
const dataSourceConfig = dataSources[rulesSourceName].result;
if (!dataSourceConfig) { if (!dsFeatures) {
return; return;
} }
const shouldLoadProm = force || !promRules[rulesSourceName]?.loading; const shouldLoadProm = force || !promRules[rulesSourceName]?.loading;
const shouldLoadRuler = const shouldLoadRuler = (force || !rulerRules[rulesSourceName]?.loading) && Boolean(dsFeatures?.rulerConfig);
(force || !rulerRules[rulesSourceName]?.loading) && Boolean(dataSourceConfig.rulerConfig);
await Promise.allSettled([ await Promise.allSettled([
shouldLoadProm && dispatch(fetchPromRulesAction({ rulesSourceName, ...options })), shouldLoadProm && dispatch(fetchPromRulesAction({ rulesSourceName, ...options })),

View File

@ -9,17 +9,11 @@ import {
fetchGrafanaAnnotationsAction, fetchGrafanaAnnotationsAction,
fetchPromRulesAction, fetchPromRulesAction,
fetchRulerRulesAction, fetchRulerRulesAction,
fetchRulesSourceBuildInfoAction,
testReceiversAction, testReceiversAction,
updateAlertManagerConfigAction, updateAlertManagerConfigAction,
} from './actions'; } from './actions';
export const reducer = combineReducers({ export const reducer = combineReducers({
dataSources: createAsyncMapSlice(
'dataSources',
fetchRulesSourceBuildInfoAction,
({ rulesSourceName }) => rulesSourceName
).reducer,
promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, ({ rulesSourceName }) => rulesSourceName).reducer, promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, ({ rulesSourceName }) => rulesSourceName).reducer,
rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, ({ rulesSourceName }) => rulesSourceName) rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, ({ rulesSourceName }) => rulesSourceName)
.reducer, .reducer,

View File

@ -8,7 +8,6 @@ import {
} from 'app/plugins/datasource/alertmanager/types'; } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { RulesSource } from 'app/types/unified-alerting'; import { RulesSource } from 'app/types/unified-alerting';
import { PromApplication, RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { alertmanagerApi } from '../api/alertmanagerApi'; import { alertmanagerApi } from '../api/alertmanagerApi';
import { PERMISSIONS_CONTACT_POINTS } from '../components/contact-points/permissions'; 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_RULES_SOURCE_NAME = 'grafana';
export const GRAFANA_DATASOURCE_NAME = '-- Grafana --'; export const GRAFANA_DATASOURCE_NAME = '-- Grafana --';
export type RulesSourceIdentifier = { rulesSourceName: string } | { uid: string };
export enum DataSourceType { export enum DataSourceType {
Alertmanager = 'alertmanager', Alertmanager = 'alertmanager',
Loki = 'loki', Loki = 'loki',
@ -39,12 +40,15 @@ export interface AlertManagerDataSource {
export const RulesDataSourceTypes: string[] = [DataSourceType.Loki, DataSourceType.Prometheus]; export const RulesDataSourceTypes: string[] = [DataSourceType.Loki, DataSourceType.Prometheus];
export function getRulesDataSources() { 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 [];
} }
return getAllDataSources() 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)); .sort((a, b) => a.name.localeCompare(b.name));
} }
@ -56,6 +60,10 @@ export function getRulesDataSource(rulesSourceName: string) {
return getRulesDataSources().find((x) => x.name === rulesSourceName); return getRulesDataSources().find((x) => x.name === rulesSourceName);
} }
export function getRulesDataSourceByUID(uid: string) {
return getRulesDataSources().find((x) => x.uid === uid);
}
export function getAlertManagerDataSources() { export function getAlertManagerDataSources() {
return getAllDataSources() return getAllDataSources()
.filter(isAlertmanagerDataSourceInstance) .filter(isAlertmanagerDataSourceInstance)
@ -203,7 +211,7 @@ export function getAllRulesSources(): RulesSource[] {
const availableRulesSources: RulesSource[] = getRulesDataSources(); const availableRulesSources: RulesSource[] = getRulesDataSources();
if (contextSrv.hasPermission(AccessControlAction.AlertingRuleRead)) { if (contextSrv.hasPermission(AccessControlAction.AlertingRuleRead)) {
availableRulesSources.push(GRAFANA_RULES_SOURCE_NAME); availableRulesSources.unshift(GRAFANA_RULES_SOURCE_NAME);
} }
return availableRulesSources; return availableRulesSources;
@ -242,6 +250,10 @@ export function getDataSourceByName(name: string): DataSourceInstanceSettings<Da
return getAllDataSources().find((source) => source.name === name); 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) { export function getAlertmanagerDataSourceByName(name: string) {
return getAllDataSources() return getAllDataSources()
.filter(isAlertmanagerDataSourceInstance) .filter(isAlertmanagerDataSourceInstance)
@ -277,6 +289,22 @@ export function getDatasourceAPIUid(dataSourceName: string) {
return ds.uid; 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 { export function getFirstCompatibleDataSource(): DataSourceInstanceSettings<DataSourceJsonData> | undefined {
return getDataSourceSrv().getList({ alerting: true })[0]; return getDataSourceSrv().getList({ alerting: true })[0];
} }
@ -291,20 +319,3 @@ export function getDefaultOrFirstCompatibleDataSource(): DataSourceInstanceSetti
export function isDataSourceManagingAlerts(ds: DataSourceInstanceSettings<DataSourceJsonData>) { export function isDataSourceManagingAlerts(ds: DataSourceInstanceSettings<DataSourceJsonData>) {
return ds.jsonData.manageAlerts !== false; //if this prop is undefined it defaults to true 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
}

View File

@ -1,3 +1,5 @@
import { isEmpty } from 'lodash';
import { Labels } from '../../../../types/unified-alerting-dto'; import { Labels } from '../../../../types/unified-alerting-dto';
import { Label } from '../components/rules/state-history/common'; import { Label } from '../components/rules/state-history/common';
@ -35,7 +37,11 @@ export function arrayKeyValuesToObject(
export const GRAFANA_ORIGIN_LABEL = '__grafana_origin'; 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; return Object.keys(labels).filter((key) => !isPrivateLabelKey(key)).length;
} }

View File

@ -6,9 +6,21 @@ import { config, isFetchError } from '@grafana/runtime';
import { DataSourceRef } from '@grafana/schema'; import { DataSourceRef } from '@grafana/schema';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { escapePathSeparators } from 'app/features/alerting/unified/utils/rule-id'; 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 { 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 { import {
GrafanaAlertState, GrafanaAlertState,
PromAlertingRuleState, PromAlertingRuleState,
@ -16,7 +28,7 @@ import {
} from 'app/types/unified-alerting-dto'; } from 'app/types/unified-alerting-dto';
import { ALERTMANAGER_NAME_QUERY_KEY } from './constants'; import { ALERTMANAGER_NAME_QUERY_KEY } from './constants';
import { getRulesSourceName, isCloudRulesSource } from './datasource'; import { getRulesSourceName } from './datasource';
import { getMatcherQueryParams } from './matchers'; import { getMatcherQueryParams } from './matchers';
import * as ruleId from './rule-id'; import * as ruleId from './rule-id';
import { createAbsoluteUrl, createRelativeUrl } from './url'; import { createAbsoluteUrl, createRelativeUrl } from './url';
@ -67,13 +79,13 @@ export function createMuteTimingLink(muteTimingName: string, alertManagerSourceN
}); });
} }
export function createShareLink(ruleSource: RulesSource, rule: CombinedRule): string | undefined { export function createShareLink(ruleIdentifier: RuleIdentifier): string | undefined {
if (isCloudRulesSource(ruleSource)) { if (isCloudRuleIdentifier(ruleIdentifier) || isPrometheusRuleIdentifier(ruleIdentifier)) {
return createAbsoluteUrl( 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)) { } else if (isGrafanaRuleIdentifier(ruleIdentifier)) {
return createAbsoluteUrl(`/alerting/grafana/${rule.rulerRule.grafana_alert.uid}/view`); return createAbsoluteUrl(`/alerting/grafana/${ruleIdentifier.uid}/view`);
} }
return; return;

View File

@ -7,6 +7,7 @@ import {
mockCombinedRule, mockCombinedRule,
mockCombinedRuleGroup, mockCombinedRuleGroup,
mockGrafanaRulerRule, mockGrafanaRulerRule,
mockPromAlertingRule,
mockRuleWithLocation, mockRuleWithLocation,
mockRulerAlertingRule, mockRulerAlertingRule,
} from '../mocks'; } from '../mocks';
@ -20,21 +21,21 @@ import {
describe('getRuleOrigin', () => { describe('getRuleOrigin', () => {
it('returns undefined when no origin label is present', () => { it('returns undefined when no origin label is present', () => {
const rule = mockCombinedRule({ const rule = mockPromAlertingRule({
labels: {}, labels: {},
}); });
expect(getRulePluginOrigin(rule)).toBeUndefined(); expect(getRulePluginOrigin(rule)).toBeUndefined();
}); });
it('returns undefined when origin label does not match expected format', () => { it('returns undefined when origin label does not match expected format', () => {
const rule = mockCombinedRule({ const rule = mockPromAlertingRule({
labels: { [GRAFANA_ORIGIN_LABEL]: 'invalid_format' }, labels: { [GRAFANA_ORIGIN_LABEL]: 'invalid_format' },
}); });
expect(getRulePluginOrigin(rule)).toBeUndefined(); expect(getRulePluginOrigin(rule)).toBeUndefined();
}); });
it('returns undefined when plugin is not installed', () => { it('returns undefined when plugin is not installed', () => {
const rule = mockCombinedRule({ const rule = mockPromAlertingRule({
labels: { [GRAFANA_ORIGIN_LABEL]: 'plugin/uninstalled_plugin' }, labels: { [GRAFANA_ORIGIN_LABEL]: 'plugin/uninstalled_plugin' },
}); });
expect(getRulePluginOrigin(rule)).toBeUndefined(); expect(getRulePluginOrigin(rule)).toBeUndefined();
@ -64,7 +65,7 @@ describe('getRuleOrigin', () => {
}, },
}, },
}; };
const rule = mockCombinedRule({ const rule = mockPromAlertingRule({
labels: { [GRAFANA_ORIGIN_LABEL]: 'plugin/installed_plugin' }, labels: { [GRAFANA_ORIGIN_LABEL]: 'plugin/installed_plugin' },
}); });
expect(getRulePluginOrigin(rule)).toEqual({ pluginId: 'installed_plugin' }); expect(getRulePluginOrigin(rule)).toEqual({ pluginId: 'installed_plugin' });

View File

@ -176,10 +176,12 @@ export interface RulePluginOrigin {
pluginId: string; pluginId: string;
} }
export function getRulePluginOrigin(rule: CombinedRule): RulePluginOrigin | undefined { export function getRulePluginOrigin(rule?: Rule | RulerRuleDTO): RulePluginOrigin | undefined {
// com.grafana.origin=plugin/<plugin-identifier> if (!rule) {
// Prom and Mimir do not support dots in label names 😔 return undefined;
const origin = rule.labels[GRAFANA_ORIGIN_LABEL]; }
const origin = rule.labels?.[GRAFANA_ORIGIN_LABEL];
if (!origin) { if (!origin) {
return undefined; return undefined;
} }
@ -203,7 +205,7 @@ function isPluginInstalled(pluginId: string) {
return Boolean(config.apps[pluginId]); return Boolean(config.apps[pluginId]);
} }
export function isPluginProvidedRule(rule: CombinedRule): boolean { export function isPluginProvidedRule(rule?: Rule | RulerRuleDTO): boolean {
return Boolean(getRulePluginOrigin(rule)); return Boolean(getRulePluginOrigin(rule));
} }

View File

@ -77,7 +77,7 @@ export enum PromApplication {
Thanos = 'Thanos', Thanos = 'Thanos',
} }
export type RulesSourceApplication = PromApplication | 'loki' | 'grafana'; export type RulesSourceApplication = PromApplication | 'Loki' | 'grafana';
export interface PromBuildInfoResponse { export interface PromBuildInfoResponse {
data: { data: {
@ -96,7 +96,7 @@ export interface PromBuildInfoResponse {
} }
export interface PromApiFeatures { export interface PromApiFeatures {
application?: PromApplication; application: RulesSourceApplication;
features: { features: {
rulerApiEnabled: boolean; rulerApiEnabled: boolean;
}; };

View File

@ -280,6 +280,10 @@
"success": "Successfully updated rule group" "success": "Successfully updated rule group"
} }
}, },
"rule-list": {
"configure-datasource": "Configure",
"new-alert-rule": "New alert rule"
},
"rule-state": { "rule-state": {
"creating": "Creating", "creating": "Creating",
"deleting": "Deleting", "deleting": "Deleting",

View File

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