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
|
@@ -16,6 +16,7 @@ describe("miravo status", () => {
|
|
|
16
16
|
expect(optionNames).toContain("--watch");
|
|
17
17
|
expect(optionNames).toContain("--instance");
|
|
18
18
|
expect(optionNames).toContain("--json");
|
|
19
|
+
expect(optionNames).toContain("--admin-url");
|
|
19
20
|
expect(optionNames).toContain("--template");
|
|
20
21
|
});
|
|
21
22
|
|
|
@@ -28,32 +29,12 @@ describe("miravo status", () => {
|
|
|
28
29
|
expect(watchOpt?.short).toBe("-w");
|
|
29
30
|
});
|
|
30
31
|
|
|
31
|
-
test("sets exit code 1
|
|
32
|
+
test("sets exit code 1 with invalid admin URL", async () => {
|
|
32
33
|
const { program } = await findCliCommand("status");
|
|
33
34
|
program.exitOverride();
|
|
34
35
|
|
|
35
36
|
try {
|
|
36
|
-
await program.parseAsync(["node", "miravo", "status"]);
|
|
37
|
-
} catch {
|
|
38
|
-
// Expected: connection error
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Should fail because no broker is running on default port
|
|
42
|
-
expect(process.exitCode).toBe(1);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("sets exit code 1 with bad template path", async () => {
|
|
46
|
-
const { program } = await findCliCommand("status");
|
|
47
|
-
program.exitOverride();
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
await program.parseAsync([
|
|
51
|
-
"node",
|
|
52
|
-
"miravo",
|
|
53
|
-
"status",
|
|
54
|
-
"--template",
|
|
55
|
-
"/nonexistent.miravo.yaml",
|
|
56
|
-
]);
|
|
37
|
+
await program.parseAsync(["node", "miravo", "status", "--admin-url", "not-a-url"]);
|
|
57
38
|
} catch {
|
|
58
39
|
// Expected
|
|
59
40
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CONTROL_STATUS_TIMEOUT_MS,
|
|
3
3
|
type EngineMetricsSnapshot,
|
|
4
|
-
EngineMetricsSnapshotSchema,
|
|
5
4
|
type EngineStateSnapshot,
|
|
6
|
-
|
|
5
|
+
MiravoError,
|
|
7
6
|
} from "@miravo/shared";
|
|
8
7
|
import type { Command } from "commander";
|
|
9
8
|
import { colors } from "consola/utils";
|
|
10
|
-
import
|
|
9
|
+
import { getAdminSnapshot, resolveAdminUrl } from "../lib/admin-client.ts";
|
|
11
10
|
import { connectToBroker, disconnectClient } from "../lib/mqtt-client.ts";
|
|
12
11
|
import { parseDurationMs } from "../lib/parse.ts";
|
|
13
|
-
import {
|
|
14
|
-
discoverRunningSimulations,
|
|
15
|
-
resolveTargetSimulation,
|
|
16
|
-
type SimulationTopics,
|
|
17
|
-
} from "../lib/resolve-simulation.ts";
|
|
12
|
+
import { discoverRunningSimulations } from "../lib/resolve-simulation.ts";
|
|
18
13
|
import { engineLog, handleCommandError, isPlain, writeOutput } from "../logger.ts";
|
|
19
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
formatCount,
|
|
16
|
+
formatDuration,
|
|
17
|
+
formatHealthSuffix,
|
|
18
|
+
formatKeyValue,
|
|
19
|
+
formatTable,
|
|
20
|
+
} from "../output.ts";
|
|
21
|
+
|
|
22
|
+
const STATUS_WATCH_INTERVAL_MS = 1000;
|
|
20
23
|
|
|
21
24
|
interface StatusOptions {
|
|
22
25
|
readonly name?: string;
|
|
@@ -24,13 +27,14 @@ interface StatusOptions {
|
|
|
24
27
|
readonly watch?: boolean;
|
|
25
28
|
readonly instance?: string;
|
|
26
29
|
readonly json?: boolean;
|
|
30
|
+
readonly adminUrl?: string;
|
|
27
31
|
readonly template?: string;
|
|
28
32
|
readonly timeout?: number;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
36
|
* Register the `miravo status` command with the root Commander program.
|
|
33
|
-
* Shows status of a running simulation via the
|
|
37
|
+
* Shows status of a running simulation via the admin HTTP server.
|
|
34
38
|
*/
|
|
35
39
|
export function registerStatusCommand(program: Command): void {
|
|
36
40
|
program
|
|
@@ -38,16 +42,17 @@ export function registerStatusCommand(program: Command): void {
|
|
|
38
42
|
.description("Show status of a running simulation")
|
|
39
43
|
.option("--name <name>", "Target simulation name")
|
|
40
44
|
.option("--discover", "Discover running simulations and exit")
|
|
41
|
-
.option("-w, --watch", "
|
|
45
|
+
.option("-w, --watch", "Poll the admin server and re-render on updates")
|
|
42
46
|
.option("--instance <id>", "Show detailed status for one instance")
|
|
43
47
|
.option("--json", "Machine-readable JSON output")
|
|
48
|
+
.option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
|
|
44
49
|
.option(
|
|
45
50
|
"--template <path>",
|
|
46
|
-
"Path to .miravo.yaml for
|
|
51
|
+
"Path to .miravo.yaml for MQTT discovery connection info (env: MIRAVO_TEMPLATE)",
|
|
47
52
|
)
|
|
48
53
|
.option(
|
|
49
54
|
"--timeout <ms>",
|
|
50
|
-
"
|
|
55
|
+
"Admin request timeout in milliseconds (default: 5000)",
|
|
51
56
|
parseDurationMs,
|
|
52
57
|
)
|
|
53
58
|
.action(async (options: StatusOptions) => {
|
|
@@ -55,93 +60,70 @@ export function registerStatusCommand(program: Command): void {
|
|
|
55
60
|
});
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
// Types for parsed control plane messages
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
|
|
62
63
|
type MetricsSnapshot = EngineMetricsSnapshot;
|
|
63
64
|
type StateSnapshot = EngineStateSnapshot;
|
|
64
65
|
|
|
65
|
-
function parseMetricsSnapshot(payload: unknown): MetricsSnapshot | null {
|
|
66
|
-
const parsed = EngineMetricsSnapshotSchema.safeParse(payload);
|
|
67
|
-
return parsed.success ? (parsed.data as MetricsSnapshot) : null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function parseStateSnapshot(payload: unknown): StateSnapshot | null {
|
|
71
|
-
const parsed = EngineStateSnapshotSchema.safeParse(payload);
|
|
72
|
-
return parsed.success ? (parsed.data as StateSnapshot) : null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
66
|
// ---------------------------------------------------------------------------
|
|
76
67
|
// Action
|
|
77
68
|
// ---------------------------------------------------------------------------
|
|
78
69
|
|
|
79
70
|
async function statusAction(options: StatusOptions): Promise<void> {
|
|
80
71
|
try {
|
|
81
|
-
const templateOption = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
|
|
82
72
|
const timeoutMs = options.timeout ?? CONTROL_STATUS_TIMEOUT_MS;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const simulations = await discoverRunningSimulations(client, {
|
|
88
|
-
timeoutMs,
|
|
89
|
-
brokerUrl: brokerInfo.url,
|
|
90
|
-
});
|
|
91
|
-
if (simulations.length === 0) {
|
|
92
|
-
engineLog.error(
|
|
93
|
-
`No running simulations found on broker ${brokerInfo.url}. Start one with 'miravo start'.`,
|
|
94
|
-
);
|
|
95
|
-
process.exitCode = 1;
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
writeOutput(`Found ${simulations.length} simulation(s):`);
|
|
99
|
-
for (const simulationName of simulations) {
|
|
100
|
-
writeOutput(` ${simulationName}`);
|
|
101
|
-
}
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const target = await resolveTargetSimulation(client, {
|
|
106
|
-
timeoutMs,
|
|
107
|
-
brokerUrl: brokerInfo.url,
|
|
108
|
-
...(options.name !== undefined ? { name: options.name } : {}),
|
|
109
|
-
});
|
|
73
|
+
if (options.discover) {
|
|
74
|
+
await discoverMode(options, timeoutMs);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
110
77
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
} finally {
|
|
117
|
-
if (!options.watch) {
|
|
118
|
-
disconnectClient(client);
|
|
119
|
-
}
|
|
78
|
+
const adminUrl = resolveAdminUrl(options.adminUrl);
|
|
79
|
+
if (options.watch) {
|
|
80
|
+
await watchMode(adminUrl, options, timeoutMs);
|
|
81
|
+
} else {
|
|
82
|
+
await snapshotMode(adminUrl, options, timeoutMs);
|
|
120
83
|
}
|
|
121
84
|
} catch (error) {
|
|
122
85
|
handleCommandError(error, engineLog);
|
|
123
86
|
}
|
|
124
87
|
}
|
|
125
88
|
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Discover mode (MQTT-only)
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
async function discoverMode(options: StatusOptions, timeoutMs: number): Promise<void> {
|
|
94
|
+
const templateOption = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
|
|
95
|
+
const { client, brokerInfo } = await connectToBroker(templateOption, timeoutMs);
|
|
96
|
+
try {
|
|
97
|
+
const simulations = await discoverRunningSimulations(client, {
|
|
98
|
+
timeoutMs,
|
|
99
|
+
brokerUrl: brokerInfo.url,
|
|
100
|
+
});
|
|
101
|
+
if (simulations.length === 0) {
|
|
102
|
+
engineLog.error(
|
|
103
|
+
`No running simulations found on broker ${brokerInfo.url}. Start one with 'miravo start'.`,
|
|
104
|
+
);
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
writeOutput(`Found ${simulations.length} simulation(s):`);
|
|
109
|
+
for (const simulationName of simulations) {
|
|
110
|
+
writeOutput(` ${simulationName}`);
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
disconnectClient(client);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
126
117
|
// ---------------------------------------------------------------------------
|
|
127
118
|
// Snapshot mode (default): get one reading and exit
|
|
128
119
|
// ---------------------------------------------------------------------------
|
|
129
120
|
|
|
130
121
|
async function snapshotMode(
|
|
131
|
-
|
|
132
|
-
brokerUrl: string,
|
|
122
|
+
adminUrl: string,
|
|
133
123
|
options: StatusOptions,
|
|
134
124
|
timeoutMs: number,
|
|
135
|
-
target: { readonly simulationName: string; readonly topics: SimulationTopics },
|
|
136
125
|
): Promise<void> {
|
|
137
|
-
const data = await
|
|
138
|
-
client,
|
|
139
|
-
brokerUrl,
|
|
140
|
-
timeoutMs,
|
|
141
|
-
target.topics,
|
|
142
|
-
target.simulationName,
|
|
143
|
-
);
|
|
144
|
-
if (!data) return;
|
|
126
|
+
const data = await getStatusSnapshot(adminUrl, timeoutMs, options.name);
|
|
145
127
|
|
|
146
128
|
if (options.json) {
|
|
147
129
|
const filtered = filterForJson(data.metrics, data.state, options.instance);
|
|
@@ -164,79 +146,33 @@ async function snapshotMode(
|
|
|
164
146
|
// ---------------------------------------------------------------------------
|
|
165
147
|
|
|
166
148
|
async function watchMode(
|
|
167
|
-
|
|
149
|
+
adminUrl: string,
|
|
168
150
|
options: StatusOptions,
|
|
169
151
|
timeoutMs: number,
|
|
170
|
-
target: { readonly simulationName: string; readonly topics: SimulationTopics },
|
|
171
152
|
): Promise<void> {
|
|
172
|
-
let latestMetrics: MetricsSnapshot | null = null;
|
|
173
|
-
let latestState: StateSnapshot | null = null;
|
|
174
153
|
let firstRender = true;
|
|
154
|
+
let stopped = false;
|
|
155
|
+
const stop = (): void => {
|
|
156
|
+
stopped = true;
|
|
157
|
+
};
|
|
175
158
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
client.subscribe(target.topics.state, { qos: 0 });
|
|
179
|
-
|
|
180
|
-
// Wait for initial data
|
|
181
|
-
const initial = await waitForDataFromSubscribed(client, timeoutMs, target.topics);
|
|
182
|
-
if (!initial) {
|
|
183
|
-
disconnectClient(client);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
159
|
+
process.once("SIGINT", stop);
|
|
160
|
+
process.once("SIGTERM", stop);
|
|
186
161
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
firstRender = false;
|
|
193
|
-
|
|
194
|
-
const onMessage = (topic: string, message: Buffer): void => {
|
|
195
|
-
try {
|
|
196
|
-
const parsed = JSON.parse(message.toString()) as unknown;
|
|
197
|
-
if (topic === target.topics.metrics) {
|
|
198
|
-
const metrics = parseMetricsSnapshot(parsed);
|
|
199
|
-
if (!metrics) return;
|
|
200
|
-
latestMetrics = metrics;
|
|
201
|
-
} else if (topic === target.topics.state) {
|
|
202
|
-
const state = parseStateSnapshot(parsed);
|
|
203
|
-
if (!state) return;
|
|
204
|
-
latestState = state;
|
|
205
|
-
} else {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
162
|
+
try {
|
|
163
|
+
while (!stopped) {
|
|
164
|
+
const snapshot = await getStatusSnapshot(adminUrl, timeoutMs, options.name);
|
|
165
|
+
renderFrame(snapshot.metrics, snapshot.state, options, firstRender);
|
|
166
|
+
firstRender = false;
|
|
208
167
|
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
firstRender = false;
|
|
168
|
+
if (!stopped) {
|
|
169
|
+
await Bun.sleep(STATUS_WATCH_INTERVAL_MS);
|
|
212
170
|
}
|
|
213
|
-
} catch {
|
|
214
|
-
// Ignore parse errors in watch mode
|
|
215
171
|
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const cleanup = (): void => {
|
|
221
|
-
if (cleaned) return;
|
|
222
|
-
cleaned = true;
|
|
223
|
-
client.off("message", onMessage);
|
|
224
|
-
client.off("close", cleanup);
|
|
225
|
-
client.unsubscribe(target.topics.metrics);
|
|
226
|
-
client.unsubscribe(target.topics.state);
|
|
227
|
-
process.off("SIGINT", cleanup);
|
|
228
|
-
process.off("SIGTERM", cleanup);
|
|
229
|
-
disconnectClient(client);
|
|
230
|
-
const code =
|
|
231
|
-
typeof process.exitCode === "number" && process.exitCode !== 0 ? process.exitCode : 0;
|
|
232
|
-
process.exit(code);
|
|
233
|
-
};
|
|
234
|
-
process.once("SIGINT", cleanup);
|
|
235
|
-
process.once("SIGTERM", cleanup);
|
|
236
|
-
client.on("close", cleanup);
|
|
237
|
-
|
|
238
|
-
// Continue rendering on each new message
|
|
239
|
-
client.on("message", onMessage);
|
|
172
|
+
} finally {
|
|
173
|
+
process.off("SIGINT", stop);
|
|
174
|
+
process.off("SIGTERM", stop);
|
|
175
|
+
}
|
|
240
176
|
}
|
|
241
177
|
|
|
242
178
|
function renderFrame(
|
|
@@ -305,143 +241,19 @@ function filterForJson(
|
|
|
305
241
|
};
|
|
306
242
|
}
|
|
307
243
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
// ---------------------------------------------------------------------------
|
|
311
|
-
|
|
312
|
-
interface ControlPlaneData {
|
|
313
|
-
readonly metrics: MetricsSnapshot;
|
|
314
|
-
readonly state: StateSnapshot;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
interface WaitForDataOptions {
|
|
318
|
-
readonly client: MqttClient;
|
|
319
|
-
readonly timeoutMs: number;
|
|
320
|
-
readonly topics: SimulationTopics;
|
|
321
|
-
/** Error message shown when timeout expires. */
|
|
322
|
-
readonly timeoutMessage: string;
|
|
323
|
-
/** When true, subscribe/unsubscribe to control topics. When false, caller manages subscriptions. */
|
|
324
|
-
readonly manageSubscriptions: boolean;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function waitForData(
|
|
328
|
-
client: MqttClient,
|
|
329
|
-
brokerUrl: string,
|
|
244
|
+
async function getStatusSnapshot(
|
|
245
|
+
adminUrl: string,
|
|
330
246
|
timeoutMs: number,
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function waitForDataFromSubscribed(
|
|
344
|
-
client: MqttClient,
|
|
345
|
-
timeoutMs: number,
|
|
346
|
-
topics: SimulationTopics,
|
|
347
|
-
): Promise<ControlPlaneData | null> {
|
|
348
|
-
return waitForControlPlaneData({
|
|
349
|
-
client,
|
|
350
|
-
timeoutMs,
|
|
351
|
-
topics,
|
|
352
|
-
timeoutMessage: "No data received from simulation. Is the control plane active?",
|
|
353
|
-
manageSubscriptions: false,
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function waitForControlPlaneData(opts: WaitForDataOptions): Promise<ControlPlaneData | null> {
|
|
358
|
-
const { client, timeoutMs, timeoutMessage, manageSubscriptions, topics } = opts;
|
|
359
|
-
|
|
360
|
-
return new Promise((resolve) => {
|
|
361
|
-
let metrics: MetricsSnapshot | null = null;
|
|
362
|
-
let metricsFromRetained = false;
|
|
363
|
-
let state: StateSnapshot | null = null;
|
|
364
|
-
let stateFromRetained = false;
|
|
365
|
-
let retainedFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
366
|
-
let settled = false;
|
|
367
|
-
|
|
368
|
-
const retainedFallbackMs = Math.max(100, Math.min(750, Math.floor(timeoutMs / 4)));
|
|
369
|
-
|
|
370
|
-
const cleanup = (): void => {
|
|
371
|
-
clearTimeout(timer);
|
|
372
|
-
if (retainedFallbackTimer) {
|
|
373
|
-
clearTimeout(retainedFallbackTimer);
|
|
374
|
-
retainedFallbackTimer = null;
|
|
375
|
-
}
|
|
376
|
-
client.off("message", handler);
|
|
377
|
-
if (manageSubscriptions) {
|
|
378
|
-
client.unsubscribe(topics.metrics);
|
|
379
|
-
client.unsubscribe(topics.state);
|
|
380
|
-
}
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
const settle = (value: ControlPlaneData | null): void => {
|
|
384
|
-
if (settled) return;
|
|
385
|
-
settled = true;
|
|
386
|
-
cleanup();
|
|
387
|
-
resolve(value);
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
const maybeResolve = (): void => {
|
|
391
|
-
if (!metrics || !state) return;
|
|
392
|
-
|
|
393
|
-
if (!metricsFromRetained && !stateFromRetained) {
|
|
394
|
-
settle({ metrics, state });
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
if (retainedFallbackTimer) return;
|
|
399
|
-
|
|
400
|
-
// Prefer fresh non-retained payloads when available, but don't block
|
|
401
|
-
// indefinitely if only retained snapshots are present.
|
|
402
|
-
retainedFallbackTimer = setTimeout(() => {
|
|
403
|
-
if (metrics && state) {
|
|
404
|
-
settle({ metrics, state });
|
|
405
|
-
}
|
|
406
|
-
}, retainedFallbackMs);
|
|
407
|
-
};
|
|
408
|
-
|
|
409
|
-
const timer = setTimeout(() => {
|
|
410
|
-
if (settled) return;
|
|
411
|
-
settled = true;
|
|
412
|
-
cleanup();
|
|
413
|
-
engineLog.error(timeoutMessage);
|
|
414
|
-
process.exitCode = 1;
|
|
415
|
-
resolve(null);
|
|
416
|
-
}, timeoutMs);
|
|
417
|
-
|
|
418
|
-
const handler = (topic: string, message: Buffer, packet: IPublishPacket): void => {
|
|
419
|
-
try {
|
|
420
|
-
const parsed = JSON.parse(message.toString()) as unknown;
|
|
421
|
-
if (topic === topics.metrics && (!metrics || metricsFromRetained)) {
|
|
422
|
-
const validMetrics = parseMetricsSnapshot(parsed);
|
|
423
|
-
if (!validMetrics) return;
|
|
424
|
-
metrics = validMetrics;
|
|
425
|
-
metricsFromRetained = packet.retain;
|
|
426
|
-
} else if (topic === topics.state && (!state || stateFromRetained)) {
|
|
427
|
-
const validState = parseStateSnapshot(parsed);
|
|
428
|
-
if (!validState) return;
|
|
429
|
-
state = validState;
|
|
430
|
-
stateFromRetained = packet.retain;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
maybeResolve();
|
|
434
|
-
} catch {
|
|
435
|
-
// Ignore parse errors, wait for valid data
|
|
436
|
-
}
|
|
437
|
-
};
|
|
438
|
-
|
|
439
|
-
if (manageSubscriptions) {
|
|
440
|
-
client.subscribe(topics.metrics, { qos: 0 });
|
|
441
|
-
client.subscribe(topics.state, { qos: 0 });
|
|
442
|
-
}
|
|
443
|
-
client.on("message", handler);
|
|
444
|
-
});
|
|
247
|
+
expectedName?: string,
|
|
248
|
+
): Promise<{ readonly metrics: MetricsSnapshot; readonly state: StateSnapshot }> {
|
|
249
|
+
const snapshot = await getAdminSnapshot(adminUrl, timeoutMs);
|
|
250
|
+
if (expectedName && snapshot.state.simulationName !== expectedName) {
|
|
251
|
+
throw new MiravoError(
|
|
252
|
+
`Connected to simulation "${snapshot.state.simulationName}" instead of "${expectedName}".`,
|
|
253
|
+
"Pass --admin-url for the correct Miravo process, or omit --name to inspect the connected simulation.",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return snapshot;
|
|
445
257
|
}
|
|
446
258
|
|
|
447
259
|
// ---------------------------------------------------------------------------
|
|
@@ -462,7 +274,8 @@ function renderOverview(metrics: MetricsSnapshot, state: StateSnapshot): void {
|
|
|
462
274
|
// Engine line
|
|
463
275
|
const stateColor =
|
|
464
276
|
core.state === "running" ? colors.green : core.state === "paused" ? colors.yellow : colors.red;
|
|
465
|
-
const tps =
|
|
277
|
+
const tps =
|
|
278
|
+
core.actualTicksPerSecond > 0 ? `${core.actualTicksPerSecond.toFixed(1)} ticks/s` : "";
|
|
466
279
|
|
|
467
280
|
writeOutput(
|
|
468
281
|
formatKeyValue([
|
|
@@ -472,7 +285,7 @@ function renderOverview(metrics: MetricsSnapshot, state: StateSnapshot): void {
|
|
|
472
285
|
],
|
|
473
286
|
[
|
|
474
287
|
colors.dim("Fleet"),
|
|
475
|
-
`${colors.bold(String(core.instanceCount))} instances${sep}${colors.bold(String(core.
|
|
288
|
+
`${colors.bold(String(core.instanceCount))} instances${sep}${colors.bold(String(core.memberValueCount))} data points`,
|
|
476
289
|
],
|
|
477
290
|
]),
|
|
478
291
|
);
|
|
@@ -513,11 +326,33 @@ function renderOverview(metrics: MetricsSnapshot, state: StateSnapshot): void {
|
|
|
513
326
|
mqttAdapter.metrics.errorsCount > 0
|
|
514
327
|
? colors.yellow(String(mqttAdapter.metrics.errorsCount))
|
|
515
328
|
: colors.green("0");
|
|
329
|
+
const healthStr = formatHealthSuffix(mqttAdapter.metrics, sep);
|
|
516
330
|
writeOutput(
|
|
517
331
|
formatKeyValue([
|
|
518
332
|
[
|
|
519
333
|
colors.dim("MQTT"),
|
|
520
|
-
`${connStr}${sep}${formatCount(mqttAdapter.metrics.messagesPublished)} published${sep}${errStr} errors`,
|
|
334
|
+
`${connStr}${sep}${formatCount(mqttAdapter.metrics.messagesPublished)} published${sep}${errStr} errors${healthStr}`,
|
|
335
|
+
],
|
|
336
|
+
]),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// OPC UA
|
|
341
|
+
const opcuaAdapter = metrics.adapters.find((a) => a.name === "opcua");
|
|
342
|
+
if (opcuaAdapter) {
|
|
343
|
+
const connStr = opcuaAdapter.metrics.connected
|
|
344
|
+
? colors.green("connected")
|
|
345
|
+
: colors.red("disconnected");
|
|
346
|
+
const errStr =
|
|
347
|
+
opcuaAdapter.metrics.errorsCount > 0
|
|
348
|
+
? colors.yellow(String(opcuaAdapter.metrics.errorsCount))
|
|
349
|
+
: colors.green("0");
|
|
350
|
+
const healthStr = formatHealthSuffix(opcuaAdapter.metrics, sep);
|
|
351
|
+
writeOutput(
|
|
352
|
+
formatKeyValue([
|
|
353
|
+
[
|
|
354
|
+
colors.dim("OPC UA"),
|
|
355
|
+
`${connStr}${sep}${formatCount(opcuaAdapter.metrics.messagesPublished)} updates${sep}${errStr} errors${healthStr}`,
|
|
521
356
|
],
|
|
522
357
|
]),
|
|
523
358
|
);
|
|
@@ -601,7 +436,7 @@ function renderInstanceDetail(
|
|
|
601
436
|
|
|
602
437
|
writeOutput(
|
|
603
438
|
formatKeyValue([
|
|
604
|
-
[colors.dim("UNS Path"), instance.
|
|
439
|
+
[colors.dim("UNS Path"), instance.browsePath.join("/")],
|
|
605
440
|
[
|
|
606
441
|
colors.dim("Lifecycle"),
|
|
607
442
|
`${instance.lifecycleStage} (${formatCount(Math.floor(instance.operatingHours))} operating hours)`,
|
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
3
2
|
import { resolve } from "node:path";
|
|
4
3
|
import { loadTemplate } from "@miravo/core";
|
|
5
4
|
import { createTmpFileUtils } from "@miravo/core/test-tmp-utils";
|
|
6
|
-
import { MIRAVO_CONTENT_DIR_ENV } from "@miravo/shared";
|
|
7
5
|
import { stripAnsi } from "consola/utils";
|
|
8
6
|
import { configureLogger } from "../logger.ts";
|
|
9
|
-
import { runCliCommand
|
|
7
|
+
import { runCliCommand } from "./cli-test-setup.ts";
|
|
10
8
|
import { formatTemplateInspect } from "./templates.ts";
|
|
11
9
|
|
|
12
10
|
configureLogger({ silent: true });
|
|
13
11
|
|
|
14
|
-
const templatesDir = resolve(import.meta.dir, "..", "..", "..", "
|
|
15
|
-
const {
|
|
16
|
-
resolve(import.meta.dir, ".tmp-test-templates"),
|
|
17
|
-
);
|
|
12
|
+
const templatesDir = resolve(import.meta.dir, "..", "..", "..", "content", "templates");
|
|
13
|
+
const { cleanupTmpDir } = createTmpFileUtils(resolve(import.meta.dir, ".tmp-test-templates"));
|
|
18
14
|
|
|
19
15
|
beforeEach(() => {
|
|
20
16
|
process.exitCode = 0;
|
|
@@ -34,7 +30,7 @@ describe("formatTemplateInspect", () => {
|
|
|
34
30
|
const output = stripAnsi(raw);
|
|
35
31
|
|
|
36
32
|
expect(output).toContain("Smart Factory Production Line");
|
|
37
|
-
expect(output).toContain("built-in
|
|
33
|
+
expect(output).toContain("built-in");
|
|
38
34
|
expect(output).toContain("Template ID:");
|
|
39
35
|
expect(output).toContain("smart-factory");
|
|
40
36
|
expect(output).toContain("Namespace:");
|
|
@@ -68,48 +64,6 @@ describe("miravo templates", () => {
|
|
|
68
64
|
expect(process.exitCode).toBe(0);
|
|
69
65
|
});
|
|
70
66
|
|
|
71
|
-
test("templates inspect --copy copies template and referenced built-in models", async () => {
|
|
72
|
-
await withEnvVar(MIRAVO_CONTENT_DIR_ENV, tmpDir, async () => {
|
|
73
|
-
await runCliCommand(["templates", "inspect", "smart-factory", "--copy"]);
|
|
74
|
-
|
|
75
|
-
expect(process.exitCode).toBe(0);
|
|
76
|
-
|
|
77
|
-
const copiedTemplatePath = resolve(tmpDir, "templates", "smart-factory.miravo.yaml");
|
|
78
|
-
expect(await Bun.file(copiedTemplatePath).exists()).toBe(true);
|
|
79
|
-
|
|
80
|
-
const copiedTemplate = await loadTemplate(copiedTemplatePath);
|
|
81
|
-
const inlineModels = new Set((copiedTemplate.models ?? []).map((model) => model.name));
|
|
82
|
-
const referencedModels = new Set<string>();
|
|
83
|
-
for (const area of copiedTemplate.areas) {
|
|
84
|
-
for (const line of area.lines) {
|
|
85
|
-
for (const twin of line.twins) {
|
|
86
|
-
referencedModels.add(twin.model);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
for (const modelName of referencedModels) {
|
|
92
|
-
if (inlineModels.has(modelName)) continue;
|
|
93
|
-
const copiedModelPath = resolve(tmpDir, "models", `${modelName}.twin.yaml`);
|
|
94
|
-
expect(await Bun.file(copiedModelPath).exists()).toBe(true);
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("templates inspect --copy skips existing files with a message", async () => {
|
|
100
|
-
const targetPath = resolve(tmpDir, "templates", "smart-factory.miravo.yaml");
|
|
101
|
-
await mkdir(resolve(tmpDir, "templates"), { recursive: true });
|
|
102
|
-
const dummyContent = `kind: MiravoTemplate\nversion: "1"\nname: keep-existing\nnamespace:\n prefix: test\nareas:\n - name: a\n lines:\n - name: l\n twins:\n - model: m\n count: 1\n`;
|
|
103
|
-
await Bun.write(targetPath, dummyContent);
|
|
104
|
-
|
|
105
|
-
await withEnvVar(MIRAVO_CONTENT_DIR_ENV, tmpDir, async () => {
|
|
106
|
-
await runCliCommand(["templates", "inspect", "smart-factory", "--copy"]);
|
|
107
|
-
|
|
108
|
-
expect(process.exitCode).toBe(0);
|
|
109
|
-
expect(await Bun.file(targetPath).text()).toContain("name: keep-existing");
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
67
|
test("templates list with bad directory sets exit code 1", async () => {
|
|
114
68
|
await runCliCommand(["templates", "list", "--dir", "/nonexistent/templates-dir"]);
|
|
115
69
|
expect(process.exitCode).toBe(1);
|