@oneuptime/common 14.0.2 → 14.0.3
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/AlertFeed.ts +2 -0
- package/Models/DatabaseModels/IncidentAlert.ts +540 -0
- package/Models/DatabaseModels/IncidentFeed.ts +2 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/Project.ts +60 -0
- package/Models/DatabaseModels/RumApplication.ts +52 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/IncidentAlertService.ts +1811 -0
- package/Server/Services/IncidentService.ts +210 -10
- package/Server/Services/IncidentStateTimelineService.ts +24 -0
- package/Server/Services/Index.ts +2 -0
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
- package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
- package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
- package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
- package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
- package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
- package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
- package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
- package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
- package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
- package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
- package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
- package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
- package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
- package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
- package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
- package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
- package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
- package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
- package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
- package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
- package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
- package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
- package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
- package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
- package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
- package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
- package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
- package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
- package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
- package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
- package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
- package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
- package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
- package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
- package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
- package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
- package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
- package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
- package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
- package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
- package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
- package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
- package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
- package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
- package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
- package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
- package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
- package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
- package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
- package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
- package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
- package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
- package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
- package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
- package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
- package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
- package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
- package/Tests/Types/Rum/WebVitals.test.ts +173 -0
- package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
- package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
- package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
- package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
- package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
- package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
- package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
- package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
- package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
- package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
- package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
- package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
- package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
- package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
- package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
- package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
- package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
- package/Types/Incident/IncidentAlertLink.ts +38 -0
- package/Types/Permission.ts +48 -0
- package/Types/Rum/SessionReplay.ts +25 -6
- package/Types/Rum/WebVitals.ts +52 -0
- package/UI/Components/Detail/FieldLabel.tsx +8 -0
- package/UI/Components/InfoCard/InfoCard.tsx +47 -1
- package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
- package/UI/Components/ModelTable/Column.ts +5 -0
- package/UI/Components/Table/TableHeader.tsx +47 -2
- package/UI/Components/Table/Types/Column.ts +7 -0
- package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
- package/UI/Utils/PermissionGate.ts +11 -2
- package/Utils/Incident/IncidentFromAlerts.ts +516 -0
- package/Utils/Rum/SessionTraceState.ts +212 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
- package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +62 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
- package/build/dist/Server/Services/IncidentService.js +164 -12
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
- package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
- package/build/dist/Types/Permission.js +42 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rum/WebVitals.js +25 -0
- package/build/dist/Types/Rum/WebVitals.js.map +1 -1
- package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
- package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +18 -2
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
- package/build/dist/UI/Utils/PermissionGate.js +4 -2
- package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
- package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
- package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import {
|
|
5
|
+
MAX_DESCRIPTION_LENGTH,
|
|
6
|
+
expectReadableDescriptionRecord,
|
|
7
|
+
expectReadableMetricDescription,
|
|
8
|
+
expectTitleExplained,
|
|
9
|
+
} from "./MetricDescriptionRules";
|
|
10
|
+
import {
|
|
11
|
+
PROXMOX_METRIC_DESCRIPTIONS,
|
|
12
|
+
ProxmoxMetric,
|
|
13
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/MetricDescriptions/ProxmoxMetricDescriptions";
|
|
14
|
+
import { AggregationIntervalUtil } from "../../../Types/BaseDatabase/AggregationIntervalUtil";
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
* The words behind every (i) on the Proxmox pages. The shared rules make
|
|
18
|
+
* each one a short, finished sentence; the anchors below make each one say
|
|
19
|
+
* what the page actually computes - the window it averages, the snapshot it
|
|
20
|
+
* reads, the denominator of its percentage - and they read the constants
|
|
21
|
+
* those claims depend on out of the page sources, so a change to the code
|
|
22
|
+
* that makes a sentence untrue fails here.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const D: Record<ProxmoxMetric, string> = PROXMOX_METRIC_DESCRIPTIONS;
|
|
26
|
+
|
|
27
|
+
const PROXMOX_PAGES: string = path.join(
|
|
28
|
+
__dirname,
|
|
29
|
+
"..",
|
|
30
|
+
"..",
|
|
31
|
+
"..",
|
|
32
|
+
"..",
|
|
33
|
+
"App",
|
|
34
|
+
"FeatureSet",
|
|
35
|
+
"Dashboard",
|
|
36
|
+
"src",
|
|
37
|
+
"Pages",
|
|
38
|
+
"Proxmox",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
function readPage(relativePath: string): string {
|
|
42
|
+
return fs.readFileSync(path.join(PROXMOX_PAGES, relativePath), "utf8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const OVERVIEW_SOURCE: string = readPage("View/Index.tsx");
|
|
46
|
+
const UTILS_SOURCE: string = readPage("Utils/ProxmoxResourceUtils.ts");
|
|
47
|
+
const STORAGE_DETAIL_SOURCE: string = readPage("View/StorageDetail.tsx");
|
|
48
|
+
|
|
49
|
+
/*
|
|
50
|
+
* Each (i) as the page labels it, so a title that names a percentile is
|
|
51
|
+
* held to explaining it (none do today; the check keeps it that way).
|
|
52
|
+
*/
|
|
53
|
+
const TITLES: Record<ProxmoxMetric, string> = {
|
|
54
|
+
nodeAvailability: "Node Availability",
|
|
55
|
+
clusterCpu: "CPU",
|
|
56
|
+
clusterMemory: "Memory",
|
|
57
|
+
fullestStorage: "Storage",
|
|
58
|
+
guestsRunning: "Guests",
|
|
59
|
+
backupCoverage: "Backup Coverage",
|
|
60
|
+
cpuChart: "CPU",
|
|
61
|
+
memoryChart: "Memory",
|
|
62
|
+
storageChart: "Storage",
|
|
63
|
+
networkChart: "Network",
|
|
64
|
+
clusterInventoryCounts: "Cluster inventory counts",
|
|
65
|
+
haStates: "High-availability states",
|
|
66
|
+
clusterHealth: "Cluster Health",
|
|
67
|
+
quorum: "Quorum",
|
|
68
|
+
nodeCount: "Nodes",
|
|
69
|
+
guestCount: "Guests",
|
|
70
|
+
storageCount: "Storage",
|
|
71
|
+
agentStatus: "Agent Status",
|
|
72
|
+
replicationLastSync: "Last Sync",
|
|
73
|
+
replicationDuration: "Duration",
|
|
74
|
+
replicationFailedSyncs: "Failed Syncs",
|
|
75
|
+
cephCapacityUsed: "Capacity used",
|
|
76
|
+
topGuestsByCpu: "CPU Usage",
|
|
77
|
+
topGuestsByMemory: "Memory Usage",
|
|
78
|
+
nodeStatus: "Status",
|
|
79
|
+
nodeUptime: "Uptime",
|
|
80
|
+
nodeCpu: "CPU",
|
|
81
|
+
nodeMemory: "Memory (Used / Total)",
|
|
82
|
+
nodeHaState: "HA State",
|
|
83
|
+
nodeNetworkThroughput: "Network Throughput",
|
|
84
|
+
nodeDiskThroughput: "Disk Throughput",
|
|
85
|
+
guestStatus: "Status",
|
|
86
|
+
guestHaState: "HA State",
|
|
87
|
+
guestStartOnBoot: "Start on Boot",
|
|
88
|
+
guestBackupCoverage: "Backup Job Coverage",
|
|
89
|
+
guestUptime: "Uptime",
|
|
90
|
+
guestCpu: "CPU",
|
|
91
|
+
guestMemory: "Memory (Used / Max)",
|
|
92
|
+
guestDisk: "Disk (Used / Max)",
|
|
93
|
+
guestNetworkThroughput: "Network Throughput",
|
|
94
|
+
guestDiskThroughput: "Disk Throughput",
|
|
95
|
+
storageStatus: "Status",
|
|
96
|
+
storageUsed: "Used",
|
|
97
|
+
storageTotal: "Total",
|
|
98
|
+
storageUsedPercent: "Used %",
|
|
99
|
+
storageGrowthForecast: "Growth Forecast",
|
|
100
|
+
nodesTableStatus: "Status",
|
|
101
|
+
nodesTableCpu: "CPU",
|
|
102
|
+
nodesTableMemory: "Memory",
|
|
103
|
+
nodesTableUptime: "Age",
|
|
104
|
+
guestsTableStatus: "Status",
|
|
105
|
+
guestsTableCpu: "CPU",
|
|
106
|
+
guestsTableMemory: "Memory",
|
|
107
|
+
guestsTableUptime: "Age",
|
|
108
|
+
guestsTableHaState: "HA State",
|
|
109
|
+
guestsTableBackup: "Backup",
|
|
110
|
+
storageTableStatus: "Status",
|
|
111
|
+
storageTableUsage: "Used / Total",
|
|
112
|
+
storageTableAge: "Age",
|
|
113
|
+
insightsDiskThroughput: "Disk Throughput",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const KEYS: Array<ProxmoxMetric> = Object.keys(D) as Array<ProxmoxMetric>;
|
|
117
|
+
|
|
118
|
+
// A numeric constant as the source declares it, e.g. `= 5;` or `= 15 * 60 * 1000;`.
|
|
119
|
+
function numericConstant(source: string, name: string): number {
|
|
120
|
+
const declaration: RegExp = new RegExp(`const ${name}: number = ([0-9 *]+);`);
|
|
121
|
+
const match: RegExpMatchArray | null = source.match(declaration);
|
|
122
|
+
|
|
123
|
+
expect({ name, found: Boolean(match) }).toEqual({ name, found: true });
|
|
124
|
+
|
|
125
|
+
return match![1]!.split("*").reduce((product: number, factor: string) => {
|
|
126
|
+
return product * Number(factor.trim());
|
|
127
|
+
}, 1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("Proxmox metric descriptions follow the house rules", () => {
|
|
131
|
+
test("every description is a short, finished sentence and no two repeat", () => {
|
|
132
|
+
expectReadableDescriptionRecord(D, "PROXMOX_METRIC_DESCRIPTIONS");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test.each(KEYS)("%s reads as a plain explanation", (key: ProxmoxMetric) => {
|
|
136
|
+
expectReadableMetricDescription(D[key], key);
|
|
137
|
+
expect(D[key].length).toBeLessThanOrEqual(MAX_DESCRIPTION_LENGTH);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test.each(KEYS)(
|
|
141
|
+
"%s explains any percentile its title names",
|
|
142
|
+
(key: ProxmoxMetric) => {
|
|
143
|
+
expectTitleExplained(TITLES[key], D[key]);
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
test("every description has a title it sits beside, and no title is left without one", () => {
|
|
148
|
+
expect(Object.keys(TITLES).sort()).toEqual([...KEYS].sort());
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test.each(KEYS)(
|
|
152
|
+
"%s is one or two sentences, so it stays a small tooltip",
|
|
153
|
+
(key: ProxmoxMetric) => {
|
|
154
|
+
const sentences: Array<string> = D[key]
|
|
155
|
+
.split(/(?<=[.!?])\s+(?=[A-Z'"])/)
|
|
156
|
+
.filter(Boolean);
|
|
157
|
+
|
|
158
|
+
expect(sentences.length).toBeGreaterThanOrEqual(1);
|
|
159
|
+
expect(sentences.length).toBeLessThanOrEqual(2);
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
test.each(KEYS)(
|
|
164
|
+
"%s speaks to the customer, not in raw exporter metric names",
|
|
165
|
+
(key: ProxmoxMetric) => {
|
|
166
|
+
expect(D[key]).not.toMatch(/\bpve_[a-z_]+/);
|
|
167
|
+
expect(D[key]).not.toMatch(/\bceph_[a-z_]+/);
|
|
168
|
+
expect(D[key]).not.toMatch(/pve-exporter|corosync|vzdump|pvesr/i);
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("the overview tiles say which window they average", () => {
|
|
174
|
+
const TILE_WINDOW: number = numericConstant(
|
|
175
|
+
OVERVIEW_SOURCE,
|
|
176
|
+
"TILE_WINDOW_MINUTES",
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
test("the page still averages the tiles over the window the text names", () => {
|
|
180
|
+
expect(TILE_WINDOW).toBe(5);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test.each(["clusterCpu", "clusterMemory", "guestsRunning"] as const)(
|
|
184
|
+
"%s names the tile window and its whole-range fallback",
|
|
185
|
+
(key: ProxmoxMetric) => {
|
|
186
|
+
expect(D[key]).toContain(`last ${TILE_WINDOW} minutes`);
|
|
187
|
+
expect(D[key]).toContain("whole range");
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
/*
|
|
192
|
+
* The tile keeps the buckets whose START is inside the last 5 minutes.
|
|
193
|
+
* Once a range is long enough for the server to use buckets wider than
|
|
194
|
+
* that window, usually no bucket starts inside it and the tile quietly
|
|
195
|
+
* shows the whole-range mean - so the text must not promise "the last 5
|
|
196
|
+
* minutes" on long ranges, and the range where that begins must be the
|
|
197
|
+
* one the server's bucket picker really uses.
|
|
198
|
+
*/
|
|
199
|
+
test.each(["clusterCpu", "clusterMemory", "guestsRunning"] as const)(
|
|
200
|
+
"%s warns that long ranges often show the whole-range mean",
|
|
201
|
+
(key: ProxmoxMetric) => {
|
|
202
|
+
expect(D[key]).toMatch(/often the whole range on ranges over 12 hours/);
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
test("12 hours is where the server's buckets outgrow the tile window", () => {
|
|
207
|
+
const end: Date = new Date("2026-09-24T12:00:00.000Z");
|
|
208
|
+
const bucketMsFor: (rangeMs: number) => number = (
|
|
209
|
+
rangeMs: number,
|
|
210
|
+
): number => {
|
|
211
|
+
return AggregationIntervalUtil.getAggregationIntervalMs(
|
|
212
|
+
AggregationIntervalUtil.getAggregationIntervalForWindow({
|
|
213
|
+
startDate: new Date(end.getTime() - rangeMs),
|
|
214
|
+
endDate: end,
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
};
|
|
218
|
+
const HOUR: number = 60 * 60 * 1000;
|
|
219
|
+
const windowMs: number = TILE_WINDOW * 60 * 1000;
|
|
220
|
+
|
|
221
|
+
// Up to 12 hours a bucket always starts inside the tile window...
|
|
222
|
+
expect(bucketMsFor(30 * 60 * 1000)).toBeLessThanOrEqual(windowMs);
|
|
223
|
+
expect(bucketMsFor(12 * HOUR)).toBeLessThanOrEqual(windowMs);
|
|
224
|
+
// ...past 12 hours the buckets are wider than the window.
|
|
225
|
+
expect(bucketMsFor(12 * HOUR + 60 * 1000)).toBeGreaterThan(windowMs);
|
|
226
|
+
expect(bucketMsFor(24 * HOUR)).toBeGreaterThan(windowMs);
|
|
227
|
+
|
|
228
|
+
// And the tile really does filter on each bucket's start time.
|
|
229
|
+
expect(OVERVIEW_SOURCE).toContain(
|
|
230
|
+
"if (p.x.getTime() < tileWindowStartMs) {",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test.each([
|
|
235
|
+
"cpuChart",
|
|
236
|
+
"memoryChart",
|
|
237
|
+
"storageChart",
|
|
238
|
+
"networkChart",
|
|
239
|
+
] as const)(
|
|
240
|
+
"the %s card covers the whole selected range, not the tile window",
|
|
241
|
+
(key: ProxmoxMetric) => {
|
|
242
|
+
expect(D[key]).toMatch(/selected range/);
|
|
243
|
+
expect(D[key]).not.toMatch(/5 minutes/);
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
test("CPU is weighted by cores, so a big node counts more", () => {
|
|
248
|
+
expect(D.clusterCpu).toMatch(/CPU cores on all nodes/);
|
|
249
|
+
expect(D.clusterCpu).toMatch(/big node counts more/);
|
|
250
|
+
expect(D.cpuChart).toMatch(/bigger nodes weighted more/);
|
|
251
|
+
// The page really does weight by pve_cpu_usage_limit (cores).
|
|
252
|
+
expect(OVERVIEW_SOURCE).toContain("weightedSum += ratio * limit;");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("the Memory chart is bytes even though the Memory tile is a percentage", () => {
|
|
256
|
+
expect(D.memoryChart).toMatch(/in bytes/);
|
|
257
|
+
expect(D.memoryChart).toMatch(/percentage/);
|
|
258
|
+
expect(D.clusterMemory).toMatch(/share of their combined memory/);
|
|
259
|
+
expect(OVERVIEW_SOURCE).toContain("memBytesPerBucket.set(t, usedSum);");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("the Memory tile's sub-line is the latest point, not the average", () => {
|
|
263
|
+
expect(D.clusterMemory).toMatch(/latest data point/);
|
|
264
|
+
expect(OVERVIEW_SOURCE).toContain("if (t > latestMemTs) {");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("snapshot numbers say they ignore the time picker", () => {
|
|
269
|
+
test("the hero's count chips say what they count and that they ignore the range", () => {
|
|
270
|
+
expect(D.clusterInventoryCounts).toMatch(/Nodes online out of all nodes/);
|
|
271
|
+
expect(D.clusterInventoryCounts).toMatch(/guests running out of all/);
|
|
272
|
+
expect(D.clusterInventoryCounts).toMatch(/once per node/);
|
|
273
|
+
expect(D.clusterInventoryCounts).toMatch(
|
|
274
|
+
/rather than the selected time range/,
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test.each(["nodeAvailability", "fullestStorage", "guestsRunning"] as const)(
|
|
279
|
+
"%s reads the latest report",
|
|
280
|
+
(key: ProxmoxMetric) => {
|
|
281
|
+
expect(D[key]).toMatch(/latest report/);
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
test("the Storage tile is the single fullest volume, not an average", () => {
|
|
286
|
+
expect(D.fullestStorage).toMatch(/fullest/);
|
|
287
|
+
expect(D.fullestStorage).toMatch(/not an average/);
|
|
288
|
+
expect(D.fullestStorage).toMatch(/rather than the selected time range/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("the 85% storage threshold in the text is the one the page uses", () => {
|
|
292
|
+
const nearFull: number = numericConstant(
|
|
293
|
+
OVERVIEW_SOURCE,
|
|
294
|
+
"STORAGE_NEAR_FULL_PERCENT",
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
expect(nearFull).toBe(85);
|
|
298
|
+
|
|
299
|
+
for (const key of [
|
|
300
|
+
"fullestStorage",
|
|
301
|
+
"clusterHealth",
|
|
302
|
+
"storageUsedPercent",
|
|
303
|
+
] as const) {
|
|
304
|
+
expect(D[key]).toContain(`${nearFull}%`);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("the Storage chart admits that shared storage is counted once per node", () => {
|
|
309
|
+
expect(D.storageChart).toMatch(/counted once for each node/);
|
|
310
|
+
expect(D.storageChart).toMatch(/higher than the space really used/);
|
|
311
|
+
expect(D.storageCount).toMatch(/listed once per node/);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("list columns and detail fields say how stale their numbers can be", () => {
|
|
316
|
+
const staleMinutes: number =
|
|
317
|
+
numericConstant(UTILS_SOURCE, "METRIC_STALE_MS") / 60 / 1000;
|
|
318
|
+
|
|
319
|
+
test("the staleness cut-off in the text is the one the lists use", () => {
|
|
320
|
+
expect(staleMinutes).toBe(15);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test.each([
|
|
324
|
+
"nodesTableCpu",
|
|
325
|
+
"nodesTableMemory",
|
|
326
|
+
"guestsTableCpu",
|
|
327
|
+
"guestsTableMemory",
|
|
328
|
+
] as const)(
|
|
329
|
+
"%s shows N/A after 15 minutes without an update",
|
|
330
|
+
(key: ProxmoxMetric) => {
|
|
331
|
+
expect(D[key]).toContain(`last ${staleMinutes} minutes`);
|
|
332
|
+
expect(D[key]).toContain("N/A");
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
test.each(["topGuestsByCpu", "topGuestsByMemory"] as const)(
|
|
337
|
+
"%s leaves out guests that went quiet",
|
|
338
|
+
(key: ProxmoxMetric) => {
|
|
339
|
+
expect(D[key]).toContain(`last ${staleMinutes} minutes`);
|
|
340
|
+
expect(D[key]).toMatch(/left out/);
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
test.each(["nodeCpu", "guestCpu"] as const)(
|
|
345
|
+
"%s on a detail page warns it may be the last known value",
|
|
346
|
+
(key: ProxmoxMetric) => {
|
|
347
|
+
expect(D[key]).toMatch(/last value the agent sent/);
|
|
348
|
+
expect(D[key]).toMatch(/out of date/);
|
|
349
|
+
// Detail pages do not hide stale values, so they must not promise to.
|
|
350
|
+
expect(D[key]).not.toMatch(/N\/A/);
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe("percentages name their denominator", () => {
|
|
356
|
+
test.each(["guestCpu", "guestsTableCpu", "topGuestsByCpu"] as const)(
|
|
357
|
+
"guest CPU (%s) is a share of the cores assigned to the guest",
|
|
358
|
+
(key: ProxmoxMetric) => {
|
|
359
|
+
expect(D[key]).toMatch(/CPU cores assigned to/);
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
test.each(["nodeCpu", "nodesTableCpu"] as const)(
|
|
364
|
+
"node CPU (%s) is a share of all the node's cores",
|
|
365
|
+
(key: ProxmoxMetric) => {
|
|
366
|
+
expect(D[key]).toMatch(/all its CPU cores/);
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
test("guest memory is compared with what was assigned, node memory with the total", () => {
|
|
371
|
+
expect(D.guestMemory).toMatch(/memory assigned to it/);
|
|
372
|
+
expect(D.guestsTableMemory).toMatch(/memory assigned to it/);
|
|
373
|
+
expect(D.topGuestsByMemory).toMatch(/assigned memory/);
|
|
374
|
+
expect(D.nodeMemory).toMatch(/total memory/);
|
|
375
|
+
expect(D.nodesTableMemory).toMatch(/total memory/);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("Ceph capacity is raw capacity across the OSDs, with the card's colour steps", () => {
|
|
379
|
+
expect(D.cephCapacityUsed).toMatch(/raw disk capacity/);
|
|
380
|
+
expect(D.cephCapacityUsed).toMatch(/OSDs \(storage disks\)/);
|
|
381
|
+
expect(D.cephCapacityUsed).toContain("75%");
|
|
382
|
+
expect(D.cephCapacityUsed).toContain("90%");
|
|
383
|
+
expect(OVERVIEW_SOURCE).toContain("capacityPercent >= 90");
|
|
384
|
+
expect(OVERVIEW_SOURCE).toContain("capacityPercent >= 75");
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("health, quorum and backups say exactly what they check", () => {
|
|
389
|
+
test("Cluster Health lists every driver the page uses", () => {
|
|
390
|
+
for (const driver of [
|
|
391
|
+
/Unhealthy/,
|
|
392
|
+
/offline/,
|
|
393
|
+
/error or fence/,
|
|
394
|
+
/Degraded/,
|
|
395
|
+
/85% full/,
|
|
396
|
+
/start-on-boot guest is stopped/,
|
|
397
|
+
/no backup job/,
|
|
398
|
+
/replication fails/,
|
|
399
|
+
/Unknown if the agent is disconnected/,
|
|
400
|
+
]) {
|
|
401
|
+
expect(D.clusterHealth).toMatch(driver);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("Quorum explains quorum and admits it is derived, not read", () => {
|
|
406
|
+
expect(D.quorum).toMatch(/more than half/);
|
|
407
|
+
// Without quorum running guests keep running; changes and HA stop.
|
|
408
|
+
expect(D.quorum).not.toMatch(/keep running/);
|
|
409
|
+
expect(D.quorum).toMatch(/start guests, change settings/);
|
|
410
|
+
expect(D.quorum).toMatch(/\(quorum\)/);
|
|
411
|
+
expect(D.quorum).toMatch(/red at half or fewer/);
|
|
412
|
+
expect(D.quorum).toMatch(/not read from Proxmox's own quorum service/);
|
|
413
|
+
expect(OVERVIEW_SOURCE).toContain("nodesOnline / nodesTotal <= 0.5");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test.each([
|
|
417
|
+
"backupCoverage",
|
|
418
|
+
"guestBackupCoverage",
|
|
419
|
+
"guestsTableBackup",
|
|
420
|
+
] as const)(
|
|
421
|
+
"%s checks job coverage only, not backup success",
|
|
422
|
+
(key: ProxmoxMetric) => {
|
|
423
|
+
expect(D[key]).toMatch(/backup job/);
|
|
424
|
+
expect(D[key]).toMatch(/succeeded/);
|
|
425
|
+
expect(D[key]).toMatch(/not|does not/);
|
|
426
|
+
},
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
test("the backup column spells out all three values it can show", () => {
|
|
430
|
+
expect(D.guestsTableBackup).toMatch(/In job/);
|
|
431
|
+
expect(D.guestsTableBackup).toMatch(/Not backed up/);
|
|
432
|
+
expect(D.guestsTableBackup).toMatch(/dash/);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test.each([
|
|
436
|
+
"haStates",
|
|
437
|
+
"nodeHaState",
|
|
438
|
+
"guestHaState",
|
|
439
|
+
"guestsTableHaState",
|
|
440
|
+
] as const)(
|
|
441
|
+
"%s explains high availability and its problem states",
|
|
442
|
+
(key: ProxmoxMetric) => {
|
|
443
|
+
expect(D[key]).toMatch(/high-availability|high availability/);
|
|
444
|
+
expect(D[key]).toMatch(/fence/);
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
test("HA error and fence really do make the cluster Unhealthy", () => {
|
|
449
|
+
expect(D.haStates).toMatch(/Unhealthy/);
|
|
450
|
+
expect(OVERVIEW_SOURCE).toContain(
|
|
451
|
+
'row.haState === "error" || row.haState === "fence"',
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("the agent status names the disconnect delay the server applies", () => {
|
|
456
|
+
expect(D.agentStatus).toMatch(/15 to 20 minutes after data stops/);
|
|
457
|
+
|
|
458
|
+
/*
|
|
459
|
+
* A cluster is marked disconnected once lastSeenAt is 15 minutes old,
|
|
460
|
+
* by a job that runs every 5 minutes - hence "15 to 20 minutes".
|
|
461
|
+
*/
|
|
462
|
+
const service: string = fs.readFileSync(
|
|
463
|
+
path.join(
|
|
464
|
+
__dirname,
|
|
465
|
+
"..",
|
|
466
|
+
"..",
|
|
467
|
+
"..",
|
|
468
|
+
"Server",
|
|
469
|
+
"Services",
|
|
470
|
+
"ProxmoxClusterService.ts",
|
|
471
|
+
),
|
|
472
|
+
"utf8",
|
|
473
|
+
);
|
|
474
|
+
const cron: string = fs.readFileSync(
|
|
475
|
+
path.join(
|
|
476
|
+
__dirname,
|
|
477
|
+
"..",
|
|
478
|
+
"..",
|
|
479
|
+
"..",
|
|
480
|
+
"..",
|
|
481
|
+
"App",
|
|
482
|
+
"FeatureSet",
|
|
483
|
+
"Workers",
|
|
484
|
+
"Jobs",
|
|
485
|
+
"Proxmox",
|
|
486
|
+
"CleanupStaleResources.ts",
|
|
487
|
+
),
|
|
488
|
+
"utf8",
|
|
489
|
+
);
|
|
490
|
+
const markDisconnected: string = service.slice(
|
|
491
|
+
service.indexOf("public async markDisconnectedClusters"),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
expect(markDisconnected).toContain(
|
|
495
|
+
"OneUptimeDate.getCurrentDate(),\n -15,",
|
|
496
|
+
);
|
|
497
|
+
expect(markDisconnected).toContain('otelCollectorStatus: "disconnected"');
|
|
498
|
+
expect(cron).toContain("schedule: EVERY_FIVE_MINUTE");
|
|
499
|
+
expect(cron).toContain("markDisconnectedClusters()");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
describe("replication, forecast and uptime", () => {
|
|
504
|
+
test("Last Sync names the amber and red steps the table uses", () => {
|
|
505
|
+
const warn: number = numericConstant(
|
|
506
|
+
OVERVIEW_SOURCE,
|
|
507
|
+
"REPLICATION_SYNC_WARN_SECONDS",
|
|
508
|
+
);
|
|
509
|
+
const danger: number = numericConstant(
|
|
510
|
+
OVERVIEW_SOURCE,
|
|
511
|
+
"REPLICATION_SYNC_DANGER_SECONDS",
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
expect(D.replicationLastSync).toContain(`${warn / 3600} hour`);
|
|
515
|
+
expect(D.replicationLastSync).toContain(`${danger / 3600} hours`);
|
|
516
|
+
expect(D.replicationLastSync).toMatch(/successful/);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("Failed Syncs says any failure marks the cluster Degraded", () => {
|
|
520
|
+
expect(D.replicationFailedSyncs).toMatch(/above zero/);
|
|
521
|
+
expect(D.replicationFailedSyncs).toMatch(/at least Degraded/);
|
|
522
|
+
/*
|
|
523
|
+
* Replication failures only lift a Healthy cluster to Degraded; an
|
|
524
|
+
* Unhealthy one stays Unhealthy.
|
|
525
|
+
*/
|
|
526
|
+
expect(OVERVIEW_SOURCE).toContain(
|
|
527
|
+
'inventoryHealth === "Healthy" && replicationDegradedItems.length > 0',
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("Failed Syncs is a run of failures since the last good sync, not a lifetime total", () => {
|
|
532
|
+
expect(D.replicationFailedSyncs).toMatch(/failed in a row/);
|
|
533
|
+
expect(D.replicationFailedSyncs).toMatch(/since its last successful sync/);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("the growth forecast names its fit window, its cut-off and 'Full now'", () => {
|
|
537
|
+
const hours: number = numericConstant(
|
|
538
|
+
STORAGE_DETAIL_SOURCE,
|
|
539
|
+
"PROJECTION_WINDOW_HOURS",
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
expect(D.storageGrowthForecast).toContain(`last ${hours} hours`);
|
|
543
|
+
expect(D.storageGrowthForecast).toMatch(/straight-line/);
|
|
544
|
+
expect(D.storageGrowthForecast).toMatch(/within a year/);
|
|
545
|
+
expect(D.storageGrowthForecast).toMatch(/'Full now'/);
|
|
546
|
+
expect(STORAGE_DETAIL_SOURCE).toContain("daysToFull <= 365");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test.each(["nodesTableUptime", "guestsTableUptime"] as const)(
|
|
550
|
+
"the Age column (%s) is uptime, not age",
|
|
551
|
+
(key: ProxmoxMetric) => {
|
|
552
|
+
expect(D[key]).toMatch(/\(its uptime\)/);
|
|
553
|
+
},
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
test("the storage Age column admits Proxmox reports no uptime for storage", () => {
|
|
557
|
+
expect(D.storageTableAge).toMatch(/does not report an uptime/);
|
|
558
|
+
expect(D.storageTableAge).toMatch(/dash/);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("the QEMU disk field names the guest agent it depends on", () => {
|
|
562
|
+
expect(D.guestDisk).toMatch(/QEMU guest agent/);
|
|
563
|
+
expect(D.guestDisk).toMatch(/0 or N\/A/);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
describe("throughput charts", () => {
|
|
568
|
+
test.each([
|
|
569
|
+
"networkChart",
|
|
570
|
+
"nodeNetworkThroughput",
|
|
571
|
+
"nodeDiskThroughput",
|
|
572
|
+
"guestNetworkThroughput",
|
|
573
|
+
"guestDiskThroughput",
|
|
574
|
+
"insightsDiskThroughput",
|
|
575
|
+
] as const)("%s is a per-second rate", (key: ProxmoxMetric) => {
|
|
576
|
+
expect(D[key]).toMatch(/per second/);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test.each(["nodeNetworkThroughput", "nodeDiskThroughput"] as const)(
|
|
580
|
+
"%s warns it is often empty for nodes",
|
|
581
|
+
(key: ProxmoxMetric) => {
|
|
582
|
+
expect(D[key]).toMatch(/Often empty/);
|
|
583
|
+
},
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
test("the cluster-wide charts say whose traffic they add up", () => {
|
|
587
|
+
expect(D.networkChart).toMatch(/all virtual machines and containers/);
|
|
588
|
+
expect(D.insightsDiskThroughput).toMatch(
|
|
589
|
+
/every virtual machine and container/,
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
});
|