@oneuptime/common 10.2.16 → 10.2.18

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.
@@ -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();
@@ -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
  }
@@ -10,6 +10,7 @@ import MetricsAggregationType from "../Metrics/MetricsAggregationType";
10
10
  import IncidentMetricType from "../Incident/IncidentMetricType";
11
11
  import MonitorMetricType from "../Monitor/MonitorMetricType";
12
12
  import MetricDashboardMetricType from "../Metrics/MetricDashboardMetricType";
13
+ import { DashboardValueTrendDirection } from "./DashboardComponents/DashboardValueComponent";
13
14
 
14
15
  /*
15
16
  * Trace / Exception / Profiles entries are intentionally not in this
@@ -147,6 +148,13 @@ function createValueComponent(data: {
147
148
  left: number;
148
149
  width: number;
149
150
  metricConfig?: MetricConfig;
151
+ /*
152
+ * Per-widget override for the trend-arrow colour. Leave `undefined` to
153
+ * let the renderer apply its metric-name heuristic (incident counts,
154
+ * error rates, latency, CPU/memory usage flip the colour); set
155
+ * explicitly when the heuristic would guess wrong.
156
+ */
157
+ trendDirection?: DashboardValueTrendDirection;
150
158
  }): DashboardBaseComponent {
151
159
  return {
152
160
  _type: ObjectType.DashboardComponent,
@@ -168,6 +176,7 @@ function createValueComponent(data: {
168
176
  groupBy: undefined,
169
177
  },
170
178
  },
179
+ trendDirection: data.trendDirection,
171
180
  },
172
181
  };
173
182
  }
@@ -390,6 +399,7 @@ function createMonitorDashboardConfig(): DashboardViewConfig {
390
399
  aggregationType: MetricsAggregationType.Avg,
391
400
  legendUnit: "ms",
392
401
  },
402
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
393
403
  }),
394
404
  /*
395
405
  * IsOnline is emitted as 0/1 with unit "" by MonitorMetricUtil, so
@@ -407,6 +417,7 @@ function createMonitorDashboardConfig(): DashboardViewConfig {
407
417
  metricName: MonitorMetricType.IsOnline,
408
418
  aggregationType: MetricsAggregationType.Avg,
409
419
  },
420
+ trendDirection: DashboardValueTrendDirection.HigherIsBetter,
410
421
  }),
411
422
  /*
412
423
  * ResponseStatusCode is the literal HTTP status code (200, 404,
@@ -424,6 +435,7 @@ function createMonitorDashboardConfig(): DashboardViewConfig {
424
435
  metricName: MonitorMetricType.ResponseStatusCode,
425
436
  aggregationType: MetricsAggregationType.Count,
426
437
  },
438
+ trendDirection: DashboardValueTrendDirection.HigherIsBetter,
427
439
  }),
428
440
  createValueComponent({
429
441
  title: "Execution Time",
@@ -435,6 +447,7 @@ function createMonitorDashboardConfig(): DashboardViewConfig {
435
447
  aggregationType: MetricsAggregationType.Avg,
436
448
  legendUnit: "ms",
437
449
  },
450
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
438
451
  }),
439
452
 
440
453
  // Row 2-4: Charts
@@ -586,7 +599,7 @@ function createIncidentDashboardConfig(): DashboardViewConfig {
586
599
  isBold: true,
587
600
  }),
588
601
 
589
- // Row 1: Key incident metrics
602
+ // Row 1: Key incident metrics — every one is "higher = worse".
590
603
  createValueComponent({
591
604
  title: "Incident Count",
592
605
  top: 1,
@@ -596,6 +609,7 @@ function createIncidentDashboardConfig(): DashboardViewConfig {
596
609
  metricName: IncidentMetricType.IncidentCount,
597
610
  aggregationType: MetricsAggregationType.Sum,
598
611
  },
612
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
599
613
  }),
600
614
  createValueComponent({
601
615
  title: "MTTR",
@@ -606,6 +620,7 @@ function createIncidentDashboardConfig(): DashboardViewConfig {
606
620
  metricName: IncidentMetricType.TimeToResolve,
607
621
  aggregationType: MetricsAggregationType.Avg,
608
622
  },
623
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
609
624
  }),
610
625
  createValueComponent({
611
626
  title: "MTTA",
@@ -616,6 +631,7 @@ function createIncidentDashboardConfig(): DashboardViewConfig {
616
631
  metricName: IncidentMetricType.TimeToAcknowledge,
617
632
  aggregationType: MetricsAggregationType.Avg,
618
633
  },
634
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
619
635
  }),
620
636
  createValueComponent({
621
637
  title: "Avg Duration",
@@ -626,6 +642,7 @@ function createIncidentDashboardConfig(): DashboardViewConfig {
626
642
  metricName: IncidentMetricType.IncidentDuration,
627
643
  aggregationType: MetricsAggregationType.Avg,
628
644
  },
645
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
629
646
  }),
630
647
 
631
648
  // Row 2-4: Incident trends
@@ -843,6 +860,7 @@ function createKubernetesDashboardConfig(): DashboardViewConfig {
843
860
  /*
844
861
  * Row 1: Key cluster metrics — averages render with proper units via
845
862
  * ValueFormatter (CPU utilization → "%", memory.usage → "MB"/"GB").
863
+ * All four are "higher = worse" (closer to capacity = bad).
846
864
  */
