@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
@@ -1,4 +1,8 @@
1
1
  import { escapeMarkdownInline } from "../../../Utils/Markdown/MarkdownEscape";
2
+ import RootCauseList, {
3
+ RootCauseListDetail,
4
+ RootCauseListItem,
5
+ } from "./RootCauseList";
2
6
 
3
7
  /*
4
8
  * The "Affected Resources" block of a platform monitor's root cause.
@@ -30,10 +34,8 @@ import { escapeMarkdownInline } from "../../../Utils/Markdown/MarkdownEscape";
30
34
  * and the rest of its identity sits underneath as labelled bullets. A
31
35
  * missing attribute is simply left out, where the table had to print "-".
32
36
  *
33
- * Plain markdown, deliberately: marked (email), react-markdown (dashboard),
34
- * slackify-markdown (Slack), the dashboard editor's WYSIWYG converters and
35
- * Markdown.convertToPlainText all understand a nested list, so nothing
36
- * downstream needs to know this block exists.
37
+ * The list itself is a RootCauseList, the shape the Breaching Samples block
38
+ * of a metric monitor's root cause uses too.
37
39
  */
38
40
 
39
41
  export interface AffectedResourceListDetail {
@@ -67,46 +69,36 @@ export default class AffectedResourceList {
67
69
  // The resources to show, already sorted worst first.
68
70
  entries: Array<AffectedResourceListEntry>;
69
71
  }): string {
70
- const lines: Array<string> = [];
71
-
72
- input.entries.forEach(
73
- (entry: AffectedResourceListEntry, index: number): void => {
74
- const marker: string = `${index + 1}.`;
75
-
76
- /*
77
- * A nested bullet belongs to its item only when it is indented to
78
- * the item's content column, which is one past the marker — three
79
- * spaces under "1.", four under "10.". A fixed three-space indent
80
- * would push item 10's details out into a separate top-level list.
81
- */
82
- const indent: string = " ".repeat(marker.length + 1);
83
-
72
+ const items: Array<RootCauseListItem> = input.entries.map(
73
+ (entry: AffectedResourceListEntry): RootCauseListItem => {
84
74
  /*
85
75
  * The kind is bold so each item's first line stands out from the
86
76
  * detail bullets under it — the value at the end of the line is
87
77
  * bold too, so the eye lands on what it is and how bad it is.
88
78
  */
89
79
  const kind: string = escapeMarkdownInline(entry.kind).trim();
90
- const title: string = [kind ? `**${kind}**` : "", entry.name.trim()]
91
- .filter((part: string): boolean => {
92
- return part.length > 0;
93
- })
94
- .join(" ");
95
80
 
96
- lines.push(`${marker} ${title} — ${entry.value}`);
97
-
98
- for (const detail of entry.details) {
99
- if (!detail.value || detail.value.trim().length === 0) {
100
- continue;
101
- }
102
-
103
- lines.push(
104
- `${indent}- ${escapeMarkdownInline(detail.label)}: ${detail.value}`,
105
- );
106
- }
81
+ return {
82
+ title: [kind ? `**${kind}**` : "", entry.name.trim()]
83
+ .filter((part: string): boolean => {
84
+ return part.length > 0;
85
+ })
86
+ .join(" "),
87
+ value: entry.value,
88
+ details: entry.details.map(
89
+ (detail: AffectedResourceListDetail): RootCauseListDetail => {
90
+ return {
91
+ label: escapeMarkdownInline(detail.label),
92
+ value: detail.value,
93
+ };
94
+ },
95
+ ),
96
+ };
107
97
  },
108
98
  );
109
99
 
100
+ const lines: Array<string> = [RootCauseList.render(items)];
101
+
110
102
  const hiddenCount: number = input.totalCount - input.entries.length;
111
103
 
112
104
  if (hiddenCount > 0) {
@@ -125,47 +117,13 @@ export default class AffectedResourceList {
125
117
  }
126
118
 
127
119
  /*
128
- * A resource identifier as an inline code span.
129
- *
130
- * The identifiers come from telemetry attributes, and a VMware VM or a
131
- * Proxmox guest can be named anything. Wrapping a name that contains a
132
- * backtick in single backticks closes the span early and spills the rest
133
- * of the name — and whatever markdown it contains — into the list. So the
134
- * fence is always one backtick longer than the longest run inside the
135
- * value, and padded with a space on each side when the value has any
136
- * backticks at all (CommonMark strips exactly one from each side, so the
137
- * padding never shows). Line breaks become spaces: a newline inside a
138
- * list item would end the item.
120
+ * A resource identifier as an inline code span. The identifiers come from
121
+ * telemetry attributes, and a VMware VM or a Proxmox guest can be named
122
+ * anything — RootCauseList.code keeps a name with backticks or line breaks
123
+ * inside its span.
139
124
  */
140
125
  public static code(value: string | undefined | null): string {
141
- if (value === undefined || value === null) {
142
- return "";
143
- }
144
-
145
- const text: string = String(value)
146
- .replace(/\s*[\r\n]+\s*/g, " ")
147
- .trim();
148
-
149
- if (text.length === 0) {
150
- return "";
151
- }
152
-
153
- const backtickRuns: Array<string> = text.match(/`+/g) || [];
154
-
155
- const longestRun: number = backtickRuns.reduce(
156
- (longest: number, run: string): number => {
157
- return Math.max(longest, run.length);
158
- },
159
- 0,
160
- );
161
-
162
- if (longestRun === 0) {
163
- return `\`${text}\``;
164
- }
165
-
166
- const fence: string = "`".repeat(longestRun + 1);
167
-
168
- return `${fence} ${text} ${fence}`;
126
+ return RootCauseList.code(value);
169
127
  }
170
128
 
171
129
  /*
@@ -984,7 +984,7 @@ export default class CompareCriteria {
984
984
  * reads naturally ("is 42, 55, 60"). For larger arrays, summarize
985
985
  * — dumping 30+ numbers on one line makes the root cause unreadable
986
986
  * and the detailed breakdown is shown in the Breaching Samples
987
- * table below.
987
+ * list below.
988
988
  */
989
989
  const MAX_INLINE: number = 5;
990
990
 
@@ -1004,7 +1004,7 @@ export default class MetricMonitorCriteria {
1004
1004
  const sigmaAbs: number = Math.abs(firstBreach.sigma);
1005
1005
 
1006
1006
  /*
1007
- * Same rendering as the breaching-samples table and the comparison
1007
+ * Same rendering as the breaching-samples list and the comparison
1008
1008
  * sentence — an anomaly on a memory metric reads "1.07 GB", not
1009
1009
  * "1073741824.000 By". σ is a count of standard deviations, not a
1010
1010
  * quantity in the metric's unit, so it stays a bare number; the
@@ -34,6 +34,10 @@ import AffectedResourceList, {
34
34
  AffectedResourceListDetail,
35
35
  AffectedResourceListEntry,
36
36
  } from "./AffectedResourceList";
37
+ import RootCauseList, {
38
+ RootCauseListDetail,
39
+ RootCauseListItem,
40
+ } from "./RootCauseList";
37
41
  import DataToProcess from "./DataToProcess";
38
42
  import Monitor from "../../../Models/DatabaseModels/Monitor";
39
43
  import MonitorCriteria from "../../../Types/Monitor/MonitorCriteria";
@@ -124,6 +128,15 @@ export interface TelemetryExplorerDeepLink {
124
128
  dropped: Array<string>;
125
129
  }
126
130
 
131
+ /**
132
+ * Which affected-resources rows breached the matched criteria, and which
133
+ * end of the value range is the worst.
134
+ */
135
+ interface AffectedResourceBreachPredicate {
136
+ matches: (value: number) => boolean;
137
+ worstIsLowest: boolean;
138
+ }
139
+
127
140
  export default class MonitorCriteriaEvaluator {
128
141
  public static async processMonitorStep(input: {
129
142
  dataToProcess: DataToProcess;
@@ -1556,9 +1569,7 @@ ${contextBlock}
1556
1569
  samples: breachingSamples,
1557
1570
  totalSamples: ctx.totalSamplesInWindow,
1558
1571
  unit: ctx.unit,
1559
- metricName: ctx.metricName,
1560
1572
  unitHeuristicMetricName: unitHeuristicMetricName,
1561
- alias: ctx.alias,
1562
1573
  components: ctx.components || [],
1563
1574
  })}`,
1564
1575
  );
@@ -1614,11 +1625,25 @@ ${contextBlock}
1614
1625
  }
1615
1626
 
1616
1627
  /**
1617
- * Build the **Breaching Samples** markdown section — a table of
1618
- * timestamps, values, and any group-by attributes. Caps the row count
1619
- * so a 30-minute window at 1-second granularity doesn't dump thousands
1620
- * of lines onto the root-cause page; the caller can always drill in
1621
- * via the metric explorer link.
1628
+ * Build the **Breaching Samples** markdown section — a numbered list of
1629
+ * the samples that breached, oldest first. Caps the list so a 30-minute
1630
+ * window at 1-second granularity doesn't dump thousands of lines onto
1631
+ * the root-cause page; the caller can always drill in via the metric
1632
+ * explorer link.
1633
+ *
1634
+ * 1. `2026-08-14T10:30:00.000Z` — **1.07 GB**
1635
+ * - `a`: 537 MB
1636
+ * - `b`: 1.5 sec
1637
+ * - `k8s.pod.name`: `web-1`
1638
+ *
1639
+ * It used to be a table — Timestamp | Metric | Alias | Value, a column
1640
+ * per formula component and one per attribute key — which grew wider
1641
+ * with every attribute and read badly in the email card, in Slack and on
1642
+ * the dashboard (see RootCauseList). Its Metric and Alias columns
1643
+ * repeated the same two values on every row, the ones the Metric
1644
+ * Details list directly above already states, so the list leaves them
1645
+ * out; and a component or attribute a sample has no value for is left
1646
+ * out rather than printed as "-".
1622
1647
  *
1623
1648
  * Timestamps are emitted as inline code wrapping ISO 8601 strings so
1624
1649
  * the client-side markdown viewer can localize them to the viewer's
@@ -1628,16 +1653,14 @@ ${contextBlock}
1628
1653
  samples: Array<MetricBreachingSample>;
1629
1654
  totalSamples?: number | undefined;
1630
1655
  unit: string | null;
1631
- metricName: string;
1632
1656
  /**
1633
1657
  * The metric name to reason about, or undefined for a formula.
1634
1658
  * See MonitorCriteriaEvaluator.metricNameForUnitHeuristics.
1635
1659
  */
1636
1660
  unitHeuristicMetricName: string | undefined;
1637
- alias: string;
1638
1661
  components: Array<MetricComponent>;
1639
1662
  }): string {
1640
- const MAX_ROWS: number = 20;
1663
+ const MAX_SAMPLES_SHOWN: number = 20;
1641
1664
 
1642
1665
  // Sort chronologically and de-duplicate any accidental repeats
1643
1666
  const sorted: Array<MetricBreachingSample> = [...input.samples].sort(
@@ -1650,10 +1673,13 @@ ${contextBlock}
1650
1673
 
1651
1674
  const displayedSamples: Array<MetricBreachingSample> = sorted.slice(
1652
1675
  0,
1653
- MAX_ROWS,
1676
+ MAX_SAMPLES_SHOWN,
1654
1677
  );
1655
1678
 
1656
- // Collect attribute keys that appear on any displayed sample
1679
+ /*
1680
+ * Collect attribute keys that appear on any displayed sample, so every
1681
+ * item lists the attributes it has in the same order.
1682
+ */
1657
1683
  const attrKeySet: Set<string> = new Set<string>();
1658
1684
  for (const s of displayedSamples) {
1659
1685
  for (const k of Object.keys(s.attributes || {})) {
@@ -1662,107 +1688,74 @@ ${contextBlock}
1662
1688
  }
1663
1689
  const attrKeys: Array<string> = Array.from(attrKeySet);
1664
1690
 
1665
- /*
1666
- * Escape pipe characters that could appear in the metric display
1667
- * name (formulas like "a | b" are unlikely but possible) so they
1668
- * don't break GitHub-flavored-markdown tables.
1669
- */
1670
- const escapeCell: (value: string) => string = (value: string): string => {
1671
- return value.replace(/\|/g, "\\|");
1672
- };
1673
-
1674
- /*
1675
- * Column layout:
1676
- * Timestamp | Metric | Alias | Value | <component_1> | ... | <attr_1> | ...
1677
- *
1678
- * The component columns let the reader see what each variable of a
1679
- * formula resolved to at the breach time — e.g. "when c = a + b
1680
- * breached 100, a was 55, b was 46". They are omitted for plain
1681
- * metric criteria.
1682
- */
1683
- const headerCells: Array<string> = [
1684
- "Timestamp",
1685
- "Metric",
1686
- "Alias",
1687
- "Value",
1688
- ];
1689
-
1690
- /*
1691
- * The component columns carry no unit in their header any more. Each
1692
- * cell now names the unit it actually landed on, and auto-scaling
1693
- * means neighbouring rows can land on different ones — a header that
1694
- * read "a (By)" over cells reading "900 KB" and "1.2 MB" states a
1695
- * unit that neither row uses. The configured unit is not lost: the
1696
- * Components bullet list above the table still records it.
1697
- */
1698
- for (const component of input.components) {
1699
- headerCells.push(component.alias);
1700
- }
1701
-
1702
- headerCells.push(...attrKeys);
1703
-
1704
- const headerRow: string = `| ${headerCells.join(" | ")} |`;
1705
- const dividerRow: string = `| ${headerCells
1706
- .map(() => {
1707
- return "---";
1708
- })
1709
- .join(" | ")} |`;
1710
-
1711
- const metricCell: string = `\`${escapeCell(input.metricName)}\``;
1712
- const aliasCell: string = input.alias
1713
- ? `\`${escapeCell(input.alias)}\``
1714
- : "-";
1715
-
1716
- const dataRows: Array<string> = displayedSamples.map(
1717
- (s: MetricBreachingSample) => {
1718
- const timestampIso: string = new Date(s.timestamp).toISOString();
1719
- const cells: Array<string> = [
1720
- `\`${timestampIso}\``,
1721
- metricCell,
1722
- aliasCell,
1723
- MetricValueFormatter.format({
1724
- value: s.value,
1725
- unit: input.unit,
1726
- metricName: input.unitHeuristicMetricName,
1727
- }),
1728
- ];
1691
+ const items: Array<RootCauseListItem> = displayedSamples.map(
1692
+ (s: MetricBreachingSample): RootCauseListItem => {
1693
+ const details: Array<RootCauseListDetail> = [];
1729
1694
 
1695
+ /*
1696
+ * What each variable of a formula resolved to at the breach time —
1697
+ * e.g. "when c = a + b breached 100, a was 55, b was 46". Absent
1698
+ * for plain metric criteria.
1699
+ */
1730
1700
  for (const component of input.components) {
1731
1701
  const match: MetricComponentValue | undefined = (
1732
1702
  s.componentValues || []
1733
1703
  ).find((cv: MetricComponentValue) => {
1734
1704
  return cv.alias === component.alias;
1735
1705
  });
1736
- if (match && typeof match.value === "number") {
1737
- /*
1738
- * Each component keeps its OWN unit. Component values are
1739
- * indexed off the per-query series and never go through the
1740
- * sample→threshold-unit conversion the main Value column
1741
- * does, so they are in the component's legendUnit — mixing
1742
- * units across the row is deliberate, and is why every cell
1743
- * has to say which one it is in.
1744
- */
1745
- cells.push(
1746
- MetricValueFormatter.format({
1747
- value: match.value,
1748
- unit: component.unit,
1749
- metricName:
1750
- MonitorCriteriaEvaluator.metricNameForUnitHeuristics({
1751
- metricName: component.name,
1752
- isFormula: component.isFormula,
1753
- }),
1754
- }),
1755
- );
1756
- } else {
1757
- cells.push("-");
1706
+
1707
+ if (!match || typeof match.value !== "number") {
1708
+ continue;
1758
1709
  }
1710
+
1711
+ /*
1712
+ * Each component keeps its OWN unit. Component values are
1713
+ * indexed off the per-query series and never go through the
1714
+ * sample→threshold-unit conversion the sample's value does, so
1715
+ * they are in the component's legendUnit — which is why each
1716
+ * one names the unit it is in. Its configured unit is in the
1717
+ * Components list of the Metric Details above.
1718
+ */
1719
+ details.push({
1720
+ label: RootCauseList.code(component.alias),
1721
+ value: MetricValueFormatter.format({
1722
+ value: match.value,
1723
+ unit: component.unit,
1724
+ metricName: MonitorCriteriaEvaluator.metricNameForUnitHeuristics({
1725
+ metricName: component.name,
1726
+ isFormula: component.isFormula,
1727
+ }),
1728
+ }),
1729
+ });
1759
1730
  }
1760
1731
 
1732
+ /*
1733
+ * The attributes identify the series the sample came from. Keys
1734
+ * and values are telemetry, so both go in code spans:
1735
+ * RootCauseList.code keeps whatever they contain inside the span.
1736
+ */
1761
1737
  for (const k of attrKeys) {
1762
1738
  const v: unknown = (s.attributes as Record<string, unknown>)[k];
1763
- cells.push(v === undefined || v === null ? "-" : String(v));
1739
+
1740
+ if (v === undefined || v === null) {
1741
+ continue;
1742
+ }
1743
+
1744
+ details.push({
1745
+ label: RootCauseList.code(k),
1746
+ value: RootCauseList.code(String(v)),
1747
+ });
1764
1748
  }
1765
- return `| ${cells.join(" | ")} |`;
1749
+
1750
+ return {
1751
+ title: RootCauseList.code(new Date(s.timestamp).toISOString()),
1752
+ value: `**${MetricValueFormatter.format({
1753
+ value: s.value,
1754
+ unit: input.unit,
1755
+ metricName: input.unitHeuristicMetricName,
1756
+ })}**`,
1757
+ details: details,
1758
+ };
1766
1759
  },
1767
1760
  );
1768
1761
 
@@ -1773,14 +1766,17 @@ ${contextBlock}
1773
1766
  totalSamples: input.totalSamples,
1774
1767
  }),
1775
1768
  "",
1776
- headerRow,
1777
- dividerRow,
1778
- ...dataRows,
1769
+ RootCauseList.render(items),
1779
1770
  ];
1780
1771
 
1781
1772
  if (sorted.length > displayedSamples.length) {
1773
+ /*
1774
+ * The blank line matters: without it the note is a lazy
1775
+ * continuation of the last item, not a paragraph of its own.
1776
+ */
1782
1777
  lines.push(
1783
- `\n_Showing the first ${displayedSamples.length} of ${sorted.length} breaching samples._`,
1778
+ "",
1779
+ `_Showing the first ${displayedSamples.length} of ${sorted.length} breaching samples._`,
1784
1780
  );
1785
1781
  }
1786
1782
 
@@ -2172,6 +2168,7 @@ ${contextBlock}
2172
2168
  dataToProcess: DataToProcess;
2173
2169
  monitorStep: MonitorStep;
2174
2170
  monitor: Monitor;
2171
+ criteriaInstance?: MonitorCriteriaInstance | undefined;
2175
2172
  }): Promise<string | null> {
2176
2173
  const metricResponse: MetricMonitorResponse =
2177
2174
  input.dataToProcess as MetricMonitorResponse;
@@ -2204,18 +2201,40 @@ ${contextBlock}
2204
2201
 
2205
2202
  // Affected resources
2206
2203
  if (breakdown.affectedResources && breakdown.affectedResources.length > 0) {
2207
- // Sort by metric value descending (worst first) and filter out zero-value resources
2208
- const sortedResources: Array<KubernetesAffectedResource> = [
2209
- ...breakdown.affectedResources,
2210
- ]
2211
- .filter((r: KubernetesAffectedResource) => {
2212
- return r.metricValue > 0;
2213
- })
2214
- .sort(
2215
- (a: KubernetesAffectedResource, b: KubernetesAffectedResource) => {
2216
- return b.metricValue - a.metricValue;
2217
- },
2218
- );
2204
+ /*
2205
+ * Keep the rows that satisfy the criteria that just matched, worst
2206
+ * first — NOT simply the non-zero rows. k8s-node-not-ready fires on
2207
+ * `k8s.node.condition_ready = 0`, so the zero rows are the NotReady
2208
+ * nodes; the old hardcoded `> 0` dropped exactly those and named a
2209
+ * healthy node in the analysis below.
2210
+ */
2211
+ const breach: AffectedResourceBreachPredicate =
2212
+ MonitorCriteriaEvaluator.getAffectedResourceBreachPredicate({
2213
+ criteriaInstance: input.criteriaInstance,
2214
+ floorEqualityFiresOnFall: true,
2215
+ });
2216
+
2217
+ const sortedResources: Array<KubernetesAffectedResource> =
2218
+ breakdown.affectedResources
2219
+ .map((r: KubernetesAffectedResource) => {
2220
+ /*
2221
+ * A fall criteria breached on the resource's lowest sample
2222
+ * in the window, not its highest.
2223
+ */
2224
+ return breach.worstIsLowest
2225
+ ? { ...r, metricValue: r.lowestMetricValue ?? r.metricValue }
2226
+ : r;
2227
+ })
2228
+ .filter((r: KubernetesAffectedResource) => {
2229
+ return breach.matches(r.metricValue);
2230
+ })
2231
+ .sort(
2232
+ (a: KubernetesAffectedResource, b: KubernetesAffectedResource) => {
2233
+ return breach.worstIsLowest
2234
+ ? a.metricValue - b.metricValue
2235
+ : b.metricValue - a.metricValue;
2236
+ },
2237
+ );
2219
2238
 
2220
2239
  if (sortedResources.length === 0) {
2221
2240
  return sections.join("\n");
@@ -3273,37 +3292,61 @@ ${contextBlock}
3273
3292
  }
3274
3293
 
3275
3294
  /*
3276
- * Which rows of the Ceph affected-resources list actually BREACHED.
3295
+ * Which rows of an affected-resources list actually BREACHED.
3277
3296
  *
3278
3297
  * The blanket `metricValue > 0` below is right for every count- or
3279
3298
  * level-style criteria ("> 0 active health checks", "> 85 % used"), but
3280
- * it is exactly backwards for the availability signals: the OSD Down /
3281
- * OSD Out / Quorum Degraded criteria fire on `< 1` over ceph_osd_up /
3282
- * ceph_osd_in / ceph_mon_quorum_status, so the ZERO rows are the down
3283
- * daemons. Dropping them rendered a list of the HEALTHY daemons under
3284
- * an incident that tells the reader to "check the root cause for the
3285
- * affected ceph_daemon label".
3299
+ * it is exactly backwards for the availability signals: the Ceph OSD
3300
+ * Down / OSD Out / Quorum Degraded criteria fire on `< 1` over
3301
+ * ceph_osd_up / ceph_osd_in / ceph_mon_quorum_status, so the ZERO rows
3302
+ * are the down daemons. Dropping them rendered a list of the HEALTHY
3303
+ * daemons under an incident that tells the reader to "check the root
3304
+ * cause for the affected ceph_daemon label".
3286
3305
  *
3287
3306
  * Invert only for a criteria that fires when the metric FALLS.
3288
3307
  * Everything else — every `> 0` health check, PG count, latency and
3289
- * capacity ratio, and every `= 0` recovery criteria — keeps the
3290
- * existing `> 0`, worst-highest-first list byte for byte.
3308
+ * capacity ratio, and (by default) every `= 0` recovery criteria —
3309
+ * keeps the existing `> 0`, worst-highest-first list byte for byte.
3310
+ *
3311
+ * `floorEqualityFiresOnFall` additionally counts a FIRING `= 0` (or
3312
+ * below) as a fall. Kubernetes needs it: k8s-node-not-ready and
3313
+ * k8s-etcd-no-leader fire on `= 0` over k8s.node.condition_ready /
3314
+ * etcd_server_has_leader, and zero is the floor of those metrics, so
3315
+ * the only way to reach it is down. It is gated on the criteria opening
3316
+ * an incident or alert because the same `= 0` is also how the restart,
3317
+ * failed-pod and unavailable-replica templates express RECOVERY, and
3318
+ * that all-clear must not become a list of healthy zero rows. Ceph
3319
+ * leaves it off: all of its `= 0` criteria are recoveries.
3291
3320
  */
3292
- private static getCephBreachPredicate(
3293
- criteriaInstance?: MonitorCriteriaInstance | undefined,
3294
- ): { matches: (value: number) => boolean; worstIsLowest: boolean } {
3321
+ private static getAffectedResourceBreachPredicate(input: {
3322
+ criteriaInstance?: MonitorCriteriaInstance | undefined;
3323
+ floorEqualityFiresOnFall?: boolean | undefined;
3324
+ }): AffectedResourceBreachPredicate {
3295
3325
  const metricFilters: Array<CriteriaFilter> = (
3296
- criteriaInstance?.data?.filters || []
3326
+ input.criteriaInstance?.data?.filters || []
3297
3327
  ).filter((f: CriteriaFilter) => {
3298
3328
  return f.checkOn === CheckOn.MetricValue && typeof f.value === "number";
3299
3329
  });
3300
3330
 
3331
+ const opensIncidentOrAlert: boolean =
3332
+ input.criteriaInstance?.data?.createIncidents === true ||
3333
+ input.criteriaInstance?.data?.createAlerts === true;
3334
+
3301
3335
  const firesWhenMetricFalls: boolean =
3302
3336
  metricFilters.length > 0 &&
3303
3337
  metricFilters.every((f: CriteriaFilter) => {
3304
- return (
3338
+ if (
3305
3339
  f.filterType === FilterType.LessThan ||
3306
3340
  f.filterType === FilterType.LessThanOrEqualTo
3341
+ ) {
3342
+ return true;
3343
+ }
3344
+
3345
+ return (
3346
+ input.floorEqualityFiresOnFall === true &&
3347
+ opensIncidentOrAlert &&
3348
+ f.filterType === FilterType.EqualTo &&
3349
+ (f.value as number) <= 0
3307
3350
  );
3308
3351
  });
3309
3352
 
@@ -3321,6 +3364,10 @@ ${contextBlock}
3321
3364
  return metricFilters.some((f: CriteriaFilter) => {
3322
3365
  const threshold: number = f.value as number;
3323
3366
 
3367
+ if (f.filterType === FilterType.EqualTo) {
3368
+ return value === threshold;
3369
+ }
3370
+
3324
3371
  return f.filterType === FilterType.LessThan
3325
3372
  ? value < threshold
3326
3373
  : value <= threshold;
@@ -3478,12 +3525,10 @@ ${contextBlock}
3478
3525
  * ceph_osd_in / ceph_mon_quorum_status the criteria is `< 1`, so
3479
3526
  * the zero rows are the whole point of the incident.
3480
3527
  */
3481
- const breach: {
3482
- matches: (value: number) => boolean;
3483
- worstIsLowest: boolean;
3484
- } = MonitorCriteriaEvaluator.getCephBreachPredicate(
3485
- input.criteriaInstance,
3486
- );
3528
+ const breach: AffectedResourceBreachPredicate =
3529
+ MonitorCriteriaEvaluator.getAffectedResourceBreachPredicate({
3530
+ criteriaInstance: input.criteriaInstance,
3531
+ });
3487
3532
 
3488
3533
  const sortedResources: Array<CephAffectedResource> = [
3489
3534
  ...breakdown.affectedResources,