@oneuptime/common 11.1.2 → 11.2.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.
- package/Models/DatabaseModels/Alert.ts +55 -0
- package/Models/DatabaseModels/Incident.ts +55 -0
- package/Models/DatabaseModels/Index.ts +12 -0
- package/Models/DatabaseModels/IoTDevice.ts +690 -0
- package/Models/DatabaseModels/IoTFleet.ts +862 -0
- package/Models/DatabaseModels/IoTFleetLabelRule.ts +514 -0
- package/Models/DatabaseModels/IoTFleetOwnerRule.ts +596 -0
- package/Models/DatabaseModels/IoTFleetOwnerTeam.ts +485 -0
- package/Models/DatabaseModels/IoTFleetOwnerUser.ts +484 -0
- package/Models/DatabaseModels/ScheduledMaintenance.ts +55 -0
- package/Server/API/IoTDeviceAPI.ts +128 -0
- package/Server/API/TelemetryAPI.ts +104 -35
- package/Server/Infrastructure/Postgres/SchemaMigrations/1782900000000-AddIoTFleetAndDeviceTables.ts +399 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783000000000-AddIoTFleetActivityJoinTables.ts +97 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783010000000-MigrationName.ts +979 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
- package/Server/Services/AlertService.ts +24 -3
- package/Server/Services/IncidentService.ts +24 -3
- package/Server/Services/IoTDeviceService.ts +356 -0
- package/Server/Services/IoTFleetLabelRuleEngineService.ts +198 -0
- package/Server/Services/IoTFleetLabelRuleService.ts +14 -0
- package/Server/Services/IoTFleetOwnerRuleEngineService.ts +216 -0
- package/Server/Services/IoTFleetOwnerRuleService.ts +14 -0
- package/Server/Services/IoTFleetOwnerTeamService.ts +10 -0
- package/Server/Services/IoTFleetOwnerUserService.ts +10 -0
- package/Server/Services/IoTFleetService.ts +376 -0
- package/Server/Services/OpenTelemetryIngestService.ts +16 -0
- package/Server/Services/StatusPageService.ts +69 -26
- package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +36 -0
- package/Server/Services/TelemetryUsageBillingService.ts +18 -0
- package/Server/Services/TraceAggregationService.ts +174 -0
- package/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.ts +1 -1
- package/Server/Utils/Monitor/MonitorAlert.ts +24 -4
- package/Server/Utils/Monitor/MonitorClusterContext.ts +26 -2
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +4 -2
- package/Server/Utils/Monitor/MonitorIncident.ts +39 -5
- package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +14 -0
- package/Server/Utils/Monitor/SeriesResourceLabels.ts +22 -0
- package/Server/Utils/Telemetry/IoTSnapshotScan.ts +408 -0
- package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +36 -0
- package/Types/Icon/IconProp.ts +1 -0
- package/Types/Monitor/IotAlertTemplates.ts +593 -0
- package/Types/Monitor/IotMetricCatalog.ts +146 -0
- package/Types/Monitor/MonitorStep.ts +31 -0
- package/Types/Monitor/MonitorStepIoTMonitor.ts +65 -0
- package/Types/Monitor/MonitorType.ts +12 -1
- package/Types/Permission.ts +230 -0
- package/Types/StatusPage/StatusPageSubscriberNotificationEventType.ts +3 -0
- package/Types/Telemetry/ServiceType.ts +1 -0
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +5 -5
- package/UI/Components/Icon/Icon.tsx +18 -2
- package/UI/Components/Page/Page.tsx +1 -2
- package/build/dist/Models/DatabaseModels/Alert.js +54 -0
- package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Incident.js +54 -0
- package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +12 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IoTDevice.js +722 -0
- package/build/dist/Models/DatabaseModels/IoTDevice.js.map +1 -0
- package/build/dist/Models/DatabaseModels/IoTFleet.js +882 -0
- package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -0
- package/build/dist/Models/DatabaseModels/IoTFleetLabelRule.js +522 -0
- package/build/dist/Models/DatabaseModels/IoTFleetLabelRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/IoTFleetOwnerRule.js +603 -0
- package/build/dist/Models/DatabaseModels/IoTFleetOwnerRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/IoTFleetOwnerTeam.js +503 -0
- package/build/dist/Models/DatabaseModels/IoTFleetOwnerTeam.js.map +1 -0
- package/build/dist/Models/DatabaseModels/IoTFleetOwnerUser.js +502 -0
- package/build/dist/Models/DatabaseModels/IoTFleetOwnerUser.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +54 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
- package/build/dist/Server/API/IoTDeviceAPI.js +92 -0
- package/build/dist/Server/API/IoTDeviceAPI.js.map +1 -0
- package/build/dist/Server/API/TelemetryAPI.js +76 -27
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782900000000-AddIoTFleetAndDeviceTables.js +187 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782900000000-AddIoTFleetAndDeviceTables.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783000000000-AddIoTFleetActivityJoinTables.js +48 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783000000000-AddIoTFleetActivityJoinTables.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783010000000-MigrationName.js +340 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783010000000-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +22 -3
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +22 -3
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceService.js +249 -0
- package/build/dist/Server/Services/IoTDeviceService.js.map +1 -0
- package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js +166 -0
- package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/IoTFleetLabelRuleService.js +13 -0
- package/build/dist/Server/Services/IoTFleetLabelRuleService.js.map +1 -0
- package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js +186 -0
- package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/IoTFleetOwnerRuleService.js +13 -0
- package/build/dist/Server/Services/IoTFleetOwnerRuleService.js.map +1 -0
- package/build/dist/Server/Services/IoTFleetOwnerTeamService.js +9 -0
- package/build/dist/Server/Services/IoTFleetOwnerTeamService.js.map +1 -0
- package/build/dist/Server/Services/IoTFleetOwnerUserService.js +9 -0
- package/build/dist/Server/Services/IoTFleetOwnerUserService.js.map +1 -0
- package/build/dist/Server/Services/IoTFleetService.js +340 -0
- package/build/dist/Server/Services/IoTFleetService.js.map +1 -0
- package/build/dist/Server/Services/OpenTelemetryIngestService.js +26 -11
- package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +57 -23
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +34 -0
- package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
- package/build/dist/Server/Services/TelemetryUsageBillingService.js +13 -0
- package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
- package/build/dist/Server/Services/TraceAggregationService.js +136 -0
- package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js +15 -15
- package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js +37 -19
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorClusterContext.js +27 -4
- package/build/dist/Server/Utils/Monitor/MonitorClusterContext.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +4 -2
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js +39 -11
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +8 -0
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +17 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +300 -0
- package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -0
- package/build/dist/Types/Icon/IconProp.js +1 -0
- package/build/dist/Types/Icon/IconProp.js.map +1 -1
- package/build/dist/Types/Monitor/IotAlertTemplates.js +467 -0
- package/build/dist/Types/Monitor/IotAlertTemplates.js.map +1 -0
- package/build/dist/Types/Monitor/IotMetricCatalog.js +108 -0
- package/build/dist/Types/Monitor/IotMetricCatalog.js.map +1 -0
- package/build/dist/Types/Monitor/MonitorStep.js +23 -0
- package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorStepIoTMonitor.js +32 -0
- package/build/dist/Types/Monitor/MonitorStepIoTMonitor.js.map +1 -0
- package/build/dist/Types/Monitor/MonitorType.js +11 -1
- package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
- package/build/dist/Types/Permission.js +204 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/StatusPage/StatusPageSubscriberNotificationEventType.js +2 -0
- package/build/dist/Types/StatusPage/StatusPageSubscriberNotificationEventType.js.map +1 -1
- package/build/dist/Types/Telemetry/ServiceType.js +1 -0
- package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +3 -3
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Icon/Icon.js +12 -2
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/UI/Components/Page/Page.js +1 -2
- package/build/dist/UI/Components/Page/Page.js.map +1 -1
- package/package.json +1 -1
|
@@ -400,6 +400,9 @@ import { OptimizeTelemetryExceptionWritePath1782500000000 } from "./178250000000
|
|
|
400
400
|
import { AddArchiveToResources1782600000000 } from "./1782600000000-AddArchiveToResources";
|
|
401
401
|
import { RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000 } from "./1782700000000-RemoveDeprecatedEnableSubscribersFromStatusPage";
|
|
402
402
|
import { WidenDockerResourceImageColumns1782800000000 } from "./1782800000000-WidenDockerResourceImageColumns";
|
|
403
|
+
import { AddIoTFleetAndDeviceTables1782900000000 } from "./1782900000000-AddIoTFleetAndDeviceTables";
|
|
404
|
+
import { AddIoTFleetActivityJoinTables1783000000000 } from "./1783000000000-AddIoTFleetActivityJoinTables";
|
|
405
|
+
import { MigrationName1783010000000 } from "./1783010000000-MigrationName";
|
|
403
406
|
|
|
404
407
|
export default [
|
|
405
408
|
InitialMigration,
|
|
@@ -804,4 +807,7 @@ export default [
|
|
|
804
807
|
AddArchiveToResources1782600000000,
|
|
805
808
|
RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000,
|
|
806
809
|
WidenDockerResourceImageColumns1782800000000,
|
|
810
|
+
AddIoTFleetAndDeviceTables1782900000000,
|
|
811
|
+
AddIoTFleetActivityJoinTables1783000000000,
|
|
812
|
+
MigrationName1783010000000,
|
|
807
813
|
];
|
|
@@ -772,11 +772,32 @@ ${alert.remediationNotes || "No remediation notes provided."}
|
|
|
772
772
|
},
|
|
773
773
|
});
|
|
774
774
|
|
|
775
|
-
if (
|
|
776
|
-
|
|
775
|
+
if (timeline && timeline.startsAt) {
|
|
776
|
+
return timeline.startsAt;
|
|
777
777
|
}
|
|
778
778
|
|
|
779
|
-
|
|
779
|
+
/*
|
|
780
|
+
* The identified-state timeline is created asynchronously after the alert
|
|
781
|
+
* is committed (see onCreateSuccess), so it may not exist yet, or may be
|
|
782
|
+
* missing entirely if that step failed. Fall back to the alert's creation
|
|
783
|
+
* date instead of throwing, otherwise the owner-notification cron fails
|
|
784
|
+
* permanently for this alert and retries every minute forever.
|
|
785
|
+
*/
|
|
786
|
+
const alert: Model | null = await this.findOneById({
|
|
787
|
+
id: alertId,
|
|
788
|
+
select: {
|
|
789
|
+
createdAt: true,
|
|
790
|
+
},
|
|
791
|
+
props: {
|
|
792
|
+
isRoot: true,
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
if (alert && alert.createdAt) {
|
|
797
|
+
return alert.createdAt;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
throw new BadDataException("Alert identified date not found.");
|
|
780
801
|
}
|
|
781
802
|
|
|
782
803
|
@CaptureSpan()
|
|
@@ -1474,11 +1474,32 @@ ${incident.remediationNotes || "No remediation notes provided."}
|
|
|
1474
1474
|
},
|
|
1475
1475
|
});
|
|
1476
1476
|
|
|
1477
|
-
if (
|
|
1478
|
-
|
|
1477
|
+
if (timeline && timeline.startsAt) {
|
|
1478
|
+
return timeline.startsAt;
|
|
1479
1479
|
}
|
|
1480
1480
|
|
|
1481
|
-
|
|
1481
|
+
/*
|
|
1482
|
+
* The identified-state timeline is created asynchronously after the
|
|
1483
|
+
* incident is committed (see onCreateSuccess), so it may not exist yet, or
|
|
1484
|
+
* may be missing entirely if that step failed. Fall back to the incident's
|
|
1485
|
+
* creation date instead of throwing, otherwise the owner-notification cron
|
|
1486
|
+
* fails permanently for this incident and retries every minute forever.
|
|
1487
|
+
*/
|
|
1488
|
+
const incident: Model | null = await this.findOneById({
|
|
1489
|
+
id: incidentId,
|
|
1490
|
+
select: {
|
|
1491
|
+
createdAt: true,
|
|
1492
|
+
},
|
|
1493
|
+
props: {
|
|
1494
|
+
isRoot: true,
|
|
1495
|
+
},
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
if (incident && incident.createdAt) {
|
|
1499
|
+
return incident.createdAt;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
throw new BadDataException("Incident identified date not found.");
|
|
1482
1503
|
}
|
|
1483
1504
|
|
|
1484
1505
|
@CaptureSpan()
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import DatabaseService from "./DatabaseService";
|
|
2
|
+
import Model from "../../Models/DatabaseModels/IoTDevice";
|
|
3
|
+
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
4
|
+
import ObjectID from "../../Types/ObjectID";
|
|
5
|
+
import OneUptimeDate from "../../Types/Date";
|
|
6
|
+
import logger from "../Utils/Logger";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* ------------------------------------------------------------------
|
|
10
|
+
* IoTDeviceService
|
|
11
|
+
*
|
|
12
|
+
* Writes and reads the IoT device inventory table populated by the
|
|
13
|
+
* telemetry ingest path. Callers are either:
|
|
14
|
+
* - OtelMetricsIngestService (bulkUpsert + bulkUpdateLatestMetrics,
|
|
15
|
+
* from the iot_* snapshot scan in processMetricsAsync)
|
|
16
|
+
* - CleanupStaleResources worker (deleteStaleForFleet)
|
|
17
|
+
* - IoTDeviceAPI / the dashboard pages (reads via the inherited
|
|
18
|
+
* DatabaseService CRUD)
|
|
19
|
+
*
|
|
20
|
+
* Identity + status and the latest-metric mirror both arrive on the
|
|
21
|
+
* same metric scrape (unlike K8s, which needs a separate k8sobjects
|
|
22
|
+
* log stream for identity), so both writes happen in the same flush.
|
|
23
|
+
*
|
|
24
|
+
* ------------------------------------------------------------------
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface ParsedIoTDevice {
|
|
28
|
+
kind: string; // Device | Sensor | Gateway
|
|
29
|
+
externalId: string; // raw iot `device.id` label
|
|
30
|
+
name: string | null;
|
|
31
|
+
deviceType: string | null;
|
|
32
|
+
firmwareVersion: string | null;
|
|
33
|
+
isUp: boolean | null;
|
|
34
|
+
uptimeSeconds: number | null;
|
|
35
|
+
lastSeenAt: Date;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface IoTDeviceLatestMetric {
|
|
39
|
+
kind: string;
|
|
40
|
+
externalId: string;
|
|
41
|
+
cpuPercent: number | null;
|
|
42
|
+
memoryBytes: number | null;
|
|
43
|
+
maxMemoryBytes: number | null;
|
|
44
|
+
memoryPercent: number | null;
|
|
45
|
+
batteryPercent: number | null;
|
|
46
|
+
signalStrengthDbm: number | null;
|
|
47
|
+
temperatureCelsius: number | null;
|
|
48
|
+
observedAt: Date;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IoTInventorySummary {
|
|
52
|
+
deviceCount: number;
|
|
53
|
+
onlineDeviceCount: number;
|
|
54
|
+
countsByKind: Record<string, number>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const UPSERT_BATCH_SIZE: number = 500;
|
|
58
|
+
const STALE_DELETE_WARN_THRESHOLD: number = 100;
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* Column order used by bulkUpsert() and its generated parameter tuples.
|
|
62
|
+
* Keep this and the INSERT column list in perfect sync.
|
|
63
|
+
*/
|
|
64
|
+
const UPSERT_COLUMNS: Array<string> = [
|
|
65
|
+
"projectId",
|
|
66
|
+
"iotFleetId",
|
|
67
|
+
"kind",
|
|
68
|
+
"externalId",
|
|
69
|
+
"name",
|
|
70
|
+
"deviceType",
|
|
71
|
+
"firmwareVersion",
|
|
72
|
+
"isUp",
|
|
73
|
+
"uptimeSeconds",
|
|
74
|
+
"lastSeenAt",
|
|
75
|
+
"version",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export class Service extends DatabaseService<Model> {
|
|
79
|
+
public constructor() {
|
|
80
|
+
super(Model);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Upsert a batch of parsed devices for a single (project, fleet)
|
|
85
|
+
* pair. Uses ON CONFLICT on the UNIQUE (projectId, iotFleetId,
|
|
86
|
+
* kind, externalId) index with a dominance guard on lastSeenAt so
|
|
87
|
+
* out-of-order ingest never regresses a newer snapshot.
|
|
88
|
+
*
|
|
89
|
+
* Identity/status columns COALESCE against the existing row (unlike
|
|
90
|
+
* the K8s upsert, which overwrites): an iot-exporter batch is usually
|
|
91
|
+
* a complete scrape, but a batch that happens to lack an info series
|
|
92
|
+
* (e.g. only iot_device_up made it through a pipeline filter) must
|
|
93
|
+
* not blank name/deviceType/firmwareVersion that an earlier batch
|
|
94
|
+
* already filled.
|
|
95
|
+
*/
|
|
96
|
+
@CaptureSpan()
|
|
97
|
+
public async bulkUpsert(data: {
|
|
98
|
+
projectId: ObjectID;
|
|
99
|
+
iotFleetId: ObjectID;
|
|
100
|
+
devices: Array<ParsedIoTDevice>;
|
|
101
|
+
}): Promise<void> {
|
|
102
|
+
if (data.devices.length === 0) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Chunk to keep individual statement parameter counts reasonable.
|
|
107
|
+
for (let i: number = 0; i < data.devices.length; i += UPSERT_BATCH_SIZE) {
|
|
108
|
+
const chunk: Array<ParsedIoTDevice> = data.devices.slice(
|
|
109
|
+
i,
|
|
110
|
+
i + UPSERT_BATCH_SIZE,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const valueFragments: Array<string> = [];
|
|
114
|
+
const params: Array<unknown> = [];
|
|
115
|
+
let paramIndex: number = 1;
|
|
116
|
+
|
|
117
|
+
for (const r of chunk) {
|
|
118
|
+
const placeholders: Array<string> = [];
|
|
119
|
+
for (let c: number = 0; c < UPSERT_COLUMNS.length; c++) {
|
|
120
|
+
placeholders.push(`$${paramIndex++}`);
|
|
121
|
+
}
|
|
122
|
+
valueFragments.push(`(${placeholders.join(", ")})`);
|
|
123
|
+
|
|
124
|
+
params.push(
|
|
125
|
+
data.projectId.toString(),
|
|
126
|
+
data.iotFleetId.toString(),
|
|
127
|
+
r.kind,
|
|
128
|
+
r.externalId,
|
|
129
|
+
r.name,
|
|
130
|
+
r.deviceType,
|
|
131
|
+
r.firmwareVersion,
|
|
132
|
+
r.isUp,
|
|
133
|
+
r.uptimeSeconds !== null ? Math.trunc(r.uptimeSeconds) : null,
|
|
134
|
+
r.lastSeenAt,
|
|
135
|
+
0, // version (BaseModel @VersionColumn)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const sql: string = `
|
|
140
|
+
INSERT INTO "IoTDevice" (
|
|
141
|
+
"projectId", "iotFleetId", "kind", "externalId",
|
|
142
|
+
"name", "deviceType", "firmwareVersion",
|
|
143
|
+
"isUp", "uptimeSeconds",
|
|
144
|
+
"lastSeenAt", "version"
|
|
145
|
+
)
|
|
146
|
+
VALUES ${valueFragments.join(", ")}
|
|
147
|
+
ON CONFLICT ("projectId", "iotFleetId", "kind", "externalId")
|
|
148
|
+
DO UPDATE SET
|
|
149
|
+
"name" = COALESCE(EXCLUDED."name", "IoTDevice"."name"),
|
|
150
|
+
"deviceType" = COALESCE(EXCLUDED."deviceType", "IoTDevice"."deviceType"),
|
|
151
|
+
"firmwareVersion" = COALESCE(EXCLUDED."firmwareVersion", "IoTDevice"."firmwareVersion"),
|
|
152
|
+
"isUp" = COALESCE(EXCLUDED."isUp", "IoTDevice"."isUp"),
|
|
153
|
+
"uptimeSeconds" = COALESCE(EXCLUDED."uptimeSeconds", "IoTDevice"."uptimeSeconds"),
|
|
154
|
+
"lastSeenAt" = EXCLUDED."lastSeenAt",
|
|
155
|
+
"updatedAt" = now()
|
|
156
|
+
WHERE EXCLUDED."lastSeenAt" >= "IoTDevice"."lastSeenAt"
|
|
157
|
+
`;
|
|
158
|
+
|
|
159
|
+
await this.getRepository().manager.query(sql, params);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Update the latest-metric mirror columns for a batch of devices.
|
|
165
|
+
* Plain UPDATE: in practice the row always exists because bulkUpsert
|
|
166
|
+
* runs in the same flush; if it somehow doesn't, the write is
|
|
167
|
+
* silently skipped and the next flush catches up.
|
|
168
|
+
*
|
|
169
|
+
* Guarded by metricsUpdatedAt so out-of-order points don't regress a
|
|
170
|
+
* newer observation. COALESCE keeps the existing value when a batch
|
|
171
|
+
* lacks a series — notably a battery/signal/temperature value stays
|
|
172
|
+
* at its last-known reading (never 0) when a device omits that series.
|
|
173
|
+
*/
|
|
174
|
+
@CaptureSpan()
|
|
175
|
+
public async bulkUpdateLatestMetrics(data: {
|
|
176
|
+
projectId: ObjectID;
|
|
177
|
+
iotFleetId: ObjectID;
|
|
178
|
+
metrics: Array<IoTDeviceLatestMetric>;
|
|
179
|
+
}): Promise<void> {
|
|
180
|
+
if (data.metrics.length === 0) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (let i: number = 0; i < data.metrics.length; i += UPSERT_BATCH_SIZE) {
|
|
185
|
+
const chunk: Array<IoTDeviceLatestMetric> = data.metrics.slice(
|
|
186
|
+
i,
|
|
187
|
+
i + UPSERT_BATCH_SIZE,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const valueFragments: Array<string> = [];
|
|
191
|
+
const params: Array<unknown> = [
|
|
192
|
+
data.projectId.toString(),
|
|
193
|
+
data.iotFleetId.toString(),
|
|
194
|
+
];
|
|
195
|
+
let paramIndex: number = 3;
|
|
196
|
+
|
|
197
|
+
for (const m of chunk) {
|
|
198
|
+
valueFragments.push(
|
|
199
|
+
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}::numeric, $${paramIndex++}::bigint, $${paramIndex++}::bigint, $${paramIndex++}::numeric, $${paramIndex++}::numeric, $${paramIndex++}::numeric, $${paramIndex++}::timestamptz)`,
|
|
200
|
+
);
|
|
201
|
+
params.push(
|
|
202
|
+
m.kind,
|
|
203
|
+
m.externalId,
|
|
204
|
+
m.cpuPercent !== null && m.cpuPercent !== undefined
|
|
205
|
+
? m.cpuPercent
|
|
206
|
+
: null,
|
|
207
|
+
m.memoryBytes !== null && m.memoryBytes !== undefined
|
|
208
|
+
? Math.trunc(m.memoryBytes).toString()
|
|
209
|
+
: null,
|
|
210
|
+
m.maxMemoryBytes !== null && m.maxMemoryBytes !== undefined
|
|
211
|
+
? Math.trunc(m.maxMemoryBytes).toString()
|
|
212
|
+
: null,
|
|
213
|
+
m.memoryPercent !== null && m.memoryPercent !== undefined
|
|
214
|
+
? m.memoryPercent
|
|
215
|
+
: null,
|
|
216
|
+
m.batteryPercent !== null && m.batteryPercent !== undefined
|
|
217
|
+
? m.batteryPercent
|
|
218
|
+
: null,
|
|
219
|
+
m.signalStrengthDbm !== null && m.signalStrengthDbm !== undefined
|
|
220
|
+
? m.signalStrengthDbm
|
|
221
|
+
: null,
|
|
222
|
+
m.temperatureCelsius !== null && m.temperatureCelsius !== undefined
|
|
223
|
+
? m.temperatureCelsius
|
|
224
|
+
: null,
|
|
225
|
+
m.observedAt,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const sql: string = `
|
|
230
|
+
UPDATE "IoTDevice" AS p
|
|
231
|
+
SET
|
|
232
|
+
"latestCpuPercent" = COALESCE(v."cpu", p."latestCpuPercent"),
|
|
233
|
+
"latestMemoryBytes" = COALESCE(v."mem", p."latestMemoryBytes"),
|
|
234
|
+
"maxMemoryBytes" = COALESCE(v."maxMem", p."maxMemoryBytes"),
|
|
235
|
+
"latestMemoryPercent" = COALESCE(v."memPct", p."latestMemoryPercent"),
|
|
236
|
+
"latestBatteryPercent" = COALESCE(v."battery", p."latestBatteryPercent"),
|
|
237
|
+
"latestSignalStrengthDbm" = COALESCE(v."signal", p."latestSignalStrengthDbm"),
|
|
238
|
+
"latestTemperatureCelsius" = COALESCE(v."temp", p."latestTemperatureCelsius"),
|
|
239
|
+
"metricsUpdatedAt" = v."observedAt",
|
|
240
|
+
"updatedAt" = now()
|
|
241
|
+
FROM (VALUES ${valueFragments.join(", ")})
|
|
242
|
+
AS v("kind", "externalId", "cpu", "mem", "maxMem", "memPct", "battery", "signal", "temp", "observedAt")
|
|
243
|
+
WHERE
|
|
244
|
+
p."projectId" = $1
|
|
245
|
+
AND p."iotFleetId" = $2
|
|
246
|
+
AND p."kind" = v."kind"
|
|
247
|
+
AND p."externalId" = v."externalId"
|
|
248
|
+
AND (p."metricsUpdatedAt" IS NULL OR v."observedAt" >= p."metricsUpdatedAt")
|
|
249
|
+
`;
|
|
250
|
+
|
|
251
|
+
await this.getRepository().manager.query(sql, params);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Hard-delete all devices in a fleet whose last scrape is older
|
|
257
|
+
* than olderThan. Returns the number of deleted rows. Only called by
|
|
258
|
+
* the cleanup worker for fleets that are still connected — a
|
|
259
|
+
* disconnected fleet keeps its last-known inventory.
|
|
260
|
+
*/
|
|
261
|
+
@CaptureSpan()
|
|
262
|
+
public async deleteStaleForFleet(data: {
|
|
263
|
+
iotFleetId: ObjectID;
|
|
264
|
+
olderThan: Date;
|
|
265
|
+
}): Promise<number> {
|
|
266
|
+
const result: Array<{ affected?: number }> | { affected?: number } =
|
|
267
|
+
await this.getRepository().manager.query(
|
|
268
|
+
`DELETE FROM "IoTDevice" WHERE "iotFleetId" = $1 AND "lastSeenAt" < $2`,
|
|
269
|
+
[data.iotFleetId.toString(), data.olderThan],
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// Postgres driver returns [rows, affected] for DELETE — normalize.
|
|
273
|
+
let affected: number = 0;
|
|
274
|
+
if (Array.isArray(result) && result.length >= 2) {
|
|
275
|
+
const second: unknown = (result as Array<unknown>)[1];
|
|
276
|
+
if (typeof second === "number") {
|
|
277
|
+
affected = second;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (affected > STALE_DELETE_WARN_THRESHOLD) {
|
|
282
|
+
logger.warn(
|
|
283
|
+
`IoTDevice cleanup deleted ${affected} stale rows for fleet ${data.iotFleetId.toString()} — larger than expected; investigate agent health.`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return affected;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Compute the sidebar/overview summary in Postgres: counts per kind
|
|
292
|
+
* plus the total and online device breakdowns, in a single round-trip.
|
|
293
|
+
*/
|
|
294
|
+
@CaptureSpan()
|
|
295
|
+
public async getInventorySummary(data: {
|
|
296
|
+
projectId: ObjectID;
|
|
297
|
+
iotFleetId: ObjectID;
|
|
298
|
+
}): Promise<IoTInventorySummary> {
|
|
299
|
+
const rows: Array<{
|
|
300
|
+
kind: string;
|
|
301
|
+
count: string;
|
|
302
|
+
upCount: string;
|
|
303
|
+
}> = await this.getRepository().manager.query(
|
|
304
|
+
`SELECT "kind",
|
|
305
|
+
COUNT(*)::text AS count,
|
|
306
|
+
COUNT(*) FILTER (WHERE "isUp" IS TRUE)::text AS "upCount"
|
|
307
|
+
FROM "IoTDevice"
|
|
308
|
+
WHERE "projectId" = $1 AND "iotFleetId" = $2 AND "deletedAt" IS NULL
|
|
309
|
+
GROUP BY "kind"`,
|
|
310
|
+
[data.projectId.toString(), data.iotFleetId.toString()],
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const countsByKind: Record<string, number> = {};
|
|
314
|
+
let deviceCount: number = 0;
|
|
315
|
+
let onlineDeviceCount: number = 0;
|
|
316
|
+
for (const row of rows) {
|
|
317
|
+
const count: number = parseInt(row.count, 10) || 0;
|
|
318
|
+
const upCount: number = parseInt(row.upCount, 10) || 0;
|
|
319
|
+
countsByKind[row.kind] = count;
|
|
320
|
+
deviceCount += count;
|
|
321
|
+
onlineDeviceCount += upCount;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
deviceCount,
|
|
326
|
+
onlineDeviceCount,
|
|
327
|
+
countsByKind,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Helper for the cleanup worker: snapshot-interval aware cutoff.
|
|
333
|
+
* 3× the 5-minute scrape interval by default. Tune via
|
|
334
|
+
* IOT_INVENTORY_STALE_MINUTES (min 5).
|
|
335
|
+
*/
|
|
336
|
+
public getStaleThresholdDate(nowOverride?: Date): Date {
|
|
337
|
+
const minutes: number = this.getStaleThresholdMinutes();
|
|
338
|
+
return OneUptimeDate.addRemoveMinutes(
|
|
339
|
+
nowOverride || OneUptimeDate.getCurrentDate(),
|
|
340
|
+
-minutes,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
public getStaleThresholdMinutes(): number {
|
|
345
|
+
const raw: string | undefined = process.env["IOT_INVENTORY_STALE_MINUTES"];
|
|
346
|
+
if (raw) {
|
|
347
|
+
const parsed: number = parseInt(raw, 10);
|
|
348
|
+
if (!isNaN(parsed) && parsed >= 5) {
|
|
349
|
+
return parsed;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return 15;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export default new Service();
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import Label from "../../Models/DatabaseModels/Label";
|
|
2
|
+
import IoTFleet from "../../Models/DatabaseModels/IoTFleet";
|
|
3
|
+
import IoTFleetLabelRule from "../../Models/DatabaseModels/IoTFleetLabelRule";
|
|
4
|
+
import IoTFleetLabelRuleService from "./IoTFleetLabelRuleService";
|
|
5
|
+
import IoTFleetService from "./IoTFleetService";
|
|
6
|
+
import ObjectID from "../../Types/ObjectID";
|
|
7
|
+
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
8
|
+
import logger, { LogAttributes } from "../Utils/Logger";
|
|
9
|
+
|
|
10
|
+
class IoTFleetLabelRuleEngineServiceClass {
|
|
11
|
+
/**
|
|
12
|
+
* Evaluates IoTFleetLabelRule rows for the given IoT fleet and attaches matched
|
|
13
|
+
* labels to it. The union is deduped against labels already on the IoT fleet
|
|
14
|
+
* before insert to avoid PK conflicts on the join table.
|
|
15
|
+
*/
|
|
16
|
+
@CaptureSpan()
|
|
17
|
+
public async applyRulesToIoTFleet(iotFleet: IoTFleet): Promise<void> {
|
|
18
|
+
if (!iotFleet.id || !iotFleet.projectId) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const rules: Array<IoTFleetLabelRule> =
|
|
24
|
+
await IoTFleetLabelRuleService.findBy({
|
|
25
|
+
query: {
|
|
26
|
+
projectId: iotFleet.projectId,
|
|
27
|
+
isEnabled: true,
|
|
28
|
+
},
|
|
29
|
+
props: { isRoot: true },
|
|
30
|
+
select: {
|
|
31
|
+
_id: true,
|
|
32
|
+
name: true,
|
|
33
|
+
iotFleetLabels: { _id: true },
|
|
34
|
+
iotFleetNamePattern: true,
|
|
35
|
+
iotFleetDescriptionPattern: true,
|
|
36
|
+
labelsToAdd: { _id: true },
|
|
37
|
+
},
|
|
38
|
+
limit: 100,
|
|
39
|
+
skip: 0,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (rules.length === 0) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const iotFleetWithDetails: IoTFleet | null =
|
|
47
|
+
await IoTFleetService.findOneById({
|
|
48
|
+
id: iotFleet.id,
|
|
49
|
+
select: {
|
|
50
|
+
name: true,
|
|
51
|
+
description: true,
|
|
52
|
+
labels: { _id: true },
|
|
53
|
+
},
|
|
54
|
+
props: { isRoot: true },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!iotFleetWithDetails) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const labelIdsToAdd: Set<string> = new Set();
|
|
62
|
+
|
|
63
|
+
for (const rule of rules) {
|
|
64
|
+
const matches: boolean = this.doesIoTFleetMatchRule(
|
|
65
|
+
iotFleetWithDetails,
|
|
66
|
+
rule,
|
|
67
|
+
);
|
|
68
|
+
if (!matches) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
for (const label of rule.labelsToAdd || []) {
|
|
72
|
+
if (label.id) {
|
|
73
|
+
labelIdsToAdd.add(label.id.toString());
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (labelIdsToAdd.size === 0) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const existingLabelIds: Set<string> = new Set(
|
|
83
|
+
(iotFleetWithDetails.labels || [])
|
|
84
|
+
.map((l: Label) => {
|
|
85
|
+
return l.id?.toString() || "";
|
|
86
|
+
})
|
|
87
|
+
.filter((id: string) => {
|
|
88
|
+
return id !== "";
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const newLabelIds: Array<string> = Array.from(labelIdsToAdd).filter(
|
|
93
|
+
(id: string) => {
|
|
94
|
+
return !existingLabelIds.has(id);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
if (newLabelIds.length === 0) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await IoTFleetService.getRepository()
|
|
102
|
+
.createQueryBuilder()
|
|
103
|
+
.relation(IoTFleet, "labels")
|
|
104
|
+
.of(iotFleet.id.toString())
|
|
105
|
+
.add(newLabelIds);
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* Sync in-memory iotFleet.labels so a downstream owner-rule engine in
|
|
109
|
+
* the same onCreateSuccess chain can match on rule-added labels.
|
|
110
|
+
*/
|
|
111
|
+
const mergedLabelIds: Set<string> = new Set([
|
|
112
|
+
...existingLabelIds,
|
|
113
|
+
...newLabelIds,
|
|
114
|
+
]);
|
|
115
|
+
iotFleet.labels = Array.from(mergedLabelIds).map((id: string) => {
|
|
116
|
+
const label: Label = new Label();
|
|
117
|
+
label.id = new ObjectID(id);
|
|
118
|
+
return label;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
logger.debug(
|
|
122
|
+
`IoTFleetLabelRuleEngine attached ${newLabelIds.length} labels to IoT fleet ${iotFleet.id}`,
|
|
123
|
+
{ projectId: iotFleet.projectId.toString() } as LogAttributes,
|
|
124
|
+
);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logger.error(`Error applying IoT fleet label rules: ${error}`, {
|
|
127
|
+
projectId: iotFleet.projectId?.toString(),
|
|
128
|
+
iotFleetId: iotFleet.id?.toString(),
|
|
129
|
+
} as LogAttributes);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private doesIoTFleetMatchRule(
|
|
134
|
+
iotFleet: IoTFleet,
|
|
135
|
+
rule: IoTFleetLabelRule,
|
|
136
|
+
): boolean {
|
|
137
|
+
if (rule.iotFleetLabels && rule.iotFleetLabels.length > 0) {
|
|
138
|
+
if (!iotFleet.labels || iotFleet.labels.length === 0) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
const ruleLabelIds: Array<string> = rule.iotFleetLabels.map(
|
|
142
|
+
(l: Label) => {
|
|
143
|
+
return l.id?.toString() || "";
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
const labelIds: Array<string> = iotFleet.labels.map((l: Label) => {
|
|
147
|
+
return l.id?.toString() || "";
|
|
148
|
+
});
|
|
149
|
+
if (
|
|
150
|
+
!ruleLabelIds.some((id: string) => {
|
|
151
|
+
return labelIds.includes(id);
|
|
152
|
+
})
|
|
153
|
+
) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (
|
|
159
|
+
rule.iotFleetNamePattern &&
|
|
160
|
+
(!iotFleet.name ||
|
|
161
|
+
!this.testRegex(rule.iotFleetNamePattern, iotFleet.name, rule))
|
|
162
|
+
) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
rule.iotFleetDescriptionPattern &&
|
|
168
|
+
(!iotFleet.description ||
|
|
169
|
+
!this.testRegex(
|
|
170
|
+
rule.iotFleetDescriptionPattern,
|
|
171
|
+
iotFleet.description,
|
|
172
|
+
rule,
|
|
173
|
+
))
|
|
174
|
+
) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private testRegex(
|
|
182
|
+
pattern: string,
|
|
183
|
+
value: string,
|
|
184
|
+
rule: IoTFleetLabelRule,
|
|
185
|
+
): boolean {
|
|
186
|
+
try {
|
|
187
|
+
const regex: RegExp = new RegExp(pattern, "i");
|
|
188
|
+
return regex.test(value);
|
|
189
|
+
} catch {
|
|
190
|
+
logger.warn(
|
|
191
|
+
`Invalid regex in IoT fleet label rule ${rule.id}: ${pattern}`,
|
|
192
|
+
);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default new IoTFleetLabelRuleEngineServiceClass();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import DatabaseService from "./DatabaseService";
|
|
2
|
+
import Model from "../../Models/DatabaseModels/IoTFleetLabelRule";
|
|
3
|
+
import { IsBillingEnabled } from "../EnvironmentConfig";
|
|
4
|
+
|
|
5
|
+
export class Service extends DatabaseService<Model> {
|
|
6
|
+
public constructor() {
|
|
7
|
+
super(Model);
|
|
8
|
+
if (IsBillingEnabled) {
|
|
9
|
+
this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default new Service();
|