@oneuptime/common 10.2.15 → 10.2.17

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 (29) hide show
  1. package/Server/API/DashboardAPI.ts +0 -6
  2. package/Server/Services/DockerHostService.ts +91 -0
  3. package/Server/Services/IncidentService.ts +60 -23
  4. package/Server/Services/KubernetesClusterService.ts +92 -0
  5. package/Types/Dashboard/DashboardComponents/DashboardValueComponent.ts +15 -0
  6. package/Types/Dashboard/DashboardTemplates.ts +260 -971
  7. package/Types/Dashboard/DashboardVariable.ts +0 -8
  8. package/UI/Components/Charts/Utils/DataPoint.ts +0 -0
  9. package/Utils/Dashboard/Components/DashboardValueComponent.ts +36 -2
  10. package/Utils/ValueFormatter.ts +57 -0
  11. package/build/dist/Server/API/DashboardAPI.js +0 -3
  12. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  13. package/build/dist/Server/Services/DockerHostService.js +73 -0
  14. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  15. package/build/dist/Server/Services/IncidentService.js +55 -18
  16. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  17. package/build/dist/Server/Services/KubernetesClusterService.js +74 -0
  18. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  19. package/build/dist/Types/Dashboard/DashboardComponents/DashboardValueComponent.js +14 -1
  20. package/build/dist/Types/Dashboard/DashboardComponents/DashboardValueComponent.js.map +1 -1
  21. package/build/dist/Types/Dashboard/DashboardTemplates.js +240 -928
  22. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  23. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +0 -0
  24. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  25. package/build/dist/Utils/Dashboard/Components/DashboardValueComponent.js +31 -1
  26. package/build/dist/Utils/Dashboard/Components/DashboardValueComponent.js.map +1 -1
  27. package/build/dist/Utils/ValueFormatter.js +51 -0
  28. package/build/dist/Utils/ValueFormatter.js.map +1 -1
  29. package/package.json +1 -1
@@ -346,11 +346,6 @@ export default class DashboardAPI extends BaseAPI<
346
346
  throw new BadDataException("attributeKey is required.");
347
347
  }
348
348
 
349
- const metricNameRaw: string | undefined =
350
- req.body && (req.body["metricName"] as string);
351
- const metricName: string | undefined =
352
- metricNameRaw && metricNameRaw.trim() ? metricNameRaw : undefined;
353
-
354
349
  const telemetryTypeRaw: string | undefined =
355
350
  req.body && (req.body["telemetryType"] as string);
356
351
  let telemetryType: TelemetryType = TelemetryType.Metric;
@@ -386,7 +381,6 @@ export default class DashboardAPI extends BaseAPI<
386
381
  projectId: dashboard.projectId,
387
382
  telemetryType,
388
383
  attributeKey: attributeKey.trim(),
389
- metricName,
390
384
  });
391
385
 
