@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
@@ -32,19 +32,25 @@ import { describe, expect, test } from "@jest/globals";
32
32
  * run it through Markdown.convertToHTML for the email body.
33
33
  *
34
34
  * Before this suite existed, nothing anywhere pinned the Breaching
35
- * Samples table or the "- Unit:" line, and both rendered raw digits with
36
- * the exporter's raw UCUM code glued on: a memory breach that the
37
- * dashboard drew as "1.07 GB" arrived in the inbox as "1073741824 By",
38
- * and a ratio metric's Value column read "0.06 1".
35
+ * Samples block (a table back then) or the "- Unit:" line, and both
36
+ * rendered raw digits with the exporter's raw UCUM code glued on: a memory
37
+ * breach that the dashboard drew as "1.07 GB" arrived in the inbox as
38
+ * "1073741824 By", and a ratio metric's Value column read "0.06 1".
39
39
  *
40
40
  * These tests pin:
41
- * - the Value column at human scale, in the unit each row landed on,
42
- * - component columns each in their OWN unit, with no unit in the header
43
- * (auto-scaling means neighbouring rows disagree with any one header),
41
+ * - each sample's value at human scale, in the unit that sample landed
42
+ * on,
43
+ * - formula components each in their OWN unit, labelled by alias alone
44
+ * (auto-scaling means neighbouring samples disagree with any one
45
+ * configured unit),
44
46
  * - the "- Unit:" line spelled out, and dropped when there is no unit,
45
47
  * - the formula guard: a formula expression is not a metric name,
46
- * - that the table and the "Filter Conditions Met" sentence in the same
47
- * email render the same sample identically.
48
+ * - that the Breaching Samples list and the "Filter Conditions Met"
49
+ * sentence in the same email render the same sample identically.
50
+ *
51
+ * The list's own shape — its markdown, and how it parses and renders in
52
+ * the email, Slack and plain text — is pinned in
53
+ * MonitorCriteriaEvaluatorBreachingSamples.test.ts.
48
54
  */
49
55
 
