@oneuptime/common 14.0.0 → 14.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/Models/DatabaseModels/Index.ts +4 -0
  2. package/Models/DatabaseModels/ServiceLevelObjectiveFeed.ts +2 -0
  3. package/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.ts +522 -0
  4. package/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.ts +38 -4
  5. package/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.ts +603 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.ts +248 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.ts +19 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  9. package/Server/Services/DatabaseService.ts +10 -0
  10. package/Server/Services/Index.ts +4 -0
  11. package/Server/Services/MonitorService.ts +3 -2
  12. package/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.ts +385 -0
  13. package/Server/Services/ServiceLevelObjectiveLabelRuleService.ts +51 -0
  14. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +92 -16
  15. package/Server/Services/ServiceLevelObjectiveMonitorRuleService.ts +36 -1
  16. package/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.ts +521 -0
  17. package/Server/Services/ServiceLevelObjectiveOwnerRuleService.ts +51 -0
  18. package/Server/Services/ServiceLevelObjectiveService.ts +28 -0
  19. package/Server/Types/Workflow/Components/Telegram/SendMessageToChat.ts +27 -1
  20. package/Server/Utils/AI/Toolbox/SloTools.ts +1 -0
  21. package/Server/Utils/Monitor/AffectedResourceList.ts +31 -73
  22. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +1 -1
  23. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +1 -1
  24. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +181 -136
  25. package/Server/Utils/Monitor/RootCauseList.ts +139 -0
  26. package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +28 -0
  27. package/Server/Utils/Slo/SloRulePatternValidator.ts +42 -0
  28. package/Tests/App/Dashboard/FeedFilterSortIntegration.test.tsx +1632 -0
  29. package/Tests/App/Dashboard/FeedOptionsLocales.test.ts +250 -0
  30. package/Tests/App/Dashboard/FeedOrdering.test.ts +279 -9
  31. package/Tests/App/Dashboard/InvestigationFeedRefresh.test.tsx +60 -1
  32. package/Tests/App/Dashboard/InvestigationPanel.test.tsx +594 -3
  33. package/Tests/App/Dashboard/ProductOrientation.test.tsx +646 -0
  34. package/Tests/App/Dashboard/ResourceFeed.test.tsx +612 -2
  35. package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +30 -0
  36. package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +1202 -0
  37. package/Tests/App/Dashboard/ScheduledMaintenanceFeedRefresh.test.tsx +52 -1
  38. package/Tests/App/Dashboard/SloFeed.test.tsx +106 -1
  39. package/Tests/App/Dashboard/SloMonitorRuleCriteria.test.tsx +566 -0
  40. package/Tests/App/Dashboard/SloNavigationWiring.test.tsx +6 -4
  41. package/Tests/App/Dashboard/SloNoticeBanner.test.tsx +186 -0
  42. package/Tests/App/Dashboard/SloOverviewKpiStrip.test.tsx +388 -44
  43. package/Tests/App/Dashboard/SloOverviewLayout.test.tsx +613 -0
  44. package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +0 -8
  45. package/Tests/Models/DatabaseModels/SloLabelAndOwnerRuleModels.test.ts +404 -0
  46. package/Tests/Models/DatabaseModels/SloOverhaulModels.test.ts +17 -1
  47. package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
  48. package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
  49. package/Tests/Server/Infrastructure/Postgres/AddSloLabelAndOwnerRulesMigration.test.ts +407 -0
  50. package/Tests/Server/Infrastructure/Postgres/AddSloMonitorRuleMonitorTypeMigration.test.ts +171 -0
  51. package/Tests/Server/Infrastructure/Postgres/SloProductOverhaulMigration.test.ts +88 -4
  52. package/Tests/Server/Services/DatabaseServiceRuleCriteriaValidation.test.ts +104 -0
  53. package/Tests/Server/Services/RuleRun/LabelRuleEnginesGroupBRun.test.ts +48 -1
  54. package/Tests/Server/Services/RuleRun/OwnerRuleEnginesGroupBRun.test.ts +30 -0
  55. package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleEngines.test.ts +1170 -0
  56. package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleServices.test.ts +172 -0
  57. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +323 -0
  58. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleService.test.ts +115 -0
  59. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleTriggers.test.ts +21 -2
  60. package/Tests/Server/Services/ServiceLevelObjectiveMonitorTypePostgres.test.ts +480 -0
  61. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +162 -0
  62. package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +45 -6
  63. package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +745 -0
  64. package/Tests/Server/Types/Markdown.test.ts +2 -1
  65. package/Tests/Server/Types/Workflow/Components/ApiComponentSsrf.test.ts +49 -0
  66. package/Tests/Server/Types/Workflow/Components/TelegramSendMessageToChat.test.ts +331 -0
  67. package/Tests/Server/Utils/AI/SloTools.test.ts +10 -0
  68. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +1 -1
  69. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +405 -0
  70. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorBreachingSamples.test.ts +1101 -0
  71. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +106 -80
  72. package/Tests/Server/Utils/Monitor/RootCauseList.test.ts +252 -0
  73. package/Tests/Server/Utils/Rules/RuleRun/RuleRunPermission.test.ts +154 -0
  74. package/Tests/Server/Utils/SSRFProtection.test.ts +111 -0
  75. package/Tests/Server/Utils/Slo/SloRulePatternValidator.test.ts +67 -0
  76. package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +213 -0
  77. package/Tests/Types/API/Route.test.ts +56 -0
  78. package/Tests/Types/API/URLSchemeLikePath.test.ts +304 -0
  79. package/Tests/UI/Components/FeedOptions.test.ts +1404 -0
  80. package/Tests/UI/Components/FeedOptionsButton.test.tsx +2603 -0
  81. package/Tests/UI/Components/Icon.test.tsx +26 -0
  82. package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
  83. package/Tests/UI/Components/useFeedItemsViewKey.test.tsx +1017 -0
  84. package/Tests/UI/Components/useFeedOptions.test.tsx +1107 -0
  85. package/Tests/Utils/LabelRuleImportExport.test.ts +102 -0
  86. package/Tests/Utils/Slo/SloDuration.test.ts +115 -24
  87. package/Tests/Utils/Slo/SloHealth.test.ts +93 -10
  88. package/Tests/Utils/Slo/SloMonitorRuleCriteria.test.ts +109 -0
  89. package/Tests/Utils/Slo/SloOverviewText.test.ts +64 -0
  90. package/Types/API/Route.ts +19 -2
  91. package/Types/API/URL.ts +26 -2
  92. package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +6 -5
  93. package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +9 -0
  94. package/Types/Permission.ts +90 -0
  95. package/Types/Rules/RuleCriteriaFieldRegistry.ts +11 -0
  96. package/Types/Rules/RuleRun.ts +4 -0
  97. package/UI/Components/Feed/FeedOptions.ts +427 -0
  98. package/UI/Components/Feed/FeedOptionsButton.tsx +692 -0
  99. package/UI/Components/Feed/useFeedItems.ts +28 -9
  100. package/UI/Components/Feed/useFeedOptions.ts +225 -0
  101. package/UI/Components/Icon/Icon.tsx +1 -1
  102. package/Utils/LabelRuleImportExport.ts +27 -9
  103. package/Utils/Monitor/MetricValueFormatter.ts +2 -2
  104. package/Utils/Rules/MonitorTypeRuleCriteria.ts +31 -0
  105. package/Utils/Slo/SloDuration.ts +22 -6
  106. package/Utils/Slo/SloHealth.ts +8 -7
  107. package/Utils/Slo/SloMonitorRuleCriteria.ts +33 -1
  108. package/Utils/Slo/SloOverviewText.ts +33 -0
  109. package/Utils/Slo/SloProjection.ts +4 -3
  110. package/build/dist/Models/DatabaseModels/Index.js +4 -0
  111. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  112. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js +2 -0
  113. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js.map +1 -1
  114. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js +530 -0
  115. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js.map +1 -0
  116. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js +39 -4
  117. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js.map +1 -1
  118. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js +612 -0
  119. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js.map +1 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js +93 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js.map +1 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.js +12 -0
  123. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.js.map +1 -0
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  126. package/build/dist/Server/Services/DatabaseService.js +7 -0
  127. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  128. package/build/dist/Server/Services/Index.js +4 -0
  129. package/build/dist/Server/Services/Index.js.map +1 -1
  130. package/build/dist/Server/Services/MonitorService.js +3 -2
  131. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  132. package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js +295 -0
  133. package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js.map +1 -0
  134. package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js +56 -0
  135. package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js.map +1 -0
  136. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +59 -14
  137. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -1
  138. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js +22 -1
  139. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js.map +1 -1
  140. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js +400 -0
  141. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js.map +1 -0
  142. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js +56 -0
  143. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js.map +1 -0
  144. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +21 -0
  145. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  146. package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js +20 -1
  147. package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js.map +1 -1
  148. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +1 -0
  149. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -1
  150. package/build/dist/Server/Utils/Monitor/AffectedResourceList.js +22 -51
  151. package/build/dist/Server/Utils/Monitor/AffectedResourceList.js.map +1 -1
  152. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +1 -1
  153. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  154. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +139 -109
  155. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  156. package/build/dist/Server/Utils/Monitor/RootCauseList.js +101 -0
  157. package/build/dist/Server/Utils/Monitor/RootCauseList.js.map +1 -0
  158. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +28 -0
  159. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
  160. package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js +28 -0
  161. package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js.map +1 -0
  162. package/build/dist/Types/API/Route.js +18 -2
  163. package/build/dist/Types/API/Route.js.map +1 -1
  164. package/build/dist/Types/API/URL.js +23 -2
  165. package/build/dist/Types/API/URL.js.map +1 -1
  166. package/build/dist/Types/Permission.js +84 -0
  167. package/build/dist/Types/Permission.js.map +1 -1
  168. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +11 -0
  169. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
  170. package/build/dist/Types/Rules/RuleRun.js +4 -0
  171. package/build/dist/Types/Rules/RuleRun.js.map +1 -1
  172. package/build/dist/UI/Components/Feed/FeedOptions.js +235 -0
  173. package/build/dist/UI/Components/Feed/FeedOptions.js.map +1 -0
  174. package/build/dist/UI/Components/Feed/FeedOptionsButton.js +346 -0
  175. package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -0
  176. package/build/dist/UI/Components/Feed/useFeedItems.js +17 -7
  177. package/build/dist/UI/Components/Feed/useFeedItems.js.map +1 -1
  178. package/build/dist/UI/Components/Feed/useFeedOptions.js +130 -0
  179. package/build/dist/UI/Components/Feed/useFeedOptions.js.map +1 -0
  180. package/build/dist/UI/Components/Icon/Icon.js +1 -1
  181. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  182. package/build/dist/Utils/LabelRuleImportExport.js +22 -6
  183. package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
  184. package/build/dist/Utils/Monitor/MetricValueFormatter.js +2 -2
  185. package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js +18 -0
  186. package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js.map +1 -0
  187. package/build/dist/Utils/Slo/SloDuration.js +13 -5
  188. package/build/dist/Utils/Slo/SloDuration.js.map +1 -1
  189. package/build/dist/Utils/Slo/SloHealth.js +8 -7
  190. package/build/dist/Utils/Slo/SloHealth.js.map +1 -1
  191. package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js +16 -1
  192. package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js.map +1 -1
  193. package/build/dist/Utils/Slo/SloOverviewText.js +16 -0
  194. package/build/dist/Utils/Slo/SloOverviewText.js.map +1 -1
  195. package/build/dist/Utils/Slo/SloProjection.js.map +1 -1
  196. package/package.json +1 -1
