miravo 0.1.4 → 0.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/README.md +38 -32
- package/package.json +11 -5
- package/src/__tests__/integration/executable-methods-runner.ts +253 -0
- package/src/__tests__/integration/executable-methods.integration.ts +1688 -0
- package/src/commands/fork.ts +205 -0
- package/src/commands/inject.test.ts +6 -9
- package/src/commands/inject.ts +48 -158
- package/src/commands/models.test.ts +32 -8
- package/src/commands/models.ts +71 -45
- package/src/commands/start.test.ts +32 -63
- package/src/commands/start.ts +333 -193
- package/src/commands/status.test.ts +3 -22
- package/src/commands/status.ts +114 -279
- package/src/commands/templates.test.ts +4 -50
- package/src/commands/templates.ts +12 -191
- package/src/index.ts +42 -8
- package/src/lib/admin-client.ts +214 -0
- package/src/lib/admin-server.test.ts +276 -0
- package/src/lib/admin-server.ts +307 -0
- package/src/lib/shutdown.test.ts +221 -93
- package/src/lib/shutdown.ts +478 -98
- package/src/lib/template-category.test.ts +14 -7
- package/src/lib/template-category.ts +6 -1
- package/src/lib/template-locator.ts +5 -8
- package/src/lib/template-utils.ts +14 -0
- package/src/logger.test.ts +2 -2
- package/src/logger.ts +68 -15
- package/src/output.ts +41 -1
- package/src/lib/health-server.test.ts +0 -161
- package/src/lib/health-server.ts +0 -154
package/src/commands/start.ts
CHANGED
|
@@ -12,11 +12,13 @@ import {
|
|
|
12
12
|
validateFullTemplate,
|
|
13
13
|
} from "@miravo/core";
|
|
14
14
|
import { MQTTAdapter } from "@miravo/mqtt";
|
|
15
|
-
import
|
|
15
|
+
import { OPCUAAdapter } from "@miravo/opcua";
|
|
16
|
+
import type { DiscoveredTemplate, MiravoTemplate, TwinModel } from "@miravo/shared";
|
|
16
17
|
import {
|
|
17
18
|
ConfigNotFoundError,
|
|
18
19
|
ConfigValidationError,
|
|
19
20
|
DEFAULT_MQTT_PORT,
|
|
21
|
+
DEFAULT_OPCUA_PORT,
|
|
20
22
|
DEFAULT_TICK_INTERVAL_MS,
|
|
21
23
|
DEFAULT_TIME_MULTIPLIER,
|
|
22
24
|
MIRAVO_VERSION,
|
|
@@ -24,8 +26,8 @@ import {
|
|
|
24
26
|
SimulationNameSchema,
|
|
25
27
|
} from "@miravo/shared";
|
|
26
28
|
import type { Command } from "commander";
|
|
27
|
-
import { box, colors } from "consola/utils";
|
|
28
|
-
import {
|
|
29
|
+
import { box, colors, stripAnsi } from "consola/utils";
|
|
30
|
+
import { startAdminServer } from "../lib/admin-server.ts";
|
|
29
31
|
import {
|
|
30
32
|
parseDurationMs,
|
|
31
33
|
parseNonNegativeInt,
|
|
@@ -33,9 +35,10 @@ import {
|
|
|
33
35
|
parsePositiveFloat,
|
|
34
36
|
parsePositiveInt,
|
|
35
37
|
} from "../lib/parse.ts";
|
|
36
|
-
import {
|
|
38
|
+
import { createShutdownController, printSummaryLine } from "../lib/shutdown.ts";
|
|
37
39
|
import {
|
|
38
40
|
groupTemplatesByCategory,
|
|
41
|
+
isStressBenchmarkTemplate,
|
|
39
42
|
sortTemplatesByCategoryAndName,
|
|
40
43
|
} from "../lib/template-category.ts";
|
|
41
44
|
import {
|
|
@@ -53,27 +56,31 @@ import {
|
|
|
53
56
|
isPlain,
|
|
54
57
|
lifecycleLog,
|
|
55
58
|
mqttLog,
|
|
59
|
+
opcuaLog,
|
|
56
60
|
setRuntimeLogContext,
|
|
57
61
|
writeOutput,
|
|
58
62
|
} from "../logger.ts";
|
|
59
|
-
import {
|
|
63
|
+
import { formatKeyValue, formatTable } from "../output.ts";
|
|
60
64
|
|
|
61
65
|
interface StartOptions {
|
|
62
66
|
readonly name?: string;
|
|
63
67
|
readonly template?: string;
|
|
64
68
|
readonly modelsDir?: string;
|
|
65
|
-
readonly contentDir?: string;
|
|
66
69
|
readonly speed?: number;
|
|
67
70
|
readonly seed?: number;
|
|
68
71
|
readonly tick?: number;
|
|
69
|
-
readonly
|
|
72
|
+
readonly mqttPort?: number;
|
|
70
73
|
readonly mqttHost?: string;
|
|
71
74
|
readonly mqttMode?: string;
|
|
72
75
|
readonly mqttUrl?: string;
|
|
73
76
|
readonly mqttUsername?: string;
|
|
74
77
|
readonly mqttPassword?: string;
|
|
75
|
-
readonly
|
|
76
|
-
readonly
|
|
78
|
+
readonly mqtt?: boolean;
|
|
79
|
+
readonly opcuaPort?: number;
|
|
80
|
+
readonly opcuaHost?: string;
|
|
81
|
+
readonly opcua?: boolean;
|
|
82
|
+
readonly adminHost?: string;
|
|
83
|
+
readonly adminPort?: number;
|
|
77
84
|
readonly dryRun?: boolean;
|
|
78
85
|
readonly summaryInterval?: number;
|
|
79
86
|
}
|
|
@@ -98,47 +105,41 @@ const ERROR_CONTEXT_OVERFLOW = "__overflow__";
|
|
|
98
105
|
export function registerStartCommand(program: Command): void {
|
|
99
106
|
program
|
|
100
107
|
.command("start")
|
|
101
|
-
.description("Start the
|
|
102
|
-
.option("--name <name>", "Simulation name for control topics (env: MIRAVO_NAME)")
|
|
108
|
+
.description("Start the simulation engine")
|
|
109
|
+
.option("-n, --name <name>", "Simulation name for control topics (env: MIRAVO_NAME)")
|
|
103
110
|
.option(
|
|
104
|
-
"--template <id|name|path>",
|
|
111
|
+
"-t, --template <id|name|path>",
|
|
105
112
|
"Template identifier, display name, or .miravo.yaml path (env: MIRAVO_TEMPLATE)",
|
|
106
113
|
)
|
|
107
114
|
.option("--models-dir <path>", "Additional directory to search for .twin.yaml models")
|
|
108
|
-
.option(
|
|
109
|
-
"--content-dir <path>",
|
|
110
|
-
"User content root with /templates and /models (env: MIRAVO_CONTENT_DIR)",
|
|
111
|
-
)
|
|
112
|
-
.option("--speed <multiplier>", "Time multiplier override (default: 1)", parsePositiveFloat)
|
|
115
|
+
.option("-s, --speed <multiplier>", "Time multiplier (default: 1)", parsePositiveFloat)
|
|
113
116
|
.option("--seed <number>", "RNG seed for deterministic simulation", parseNonNegativeInt)
|
|
117
|
+
.option("--tick <ms>", "Tick interval in milliseconds (default: 1000)", parseDurationMs)
|
|
114
118
|
.option(
|
|
115
|
-
"--
|
|
116
|
-
"
|
|
117
|
-
parseDurationMs,
|
|
118
|
-
)
|
|
119
|
-
.option(
|
|
120
|
-
"--port <number>",
|
|
121
|
-
"MQTT broker port override (default: 1883, env: MIRAVO_MQTT_PORT)",
|
|
119
|
+
"-p, --mqtt-port <number>",
|
|
120
|
+
"MQTT broker port (default: 1883, env: MIRAVO_MQTT_PORT)",
|
|
122
121
|
parsePort,
|
|
123
122
|
)
|
|
124
|
-
.option(
|
|
125
|
-
|
|
126
|
-
"Embedded MQTT bind host override (default: 127.0.0.1, env: MIRAVO_MQTT_HOST)",
|
|
127
|
-
)
|
|
128
|
-
.option("--mqtt-mode <mode>", "MQTT mode override: embedded | external (env: MIRAVO_MQTT_MODE)")
|
|
123
|
+
.option("--mqtt-host <host>", "MQTT bind host (default: 127.0.0.1, env: MIRAVO_MQTT_HOST)")
|
|
124
|
+
.option("--mqtt-mode <mode>", "MQTT mode: embedded | external (env: MIRAVO_MQTT_MODE)")
|
|
129
125
|
.option("--mqtt-url <url>", "External MQTT broker URL (env: MIRAVO_MQTT_URL)")
|
|
130
126
|
.option("--mqtt-username <username>", "External MQTT username (env: MIRAVO_MQTT_USERNAME)")
|
|
131
127
|
.option("--mqtt-password <password>", "External MQTT password (env: MIRAVO_MQTT_PASSWORD)")
|
|
128
|
+
.option("--no-mqtt", "Disable MQTT broker")
|
|
129
|
+
.option("--opcua", "Enable OPC UA server")
|
|
132
130
|
.option(
|
|
133
|
-
"--
|
|
134
|
-
"
|
|
131
|
+
"--opcua-port <number>",
|
|
132
|
+
"OPC UA server port (default: 4840, env: MIRAVO_OPCUA_PORT)",
|
|
133
|
+
parsePort,
|
|
135
134
|
)
|
|
135
|
+
.option("--opcua-host <host>", "OPC UA server bind host (env: MIRAVO_OPCUA_HOST)")
|
|
136
|
+
.option("--admin-host <host>", "Admin server host (default: 127.0.0.1, env: MIRAVO_ADMIN_HOST)")
|
|
136
137
|
.option(
|
|
137
|
-
"--
|
|
138
|
-
"
|
|
138
|
+
"--admin-port <number>",
|
|
139
|
+
"Admin server port (default: 8080, env: MIRAVO_ADMIN_PORT)",
|
|
139
140
|
parsePort,
|
|
140
141
|
)
|
|
141
|
-
.option("--dry-run", "Validate config and print summary without starting")
|
|
142
|
+
.option("-d, --dry-run", "Validate config and print summary without starting")
|
|
142
143
|
.option(
|
|
143
144
|
"--summary-interval <seconds>",
|
|
144
145
|
"Seconds between periodic summary lines (default: 5)",
|
|
@@ -155,62 +156,128 @@ export function registerStartCommand(program: Command): void {
|
|
|
155
156
|
|
|
156
157
|
async function startAction(options: StartOptions): Promise<void> {
|
|
157
158
|
let runtimeLogContextSet = false;
|
|
159
|
+
let shutdown: ReturnType<typeof createShutdownController> | null = null;
|
|
160
|
+
let engine: Engine | undefined;
|
|
161
|
+
let stopCliResources = (): void => {};
|
|
158
162
|
try {
|
|
159
|
-
const catalog = createCatalog(
|
|
163
|
+
const catalog = createCatalog();
|
|
160
164
|
|
|
161
165
|
// Resolve env-var fallbacks for common options
|
|
162
166
|
const templateOption = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
|
|
163
167
|
const simulationNameEnv = Bun.env["MIRAVO_NAME"];
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
};
|
|
179
|
-
let portOption = options.port;
|
|
180
|
-
if (portOption === undefined && portEnv) {
|
|
181
|
-
try {
|
|
182
|
-
portOption = parsePort(portEnv);
|
|
183
|
-
} catch {
|
|
184
|
-
throw new ConfigValidationError({
|
|
185
|
-
message: `Invalid MIRAVO_MQTT_PORT environment variable: "${portEnv}".`,
|
|
186
|
-
fix: "Set MIRAVO_MQTT_PORT to a valid port number between 1 and 65535 (for example, 1883).",
|
|
187
|
-
});
|
|
168
|
+
// Protocol selection: CLI flags decide which protocols run
|
|
169
|
+
const mqttEnabled = options.mqtt !== false; // --no-mqtt disables
|
|
170
|
+
const opcuaEnabled = options.opcua === true; // --opcua enables
|
|
171
|
+
|
|
172
|
+
// Warn on ignored protocol-specific flags
|
|
173
|
+
if (!mqttEnabled) {
|
|
174
|
+
if (options.mqttPort !== undefined) {
|
|
175
|
+
configLog.warn("--mqtt-port ignored because MQTT is disabled.");
|
|
176
|
+
}
|
|
177
|
+
if (options.mqttHost !== undefined) {
|
|
178
|
+
configLog.warn("--mqtt-host ignored because MQTT is disabled.");
|
|
179
|
+
}
|
|
180
|
+
if (options.mqttMode !== undefined) {
|
|
181
|
+
configLog.warn("--mqtt-mode ignored because MQTT is disabled.");
|
|
188
182
|
}
|
|
189
183
|
}
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
184
|
+
if (!opcuaEnabled) {
|
|
185
|
+
if (options.opcuaPort !== undefined) {
|
|
186
|
+
configLog.warn(
|
|
187
|
+
"--opcua-port ignored because OPC UA is not enabled. Use --opcua to enable it.",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (options.opcuaHost !== undefined) {
|
|
191
|
+
configLog.warn(
|
|
192
|
+
"--opcua-host ignored because OPC UA is not enabled. Use --opcua to enable it.",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
195
|
}
|
|
196
|
-
|
|
196
|
+
|
|
197
|
+
const adminHost = options.adminHost ?? Bun.env["MIRAVO_ADMIN_HOST"] ?? "127.0.0.1";
|
|
198
|
+
const adminPortEnv = Bun.env["MIRAVO_ADMIN_PORT"];
|
|
199
|
+
if (adminHost.trim().length === 0) {
|
|
197
200
|
throw new ConfigValidationError({
|
|
198
|
-
message: "Invalid
|
|
199
|
-
fix: "Set --
|
|
201
|
+
message: "Invalid admin server host: value is empty.",
|
|
202
|
+
fix: "Set --admin-host / MIRAVO_ADMIN_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
|
|
200
203
|
});
|
|
201
204
|
}
|
|
202
|
-
|
|
203
|
-
|
|
205
|
+
|
|
206
|
+
// MQTT env overrides — only parsed when MQTT is enabled
|
|
207
|
+
let portOption = options.mqttPort;
|
|
208
|
+
let mqttOverrides: MqttRunOverrides = {};
|
|
209
|
+
if (mqttEnabled) {
|
|
210
|
+
const portEnv = Bun.env["MIRAVO_MQTT_PORT"];
|
|
211
|
+
const mqttHost = options.mqttHost ?? Bun.env["MIRAVO_MQTT_HOST"];
|
|
212
|
+
const mqttMode = parseMqttModeOption(options.mqttMode ?? Bun.env["MIRAVO_MQTT_MODE"]);
|
|
213
|
+
const mqttUrl = options.mqttUrl ?? Bun.env["MIRAVO_MQTT_URL"];
|
|
214
|
+
const mqttUsername = options.mqttUsername ?? Bun.env["MIRAVO_MQTT_USERNAME"];
|
|
215
|
+
const mqttPassword = options.mqttPassword ?? Bun.env["MIRAVO_MQTT_PASSWORD"];
|
|
216
|
+
mqttOverrides = {
|
|
217
|
+
...(mqttMode !== undefined ? { mode: mqttMode } : {}),
|
|
218
|
+
...(mqttHost !== undefined && mqttHost.length > 0 ? { host: mqttHost } : {}),
|
|
219
|
+
...(mqttUrl !== undefined && mqttUrl.length > 0 ? { url: mqttUrl } : {}),
|
|
220
|
+
...(mqttUsername !== undefined && mqttUsername.length > 0
|
|
221
|
+
? { username: mqttUsername }
|
|
222
|
+
: {}),
|
|
223
|
+
...(mqttPassword !== undefined && mqttPassword.length > 0
|
|
224
|
+
? { password: mqttPassword }
|
|
225
|
+
: {}),
|
|
226
|
+
};
|
|
227
|
+
if (portOption === undefined && portEnv) {
|
|
228
|
+
try {
|
|
229
|
+
portOption = parsePort(portEnv);
|
|
230
|
+
} catch {
|
|
231
|
+
throw new ConfigValidationError({
|
|
232
|
+
message: `Invalid MIRAVO_MQTT_PORT environment variable: "${portEnv}".`,
|
|
233
|
+
fix: "Set MIRAVO_MQTT_PORT to a valid port number between 1 and 65535 (for example, 1883).",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (mqttHost !== undefined && mqttHost.trim().length === 0) {
|
|
238
|
+
throw new ConfigValidationError({
|
|
239
|
+
message: "Invalid MQTT bind host: value is empty.",
|
|
240
|
+
fix: "Set --mqtt-host / MIRAVO_MQTT_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// OPC UA env overrides — only parsed when OPC UA is enabled
|
|
246
|
+
let opcuaPort: number | undefined = options.opcuaPort;
|
|
247
|
+
let opcuaHost: string | undefined;
|
|
248
|
+
if (opcuaEnabled) {
|
|
249
|
+
const opcuaPortEnv = Bun.env["MIRAVO_OPCUA_PORT"];
|
|
250
|
+
if (opcuaPort === undefined && opcuaPortEnv) {
|
|
251
|
+
try {
|
|
252
|
+
opcuaPort = parsePort(opcuaPortEnv);
|
|
253
|
+
} catch {
|
|
254
|
+
throw new ConfigValidationError({
|
|
255
|
+
message: `Invalid MIRAVO_OPCUA_PORT environment variable: "${opcuaPortEnv}".`,
|
|
256
|
+
fix: "Set MIRAVO_OPCUA_PORT to a valid port number between 1 and 65535 (for example, 4840).",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
opcuaHost = options.opcuaHost ?? Bun.env["MIRAVO_OPCUA_HOST"] ?? undefined;
|
|
261
|
+
if (opcuaHost !== undefined && opcuaHost.trim().length === 0) {
|
|
262
|
+
throw new ConfigValidationError({
|
|
263
|
+
message: "Invalid OPC UA bind host: value is empty.",
|
|
264
|
+
fix: "Set --opcua-host / MIRAVO_OPCUA_HOST to a valid host (for example, 0.0.0.0).",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let adminPort = options.adminPort;
|
|
270
|
+
if (adminPort === undefined && adminPortEnv) {
|
|
204
271
|
try {
|
|
205
|
-
|
|
272
|
+
adminPort = parsePort(adminPortEnv);
|
|
206
273
|
} catch {
|
|
207
274
|
throw new ConfigValidationError({
|
|
208
|
-
message: `Invalid
|
|
209
|
-
fix: "Set
|
|
275
|
+
message: `Invalid MIRAVO_ADMIN_PORT environment variable: "${adminPortEnv}".`,
|
|
276
|
+
fix: "Set MIRAVO_ADMIN_PORT to a valid port number between 1 and 65535 (for example, 8080).",
|
|
210
277
|
});
|
|
211
278
|
}
|
|
212
279
|
}
|
|
213
|
-
|
|
280
|
+
adminPort ??= 8080;
|
|
214
281
|
|
|
215
282
|
// Step 1: Resolve template path
|
|
216
283
|
const templatePath = await resolveTemplatePath(templateOption, catalog);
|
|
@@ -241,28 +308,60 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
241
308
|
runtimeLogContextSet = true;
|
|
242
309
|
controlLog.info(`Run ID: ${runId}`);
|
|
243
310
|
|
|
244
|
-
|
|
245
|
-
const engine = createAndPopulateEngine(
|
|
246
|
-
template,
|
|
247
|
-
models,
|
|
311
|
+
shutdown = createShutdownController({
|
|
248
312
|
seed,
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
)
|
|
313
|
+
seedExplicit,
|
|
314
|
+
});
|
|
315
|
+
const shutdownRequested = shutdown.waitForShutdownRequest();
|
|
316
|
+
shutdown.addTask("finalize", "runtime log context", () => {
|
|
317
|
+
if (runtimeLogContextSet) {
|
|
318
|
+
clearRuntimeLogContext();
|
|
319
|
+
runtimeLogContextSet = false;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
253
322
|
|
|
254
|
-
// Step
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
323
|
+
// Step 4: Create engine and populate
|
|
324
|
+
engine = createAndPopulateEngine(template, models, seed, options, templatePath, simulationName);
|
|
325
|
+
shutdown.setEngine(engine);
|
|
326
|
+
|
|
327
|
+
let controlPlane: ControlPlane | null = null;
|
|
328
|
+
let adminServer: { endpoint: string; stop: () => void } | null = null;
|
|
329
|
+
let lastTickProgressAt = performance.now();
|
|
330
|
+
let tickProgressSubscribed = false;
|
|
331
|
+
const onTickProgress = (): void => {
|
|
332
|
+
lastTickProgressAt = performance.now();
|
|
333
|
+
};
|
|
334
|
+
let unsubscribeEvents = (): void => {};
|
|
335
|
+
let summaryTimer: ReturnType<typeof setInterval> | null = null;
|
|
336
|
+
|
|
337
|
+
stopCliResources = (): void => {
|
|
338
|
+
controlPlane?.stop();
|
|
339
|
+
adminServer?.stop();
|
|
340
|
+
if (summaryTimer !== null) {
|
|
341
|
+
clearInterval(summaryTimer);
|
|
342
|
+
summaryTimer = null;
|
|
343
|
+
}
|
|
344
|
+
if (tickProgressSubscribed && engine) {
|
|
345
|
+
engine.bus.off("tick:complete", onTickProgress);
|
|
346
|
+
tickProgressSubscribed = false;
|
|
347
|
+
}
|
|
348
|
+
unsubscribeEvents();
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
shutdown.addTask("cleanup", "CLI resources", stopCliResources);
|
|
352
|
+
|
|
353
|
+
// Step 5: Start MQTT adapter (unless --no-mqtt)
|
|
354
|
+
const mqttStartResult = mqttEnabled
|
|
355
|
+
? await startMQTTAdapter(engine, template, portOption, mqttOverrides, simulationName)
|
|
356
|
+
: null;
|
|
262
357
|
const adapter = mqttStartResult?.adapter ?? null;
|
|
263
358
|
|
|
359
|
+
// Step 5a: Start OPC UA adapter (only when --opcua)
|
|
360
|
+
const opcuaResult = opcuaEnabled
|
|
361
|
+
? await startOPCUAAdapter(engine, template, opcuaPort, opcuaHost, simulationName)
|
|
362
|
+
: null;
|
|
363
|
+
|
|
264
364
|
// Step 5b: Wire control plane
|
|
265
|
-
let controlPlane: ControlPlane | null = null;
|
|
266
365
|
if (adapter) {
|
|
267
366
|
const transport = adapter.getControlTransport();
|
|
268
367
|
if (transport) {
|
|
@@ -273,33 +372,33 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
273
372
|
bus: engine.bus,
|
|
274
373
|
});
|
|
275
374
|
controlPlane.start();
|
|
276
|
-
controlLog.info(
|
|
375
|
+
controlLog.info(`active on $miravo/${simulationName}/* topics`);
|
|
277
376
|
}
|
|
278
377
|
}
|
|
279
378
|
|
|
280
|
-
// Step 5c: Start
|
|
281
|
-
let healthServer: { endpoint: string; stop: () => void } | null = null;
|
|
282
|
-
let lastTickProgressAt = Date.now();
|
|
283
|
-
const onTickProgress = (): void => {
|
|
284
|
-
lastTickProgressAt = Date.now();
|
|
285
|
-
};
|
|
379
|
+
// Step 5c: Start admin HTTP server
|
|
286
380
|
engine.bus.on("tick:complete", onTickProgress);
|
|
381
|
+
tickProgressSubscribed = true;
|
|
287
382
|
try {
|
|
288
|
-
|
|
383
|
+
adminServer = startAdminServer({
|
|
289
384
|
engine,
|
|
290
|
-
host:
|
|
291
|
-
port:
|
|
385
|
+
host: adminHost,
|
|
386
|
+
port: adminPort,
|
|
387
|
+
tickIntervalMs: engine.getState().config.tickIntervalMs,
|
|
292
388
|
mqttEnabled: adapter !== null,
|
|
293
389
|
isMqttConnected: () => (adapter ? adapter.getMetrics().connected : false),
|
|
390
|
+
opcuaEnabled: opcuaResult !== null,
|
|
391
|
+
isOpcuaConnected: () => (opcuaResult ? opcuaResult.adapter.getMetrics().connected : false),
|
|
294
392
|
getLastTickProgressAt: () => lastTickProgressAt,
|
|
295
393
|
});
|
|
296
|
-
controlLog.info(`
|
|
394
|
+
controlLog.info(`admin server ready · endpoint: ${colors.cyan(adminServer.endpoint)}`);
|
|
297
395
|
} catch (error) {
|
|
396
|
+
tickProgressSubscribed = false;
|
|
298
397
|
engine.bus.off("tick:complete", onTickProgress);
|
|
299
398
|
const message = error instanceof Error ? error.message : String(error);
|
|
300
399
|
throw new MiravoError(
|
|
301
|
-
`
|
|
302
|
-
"Set --
|
|
400
|
+
`Admin server failed to start on ${adminHost}:${adminPort}: ${message}`,
|
|
401
|
+
"Set --admin-port / MIRAVO_ADMIN_PORT to a free port, or change --admin-host.",
|
|
303
402
|
);
|
|
304
403
|
}
|
|
305
404
|
|
|
@@ -310,49 +409,38 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
310
409
|
seed,
|
|
311
410
|
seedExplicit,
|
|
312
411
|
mqttStartResult?.endpoint ?? null,
|
|
412
|
+
opcuaResult?.endpoint ?? null,
|
|
313
413
|
simulationName,
|
|
314
414
|
runId,
|
|
315
|
-
|
|
415
|
+
adminServer.endpoint,
|
|
316
416
|
);
|
|
317
417
|
|
|
318
418
|
// Step 7: Subscribe to events + periodic summary
|
|
319
419
|
const errorContextCounts = new Map<string, number>();
|
|
320
|
-
|
|
420
|
+
unsubscribeEvents = subscribeToEvents(engine, errorContextCounts);
|
|
421
|
+
shutdown.setErrorContextCounts(errorContextCounts);
|
|
321
422
|
const summaryInterval = options.summaryInterval ?? 5;
|
|
322
|
-
|
|
423
|
+
summaryTimer = startPeriodicSummary(engine, summaryInterval);
|
|
323
424
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
engine,
|
|
327
|
-
seed,
|
|
328
|
-
seedExplicit,
|
|
329
|
-
cleanup: () => {
|
|
330
|
-
controlPlane?.stop();
|
|
331
|
-
healthServer?.stop();
|
|
332
|
-
engine.bus.off("tick:complete", onTickProgress);
|
|
333
|
-
clearInterval(summaryTimer);
|
|
334
|
-
unsubscribeEvents();
|
|
335
|
-
clearRuntimeLogContext();
|
|
336
|
-
runtimeLogContextSet = false;
|
|
337
|
-
},
|
|
338
|
-
errorContextCounts,
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
try {
|
|
342
|
-
engine.start();
|
|
343
|
-
} catch (error) {
|
|
344
|
-
teardownShutdown();
|
|
345
|
-
controlPlane?.stop();
|
|
346
|
-
healthServer?.stop();
|
|
347
|
-
engine.bus.off("tick:complete", onTickProgress);
|
|
348
|
-
clearInterval(summaryTimer);
|
|
349
|
-
unsubscribeEvents();
|
|
350
|
-
clearRuntimeLogContext();
|
|
351
|
-
runtimeLogContextSet = false;
|
|
352
|
-
throw error;
|
|
353
|
-
}
|
|
425
|
+
engine.start();
|
|
426
|
+
shutdown.markRunning();
|
|
354
427
|
engineLog.success("Simulation started.");
|
|
428
|
+
await shutdownRequested;
|
|
429
|
+
await shutdown.runShutdown();
|
|
430
|
+
shutdown.teardown();
|
|
355
431
|
} catch (error) {
|
|
432
|
+
if (shutdown?.isShuttingDown()) {
|
|
433
|
+
await shutdown.runShutdown();
|
|
434
|
+
shutdown.teardown();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
shutdown?.teardown();
|
|
438
|
+
stopCliResources();
|
|
439
|
+
try {
|
|
440
|
+
await engine?.stop("boot error");
|
|
441
|
+
} catch {
|
|
442
|
+
// engine may not exist yet or stop may fail — swallow
|
|
443
|
+
}
|
|
356
444
|
if (runtimeLogContextSet) {
|
|
357
445
|
clearRuntimeLogContext();
|
|
358
446
|
runtimeLogContextSet = false;
|
|
@@ -404,9 +492,11 @@ async function resolveTemplatePath(
|
|
|
404
492
|
const builtinTemplateDir = catalog.getBuiltinTemplatesDir();
|
|
405
493
|
let builtinTemplates: readonly DiscoveredTemplate[] = [];
|
|
406
494
|
try {
|
|
407
|
-
|
|
495
|
+
const discovered = await discoverTemplates(builtinTemplateDir);
|
|
496
|
+
// Exclude stress/benchmark templates from built-in list (same as standalone binaries).
|
|
497
|
+
builtinTemplates = discovered.filter((t) => !isStressBenchmarkTemplate(t.id));
|
|
408
498
|
} catch {
|
|
409
|
-
// Builtin dir may not exist (e.g.
|
|
499
|
+
// Builtin dir may not exist in edge cases (e.g. standalone binary extraction failure)
|
|
410
500
|
}
|
|
411
501
|
if (builtinTemplates.length > 0) {
|
|
412
502
|
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
@@ -495,7 +585,7 @@ async function loadAndValidateTemplate(
|
|
|
495
585
|
catalog: ContentCatalog,
|
|
496
586
|
): Promise<{
|
|
497
587
|
template: MiravoTemplate;
|
|
498
|
-
models: Map<string,
|
|
588
|
+
models: Map<string, TwinModel>;
|
|
499
589
|
seed: number;
|
|
500
590
|
seedExplicit: boolean;
|
|
501
591
|
}> {
|
|
@@ -528,13 +618,10 @@ async function loadAndValidateTemplate(
|
|
|
528
618
|
}
|
|
529
619
|
|
|
530
620
|
// Build inline models map
|
|
531
|
-
const models = new Map<string,
|
|
621
|
+
const models = new Map<string, TwinModel>();
|
|
532
622
|
if (template.models) {
|
|
533
623
|
for (const inlineModel of template.models) {
|
|
534
|
-
models.set(inlineModel.name,
|
|
535
|
-
...inlineModel,
|
|
536
|
-
version: inlineModel.version ?? "1",
|
|
537
|
-
});
|
|
624
|
+
models.set(inlineModel.model.name, inlineModel);
|
|
538
625
|
}
|
|
539
626
|
}
|
|
540
627
|
|
|
@@ -605,7 +692,7 @@ export function resolveSimulationName(input: {
|
|
|
605
692
|
|
|
606
693
|
function createAndPopulateEngine(
|
|
607
694
|
template: MiravoTemplate,
|
|
608
|
-
models: Map<string,
|
|
695
|
+
models: Map<string, TwinModel>,
|
|
609
696
|
seed: number,
|
|
610
697
|
options: StartOptions,
|
|
611
698
|
templatePath: string,
|
|
@@ -728,7 +815,9 @@ async function startMQTTAdapter(
|
|
|
728
815
|
}
|
|
729
816
|
|
|
730
817
|
const endpoint = embedded ? `mqtt://localhost:${port}` : (external as string);
|
|
731
|
-
mqttLog.debug(
|
|
818
|
+
mqttLog.debug(
|
|
819
|
+
`starting adapter · mode: ${embedded ? "embedded" : "external"} · endpoint: ${endpoint}`,
|
|
820
|
+
);
|
|
732
821
|
|
|
733
822
|
const adapter = new MQTTAdapter(engine.bus);
|
|
734
823
|
try {
|
|
@@ -745,24 +834,69 @@ async function startMQTTAdapter(
|
|
|
745
834
|
...(password !== undefined ? { password } : {}),
|
|
746
835
|
...(mqttConfig?.reconnect !== undefined ? { reconnect: mqttConfig.reconnect } : {}),
|
|
747
836
|
...(mqttConfig?.rbe !== undefined ? { rbe: mqttConfig.rbe } : {}),
|
|
837
|
+
...(mqttConfig?.metadata !== undefined ? { metadata: mqttConfig.metadata } : {}),
|
|
748
838
|
};
|
|
749
839
|
|
|
750
|
-
await adapter.start(
|
|
751
|
-
...resolvedConfig,
|
|
752
|
-
});
|
|
840
|
+
await adapter.start(resolvedConfig);
|
|
753
841
|
engine.registerAdapter(adapter);
|
|
754
|
-
mqttLog.ready(`
|
|
842
|
+
mqttLog.ready(`connected · broker: ${colors.cyan(endpoint)}`);
|
|
755
843
|
return { adapter, endpoint };
|
|
756
844
|
} catch (error) {
|
|
757
845
|
const message = error instanceof Error ? error.message : String(error);
|
|
758
846
|
const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
|
|
759
847
|
const fix = isPortConflict
|
|
760
|
-
? `Port ${port} is in use. Use '--port ${port + 1}' or set protocols.mqtt.port in template.`
|
|
848
|
+
? `Port ${port} is in use. Use '--mqtt-port ${port + 1}' or set protocols.mqtt.port in template.`
|
|
761
849
|
: "Check the MQTT configuration in your template file.";
|
|
762
850
|
throw new MiravoError(`MQTT adapter failed to start: ${message}`, fix);
|
|
763
851
|
}
|
|
764
852
|
}
|
|
765
853
|
|
|
854
|
+
// ---------------------------------------------------------------------------
|
|
855
|
+
// Step 5a: OPC UA adapter
|
|
856
|
+
// ---------------------------------------------------------------------------
|
|
857
|
+
|
|
858
|
+
async function startOPCUAAdapter(
|
|
859
|
+
engine: Engine,
|
|
860
|
+
template: MiravoTemplate,
|
|
861
|
+
portOverride: number | undefined,
|
|
862
|
+
hostOverride: string | undefined,
|
|
863
|
+
simulationName: string,
|
|
864
|
+
): Promise<{ adapter: OPCUAAdapter; endpoint: string } | null> {
|
|
865
|
+
const opcuaConfig = template.protocols?.opcua;
|
|
866
|
+
|
|
867
|
+
const port = portOverride ?? opcuaConfig?.port ?? DEFAULT_OPCUA_PORT;
|
|
868
|
+
const host = hostOverride ?? opcuaConfig?.host ?? "0.0.0.0";
|
|
869
|
+
|
|
870
|
+
opcuaLog.debug(`starting adapter · endpoint: opc.tcp://${host}:${port}`);
|
|
871
|
+
|
|
872
|
+
const opcuaAdapter = new OPCUAAdapter(engine.bus, engine);
|
|
873
|
+
try {
|
|
874
|
+
const resolvedConfig = {
|
|
875
|
+
name: "opcua" as const,
|
|
876
|
+
enabled: true,
|
|
877
|
+
port,
|
|
878
|
+
host,
|
|
879
|
+
security: opcuaConfig?.security ?? "none",
|
|
880
|
+
simulationName,
|
|
881
|
+
...(opcuaConfig?.buildInfo ? { buildInfo: opcuaConfig.buildInfo } : {}),
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
await opcuaAdapter.start(resolvedConfig);
|
|
885
|
+
engine.registerAdapter(opcuaAdapter);
|
|
886
|
+
|
|
887
|
+
const endpoint = `opc.tcp://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
|
|
888
|
+
opcuaLog.ready(`server ready · endpoint: ${colors.cyan(endpoint)}`);
|
|
889
|
+
return { adapter: opcuaAdapter, endpoint };
|
|
890
|
+
} catch (error) {
|
|
891
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
892
|
+
const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
|
|
893
|
+
const fix = isPortConflict
|
|
894
|
+
? `Port ${port} is in use. Use '--opcua-port ${port + 1}'.`
|
|
895
|
+
: "Check the OPC UA port/network settings and retry.";
|
|
896
|
+
throw new MiravoError(`OPC UA adapter failed to start: ${message}`, fix);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
766
900
|
// ---------------------------------------------------------------------------
|
|
767
901
|
// Step 6: Startup banner
|
|
768
902
|
// ---------------------------------------------------------------------------
|
|
@@ -773,32 +907,55 @@ function printStartupBanner(
|
|
|
773
907
|
seed: number,
|
|
774
908
|
seedExplicit: boolean,
|
|
775
909
|
mqttEndpoint: string | null,
|
|
910
|
+
opcuaEndpoint: string | null,
|
|
776
911
|
simulationName: string,
|
|
777
912
|
runId: string,
|
|
778
|
-
|
|
913
|
+
adminEndpoint: string,
|
|
779
914
|
): void {
|
|
780
915
|
const state = engine.getState();
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
916
|
+
|
|
917
|
+
// Build model summary: one model per line for readability
|
|
918
|
+
const modelLines = state.models.map(
|
|
919
|
+
(m) => `${colors.bold(m.name)} ${colors.dim(`(${m.memberCount} members)`)}`,
|
|
920
|
+
);
|
|
921
|
+
const modelCount = `${colors.bold(String(state.models.length))} ${colors.dim("models")}`;
|
|
785
922
|
|
|
786
923
|
const seedValue = seedExplicit
|
|
787
924
|
? colors.bold(String(seed))
|
|
788
925
|
: `${colors.bold(String(seed))} ${colors.gray("(random — add seed to config to reproduce)")}`;
|
|
789
926
|
|
|
790
|
-
const
|
|
927
|
+
const mqttValue = mqttEndpoint ? colors.cyan(mqttEndpoint) : colors.dim("disabled");
|
|
928
|
+
const opcuaValue = opcuaEndpoint ? colors.cyan(opcuaEndpoint) : colors.dim("disabled");
|
|
929
|
+
|
|
930
|
+
const allEntries: ReadonlyArray<readonly [string, string]> = [
|
|
791
931
|
[colors.dim("Simulation"), colors.bold(simulationName)],
|
|
792
932
|
[colors.dim("Run ID"), colors.bold(runId)],
|
|
793
933
|
[colors.dim("Template"), templatePath],
|
|
794
|
-
[colors.dim("Models"),
|
|
934
|
+
[colors.dim("Models"), modelCount],
|
|
795
935
|
[colors.dim("Instances"), colors.bold(String(state.instances.length))],
|
|
796
|
-
[colors.dim("MQTT"),
|
|
797
|
-
[colors.dim("
|
|
936
|
+
[colors.dim("MQTT"), mqttValue],
|
|
937
|
+
[colors.dim("OPC UA"), opcuaValue],
|
|
938
|
+
[colors.dim("Admin"), colors.cyan(adminEndpoint)],
|
|
798
939
|
[colors.dim("Tick interval"), colors.bold(`${state.config.tickIntervalMs}ms`)],
|
|
799
940
|
[colors.dim("Time multiplier"), colors.bold(`${state.config.timeMultiplier}x`)],
|
|
800
941
|
[colors.dim("Seed"), seedValue],
|
|
801
|
-
]
|
|
942
|
+
];
|
|
943
|
+
|
|
944
|
+
const kvBlock = formatKeyValue(allEntries);
|
|
945
|
+
|
|
946
|
+
// Compute the value column offset used by formatKeyValue so model detail
|
|
947
|
+
// lines align with the value of the "Models" row.
|
|
948
|
+
const maxKeyWidth = Math.max(...allEntries.map(([key]) => stripAnsi(key).length));
|
|
949
|
+
const valueIndent = 4 + maxKeyWidth + 3; // " " + key + ":" + padding(2) + " "
|
|
950
|
+
|
|
951
|
+
// Inject per-model detail lines right after the "Models" row
|
|
952
|
+
const kvLines = kvBlock.split("\n");
|
|
953
|
+
const modelsIdx = kvLines.findIndex((line) => stripAnsi(line).includes("Models:"));
|
|
954
|
+
if (modelsIdx !== -1 && modelLines.length > 0) {
|
|
955
|
+
const detailLines = modelLines.map((line) => `${"".padEnd(valueIndent)}${line}`);
|
|
956
|
+
kvLines.splice(modelsIdx + 1, 0, ...detailLines);
|
|
957
|
+
}
|
|
958
|
+
const content = kvLines.join("\n");
|
|
802
959
|
|
|
803
960
|
const header = `${colors.cyan(colors.bold("Miravo"))} ${colors.dim(`v${MIRAVO_VERSION}`)}`;
|
|
804
961
|
|
|
@@ -856,7 +1013,11 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
|
|
|
856
1013
|
};
|
|
857
1014
|
|
|
858
1015
|
const onFaultCleared = (event: { instanceId: string; fault: string }): void => {
|
|
859
|
-
faultLog.
|
|
1016
|
+
faultLog.debug(`${event.instanceId} ${event.fault} cleared`);
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
const onWarn = (event: { message: string; context: string }): void => {
|
|
1020
|
+
engineLog.debug(event.message, { context: event.context });
|
|
860
1021
|
};
|
|
861
1022
|
|
|
862
1023
|
const onError = (event: { context: string; error: Error }): void => {
|
|
@@ -875,12 +1036,14 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
|
|
|
875
1036
|
engine.bus.on("lifecycle:changed", onLifecycle);
|
|
876
1037
|
engine.bus.on("fault:triggered", onFaultTriggered);
|
|
877
1038
|
engine.bus.on("fault:cleared", onFaultCleared);
|
|
1039
|
+
engine.bus.on("engine:warn", onWarn);
|
|
878
1040
|
engine.bus.on("engine:error", onError);
|
|
879
1041
|
|
|
880
1042
|
return () => {
|
|
881
1043
|
engine.bus.off("lifecycle:changed", onLifecycle);
|
|
882
1044
|
engine.bus.off("fault:triggered", onFaultTriggered);
|
|
883
1045
|
engine.bus.off("fault:cleared", onFaultCleared);
|
|
1046
|
+
engine.bus.off("engine:warn", onWarn);
|
|
884
1047
|
engine.bus.off("engine:error", onError);
|
|
885
1048
|
};
|
|
886
1049
|
}
|
|
@@ -913,29 +1076,7 @@ function startPeriodicSummary(
|
|
|
913
1076
|
): ReturnType<typeof setInterval> {
|
|
914
1077
|
return setInterval(() => {
|
|
915
1078
|
const metrics = engine.getMetrics();
|
|
916
|
-
|
|
917
|
-
const mqttAdapter = metrics.adapters.find((a) => a.name === "mqtt");
|
|
918
|
-
|
|
919
|
-
const sep = colors.dim(" · ");
|
|
920
|
-
|
|
921
|
-
let mqttStatus: string;
|
|
922
|
-
if (mqttAdapter) {
|
|
923
|
-
const errCount = mqttAdapter.metrics.errorsCount;
|
|
924
|
-
const errStr =
|
|
925
|
-
errCount === 0 ? colors.green(formatCount(errCount)) : colors.yellow(formatCount(errCount));
|
|
926
|
-
mqttStatus = `MQTT: ${colors.bold(formatCount(mqttAdapter.metrics.messagesPublished))} published${sep}${errStr} errors`;
|
|
927
|
-
} else {
|
|
928
|
-
mqttStatus = colors.dim("MQTT: not connected");
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
const tps =
|
|
932
|
-
core.actualTicksPerSecond > 0
|
|
933
|
-
? `${colors.bold(core.actualTicksPerSecond.toFixed(1))} tps`
|
|
934
|
-
: "";
|
|
935
|
-
|
|
936
|
-
engineLog.info(
|
|
937
|
-
`tick #${colors.bold(formatCount(core.tickCount))}${sep}${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.unsEntryCount))} UNS topics${sep}sim: ${colors.bold(core.simulationTimeSeconds.toFixed(1))}s${sep}${tps}${tps ? sep : ""}${mqttStatus}`,
|
|
938
|
-
);
|
|
1079
|
+
printSummaryLine(metrics);
|
|
939
1080
|
}, intervalSeconds * 1_000);
|
|
940
1081
|
}
|
|
941
1082
|
|
|
@@ -945,7 +1086,7 @@ function startPeriodicSummary(
|
|
|
945
1086
|
|
|
946
1087
|
function printDryRunSummary(
|
|
947
1088
|
template: MiravoTemplate,
|
|
948
|
-
models: Map<string,
|
|
1089
|
+
models: Map<string, TwinModel>,
|
|
949
1090
|
seed: number,
|
|
950
1091
|
seedExplicit: boolean,
|
|
951
1092
|
templatePath: string,
|
|
@@ -967,8 +1108,8 @@ function printDryRunSummary(
|
|
|
967
1108
|
|
|
968
1109
|
// Model table
|
|
969
1110
|
const modelRows = [...models.values()].map((m) => ({
|
|
970
|
-
name: colors.cyan(m.name),
|
|
971
|
-
|
|
1111
|
+
name: colors.cyan(m.model.name),
|
|
1112
|
+
members: String(Object.keys(m.members).length),
|
|
972
1113
|
faults: String(m.faults?.length ?? 0),
|
|
973
1114
|
lifecycle: m.lifecycle ? `${m.lifecycle.stages.length} stages` : colors.dim("none"),
|
|
974
1115
|
}));
|
|
@@ -980,7 +1121,7 @@ function printDryRunSummary(
|
|
|
980
1121
|
formatTable(
|
|
981
1122
|
{
|
|
982
1123
|
name: colors.dim("Name"),
|
|
983
|
-
|
|
1124
|
+
members: colors.dim("Members"),
|
|
984
1125
|
faults: colors.dim("Faults"),
|
|
985
1126
|
lifecycle: colors.dim("Lifecycle"),
|
|
986
1127
|
},
|
|
@@ -991,14 +1132,14 @@ function printDryRunSummary(
|
|
|
991
1132
|
|
|
992
1133
|
// Instance summary
|
|
993
1134
|
let totalInstances = 0;
|
|
994
|
-
let
|
|
1135
|
+
let totalMembers = 0;
|
|
995
1136
|
for (const area of template.areas) {
|
|
996
1137
|
for (const line of area.lines) {
|
|
997
1138
|
for (const twin of line.twins) {
|
|
998
1139
|
totalInstances += twin.count;
|
|
999
1140
|
const model = models.get(twin.model);
|
|
1000
1141
|
if (model) {
|
|
1001
|
-
|
|
1142
|
+
totalMembers += twin.count * Object.keys(model.members).length;
|
|
1002
1143
|
}
|
|
1003
1144
|
}
|
|
1004
1145
|
}
|
|
@@ -1008,8 +1149,7 @@ function printDryRunSummary(
|
|
|
1008
1149
|
lines.push(
|
|
1009
1150
|
formatKeyValue([
|
|
1010
1151
|
[colors.dim("Total instances"), colors.bold(String(totalInstances))],
|
|
1011
|
-
[colors.dim("Total
|
|
1012
|
-
[colors.dim("Total UNS paths"), colors.bold(String(totalSensors))],
|
|
1152
|
+
[colors.dim("Total members"), colors.bold(String(totalMembers))],
|
|
1013
1153
|
]),
|
|
1014
1154
|
);
|
|
1015
1155
|
|