847
865
  createValueComponent({
848
866
  title: "Pod CPU (avg)",
@@ -853,6 +871,7 @@ function createKubernetesDashboardConfig(): DashboardViewConfig {
853
871
  metricName: "k8s.pod.cpu.utilization",
854
872
  aggregationType: MetricsAggregationType.Avg,
855
873
  },
874
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
856
875
  }),
857
876
  createValueComponent({
858
877
  title: "Pod Memory (avg)",
@@ -863,6 +882,7 @@ function createKubernetesDashboardConfig(): DashboardViewConfig {
863
882
  metricName: "k8s.pod.memory.usage",
864
883
  aggregationType: MetricsAggregationType.Avg,
865
884
  },
885
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
866
886
  }),
867
887
  createValueComponent({
868
888
  title: "Node CPU (avg)",
@@ -873,6 +893,7 @@ function createKubernetesDashboardConfig(): DashboardViewConfig {
873
893
  metricName: "k8s.node.cpu.utilization",
874
894
  aggregationType: MetricsAggregationType.Avg,
875
895
  },
896
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
876
897
  }),
877
898
  createValueComponent({
878
899
  title: "Node Memory (avg)",
@@ -883,6 +904,7 @@ function createKubernetesDashboardConfig(): DashboardViewConfig {
883
904
  metricName: "k8s.node.memory.usage",
884
905
  aggregationType: MetricsAggregationType.Avg,
885
906
  },
907
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
886
908
  }),
887
909
 
888
910
  // Row 2-4: Resource usage charts
@@ -1075,7 +1097,11 @@ function createMetricsDashboardConfig(): DashboardViewConfig {
1075
1097
  isBold: true,
1076
1098
  }),
1077
1099
 
1078
- // Row 1: Key HTTP metrics
1100
+ /*
1101
+ * Row 1: Key HTTP metrics. Request volume rising is generally a
1102
+ * sign of activity (good); latency, errors, and active in-flight
1103
+ * requests rising signal saturation or trouble (bad).
1104
+ */
1079
1105
  createValueComponent({
1080
1106
  title: "Request Rate",
1081
1107
  top: 1,
@@ -1086,6 +1112,7 @@ function createMetricsDashboardConfig(): DashboardViewConfig {
1086
1112
  aggregationType: MetricsAggregationType.Sum,
1087
1113
  legendUnit: "req/s",
1088
1114
  },
1115
+ trendDirection: DashboardValueTrendDirection.HigherIsBetter,
1089
1116
  }),