@@ -0,0 +1,250 @@
1
+ import {
2
+ FEED_OPTIONS_TEXT,
3
+ FEED_SORT_ORDER_OPTIONS,
4
+ FILTERED_FEED_NO_ITEMS_MESSAGE,
5
+ FeedSortOrderOption,
6
+ getFeedEventTypeLabel,
7
+ } from "../../../UI/Components/Feed/FeedOptions";
8
+ import {
9
+ DEFAULT_DASHBOARD_LANGUAGE,
10
+ SUPPORTED_DASHBOARD_LANGUAGE_CODES,
11
+ } from "../../../Types/Dashboard/DashboardLanguage";
12
+ import { describe, expect, test } from "@jest/globals";
13
+ import fs from "fs";
14
+ import path from "path";
15
+
16
+ /*
17
+ * The "Filter & Sort" control on every dashboard activity feed looks each of
18
+ * its strings up in the Dashboard locale files by its English text - its own
19
+ * sentences, the two sort choices, the filtered empty state, and the label of
20
+ * every event type in the checklist. A string with no entry silently stays
21
+ * English, and a translation that drops or renames a {{placeholder}} shows a
22
+ * raw brace or a missing number. This pins the English keys to what the code
23
+ * renders and every other language to having a usable translation of each.
24
+ *
25
+ * The event types are discovered from the feed models on disk, not listed
26
+ * here, so a feed model or event type added later needs its label translated
27
+ * before this passes.
28
+ */
29
+
30
+ const COMMON_ROOT: string = path.resolve(__dirname, "..", "..", "..");
31
+
32
+ const MODELS_DIRECTORY: string = path.join(
33
+ COMMON_ROOT,
34
+ "Models",
35
+ "DatabaseModels",
36
+ );
37
+
38
+ const DASHBOARD_LOCALES: string = path.join(
39
+ COMMON_ROOT,
40
+ "..",
41
+ "App",
42
+ "FeatureSet",
43
+ "Dashboard",
44
+ "src",
45
+ "Locales",
46
+ );
47
+
48
+ // The sentence the trigger used as its accessible name before it was retired.
49
+ const RETIRED_KEY: string = "Filter and sort feed: {{summary}}";
50
+
51
+ const FEED_EVENT_TYPE_ENUM_PATTERN: RegExp =
52
+ /export enum (\w+FeedEventType)\b/g;
53
+
54
+ const PLACEHOLDER_PATTERN: RegExp = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g;
55
+
56
+ interface FeedEventTypeEnum {
57
+ model: string;
58
+ enumName: string;
59
+ values: Array<string>;
60
+ }
61
+
62
+ type FindFeedEventTypeEnums = () => Array<FeedEventTypeEnum>;
63
+
64
+ /*
65
+ * Every *FeedEventType enum under Models/DatabaseModels, with the values the
66
+ * model module actually exports (loaded, not parsed, so a value written in an
67
+ * unusual way cannot be misread).
68
+ */
69
+ const findFeedEventTypeEnums: FindFeedEventTypeEnums =
70
+ (): Array<FeedEventTypeEnum> => {
71
+ const found: Array<FeedEventTypeEnum> = [];
72
+
73
+ for (const entry of fs.readdirSync(MODELS_DIRECTORY, {
74
+ withFileTypes: true,
75
+ })) {
76
+ if (!entry.isFile() || !entry.name.endsWith(".ts")) {
77
+ continue;
78
+ }
79
+
80
+ const source: string = fs.readFileSync(
81
+ path.join(MODELS_DIRECTORY, entry.name),
82
+ "utf8",
83
+ );
84
+ const enumNames: Array<string> = Array.from(
85
+ source.matchAll(FEED_EVENT_TYPE_ENUM_PATTERN),
86
+ (match: RegExpMatchArray): string => {
87
+ return match[1]!;
88
+ },
89
+ );
90
+
91
+ if (enumNames.length === 0) {
92
+ continue;
93
+ }
94
+
95
+ const model: string = entry.name.replace(/\.ts$/, "");
96
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
97
+ const exported: Record<string, unknown> = require(
98
+ path.join(MODELS_DIRECTORY, model),
99
+ );
100
+
101
+ for (const enumName of enumNames) {
102
+ const eventTypeEnum: unknown = exported[enumName];
103
+
104
+ found.push({
105
+ model,
106
+ enumName,
107
+ values:
108
+ eventTypeEnum && typeof eventTypeEnum === "object"
109
+ ? Object.values(eventTypeEnum as Record<string, string>)
110
+ : [],
111
+ });
112
+ }
113
+ }
114
+
115
+ return found;
116
+ };
117
+
118
+ type ReadLocale = (fileName: string) => Record<string, unknown>;
119
+
120
+ const readLocale: ReadLocale = (fileName: string): Record<string, unknown> => {
121
+ return JSON.parse(
122
+ fs.readFileSync(path.join(DASHBOARD_LOCALES, fileName), "utf8"),
123
+ ) as Record<string, unknown>;
124
+ };
125
+
126
+ type GetPlaceholders = (text: string) => Array<string>;
127
+
128
+ // The placeholder names in a string, repeats kept, in a stable order.
129
+ const getPlaceholders: GetPlaceholders = (text: string): Array<string> => {
130
+ return Array.from(
131
+ text.matchAll(PLACEHOLDER_PATTERN),
132
+ (match: RegExpMatchArray): string => {
133
+ return match[1]!;
134
+ },
135
+ ).sort();
136
+ };
137
+
138
+ type Unique = (values: Array<string>) => Array<string>;
139
+
140
+ const unique: Unique = (values: Array<string>): Array<string> => {
141
+ return Array.from(new Set<string>(values));
142
+ };
143
+
144
+ const FEED_EVENT_TYPE_ENUMS: Array<FeedEventTypeEnum> =
145
+ findFeedEventTypeEnums();
146
+
147
+ // The control's own words, exactly as it passes them to the translator.
148
+ const CONTROL_KEYS: Array<string> = unique([
149
+ ...Object.values(FEED_OPTIONS_TEXT),
150
+ ...FEED_SORT_ORDER_OPTIONS.map((option: FeedSortOrderOption): string => {
151
+ return option.label;
152
+ }),
153
+ FILTERED_FEED_NO_ITEMS_MESSAGE,
154
+ ]);
155
+
156
+ // The checklist's labels, which are translated by their English text too.
157
+ const EVENT_TYPE_LABEL_KEYS: Array<string> = unique(
158
+ FEED_EVENT_TYPE_ENUMS.flatMap((eventTypeEnum: FeedEventTypeEnum) => {
159
+ return eventTypeEnum.values.map((value: string): string => {
160
+ return getFeedEventTypeLabel(value);
161
+ });
162
+ }),
163
+ );
164
+
165
+ const ALL_KEYS: Array<string> = unique([
166
+ ...CONTROL_KEYS,
167
+ ...EVENT_TYPE_LABEL_KEYS,
168
+ ]);
169
+
170
+ const OTHER_LANGUAGES: Array<string> =
171
+ SUPPORTED_DASHBOARD_LANGUAGE_CODES.filter((code: string): boolean => {
172
+ return code !== DEFAULT_DASHBOARD_LANGUAGE;
173
+ });
174
+
175
+ describe("Filter & Sort translations in the Dashboard locales", () => {
176
+ test("the feed models on disk were found and loaded", () => {
177
+ // An empty or unloaded scan would make every check below vacuous.
178
+ expect(FEED_EVENT_TYPE_ENUMS.length).toBeGreaterThan(0);
179
+
180
+ for (const eventTypeEnum of FEED_EVENT_TYPE_ENUMS) {
181
+ expect({
182
+ enumName: eventTypeEnum.enumName,
183
+ loaded: eventTypeEnum.values.length > 0,
184
+ }).toEqual({ enumName: eventTypeEnum.enumName, loaded: true });
185
+ }
186
+
187
+ expect(OTHER_LANGUAGES.length).toBeGreaterThan(0);
188
+ });
189
+
190
+ test("English maps every string the control shows to itself", () => {
191
+ const english: Record<string, unknown> = readLocale(
192
+ `${DEFAULT_DASHBOARD_LANGUAGE}.json`,
193
+ );
194
+
195
+ const missing: Array<string> = ALL_KEYS.filter((key: string): boolean => {
196
+ return english[key] !== key;
197
+ });
198
+
199
+ expect(missing).toEqual([]);
200
+ });
201
+
202
+ test.each(OTHER_LANGUAGES)(
203
+ "%s translates every string the control shows, keeping its placeholders",
204
+ (language: string) => {
205
+ const locale: Record<string, unknown> = readLocale(`${language}.json`);
206
+ const problems: Array<string> = [];
207
+
208
+ for (const key of ALL_KEYS) {
209
+ const value: unknown = locale[key];
210
+
211
+ if (typeof value !== "string" || value.trim().length === 0) {
212
+ problems.push(`missing: ${key}`);
213
+ continue;
214
+ }
215
+
216
+ const expected: string = getPlaceholders(key).join(",");
217
+ const actual: string = getPlaceholders(value).join(",");
218
+
219
+ if (actual !== expected) {
220
+ problems.push(
221
+ `placeholders {${actual}} instead of {${expected}}: ${key}`,
222
+ );
223
+ }
224
+ }
225
+
226
+ expect(problems).toEqual([]);
227
+ },
228
+ );
229
+
230
+ test("the retired accessible-name sentence is in no locale file", () => {
231
+ const localeFiles: Array<string> = fs
232
+ .readdirSync(DASHBOARD_LOCALES)
233
+ .filter((fileName: string): boolean => {
234
+ return fileName.endsWith(".json");
235
+ });
236
+
237
+ expect(localeFiles.length).toBeGreaterThan(1);
238
+
239
+ const stillThere: Array<string> = localeFiles.filter(
240
+ (fileName: string): boolean => {
241
+ return Object.prototype.hasOwnProperty.call(
242
+ readLocale(fileName),
243
+ RETIRED_KEY,
244
+ );
245
+ },
246
+ );
247
+
248
+ expect(stillThere).toEqual([]);
249
+ });
250
+ });
@@ -7,6 +7,22 @@ interface FeedImplementation {
7
7
  relativePath: string;
8
8
  }
