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
|
@@ -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.blue,
|
|
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
|
package/src/output.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Uses consola/utils for ANSI-aware width calculation.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import type { AdapterHealthSnapshot, AdapterMetrics } from "@miravo/shared";
|
|
7
|
+
import { colors, stripAnsi } from "consola/utils";
|
|
7
8
|
|
|
8
9
|
/** A single row in a table, where each cell is a string value keyed by column key. */
|
|
9
10
|
interface TableRow {
|
|
@@ -115,3 +116,42 @@ export function formatDuration(ms: number): string {
|
|
|
115
116
|
|
|
116
117
|
return parts.join(" ");
|
|
117
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Format the health suffix for an adapter line in pretty/plain CLI output.
|
|
122
|
+
* Returns a string like: " · State: healthy" or " · Pending: 18,420 · State: saturated"
|
|
123
|
+
*/
|
|
124
|
+
export function formatHealthSuffix(metrics: AdapterMetrics, sep: string): string {
|
|
125
|
+
const health = metrics.health;
|
|
126
|
+
if (!health) return "";
|
|
127
|
+
|
|
128
|
+
let suffix = "";
|
|
129
|
+
|
|
130
|
+
if (health.pendingWorkCount > 0) {
|
|
131
|
+
suffix += `${sep}Pending: ${colors.yellow(formatCount(health.pendingWorkCount))}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
suffix += `${sep}State: ${healthStateColor(health.state)}`;
|
|
135
|
+
|
|
136
|
+
return suffix;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function healthStateColor(state: AdapterHealthSnapshot["state"]): string {
|
|
140
|
+
switch (state) {
|
|
141
|
+
case "healthy":
|
|
142
|
+
return colors.green(state);
|
|
143
|
+
case "busy":
|
|
144
|
+
return colors.yellow(state);
|
|
145
|
+
case "saturated":
|
|
146
|
+
return colors.red(state);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Compute average throughput over the entire run.
|
|
152
|
+
* Returns null when there's no meaningful data (zero uptime or zero messages).
|
|
153
|
+
*/
|
|
154
|
+
export function computeAverageThroughput(totalMessages: number, uptimeMs: number): number | null {
|
|
155
|
+
if (uptimeMs <= 0 || totalMessages === 0) return null;
|
|
156
|
+
return Math.round(totalMessages / (uptimeMs / 1_000));
|
|
157
|
+
}
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import type { EngineCoreMetrics, EngineStateSnapshot } from "@miravo/core";
|
|
3
|
-
import { startHealthServer } from "./health-server.ts";
|
|
4
|
-
|
|
5
|
-
function createMockHealthEngine(initialState: EngineCoreMetrics["state"], tickIntervalMs: number) {
|
|
6
|
-
const runtime = {
|
|
7
|
-
state: initialState as EngineCoreMetrics["state"],
|
|
8
|
-
tickCount: 0,
|
|
9
|
-
lastTickProgressAt: Date.now(),
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
const snapshot: EngineStateSnapshot = {
|
|
13
|
-
simulationName: "test-sim",
|
|
14
|
-
models: [],
|
|
15
|
-
instances: [],
|
|
16
|
-
config: {
|
|
17
|
-
seed: 1,
|
|
18
|
-
tickIntervalMs,
|
|
19
|
-
timeMultiplier: 1,
|
|
20
|
-
adapters: [],
|
|
21
|
-
},
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
return {
|
|
25
|
-
engine: {
|
|
26
|
-
getMetrics: () => ({
|
|
27
|
-
core: {
|
|
28
|
-
state: runtime.state,
|
|
29
|
-
tickCount: runtime.tickCount,
|
|
30
|
-
simulationTimeSeconds: 0,
|
|
31
|
-
instanceCount: 1,
|
|
32
|
-
unsEntryCount: 1,
|
|
33
|
-
quarantinedInstanceCount: 0,
|
|
34
|
-
engineErrorsCount: 0,
|
|
35
|
-
tickOverrunsCount: 0,
|
|
36
|
-
circuitBreakerTripsCount: 0,
|
|
37
|
-
uptimeMs: 0,
|
|
38
|
-
actualTicksPerSecond: 1,
|
|
39
|
-
},
|
|
40
|
-
}),
|
|
41
|
-
getState: () => snapshot,
|
|
42
|
-
},
|
|
43
|
-
setState(state: EngineCoreMetrics["state"]) {
|
|
44
|
-
runtime.state = state;
|
|
45
|
-
},
|
|
46
|
-
setTickCount(tickCount: number) {
|
|
47
|
-
runtime.tickCount = tickCount;
|
|
48
|
-
},
|
|
49
|
-
setLastTickProgressAt(timestamp: number) {
|
|
50
|
-
runtime.lastTickProgressAt = timestamp;
|
|
51
|
-
},
|
|
52
|
-
getLastTickProgressAt() {
|
|
53
|
-
return runtime.lastTickProgressAt;
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function serverBaseUrl(endpoint: string): string {
|
|
59
|
-
const url = new URL(endpoint);
|
|
60
|
-
return `http://127.0.0.1:${url.port}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
interface StartServerOptions {
|
|
64
|
-
readonly state: EngineCoreMetrics["state"];
|
|
65
|
-
readonly tickIntervalMs?: number;
|
|
66
|
-
readonly mqttEnabled: boolean;
|
|
67
|
-
readonly isMqttConnected: () => boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function startTestServer(options: StartServerOptions, handles: Array<{ stop: () => void }>) {
|
|
71
|
-
const mock = createMockHealthEngine(options.state, options.tickIntervalMs ?? 1000);
|
|
72
|
-
const handle = startHealthServer({
|
|
73
|
-
engine: mock.engine,
|
|
74
|
-
host: "127.0.0.1",
|
|
75
|
-
port: 0,
|
|
76
|
-
mqttEnabled: options.mqttEnabled,
|
|
77
|
-
isMqttConnected: options.isMqttConnected,
|
|
78
|
-
getLastTickProgressAt: () => mock.getLastTickProgressAt(),
|
|
79
|
-
});
|
|
80
|
-
handles.push(handle);
|
|
81
|
-
const baseUrl = serverBaseUrl(handle.endpoint);
|
|
82
|
-
return { mock, baseUrl };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
describe("health server", () => {
|
|
86
|
-
const handles: Array<{ stop: () => void }> = [];
|
|
87
|
-
|
|
88
|
-
afterEach(() => {
|
|
89
|
-
for (const handle of handles) {
|
|
90
|
-
handle.stop();
|
|
91
|
-
}
|
|
92
|
-
handles.length = 0;
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test.each([
|
|
96
|
-
{
|
|
97
|
-
label: "200 when running and mqtt connected",
|
|
98
|
-
state: "running" as const,
|
|
99
|
-
healthStatus: 200,
|
|
100
|
-
readyStatus: 200,
|
|
101
|
-
},
|
|
102
|
-
{ label: "503 when paused", state: "paused" as const, healthStatus: 200, readyStatus: 503 },
|
|
103
|
-
])("/readyz returns $label", async ({ state, healthStatus, readyStatus }) => {
|
|
104
|
-
const { baseUrl } = startTestServer(
|
|
105
|
-
{ state, mqttEnabled: true, isMqttConnected: () => true },
|
|
106
|
-
handles,
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
const health = await fetch(`${baseUrl}/healthz`);
|
|
110
|
-
const ready = await fetch(`${baseUrl}/readyz`);
|
|
111
|
-
|
|
112
|
-
expect(health.status).toBe(healthStatus);
|
|
113
|
-
expect(ready.status).toBe(readyStatus);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("/readyz returns 503 when mqtt is enabled but disconnected", async () => {
|
|
117
|
-
const { baseUrl } = startTestServer(
|
|
118
|
-
{ state: "running", mqttEnabled: true, isMqttConnected: () => false },
|
|
119
|
-
handles,
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
const ready = await fetch(`${baseUrl}/readyz`);
|
|
123
|
-
expect(ready.status).toBe(503);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("/readyz ignores mqtt when mqtt is disabled", async () => {
|
|
127
|
-
const { baseUrl } = startTestServer(
|
|
128
|
-
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
129
|
-
handles,
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
const ready = await fetch(`${baseUrl}/readyz`);
|
|
133
|
-
expect(ready.status).toBe(200);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("/healthz returns 503 when state is stopped", async () => {
|
|
137
|
-
const { baseUrl } = startTestServer(
|
|
138
|
-
{ state: "stopped", mqttEnabled: false, isMqttConnected: () => false },
|
|
139
|
-
handles,
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
const health = await fetch(`${baseUrl}/healthz`);
|
|
143
|
-
expect(health.status).toBe(503);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("/healthz returns 503 on the first probe after running tick loop stalls", async () => {
|
|
147
|
-
const { mock, baseUrl } = startTestServer(
|
|
148
|
-
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
149
|
-
handles,
|
|
150
|
-
);
|
|
151
|
-
mock.setLastTickProgressAt(Date.now() - 3_500);
|
|
152
|
-
|
|
153
|
-
const stalled = await fetch(`${baseUrl}/healthz`);
|
|
154
|
-
expect(stalled.status).toBe(503);
|
|
155
|
-
|
|
156
|
-
mock.setLastTickProgressAt(Date.now());
|
|
157
|
-
mock.setTickCount(1);
|
|
158
|
-
const recovered = await fetch(`${baseUrl}/healthz`);
|
|
159
|
-
expect(recovered.status).toBe(200);
|
|
160
|
-
});
|
|
161
|
-
});
|
package/src/lib/health-server.ts
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import type { EngineCoreMetrics, EngineStateSnapshot } from "@miravo/core";
|
|
2
|
-
|
|
3
|
-
interface HealthEngine {
|
|
4
|
-
getMetrics(): { readonly core: EngineCoreMetrics };
|
|
5
|
-
getState(): EngineStateSnapshot;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
interface HealthServerOptions {
|
|
9
|
-
readonly engine: HealthEngine;
|
|
10
|
-
readonly host: string;
|
|
11
|
-
readonly port: number;
|
|
12
|
-
readonly mqttEnabled: boolean;
|
|
13
|
-
readonly isMqttConnected: () => boolean;
|
|
14
|
-
readonly getLastTickProgressAt: () => number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface HealthServerStatus {
|
|
18
|
-
readonly status: "ok" | "fail";
|
|
19
|
-
readonly reason?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface HealthServerHandle {
|
|
23
|
-
readonly endpoint: string;
|
|
24
|
-
stop(): void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface HealthResponseBody {
|
|
28
|
-
readonly status: "ok" | "fail";
|
|
29
|
-
readonly simulationState: EngineCoreMetrics["state"];
|
|
30
|
-
readonly tickCount: number;
|
|
31
|
-
readonly mqttEnabled: boolean;
|
|
32
|
-
readonly mqttConnected: boolean | null;
|
|
33
|
-
readonly stalled: boolean;
|
|
34
|
-
readonly reason?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function toJsonResponse(body: HealthResponseBody, status: number): Response {
|
|
38
|
-
return new Response(JSON.stringify(body), {
|
|
39
|
-
status,
|
|
40
|
-
headers: {
|
|
41
|
-
"content-type": "application/json; charset=utf-8",
|
|
42
|
-
"cache-control": "no-store",
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function livenessStatus(input: {
|
|
48
|
-
readonly core: EngineCoreMetrics;
|
|
49
|
-
readonly stalled: boolean;
|
|
50
|
-
}): HealthServerStatus {
|
|
51
|
-
if (input.core.state === "stopped") {
|
|
52
|
-
return { status: "fail", reason: 'engine state is "stopped"' };
|
|
53
|
-
}
|
|
54
|
-
if (input.stalled) {
|
|
55
|
-
return { status: "fail", reason: "tick loop stalled" };
|
|
56
|
-
}
|
|
57
|
-
return { status: "ok" };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function readinessStatus(input: {
|
|
61
|
-
readonly core: EngineCoreMetrics;
|
|
62
|
-
readonly stalled: boolean;
|
|
63
|
-
readonly mqttEnabled: boolean;
|
|
64
|
-
readonly mqttConnected: boolean;
|
|
65
|
-
}): HealthServerStatus {
|
|
66
|
-
if (input.core.state !== "running") {
|
|
67
|
-
return { status: "fail", reason: `engine state is "${input.core.state}"` };
|
|
68
|
-
}
|
|
69
|
-
if (input.stalled) {
|
|
70
|
-
return { status: "fail", reason: "tick loop stalled" };
|
|
71
|
-
}
|
|
72
|
-
if (input.mqttEnabled && !input.mqttConnected) {
|
|
73
|
-
return { status: "fail", reason: "mqtt disconnected" };
|
|
74
|
-
}
|
|
75
|
-
return { status: "ok" };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Start a lightweight HTTP health server for container probes.
|
|
80
|
-
*
|
|
81
|
-
* - `/healthz`: liveness (process/tick loop health)
|
|
82
|
-
* - `/readyz`: readiness (engine running + MQTT connected when enabled)
|
|
83
|
-
*/
|
|
84
|
-
function startHealthServer(options: HealthServerOptions): HealthServerHandle {
|
|
85
|
-
const { engine, host, port, mqttEnabled, isMqttConnected, getLastTickProgressAt } = options;
|
|
86
|
-
|
|
87
|
-
const server = Bun.serve({
|
|
88
|
-
hostname: host,
|
|
89
|
-
port,
|
|
90
|
-
fetch(request) {
|
|
91
|
-
const pathname = new URL(request.url).pathname;
|
|
92
|
-
const metrics = engine.getMetrics();
|
|
93
|
-
const state = engine.getState();
|
|
94
|
-
|
|
95
|
-
const now = Date.now();
|
|
96
|
-
const stallThresholdMs = Math.max(100, state.config.tickIntervalMs * 3);
|
|
97
|
-
const lastTickProgressAt = getLastTickProgressAt();
|
|
98
|
-
const stalled =
|
|
99
|
-
metrics.core.state === "running" &&
|
|
100
|
-
Number.isFinite(lastTickProgressAt) &&
|
|
101
|
-
now - lastTickProgressAt > stallThresholdMs;
|
|
102
|
-
|
|
103
|
-
const mqttConnected = mqttEnabled ? isMqttConnected() : false;
|
|
104
|
-
const bodyBase = {
|
|
105
|
-
simulationState: metrics.core.state,
|
|
106
|
-
tickCount: metrics.core.tickCount,
|
|
107
|
-
mqttEnabled,
|
|
108
|
-
mqttConnected: mqttEnabled ? mqttConnected : null,
|
|
109
|
-
stalled,
|
|
110
|
-
} as const;
|
|
111
|
-
|
|
112
|
-
if (pathname === "/healthz") {
|
|
113
|
-
const status = livenessStatus({ core: metrics.core, stalled });
|
|
114
|
-
return toJsonResponse(
|
|
115
|
-
{
|
|
116
|
-
status: status.status,
|
|
117
|
-
...bodyBase,
|
|
118
|
-
...(status.reason ? { reason: status.reason } : {}),
|
|
119
|
-
},
|
|
120
|
-
status.status === "ok" ? 200 : 503,
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (pathname === "/readyz") {
|
|
125
|
-
const status = readinessStatus({
|
|
126
|
-
core: metrics.core,
|
|
127
|
-
stalled,
|
|
128
|
-
mqttEnabled,
|
|
129
|
-
mqttConnected,
|
|
130
|
-
});
|
|
131
|
-
return toJsonResponse(
|
|
132
|
-
{
|
|
133
|
-
status: status.status,
|
|
134
|
-
...bodyBase,
|
|
135
|
-
...(status.reason ? { reason: status.reason } : {}),
|
|
136
|
-
},
|
|
137
|
-
status.status === "ok" ? 200 : 503,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return new Response("Not Found", { status: 404 });
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
endpoint: `http://${host}:${server.port}`,
|
|
147
|
-
stop() {
|
|
148
|
-
server.stop(true);
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export { startHealthServer };
|
|
154
|
-
export type { HealthEngine, HealthServerHandle, HealthServerOptions };
|