@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
@@ -8,6 +8,7 @@ import Label from "../../Models/DatabaseModels/Label";
8
8
  import Monitor from "../../Models/DatabaseModels/Monitor";
9
9
  import MonitorLabelRule from "../../Models/DatabaseModels/MonitorLabelRule";
10
10
  import NetworkDeviceLabelRule from "../../Models/DatabaseModels/NetworkDeviceLabelRule";
11
+ import ServiceLevelObjectiveLabelRule from "../../Models/DatabaseModels/ServiceLevelObjectiveLabelRule";
11
12
  import TableColumnType from "../../Types/Database/TableColumnType";
12
13
  import FilterCondition from "../../Types/Filter/FilterCondition";
13
14
  import { JSONObject } from "../../Types/JSON";
@@ -175,6 +176,11 @@ const criteriaByType: Record<string, [string, string, string]> = {
175
176
  "serviceDescriptionPattern",
176
177
  "serviceLabels",
177
178
  ],
179
+ ServiceLevelObjectiveLabelRule: [
180
+ "serviceLevelObjectiveNamePattern",
181
+ "serviceLevelObjectiveDescriptionPattern",
182
+ "serviceLevelObjectiveLabels",
183
+ ],
178
184
  StatusPageLabelRule: [
179
185
  "statusPageNamePattern",
180
186
  "statusPageDescriptionPattern",
@@ -590,6 +596,102 @@ describe("portable label rule files", () => {
590
596
  },
591
597
  );
592
598
 
599
+ /*
600
+ * SLO label rules match patterns the way network device rules do - a regex,
601
+ * with a '*' wildcard fallback, on the trimmed pattern - so the two trade
602
+ * any pattern, and both refuse one whose meaning a regex-only rule type
603
+ * would change.
604
+ */
605
+ test("preserves wildcard SLO patterns when importing SLO rules", () => {
606
+ expect(
607
+ parse(
608
+ [{ ...base, serviceLevelObjectiveNamePattern: "*checkout*" }],
609
+ ServiceLevelObjectiveLabelRule,
610
+ ).items[0]!.json["serviceLevelObjectiveNamePattern"],
611
+ ).toBe("*checkout*");
612
+ });
613
+
614
+ test.each([
615
+ [NetworkDeviceLabelRule, ServiceLevelObjectiveLabelRule],
616
+ [ServiceLevelObjectiveLabelRule, NetworkDeviceLabelRule],
617
+ ])(
618
+ "moves a wildcard between %p and %p without changing its meaning",
619
+ (source: DatabaseBaseModelType, destination: DatabaseBaseModelType) => {
620
+ const sourceField: string =
621
+ source === NetworkDeviceLabelRule
622
+ ? "networkDeviceNamePattern"
623
+ : "serviceLevelObjectiveNamePattern";
624
+ const destinationField: string =
625
+ destination === NetworkDeviceLabelRule
626
+ ? "networkDeviceNamePattern"
627
+ : "serviceLevelObjectiveNamePattern";
628
+
629
+ expect(
630
+ parse([{ ...base, [sourceField]: "*edge*" }], source, destination)
631
+ .items[0]!.json[destinationField],
632
+ ).toBe("*edge*");
633
+ },
634
+ );
635
+
636
+ test.each(["*checkout*", "checkout.*", " checkout ", " "])(
637
+ "rejects moving the SLO pattern %p to a regex-only rule type",
638
+ (pattern: string) => {
639
+ expect(() => {
640
+ parse(
641
+ [{ ...base, serviceLevelObjectiveNamePattern: pattern }],
642
+ ServiceLevelObjectiveLabelRule,
643
+ MonitorLabelRule,
644
+ );
645
+ }).toThrow("without changing its meaning");
646
+ },
647
+ );
648
+
649
+ test.each(["checkout.*", " checkout "])(
650
+ "rejects importing the regex %p into the broader SLO matcher",
651
+ (pattern: string) => {
652
+ expect(() => {
653
+ parse(
654
+ [{ ...base, monitorNamePattern: pattern }],
655
+ MonitorLabelRule,
656
+ ServiceLevelObjectiveLabelRule,
657
+ );
658
+ }).toThrow("without changing its meaning");
659
+ },
660
+ );
661
+
662
+ test("moves a plain SLO pattern to a regex-only rule type", () => {
663
+ expect(
664
+ parse(
665
+ [{ ...base, serviceLevelObjectiveNamePattern: "^checkout-" }],
666
+ ServiceLevelObjectiveLabelRule,
667
+ MonitorLabelRule,
668
+ ).items[0]!.json["monitorNamePattern"],
669
+ ).toBe("^checkout-");
670
+ });
671
+
672
+ test("accepts a wildcard, but not an invalid pattern, for SLO rules", () => {
673
+ expect(() => {
674
+ parse(
675
+ [{ ...base, serviceLevelObjectiveDescriptionPattern: "*tier 1*" }],
676
+ ServiceLevelObjectiveLabelRule,
677
+ );
678
+ }).not.toThrow();
679
+
680
+ expect(() => {
681
+ parse(
682
+ [{ ...base, serviceLevelObjectiveDescriptionPattern: "tier-(1" }],
683
+ ServiceLevelObjectiveLabelRule,
684
+ );
685
+ }).toThrow("is not a valid regular expression or wildcard pattern");
686
+ });
687
+
688
+ // A regex-only rule type still refuses the wildcard syntax outright.
689
+ test("still rejects a bare wildcard for a regex-only rule type", () => {
690
+ expect(() => {
691
+ parse([{ ...base, monitorNamePattern: "*checkout*" }], MonitorLabelRule);
692
+ }).toThrow("is not a valid regular expression.");
693
+ });
694
+
593
695
  test("preserves wildcard network patterns when importing network rules", () => {
594
696
  expect(
595
697
  parse(
@@ -4,8 +4,6 @@ import {
4
4
  formatErrorBudgetRemainingOfTotal,
5
5
  } from "../../../Utils/Slo/SloDuration";
6
6
 
7
- const MINUS_SIGN: string = "−";
8
-
9
7
  describe("SloDuration", () => {
10
8
  describe("formatDurationCompact", () => {
11
9
  it("renders seconds under a minute", () => {
@@ -74,12 +72,38 @@ describe("SloDuration", () => {
74
72
 
75
73
  it("renders an exhausted-but-not-overspent budget as 0s left", () => {
76
74
  expect(formatErrorBudgetRemaining(0)).toBe("0s left");
75
+ expect(formatErrorBudgetRemaining(-0)).toBe("0s left");
76
+ });
77
+
78
+ it("states the overage as a positive duration with over budget", () => {
79
+ expect(formatErrorBudgetRemaining(-750)).toBe("12m 30s over budget");
77
80
  });
78
81
 
79
- it("renders an overspent budget with a minus sign and over budget", () => {
80
- expect(formatErrorBudgetRemaining(-750)).toBe(
81
- `${MINUS_SIGN}12m 30s over budget`,
82
- );
82
+ it("keeps a multi-day overage readable", () => {
83
+ expect(formatErrorBudgetRemaining(-346980)).toBe("4d 23m over budget");
84
+ });
85
+
86
+ it.each([0.001, 0.4, 0.999])(
87
+ "distinguishes a positive %ps remainder from an empty budget",
88
+ (seconds: number) => {
89
+ expect(formatErrorBudgetRemaining(seconds)).toBe("under 1s left");
90
+ },
91
+ );
92
+
93
+ it.each([-0.001, -0.4, -0.999])(
94
+ "preserves a %ps overage instead of rounding it to zero",
95
+ (seconds: number) => {
96
+ expect(formatErrorBudgetRemaining(seconds)).toBe(
97
+ "under 1s over budget",
98
+ );
99
+ },
100
+ );
101
+
102
+ it("uses seconds at the one-second boundary without rounding up", () => {
103
+ expect(formatErrorBudgetRemaining(1)).toBe("1s left");
104
+ expect(formatErrorBudgetRemaining(-1)).toBe("1s over budget");
105
+ expect(formatErrorBudgetRemaining(59.9)).toBe("59s left");
106
+ expect(formatErrorBudgetRemaining(-59.9)).toBe("59s over budget");
83
107
  });
84
108
 
85
109
  it("returns null when the SLO has not been evaluated", () => {
@@ -90,6 +114,7 @@ describe("SloDuration", () => {
90
114
  it("returns null for non-finite values", () => {
91
115
  expect(formatErrorBudgetRemaining(Number.NaN)).toBeNull();
92
116
  expect(formatErrorBudgetRemaining(Number.POSITIVE_INFINITY)).toBeNull();
117
+ expect(formatErrorBudgetRemaining(Number.NEGATIVE_INFINITY)).toBeNull();
93
118
  });
94
119
 
95
120
  it("rejects a numeric string rather than coercing it", () => {
@@ -97,6 +122,11 @@ describe("SloDuration", () => {
97
122
  formatErrorBudgetRemaining("2592" as unknown as number),
98
123
  ).toBeNull();
99
124
  });
125
+
126
+ it("rejects booleans rather than treating them as zero or one", () => {
127
+ expect(formatErrorBudgetRemaining(false as unknown as number)).toBeNull();
128
+ expect(formatErrorBudgetRemaining(true as unknown as number)).toBeNull();
129
+ });
100
130
  });
101
131
 
102
132
  describe("formatErrorBudgetRemainingOfTotal", () => {
@@ -109,40 +139,101 @@ describe("SloDuration", () => {
109
139
  ).toBe("12m 30s left of 43m 12s");
110
140
  });
111
141
 
112
- it("keeps the total visible when the budget is overspent", () => {
142
+ it("identifies the allowance separately from how far over budget it is", () => {
113
143
  expect(
114
144
  formatErrorBudgetRemainingOfTotal({
115
145
  remainingSeconds: -750,
116
146
  totalSeconds: 2592,
117
147
  }),
118
- ).toBe(`${MINUS_SIGN}12m 30s over budget of 43m 12s`);
148
+ ).toBe("12m 30s over budget · 43m 12s allowed");
119
149
  });
120
150
 
121
- it("omits the total when it is unknown", () => {
151
+ it("explains the reported multi-day overage against its small allowance", () => {
122
152
  expect(
123
153
  formatErrorBudgetRemainingOfTotal({
124
- remainingSeconds: 750,
125
- totalSeconds: null,
154
+ remainingSeconds: -346980,
155
+ totalSeconds: 347,
126
156
  }),
127
- ).toBe("12m 30s left");
157
+ ).toBe("4d 23m over budget · 5m 47s allowed");
128
158
  });
129
159
 
130
- it("omits a zero total — a brand-new SLO has no elapsed window yet", () => {
160
+ it("retains the allowance at exactly zero without claiming an overage", () => {
131
161
  expect(
132
162
  formatErrorBudgetRemainingOfTotal({
133
163
  remainingSeconds: 0,
134
- totalSeconds: 0,
135
- }),
136
- ).toBe("0s left");
137
- });
138
-
139
- it("returns null when the SLO has not been evaluated", () => {
140
- expect(
141
- formatErrorBudgetRemainingOfTotal({
142
- remainingSeconds: null,
143
164
  totalSeconds: 2592,
144
165
  }),
145
- ).toBeNull();
146
- });
166
+ ).toBe("0s left of 43m 12s");
167
+ });
168
+
169
+ it.each([
170
+ [0.25, "under 1s left of under 1s"],
171
+ [-0.25, "under 1s over budget · under 1s allowed"],
172
+ [0, "0s left of under 1s"],
173
+ ])(
174
+ "does not round a subsecond allowance to zero for a %ps remainder",
175
+ (remainingSeconds: number, expected: string) => {
176
+ expect(
177
+ formatErrorBudgetRemainingOfTotal({
178
+ remainingSeconds,
179
+ totalSeconds: 0.5,
180
+ }),
181
+ ).toBe(expected);
182
+ },
183
+ );
184
+
185
+ it.each([
186
+ ["missing", undefined],
187
+ ["null", null],
188
+ ["zero", 0],
189
+ ["negative", -2592],
190
+ ["NaN", Number.NaN],
191
+ ["positive infinity", Number.POSITIVE_INFINITY],
192
+ ["negative infinity", Number.NEGATIVE_INFINITY],
193
+ ["numeric string", "2592" as unknown as number],
194
+ ["boolean", true as unknown as number],
195
+ ])(
196
+ "omits an unusable %s total while preserving the remaining or over-budget state",
197
+ (_label: string, totalSeconds: number | null | undefined) => {
198
+ expect(
199
+ formatErrorBudgetRemainingOfTotal({
200
+ remainingSeconds: 750,
201
+ totalSeconds,
202
+ }),
203
+ ).toBe("12m 30s left");
204
+ expect(
205
+ formatErrorBudgetRemainingOfTotal({
206
+ remainingSeconds: -750,
207
+ totalSeconds,
208
+ }),
209
+ ).toBe("12m 30s over budget");
210
+ expect(
211
+ formatErrorBudgetRemainingOfTotal({
212
+ remainingSeconds: 0,
213
+ totalSeconds,
214
+ }),
215
+ ).toBe("0s left");
216
+ },
217
+ );
218
+
219
+ it.each([
220
+ ["missing", undefined],
221
+ ["null", null],
222
+ ["NaN", Number.NaN],
223
+ ["positive infinity", Number.POSITIVE_INFINITY],
224
+ ["negative infinity", Number.NEGATIVE_INFINITY],
225
+ ["numeric string", "750" as unknown as number],
226
+ ["boolean", false as unknown as number],
227
+ ])(
228
+ "does not let a known total imply an evaluated budget when the remainder is %s",
229
+ (_label: string, remainingSeconds: number | null | undefined) => {
230
+ expect(
231
+ formatErrorBudgetRemainingOfTotal({
232
+ remainingSeconds,
233
+ totalSeconds: 2592,
234
+ }),
235
+ ).toBeNull();
236
+ },
237
+ );
147
238
  });
148
239
  });
@@ -58,7 +58,7 @@ const EVERY_NOTICE_INPUT: Array<{ name: string; data: SloNoticeData }> = [
58
58
  },
59
59
  },
60
60
  {
61
- name: "generic misconfigured",
61
+ name: "waiting for evaluation with monitors attached",
62
62
  data: { ...MEASURING, sloStatus: SloStatus.Misconfigured },
63
63
  },
64
64
  { name: "paused", data: { ...MEASURING, sloStatus: SloStatus.Paused } },
@@ -431,17 +431,100 @@ describe("SloHealth", () => {
431
431
  expect(notice?.body).toContain(SliType.MonitorUptime);
432
432
  });
433
433
 
434
- it("falls back to a generic reason for Misconfigured", () => {
435
- const notice: SloNotice | null = getSloNotice({
436
- isEnabled: true,
437
- sloStatus: SloStatus.Misconfigured,
438
- sliType: SliType.MonitorUptime,
439
- monitorCount: 1,
440
- targetPercentage: 99.9,
441
- lastEvaluatedAt: new Date(),
434
+ describe("waiting for evaluation after monitors are attached", () => {
435
+ it.each([
436
+ { monitorCount: 1, lastEvaluatedAt: null },
437
+ { monitorCount: 16, lastEvaluatedAt: null },
438
+ { monitorCount: 1, lastEvaluatedAt: undefined },
439
+ { monitorCount: 16, lastEvaluatedAt: undefined },
440
+ {
441
+ monitorCount: 1,
442
+ lastEvaluatedAt: new Date("2026-09-21T12:00:00.000Z"),
443
+ },
444
+ {
445
+ monitorCount: 16,
446
+ lastEvaluatedAt: "2026-09-21T12:00:00.000Z",
447
+ },
448
+ ])(
449
+ "is informational with $monitorCount monitors and lastEvaluatedAt=$lastEvaluatedAt",
450
+ ({
451
+ monitorCount,
452
+ lastEvaluatedAt,
453
+ }: {
454
+ monitorCount: number;
455
+ lastEvaluatedAt: Date | string | null | undefined;
456
+ }) => {
457
+ const notice: SloNotice | null = getSloNotice({
458
+ ...MEASURING,
459
+ sloStatus: SloStatus.Misconfigured,
460
+ monitorCount,
461
+ lastEvaluatedAt,
462
+ });
463
+
464
+ expect(notice?.type).toBe(SloNoticeType.Info);
465
+ expect(notice?.title).toBe("Waiting for SLO evaluation");
466
+ expect(notice?.body).toContain("Monitors are attached");
467
+ expect(notice?.body).toContain("every few minutes");
468
+ expect(notice?.body).toContain(
469
+ "once monitor status data is available and the next evaluation completes",
470
+ );
471
+ expect(notice?.action).toEqual({
472
+ label: "View monitors",
473
+ target: SloNoticeActionTarget.Monitors,
474
+ });
475
+ },
476
+ );
477
+
478
+ it.each([undefined, null])(
479
+ "retains the default monitor-uptime behavior when sliType is %s",
480
+ (sliType: undefined | null) => {
481
+ expect(
482
+ getSloNotice({
483
+ ...MEASURING,
484
+ sloStatus: SloStatus.Misconfigured,
485
+ sliType,
486
+ })?.type,
487
+ ).toBe(SloNoticeType.Info);
488
+ },
489
+ );
490
+
491
+ it.each([0, -1, 100, 101, Number.NaN, Infinity, null, undefined])(
492
+ "still warns about target %s even with monitors attached",
493
+ (targetPercentage: number | null | undefined) => {
494
+ const notice: SloNotice | null = getSloNotice({
495
+ ...MEASURING,
496
+ sloStatus: SloStatus.Misconfigured,
497
+ monitorCount: 16,
498
+ targetPercentage,
499
+ });
500
+
501
+ expect(notice?.type).toBe(SloNoticeType.Warning);
502
+ expect(notice?.title).toBe("The target is out of range");
503
+ expect(notice?.action?.target).toBe(SloNoticeActionTarget.Settings);
504
+ },
505
+ );
506
+
507
+ it("still warns about an unsupported SLI with monitors attached", () => {
508
+ const notice: SloNotice | null = getSloNotice({
509
+ ...MEASURING,
510
+ sloStatus: SloStatus.Misconfigured,
511
+ sliType: SliType.Metric,
512
+ monitorCount: 16,
513
+ });
514
+
515
+ expect(notice?.type).toBe(SloNoticeType.Warning);
516
+ expect(notice?.title).toBe("This SLO cannot be evaluated");
517
+ expect(notice?.action).toBeUndefined();
442
518
  });
443
519
 
444
- expect(notice?.title).toBe("This SLO cannot be evaluated");
520
+ it.each([SloStatus.Healthy, SloStatus.AtRisk, SloStatus.BudgetExhausted])(
521
+ "stops displaying the notice after evaluation reports %s",
522
+ (sloStatus: SloStatus) => {
523
+ expect(
524
+ getSloNotice({ ...MEASURING, sloStatus, monitorCount: 16 }),
525
+ ).toBeNull();
526
+ },
527
+ );
445
528
  });
446
529
 
447
530
  it("explains Paused", () => {
@@ -1,5 +1,6 @@
1
1
  import FilterCondition from "../../../Types/Filter/FilterCondition";
2
2
  import ObjectID from "../../../Types/ObjectID";
3
+ import MonitorType from "../../../Types/Monitor/MonitorType";
3
4
  import RuleCriteria, {
4
5
  RULE_CRITERIA_LEGACY_NEVER_MATCH_PATTERN,
5
6
  RULE_CRITERIA_SCHEMA_VERSION,
@@ -385,3 +386,111 @@ describe("getSloMonitorRuleCriteriaKey", () => {
385
386
  );
386
387
  });
387
388
  });
389
+
390
+ describe("SLO monitor type criteria descriptions", () => {
391
+ test.each(["", "Invalid monitor type"])(
392
+ "describes an invalid legacy monitor type as matching no monitors: %p",
393
+ (monitorType: string) => {
394
+ expect(
395
+ describeSloMonitorRuleCriteria({
396
+ rule: {
397
+ monitorType: monitorType as MonitorType,
398
+ monitorNamePattern: ".*",
399
+ },
400
+ }),
401
+ ).toBe(SLO_MONITOR_RULE_INVALID_CRITERIA_DESCRIPTION);
402
+ },
403
+ );
404
+
405
+ test.each([
406
+ { operator: RuleCriteriaOperator.Equals, value: "Invalid monitor type" },
407
+ { operator: RuleCriteriaOperator.NotEquals, value: "" },
408
+ { operator: RuleCriteriaOperator.Contains, value: MonitorType.API },
409
+ { operator: RuleCriteriaOperator.HasAnyOf, value: [MonitorType.API] },
410
+ ])(
411
+ "describes invalid type criteria as matching no monitors: %p",
412
+ (filter: Pick<RuleCriteriaFilter, "operator" | "value">) => {
413
+ expect(
414
+ describeSloMonitorRuleCriteria({
415
+ rule: {
416
+ criteria: criteria(FilterCondition.Any, [
417
+ { field: "monitorType", ...filter },
418
+ ]),
419
+ },
420
+ }),
421
+ ).toBe(SLO_MONITOR_RULE_INVALID_CRITERIA_DESCRIPTION);
422
+ },
423
+ );
424
+
425
+ test.each([RuleCriteriaOperator.Equals, RuleCriteriaOperator.NotEquals])(
426
+ "describes configured monitor type %s without looking up labels",
427
+ (operator: RuleCriteriaOperator) => {
428
+ const rule: SloMonitorRuleCriteriaCarrier = {
429
+ criteria: criteria(FilterCondition.All, [
430
+ { field: "monitorType", operator, value: MonitorType.API },
431
+ {
432
+ field: "monitorNamePattern",
433
+ operator: RuleCriteriaOperator.DoesNotMatchPattern,
434
+ value: "^staging-",
435
+ },
436
+ ]),
437
+ monitorType: MonitorType.Website,
438
+ };
439
+ expect(describeSloMonitorRuleCriteria({ rule })).toBe(
440
+ `Type ${SLO_MONITOR_RULE_OPERATOR_PHRASES[operator]} "API" AND Name does not match pattern "^staging-"`,
441
+ );
442
+ expect(getSloMonitorRuleLabelIds(rule)).toEqual([]);
443
+ },
444
+ );
445
+
446
+ test("describes a legacy monitor type combined with other attributes", () => {
447
+ expect(
448
+ describeSloMonitorRuleCriteria({
449
+ rule: {
450
+ monitorType: MonitorType.Website,
451
+ monitorLabels: [{ _id: PRODUCTION_LABEL_ID, name: "Production" }],
452
+ monitorNamePattern: "^checkout-",
453
+ },
454
+ }),
455
+ ).toBe(
456
+ 'Labels has any of "Production" AND Type is "Website" AND Name matches pattern "^checkout-"',
457
+ );
458
+ });
459
+
460
+ test("describes a type-only legacy rule as a configured condition", () => {
461
+ expect(
462
+ describeSloMonitorRuleCriteria({
463
+ rule: { monitorType: MonitorType.API },
464
+ }),
465
+ ).toBe('Type is "API"');
466
+ });
467
+
468
+ test("detects changes to legacy monitor types for audit and feed updates", () => {
469
+ const apiKey: string = getSloMonitorRuleCriteriaKey({
470
+ monitorType: MonitorType.API,
471
+ });
472
+ expect(apiKey).not.toBe(
473
+ getSloMonitorRuleCriteriaKey({ monitorType: MonitorType.Website }),
474
+ );
475
+ expect(apiKey).not.toBe(getSloMonitorRuleCriteriaKey({}));
476
+ expect(getSloMonitorRuleCriteriaKey({ monitorType: null })).toBe(
477
+ getSloMonitorRuleCriteriaKey({}),
478
+ );
479
+ });
480
+
481
+ test("ignores legacy monitor type shadows for configured rule identity", () => {
482
+ const configured: RuleCriteria = criteria(FilterCondition.All, [
483
+ {
484
+ field: "monitorType",
485
+ operator: RuleCriteriaOperator.Equals,
486
+ value: MonitorType.API,
487
+ },
488
+ ]);
489
+ expect(
490
+ getSloMonitorRuleCriteriaKey({
491
+ criteria: configured,
492
+ monitorType: MonitorType.Website,
493
+ }),
494
+ ).toBe(getSloMonitorRuleCriteriaKey({ criteria: configured }));
495
+ });
496
+ });
@@ -5,6 +5,7 @@ import SloWindowType from "../../../Types/ServiceLevelObjective/SloWindowType";
5
5
  import {
6
6
  getSliTypeText,
7
7
  getSloAtRiskText,
8
+ getSloBudgetRemainingText,
8
9
  getSloDowntimeStatusesText,
9
10
  getSloHeadline,
10
11
  getSloMonitorCountText,
@@ -79,6 +80,69 @@ describe("getSloTargetText", () => {
79
80
  });
80
81
  });
81
82
 
83
+ describe("getSloBudgetRemainingText", () => {
84
+ test.each([-99882.8, -120, -0.001, -Number.MIN_VALUE, -Number.MAX_VALUE])(
85
+ "explains a %p%% remaining budget as exceeded, even if it rounds to zero",
86
+ (percentage: number) => {
87
+ expect(getSloBudgetRemainingText(percentage)).toBe("Budget exceeded");
88
+ },
89
+ );
90
+
91
+ test.each([0, -0])(
92
+ "distinguishes an exactly exhausted %p%% budget from an overage",
93
+ (percentage: number) => {
94
+ expect(getSloBudgetRemainingText(percentage)).toBe("No budget left");
95
+ },
96
+ );
97
+
98
+ test.each([Number.MIN_VALUE, 0.001, 0.049, 0.05, 0.0999])(
99
+ "keeps a small positive %p%% budget distinct from no budget left",
100
+ (percentage: number) => {
101
+ expect(getSloBudgetRemainingText(percentage)).toBe("<0.1%");
102
+ },
103
+ );
104
+
105
+ test.each([
106
+ [0.1, "0.1%"],
107
+ [0.14, "0.1%"],
108
+ [0.15, "0.2%"],
109
+ [15, "15%"],
110
+ [20, "20%"],
111
+ [62.5, "62.5%"],
112
+ [62.56, "62.6%"],
113
+ [100, "100%"],
114
+ ])(
115
+ "shows a positive %p%% remainder as %s without unnecessary decimal places",
116
+ (percentage: number, expected: string) => {
117
+ expect(getSloBudgetRemainingText(percentage)).toBe(expected);
118
+ },
119
+ );
120
+
121
+ test.each([100.01, 150, Number.MAX_VALUE])(
122
+ "never claims more than a full budget when the input is %p%%",
123
+ (percentage: number) => {
124
+ expect(getSloBudgetRemainingText(percentage)).toBe("100%");
125
+ },
126
+ );
127
+
128
+ test.each([
129
+ ["missing", undefined],
130
+ ["null", null],
131
+ ["NaN", Number.NaN],
132
+ ["positive infinity", Number.POSITIVE_INFINITY],
133
+ ["negative infinity", Number.NEGATIVE_INFINITY],
134
+ ["numeric string", "62.5" as unknown as number],
135
+ ["negative numeric string", "-99882.8" as unknown as number],
136
+ ["true", true as unknown as number],
137
+ ["false", false as unknown as number],
138
+ ])(
139
+ "makes no budget claim for %s data",
140
+ (_label: string, percentage: number | null | undefined) => {
141
+ expect(getSloBudgetRemainingText(percentage)).toBeNull();
142
+ },
143
+ );
144
+ });
145
+
82
146
  describe("getSloMonitorCountText", () => {
83
147
  test.each([
84
148
  [0, "No monitors"],
@@ -7,13 +7,30 @@ import { FindOperator } from "typeorm";
7
7
  export default class Route extends DatabaseProperty {
8
8
  private static readonly SCHEME_PREFIX: RegExp = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
9
9
 
10
+ /*
11
+ * True when the value, read on its own as a relative reference, would start
12
+ * with a scheme (RFC 3986 4.2): "javascript:alert(1)", but equally
13
+ * "bot123:ABC/sendMessage". Route refuses such values so a scheme can never
14
+ * reach a navigation sink; URL uses this to know when a path it parsed has
15
+ * to keep its leading "/".
16
+ */
17
+ public static hasSchemePrefix(route: string): boolean {
18
+ return Route.SCHEME_PREFIX.test(route);
19
+ }
20
+
10
21
  private static validateRoute(route: string): void {
11
- if (Route.SCHEME_PREFIX.test(route)) {
22
+ if (Route.hasSchemePrefix(route)) {
12
23
  throw new BadDataException(`Invalid route: ${route}`);
13
24
  }
14
25
 
26
+ /*
27
+ * RFC 3986 path characters: unreserved (which includes "~"), sub-delims,
28
+ * ":" and "@", plus "/", "?", "#", "[", "]" and "%" for the rest of a
29
+ * path-and-query. "~" was missing, so "https://example.com/~user" could
30
+ * not be parsed at all.
31
+ */
15
32
  const matchRouteCharacters: RegExp =
16
- /^[a-zA-Z_\d\-!#$%&'()*+,./:;=?@[\]]*$/;
33
+ /^[a-zA-Z_\d\-!#$%&'()*+,./:;=?@[\]~]*$/;
17
34
 
18
35
  if (route && !matchRouteCharacters.test(route)) {
19
36
  throw new BadDataException(`Invalid route: ${route}`);