1090
1117
  createValueComponent({
1091
1118
  title: "Avg Latency",
@@ -1097,6 +1124,7 @@ function createMetricsDashboardConfig(): DashboardViewConfig {
1097
1124
  aggregationType: MetricsAggregationType.Avg,
1098
1125
  legendUnit: "ms",
1099
1126
  },
1127
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
1100
1128
  }),
1101
1129
  createValueComponent({
1102
1130
  title: "Error Rate",
@@ -1108,6 +1136,7 @@ function createMetricsDashboardConfig(): DashboardViewConfig {
1108
1136
  aggregationType: MetricsAggregationType.Avg,
1109
1137
  legendUnit: "%",
1110
1138
  },
1139
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
1111
1140
  }),
1112
1141
  createValueComponent({
1113
1142
  title: "Active Requests",
@@ -1118,6 +1147,7 @@ function createMetricsDashboardConfig(): DashboardViewConfig {
1118
1147
  metricName: MetricDashboardMetricType.HttpActiveRequests,
1119
1148
  aggregationType: MetricsAggregationType.Avg,
1120
1149
  },
1150
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
1121
1151
  }),
1122
1152
 
1123
1153
  // Row 2-4: HTTP request charts
@@ -1230,6 +1260,7 @@ function createMetricsDashboardConfig(): DashboardViewConfig {
1230
1260
  metricName: MetricDashboardMetricType.SystemMemoryUsage,
1231
1261
  aggregationType: MetricsAggregationType.Avg,
1232
1262
  },
1263
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
1233
1264
  }),
1234
1265
  createChartComponent({
1235
1266
  title: "CPU Usage Over Time",
@@ -3,7 +3,9 @@ import {
3
3
  ComponentArgumentSection,
4
4
  ComponentInputType,
5
5
  } from "../../../Types/Dashboard/DashboardComponents/ComponentArgument";
6
- import DashboardValueComponent from "../../../Types/Dashboard/DashboardComponents/DashboardValueComponent";
6
+ import DashboardValueComponent, {
7
+ DashboardValueTrendDirection,
8
+ } from "../../../Types/Dashboard/DashboardComponents/DashboardValueComponent";
7
9
  import DashboardComponentType from "../../../Types/Dashboard/DashboardComponentType";
8
10
  import { ObjectType } from "../../../Types/JSON";
9
11
  import ObjectID from "../../../Types/ObjectID";
@@ -15,10 +17,17 @@ const DataSourceSection: ComponentArgumentSection = {
15
17
  order: 1,
16
18
  };
17
19
 
20
+ const DisplaySection: ComponentArgumentSection = {
21
+ name: "Display",
22
+ description: "Tune how the trend arrow is coloured",
23
+ order: 2,
24
+ defaultCollapsed: true,
25
+ };
26
+
18
27
  const ThresholdsSection: ComponentArgumentSection = {
19
28
  name: "Thresholds",
20
29
  description: "Set warning and critical levels",
21
- order: 2,
30
+ order: 3,
22
31
  defaultCollapsed: true,
23
32
  };
24
33
 
@@ -71,6 +80,31 @@ export default class DashboardValueComponentUtil extends DashboardBaseComponentU
71
80
  section: DataSourceSection,
72
81
  });
73
82
 