9
9
 
10
+ /*
11
+ * How one feed connects its Filter & Sort state to the request it makes. The
12
+ * checks below compare against the name the feed itself gave the
13
+ * useFeedOptions() result, and look inside the two hook calls rather than
14
+ * the whole file, so they survive renames and reformatting but not a wire
15
+ * that goes to the wrong place.
16
+ */
17
+ interface FeedOptionsWiring {
18
+ // What the feed called its useFeedOptions() result.
19
+ hook: string;
20
+ // The arguments of the useFeedOptions() call.
21
+ optionsCall: string;
22
+ // The arguments of the useFeedItems() call: the request the feed makes.
23
+ itemsCall: string;
24
+ }
25
+
10
26
  const DASHBOARD_COMPONENTS: string = path.join(
11
27
  __dirname,
12
28
  "..",
@@ -62,14 +78,30 @@ const FEED_IMPLEMENTATIONS: Array<FeedImplementation> = [
62
78
  },
63
79
  ];
64
80
 
65
- function readSource(implementation: FeedImplementation): string {
81
+ /*
82
+ * Ends at the call's opening parenthesis, so the same pattern both names the
83
+ * result and locates the call's arguments.
84
+ */
85
+ const FEED_OPTIONS_HOOK_CALL: RegExp =
86
+ /const\s+(\w+)\s*:\s*UseFeedOptionsResult\s*=\s*useFeedOptions\s*\(/;
87
+
88
+ const FEED_ITEMS_HOOK_CALL: RegExp = /\buseFeedItems\s*(?:<[^<>()]*>)?\s*\(/;
89
+
90
+ // A storage key written as a string literal ("incident"), not an expression.
91
+ const QUOTED_STRING_LITERAL: RegExp = /^(["'`])[^"'`]*\1$/;
92
+
93
+ const readSource: (implementation: FeedImplementation) => string = (
94
+ implementation: FeedImplementation,
95
+ ): string => {
66
96
  return fs.readFileSync(
67
97
  path.join(DASHBOARD_COMPONENTS, implementation.relativePath),
68
98
  "utf8",
69
99
  );
70
- }
100
+ };
71
101
 
72
- function findFeedImplementationPaths(directory: string): Array<string> {
102
+ const findFeedImplementationPaths: (directory: string) => Array<string> = (
103
+ directory: string,
104
+ ): Array<string> => {
73
105
  return fs
74
106
  .readdirSync(directory, { withFileTypes: true })
75
107
  .flatMap((entry: fs.Dirent): Array<string> => {
@@ -95,7 +127,104 @@ function findFeedImplementationPaths(directory: string): Array<string> {
95
127
  .join("/"),
96
128
  ];
97
129
  });
98
- }
130
+ };
131
+
132
+ /*
133
+ * The text between the parentheses of the first call that `callee` finds (a
134
+ * pattern ending at the opening parenthesis). Strings and comments are
135
+ * skipped, so a bracket inside one cannot end the call early.
136
+ */
137
+ const getCallArguments: (source: string, callee: RegExp) => string = (
138
+ source: string,
139
+ callee: RegExp,
140
+ ): string => {
141
+ const match: RegExpExecArray | null = callee.exec(source);
142
+
143
+ if (!match) {
144
+ throw new Error(`No call matching ${callee.toString()}`);
145
+ }
146
+
147
+ const openIndex: number = match.index + match[0].length - 1;
148
+ let depth: number = 0;
149
+ let index: number = openIndex;
150
+
151
+ while (index < source.length) {
152
+ const character: string = source[index]!;
153
+ const nextCharacter: string | undefined = source[index + 1];
154
+
155
+ if (character === "/" && nextCharacter === "*") {
156
+ index = source.indexOf("*/", index + 2) + 2;
157
+ continue;
158
+ }
159
+
160
+ if (character === "/" && nextCharacter === "/") {
161
+ const lineEnd: number = source.indexOf("\n", index);
162
+ index = lineEnd === -1 ? source.length : lineEnd;
163
+ continue;
164
+ }
165
+
166
+ if (character === '"' || character === "'" || character === "`") {
167
+ index++;
168
+
169
+ while (index < source.length && source[index] !== character) {
170
+ index += source[index] === "\\" ? 2 : 1;
171
+ }
172
+
173
+ index++;
174
+ continue;
175
+ }
176
+
177
+ if ("([{".includes(character)) {
178
+ depth++;
179
+ } else if (")]}".includes(character)) {
180
+ depth--;
181
+
182
+ if (depth === 0) {
183
+ return source.slice(openIndex + 1, index);
184
+ }
185
+ }
186
+
187
+ index++;
188
+ }
189
+
190
+ throw new Error(`Unbalanced call matching ${callee.toString()}`);
191
+ };
192
+
193
+ /*
194
+ * A property's value expression inside a call's arguments, with whitespace
195
+ * removed so that line breaks never decide whether two expressions match.
196
+ */
197
+ const getPropertyExpression: (
198
+ callArguments: string,
199
+ property: string,
200
+ ) => string | undefined = (
201
+ callArguments: string,
202
+ property: string,
203
+ ): string | undefined => {
204
+ const match: RegExpMatchArray | null = callArguments.match(
205
+ new RegExp(String.raw`\b${property}\s*:\s*([^,}]+?)\s*[,}]`),
206
+ );
207
+
208
+ return match ? match[1]!.replace(/\s+/g, "") : undefined;
209
+ };
210
+
211
+ const getFeedOptionsWiring: (source: string) => FeedOptionsWiring = (
212
+ source: string,
213
+ ): FeedOptionsWiring => {
214
+ const hookMatch: RegExpMatchArray | null = source.match(
215
+ FEED_OPTIONS_HOOK_CALL,
216
+ );
217
+
218
+ if (!hookMatch) {
219
+ throw new Error("The feed does not keep a UseFeedOptionsResult");
220
+ }
221
+
222
+ return {
223
+ hook: hookMatch[1]!,
224
+ optionsCall: getCallArguments(source, FEED_OPTIONS_HOOK_CALL),
225
+ itemsCall: getCallArguments(source, FEED_ITEMS_HOOK_CALL),
226
+ };
227
+ };
99
228
 
100
229
  describe("dashboard feed ordering parity", () => {
101
230
  test("tracks every feed implementation", () => {
@@ -110,20 +239,161 @@ describe("dashboard feed ordering parity", () => {
110
239
  );
111
240
  });
112
241
 
242
+ /*
243
+ * The order is the reader's choice from the Filter & Sort button, and it
244
+ * is applied by the API. Reversing or re-sorting the loaded window in the
245
+ * browser would show the newest ten upside down instead of the oldest ten.
246
+ */
113
247
  test.each(FEED_IMPLEMENTATIONS)(
114
- "$name requests the newest activity first",
248
+ "$name requests activity in the reader's chosen order",
115
249
  (implementation: FeedImplementation) => {
116
250
  const source: string = readSource(implementation);
251
+ const wiring: FeedOptionsWiring = getFeedOptionsWiring(source);
117
252
 
118
- expect(source).toContain("postedAt: SortOrder.Descending");
119
- expect(source).not.toContain("postedAt: SortOrder.Ascending");
253
+ expect(wiring.itemsCall).toMatch(
254
+ new RegExp(
255
+ String.raw`\bpostedAt\s*:\s*${wiring.hook}\.options\.sortOrder\b`,
256
+ ),
257
+ );
258
+ expect(source).not.toMatch(/\bpostedAt\s*:\s*SortOrder\./);
120
259
  expect(source).not.toContain(".reverse()");
121
- expect(source).toContain("skip: 0");
122
- expect(source).toContain("limit,");
260
+ expect(wiring.itemsCall).toMatch(/\bskip\s*:\s*0\b/);
261
+ expect(wiring.itemsCall).toMatch(/\blimit\s*,/);
123
262
  expect(source).not.toContain("LIMIT_PER_PROJECT");
124
263
  },
125
264
  );
126
265
 
266
+ test.each(FEED_IMPLEMENTATIONS)(
267
+ "$name offers Filter & Sort as the first header control and filters through the API",
268
+ (implementation: FeedImplementation) => {
269
+ const source: string = readSource(implementation);
270
+ const wiring: FeedOptionsWiring = getFeedOptionsWiring(source);
271
+ const hook: string = wiring.hook;
272
+
273
+ expect(source).toMatch(
274
+ /import\s+useFeedOptions\b[^;]*from\s+"Common\/UI\/Components\/Feed\/useFeedOptions"/,
275
+ );
276
+ expect(source).toMatch(
277
+ /import\s+FeedOptionsButton\s+from\s+"Common\/UI\/Components\/Feed\/FeedOptionsButton"/,
278
+ );
279
+ expect(source).toMatch(/buttons=\{\[\s*<FeedOptionsButton\b/);
280
+
281
+ /*
282
+ * A change of options must both re-read the feed (viewKey) and change
283
+ * what is read (the event type query). Either one alone shows stale or
284
+ * mislabelled items.
285
+ */
286
+ expect(wiring.itemsCall).toMatch(
287
+ new RegExp(String.raw`\bviewKey\s*:\s*${hook}\.optionsKey\b`),
288
+ );
289
+ /*
290
+ * The first argument may carry a generic cast with a comma of its own:
291
+ * ResourceFeed names its column `as Extract<keyof TFeedModel, string>`.
292
+ */
293
+ expect(wiring.itemsCall).toMatch(
294
+ new RegExp(
295
+ String.raw`\.\.\.getFeedEventTypeQuery<\w+>\(\s*(?:[^,()<>]|<[^<>]*>)+,\s*${hook}\.options\s*,?\s*\)`,
296
+ ),
297
+ );
298
+
299
+ const button: RegExpMatchArray | null = source.match(
300
+ /<FeedOptionsButton\b[\s\S]*?\/>/,
301
+ );
302
+
303
+ expect(button).not.toBeNull();
304
+ expect(button![0]).toMatch(
305
+ new RegExp(String.raw`\bvalue=\{\s*${hook}\.options\s*\}`),
306
+ );
307
+ expect(button![0]).toMatch(
308
+ new RegExp(
309
+ String.raw`\beventTypeOptions=\{\s*${hook}\.eventTypeOptions\s*\}`,
310
+ ),
311
+ );
312
+ expect(button![0]).toMatch(
313
+ new RegExp(String.raw`\bonChange=\{\s*${hook}\.setOptions\s*\}`),
314
+ );
315
+
316
+ /*
317
+ * A filtered feed with no matches must say it is filtered, not claim
318
+ * the resource has no activity at all.
319
+ */
320
+ expect(
321
+ getCallArguments(
322
+ source,
323
+ /noItemsMessage=\{\s*getFeedNoItemsMessage\s*\(/,
324
+ ),
325
+ ).toMatch(new RegExp(String.raw`\boptions\s*:\s*${hook}\.options\b`));
326
+ },
327
+ );
328
+
329
+ /*
330
+ * The dashboard moves from one incident (cluster, monitor, ...) to the next
331
+ * without remounting the feed. useFeedItems starts over when its
332
+ * resourceKey changes, and useFeedOptions drops the event type filter when
333
+ * its resetKey changes. Only when both are the same expression do the two
334
+ * happen in the same render, so the first request for the new resource is
335
+ * unfiltered instead of carrying the filter chosen on the previous one.
336
+ */
337
+ test.each(FEED_IMPLEMENTATIONS)(
338
+ "$name drops its event type filter on the render that moves it to another resource",
339
+ (implementation: FeedImplementation) => {
340
+ const wiring: FeedOptionsWiring = getFeedOptionsWiring(
341
+ readSource(implementation),
342
+ );
343
+ const resourceKey: string | undefined = getPropertyExpression(
344
+ wiring.itemsCall,
345
+ "resourceKey",
346
+ );
347
+
348
+ expect(resourceKey).toBeTruthy();
349
+ expect(getPropertyExpression(wiring.optionsCall, "resetKey")).toBe(
350
+ resourceKey,
351
+ );
352
+ },
353
+ );
354
+
355
+ /*
356
+ * Only literal keys can be compared here. ResourceFeed keys its order by
357
+ * props.eventTypeColumn, one per product, and
358
+ * ResourceFeedPagesFilterSort.test.tsx checks that those are distinct.
359
+ */
360
+ test("each feed remembers its sort order under its own storage key", () => {
361
+ const storageKeys: Array<[string, string | undefined]> =
362
+ FEED_IMPLEMENTATIONS.map(
363
+ (implementation: FeedImplementation): [string, string | undefined] => {
364
+ return [
365
+ implementation.name,
366
+ getPropertyExpression(
367
+ getFeedOptionsWiring(readSource(implementation)).optionsCall,
368
+ "storageKey",
369
+ ),
370
+ ];
371
+ },
372
+ );
373
+
374
+ // Without a key the order is forgotten on every page load.
375
+ expect(
376
+ storageKeys
377
+ .filter((entry: [string, string | undefined]): boolean => {
378
+ return !entry[1];
379
+ })
380
+ .map((entry: [string, string | undefined]): string => {
381
+ return entry[0];
382
+ }),
383
+ ).toEqual([]);
384
+
385
+ const literalKeys: Array<string> = storageKeys
386
+ .map((entry: [string, string | undefined]): string => {
387
+ return entry[1] || "";
388
+ })
389
+ .filter((key: string): boolean => {
390
+ return QUOTED_STRING_LITERAL.test(key);
391
+ });
392
+
393
+ expect(literalKeys.length).toBeGreaterThan(1);
394
+ expect(new Set(literalKeys).size).toBe(literalKeys.length);
395
+ });
396
+
127
397
  test.each(FEED_IMPLEMENTATIONS)(
128
398
  "$name uses the shared progressive feed UI",
129
399
  (implementation: FeedImplementation) => {
@@ -1,4 +1,11 @@
1
- import { afterEach, describe, expect, jest, test } from "@jest/globals";
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ jest,
7
+ test,
8
+ } from "@jest/globals";
2
9
  import "@testing-library/jest-dom";
3
10
  import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
4
11
  import * as React from "react";
@@ -59,6 +66,7 @@ import IconProp from "../../../Types/Icon/IconProp";
59
66
  import ObjectID from "../../../Types/ObjectID";
60
67
  import SortOrder from "../../../Types/BaseDatabase/SortOrder";
61
68
  import { DEFAULT_LIMIT } from "../../../Types/Database/LimitMax";
69
+ import { getSortOrderStorageKey } from "../../../UI/Components/Feed/useFeedOptions";
62
70
 
63
71
  interface RenderedFeedItem {
64
72
  key: string;
@@ -322,6 +330,15 @@ function lastRenderedFeedProps(): RenderedFeedProps {
322
330
  return calls[calls.length - 1]![0]!;
323
331
  }
324
332
 
333
+ /*
334
+ * Both feeds open in the sort order the reader last chose, read from
335
+ * localStorage on mount, so one test's choice must not reorder the next
336
+ * test's requests.
337
+ */
338
+ beforeEach(() => {
339
+ window.localStorage.clear();
340
+ });
341
+
325
342
  afterEach(() => {
326
343
  cleanup();
327
344
  getListMock.mockReset();
@@ -439,6 +456,48 @@ describe("investigation reports in incident and alert feeds", () => {
439
456
  expect(getListMock).toHaveBeenCalledTimes(2);
440
457
  });
441
458
 
459
+ /*
460
+ * The refresh after an investigation re-reads the same view the reader is
461
+ * looking at. A reader who keeps these feeds oldest first must get the new
462
+ * report at the end of that order, not a feed flipped back to newest first.
463
+ */
464
+ test("a refresh re-reads the incident and alert feeds in the reader's remembered order", async () => {
465
+ window.localStorage.setItem(
466
+ getSortOrderStorageKey("incident"),
467
+ SortOrder.Ascending,
468
+ );
469
+ window.localStorage.setItem(
470
+ getSortOrderStorageKey("alert"),
471
+ SortOrder.Ascending,
472
+ );
473
+ getListMock.mockResolvedValue(listResult<IncidentFeed>([]) as never);
474
+
475
+ const incidentView: ReturnType<typeof render> = render(incidentElement(0));
476
+ await flush();
477
+ incidentView.rerender(incidentElement(1));
478
+ await flush();
479
+
480
+ const alertView: ReturnType<typeof render> = render(alertElement(0));
481
+ await flush();
482
+ alertView.rerender(alertElement(1));
483
+ await flush();
484
+
485
+ const requests: Array<FeedListRequest> = getListMock.mock.calls.map(
486
+ (call: Array<unknown>): FeedListRequest => {
487
+ return call[0] as FeedListRequest;
488
+ },
489
+ );
490
+
491
+ expect(
492
+ requests.map((request: FeedListRequest): unknown => {
493
+ return request.modelType;
494
+ }),
495
+ ).toEqual([IncidentFeed, IncidentFeed, AlertFeed, AlertFeed]);
496
+ for (const request of requests) {
497
+ expect(request.sort).toEqual({ postedAt: SortOrder.Ascending });
498
+ }
499
+ });
500
+
442
501
  test("a late mount-time incident response cannot erase the refreshed report", async () => {
443
502
  const initialRequest: Deferred<ListResult<IncidentFeed>> =
444
503
  createDeferred<ListResult<IncidentFeed>>();