miravo 0.1.4 → 0.2.0
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 +281 -149
- 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 +16 -6
- 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 +2 -2
- package/src/lib/shutdown.ts +218 -35
- 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/lib/shutdown.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Engine, EngineMetricsSnapshot } from "@miravo/core";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import type { AdapterHealthSnapshot } from "@miravo/shared";
|
|
3
|
+
import type { TreeItem } from "consola/utils";
|
|
4
|
+
import { box, colors, formatTree } from "consola/utils";
|
|
5
|
+
import { engineLog, isJsonLogFormat, isPlain, isSilent, writeOutput } from "../logger.ts";
|
|
6
|
+
import { computeAverageThroughput, formatCount, formatDuration } from "../output.ts";
|
|
5
7
|
|
|
6
8
|
/** Parameters for the shutdown handler. Supports dependency injection for tests. */
|
|
7
9
|
export interface ShutdownOptions {
|
|
@@ -54,14 +56,13 @@ export function installShutdownHandlers(options: ShutdownOptions): () => void {
|
|
|
54
56
|
// Capture metrics before stop() clears bus listeners
|
|
55
57
|
const finalMetrics = engine.getMetrics();
|
|
56
58
|
|
|
57
|
-
cleanup?.();
|
|
58
|
-
|
|
59
59
|
let timedOut = false;
|
|
60
60
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
61
61
|
try {
|
|
62
|
-
|
|
62
|
+
engineLog.start("Flushing pending messages (5s timeout)...");
|
|
63
63
|
const stopResult = engine.stop(signal === "SIGINT" ? "user interrupt" : signal);
|
|
64
|
-
//
|
|
64
|
+
// Suppress unhandled rejection if stop() rejects after timeout wins the race
|
|
65
|
+
stopResult.catch(() => {});
|
|
65
66
|
await Promise.race([
|
|
66
67
|
stopResult,
|
|
67
68
|
new Promise<void>((resolve) => {
|
|
@@ -74,7 +75,7 @@ export function installShutdownHandlers(options: ShutdownOptions): () => void {
|
|
|
74
75
|
if (timedOut) {
|
|
75
76
|
engineLog.warn("Engine stop timed out after 5s, proceeding with exit.");
|
|
76
77
|
} else {
|
|
77
|
-
|
|
78
|
+
engineLog.success("Stopped.");
|
|
78
79
|
}
|
|
79
80
|
} catch (err) {
|
|
80
81
|
engineLog.error("Error during shutdown:", err);
|
|
@@ -84,11 +85,16 @@ export function installShutdownHandlers(options: ShutdownOptions): () => void {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
// Clean up timers, admin server, and bus listeners AFTER engine.stop()
|
|
89
|
+
// so that adapter shutdown errors are still observable on the bus.
|
|
90
|
+
cleanup?.();
|
|
91
|
+
|
|
87
92
|
if (forcedExit) {
|
|
88
93
|
return;
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
printShutdownSummary(finalMetrics, seed, seedExplicit, errorContextCounts);
|
|
97
|
+
Bun.stdout.writer().flush();
|
|
92
98
|
exit(0);
|
|
93
99
|
};
|
|
94
100
|
|
|
@@ -109,52 +115,212 @@ export function installShutdownHandlers(options: ShutdownOptions): () => void {
|
|
|
109
115
|
};
|
|
110
116
|
}
|
|
111
117
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Shared summary rendering
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
interface SummaryInput {
|
|
123
|
+
readonly metrics: EngineMetricsSnapshot;
|
|
124
|
+
readonly seed?: number | undefined;
|
|
125
|
+
readonly seedExplicit?: boolean | undefined;
|
|
126
|
+
readonly errorContextCounts?: ReadonlyMap<string, number> | undefined;
|
|
127
|
+
}
|
|
119
128
|
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
function healthStateLabel(state: AdapterHealthSnapshot["state"]): string {
|
|
130
|
+
switch (state) {
|
|
131
|
+
case "healthy":
|
|
132
|
+
return colors.green(state);
|
|
133
|
+
case "busy":
|
|
134
|
+
return colors.yellow(state);
|
|
135
|
+
case "saturated":
|
|
136
|
+
return colors.red(state);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
122
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Build the pretty summary lines from an engine metrics snapshot.
|
|
142
|
+
* Used by both the shutdown summary and periodic summary.
|
|
143
|
+
*/
|
|
144
|
+
function buildSummaryLines(input: SummaryInput): string[] {
|
|
145
|
+
const { core, fleet, adapters } = input.metrics;
|
|
123
146
|
const sep = colors.dim(" \u00B7 ");
|
|
124
147
|
const lines: string[] = [];
|
|
125
148
|
|
|
149
|
+
// --- Core stats ---
|
|
126
150
|
lines.push(
|
|
127
|
-
`${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(
|
|
151
|
+
`${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(String(core.actualTicksPerSecond.toFixed(1)))} ticks/s`,
|
|
152
|
+
);
|
|
153
|
+
lines.push(
|
|
154
|
+
`${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.memberValueCount))} data points${sep}sim: ${colors.bold(core.simulationTimeSeconds.toFixed(1))}s`,
|
|
128
155
|
);
|
|
129
156
|
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
// --- Protocol adapters ---
|
|
158
|
+
for (const adapter of adapters) {
|
|
159
|
+
const m = adapter.metrics;
|
|
160
|
+
const label = adapter.name === "opcua" ? "OPC UA" : adapter.name.toUpperCase();
|
|
161
|
+
const verb = adapter.name === "opcua" ? "updates" : "published";
|
|
162
|
+
const connStr = m.connected ? colors.green("connected") : colors.red("disconnected");
|
|
132
163
|
const errStr =
|
|
133
|
-
|
|
164
|
+
m.errorsCount === 0
|
|
165
|
+
? colors.green(formatCount(m.errorsCount))
|
|
166
|
+
: colors.yellow(formatCount(m.errorsCount));
|
|
167
|
+
const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
|
|
168
|
+
const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
|
|
169
|
+
const filteredStr =
|
|
170
|
+
m.messagesFiltered > 0 ? `${sep}${colors.dim(formatCount(m.messagesFiltered))} filtered` : "";
|
|
134
171
|
lines.push(
|
|
135
|
-
|
|
172
|
+
`${label}: ${connStr}${sep}${colors.bold(formatCount(m.messagesPublished))} ${verb}${sep}${errStr} errors${throughputStr}${filteredStr}`,
|
|
136
173
|
);
|
|
174
|
+
// Health on a separate line to keep the box narrow
|
|
175
|
+
const health = m.health;
|
|
176
|
+
if (health) {
|
|
177
|
+
const pendingStr =
|
|
178
|
+
health.pendingWorkCount > 0 ? `${formatCount(health.pendingWorkCount)} pending${sep}` : "";
|
|
179
|
+
lines.push(` ${pendingStr}${healthStateLabel(health.state)}`);
|
|
180
|
+
}
|
|
137
181
|
}
|
|
138
182
|
|
|
183
|
+
// --- Fleet health ---
|
|
184
|
+
const healthParts: string[] = [];
|
|
139
185
|
if (fleet.activeFaultCount > 0) {
|
|
140
|
-
|
|
186
|
+
healthParts.push(`${colors.yellow(String(fleet.activeFaultCount))} active faults`);
|
|
187
|
+
}
|
|
188
|
+
if (core.quarantinedInstanceCount > 0) {
|
|
189
|
+
healthParts.push(`${colors.yellow(String(core.quarantinedInstanceCount))} quarantined`);
|
|
190
|
+
}
|
|
191
|
+
if (core.engineErrorsCount > 0) {
|
|
192
|
+
healthParts.push(`${colors.yellow(formatCount(core.engineErrorsCount))} engine errors`);
|
|
193
|
+
}
|
|
194
|
+
if (core.tickOverrunsCount > 0) {
|
|
195
|
+
healthParts.push(`${colors.yellow(formatCount(core.tickOverrunsCount))} overruns`);
|
|
196
|
+
}
|
|
197
|
+
if (core.circuitBreakerTripsCount > 0) {
|
|
198
|
+
healthParts.push(`${colors.yellow(formatCount(core.circuitBreakerTripsCount))} breaker trips`);
|
|
199
|
+
}
|
|
200
|
+
if (healthParts.length > 0) {
|
|
201
|
+
lines.push(healthParts.join(sep));
|
|
141
202
|
}
|
|
142
203
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
204
|
+
// --- Fault breakdown (tree) ---
|
|
205
|
+
if (fleet.faultBreakdown.length > 0) {
|
|
206
|
+
const faultItems: TreeItem[] = fleet.faultBreakdown.map(
|
|
207
|
+
(f) => `${colors.yellow(String(f.count))} ${f.fault}`,
|
|
208
|
+
);
|
|
209
|
+
lines.push(formatTree(faultItems, { color: "yellow", prefix: " " }).trimEnd());
|
|
210
|
+
}
|
|
147
211
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
212
|
+
// --- Model & lifecycle (tree) ---
|
|
213
|
+
const treeItems: TreeItem[] = [];
|
|
214
|
+
if (fleet.modelBreakdown.length > 0) {
|
|
215
|
+
treeItems.push({
|
|
216
|
+
text: `${colors.cyan("Models")} ${colors.dim(`(${fleet.modelBreakdown.length})`)}`,
|
|
217
|
+
children: fleet.modelBreakdown.map((m) => `${colors.bold(String(m.count))} ${m.model}`),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const lifecycleEntries = Object.entries(fleet.lifecycleBreakdown).filter(
|
|
221
|
+
([, count]) => count > 0,
|
|
222
|
+
);
|
|
223
|
+
if (lifecycleEntries.length > 0) {
|
|
224
|
+
treeItems.push({
|
|
225
|
+
text: colors.cyan("Lifecycle"),
|
|
226
|
+
children: lifecycleEntries.map(([stage, count]) => `${colors.bold(String(count))} ${stage}`),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (treeItems.length > 0) {
|
|
230
|
+
lines.push(formatTree(treeItems, { color: "gray", prefix: " " }).trimEnd());
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- Seed ---
|
|
234
|
+
if (input.seed !== undefined) {
|
|
235
|
+
const seedLine = input.seedExplicit
|
|
236
|
+
? `Seed: ${colors.bold(String(input.seed))}`
|
|
237
|
+
: `Seed: ${colors.bold(String(input.seed))} ${colors.gray("(add seed to config to reproduce)")}`;
|
|
238
|
+
lines.push(seedLine);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- Top error contexts (tree) ---
|
|
242
|
+
if (input.errorContextCounts && input.errorContextCounts.size > 0) {
|
|
243
|
+
const topContexts = [...input.errorContextCounts.entries()]
|
|
244
|
+
.sort((a, b) => b[1] - a[1])
|
|
245
|
+
.slice(0, 5);
|
|
246
|
+
const errorItems: TreeItem[] = topContexts.map(
|
|
247
|
+
([context, count]) => `${context} ${colors.dim("\u00B7")} ${colors.bold(formatCount(count))}`,
|
|
248
|
+
);
|
|
249
|
+
lines.push(`${colors.cyan("Top errors")}`);
|
|
250
|
+
lines.push(formatTree(errorItems, { color: "red", prefix: " " }).trimEnd());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return lines;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Build a structured summary object for JSON log output.
|
|
258
|
+
*/
|
|
259
|
+
function buildJsonSummary(input: SummaryInput): Record<string, unknown> {
|
|
260
|
+
const { core, fleet, adapters } = input.metrics;
|
|
261
|
+
|
|
262
|
+
const adapterSummaries: Record<string, unknown>[] = [];
|
|
263
|
+
for (const adapter of adapters) {
|
|
264
|
+
const m = adapter.metrics;
|
|
265
|
+
const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
|
|
266
|
+
adapterSummaries.push({
|
|
267
|
+
name: adapter.name,
|
|
268
|
+
messagesPublished: m.messagesPublished,
|
|
269
|
+
errorsCount: m.errorsCount,
|
|
270
|
+
connected: m.connected,
|
|
271
|
+
...(avgThroughput !== null ? { avgMsgPerSecond: avgThroughput } : {}),
|
|
272
|
+
...(m.health
|
|
273
|
+
? { health: { state: m.health.state, pendingWorkCount: m.health.pendingWorkCount } }
|
|
274
|
+
: {}),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const summary: Record<string, unknown> = {
|
|
279
|
+
uptimeMs: core.uptimeMs,
|
|
280
|
+
tickCount: core.tickCount,
|
|
281
|
+
ticksPerSecond: Number(core.actualTicksPerSecond.toFixed(1)),
|
|
282
|
+
simulationTimeSeconds: Number(core.simulationTimeSeconds.toFixed(1)),
|
|
283
|
+
instanceCount: core.instanceCount,
|
|
284
|
+
memberValueCount: core.memberValueCount,
|
|
285
|
+
engineErrorsCount: core.engineErrorsCount,
|
|
286
|
+
activeFaultCount: fleet.activeFaultCount,
|
|
287
|
+
quarantinedInstanceCount: core.quarantinedInstanceCount,
|
|
288
|
+
tickOverrunsCount: core.tickOverrunsCount,
|
|
289
|
+
circuitBreakerTripsCount: core.circuitBreakerTripsCount,
|
|
290
|
+
lifecycleBreakdown: fleet.lifecycleBreakdown,
|
|
291
|
+
adapters: adapterSummaries,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
if (input.seed !== undefined) {
|
|
295
|
+
summary["seed"] = input.seed;
|
|
296
|
+
summary["seedExplicit"] = input.seedExplicit ?? false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (input.errorContextCounts && input.errorContextCounts.size > 0) {
|
|
300
|
+
const topContexts = [...input.errorContextCounts.entries()]
|
|
301
|
+
.sort((a, b) => b[1] - a[1])
|
|
302
|
+
.slice(0, 5);
|
|
303
|
+
summary["topErrorContexts"] = Object.fromEntries(topContexts);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return summary;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Render a summary box (or plain/JSON equivalent) with a given title.
|
|
311
|
+
* Shared renderer for shutdown and periodic summaries.
|
|
312
|
+
*/
|
|
313
|
+
export function printSummaryBox(title: string, input: SummaryInput, logLabel: string): void {
|
|
314
|
+
if (isSilent()) return;
|
|
315
|
+
|
|
316
|
+
if (isJsonLogFormat()) {
|
|
317
|
+
engineLog.info(logLabel, buildJsonSummary(input));
|
|
154
318
|
}
|
|
155
319
|
|
|
320
|
+
const lines = buildSummaryLines(input);
|
|
321
|
+
|
|
156
322
|
if (isPlain()) {
|
|
157
|
-
writeOutput(
|
|
323
|
+
writeOutput(title);
|
|
158
324
|
for (const line of lines) {
|
|
159
325
|
writeOutput(` ${line}`);
|
|
160
326
|
}
|
|
@@ -164,8 +330,25 @@ function printShutdownSummary(
|
|
|
164
330
|
|
|
165
331
|
writeOutput(
|
|
166
332
|
box(lines.join("\n"), {
|
|
167
|
-
title:
|
|
333
|
+
title: ` ${title} `,
|
|
168
334
|
style: { borderColor: "gray", borderStyle: "rounded" },
|
|
169
335
|
}),
|
|
170
336
|
);
|
|
171
337
|
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Shutdown-specific
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
function printShutdownSummary(
|
|
344
|
+
metrics: EngineMetricsSnapshot,
|
|
345
|
+
seed: number,
|
|
346
|
+
seedExplicit: boolean,
|
|
347
|
+
errorContextCounts?: ReadonlyMap<string, number>,
|
|
348
|
+
): void {
|
|
349
|
+
printSummaryBox(
|
|
350
|
+
"Shutdown Summary",
|
|
351
|
+
{ metrics, seed, seedExplicit, errorContextCounts },
|
|
352
|
+
"Shutdown summary",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
@@ -21,7 +21,10 @@ function discoveredTemplate(id: string, name: string): DiscoveredTemplate {
|
|
|
21
21
|
|
|
22
22
|
describe("template category helpers", () => {
|
|
23
23
|
test("categorizeTemplate maps stress templates to Stress & Benchmark", () => {
|
|
24
|
-
expect(categorizeTemplate(discoveredTemplate("stress-
|
|
24
|
+
expect(categorizeTemplate(discoveredTemplate("stress-5k", "Stress 5K"))).toBe(
|
|
25
|
+
"Stress & Benchmark",
|
|
26
|
+
);
|
|
27
|
+
expect(categorizeTemplate(discoveredTemplate("stress-10k", "Stress 10K"))).toBe(
|
|
25
28
|
"Stress & Benchmark",
|
|
26
29
|
);
|
|
27
30
|
expect(categorizeTemplate(discoveredTemplate("smart-factory", "Smart Factory"))).toBe(
|
|
@@ -31,24 +34,28 @@ describe("template category helpers", () => {
|
|
|
31
34
|
|
|
32
35
|
test("sortTemplatesByCategoryAndName sorts by category then name", () => {
|
|
33
36
|
const templates = [
|
|
34
|
-
discoveredTemplate("stress-100k
|
|
37
|
+
discoveredTemplate("stress-100k", "Stress 100K"),
|
|
35
38
|
discoveredTemplate("water-treatment", "Water Treatment"),
|
|
36
39
|
discoveredTemplate("smart-factory", "Smart Factory"),
|
|
37
|
-
discoveredTemplate("stress-10k
|
|
40
|
+
discoveredTemplate("stress-10k", "Stress 10K"),
|
|
41
|
+
discoveredTemplate("stress-5k", "Stress 5K"),
|
|
42
|
+
discoveredTemplate("stress-50k", "Stress 50K"),
|
|
38
43
|
];
|
|
39
44
|
|
|
40
45
|
const sortedIds = sortTemplatesByCategoryAndName(templates).map((template) => template.id);
|
|
41
46
|
expect(sortedIds).toEqual([
|
|
42
47
|
"smart-factory",
|
|
43
48
|
"water-treatment",
|
|
44
|
-
"stress-100k
|
|
45
|
-
"stress-10k
|
|
49
|
+
"stress-100k",
|
|
50
|
+
"stress-10k",
|
|
51
|
+
"stress-50k",
|
|
52
|
+
"stress-5k",
|
|
46
53
|
]);
|
|
47
54
|
});
|
|
48
55
|
|
|
49
56
|
test("groupTemplatesByCategory returns non-empty groups in category order", () => {
|
|
50
57
|
const templates = sortTemplatesByCategoryAndName([
|
|
51
|
-
discoveredTemplate("stress-50k
|
|
58
|
+
discoveredTemplate("stress-50k", "Stress 50K"),
|
|
52
59
|
discoveredTemplate("water-treatment", "Water Treatment"),
|
|
53
60
|
]);
|
|
54
61
|
|
|
@@ -58,6 +65,6 @@ describe("template category helpers", () => {
|
|
|
58
65
|
"Stress & Benchmark",
|
|
59
66
|
]);
|
|
60
67
|
expect(grouped[0]?.templates.map((template) => template.id)).toEqual(["water-treatment"]);
|
|
61
|
-
expect(grouped[1]?.templates.map((template) => template.id)).toEqual(["stress-50k
|
|
68
|
+
expect(grouped[1]?.templates.map((template) => template.id)).toEqual(["stress-50k"]);
|
|
62
69
|
});
|
|
63
70
|
});
|
|
@@ -4,8 +4,13 @@ export type TemplateCategory = "Industrial Scenarios" | "Stress & Benchmark";
|
|
|
4
4
|
|
|
5
5
|
const CATEGORY_ORDER: readonly TemplateCategory[] = ["Industrial Scenarios", "Stress & Benchmark"];
|
|
6
6
|
|
|
7
|
+
/** True if the template is a stress/benchmark template (excluded from built-in list in CLI, same as in standalone binaries). */
|
|
8
|
+
export function isStressBenchmarkTemplate(id: string): boolean {
|
|
9
|
+
return id.startsWith("stress-");
|
|
10
|
+
}
|
|
11
|
+
|
|
7
12
|
export function categorizeTemplate(template: Pick<DiscoveredTemplate, "id">): TemplateCategory {
|
|
8
|
-
if (template.id
|
|
13
|
+
if (isStressBenchmarkTemplate(template.id)) {
|
|
9
14
|
return "Stress & Benchmark";
|
|
10
15
|
}
|
|
11
16
|
return "Industrial Scenarios";
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { ContentCatalog, type DiscoveredTemplateWithSource } from "@miravo/core";
|
|
3
|
-
import {
|
|
3
|
+
import { MiravoError, TEMPLATE_FILE_EXTENSION } from "@miravo/shared";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Create a ContentCatalog with
|
|
6
|
+
* Create a ContentCatalog with default settings.
|
|
7
7
|
* Shared across all CLI commands to ensure consistent resolution.
|
|
8
8
|
*/
|
|
9
|
-
export function createCatalog(
|
|
10
|
-
|
|
11
|
-
return new ContentCatalog({
|
|
12
|
-
...(resolved && resolved.trim().length > 0 ? { contentDir: resolved } : {}),
|
|
13
|
-
});
|
|
9
|
+
export function createCatalog(): ContentCatalog {
|
|
10
|
+
return new ContentCatalog();
|
|
14
11
|
}
|
|
15
12
|
|
|
16
13
|
export function isTemplatePathLike(idNameOrPath: string): boolean {
|
|
@@ -23,7 +20,7 @@ export function isTemplatePathLike(idNameOrPath: string): boolean {
|
|
|
23
20
|
}
|
|
24
21
|
|
|
25
22
|
/**
|
|
26
|
-
* Return local (non-builtin) templates from the catalog:
|
|
23
|
+
* Return local (non-builtin) templates from the catalog: cwd + local registry.
|
|
27
24
|
*/
|
|
28
25
|
export async function discoverPreferredLocalTemplates(
|
|
29
26
|
catalog: ContentCatalog,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MiravoTemplate } from "@miravo/shared";
|
|
2
|
+
|
|
3
|
+
/** Collect all unique model names referenced by a template's twin assignments. */
|
|
4
|
+
export function collectReferencedModels(template: MiravoTemplate): Set<string> {
|
|
5
|
+
const models = new Set<string>();
|
|
6
|
+
for (const area of template.areas) {
|
|
7
|
+
for (const line of area.lines) {
|
|
8
|
+
for (const twin of line.twins) {
|
|
9
|
+
models.add(twin.model);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return models;
|
|
14
|
+
}
|
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
|
@@ -4,20 +4,42 @@ import { createConsola } from "consola";
|
|
|
4
4
|
import { colors, stripAnsi } from "consola/utils";
|
|
5
5
|
|
|
6
6
|
export type LogFormat = "pretty" | "json";
|
|
7
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
8
|
+
|
|
9
|
+
const LOG_LEVEL_MAP: Readonly<Record<LogLevel, number>> = {
|
|
10
|
+
debug: 4,
|
|
11
|
+
info: 3,
|
|
12
|
+
warn: 2,
|
|
13
|
+
error: 0,
|
|
14
|
+
};
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
|
-
* Color map for log tags.
|
|
10
|
-
* ANSI codes take precedence, so pre-coloring the tag overrides the gray.
|
|
17
|
+
* Color map for log tags. Used in pretty mode to prepend a colored label.
|
|
11
18
|
*/
|
|
12
19
|
const TAG_COLORS: Readonly<Record<string, (s: string) => string>> = {
|
|
13
20
|
engine: colors.cyan,
|
|
14
21
|
mqtt: colors.green,
|
|
22
|
+
opcua: colors.green,
|
|
15
23
|
config: colors.yellow,
|
|
16
24
|
lifecycle: colors.magenta,
|
|
17
|
-
fault: colors.
|
|
25
|
+
fault: colors.yellowBright,
|
|
18
26
|
control: colors.blue,
|
|
19
27
|
};
|
|
20
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Display names for log tags. Tags are internal identifiers; these are
|
|
31
|
+
* the user-facing labels shown at the start of each log line.
|
|
32
|
+
*/
|
|
33
|
+
const TAG_DISPLAY_NAMES: Readonly<Record<string, string>> = {
|
|
34
|
+
engine: "Engine",
|
|
35
|
+
mqtt: "MQTT",
|
|
36
|
+
opcua: "OPC UA",
|
|
37
|
+
config: "Config",
|
|
38
|
+
lifecycle: "Lifecycle",
|
|
39
|
+
fault: "Fault",
|
|
40
|
+
control: "Control",
|
|
41
|
+
};
|
|
42
|
+
|
|
21
43
|
/**
|
|
22
44
|
* Root consola logger for the Miravo CLI.
|
|
23
45
|
* `fancy: true` enables icons and color when a TTY is available.
|
|
@@ -43,14 +65,21 @@ interface ReporterLogObject {
|
|
|
43
65
|
const fancyReporters = [...logger.options.reporters];
|
|
44
66
|
const prettyReporter = {
|
|
45
67
|
log(logObj: ReporterLogObject, ctx: unknown) {
|
|
46
|
-
|
|
47
|
-
logObj.args[0] = ` ${logObj.args[0]}`;
|
|
48
|
-
}
|
|
68
|
+
// Move tag from the right side to a colored prefix at the start of the message
|
|
49
69
|
if (logObj.tag) {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
const rawTag = logObj.tag;
|
|
71
|
+
const displayName = TAG_DISPLAY_NAMES[rawTag] ?? rawTag;
|
|
72
|
+
const colorFn = TAG_COLORS[rawTag] ?? colors.gray;
|
|
73
|
+
const prefix = `${colorFn(displayName)}${colors.dim(":")}`;
|
|
74
|
+
if (logObj.args.length > 0 && typeof logObj.args[0] === "string") {
|
|
75
|
+
logObj.args[0] = ` ${prefix} ${logObj.args[0]}`;
|
|
76
|
+
} else {
|
|
77
|
+
logObj.args.unshift(` ${prefix}`);
|
|
53
78
|
}
|
|
79
|
+
// Clear the tag so consola doesn't also render it on the right
|
|
80
|
+
logObj.tag = "";
|
|
81
|
+
} else if (logObj.args.length > 0 && typeof logObj.args[0] === "string") {
|
|
82
|
+
logObj.args[0] = ` ${logObj.args[0]}`;
|
|
54
83
|
}
|
|
55
84
|
for (const reporter of fancyReporters) {
|
|
56
85
|
reporter.log(logObj as never, ctx as never);
|
|
@@ -81,7 +110,8 @@ function levelToString(level: number): "trace" | "debug" | "info" | "warn" | "er
|
|
|
81
110
|
if (level === 4) return "debug";
|
|
82
111
|
if (level === 3) return "info";
|
|
83
112
|
if (level === 2) return "warn";
|
|
84
|
-
if (level === 1) return "
|
|
113
|
+
if (level === 1) return "warn";
|
|
114
|
+
if (level === 0) return "error";
|
|
85
115
|
return "fatal";
|
|
86
116
|
}
|
|
87
117
|
|
|
@@ -137,6 +167,9 @@ export const lifecycleLog: ConsolaInstance = logger.withTag("lifecycle");
|
|
|
137
167
|
/** Fault trigger and clear events. */
|
|
138
168
|
export const faultLog: ConsolaInstance = logger.withTag("fault");
|
|
139
169
|
|
|
170
|
+
/** OPC UA adapter connection and server events. */
|
|
171
|
+
export const opcuaLog: ConsolaInstance = logger.withTag("opcua");
|
|
172
|
+
|
|
140
173
|
/** Control plane status and command routing events. */
|
|
141
174
|
export const controlLog: ConsolaInstance = logger.withTag("control");
|
|
142
175
|
|
|
@@ -145,6 +178,7 @@ const allLoggers: ConsolaInstance[] = [
|
|
|
145
178
|
logger,
|
|
146
179
|
engineLog,
|
|
147
180
|
mqttLog,
|
|
181
|
+
opcuaLog,
|
|
148
182
|
configLog,
|
|
149
183
|
lifecycleLog,
|
|
150
184
|
faultLog,
|
|
@@ -164,12 +198,14 @@ export interface LoggerOptions {
|
|
|
164
198
|
readonly silent?: boolean | undefined;
|
|
165
199
|
readonly plain?: boolean | undefined;
|
|
166
200
|
readonly logFormat?: LogFormat | undefined;
|
|
201
|
+
readonly logLevel?: LogLevel | undefined;
|
|
167
202
|
}
|
|
168
203
|
|
|
169
204
|
let _outputSuppressed = false;
|
|
170
205
|
let _plainOutput = false;
|
|
171
206
|
let _locked = false;
|
|
172
207
|
let _logFormat: LogFormat = "pretty";
|
|
208
|
+
let _currentLevel = LOG_LEVEL_MAP.info;
|
|
173
209
|
const runtimeLogContext: { simulationName: string | undefined; runId: string | undefined } = {
|
|
174
210
|
simulationName: undefined,
|
|
175
211
|
runId: undefined,
|
|
@@ -183,6 +219,14 @@ export function parseLogFormat(raw: string): LogFormat {
|
|
|
183
219
|
);
|
|
184
220
|
}
|
|
185
221
|
|
|
222
|
+
export function parseLogLevel(raw: string): LogLevel {
|
|
223
|
+
if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") return raw;
|
|
224
|
+
throw new MiravoError(
|
|
225
|
+
`Invalid log level "${raw}".`,
|
|
226
|
+
"Use --log-level debug | info | warn | error.",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
186
230
|
export function setRuntimeLogContext(context: { simulationName?: string; runId?: string }): void {
|
|
187
231
|
runtimeLogContext.simulationName = context.simulationName;
|
|
188
232
|
runtimeLogContext.runId = context.runId;
|
|
@@ -195,9 +239,9 @@ export function clearRuntimeLogContext(): void {
|
|
|
195
239
|
|
|
196
240
|
/**
|
|
197
241
|
* Set the global log level based on CLI flags.
|
|
198
|
-
* --silent
|
|
242
|
+
* Priority: --silent > --log-level > --verbose > default (info).
|
|
199
243
|
*
|
|
200
|
-
*
|
|
244
|
+
* Consola levels: 0 = errors/fatals, 2 = warn, 3 = info, 4 = debug.
|
|
201
245
|
*/
|
|
202
246
|
export function configureLogger(opts: LoggerOptions): void {
|
|
203
247
|
if (_locked) return;
|
|
@@ -207,15 +251,19 @@ export function configureLogger(opts: LoggerOptions): void {
|
|
|
207
251
|
|
|
208
252
|
let level: number;
|
|
209
253
|
if (opts.silent) {
|
|
210
|
-
level =
|
|
254
|
+
level = 0;
|
|
211
255
|
_outputSuppressed = true;
|
|
256
|
+
} else if (opts.logLevel) {
|
|
257
|
+
level = LOG_LEVEL_MAP[opts.logLevel];
|
|
258
|
+
_outputSuppressed = false;
|
|
212
259
|
} else if (opts.verbose) {
|
|
213
|
-
level =
|
|
260
|
+
level = LOG_LEVEL_MAP.debug;
|
|
214
261
|
_outputSuppressed = false;
|
|
215
262
|
} else {
|
|
216
|
-
level =
|
|
263
|
+
level = LOG_LEVEL_MAP.info;
|
|
217
264
|
_outputSuppressed = false;
|
|
218
265
|
}
|
|
266
|
+
_currentLevel = level;
|
|
219
267
|
for (const l of allLoggers) {
|
|
220
268
|
l.level = level;
|
|
221
269
|
}
|
|
@@ -280,6 +328,11 @@ export function isPlain(): boolean {
|
|
|
280
328
|
return _plainOutput;
|
|
281
329
|
}
|
|
282
330
|
|
|
331
|
+
/** Returns true when log level is debug (--verbose or --log-level debug). */
|
|
332
|
+
export function isDebugLevel(): boolean {
|
|
333
|
+
return _currentLevel >= LOG_LEVEL_MAP.debug;
|
|
334
|
+
}
|
|
335
|
+
|
|
283
336
|
/**
|
|
284
337
|
* Handle a caught error in a CLI command action. If it's a `MiravoError`,
|
|
285
338
|
* log the message and fix string and set a non-zero exit code. Otherwise
|