50
56
  type EvaluatorPrivate = {
@@ -115,33 +121,57 @@ function rootCause(ctx: MetricCriteriaContext): string {
115
121
  return context as string;
116
122
  }
117
123
 
118
- /** The one data row of the Breaching Samples table. */
119
- function sampleRow(context: string): string {
120
- const row: string | undefined = context.split("\n").find((line: string) => {
121
- return line.includes(BREACH_TIME_ISO);
124
+ /*
125
+ * The Breaching Samples list item of the sample taken at BREACH_TIME: its
126
+ * first line ("1. `<timestamp>` — **<value>**"), the value on it, and the
127
+ * detail bullets nested under it.
128
+ */
129
+ const LIST_ITEM_LINE: RegExp = /^\d+\. /;
130
+ const DETAIL_LINE: RegExp = /^ {3,4}- /;
131
+
132
+ function sampleItemLines(context: string): Array<string> {
133
+ const lines: Array<string> = context.split("\n");
134
+ const index: number = lines.findIndex((line: string) => {
135
+ return LIST_ITEM_LINE.test(line) && line.includes(BREACH_TIME_ISO);
122
136
  });
123
137
 
124
- expect(row).toBeDefined();
125
- return row as string;
138
+ expect(index).toBeGreaterThan(-1);
139
+
140
+ const itemLines: Array<string> = [lines[index] as string];
141
+
142
+ for (const line of lines.slice(index + 1)) {
143
+ if (!DETAIL_LINE.test(line)) {
144
+ break;
145
+ }
146
+
147
+ itemLines.push(line);
148
+ }
149
+
150
+ return itemLines;
126
151
  }
127
152
 
128
- /** The header row of the Breaching Samples table. */
129
- function headerRow(context: string): string {
130
- const row: string | undefined = context.split("\n").find((line: string) => {
131
- return line.startsWith("| Timestamp |");
132
- });
153
+ function sampleItem(context: string): string {
154
+ return sampleItemLines(context)[0] as string;
155
+ }
133
156
 
134
- expect(row).toBeDefined();
135
- return row as string;
157
+ function sampleValue(context: string): string {
158
+ const match: RegExpMatchArray | null =
159
+ sampleItem(context).match(/ — \*\*(.*)\*\*$/);
160
+
161
+ expect(match).not.toBeNull();
162
+ return (match as RegExpMatchArray)[1] as string;
163
+ }
164
+
165
+ function sampleDetails(context: string): Array<string> {
166
+ return sampleItemLines(context).slice(1);
136
167
  }
137
168
 
138
- describe("MonitorCriteriaEvaluator - Breaching Samples value column", () => {
169
+ describe("MonitorCriteriaEvaluator - Breaching Samples values", () => {
139
170
  test("renders bytes at human scale instead of raw digits", () => {
140
171
  const context: string = rootCause(makeContext({ unit: "By" }));
141
172
 
142
- expect(sampleRow(context)).toBe(
143
- `| \`${BREACH_TIME_ISO}\` | \`k8s.pod.memory.usage\` | \`a\` | 1.07 GB |`,
144
- );
173
+ expect(sampleItem(context)).toBe(`1. \`${BREACH_TIME_ISO}\` — **1.07 GB**`);
174
+ expect(sampleDetails(context)).toEqual([]);
145
175
  expect(context).not.toContain("1073741824");
146
176
  });
147
177
 
@@ -156,7 +186,7 @@ describe("MonitorCriteriaEvaluator - Breaching Samples value column", () => {
156
186
  }),
157
187
  );
158
188
 
159
- expect(sampleRow(context)).toContain("| 1.5 sec |");
189
+ expect(sampleValue(context)).toBe("1.5 sec");
160
190
  });
161
191
 
162
192
  test("renders a value the user typed a GB threshold against in GB terms", () => {
@@ -175,10 +205,10 @@ describe("MonitorCriteriaEvaluator - Breaching Samples value column", () => {
175
205
  }),
176
206
  );
177
207
 
178
- expect(sampleRow(context)).toContain("| 2.5 TB |");
208
+ expect(sampleValue(context)).toBe("2.5 TB");
179
209
  });
180
210
 
181
- test("each row names the unit IT landed on", () => {
211
+ test("each sample names the unit IT landed on", () => {
182
212
  const context: string = rootCause(
183
213
  makeContext({
184
214
  unit: "By",
@@ -194,12 +224,12 @@ describe("MonitorCriteriaEvaluator - Breaching Samples value column", () => {
194
224
  );
195
225
 
196
226
  /*
197
- * 922 KB and 1.26 MB in the same table. This is exactly why the unit
198
- * rides each cell instead of the column header — no single header
199
- * could describe both rows.
227
+ * 922 KB and 1.26 MB in the same list. This is exactly why the unit
228
+ * rides each value instead of a heading — no single unit could
229
+ * describe both samples.
200
230
  */
201
- expect(context).toContain("| 922 KB |");
202
- expect(context).toContain("| 1.26 MB |");
231
+ expect(context).toContain("1. `2026-08-14T10:29:00.000Z` — **922 KB**");
232
+ expect(context).toContain(`2. \`${BREACH_TIME_ISO}\` — **1.26 MB**`);
203
233
  });
204
234
 
205
235
  test("a ratio metric's fraction is shown as the percentage it means", () => {
@@ -213,9 +243,9 @@ describe("MonitorCriteriaEvaluator - Breaching Samples value column", () => {
213
243
  }),
214
244
  );
215
245
 
216
- expect(sampleRow(context)).toContain("| 5.85% |");
246
+ expect(sampleValue(context)).toBe("5.85%");
217
247
  // The old rendering: a bare "0.06", which reads as 0.06%.
218
- expect(context).not.toContain("| 0.06 |");
248
+ expect(context).not.toContain("**0.06**");
219
249
  expect(context).not.toContain("0.06 1");
220
250
  });
221
251
 
@@ -230,14 +260,14 @@ describe("MonitorCriteriaEvaluator - Breaching Samples value column", () => {
230
260
  }),
231
261
  );
232
262
 
233
- expect(sampleRow(context)).toContain("| 512 |");
263
+ expect(sampleValue(context)).toBe("512");
234
264
  expect(context).not.toContain("{thread}");
235
265
  });
236
266
 
237
267
  /*
238
268
  * BACKWARD COMPATIBILITY. A metric with no unit must keep every digit —
239
269
  * a counter at 5000 is not "5K" in an alert, because the reader may be
240
- * scanning the table for the exact value that crossed the threshold.
270
+ * scanning the list for the exact value that crossed the threshold.
241
271
  */
242
272
  test("a unitless value keeps its exact digits", () => {
243
273
  const context: string = rootCause(
@@ -250,7 +280,7 @@ describe("MonitorCriteriaEvaluator - Breaching Samples value column", () => {
250
280
  }),
251
281
  );
252
282
 
253
- expect(sampleRow(context)).toContain("| 5000 |");
283
+ expect(sampleValue(context)).toBe("5000");
254
284
  expect(context).not.toContain("5K");
255
285
  });
256
286
 
@@ -264,7 +294,7 @@ describe("MonitorCriteriaEvaluator - Breaching Samples value column", () => {
264
294
  }),
265
295
  );
266
296
 
267
- expect(sampleRow(context)).toContain("| 2.33 |");
297
+ expect(sampleValue(context)).toBe("2.33");
268
298
  });
269
299
 
270
300
  /*
@@ -282,11 +312,11 @@ describe("MonitorCriteriaEvaluator - Breaching Samples value column", () => {
282
312
  }),
283
313
  );
284
314
 
285
- expect(sampleRow(context)).toContain("| Infinity |");
315
+ expect(sampleValue(context)).toBe("Infinity");
286
316
  expect(context).not.toContain("InfinityP");
287
317
  });
288
318
 
289
- test("attribute columns are unaffected", () => {
319
+ test("attributes are listed under the sample, unaffected by the unit", () => {
290
320
  const context: string = rootCause(
291
321
  makeContext({
292
322
  unit: "By",
@@ -301,14 +331,12 @@ describe("MonitorCriteriaEvaluator - Breaching Samples value column", () => {
301
331
  }),
302
332
  );
303
333
 
304
- expect(headerRow(context)).toBe(
305
- "| Timestamp | Metric | Alias | Value | k8s.pod.name |",
306
- );
307
- expect(sampleRow(context)).toContain("| web-1 |");
334
+ expect(sampleItem(context)).toBe(`1. \`${BREACH_TIME_ISO}\` — **1.07 GB**`);
335
+ expect(sampleDetails(context)).toEqual([" - `k8s.pod.name`: `web-1`"]);
308
336
  });
309
337
  });
310
338
 
311
- describe("MonitorCriteriaEvaluator - Breaching Samples component columns", () => {
339
+ describe("MonitorCriteriaEvaluator - Breaching Samples formula components", () => {
312
340
  function formulaContext(): MetricCriteriaContext {
313
341
  return makeContext({
314
342
  metricName: "a + b",
@@ -344,27 +372,27 @@ describe("MonitorCriteriaEvaluator - Breaching Samples component columns", () =>
344
372
  });
345
373
  }
346
374
 
347
- test("each component cell is formatted in its own unit", () => {
375
+ test("each component is formatted in its own unit", () => {
348
376
  const context: string = rootCause(formulaContext());
349
377
 
350
- expect(sampleRow(context)).toBe(
351
- `| \`${BREACH_TIME_ISO}\` | \`a + b\` | \`c\` | 1.07 GB | 537 MB | 1.5 sec |`,
352
- );
378
+ expect(sampleItemLines(context)).toEqual([
379
+ `1. \`${BREACH_TIME_ISO}\` — **1.07 GB**`,
380
+ " - `a`: 537 MB",
381
+ " - `b`: 1.5 sec",
382
+ ]);
353
383
  });
354
384
 
355
385
  /*
356
- * The header used to read "a (By)". Now that a cell says which scale it
357
- * landed on, a header unit is a claim two rows of the same column can
358
- * contradict — and the Components bullet list above the table still
359
- * records the configured unit, so nothing is lost.
386
+ * The table header used to read "a (By)". Now that each value says which
387
+ * scale it landed on, a configured unit beside the alias is a claim two
388
+ * samples can contradict — and the Components bullet list above the
389
+ * samples still records the configured unit, so nothing is lost.
360
390
  */
361
- test("component headers carry the alias only, not the configured unit", () => {
391
+ test("components are labelled by alias only, not the configured unit", () => {
362
392
  const context: string = rootCause(formulaContext());
363
393
 
364
- expect(headerRow(context)).toBe(
365
- "| Timestamp | Metric | Alias | Value | a | b |",
366
- );
367
- expect(context).not.toContain("| a (By) |");
394
+ expect(context).not.toContain("a (By)");
395
+ expect(context).not.toContain("`a (By)`");
368
396
  });
369
397
 
370
398
  test("the Components bullet list spells each component's unit out", () => {
@@ -376,14 +404,18 @@ describe("MonitorCriteriaEvaluator - Breaching Samples component columns", () =>
376
404
  );
377
405
  });
378
406
 
379
- test("a component with no value for this sample still renders a dash", () => {
407
+ test("a component with no value for this sample is left out, not printed as a dash", () => {
380
408
  const ctx: MetricCriteriaContext = formulaContext();
381
409
  ctx.breachingSamples![0]!.componentValues = [
382
410
  { alias: "a", value: 536870912 },
383
411
  { alias: "b", value: null },
384
412
  ];
385
413
 
386
- expect(sampleRow(rootCause(ctx))).toContain("| 537 MB | - |");
414
+ const context: string = rootCause(ctx);
415
+
416
+ expect(sampleDetails(context)).toEqual([" - `a`: 537 MB"]);
417
+ expect(context).not.toContain("`b`:");
418
+ expect(context).not.toContain(": -");
387
419
  });
388
420
  });
389
421
 
@@ -438,7 +470,7 @@ describe("MonitorCriteriaEvaluator - formula names are not metric names", () =>
438
470
  }),
439
471
  );
440
472
 
441
- expect(sampleRow(context)).toContain("| 0.42 |");
473
+ expect(sampleValue(context)).toBe("0.42");
442
474
  expect(context).not.toContain("42.00%");
443
475
  expect(context).not.toContain("- Unit:");
444
476
  });
@@ -470,7 +502,7 @@ describe("MonitorCriteriaEvaluator - formula names are not metric names", () =>
470
502
  }),
471
503
  );
