miravo 0.1.4 → 1.0.5
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 +23 -18
- package/package.json +5 -4
- package/src/commands/start.ts +236 -70
- package/src/commands/status.ts +20 -0
- package/src/lib/health-server.test.ts +38 -0
- package/src/lib/health-server.ts +25 -2
- package/src/lib/shutdown.ts +55 -6
- package/src/logger.test.ts +2 -2
- package/src/logger.ts +13 -3
package/README.md
CHANGED
|
@@ -87,24 +87,29 @@ miravo start --content-dir ./my-content
|
|
|
87
87
|
|
|
88
88
|
Start the simulation engine with an embedded MQTT broker.
|
|
89
89
|
|
|
90
|
-
| Option
|
|
91
|
-
|
|
|
92
|
-
|
|
|
93
|
-
|
|
|
94
|
-
| `--
|
|
95
|
-
| `--
|
|
96
|
-
|
|
|
97
|
-
| `--
|
|
98
|
-
| `--
|
|
99
|
-
|
|
|
100
|
-
| `--mqtt-
|
|
101
|
-
| `--mqtt-
|
|
102
|
-
| `--mqtt-
|
|
103
|
-
| `--mqtt-
|
|
104
|
-
| `--
|
|
105
|
-
| `--
|
|
106
|
-
| `--
|
|
107
|
-
| `--
|
|
90
|
+
| Option | Description |
|
|
91
|
+
| -------------------------------- | ----------------------------------------------------------------------------- |
|
|
92
|
+
| `-t, --template <id\|name\|path>`| Template to run (env: `MIRAVO_TEMPLATE`) |
|
|
93
|
+
| `-n, --name <name>` | Simulation name for control topics (env: `MIRAVO_NAME`) |
|
|
94
|
+
| `--content-dir <path>` | User content root with `/templates` and `/models` (env: `MIRAVO_CONTENT_DIR`) |
|
|
95
|
+
| `--models-dir <path>` | Additional `.twin.yaml` model directory |
|
|
96
|
+
| `-s, --speed <multiplier>` | Time multiplier (default: `1`) |
|
|
97
|
+
| `--seed <number>` | Deterministic RNG seed |
|
|
98
|
+
| `--tick <ms>` | Tick interval in ms (default: `1000`) |
|
|
99
|
+
| `-p, --mqtt-port <number>` | MQTT port (default: `1883`, env: `MIRAVO_MQTT_PORT`) |
|
|
100
|
+
| `--mqtt-host <host>` | MQTT bind host (default: `127.0.0.1`, env: `MIRAVO_MQTT_HOST`) |
|
|
101
|
+
| `--mqtt-mode <mode>` | `embedded` or `external` (env: `MIRAVO_MQTT_MODE`) |
|
|
102
|
+
| `--mqtt-url <url>` | External broker URL (env: `MIRAVO_MQTT_URL`) |
|
|
103
|
+
| `--mqtt-username <user>` | External broker username (env: `MIRAVO_MQTT_USERNAME`) |
|
|
104
|
+
| `--mqtt-password <pass>` | External broker password (env: `MIRAVO_MQTT_PASSWORD`) |
|
|
105
|
+
| `--no-mqtt` | Disable MQTT broker |
|
|
106
|
+
| `--opcua` | Enable OPC UA server |
|
|
107
|
+
| `--opcua-port <number>` | OPC UA port (default: `4840`, env: `MIRAVO_OPCUA_PORT`) |
|
|
108
|
+
| `--opcua-host <host>` | OPC UA bind host (default: `0.0.0.0`, env: `MIRAVO_OPCUA_HOST`) |
|
|
109
|
+
| `--health-host <host>` | Health server host (default: `0.0.0.0`, env: `MIRAVO_HEALTH_HOST`) |
|
|
110
|
+
| `--health-port <number>` | Health server port (default: `8080`, env: `MIRAVO_HEALTH_PORT`) |
|
|
111
|
+
| `-d, --dry-run` | Validate and print summary without starting |
|
|
112
|
+
| `--summary-interval <s>` | Seconds between periodic summaries (default: `5`) |
|
|
108
113
|
|
|
109
114
|
### `miravo templates list`
|
|
110
115
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "miravo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Digital twin engine for Industrial IoT simulation",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -31,9 +31,10 @@
|
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@clack/prompts": "^1.1.0",
|
|
34
|
-
"@miravo/core": "0.
|
|
35
|
-
"@miravo/mqtt": "0.
|
|
36
|
-
"@miravo/
|
|
34
|
+
"@miravo/core": "1.0.5",
|
|
35
|
+
"@miravo/mqtt": "1.0.5",
|
|
36
|
+
"@miravo/opcua": "1.0.5",
|
|
37
|
+
"@miravo/shared": "1.0.5",
|
|
37
38
|
"commander": "^13.0.0",
|
|
38
39
|
"consola": "^3.4.0",
|
|
39
40
|
"mqtt": "^5.15.0"
|
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 { OPCUAAdapter } from "@miravo/opcua";
|
|
15
16
|
import type { DigitalTwinModel, DiscoveredTemplate, MiravoTemplate } 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,7 +26,7 @@ import {
|
|
|
24
26
|
SimulationNameSchema,
|
|
25
27
|
} from "@miravo/shared";
|
|
26
28
|
import type { Command } from "commander";
|
|
27
|
-
import { box, colors } from "consola/utils";
|
|
29
|
+
import { box, colors, stripAnsi } from "consola/utils";
|
|
28
30
|
import { startHealthServer } from "../lib/health-server.ts";
|
|
29
31
|
import {
|
|
30
32
|
parseDurationMs,
|
|
@@ -53,6 +55,7 @@ import {
|
|
|
53
55
|
isPlain,
|
|
54
56
|
lifecycleLog,
|
|
55
57
|
mqttLog,
|
|
58
|
+
opcuaLog,
|
|
56
59
|
setRuntimeLogContext,
|
|
57
60
|
writeOutput,
|
|
58
61
|
} from "../logger.ts";
|
|
@@ -66,12 +69,16 @@ interface StartOptions {
|
|
|
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;
|
|
78
|
+
readonly mqtt?: boolean;
|
|
79
|
+
readonly opcuaPort?: number;
|
|
80
|
+
readonly opcuaHost?: string;
|
|
81
|
+
readonly opcua?: boolean;
|
|
75
82
|
readonly healthHost?: string;
|
|
76
83
|
readonly healthPort?: number;
|
|
77
84
|
readonly dryRun?: boolean;
|
|
@@ -99,9 +106,9 @@ export function registerStartCommand(program: Command): void {
|
|
|
99
106
|
program
|
|
100
107
|
.command("start")
|
|
101
108
|
.description("Start the digital twin simulation")
|
|
102
|
-
.option("--name <name>", "Simulation name for control topics (env: MIRAVO_NAME)")
|
|
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")
|
|
@@ -109,26 +116,27 @@ export function registerStartCommand(program: Command): void {
|
|
|
109
116
|
"--content-dir <path>",
|
|
110
117
|
"User content root with /templates and /models (env: MIRAVO_CONTENT_DIR)",
|
|
111
118
|
)
|
|
112
|
-
.option("--speed <multiplier>", "Time multiplier
|
|
119
|
+
.option("-s, --speed <multiplier>", "Time multiplier (default: 1)", parsePositiveFloat)
|
|
113
120
|
.option("--seed <number>", "RNG seed for deterministic simulation", parseNonNegativeInt)
|
|
121
|
+
.option("--tick <ms>", "Tick interval in milliseconds (default: 1000)", parseDurationMs)
|
|
114
122
|
.option(
|
|
115
|
-
"--
|
|
116
|
-
"
|
|
117
|
-
parseDurationMs,
|
|
118
|
-
)
|
|
119
|
-
.option(
|
|
120
|
-
"--port <number>",
|
|
121
|
-
"MQTT broker port override (default: 1883, env: MIRAVO_MQTT_PORT)",
|
|
123
|
+
"-p, --mqtt-port <number>",
|
|
124
|
+
"MQTT broker port (default: 1883, env: MIRAVO_MQTT_PORT)",
|
|
122
125
|
parsePort,
|
|
123
126
|
)
|
|
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)")
|
|
127
|
+
.option("--mqtt-host <host>", "MQTT bind host (default: 127.0.0.1, env: MIRAVO_MQTT_HOST)")
|
|
128
|
+
.option("--mqtt-mode <mode>", "MQTT mode: embedded | external (env: MIRAVO_MQTT_MODE)")
|
|
129
129
|
.option("--mqtt-url <url>", "External MQTT broker URL (env: MIRAVO_MQTT_URL)")
|
|
130
130
|
.option("--mqtt-username <username>", "External MQTT username (env: MIRAVO_MQTT_USERNAME)")
|
|
131
131
|
.option("--mqtt-password <password>", "External MQTT password (env: MIRAVO_MQTT_PASSWORD)")
|
|
132
|
+
.option("--no-mqtt", "Disable MQTT broker")
|
|
133
|
+
.option("--opcua", "Enable OPC UA server")
|
|
134
|
+
.option(
|
|
135
|
+
"--opcua-port <number>",
|
|
136
|
+
"OPC UA server port (default: 4840, env: MIRAVO_OPCUA_PORT)",
|
|
137
|
+
parsePort,
|
|
138
|
+
)
|
|
139
|
+
.option("--opcua-host <host>", "OPC UA server bind host (env: MIRAVO_OPCUA_HOST)")
|
|
132
140
|
.option(
|
|
133
141
|
"--health-host <host>",
|
|
134
142
|
"Health server host (default: 0.0.0.0, env: MIRAVO_HEALTH_HOST)",
|
|
@@ -138,7 +146,7 @@ export function registerStartCommand(program: Command): void {
|
|
|
138
146
|
"Health server port (default: 8080, env: MIRAVO_HEALTH_PORT)",
|
|
139
147
|
parsePort,
|
|
140
148
|
)
|
|
141
|
-
.option("--dry-run", "Validate config and print summary without starting")
|
|
149
|
+
.option("-d, --dry-run", "Validate config and print summary without starting")
|
|
142
150
|
.option(
|
|
143
151
|
"--summary-interval <seconds>",
|
|
144
152
|
"Seconds between periodic summary lines (default: 5)",
|
|
@@ -155,50 +163,115 @@ export function registerStartCommand(program: Command): void {
|
|
|
155
163
|
|
|
156
164
|
async function startAction(options: StartOptions): Promise<void> {
|
|
157
165
|
let runtimeLogContextSet = false;
|
|
166
|
+
/** Adapter stop functions accumulated during setup. Drained on partial startup failure. */
|
|
167
|
+
const pendingAdapterStops: Array<() => Promise<void>> = [];
|
|
158
168
|
try {
|
|
159
169
|
const catalog = createCatalog(options.contentDir);
|
|
160
170
|
|
|
161
171
|
// Resolve env-var fallbacks for common options
|
|
162
172
|
const templateOption = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
|
|
163
173
|
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
|
-
});
|
|
174
|
+
// Protocol selection: CLI flags decide which protocols run
|
|
175
|
+
const mqttEnabled = options.mqtt !== false; // --no-mqtt disables
|
|
176
|
+
const opcuaEnabled = options.opcua === true; // --opcua enables
|
|
177
|
+
|
|
178
|
+
// Warn on ignored protocol-specific flags
|
|
179
|
+
if (!mqttEnabled) {
|
|
180
|
+
if (options.mqttPort !== undefined) {
|
|
181
|
+
configLog.warn("--mqtt-port ignored because MQTT is disabled.");
|
|
182
|
+
}
|
|
183
|
+
if (options.mqttHost !== undefined) {
|
|
184
|
+
configLog.warn("--mqtt-host ignored because MQTT is disabled.");
|
|
185
|
+
}
|
|
186
|
+
if (options.mqttMode !== undefined) {
|
|
187
|
+
configLog.warn("--mqtt-mode ignored because MQTT is disabled.");
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
|
+
if (!opcuaEnabled) {
|
|
191
|
+
if (options.opcuaPort !== undefined) {
|
|
192
|
+
configLog.warn(
|
|
193
|
+
"--opcua-port ignored because OPC UA is not enabled. Use --opcua to enable it.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (options.opcuaHost !== undefined) {
|
|
197
|
+
configLog.warn(
|
|
198
|
+
"--opcua-host ignored because OPC UA is not enabled. Use --opcua to enable it.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const healthHost = options.healthHost ?? Bun.env["MIRAVO_HEALTH_HOST"] ?? "0.0.0.0";
|
|
204
|
+
const healthPortEnv = Bun.env["MIRAVO_HEALTH_PORT"];
|
|
190
205
|
if (healthHost.trim().length === 0) {
|
|
191
206
|
throw new ConfigValidationError({
|
|
192
207
|
message: "Invalid health server host: value is empty.",
|
|
193
208
|
fix: "Set --health-host / MIRAVO_HEALTH_HOST to a valid host (for example, 0.0.0.0).",
|
|
194
209
|
});
|
|
195
210
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
211
|
+
|
|
212
|
+
// MQTT env overrides — only parsed when MQTT is enabled
|
|
213
|
+
let portOption = options.mqttPort;
|
|
214
|
+
let mqttOverrides: MqttRunOverrides = {};
|
|
215
|
+
if (mqttEnabled) {
|
|
216
|
+
const portEnv = Bun.env["MIRAVO_MQTT_PORT"];
|
|
217
|
+
const mqttHost = options.mqttHost ?? Bun.env["MIRAVO_MQTT_HOST"];
|
|
218
|
+
const mqttMode = parseMqttModeOption(options.mqttMode ?? Bun.env["MIRAVO_MQTT_MODE"]);
|
|
219
|
+
const mqttUrl = options.mqttUrl ?? Bun.env["MIRAVO_MQTT_URL"];
|
|
220
|
+
const mqttUsername = options.mqttUsername ?? Bun.env["MIRAVO_MQTT_USERNAME"];
|
|
221
|
+
const mqttPassword = options.mqttPassword ?? Bun.env["MIRAVO_MQTT_PASSWORD"];
|
|
222
|
+
mqttOverrides = {
|
|
223
|
+
...(mqttMode !== undefined ? { mode: mqttMode } : {}),
|
|
224
|
+
...(mqttHost !== undefined && mqttHost.length > 0 ? { host: mqttHost } : {}),
|
|
225
|
+
...(mqttUrl !== undefined && mqttUrl.length > 0 ? { url: mqttUrl } : {}),
|
|
226
|
+
...(mqttUsername !== undefined && mqttUsername.length > 0
|
|
227
|
+
? { username: mqttUsername }
|
|
228
|
+
: {}),
|
|
229
|
+
...(mqttPassword !== undefined && mqttPassword.length > 0
|
|
230
|
+
? { password: mqttPassword }
|
|
231
|
+
: {}),
|
|
232
|
+
};
|
|
233
|
+
if (portOption === undefined && portEnv) {
|
|
234
|
+
try {
|
|
235
|
+
portOption = parsePort(portEnv);
|
|
236
|
+
} catch {
|
|
237
|
+
throw new ConfigValidationError({
|
|
238
|
+
message: `Invalid MIRAVO_MQTT_PORT environment variable: "${portEnv}".`,
|
|
239
|
+
fix: "Set MIRAVO_MQTT_PORT to a valid port number between 1 and 65535 (for example, 1883).",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (mqttHost !== undefined && mqttHost.trim().length === 0) {
|
|
244
|
+
throw new ConfigValidationError({
|
|
245
|
+
message: "Invalid MQTT bind host: value is empty.",
|
|
246
|
+
fix: "Set --mqtt-host / MIRAVO_MQTT_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
201
249
|
}
|
|
250
|
+
|
|
251
|
+
// OPC UA env overrides — only parsed when OPC UA is enabled
|
|
252
|
+
let opcuaPort: number | undefined = options.opcuaPort;
|
|
253
|
+
let opcuaHost: string | undefined;
|
|
254
|
+
if (opcuaEnabled) {
|
|
255
|
+
const opcuaPortEnv = Bun.env["MIRAVO_OPCUA_PORT"];
|
|
256
|
+
if (opcuaPort === undefined && opcuaPortEnv) {
|
|
257
|
+
try {
|
|
258
|
+
opcuaPort = parsePort(opcuaPortEnv);
|
|
259
|
+
} catch {
|
|
260
|
+
throw new ConfigValidationError({
|
|
261
|
+
message: `Invalid MIRAVO_OPCUA_PORT environment variable: "${opcuaPortEnv}".`,
|
|
262
|
+
fix: "Set MIRAVO_OPCUA_PORT to a valid port number between 1 and 65535 (for example, 4840).",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
opcuaHost = options.opcuaHost ?? Bun.env["MIRAVO_OPCUA_HOST"] ?? undefined;
|
|
267
|
+
if (opcuaHost !== undefined && opcuaHost.trim().length === 0) {
|
|
268
|
+
throw new ConfigValidationError({
|
|
269
|
+
message: "Invalid OPC UA bind host: value is empty.",
|
|
270
|
+
fix: "Set --opcua-host / MIRAVO_OPCUA_HOST to a valid host (for example, 0.0.0.0).",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
202
275
|
let healthPort = options.healthPort;
|
|
203
276
|
if (healthPort === undefined && healthPortEnv) {
|
|
204
277
|
try {
|
|
@@ -251,15 +324,18 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
251
324
|
simulationName,
|
|
252
325
|
);
|
|
253
326
|
|
|
254
|
-
// Step 5: Start MQTT adapter
|
|
255
|
-
const mqttStartResult =
|
|
256
|
-
engine,
|
|
257
|
-
|
|
258
|
-
portOption,
|
|
259
|
-
mqttOverrides,
|
|
260
|
-
simulationName,
|
|
261
|
-
);
|
|
327
|
+
// Step 5: Start MQTT adapter (unless --no-mqtt)
|
|
328
|
+
const mqttStartResult = mqttEnabled
|
|
329
|
+
? await startMQTTAdapter(engine, template, portOption, mqttOverrides, simulationName)
|
|
330
|
+
: null;
|
|
262
331
|
const adapter = mqttStartResult?.adapter ?? null;
|
|
332
|
+
if (adapter) pendingAdapterStops.push(() => adapter.stop());
|
|
333
|
+
|
|
334
|
+
// Step 5a: Start OPC UA adapter (only when --opcua)
|
|
335
|
+
const opcuaResult = opcuaEnabled
|
|
336
|
+
? await startOPCUAAdapter(engine, template, opcuaPort, opcuaHost, simulationName)
|
|
337
|
+
: null;
|
|
338
|
+
if (opcuaResult) pendingAdapterStops.push(() => opcuaResult.adapter.stop());
|
|
263
339
|
|
|
264
340
|
// Step 5b: Wire control plane
|
|
265
341
|
let controlPlane: ControlPlane | null = null;
|
|
@@ -291,6 +367,8 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
291
367
|
port: healthPort,
|
|
292
368
|
mqttEnabled: adapter !== null,
|
|
293
369
|
isMqttConnected: () => (adapter ? adapter.getMetrics().connected : false),
|
|
370
|
+
opcuaEnabled: opcuaResult !== null,
|
|
371
|
+
isOpcuaConnected: () => (opcuaResult ? opcuaResult.adapter.getMetrics().connected : false),
|
|
294
372
|
getLastTickProgressAt: () => lastTickProgressAt,
|
|
295
373
|
});
|
|
296
374
|
controlLog.info(`Health server active on ${healthServer.endpoint}`);
|
|
@@ -310,6 +388,7 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
310
388
|
seed,
|
|
311
389
|
seedExplicit,
|
|
312
390
|
mqttStartResult?.endpoint ?? null,
|
|
391
|
+
opcuaResult?.endpoint ?? null,
|
|
313
392
|
simulationName,
|
|
314
393
|
runId,
|
|
315
394
|
healthServer.endpoint,
|
|
@@ -351,8 +430,12 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
351
430
|
runtimeLogContextSet = false;
|
|
352
431
|
throw error;
|
|
353
432
|
}
|
|
433
|
+
// Engine running — shutdown handlers now own adapter lifecycle
|
|
434
|
+
pendingAdapterStops.length = 0;
|
|
354
435
|
engineLog.success("Simulation started.");
|
|
355
436
|
} catch (error) {
|
|
437
|
+
// Stop adapters started before engine/shutdown handlers took ownership
|
|
438
|
+
await Promise.allSettled(pendingAdapterStops.map((fn) => fn()));
|
|
356
439
|
if (runtimeLogContextSet) {
|
|
357
440
|
clearRuntimeLogContext();
|
|
358
441
|
runtimeLogContextSet = false;
|
|
@@ -757,12 +840,59 @@ async function startMQTTAdapter(
|
|
|
757
840
|
const message = error instanceof Error ? error.message : String(error);
|
|
758
841
|
const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
|
|
759
842
|
const fix = isPortConflict
|
|
760
|
-
? `Port ${port} is in use. Use '--port ${port + 1}' or set protocols.mqtt.port in template.`
|
|
843
|
+
? `Port ${port} is in use. Use '--mqtt-port ${port + 1}' or set protocols.mqtt.port in template.`
|
|
761
844
|
: "Check the MQTT configuration in your template file.";
|
|
762
845
|
throw new MiravoError(`MQTT adapter failed to start: ${message}`, fix);
|
|
763
846
|
}
|
|
764
847
|
}
|
|
765
848
|
|
|
849
|
+
// ---------------------------------------------------------------------------
|
|
850
|
+
// Step 5a: OPC UA adapter
|
|
851
|
+
// ---------------------------------------------------------------------------
|
|
852
|
+
|
|
853
|
+
async function startOPCUAAdapter(
|
|
854
|
+
engine: Engine,
|
|
855
|
+
template: MiravoTemplate,
|
|
856
|
+
portOverride: number | undefined,
|
|
857
|
+
hostOverride: string | undefined,
|
|
858
|
+
simulationName: string,
|
|
859
|
+
): Promise<{ adapter: OPCUAAdapter; endpoint: string } | null> {
|
|
860
|
+
const opcuaConfig = template.protocols?.opcua;
|
|
861
|
+
|
|
862
|
+
const port = portOverride ?? opcuaConfig?.port ?? DEFAULT_OPCUA_PORT;
|
|
863
|
+
const host = hostOverride ?? opcuaConfig?.host ?? "0.0.0.0";
|
|
864
|
+
|
|
865
|
+
opcuaLog.debug(`Starting OPC UA adapter on opc.tcp://${host}:${port}`);
|
|
866
|
+
|
|
867
|
+
const opcuaAdapter = new OPCUAAdapter(engine.bus);
|
|
868
|
+
|
|
869
|
+
try {
|
|
870
|
+
const resolvedConfig = {
|
|
871
|
+
name: "opcua" as const,
|
|
872
|
+
enabled: true,
|
|
873
|
+
port,
|
|
874
|
+
host,
|
|
875
|
+
security: opcuaConfig?.security ?? "none",
|
|
876
|
+
simulationName,
|
|
877
|
+
...(opcuaConfig?.buildInfo ? { buildInfo: opcuaConfig.buildInfo } : {}),
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
await opcuaAdapter.start(resolvedConfig);
|
|
881
|
+
engine.registerAdapter(opcuaAdapter);
|
|
882
|
+
|
|
883
|
+
const endpoint = `opc.tcp://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
|
|
884
|
+
opcuaLog.ready(`OPC UA server: ${colors.cyan(endpoint)}`);
|
|
885
|
+
return { adapter: opcuaAdapter, endpoint };
|
|
886
|
+
} catch (error) {
|
|
887
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
888
|
+
const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
|
|
889
|
+
const fix = isPortConflict
|
|
890
|
+
? `Port ${port} is in use. Use '--opcua-port ${port + 1}'.`
|
|
891
|
+
: "Check the OPC UA port/network settings and retry.";
|
|
892
|
+
throw new MiravoError(`OPC UA adapter failed to start: ${message}`, fix);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
766
896
|
// ---------------------------------------------------------------------------
|
|
767
897
|
// Step 6: Startup banner
|
|
768
898
|
// ---------------------------------------------------------------------------
|
|
@@ -773,32 +903,55 @@ function printStartupBanner(
|
|
|
773
903
|
seed: number,
|
|
774
904
|
seedExplicit: boolean,
|
|
775
905
|
mqttEndpoint: string | null,
|
|
906
|
+
opcuaEndpoint: string | null,
|
|
776
907
|
simulationName: string,
|
|
777
908
|
runId: string,
|
|
778
909
|
healthEndpoint: string,
|
|
779
910
|
): void {
|
|
780
911
|
const state = engine.getState();
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
912
|
+
|
|
913
|
+
// Build model summary: one model per line for readability
|
|
914
|
+
const modelLines = state.models.map(
|
|
915
|
+
(m) => `${colors.bold(m.name)} ${colors.dim(`(${m.sensorCount} sensors)`)}`,
|
|
916
|
+
);
|
|
917
|
+
const modelCount = `${colors.bold(String(state.models.length))} ${colors.dim("models")}`;
|
|
785
918
|
|
|
786
919
|
const seedValue = seedExplicit
|
|
787
920
|
? colors.bold(String(seed))
|
|
788
921
|
: `${colors.bold(String(seed))} ${colors.gray("(random — add seed to config to reproduce)")}`;
|
|
789
922
|
|
|
790
|
-
const
|
|
923
|
+
const mqttValue = mqttEndpoint ? colors.cyan(mqttEndpoint) : colors.dim("disabled");
|
|
924
|
+
const opcuaValue = opcuaEndpoint ? colors.cyan(opcuaEndpoint) : colors.dim("disabled");
|
|
925
|
+
|
|
926
|
+
const allEntries: ReadonlyArray<readonly [string, string]> = [
|
|
791
927
|
[colors.dim("Simulation"), colors.bold(simulationName)],
|
|
792
928
|
[colors.dim("Run ID"), colors.bold(runId)],
|
|
793
929
|
[colors.dim("Template"), templatePath],
|
|
794
|
-
[colors.dim("Models"),
|
|
930
|
+
[colors.dim("Models"), modelCount],
|
|
795
931
|
[colors.dim("Instances"), colors.bold(String(state.instances.length))],
|
|
796
|
-
[colors.dim("MQTT"),
|
|
932
|
+
[colors.dim("MQTT"), mqttValue],
|
|
933
|
+
[colors.dim("OPC UA"), opcuaValue],
|
|
797
934
|
[colors.dim("Health"), colors.cyan(`${healthEndpoint}/healthz`)],
|
|
798
935
|
[colors.dim("Tick interval"), colors.bold(`${state.config.tickIntervalMs}ms`)],
|
|
799
936
|
[colors.dim("Time multiplier"), colors.bold(`${state.config.timeMultiplier}x`)],
|
|
800
937
|
[colors.dim("Seed"), seedValue],
|
|
801
|
-
]
|
|
938
|
+
];
|
|
939
|
+
|
|
940
|
+
const kvBlock = formatKeyValue(allEntries);
|
|
941
|
+
|
|
942
|
+
// Compute the value column offset used by formatKeyValue so model detail
|
|
943
|
+
// lines align with the value of the "Models" row.
|
|
944
|
+
const maxKeyWidth = Math.max(...allEntries.map(([key]) => stripAnsi(key).length));
|
|
945
|
+
const valueIndent = 4 + maxKeyWidth + 3; // " " + key + ":" + padding(2) + " "
|
|
946
|
+
|
|
947
|
+
// Inject per-model detail lines right after the "Models" row
|
|
948
|
+
const kvLines = kvBlock.split("\n");
|
|
949
|
+
const modelsIdx = kvLines.findIndex((line) => stripAnsi(line).includes("Models:"));
|
|
950
|
+
if (modelsIdx !== -1 && modelLines.length > 0) {
|
|
951
|
+
const detailLines = modelLines.map((line) => `${"".padEnd(valueIndent)}${line}`);
|
|
952
|
+
kvLines.splice(modelsIdx + 1, 0, ...detailLines);
|
|
953
|
+
}
|
|
954
|
+
const content = kvLines.join("\n");
|
|
802
955
|
|
|
803
956
|
const header = `${colors.cyan(colors.bold("Miravo"))} ${colors.dim(`v${MIRAVO_VERSION}`)}`;
|
|
804
957
|
|
|
@@ -856,7 +1009,7 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
|
|
|
856
1009
|
};
|
|
857
1010
|
|
|
858
1011
|
const onFaultCleared = (event: { instanceId: string; fault: string }): void => {
|
|
859
|
-
faultLog.
|
|
1012
|
+
faultLog.debug(`${event.instanceId} ${event.fault} cleared`);
|
|
860
1013
|
};
|
|
861
1014
|
|
|
862
1015
|
const onError = (event: { context: string; error: Error }): void => {
|
|
@@ -918,14 +1071,25 @@ function startPeriodicSummary(
|
|
|
918
1071
|
|
|
919
1072
|
const sep = colors.dim(" · ");
|
|
920
1073
|
|
|
921
|
-
|
|
1074
|
+
const protocolParts: string[] = [];
|
|
1075
|
+
|
|
922
1076
|
if (mqttAdapter) {
|
|
923
1077
|
const errCount = mqttAdapter.metrics.errorsCount;
|
|
924
1078
|
const errStr =
|
|
925
1079
|
errCount === 0 ? colors.green(formatCount(errCount)) : colors.yellow(formatCount(errCount));
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1080
|
+
protocolParts.push(
|
|
1081
|
+
`MQTT: ${colors.bold(formatCount(mqttAdapter.metrics.messagesPublished))} published${sep}${errStr} errors`,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const opcuaAdapter = metrics.adapters.find((a) => a.name === "opcua");
|
|
1086
|
+
if (opcuaAdapter) {
|
|
1087
|
+
const errCount = opcuaAdapter.metrics.errorsCount;
|
|
1088
|
+
const errStr =
|
|
1089
|
+
errCount === 0 ? colors.green(formatCount(errCount)) : colors.yellow(formatCount(errCount));
|
|
1090
|
+
protocolParts.push(
|
|
1091
|
+
`OPC UA: ${colors.bold(formatCount(opcuaAdapter.metrics.messagesPublished))} updates${sep}${errStr} errors`,
|
|
1092
|
+
);
|
|
929
1093
|
}
|
|
930
1094
|
|
|
931
1095
|
const tps =
|
|
@@ -933,8 +1097,10 @@ function startPeriodicSummary(
|
|
|
933
1097
|
? `${colors.bold(core.actualTicksPerSecond.toFixed(1))} tps`
|
|
934
1098
|
: "";
|
|
935
1099
|
|
|
1100
|
+
const protocolSuffix = protocolParts.length > 0 ? `${sep}${protocolParts.join(sep)}` : "";
|
|
1101
|
+
|
|
936
1102
|
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 : ""}${
|
|
1103
|
+
`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 : ""}${protocolSuffix}`,
|
|
938
1104
|
);
|
|
939
1105
|
}, intervalSeconds * 1_000);
|
|
940
1106
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -523,6 +523,26 @@ function renderOverview(metrics: MetricsSnapshot, state: StateSnapshot): void {
|
|
|
523
523
|
);
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
+
// OPC UA
|
|
527
|
+
const opcuaAdapter = metrics.adapters.find((a) => a.name === "opcua");
|
|
528
|
+
if (opcuaAdapter) {
|
|
529
|
+
const connStr = opcuaAdapter.metrics.connected
|
|
530
|
+
? colors.green("connected")
|
|
531
|
+
: colors.red("disconnected");
|
|
532
|
+
const errStr =
|
|
533
|
+
opcuaAdapter.metrics.errorsCount > 0
|
|
534
|
+
? colors.yellow(String(opcuaAdapter.metrics.errorsCount))
|
|
535
|
+
: colors.green("0");
|
|
536
|
+
writeOutput(
|
|
537
|
+
formatKeyValue([
|
|
538
|
+
[
|
|
539
|
+
colors.dim("OPC UA"),
|
|
540
|
+
`${connStr}${sep}${formatCount(opcuaAdapter.metrics.messagesPublished)} updates${sep}${errStr} errors`,
|
|
541
|
+
],
|
|
542
|
+
]),
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
526
546
|
// Errors
|
|
527
547
|
if (
|
|
528
548
|
core.engineErrorsCount > 0 ||
|
|
@@ -65,6 +65,8 @@ interface StartServerOptions {
|
|
|
65
65
|
readonly tickIntervalMs?: number;
|
|
66
66
|
readonly mqttEnabled: boolean;
|
|
67
67
|
readonly isMqttConnected: () => boolean;
|
|
68
|
+
readonly opcuaEnabled?: boolean;
|
|
69
|
+
readonly isOpcuaConnected?: () => boolean;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
function startTestServer(options: StartServerOptions, handles: Array<{ stop: () => void }>) {
|
|
@@ -75,6 +77,8 @@ function startTestServer(options: StartServerOptions, handles: Array<{ stop: ()
|
|
|
75
77
|
port: 0,
|
|
76
78
|
mqttEnabled: options.mqttEnabled,
|
|
77
79
|
isMqttConnected: options.isMqttConnected,
|
|
80
|
+
opcuaEnabled: options.opcuaEnabled ?? false,
|
|
81
|
+
isOpcuaConnected: options.isOpcuaConnected ?? (() => false),
|
|
78
82
|
getLastTickProgressAt: () => mock.getLastTickProgressAt(),
|
|
79
83
|
});
|
|
80
84
|
handles.push(handle);
|
|
@@ -133,6 +137,40 @@ describe("health server", () => {
|
|
|
133
137
|
expect(ready.status).toBe(200);
|
|
134
138
|
});
|
|
135
139
|
|
|
140
|
+
test("/readyz returns 503 when opcua is enabled but disconnected", async () => {
|
|
141
|
+
const { baseUrl } = startTestServer(
|
|
142
|
+
{
|
|
143
|
+
state: "running",
|
|
144
|
+
mqttEnabled: false,
|
|
145
|
+
isMqttConnected: () => false,
|
|
146
|
+
opcuaEnabled: true,
|
|
147
|
+
isOpcuaConnected: () => false,
|
|
148
|
+
},
|
|
149
|
+
handles,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const ready = await fetch(`${baseUrl}/readyz`);
|
|
153
|
+
expect(ready.status).toBe(503);
|
|
154
|
+
const body = (await ready.json()) as { reason?: string };
|
|
155
|
+
expect(body.reason).toBe("opcua disconnected");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("/readyz ignores opcua when opcua is disabled", async () => {
|
|
159
|
+
const { baseUrl } = startTestServer(
|
|
160
|
+
{
|
|
161
|
+
state: "running",
|
|
162
|
+
mqttEnabled: false,
|
|
163
|
+
isMqttConnected: () => false,
|
|
164
|
+
opcuaEnabled: false,
|
|
165
|
+
isOpcuaConnected: () => false,
|
|
166
|
+
},
|
|
167
|
+
handles,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const ready = await fetch(`${baseUrl}/readyz`);
|
|
171
|
+
expect(ready.status).toBe(200);
|
|
172
|
+
});
|
|
173
|
+
|
|
136
174
|
test("/healthz returns 503 when state is stopped", async () => {
|
|
137
175
|
const { baseUrl } = startTestServer(
|
|
138
176
|
{ state: "stopped", mqttEnabled: false, isMqttConnected: () => false },
|
package/src/lib/health-server.ts
CHANGED
|
@@ -11,6 +11,8 @@ interface HealthServerOptions {
|
|
|
11
11
|
readonly port: number;
|
|
12
12
|
readonly mqttEnabled: boolean;
|
|
13
13
|
readonly isMqttConnected: () => boolean;
|
|
14
|
+
readonly opcuaEnabled: boolean;
|
|
15
|
+
readonly isOpcuaConnected: () => boolean;
|
|
14
16
|
readonly getLastTickProgressAt: () => number;
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -30,6 +32,8 @@ interface HealthResponseBody {
|
|
|
30
32
|
readonly tickCount: number;
|
|
31
33
|
readonly mqttEnabled: boolean;
|
|
32
34
|
readonly mqttConnected: boolean | null;
|
|
35
|
+
readonly opcuaEnabled: boolean;
|
|
36
|
+
readonly opcuaConnected: boolean | null;
|
|
33
37
|
readonly stalled: boolean;
|
|
34
38
|
readonly reason?: string;
|
|
35
39
|
}
|
|
@@ -62,6 +66,8 @@ function readinessStatus(input: {
|
|
|
62
66
|
readonly stalled: boolean;
|
|
63
67
|
readonly mqttEnabled: boolean;
|
|
64
68
|
readonly mqttConnected: boolean;
|
|
69
|
+
readonly opcuaEnabled: boolean;
|
|
70
|
+
readonly opcuaConnected: boolean;
|
|
65
71
|
}): HealthServerStatus {
|
|
66
72
|
if (input.core.state !== "running") {
|
|
67
73
|
return { status: "fail", reason: `engine state is "${input.core.state}"` };
|
|
@@ -72,6 +78,9 @@ function readinessStatus(input: {
|
|
|
72
78
|
if (input.mqttEnabled && !input.mqttConnected) {
|
|
73
79
|
return { status: "fail", reason: "mqtt disconnected" };
|
|
74
80
|
}
|
|
81
|
+
if (input.opcuaEnabled && !input.opcuaConnected) {
|
|
82
|
+
return { status: "fail", reason: "opcua disconnected" };
|
|
83
|
+
}
|
|
75
84
|
return { status: "ok" };
|
|
76
85
|
}
|
|
77
86
|
|
|
@@ -79,10 +88,19 @@ function readinessStatus(input: {
|
|
|
79
88
|
* Start a lightweight HTTP health server for container probes.
|
|
80
89
|
*
|
|
81
90
|
* - `/healthz`: liveness (process/tick loop health)
|
|
82
|
-
* - `/readyz`: readiness (engine running +
|
|
91
|
+
* - `/readyz`: readiness (engine running + adapters connected when enabled)
|
|
83
92
|
*/
|
|
84
93
|
function startHealthServer(options: HealthServerOptions): HealthServerHandle {
|
|
85
|
-
const {
|
|
94
|
+
const {
|
|
95
|
+
engine,
|
|
96
|
+
host,
|
|
97
|
+
port,
|
|
98
|
+
mqttEnabled,
|
|
99
|
+
isMqttConnected,
|
|
100
|
+
opcuaEnabled,
|
|
101
|
+
isOpcuaConnected,
|
|
102
|
+
getLastTickProgressAt,
|
|
103
|
+
} = options;
|
|
86
104
|
|
|
87
105
|
const server = Bun.serve({
|
|
88
106
|
hostname: host,
|
|
@@ -101,11 +119,14 @@ function startHealthServer(options: HealthServerOptions): HealthServerHandle {
|
|
|
101
119
|
now - lastTickProgressAt > stallThresholdMs;
|
|
102
120
|
|
|
103
121
|
const mqttConnected = mqttEnabled ? isMqttConnected() : false;
|
|
122
|
+
const opcuaConnected = opcuaEnabled ? isOpcuaConnected() : false;
|
|
104
123
|
const bodyBase = {
|
|
105
124
|
simulationState: metrics.core.state,
|
|
106
125
|
tickCount: metrics.core.tickCount,
|
|
107
126
|
mqttEnabled,
|
|
108
127
|
mqttConnected: mqttEnabled ? mqttConnected : null,
|
|
128
|
+
opcuaEnabled,
|
|
129
|
+
opcuaConnected: opcuaEnabled ? opcuaConnected : null,
|
|
109
130
|
stalled,
|
|
110
131
|
} as const;
|
|
111
132
|
|
|
@@ -127,6 +148,8 @@ function startHealthServer(options: HealthServerOptions): HealthServerHandle {
|
|
|
127
148
|
stalled,
|
|
128
149
|
mqttEnabled,
|
|
129
150
|
mqttConnected,
|
|
151
|
+
opcuaEnabled,
|
|
152
|
+
opcuaConnected,
|
|
130
153
|
});
|
|
131
154
|
return toJsonResponse(
|
|
132
155
|
{
|
package/src/lib/shutdown.ts
CHANGED
|
@@ -119,37 +119,86 @@ function printShutdownSummary(
|
|
|
119
119
|
|
|
120
120
|
const { core, fleet, adapters } = metrics;
|
|
121
121
|
const mqttAdapter = adapters.find((a) => a.name === "mqtt");
|
|
122
|
+
const opcuaAdapter = adapters.find((a) => a.name === "opcua");
|
|
122
123
|
|
|
123
124
|
const sep = colors.dim(" \u00B7 ");
|
|
124
125
|
const lines: string[] = [];
|
|
125
126
|
|
|
127
|
+
// --- Core stats ---
|
|
126
128
|
lines.push(
|
|
127
|
-
`${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(
|
|
129
|
+
`${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(String(core.actualTicksPerSecond.toFixed(1)))} ticks/s`,
|
|
130
|
+
);
|
|
131
|
+
lines.push(
|
|
132
|
+
`${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.unsEntryCount))} UNS topics`,
|
|
128
133
|
);
|
|
129
134
|
|
|
135
|
+
// --- Protocol adapters ---
|
|
130
136
|
if (mqttAdapter) {
|
|
131
|
-
const
|
|
137
|
+
const m = mqttAdapter.metrics;
|
|
138
|
+
const errStr =
|
|
139
|
+
m.errorsCount === 0
|
|
140
|
+
? colors.green(formatCount(m.errorsCount))
|
|
141
|
+
: colors.yellow(formatCount(m.errorsCount));
|
|
142
|
+
lines.push(
|
|
143
|
+
`MQTT: ${colors.bold(formatCount(m.messagesPublished))} published${sep}${errStr} errors`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (opcuaAdapter) {
|
|
147
|
+
const m = opcuaAdapter.metrics;
|
|
132
148
|
const errStr =
|
|
133
|
-
|
|
149
|
+
m.errorsCount === 0
|
|
150
|
+
? colors.green(formatCount(m.errorsCount))
|
|
151
|
+
: colors.yellow(formatCount(m.errorsCount));
|
|
134
152
|
lines.push(
|
|
135
|
-
`
|
|
153
|
+
`OPC UA: ${colors.bold(formatCount(m.messagesPublished))} updates${sep}${errStr} errors`,
|
|
136
154
|
);
|
|
137
155
|
}
|
|
138
156
|
|
|
157
|
+
// --- Fleet health ---
|
|
158
|
+
const healthParts: string[] = [];
|
|
139
159
|
if (fleet.activeFaultCount > 0) {
|
|
140
|
-
|
|
160
|
+
healthParts.push(`${colors.yellow(String(fleet.activeFaultCount))} active faults`);
|
|
161
|
+
}
|
|
162
|
+
if (core.quarantinedInstanceCount > 0) {
|
|
163
|
+
healthParts.push(
|
|
164
|
+
`${colors.yellow(String(core.quarantinedInstanceCount))} quarantined instances`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (core.tickOverrunsCount > 0) {
|
|
168
|
+
healthParts.push(`${colors.yellow(formatCount(core.tickOverrunsCount))} tick overruns`);
|
|
169
|
+
}
|
|
170
|
+
if (core.circuitBreakerTripsCount > 0) {
|
|
171
|
+
healthParts.push(
|
|
172
|
+
`${colors.yellow(formatCount(core.circuitBreakerTripsCount))} circuit breaker trips`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (healthParts.length > 0) {
|
|
176
|
+
lines.push(healthParts.join(sep));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- Lifecycle breakdown ---
|
|
180
|
+
const lifecycleEntries = Object.entries(fleet.lifecycleBreakdown).filter(
|
|
181
|
+
([, count]) => count > 0,
|
|
182
|
+
);
|
|
183
|
+
if (lifecycleEntries.length > 0) {
|
|
184
|
+
const parts = lifecycleEntries.map(
|
|
185
|
+
([stage, count]) => `${colors.bold(String(count))} ${stage}`,
|
|
186
|
+
);
|
|
187
|
+
lines.push(`Lifecycle: ${parts.join(sep)}`);
|
|
141
188
|
}
|
|
142
189
|
|
|
190
|
+
// --- Seed ---
|
|
143
191
|
const seedLine = seedExplicit
|
|
144
192
|
? `Seed: ${colors.bold(String(seed))}`
|
|
145
193
|
: `Seed: ${colors.bold(String(seed))} ${colors.gray("(add seed to config to reproduce)")}`;
|
|
146
194
|
lines.push(seedLine);
|
|
147
195
|
|
|
196
|
+
// --- Top error contexts ---
|
|
148
197
|
if (errorContextCounts && errorContextCounts.size > 0) {
|
|
149
198
|
const topContexts = [...errorContextCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
150
199
|
lines.push("Top error contexts:");
|
|
151
200
|
for (const [context, count] of topContexts) {
|
|
152
|
-
lines.push(` ${context} ${colors.dim("
|
|
201
|
+
lines.push(` ${context} ${colors.dim("\u00B7")} ${colors.bold(formatCount(count))}`);
|
|
153
202
|
}
|
|
154
203
|
}
|
|
155
204
|
|
package/src/logger.test.ts
CHANGED
|
@@ -43,13 +43,13 @@ async function captureConsoleLogs<T>(
|
|
|
43
43
|
describe("configureLogger", () => {
|
|
44
44
|
test("--silent trumps --verbose", () => {
|
|
45
45
|
configureLogger({ verbose: true, silent: true });
|
|
46
|
-
expect(logger.level).toBe(
|
|
46
|
+
expect(logger.level).toBe(0);
|
|
47
47
|
expect(isSilent()).toBe(true);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
test("--silent sets output suppressed", () => {
|
|
51
51
|
configureLogger({ silent: true });
|
|
52
|
-
expect(logger.level).toBe(
|
|
52
|
+
expect(logger.level).toBe(0);
|
|
53
53
|
expect(isSilent()).toBe(true);
|
|
54
54
|
});
|
|
55
55
|
|
package/src/logger.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type LogFormat = "pretty" | "json";
|
|
|
12
12
|
const TAG_COLORS: Readonly<Record<string, (s: string) => string>> = {
|
|
13
13
|
engine: colors.cyan,
|
|
14
14
|
mqtt: colors.green,
|
|
15
|
+
opcua: colors.green,
|
|
15
16
|
config: colors.yellow,
|
|
16
17
|
lifecycle: colors.magenta,
|
|
17
18
|
fault: colors.red,
|
|
@@ -43,6 +44,7 @@ interface ReporterLogObject {
|
|
|
43
44
|
const fancyReporters = [...logger.options.reporters];
|
|
44
45
|
const prettyReporter = {
|
|
45
46
|
log(logObj: ReporterLogObject, ctx: unknown) {
|
|
47
|
+
if (logObj.level === 1 && !_verbose) return;
|
|
46
48
|
if (logObj.args.length > 0 && typeof logObj.args[0] === "string") {
|
|
47
49
|
logObj.args[0] = ` ${logObj.args[0]}`;
|
|
48
50
|
}
|
|
@@ -81,12 +83,13 @@ function levelToString(level: number): "trace" | "debug" | "info" | "warn" | "er
|
|
|
81
83
|
if (level === 4) return "debug";
|
|
82
84
|
if (level === 3) return "info";
|
|
83
85
|
if (level === 2) return "warn";
|
|
84
|
-
if (level === 1) return "
|
|
86
|
+
if (level === 1) return "warn";
|
|
85
87
|
return "fatal";
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
const jsonReporter = {
|
|
89
91
|
log(logObj: ReporterLogObject) {
|
|
92
|
+
if (logObj.level === 1 && !_verbose) return;
|
|
90
93
|
const errorArg = logObj.args.find((arg) => arg instanceof Error);
|
|
91
94
|
const metadataArg = logObj.args.find(
|
|
92
95
|
(arg) => typeof arg === "object" && arg !== null && !(arg instanceof Error),
|
|
@@ -137,6 +140,9 @@ export const lifecycleLog: ConsolaInstance = logger.withTag("lifecycle");
|
|
|
137
140
|
/** Fault trigger and clear events. */
|
|
138
141
|
export const faultLog: ConsolaInstance = logger.withTag("fault");
|
|
139
142
|
|
|
143
|
+
/** OPC UA adapter connection and server events. */
|
|
144
|
+
export const opcuaLog: ConsolaInstance = logger.withTag("opcua");
|
|
145
|
+
|
|
140
146
|
/** Control plane status and command routing events. */
|
|
141
147
|
export const controlLog: ConsolaInstance = logger.withTag("control");
|
|
142
148
|
|
|
@@ -145,6 +151,7 @@ const allLoggers: ConsolaInstance[] = [
|
|
|
145
151
|
logger,
|
|
146
152
|
engineLog,
|
|
147
153
|
mqttLog,
|
|
154
|
+
opcuaLog,
|
|
148
155
|
configLog,
|
|
149
156
|
lifecycleLog,
|
|
150
157
|
faultLog,
|
|
@@ -168,6 +175,7 @@ export interface LoggerOptions {
|
|
|
168
175
|
|
|
169
176
|
let _outputSuppressed = false;
|
|
170
177
|
let _plainOutput = false;
|
|
178
|
+
let _verbose = false;
|
|
171
179
|
let _locked = false;
|
|
172
180
|
let _logFormat: LogFormat = "pretty";
|
|
173
181
|
const runtimeLogContext: { simulationName: string | undefined; runId: string | undefined } = {
|
|
@@ -197,7 +205,7 @@ export function clearRuntimeLogContext(): void {
|
|
|
197
205
|
* Set the global log level based on CLI flags.
|
|
198
206
|
* --silent trumps --verbose when both are provided.
|
|
199
207
|
*
|
|
200
|
-
* Levels:
|
|
208
|
+
* Levels: 0 = errors/fatals only, 3 = info (default), 4 = debug.
|
|
201
209
|
*/
|
|
202
210
|
export function configureLogger(opts: LoggerOptions): void {
|
|
203
211
|
if (_locked) return;
|
|
@@ -205,9 +213,11 @@ export function configureLogger(opts: LoggerOptions): void {
|
|
|
205
213
|
applyLogReporter(_logFormat);
|
|
206
214
|
_plainOutput = opts.plain ?? false;
|
|
207
215
|
|
|
216
|
+
_verbose = opts.verbose ?? false;
|
|
217
|
+
|
|
208
218
|
let level: number;
|
|
209
219
|
if (opts.silent) {
|
|
210
|
-
level =
|
|
220
|
+
level = 0;
|
|
211
221
|
_outputSuppressed = true;
|
|
212
222
|
} else if (opts.verbose) {
|
|
213
223
|
level = 4;
|