392
386
  return Response.sendJsonObjectResponse(req, res, {
@@ -1,16 +1,21 @@
1
1
  import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/DockerHost";
3
+ import Label from "../../Models/DatabaseModels/Label";
3
4
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
4
5
  import ObjectID from "../../Types/ObjectID";
5
6
  import QueryHelper from "../Types/Database/QueryHelper";
6
7
  import OneUptimeDate from "../../Types/Date";
7
8
  import LIMIT_MAX from "../../Types/Database/LimitMax";
8
9
  import GlobalCache from "../Infrastructure/GlobalCache";
10
+ import logger from "../Utils/Logger";
9
11
  import crypto from "crypto";
10
12
 
11
13
  const LAST_SEEN_CACHE_NAMESPACE: string = "docker-host-last-seen";
12
14
  const LAST_SEEN_THROTTLE_SECONDS: number = 60;
13
15
 
16
+ const LABELS_APPLIED_CACHE_NAMESPACE: string = "docker-host-labels-applied";
17
+ const LABELS_APPLIED_CACHE_TTL_SECONDS: number = 60;
18
+
14
19
  export class Service extends DatabaseService<Model> {
15
20
  public constructor() {
16
21
  super(Model);
@@ -145,6 +150,83 @@ export class Service extends DatabaseService<Model> {
145
150
  });
146
151
  }
147
152
 
153
+ /**
154
+ * Additively attach labels to a Docker host. Existing labels are
155
+ * never removed — manual labels set via the UI survive ingest. The
156
+ * set of labelIds passed in is fingerprinted and cached for 60s so
157
+ * the common case (steady-state collector pushing the same label
158
+ * set every batch) costs one in-memory lookup, not a join-table
159
+ * scan.
160
+ */
161
+ @CaptureSpan()
162
+ public async attachLabels(data: {
163
+ dockerHostId: ObjectID;
164
+ labelIds: Array<ObjectID>;
165
+ }): Promise<void> {
166
+ if (!data.labelIds || data.labelIds.length === 0) {
167
+ return;
168
+ }
169
+
170
+ const cacheKey: string = data.dockerHostId.toString();
171
+ const fingerprint: string = fingerprintLabelIds(data.labelIds);
172
+ const cached: string | null = await GlobalCache.getString(
173
+ LABELS_APPLIED_CACHE_NAMESPACE,
174
+ cacheKey,
175
+ );
176
+ if (cached === fingerprint) {
177
+ return;
178
+ }
179
+
180
+ try {
181
+ const dockerHostIdStr: string = data.dockerHostId.toString();
182
+ const existingLabels: Array<Label> = await this.getRepository()
183
+ .createQueryBuilder()
184
+ .relation(Model, "labels")
185
+ .of(dockerHostIdStr)
186
+ .loadMany();
187
+
188
+ const existingIds: Set<string> = new Set();
189
+ for (const lbl of existingLabels) {
190
+ const idStr: string | undefined = lbl._id?.toString();
191
+ if (idStr) {
192
+ existingIds.add(idStr);
193
+ }
194
+ }
195
+
196
+ const toAddIds: Array<string> = [];
197
+ const seen: Set<string> = new Set();
198
+ for (const id of data.labelIds) {
199
+ const idStr: string = id.toString();
200
+ if (existingIds.has(idStr) || seen.has(idStr)) {
201
+ continue;
202
+ }
203
+ seen.add(idStr);
204
+ toAddIds.push(idStr);
205
+ }
206
+
207
+ if (toAddIds.length > 0) {
208
+ await this.getRepository()
209
+ .createQueryBuilder()
210
+ .relation(Model, "labels")
211
+ .of(dockerHostIdStr)
212
+ .add(toAddIds);
213
+ }
214
+
215
+ await GlobalCache.setString(
216
+ LABELS_APPLIED_CACHE_NAMESPACE,
217
+ cacheKey,
218
+ fingerprint,
219
+ { expiresInSeconds: LABELS_APPLIED_CACHE_TTL_SECONDS },
220
+ );
221
+ } catch (err) {
222
+ logger.warn(
223
+ `DockerHostService.attachLabels failed for docker host ${data.dockerHostId.toString()}: ${
224
+ err instanceof Error ? err.message : String(err)
225
+ }`,
226
+ );
227
+ }
228
+ }
229
+
148
230
  @CaptureSpan()
149
231
  public async markDisconnectedHosts(): Promise<void> {
150
232
  const fiveMinutesAgo: Date = OneUptimeDate.addRemoveMinutes(
@@ -183,4 +265,13 @@ export class Service extends DatabaseService<Model> {
183
265
  }
184
266
  }
185
267
 
268
+ function fingerprintLabelIds(labelIds: Array<ObjectID>): string {
269
+ const sorted: Array<string> = labelIds
270
+ .map((id: ObjectID) => {
271
+ return id.toString();
272
+ })
273
+ .sort();
274
+ return crypto.createHash("sha1").update(sorted.join(",")).digest("hex");
275
+ }
276
+
186
277
  export default new Service();
@@ -49,6 +49,7 @@ import Metric, {
49
49
  import OneUptimeDate from "../../Types/Date";
50
50
  import TelemetryUtil from "../Utils/Telemetry/Telemetry";
51
51
  import logger, { LogAttributes } from "../Utils/Logger";
52
+ import NotEqual from "../../Types/BaseDatabase/NotEqual";
52
53
  import IncidentFeedService from "./IncidentFeedService";
53
54
  import IncidentSlaService from "./IncidentSlaService";
54
55
  import { setIsPublicForMarkdownImages } from "../Utils/InlineImageAccessTokenSync";
@@ -2563,12 +2564,25 @@ ${incidentSeverity.name}
2563
2564
  const firstIncidentStateTimeline: IncidentStateTimeline | undefined =
2564
2565
  incidentStateTimelines[0];
2565
2566
 
2566
- // delete all the incident metrics with this incident id because it's a refresh.
2567
-
2567
+ /*
2568
+ * Delete the existing metrics for this incident so the time-varying
2569
+ * ones (TimeToAcknowledge / TimeToResolve / IncidentDuration /
2570
+ * TimeInState) get rewritten with the latest state-timeline values
2571
+ * on this refresh. IncidentCount is excluded from the delete: it is
2572
+ * a constant `value = 1` keyed by `serviceId + bucketTime` that
2573
+ * never changes. Re-emitting it across refreshes inflated the
2574
+ * 1-minute aggregating materialized view (`MetricItemAggMV1m_mv`),
2575
+ * because the MV trigger only fires on inserts — ALTER DELETE
2576
+ * mutations don't roll back the previously-accumulated
2577
+ * `sumState` / `countState`. That's why the Incident Dashboard
2578
+ * sum-of-IncidentCount widget read ~33% higher than the actual
2579
+ * unique-incident count.
2580
+ */
2568
2581
  await MetricService.deleteBy({
2569
2582
  query: {
2570
2583
  projectId: incident.projectId,
2571
2584
  serviceId: data.incidentId,
2585
+ name: new NotEqual<string>(IncidentMetricType.IncidentCount),
2572
2586
  },
2573
2587
  props: {
2574
2588
  isRoot: true,
@@ -2623,36 +2637,59 @@ ${incidentSeverity.name}
2623
2637
  ownerTeamNames: ownerTeamNames.join(", "),
2624
2638
  };
2625
2639
 
2626
- const incidentCountMetric: Metric = new Metric();
2640
+ /*
2641
+ * Only emit IncidentCount on the very first refresh (i.e. when no
2642
+ * existing IncidentCount row is present for this serviceId). See
2643
+ * the delete comment above — emitting it on every refresh would
2644
+ * accumulate phantom `sumState` entries in the MV that ALTER
2645
+ * DELETE can't undo. By keeping the original row alive and never
2646
+ * re-emitting, the dashboard Sum stays equal to the true count of
2647
+ * distinct incidents.
2648
+ */
2649
+ const existingIncidentCount: PositiveNumber = await MetricService.countBy({
2650
+ query: {
2651
+ projectId: incident.projectId,
2652
+ serviceId: data.incidentId,
2653
+ name: IncidentMetricType.IncidentCount,
2654
+ },
2655
+ skip: 0,
2656
+ limit: 1,
2657
+ props: {
2658
+ isRoot: true,
2659
+ },
2660
+ });
2627
2661
 
2628
- incidentCountMetric.projectId = incident.projectId;
2629
- incidentCountMetric.serviceId = incident.id!;
2630
- incidentCountMetric.serviceType = ServiceType.Incident;
2631
- incidentCountMetric.name = IncidentMetricType.IncidentCount;
2632
- incidentCountMetric.value = 1;
2633
- incidentCountMetric.attributes = { ...baseMetricAttributes };
2634
- incidentCountMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
2635
- incidentCountMetric.attributes,
2636
- );
2662
+ if (existingIncidentCount.toNumber() === 0) {
2663
+ const incidentCountMetric: Metric = new Metric();
2664
+
2665
+ incidentCountMetric.projectId = incident.projectId;
2666
+ incidentCountMetric.serviceId = incident.id!;
2667
+ incidentCountMetric.serviceType = ServiceType.Incident;
2668
+ incidentCountMetric.name = IncidentMetricType.IncidentCount;
2669
+ incidentCountMetric.value = 1;
2670
+ incidentCountMetric.attributes = { ...baseMetricAttributes };
2671
+ incidentCountMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
2672
+ incidentCountMetric.attributes,
2673
+ );
2637
2674
 
2638
- incidentCountMetric.time = incidentStartsAt;
2639
- incidentCountMetric.timeUnixNano = OneUptimeDate.toUnixNano(
2640
- incidentCountMetric.time,
2641
- );
2642
- incidentCountMetric.metricPointType = MetricPointType.Sum;
2643
- incidentCountMetric.retentionDate = incidentMetricRetentionDate;
2675
+ incidentCountMetric.time = incidentStartsAt;
2676
+ incidentCountMetric.timeUnixNano = OneUptimeDate.toUnixNano(
2677
+ incidentCountMetric.time,
2678
+ );
2679
+ incidentCountMetric.metricPointType = MetricPointType.Sum;
2680
+ incidentCountMetric.retentionDate = incidentMetricRetentionDate;
2644
2681
 
2645
- itemsToSave.push(incidentCountMetric);
2682
+ itemsToSave.push(incidentCountMetric);
2683
+ }
2646
2684
 
2647
- // add metric type for this to map.
2685
+ // Always register the metric type so it shows up in the type catalog.
2648
2686
  const metricType: MetricType = new MetricType();
2649
- metricType.name = incidentCountMetric.name;
2687
+ metricType.name = IncidentMetricType.IncidentCount;
2650
2688
  metricType.description = "Number of incidents created";
2651
2689
  metricType.unit = "";
2652
2690
  metricType.services = [];
2653
2691
 
2654
- // add to map.
2655
- metricTypesMap[incidentCountMetric.name] = metricType;
2692
+ metricTypesMap[IncidentMetricType.IncidentCount] = metricType;
2656
2693
 
2657
2694
  // is the incident acknowledged?
2658
2695
  const isIncidentAcknowledged: boolean = incidentStateTimelines.some(
@@ -1,15 +1,21 @@
1
1
  import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/KubernetesCluster";
3
+ import Label from "../../Models/DatabaseModels/Label";
3
4
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
4
5
  import ObjectID from "../../Types/ObjectID";
5
6
  import QueryHelper from "../Types/Database/QueryHelper";
6
7
  import OneUptimeDate from "../../Types/Date";
7
8
  import LIMIT_MAX from "../../Types/Database/LimitMax";
8
9
  import GlobalCache from "../Infrastructure/GlobalCache";
10
+ import logger from "../Utils/Logger";
11
+ import crypto from "crypto";
9
12
 
10
13
  const LAST_SEEN_CACHE_NAMESPACE: string = "k8s-cluster-last-seen";
11
14
  const LAST_SEEN_THROTTLE_SECONDS: number = 60;
12
15
 
16
+ const LABELS_APPLIED_CACHE_NAMESPACE: string = "k8s-cluster-labels-applied";
17
+ const LABELS_APPLIED_CACHE_TTL_SECONDS: number = 60;
18
+
13
19
  export class Service extends DatabaseService<Model> {
14
20
  public constructor() {
15
21
  super(Model);
@@ -116,6 +122,83 @@ export class Service extends DatabaseService<Model> {
116
122
  });
117
123
  }
118
124
 
125
+ /**
126
+ * Additively attach labels to a Kubernetes cluster. Existing labels
127
+ * are never removed — manual labels set via the UI survive ingest.
128
+ * The set of labelIds passed in is fingerprinted and cached for 60s
129
+ * so the common case (steady-state collector pushing the same label
130
+ * set every batch) costs one in-memory lookup, not a join-table
131
+ * scan.
132
+ */
133
+ @CaptureSpan()
134
+ public async attachLabels(data: {
135
+ kubernetesClusterId: ObjectID;
136
+ labelIds: Array<ObjectID>;
137
+ }): Promise<void> {
138
+ if (!data.labelIds || data.labelIds.length === 0) {
139
+ return;
140
+ }
141
+
142
+ const cacheKey: string = data.kubernetesClusterId.toString();
143
+ const fingerprint: string = fingerprintLabelIds(data.labelIds);
144
+ const cached: string | null = await GlobalCache.getString(
145
+ LABELS_APPLIED_CACHE_NAMESPACE,
146
+ cacheKey,
147
+ );
148
+ if (cached === fingerprint) {
149
+ return;
150
+ }
151
+
152
+ try {
153
+ const clusterIdStr: string = data.kubernetesClusterId.toString();
154
+ const existingLabels: Array<Label> = await this.getRepository()
155
+ .createQueryBuilder()
156
+ .relation(Model, "labels")
157
+ .of(clusterIdStr)
158
+ .loadMany();
159
+
160
+ const existingIds: Set<string> = new Set();
161
+ for (const lbl of existingLabels) {
162
+ const idStr: string | undefined = lbl._id?.toString();
163
+ if (idStr) {
164
+ existingIds.add(idStr);
165
+ }
166
+ }
167
+
168
+ const toAddIds: Array<string> = [];
169
+ const seen: Set<string> = new Set();
170
+ for (const id of data.labelIds) {
171
+ const idStr: string = id.toString();
172
+ if (existingIds.has(idStr) || seen.has(idStr)) {
173
+ continue;
174
+ }
175
+ seen.add(idStr);
176
+ toAddIds.push(idStr);
177
+ }
178
+
179
+ if (toAddIds.length > 0) {
180
+ await this.getRepository()
181
+ .createQueryBuilder()
182
+ .relation(Model, "labels")
183
+ .of(clusterIdStr)
184
+ .add(toAddIds);
185
+ }
186
+
187
+ await GlobalCache.setString(
188
+ LABELS_APPLIED_CACHE_NAMESPACE,
189
+ cacheKey,
190
+ fingerprint,
191
+ { expiresInSeconds: LABELS_APPLIED_CACHE_TTL_SECONDS },
192
+ );
193
+ } catch (err) {
194
+ logger.warn(
195
+ `KubernetesClusterService.attachLabels failed for cluster ${data.kubernetesClusterId.toString()}: ${
196
+ err instanceof Error ? err.message : String(err)
197
+ }`,
198
+ );
199
+ }
200
+ }
201
+
119
202
  @CaptureSpan()
120
203
  public async markDisconnectedClusters(): Promise<void> {
121
204
  const fiveMinutesAgo: Date = OneUptimeDate.addRemoveMinutes(
@@ -154,4 +237,13 @@ export class Service extends DatabaseService<Model> {
154
237
  }
155
238
  }
156
239
 
240
+ function fingerprintLabelIds(labelIds: Array<ObjectID>): string {
241
+ const sorted: Array<string> = labelIds
242
+ .map((id: ObjectID) => {
243
+ return id.toString();
244
+ })
245
+ .sort();
246
+ return crypto.createHash("sha1").update(sorted.join(",")).digest("hex");
247
+ }
248
+
157
249
  export default new Service();
@@ -3,6 +3,20 @@ import ObjectID from "../../ObjectID";
3
3
  import DashboardComponentType from "../DashboardComponentType";
4
4
  import BaseComponent from "./DashboardBaseComponent";
5
5
 
6
+ /*
7
+ * "Auto" defers to a metric-name heuristic in ValueFormatter
8
+ * (`isHigherWorseMetric`) — that lets templates skip the field unless
9
+ * they need to override. "HigherIsBetter" forces ↑ = green / ↓ = red
10
+ * (e.g. uptime, throughput, success rate). "HigherIsWorse" inverts so a
11
+ * rising incident count, error rate, latency, or restart count is shown
12
+ * in red.
13
+ */
14
+ export enum DashboardValueTrendDirection {
15
+ Auto = "Auto",
16
+ HigherIsBetter = "HigherIsBetter",
17
+ HigherIsWorse = "HigherIsWorse",
18
+ }
19
+
6
20
  export default interface DashboardValueComponent extends BaseComponent {
7
21
  componentType: DashboardComponentType.Value;
8
22
  componentId: ObjectID;
@@ -11,5 +25,6 @@ export default interface DashboardValueComponent extends BaseComponent {
11
25
  title: string;
12
26
  warningThreshold?: number | undefined;
13
27
  criticalThreshold?: number | undefined;
28
+ trendDirection?: DashboardValueTrendDirection | undefined;
14
29
  };
15
30
  }