472
504
 
473
- expect(sampleRow(context)).toContain("| 0.42 |");
505
+ expect(sampleDetails(context)).toEqual([" - `x`: 0.42"]);
474
506
  expect(context).not.toContain("42.00%");
475
507
  });
476
508
 
@@ -490,7 +522,7 @@ describe("MonitorCriteriaEvaluator - formula names are not metric names", () =>
490
522
  }),
491
523
  );
492
524
 
493
- expect(sampleRow(context)).toContain("| 42.00% |");
525
+ expect(sampleValue(context)).toBe("42.00%");
494
526
  });
495
527
  });
496
528
 
@@ -500,20 +532,20 @@ describe("MonitorCriteriaEvaluator - formula names are not metric names", () =>
500
532
  * The unit-suppression rule was hand-rolled three separate times before
501
533
  * this change — in CompareCriteria's "Filter Conditions Met" sentence, in
502
534
  * MonitorCriteriaObservationBuilder, and nowhere at all in the Breaching
503
- * Samples table — with the result that a single evaluation read
504
- * differently in its own two halves: "0.31" in the sentence and "0.31 1"
505
- * in the table directly beneath it.
535
+ * Samples table (now a list) — with the result that a single evaluation
536
+ * read differently in its own two halves: "0.31" in the sentence and
537
+ * "0.31 1" in the table directly beneath it.
506
538
  *
507
539
  * Both sections of one email now go through MetricValueFormatter. These
508
540
  * tests assert the two strings are IDENTICAL rather than merely both
509
541
  * plausible, so a fourth copy of the rule cannot quietly appear.
510
542
  */
511
- describe("MonitorCriteriaEvaluator - the table and the sentence agree", () => {
543
+ describe("MonitorCriteriaEvaluator - the Breaching Samples list and the sentence agree", () => {
512
544
  function bothRenderings(input: {
513
545
  value: number;
514
546
  unit: string | null;
515
547
  metricName: string;
516
- }): { cell: string; sentence: string } {
548
+ }): { sampleValue: string; sentence: string } {
517
549
  const ctx: MetricCriteriaContext = makeContext({
518
550
  metricName: input.metricName,
519
551
  unit: input.unit,
@@ -522,14 +554,8 @@ describe("MonitorCriteriaEvaluator - the table and the sentence agree", () => {
522
554
  ],
523
555
  });
524
556
 
525
- const row: string = sampleRow(rootCause(ctx));
526
- const cells: Array<string> = row.split("|").map((cell: string) => {
527
- return cell.trim();
528
- });
529
-
530
557
  return {
531
- // | ts | metric | alias | VALUE | → index 4 once the leading "" is counted.
532
- cell: cells[4] as string,
558
+ sampleValue: sampleValue(rootCause(ctx)),
533
559
  sentence: CompareCriteria.getCompareMessage({
534
560
  values: input.value,
535
561
  threshold: input.value,
@@ -559,12 +585,12 @@ describe("MonitorCriteriaEvaluator - the table and the sentence agree", () => {
559
585
 
560
586
  for (const testCase of cases) {
561
587
  test(`${testCase.metricName} @ ${testCase.unit ?? "no unit"} reads the same in both`, () => {
562
- const rendered: { cell: string; sentence: string } =
588
+ const rendered: { sampleValue: string; sentence: string } =
563
589
  bothRenderings(testCase);
564
590
 
565
- expect(rendered.cell.length).toBeGreaterThan(0);
591
+ expect(rendered.sampleValue.length).toBeGreaterThan(0);
566
592
  expect(rendered.sentence).toBe(
567
- `${testCase.metricName} was ${rendered.cell}. The condition requires the value to be above the ${rendered.cell} threshold.`,
593
+ `${testCase.metricName} was ${rendered.sampleValue}. The condition requires the value to be above the ${rendered.sampleValue} threshold.`,
568
594
  );
569
595
  });
570
596
  }
@@ -0,0 +1,252 @@
1
+ import RootCauseList, {
2
+ RootCauseListItem,
3
+ } from "../../../../Server/Utils/Monitor/RootCauseList";
4
+ import { marked, Tokens, Token } from "marked";
5
+ import { describe, expect, test } from "@jest/globals";
6
+
7
+ /*
8
+ * RootCauseList is the numbered list both per-item breakdowns of a root
9
+ * cause render as — a platform monitor's Affected Resources and a metric
10
+ * monitor's Breaching Samples. Each has its own suite for its own content
11
+ * (AffectedResourceList.test.ts, MonitorCriteriaEvaluatorBreachingSamples
12
+ * .test.ts); this one pins the shape they share.
13
+ */
14
+
15
+ function item(overrides: Partial<RootCauseListItem> = {}): RootCauseListItem {
16
+ return {
17
+ title: "`2026-08-14T10:30:00.000Z`",
18
+ value: "**1.07 GB**",
19
+ details: [
20
+ { label: "`a`", value: "537 MB" },
21
+ { label: "`k8s.pod.name`", value: "`web-1`" },
22
+ ],
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function items(count: number): Array<RootCauseListItem> {
28
+ const result: Array<RootCauseListItem> = [];
29
+
30
+ for (let i: number = 1; i <= count; i++) {
31
+ result.push(
32
+ item({
33
+ title: `\`item-${i}\``,
34
+ value: `**${i}**`,
35
+ details: [{ label: "Label", value: `\`detail-${i}\`` }],
36
+ }),
37
+ );
38
+ }
39
+
40
+ return result;
41
+ }
42
+
43
+ describe("RootCauseList.render", () => {
44
+ test("renders each item as a numbered line with its details as nested bullets", () => {
45
+ expect(RootCauseList.render([item()])).toBe(
46
+ [
47
+ "1. `2026-08-14T10:30:00.000Z` — **1.07 GB**",
48
+ " - `a`: 537 MB",
49
+ " - `k8s.pod.name`: `web-1`",
50
+ ].join("\n"),
51
+ );
52
+ });
53
+
54
+ test("returns just the list — no heading, and no leading or trailing blank lines", () => {
55
+ const markdown: string = RootCauseList.render(items(2));
56
+
57
+ expect(markdown.startsWith("1. ")).toBe(true);
58
+ expect(markdown.endsWith("`detail-2`")).toBe(true);
59
+ });
60
+
61
+ test("returns an empty string for no items", () => {
62
+ expect(RootCauseList.render([])).toBe("");
63
+ });
64
+
65
+ test("numbers the items from 1 in the order given", () => {
66
+ const markdown: string = RootCauseList.render(items(3));
67
+
68
+ expect(markdown.split("\n")).toEqual([
69
+ "1. `item-1` — **1**",
70
+ " - Label: `detail-1`",
71
+ "2. `item-2` — **2**",
72
+ " - Label: `detail-2`",
73
+ "3. `item-3` — **3**",
74
+ " - Label: `detail-3`",
75
+ ]);
76
+ });
77
+
78
+ test("indents details to the content column: three spaces under 1-9, four under 10-99, five under 100", () => {
79
+ const markdown: string = RootCauseList.render(items(100));
80
+
81
+ expect(markdown).toContain("9. `item-9` — **9**\n - Label: `detail-9`");
82
+ expect(markdown).toContain(
83
+ "10. `item-10` — **10**\n - Label: `detail-10`",
84
+ );
85
+ expect(markdown).toContain(
86
+ "99. `item-99` — **99**\n - Label: `detail-99`",
87
+ );
88
+ expect(markdown).toContain(
89
+ "100. `item-100` — **100**\n - Label: `detail-100`",
90
+ );
91
+ });
92
+
93
+ test("a detail with an empty or blank value is left out", () => {
94
+ const markdown: string = RootCauseList.render([
95
+ item({
96
+ details: [
97
+ { label: "`a`", value: "537 MB" },
98
+ { label: "`b`", value: "" },
99
+ { label: "`c`", value: " " },
100
+ { label: "`d`", value: "1.5 sec" },
101
+ ],
102
+ }),
103
+ ]);
104
+
105
+ expect(markdown.split("\n")).toEqual([
106
+ "1. `2026-08-14T10:30:00.000Z` — **1.07 GB**",
107
+ " - `a`: 537 MB",
108
+ " - `d`: 1.5 sec",
109
+ ]);
110
+ });
111
+
112
+ test("a detail with an empty label is just its value", () => {
113
+ const markdown: string = RootCauseList.render([
114
+ item({ details: [{ label: "", value: "`web-1`" }] }),
115
+ ]);
116
+
117
+ expect(markdown).toBe(
118
+ "1. `2026-08-14T10:30:00.000Z` — **1.07 GB**\n - `web-1`",
119
+ );
120
+ });
121
+
122
+ test("an item without details is a single line", () => {
123
+ expect(RootCauseList.render([item({ details: [] })])).toBe(
124
+ "1. `2026-08-14T10:30:00.000Z` — **1.07 GB**",
125
+ );
126
+ });
127
+
128
+ test("an empty value leaves just the title, with no dangling dash", () => {
129
+ expect(RootCauseList.render([item({ value: "", details: [] })])).toBe(
130
+ "1. `2026-08-14T10:30:00.000Z`",
131
+ );
132
+ });
133
+
134
+ test("an empty title leaves just the value", () => {
135
+ expect(RootCauseList.render([item({ title: " ", details: [] })])).toBe(
136
+ "1. **1.07 GB**",
137
+ );
138
+ });
139
+
140
+ test("trims the whitespace around the title, value, labels and values", () => {
141
+ expect(
142
+ RootCauseList.render([
143
+ item({
144
+ title: " `t` ",
145
+ value: " **v** ",
146
+ details: [{ label: " `k` ", value: " `x` " }],
147
+ }),
148
+ ]),
149
+ ).toBe("1. `t` — **v**\n - `k`: `x`");
150
+ });
151
+
152
+ test("renders labels, titles and values as markdown, unescaped", () => {
153
+ /*
154
+ * The caller decides: AffectedResourceList escapes its plain-text
155
+ * labels, the Breaching Samples block code-spans its attribute keys.
156
+ */
157
+ expect(
158
+ RootCauseList.render([
159
+ item({
160
+ title: "**Pod** `x`",
161
+ details: [{ label: "*Namespace*", value: "`y`" }],
162
+ }),
163
+ ]),
164
+ ).toBe("1. **Pod** `x` — **1.07 GB**\n - *Namespace*: `y`");
165
+ });
166
+
167
+ test("parses as one ordered list whose every item owns a nested bullet list, with no code blocks", () => {
168
+ const tokens: Array<Token> = marked
169
+ .lexer(RootCauseList.render(items(12)))
170
+ .filter((token: Token): boolean => {
171
+ return token.type !== "space";
172
+ });
173
+
174
+ expect(tokens).toHaveLength(1);
175
+
176
+ const list: Tokens.List = tokens[0] as Tokens.List;
177
+
178
+ expect(list.type).toBe("list");
179
+ expect(list.ordered).toBe(true);
180
+ expect(list.items).toHaveLength(12);
181
+
182
+ list.items.forEach((listItem: Tokens.ListItem, index: number) => {
183
+ const nested: Tokens.List | undefined = listItem.tokens.find(
184
+ (token: Token): boolean => {
185
+ return token.type === "list";
186
+ },
187
+ ) as Tokens.List | undefined;
188
+
189
+ expect(nested).toBeDefined();
190
+ expect(nested!.ordered).toBe(false);
191
+ expect(
192
+ nested!.items.map((detail: Tokens.ListItem) => {
193
+ return detail.text;
194
+ }),
195
+ ).toEqual([`Label: \`detail-${index + 1}\``]);
196
+
197
+ expect(
198
+ listItem.tokens.some((token: Token): boolean => {
199
+ return token.type === "code";
200
+ }),
201
+ ).toBe(false);
202
+ });
203
+ });
204
+ });
205
+
206
+ describe("RootCauseList.code", () => {
207
+ test("wraps a value in single backticks", () => {
208
+ expect(RootCauseList.code("web-1")).toBe("`web-1`");
209
+ });
210
+
211
+ test("an ISO timestamp stays a bare single-backtick span, which the dashboard localizes", () => {
212
+ expect(RootCauseList.code("2026-08-14T10:30:00.000Z")).toBe(
213
+ "`2026-08-14T10:30:00.000Z`",
214
+ );
215
+ });
216
+
217
+ test.each([
218
+ ["undefined", undefined],
219
+ ["null", null],
220
+ ["an empty string", ""],
221
+ ["whitespace", " \t "],
222
+ ["line breaks", "\r\n\n"],
223
+ ])("returns an empty string for %s", (_label: string, value: unknown) => {
224
+ expect(RootCauseList.code(value as string | undefined | null)).toBe("");
225
+ });
226
+
227
+ test("turns line breaks into single spaces", () => {
228
+ expect(RootCauseList.code("a\nb\r\nc \n\n d")).toBe("`a b c d`");
229
+ });
230
+
231
+ test("a value with backticks gets a fence one longer than its longest run, padded with spaces", () => {
232
+ expect(RootCauseList.code("a`b")).toBe("`` a`b ``");
233
+ expect(RootCauseList.code("a``b`c")).toBe("``` a``b`c ```");
234
+ });
235
+
236
+ test("the result parses back to exactly the value", () => {
237
+ for (const value of ["a`b", "`edge`", "x``y", "**not bold**", "a|b"]) {
238
+ const tokens: Array<Token> = marked.lexer(
239
+ `before ${RootCauseList.code(value)} after`,
240
+ );
241
+ const paragraph: Tokens.Paragraph = tokens[0] as Tokens.Paragraph;
242
+ const codespan: Tokens.Codespan | undefined = paragraph.tokens.find(
243
+ (token: Token): boolean => {
244
+ return token.type === "codespan";
245
+ },
246
+ ) as Tokens.Codespan | undefined;
247
+
248
+ expect(codespan).toBeDefined();
249
+ expect(codespan!.text).toBe(value);
250
+ }
251
+ });
252
+ });