83
+ componentArguments.push({
84
+ name: "Trend Direction",
85
+ description:
86
+ "How the trend arrow is coloured. Auto reads the metric name (e.g. 'error_count' and 'latency' are treated as 'higher is worse'). Pick a value to override.",
87
+ required: false,
88
+ type: ComponentInputType.Dropdown,
89
+ id: "trendDirection",
90
+ isAdvanced: true,
91
+ section: DisplaySection,
92
+ dropdownOptions: [
93
+ {
94
+ label: "Auto (detect from metric name)",
95
+ value: DashboardValueTrendDirection.Auto,
96
+ },
97
+ {
98
+ label: "Higher is better (↑ green)",
99
+ value: DashboardValueTrendDirection.HigherIsBetter,
100
+ },
101
+ {
102
+ label: "Higher is worse (↑ red)",
103
+ value: DashboardValueTrendDirection.HigherIsWorse,
104
+ },
105
+ ],
106
+ });
107
+
74
108
  componentArguments.push({
75
109
  name: "Warning Threshold",
76
110
  description: "Yellow background when value exceeds this",
@@ -435,6 +435,42 @@ export default class ValueFormatter {
435
435
  return fractionMetricSuffixRegex.test(metricName);
436
436
  }
437
437
 
438
+ /*
439
+ * Direction-of-goodness heuristic for trend coloring. Returns true when a
440
+ * rising value on this metric should be shown as bad (red ↑) and a falling
441
+ * value as good (green ↓) — the opposite of the default "up = good".
442
+ *
443
+ * Caller is responsible for the inversion; this method only classifies.
444
+ * Conservative by default: when the metric name carries no signal we
445
+ * return `false` so generic counters (request rate, network I/O, span
446
+ * count) keep the up = good colour scheme. Explicit "higher is better"
447
+ * tokens (uptime/availability/online/ready/healthy/available/success/
448
+ * passed) take precedence — without that allowlist `oneuptime.monitor.
449
+ * online` would match nothing and incorrectly fall through; the suffix
450
+ * test then catches incident counts, error rates, MTTR/MTTA, response
451
+ * times, CPU/memory usage, restarts, pressure, queue backlogs, etc.
452
+ *
453
+ * Adding a metric here is a single regex edit. If a dashboard widget
454
+ * really needs to override this for a specific case, callers can set
455
+ * `higherIsBetter` explicitly on the trend display.
456
+ */
457
+ public static isHigherWorseMetric(metricName: string | undefined): boolean {
458
+ if (!metricName) {
459
+ return false;
460
+ }
461
+ const lower: string = metricName.toLowerCase();
462
+
463
+ const higherIsBetter: RegExp =
464
+ /\b(uptime|availability|online|ready|healthy|available|success|passed|ok|alive|up)\b/;
465
+ if (higherIsBetter.test(lower)) {
466
+ return false;
467
+ }
468
+
469
+ const higherIsWorse: RegExp =
470
+ /(error|fail(?:ure|ed)?|incident|exception|crash|restart|timeout|dropped|aborted?|reject(?:ed)?|mttr|mtta|latenc(?:y|ies)|duration|[._-]time\b|time[._-]to[._-]|usage|utilization|pressure|unresolved|pending|backlog|lag|delay|saturation|throttl|stall|missed?)/;
471
+ return higherIsWorse.test(lower);
472
+ }
473
+
438
474
  // Check if a unit is one we can auto-scale (bytes, seconds, etc.)
439
475
  public static isScalableUnit(unit: string): boolean {
440
476
  if (!unit || unit.trim() === "") {
@@ -15,9 +15,12 @@ import QueryHelper from "../Types/Database/QueryHelper";
15
15
  import OneUptimeDate from "../../Types/Date";
16
16
  import LIMIT_MAX from "../../Types/Database/LimitMax";
17
17
  import GlobalCache from "../Infrastructure/GlobalCache";
18
+ import logger from "../Utils/Logger";
18
19
  import crypto from "crypto";
19
20
  const LAST_SEEN_CACHE_NAMESPACE = "docker-host-last-seen";
20
21
  const LAST_SEEN_THROTTLE_SECONDS = 60;
22
+ const LABELS_APPLIED_CACHE_NAMESPACE = "docker-host-labels-applied";
23
+ const LABELS_APPLIED_CACHE_TTL_SECONDS = 60;
21
24
  export class Service extends DatabaseService {
22
25
  constructor() {
23
26
  super(Model);
@@ -116,6 +119,62 @@ export class Service extends DatabaseService {
116
119
  },
117
120
  });
118
121
  }
122
+ /**
123
+ * Additively attach labels to a Docker host. Existing labels are
124
+ * never removed — manual labels set via the UI survive ingest. The
125
+ * set of labelIds passed in is fingerprinted and cached for 60s so
126
+ * the common case (steady-state collector pushing the same label
127
+ * set every batch) costs one in-memory lookup, not a join-table
128
+ * scan.
129
+ */
130
+ async attachLabels(data) {
131
+ var _a;
132
+ if (!data.labelIds || data.labelIds.length === 0) {
133
+ return;
134
+ }
135
+ const cacheKey = data.dockerHostId.toString();
136
+ const fingerprint = fingerprintLabelIds(data.labelIds);
137
+ const cached = await GlobalCache.getString(LABELS_APPLIED_CACHE_NAMESPACE, cacheKey);
138
+ if (cached === fingerprint) {
139
+ return;
140
+ }
141
+ try {
142
+ const dockerHostIdStr = data.dockerHostId.toString();
143
+ const existingLabels = await this.getRepository()
144
+ .createQueryBuilder()
145
+ .relation(Model, "labels")
146
+ .of(dockerHostIdStr)
147
+ .loadMany();
148
+ const existingIds = new Set();
149
+ for (const lbl of existingLabels) {
150
+ const idStr = (_a = lbl._id) === null || _a === void 0 ? void 0 : _a.toString();
151
+ if (idStr) {
152
+ existingIds.add(idStr);
153
+ }
154
+ }
155
+ const toAddIds = [];
156
+ const seen = new Set();
157
+ for (const id of data.labelIds) {
158
+ const idStr = id.toString();
159
+ if (existingIds.has(idStr) || seen.has(idStr)) {
160
+ continue;
161
+ }
162
+ seen.add(idStr);
163
+ toAddIds.push(idStr);
164
+ }
165
+ if (toAddIds.length > 0) {
166
+ await this.getRepository()
167
+ .createQueryBuilder()
168
+ .relation(Model, "labels")
169
+ .of(dockerHostIdStr)
170
+ .add(toAddIds);
171
+ }
172
+ await GlobalCache.setString(LABELS_APPLIED_CACHE_NAMESPACE, cacheKey, fingerprint, { expiresInSeconds: LABELS_APPLIED_CACHE_TTL_SECONDS });
173
+ }
174
+ catch (err) {
175
+ logger.warn(`DockerHostService.attachLabels failed for docker host ${data.dockerHostId.toString()}: ${err instanceof Error ? err.message : String(err)}`);
176
+ }
177
+ }
119
178
  async markDisconnectedHosts() {
120
179
  const fiveMinutesAgo = OneUptimeDate.addRemoveMinutes(OneUptimeDate.getCurrentDate(), -5);
121
180
  const connectedHosts = await this.findBy({
@@ -159,11 +218,25 @@ __decorate([
159
218
  __metadata("design:paramtypes", [ObjectID, Object]),
160
219
  __metadata("design:returntype", Promise)
161
220
  ], Service.prototype, "updateLastSeen", null);
221
+ __decorate([
222
+ CaptureSpan(),
223
+ __metadata("design:type", Function),
224
+ __metadata("design:paramtypes", [Object]),
225
+ __metadata("design:returntype", Promise)
226
+ ], Service.prototype, "attachLabels", null);
162
227
  __decorate([
163
228
  CaptureSpan(),
164
229
  __metadata("design:type", Function),
165
230
  __metadata("design:paramtypes", []),
166
231
  __metadata("design:returntype", Promise)
167
232
  ], Service.prototype, "markDisconnectedHosts", null);
233
+ function fingerprintLabelIds(labelIds) {
234
+ const sorted = labelIds
235
+ .map((id) => {
236
+ return id.toString();
237
+ })
238
+ .sort();
239
+ return crypto.createHash("sha1").update(sorted.join(",")).digest("hex");
240
+ }
168
241
  export default new Service();
169
242
  //# sourceMappingURL=DockerHostService.js.map