miravo 0.6.0 → 0.6.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/LICENSE +199 -0
- package/README.md +7 -70
- package/bin/miravo.js +84 -0
- package/package.json +20 -36
- package/console-dist/assets/activity-DsWeRq4h.js +0 -1
- package/console-dist/assets/api-4AqKvsm9.js +0 -1
- package/console-dist/assets/arrow-right-Ea_UhTNi.js +0 -1
- package/console-dist/assets/arrow-up-down-DFSjzzl2.js +0 -1
- package/console-dist/assets/blueprint-card-classes-DBFCDy0M.js +0 -1
- package/console-dist/assets/box-C1WaoOMF.js +0 -1
- package/console-dist/assets/card-BjeP32PN.js +0 -1
- package/console-dist/assets/catalog-BJzyzbLR.js +0 -1
- package/console-dist/assets/catalog-DKjEm-c4.js +0 -1
- package/console-dist/assets/catalog-template-icons-BrUeHaCY.js +0 -1
- package/console-dist/assets/checkbox-DZlJXTE4.js +0 -1
- package/console-dist/assets/chunk-OE4NN4TA-CEjpfhS6.js +0 -3
- package/console-dist/assets/combobox-BGnuCYxO.js +0 -1
- package/console-dist/assets/control-CPhvwx9o.js +0 -1
- package/console-dist/assets/control-Cg2up9NA.js +0 -1
- package/console-dist/assets/copy-button-WkbNkMcd.js +0 -1
- package/console-dist/assets/createLucideIcon-Bwxw859K.js +0 -1
- package/console-dist/assets/deploy-tab-JeWDwPoZ.js +0 -1
- package/console-dist/assets/dist-BhbDML0a.js +0 -1
- package/console-dist/assets/dist-Cy8XTNFT.js +0 -2
- package/console-dist/assets/download-DQ6NH16U.js +0 -1
- package/console-dist/assets/engine-store-CrLnz692.js +0 -1
- package/console-dist/assets/events-CXm29Ok4.js +0 -1
- package/console-dist/assets/events-D01IxhnP.js +0 -1
- package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
- package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
- package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
- package/console-dist/assets/fleet-CfxbKXt9.js +0 -1
- package/console-dist/assets/fleet-lotMGVqe.js +0 -1
- package/console-dist/assets/format-kXTonpDJ.js +0 -1
- package/console-dist/assets/gauge-Ci8zNZlk.js +0 -1
- package/console-dist/assets/get-error-message-3Q5Q-2G5.js +0 -1
- package/console-dist/assets/index-CVPKSDvJ.js +0 -12
- package/console-dist/assets/index-DJkgDeun.css +0 -2
- package/console-dist/assets/input-BWEQFptx.js +0 -1
- package/console-dist/assets/instance-detail-C5IVwuh3.js +0 -1
- package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
- package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
- package/console-dist/assets/label-uephX5ZG.js +0 -1
- package/console-dist/assets/layers-CUsLQ1fq.js +0 -1
- package/console-dist/assets/login-C3RJli0m.js +0 -126
- package/console-dist/assets/models-B_UioWQ5.js +0 -1
- package/console-dist/assets/models-_IPlPiZ7.js +0 -1
- package/console-dist/assets/not-found-B8gAm08S.js +0 -1
- package/console-dist/assets/platform-targets-R3T-4eJ7.js +0 -1
- package/console-dist/assets/platforms-BWh0BQ3Y.js +0 -1
- package/console-dist/assets/platforms-DStNbtVc.js +0 -1
- package/console-dist/assets/proxy-CsnYqnPp.js +0 -9
- package/console-dist/assets/query-keys-BvtE7BMs.js +0 -1
- package/console-dist/assets/radio-CoHegOv5.js +0 -1
- package/console-dist/assets/rolldown-runtime-COnpUsM8.js +0 -1
- package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/console-dist/assets/select-CeO4vP0G.js +0 -1
- package/console-dist/assets/select-display-items-CIzY8953.js +0 -1
- package/console-dist/assets/server-D9Kfn8fQ.js +0 -1
- package/console-dist/assets/shallow-6lGjQhtJ.js +0 -1
- package/console-dist/assets/tags-CiXOIHtY.js +0 -4
- package/console-dist/assets/tags-FQCXIxrP.js +0 -1
- package/console-dist/assets/tooltip-HGLF366Q.js +0 -1
- package/console-dist/assets/transition-nav-link-Df4Rk6vV.js +0 -1
- package/console-dist/assets/transition-types-CPlGtM8w.js +0 -1
- package/console-dist/assets/triangle-alert-adNk_sea.js +0 -1
- package/console-dist/assets/use-platform-stream-DGbK1k2L.js +0 -2
- package/console-dist/assets/use-transition-navigate-CCpcWXB0.js +0 -2
- package/console-dist/assets/validation-Blvud9_O.js +0 -1
- package/console-dist/assets/vendor-charts-CAZdaPlq.js +0 -60
- package/console-dist/assets/vendor-table-wd8JuMmT.js +0 -4
- package/console-dist/assets/vendor-ui-ByahnaY0.js +0 -1
- package/console-dist/favicon.svg +0 -37
- package/console-dist/index.html +0 -82
- package/console-dist/miravo-logo.png +0 -0
- package/src/__tests__/integration/executable-methods-runner.ts +0 -262
- package/src/__tests__/integration/executable-methods.integration.ts +0 -2348
- package/src/__tests__/integration/opcua-concurrent-clients.integration.ts +0 -314
- package/src/commands/adapter.ts +0 -249
- package/src/commands/apply.test.ts +0 -62
- package/src/commands/apply.ts +0 -216
- package/src/commands/cleanup.test.ts +0 -34
- package/src/commands/cleanup.ts +0 -201
- package/src/commands/cli-test-setup.ts +0 -91
- package/src/commands/export.test.ts +0 -51
- package/src/commands/export.ts +0 -404
- package/src/commands/fork.ts +0 -219
- package/src/commands/inject.test.ts +0 -57
- package/src/commands/inject.ts +0 -212
- package/src/commands/load.ts +0 -167
- package/src/commands/logs.ts +0 -173
- package/src/commands/models.test.ts +0 -162
- package/src/commands/models.ts +0 -338
- package/src/commands/pause.ts +0 -67
- package/src/commands/platforms.test.ts +0 -186
- package/src/commands/platforms.ts +0 -371
- package/src/commands/ps.ts +0 -116
- package/src/commands/remove.ts +0 -38
- package/src/commands/reset.ts +0 -44
- package/src/commands/rm.ts +0 -97
- package/src/commands/spawn.ts +0 -45
- package/src/commands/speed.ts +0 -40
- package/src/commands/start/adapter-config.ts +0 -157
- package/src/commands/start/catalog-le-dt.test.ts +0 -50
- package/src/commands/start/catalog-le-dt.ts +0 -187
- package/src/commands/start/catalog-model-schema.ts +0 -89
- package/src/commands/start/catalog-template-detail.ts +0 -54
- package/src/commands/start/daemon.ts +0 -255
- package/src/commands/start/display.ts +0 -150
- package/src/commands/start/engine-setup.ts +0 -247
- package/src/commands/start/events.ts +0 -126
- package/src/commands/start/index.ts +0 -897
- package/src/commands/start/protocol-plans.ts +0 -239
- package/src/commands/start/template-loader.ts +0 -197
- package/src/commands/start/template-resolver.ts +0 -185
- package/src/commands/start/types.ts +0 -73
- package/src/commands/start.test.ts +0 -480
- package/src/commands/status.test.ts +0 -20
- package/src/commands/status.ts +0 -470
- package/src/commands/stop.test.ts +0 -84
- package/src/commands/stop.ts +0 -169
- package/src/commands/templates.test.ts +0 -58
- package/src/commands/templates.ts +0 -277
- package/src/index.ts +0 -166
- package/src/lib/admin-auth.ts +0 -157
- package/src/lib/admin-client.test.ts +0 -51
- package/src/lib/admin-client.ts +0 -326
- package/src/lib/admin-platform-routes.ts +0 -1017
- package/src/lib/admin-platform-stream.test.ts +0 -434
- package/src/lib/admin-platform-stream.ts +0 -788
- package/src/lib/admin-responses.ts +0 -211
- package/src/lib/admin-server.test.ts +0 -781
- package/src/lib/admin-server.ts +0 -797
- package/src/lib/auth-store.ts +0 -87
- package/src/lib/daemon-registry.test.ts +0 -429
- package/src/lib/daemon-registry.ts +0 -343
- package/src/lib/le/le-apply.test.ts +0 -591
- package/src/lib/le/le-apply.ts +0 -832
- package/src/lib/le/le-auth.ts +0 -89
- package/src/lib/le/le-cleanup.test.ts +0 -168
- package/src/lib/le/le-cleanup.ts +0 -399
- package/src/lib/le/le-cli-helpers.ts +0 -77
- package/src/lib/le/le-client.ts +0 -184
- package/src/lib/le/le-concurrency.test.ts +0 -157
- package/src/lib/le/le-concurrency.ts +0 -78
- package/src/lib/le/le-connection-test.ts +0 -42
- package/src/lib/le/le-devicehub-properties.test.ts +0 -53
- package/src/lib/le/le-devicehub-properties.ts +0 -33
- package/src/lib/le/le-devicehub.ts +0 -213
- package/src/lib/le/le-digital-twins.ts +0 -285
- package/src/lib/le/le-drivers.ts +0 -23
- package/src/lib/le/le-endpoint.ts +0 -40
- package/src/lib/le/le-environments.test.ts +0 -73
- package/src/lib/le/le-environments.ts +0 -156
- package/src/lib/le/le-markers.ts +0 -87
- package/src/lib/le/le-plan.test.ts +0 -26
- package/src/lib/le/le-plan.ts +0 -109
- package/src/lib/le/le-platform-operations.test.ts +0 -198
- package/src/lib/le/le-platform-operations.ts +0 -340
- package/src/lib/le/le-static-model-schema.ts +0 -78
- package/src/lib/le/le-static-planner.test.ts +0 -255
- package/src/lib/le/le-static-planner.ts +0 -442
- package/src/lib/le/le-template-exporter.test.ts +0 -527
- package/src/lib/le/le-template-exporter.ts +0 -459
- package/src/lib/le/le-value-types.ts +0 -23
- package/src/lib/mqtt-client.ts +0 -115
- package/src/lib/parse.test.ts +0 -123
- package/src/lib/parse.ts +0 -87
- package/src/lib/resolve-simulation.test.ts +0 -118
- package/src/lib/resolve-simulation.ts +0 -181
- package/src/lib/session-store.ts +0 -110
- package/src/lib/shutdown.test.ts +0 -562
- package/src/lib/shutdown.ts +0 -621
- package/src/lib/template-category.test.ts +0 -71
- package/src/lib/template-category.ts +0 -72
- package/src/lib/template-locator.ts +0 -55
- package/src/lib/template-utils.ts +0 -45
- package/src/lib/ws-hub.ts +0 -281
- package/src/logger.test.ts +0 -172
- package/src/logger.ts +0 -402
- package/src/output.test.ts +0 -64
- package/src/output.ts +0 -160
|
@@ -1,1017 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Admin server platform route handlers.
|
|
3
|
-
*
|
|
4
|
-
* Handles LE environment CRUD and plan/apply/cleanup operations.
|
|
5
|
-
* Credentials are resolved server-side from ~/.miravo/platforms/environments.json.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { ContentCatalog, EventLog } from "@miravo/core";
|
|
9
|
-
import { deriveInstancePrefix } from "@miravo/core";
|
|
10
|
-
import type { ModelSchema, ProtocolName, TwinModel } from "@miravo/shared";
|
|
11
|
-
import { MiravoError, ProtocolNameSchema, TwinModelSchema } from "@miravo/shared";
|
|
12
|
-
import { parse as parseYAML } from "yaml";
|
|
13
|
-
import { z } from "zod";
|
|
14
|
-
import { buildLeDtJsonFromSchema } from "../commands/start/catalog-le-dt.ts";
|
|
15
|
-
import {
|
|
16
|
-
cancelOperation,
|
|
17
|
-
handleApplyStream,
|
|
18
|
-
handleCleanupStream,
|
|
19
|
-
} from "./admin-platform-stream.ts";
|
|
20
|
-
import { checkContentType, jsonResponse, plainResponse } from "./admin-responses.ts";
|
|
21
|
-
import type {
|
|
22
|
-
ApplyDesiredState,
|
|
23
|
-
DesiredDevice,
|
|
24
|
-
DesiredInstance,
|
|
25
|
-
DesiredModel,
|
|
26
|
-
DesiredTag,
|
|
27
|
-
ResourceProgress,
|
|
28
|
-
} from "./le/le-apply.ts";
|
|
29
|
-
import { buildApplyPlan, executePlan } from "./le/le-apply.ts";
|
|
30
|
-
import { buildCleanupPlan, executeCleanup } from "./le/le-cleanup.ts";
|
|
31
|
-
import { extractTemplateId } from "./le/le-cli-helpers.ts";
|
|
32
|
-
import { LEClient } from "./le/le-client.ts";
|
|
33
|
-
import { formatThrownError } from "./le/le-concurrency.ts";
|
|
34
|
-
import { assertSupportedEnvironmentPlatform, testLeConnection } from "./le/le-connection-test.ts";
|
|
35
|
-
import { LEDeviceHub } from "./le/le-devicehub.ts";
|
|
36
|
-
import { buildLeDeviceHubDeviceProperties } from "./le/le-devicehub-properties.ts";
|
|
37
|
-
import { LEDigitalTwins } from "./le/le-digital-twins.ts";
|
|
38
|
-
import { resolveProtocolPort } from "./le/le-endpoint.ts";
|
|
39
|
-
import type { LEEnvironment } from "./le/le-environments.ts";
|
|
40
|
-
import {
|
|
41
|
-
addEnvironment,
|
|
42
|
-
DEFAULT_PLATFORM_ID,
|
|
43
|
-
getEnvironment,
|
|
44
|
-
loadEnvironments,
|
|
45
|
-
removeEnvironment,
|
|
46
|
-
updateEnvironment,
|
|
47
|
-
} from "./le/le-environments.ts";
|
|
48
|
-
import type { ApplyScope } from "./le/le-plan.ts";
|
|
49
|
-
import {
|
|
50
|
-
appendPlatformOperation,
|
|
51
|
-
createPlatformOperationTracker,
|
|
52
|
-
listPlatformOperations,
|
|
53
|
-
} from "./le/le-platform-operations.ts";
|
|
54
|
-
import { toModelSchema } from "./le/le-static-model-schema.ts";
|
|
55
|
-
import { buildDesiredStateFromTemplate } from "./le/le-static-planner.ts";
|
|
56
|
-
import { OPCUA_VALUE_TYPE } from "./le/le-value-types.ts";
|
|
57
|
-
import { createCatalog, resolveTemplateIdentifier } from "./template-locator.ts";
|
|
58
|
-
|
|
59
|
-
// --- Zod schemas ---
|
|
60
|
-
|
|
61
|
-
const ApplyScopeSchema = z.enum(["devices", "models", "instances"]);
|
|
62
|
-
|
|
63
|
-
const PlatformApplyBodySchema = z.object({
|
|
64
|
-
environmentId: z.string().min(1),
|
|
65
|
-
template: z.string().min(1).optional(),
|
|
66
|
-
model: z.string().min(1).optional(),
|
|
67
|
-
protocol: ProtocolNameSchema.optional(),
|
|
68
|
-
scope: z.array(ApplyScopeSchema).optional(),
|
|
69
|
-
area: z.string().optional(),
|
|
70
|
-
line: z.string().optional(),
|
|
71
|
-
instance: z.string().optional(),
|
|
72
|
-
ifUnmanaged: z.enum(["fail", "skip"]).optional(),
|
|
73
|
-
/** Model apply only: Litmus Edge device / instance id (default: `{last-segment}-001`). */
|
|
74
|
-
deviceName: z.string().optional(),
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
const PlatformCleanupBodySchema = z.object({
|
|
78
|
-
environmentId: z.string().min(1),
|
|
79
|
-
template: z.string().optional(),
|
|
80
|
-
allManaged: z.boolean().optional(),
|
|
81
|
-
scope: z.array(ApplyScopeSchema).optional(),
|
|
82
|
-
area: z.string().optional(),
|
|
83
|
-
line: z.string().optional(),
|
|
84
|
-
instance: z.string().optional(),
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const CreateEnvironmentSchema = z.object({
|
|
88
|
-
platformId: z.string().min(1).default(DEFAULT_PLATFORM_ID),
|
|
89
|
-
label: z.string().min(1),
|
|
90
|
-
hostname: z.string().min(1),
|
|
91
|
-
apiToken: z.string().min(1),
|
|
92
|
-
sourceHostname: z.string().min(1),
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
const UpdateEnvironmentSchema = z.object({
|
|
96
|
-
platformId: z.string().min(1).optional(),
|
|
97
|
-
label: z.string().min(1).optional(),
|
|
98
|
-
hostname: z.string().min(1).optional(),
|
|
99
|
-
apiToken: z.string().min(1).optional(),
|
|
100
|
-
sourceHostname: z.string().min(1).optional(),
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
const TestLeConnectionSchema = z
|
|
104
|
-
.object({
|
|
105
|
-
environmentId: z.string().min(1).optional(),
|
|
106
|
-
hostname: z.string().min(1),
|
|
107
|
-
apiToken: z.string().optional(),
|
|
108
|
-
sourceHostname: z.string().min(1),
|
|
109
|
-
})
|
|
110
|
-
.superRefine((data, ctx) => {
|
|
111
|
-
if (!data.environmentId && !data.apiToken) {
|
|
112
|
-
ctx.addIssue({
|
|
113
|
-
code: z.ZodIssueCode.custom,
|
|
114
|
-
message: "apiToken is required when environmentId is omitted.",
|
|
115
|
-
path: ["apiToken"],
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
const DEFAULT_APPLY_SCOPE: ApplyScope[] = ["devices", "models", "instances"];
|
|
121
|
-
const DEFAULT_CLEANUP_SCOPE: ApplyScope[] = ["instances", "models", "devices"];
|
|
122
|
-
|
|
123
|
-
// --- Route handler ---
|
|
124
|
-
|
|
125
|
-
export async function handlePlatformRoute(
|
|
126
|
-
pathname: string,
|
|
127
|
-
request: Request,
|
|
128
|
-
checkSession: (request: Request) => Response | null,
|
|
129
|
-
eventLog?: EventLog | undefined,
|
|
130
|
-
): Promise<Response | null> {
|
|
131
|
-
if (!pathname.startsWith("/platforms/")) return null;
|
|
132
|
-
|
|
133
|
-
const sessionBlock = checkSession(request);
|
|
134
|
-
if (sessionBlock) return sessionBlock;
|
|
135
|
-
|
|
136
|
-
if (pathname === "/platforms/le/operations") {
|
|
137
|
-
if (request.method !== "GET") return plainResponse("Method Not Allowed", 405, "GET");
|
|
138
|
-
const url = new URL(request.url);
|
|
139
|
-
const type = url.searchParams.get("type");
|
|
140
|
-
const environmentId = url.searchParams.get("environmentId");
|
|
141
|
-
const limitRaw = url.searchParams.get("limit");
|
|
142
|
-
const offsetRaw = url.searchParams.get("offset");
|
|
143
|
-
|
|
144
|
-
if (type && type !== "apply" && type !== "cleanup") {
|
|
145
|
-
return jsonResponse(
|
|
146
|
-
{ message: `Invalid operation type "${type}".`, error: "Valid types: apply, cleanup" },
|
|
147
|
-
400,
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
const operationType = type === "apply" || type === "cleanup" ? type : undefined;
|
|
151
|
-
|
|
152
|
-
let limit: number | undefined;
|
|
153
|
-
if (limitRaw !== null) {
|
|
154
|
-
const parsed = Number.parseInt(limitRaw, 10);
|
|
155
|
-
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
156
|
-
return jsonResponse(
|
|
157
|
-
{ message: `Invalid limit "${limitRaw}".`, error: "limit must be a positive integer." },
|
|
158
|
-
400,
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
limit = parsed;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
let offset: number | undefined;
|
|
165
|
-
if (offsetRaw !== null) {
|
|
166
|
-
const parsed = Number.parseInt(offsetRaw, 10);
|
|
167
|
-
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
168
|
-
return jsonResponse(
|
|
169
|
-
{
|
|
170
|
-
message: `Invalid offset "${offsetRaw}".`,
|
|
171
|
-
error: "offset must be a non-negative integer.",
|
|
172
|
-
},
|
|
173
|
-
400,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
offset = parsed;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const result = await listPlatformOperations({
|
|
180
|
-
...(operationType ? { type: operationType } : {}),
|
|
181
|
-
...(environmentId ? { environmentId } : {}),
|
|
182
|
-
...(typeof limit === "number" ? { limit } : {}),
|
|
183
|
-
...(typeof offset === "number" ? { offset } : {}),
|
|
184
|
-
});
|
|
185
|
-
return jsonResponse(result, 200);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// --- Environment CRUD ---
|
|
189
|
-
|
|
190
|
-
if (pathname === "/platforms/le/environments") {
|
|
191
|
-
if (request.method === "GET") {
|
|
192
|
-
const envs = await loadEnvironments();
|
|
193
|
-
const masked = envs.map((e) => ({ ...e, apiToken: "••••••••" }));
|
|
194
|
-
return jsonResponse({ environments: masked }, 200);
|
|
195
|
-
}
|
|
196
|
-
if (request.method === "POST") {
|
|
197
|
-
const ctBlock = checkContentType(request);
|
|
198
|
-
if (ctBlock) return ctBlock;
|
|
199
|
-
const body = await parseJsonBody(request);
|
|
200
|
-
if (body instanceof Response) return body;
|
|
201
|
-
const parsed = CreateEnvironmentSchema.safeParse(body);
|
|
202
|
-
if (!parsed.success) return validationError(parsed.error);
|
|
203
|
-
const created = await addEnvironment(parsed.data);
|
|
204
|
-
return jsonResponse(created, 201);
|
|
205
|
-
}
|
|
206
|
-
return plainResponse("Method Not Allowed", 405, "GET, POST");
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (pathname === "/platforms/le/environments/test") {
|
|
210
|
-
if (request.method !== "POST") return plainResponse("Method Not Allowed", 405, "POST");
|
|
211
|
-
const ctBlock = checkContentType(request);
|
|
212
|
-
if (ctBlock) return ctBlock;
|
|
213
|
-
const body = await parseJsonBody(request);
|
|
214
|
-
if (body instanceof Response) return body;
|
|
215
|
-
return handleTestLeConnection(body);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (pathname.startsWith("/platforms/le/environments/")) {
|
|
219
|
-
const id = pathname.slice("/platforms/le/environments/".length);
|
|
220
|
-
if (!id) return jsonResponse({ message: "Missing environment ID." }, 400);
|
|
221
|
-
|
|
222
|
-
if (request.method === "GET") {
|
|
223
|
-
const env = await getEnvironment(id);
|
|
224
|
-
if (!env) return jsonResponse({ message: "Environment not found." }, 404);
|
|
225
|
-
return jsonResponse(env, 200);
|
|
226
|
-
}
|
|
227
|
-
if (request.method === "PUT") {
|
|
228
|
-
const ctBlock = checkContentType(request);
|
|
229
|
-
if (ctBlock) return ctBlock;
|
|
230
|
-
const body = await parseJsonBody(request);
|
|
231
|
-
if (body instanceof Response) return body;
|
|
232
|
-
const parsed = UpdateEnvironmentSchema.safeParse(body);
|
|
233
|
-
if (!parsed.success) return validationError(parsed.error);
|
|
234
|
-
const updated = await updateEnvironment(id, parsed.data);
|
|
235
|
-
if (!updated) return jsonResponse({ message: "Environment not found." }, 404);
|
|
236
|
-
return jsonResponse(updated, 200);
|
|
237
|
-
}
|
|
238
|
-
if (request.method === "DELETE") {
|
|
239
|
-
const removed = await removeEnvironment(id);
|
|
240
|
-
if (!removed) return jsonResponse({ message: "Environment not found." }, 404);
|
|
241
|
-
return jsonResponse({ message: "Deleted." }, 200);
|
|
242
|
-
}
|
|
243
|
-
return plainResponse("Method Not Allowed", 405, "GET, PUT, DELETE");
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// --- Plan / Apply / Cleanup ---
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
pathname === "/platforms/le/plan" ||
|
|
250
|
-
pathname === "/platforms/le/apply" ||
|
|
251
|
-
pathname === "/platforms/le/cleanup" ||
|
|
252
|
-
pathname === "/platforms/le/cleanup/plan"
|
|
253
|
-
) {
|
|
254
|
-
if (request.method !== "POST") return plainResponse("Method Not Allowed", 405, "POST");
|
|
255
|
-
const ctBlock = checkContentType(request);
|
|
256
|
-
if (ctBlock) return ctBlock;
|
|
257
|
-
const body = await parseJsonBody(request);
|
|
258
|
-
if (body instanceof Response) return body;
|
|
259
|
-
|
|
260
|
-
switch (pathname) {
|
|
261
|
-
case "/platforms/le/plan":
|
|
262
|
-
return handlePlan(body);
|
|
263
|
-
case "/platforms/le/apply":
|
|
264
|
-
return handleApply(body, eventLog);
|
|
265
|
-
case "/platforms/le/cleanup/plan":
|
|
266
|
-
return handleCleanupPlan(body);
|
|
267
|
-
case "/platforms/le/cleanup":
|
|
268
|
-
return handleCleanup(body, eventLog);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// --- Cancel endpoint ---
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
pathname.startsWith("/platforms/le/operations/") &&
|
|
276
|
-
pathname.endsWith("/cancel") &&
|
|
277
|
-
pathname.length > "/platforms/le/operations//cancel".length
|
|
278
|
-
) {
|
|
279
|
-
if (request.method !== "POST") return plainResponse("Method Not Allowed", 405, "POST");
|
|
280
|
-
const operationId = pathname.slice("/platforms/le/operations/".length, -"/cancel".length);
|
|
281
|
-
const cancelled = cancelOperation(operationId);
|
|
282
|
-
if (!cancelled) {
|
|
283
|
-
return jsonResponse({ message: "Operation not found or already complete." }, 404);
|
|
284
|
-
}
|
|
285
|
-
return jsonResponse({ cancelled: true }, 200);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// --- SSE streaming endpoints ---
|
|
289
|
-
|
|
290
|
-
if (pathname === "/platforms/le/apply/stream" || pathname === "/platforms/le/cleanup/stream") {
|
|
291
|
-
if (request.method !== "POST") return plainResponse("Method Not Allowed", 405, "POST");
|
|
292
|
-
const ctBlock = checkContentType(request);
|
|
293
|
-
if (ctBlock) return ctBlock;
|
|
294
|
-
const body = await parseJsonBody(request);
|
|
295
|
-
if (body instanceof Response) return body;
|
|
296
|
-
|
|
297
|
-
if (pathname === "/platforms/le/apply/stream") {
|
|
298
|
-
return handleApplyStream(body, eventLog);
|
|
299
|
-
}
|
|
300
|
-
return handleCleanupStream(body, eventLog);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
return null;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// --- Helpers ---
|
|
307
|
-
|
|
308
|
-
async function parseJsonBody(request: Request): Promise<unknown | Response> {
|
|
309
|
-
try {
|
|
310
|
-
return await request.json();
|
|
311
|
-
} catch {
|
|
312
|
-
return jsonResponse(
|
|
313
|
-
{ message: "Invalid JSON body.", error: "Request body must be valid JSON." },
|
|
314
|
-
400,
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function validationError(error: z.ZodError): Response {
|
|
320
|
-
return jsonResponse(
|
|
321
|
-
{ message: "Invalid request body.", error: error.issues.map((i) => i.message).join("; ") },
|
|
322
|
-
400,
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async function handleTestLeConnection(body: unknown): Promise<Response> {
|
|
327
|
-
const parsed = TestLeConnectionSchema.safeParse(body);
|
|
328
|
-
if (!parsed.success) return validationError(parsed.error);
|
|
329
|
-
|
|
330
|
-
const { environmentId, hostname, apiToken, sourceHostname } = parsed.data;
|
|
331
|
-
|
|
332
|
-
try {
|
|
333
|
-
let resolvedToken: string;
|
|
334
|
-
|
|
335
|
-
if (environmentId) {
|
|
336
|
-
const env = await getEnvironment(environmentId);
|
|
337
|
-
if (!env) return jsonResponse({ message: "Environment not found." }, 404);
|
|
338
|
-
assertSupportedEnvironmentPlatform(env);
|
|
339
|
-
resolvedToken = apiToken || env.apiToken;
|
|
340
|
-
} else {
|
|
341
|
-
resolvedToken = apiToken ?? "";
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
return jsonResponse(
|
|
345
|
-
await testLeConnection({ hostname, apiToken: resolvedToken, sourceHostname }),
|
|
346
|
-
200,
|
|
347
|
-
);
|
|
348
|
-
} catch (err) {
|
|
349
|
-
const message = formatThrownError(err);
|
|
350
|
-
return jsonResponse({ connected: false as const, error: message }, 200);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
interface ApplyContext {
|
|
355
|
-
readonly env: LEEnvironment;
|
|
356
|
-
readonly hub: LEDeviceHub;
|
|
357
|
-
readonly dt: LEDigitalTwins;
|
|
358
|
-
readonly templateId: string;
|
|
359
|
-
readonly desired: ApplyDesiredState;
|
|
360
|
-
readonly req: z.infer<typeof PlatformApplyBodySchema>;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
async function resolveApplyContext(body: unknown): Promise<ApplyContext | Response> {
|
|
364
|
-
const parsed = PlatformApplyBodySchema.safeParse(body);
|
|
365
|
-
if (!parsed.success) return validationError(parsed.error);
|
|
366
|
-
const req = parsed.data;
|
|
367
|
-
|
|
368
|
-
// Validate: exactly one of template or model must be provided
|
|
369
|
-
if (!req.template && !req.model) {
|
|
370
|
-
return jsonResponse(
|
|
371
|
-
{ message: "Invalid request body.", error: "Provide either 'template' or 'model'." },
|
|
372
|
-
400,
|
|
373
|
-
);
|
|
374
|
-
}
|
|
375
|
-
if (req.template && req.model) {
|
|
376
|
-
return jsonResponse(
|
|
377
|
-
{ message: "Invalid request body.", error: "Provide 'template' or 'model', not both." },
|
|
378
|
-
400,
|
|
379
|
-
);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const env = await getEnvironment(req.environmentId);
|
|
383
|
-
if (!env) return jsonResponse({ message: "Environment not found." }, 404);
|
|
384
|
-
if (env.platformId !== DEFAULT_PLATFORM_ID) {
|
|
385
|
-
return jsonResponse(
|
|
386
|
-
{
|
|
387
|
-
message: `Environment "${env.label}" uses unsupported platform "${env.platformId}".`,
|
|
388
|
-
error: `Only "${DEFAULT_PLATFORM_ID}" is currently supported for apply.`,
|
|
389
|
-
},
|
|
390
|
-
400,
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const client = new LEClient({ hostname: env.hostname, apiToken: env.apiToken });
|
|
395
|
-
const hub = new LEDeviceHub(client);
|
|
396
|
-
const dt = new LEDigitalTwins(client);
|
|
397
|
-
await client.checkAuth();
|
|
398
|
-
|
|
399
|
-
const catalog = createCatalog();
|
|
400
|
-
|
|
401
|
-
if (req.model) {
|
|
402
|
-
// Model-based apply: protocol is required
|
|
403
|
-
if (!req.protocol) {
|
|
404
|
-
return jsonResponse(
|
|
405
|
-
{ message: "Invalid request body.", error: "Protocol is required when applying a model." },
|
|
406
|
-
400,
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
const desired = await buildDesiredStateFromModel({
|
|
411
|
-
modelName: req.model,
|
|
412
|
-
catalog,
|
|
413
|
-
sourceHostname: env.sourceHostname,
|
|
414
|
-
protocol: req.protocol,
|
|
415
|
-
deviceName: req.deviceName,
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
return { env, hub, dt, templateId: `model:${req.model}`, desired, req };
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Template-based apply (original path) — req.template is guaranteed defined here
|
|
422
|
-
// because we returned early above when both template and model are missing.
|
|
423
|
-
const template = req.template ?? "";
|
|
424
|
-
const templatePath = await resolveTemplateIdentifier(template, catalog);
|
|
425
|
-
const templateId = extractTemplateId(templatePath);
|
|
426
|
-
|
|
427
|
-
const staticPlan = await buildDesiredStateFromTemplate({
|
|
428
|
-
templatePath,
|
|
429
|
-
catalog,
|
|
430
|
-
sourceHostname: env.sourceHostname,
|
|
431
|
-
protocol: req.protocol as ProtocolName | undefined,
|
|
432
|
-
filters: { area: req.area, line: req.line, instance: req.instance },
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
return { env, hub, dt, templateId, desired: staticPlan.desired, req };
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// --- Model-based desired state ---
|
|
439
|
-
|
|
440
|
-
interface BuildModelDesiredInput {
|
|
441
|
-
readonly modelName: string;
|
|
442
|
-
readonly catalog: ContentCatalog;
|
|
443
|
-
readonly sourceHostname: string;
|
|
444
|
-
readonly protocol: ProtocolName;
|
|
445
|
-
readonly deviceName?: string | undefined;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/** Resolve LE device/instance id for model apply (default `{prefix}-001` from `deriveInstancePrefix`). */
|
|
449
|
-
function resolveModelInstanceId(modelName: string, raw: string | undefined): string {
|
|
450
|
-
const defaultId = `${deriveInstancePrefix(modelName)}-001`;
|
|
451
|
-
if (raw === undefined) {
|
|
452
|
-
return defaultId;
|
|
453
|
-
}
|
|
454
|
-
const trimmed = raw.trim();
|
|
455
|
-
if (trimmed === "") {
|
|
456
|
-
return defaultId;
|
|
457
|
-
}
|
|
458
|
-
const s = trimmed.toLowerCase();
|
|
459
|
-
if (s.length > 120) {
|
|
460
|
-
throw new MiravoError(
|
|
461
|
-
`Device name is too long (${s.length} characters).`,
|
|
462
|
-
"Use at most 120 characters, e.g. motor-001 or my-line-a-motor.",
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s)) {
|
|
466
|
-
throw new MiravoError(
|
|
467
|
-
`Invalid device name "${trimmed}".`,
|
|
468
|
-
"Use lowercase letters, numbers, and hyphens only (e.g. motor-001 or pump-line-1).",
|
|
469
|
-
);
|
|
470
|
-
}
|
|
471
|
-
return s;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
async function buildDesiredStateFromModel(
|
|
475
|
-
input: BuildModelDesiredInput,
|
|
476
|
-
): Promise<ApplyDesiredState> {
|
|
477
|
-
if (!input.sourceHostname.trim()) {
|
|
478
|
-
throw new MiravoError(
|
|
479
|
-
"sourceHostname must not be empty.",
|
|
480
|
-
"Provide a valid hostname or IP address for the source server.",
|
|
481
|
-
);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const modelPath = await input.catalog.resolveModel(input.modelName);
|
|
485
|
-
const text = await Bun.file(modelPath).text();
|
|
486
|
-
let model: TwinModel;
|
|
487
|
-
try {
|
|
488
|
-
model = TwinModelSchema.parse(parseYAML(text));
|
|
489
|
-
} catch (error) {
|
|
490
|
-
throw new MiravoError(
|
|
491
|
-
`Failed to parse model "${input.modelName}".`,
|
|
492
|
-
error instanceof Error ? error.message : "Ensure the model YAML is valid.",
|
|
493
|
-
);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const instanceId = resolveModelInstanceId(input.modelName, input.deviceName);
|
|
497
|
-
const dtModelName = input.modelName.toUpperCase();
|
|
498
|
-
const endpointPort = resolveProtocolPort(input.protocol);
|
|
499
|
-
|
|
500
|
-
const tags = buildTagsForModel(instanceId, model, input.protocol);
|
|
501
|
-
|
|
502
|
-
const devices: DesiredDevice[] = [
|
|
503
|
-
{
|
|
504
|
-
instanceId,
|
|
505
|
-
modelName: input.modelName,
|
|
506
|
-
protocol: input.protocol,
|
|
507
|
-
deviceName: instanceId,
|
|
508
|
-
browsePath: instanceId,
|
|
509
|
-
endpoint: { host: input.sourceHostname, port: endpointPort },
|
|
510
|
-
properties: buildLeDeviceHubDeviceProperties(
|
|
511
|
-
input.protocol,
|
|
512
|
-
input.sourceHostname,
|
|
513
|
-
endpointPort,
|
|
514
|
-
),
|
|
515
|
-
tags,
|
|
516
|
-
},
|
|
517
|
-
];
|
|
518
|
-
|
|
519
|
-
const modelSchema: ModelSchema = toModelSchema(model);
|
|
520
|
-
const models: DesiredModel[] = [
|
|
521
|
-
{
|
|
522
|
-
name: dtModelName,
|
|
523
|
-
leDtJson: JSON.parse(buildLeDtJsonFromSchema(modelSchema)) as unknown,
|
|
524
|
-
},
|
|
525
|
-
];
|
|
526
|
-
|
|
527
|
-
const instances: DesiredInstance[] = [
|
|
528
|
-
{
|
|
529
|
-
instanceId,
|
|
530
|
-
modelName: input.modelName,
|
|
531
|
-
dtInstanceName: instanceId,
|
|
532
|
-
dtModelName,
|
|
533
|
-
boundDeviceName: instanceId,
|
|
534
|
-
browsePath: instanceId,
|
|
535
|
-
},
|
|
536
|
-
];
|
|
537
|
-
|
|
538
|
-
return { devices, models, instances };
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
function buildTagsForModel(
|
|
542
|
-
instanceId: string,
|
|
543
|
-
model: TwinModel,
|
|
544
|
-
protocol: ProtocolName,
|
|
545
|
-
): readonly DesiredTag[] {
|
|
546
|
-
const memberEntries = Object.entries(model.members);
|
|
547
|
-
|
|
548
|
-
switch (protocol) {
|
|
549
|
-
case "opcua":
|
|
550
|
-
return memberEntries.map(([memberName, memberDef]) => ({
|
|
551
|
-
name: `ns=1;s=${instanceId}/${memberName}`,
|
|
552
|
-
tagName: memberName,
|
|
553
|
-
valueType: OPCUA_VALUE_TYPE[memberDef.data_type] ?? "string",
|
|
554
|
-
properties: [],
|
|
555
|
-
unsPath: `${instanceId}/${memberName}`,
|
|
556
|
-
access: memberDef.access,
|
|
557
|
-
unit: memberDef.unit,
|
|
558
|
-
euRange: "eu_range" in memberDef ? memberDef.eu_range : undefined,
|
|
559
|
-
}));
|
|
560
|
-
case "mqtt":
|
|
561
|
-
return memberEntries.map(([memberName, memberDef]) => ({
|
|
562
|
-
name: `${instanceId}/${memberName}`,
|
|
563
|
-
tagName: memberName,
|
|
564
|
-
valueType: "string",
|
|
565
|
-
properties: [],
|
|
566
|
-
unsPath: `${instanceId}/${memberName}`,
|
|
567
|
-
access: memberDef.access,
|
|
568
|
-
unit: memberDef.unit,
|
|
569
|
-
euRange: "eu_range" in memberDef ? memberDef.eu_range : undefined,
|
|
570
|
-
}));
|
|
571
|
-
case "modbus":
|
|
572
|
-
// Minimal Modbus support for model apply — single device, sequential addresses
|
|
573
|
-
return memberEntries.map(([memberName, memberDef]) => ({
|
|
574
|
-
name: "H",
|
|
575
|
-
tagName: memberName,
|
|
576
|
-
valueType: "float32",
|
|
577
|
-
properties: [
|
|
578
|
-
{ Key: "address", Value: "0" },
|
|
579
|
-
{ Key: "count", Value: "1" },
|
|
580
|
-
{ Key: "polling_interval", Value: "1.0" },
|
|
581
|
-
{ Key: "endianness", Value: "AB CD" },
|
|
582
|
-
],
|
|
583
|
-
unsPath: `${instanceId}/${memberName}`,
|
|
584
|
-
access: memberDef.access,
|
|
585
|
-
unit: memberDef.unit,
|
|
586
|
-
euRange: "eu_range" in memberDef ? memberDef.eu_range : undefined,
|
|
587
|
-
}));
|
|
588
|
-
default: {
|
|
589
|
-
const _never: never = protocol;
|
|
590
|
-
throw new Error(`Unknown protocol: ${_never}`);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// --- Plan handler ---
|
|
596
|
-
|
|
597
|
-
async function handlePlan(body: unknown): Promise<Response> {
|
|
598
|
-
try {
|
|
599
|
-
const ctx = await resolveApplyContext(body);
|
|
600
|
-
if (ctx instanceof Response) return ctx;
|
|
601
|
-
|
|
602
|
-
const scope = ctx.req.scope ?? DEFAULT_APPLY_SCOPE;
|
|
603
|
-
const { plan } = await buildApplyPlan(ctx.hub, ctx.dt, ctx.desired, {
|
|
604
|
-
templateId: ctx.templateId,
|
|
605
|
-
targetHost: ctx.env.hostname,
|
|
606
|
-
sourceHostname: ctx.env.sourceHostname,
|
|
607
|
-
scope,
|
|
608
|
-
filters: { area: ctx.req.area, line: ctx.req.line, instance: ctx.req.instance },
|
|
609
|
-
ifUnmanaged: ctx.req.ifUnmanaged ?? "fail",
|
|
610
|
-
dryRun: true,
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
return jsonResponse({ plan }, 200);
|
|
614
|
-
} catch (err) {
|
|
615
|
-
return jsonResponse(
|
|
616
|
-
{ message: "Plan failed.", error: err instanceof Error ? err.message : String(err) },
|
|
617
|
-
500,
|
|
618
|
-
);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// --- Apply handler ---
|
|
623
|
-
|
|
624
|
-
async function handleApply(body: unknown, eventLog?: EventLog | undefined): Promise<Response> {
|
|
625
|
-
let tracker: ReturnType<typeof createPlatformOperationTracker> | undefined;
|
|
626
|
-
try {
|
|
627
|
-
const ctx = await resolveApplyContext(body);
|
|
628
|
-
if (ctx instanceof Response) return ctx;
|
|
629
|
-
|
|
630
|
-
const operationId = crypto.randomUUID();
|
|
631
|
-
const startTime = performance.now();
|
|
632
|
-
const startedAt = Date.now();
|
|
633
|
-
const scope = ctx.req.scope ?? DEFAULT_APPLY_SCOPE;
|
|
634
|
-
const maxAttempts = 3;
|
|
635
|
-
let attempt = 0;
|
|
636
|
-
let finalActivityLogs: Parameters<NonNullable<EventLog>["pushExternal"]>[0][] = [];
|
|
637
|
-
|
|
638
|
-
while (attempt < maxAttempts) {
|
|
639
|
-
attempt++;
|
|
640
|
-
finalActivityLogs = [];
|
|
641
|
-
|
|
642
|
-
tracker = createPlatformOperationTracker({
|
|
643
|
-
operationId,
|
|
644
|
-
operationType: "apply",
|
|
645
|
-
templateId: ctx.templateId,
|
|
646
|
-
environmentId: ctx.req.environmentId,
|
|
647
|
-
environmentLabel: ctx.env.label,
|
|
648
|
-
hostname: ctx.env.hostname,
|
|
649
|
-
scope,
|
|
650
|
-
filters: { area: ctx.req.area, line: ctx.req.line, instance: ctx.req.instance },
|
|
651
|
-
startedAt,
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
const onApplyResource = (progress: ResourceProgress) => {
|
|
655
|
-
tracker?.onResourceComplete(progress);
|
|
656
|
-
finalActivityLogs.push({
|
|
657
|
-
type: "platform:resource",
|
|
658
|
-
operationId,
|
|
659
|
-
operationType: "apply",
|
|
660
|
-
templateId: ctx.templateId,
|
|
661
|
-
environmentLabel: ctx.env.label,
|
|
662
|
-
hostname: ctx.env.hostname,
|
|
663
|
-
phase: progress.phase,
|
|
664
|
-
resourceType: progress.resourceType,
|
|
665
|
-
resourceName: progress.name,
|
|
666
|
-
action: progress.action,
|
|
667
|
-
status: progress.status,
|
|
668
|
-
...(progress.error ? { error: progress.error } : {}),
|
|
669
|
-
});
|
|
670
|
-
};
|
|
671
|
-
|
|
672
|
-
const applyOpts = {
|
|
673
|
-
templateId: ctx.templateId,
|
|
674
|
-
targetHost: ctx.env.hostname,
|
|
675
|
-
sourceHostname: ctx.env.sourceHostname,
|
|
676
|
-
scope,
|
|
677
|
-
filters: { area: ctx.req.area, line: ctx.req.line, instance: ctx.req.instance },
|
|
678
|
-
ifUnmanaged: ctx.req.ifUnmanaged ?? "fail",
|
|
679
|
-
dryRun: false,
|
|
680
|
-
onResourceComplete: onApplyResource,
|
|
681
|
-
};
|
|
682
|
-
|
|
683
|
-
try {
|
|
684
|
-
const { plan, snapshot } = await buildApplyPlan(ctx.hub, ctx.dt, ctx.desired, applyOpts);
|
|
685
|
-
|
|
686
|
-
const blockers = plan.entries.filter((e) => e.action === "blocked");
|
|
687
|
-
if (blockers.length > 0) {
|
|
688
|
-
const duration = Math.round(performance.now() - startTime);
|
|
689
|
-
const completedAt = Date.now();
|
|
690
|
-
for (const log of finalActivityLogs) {
|
|
691
|
-
eventLog?.pushExternal(log);
|
|
692
|
-
}
|
|
693
|
-
eventLog?.pushExternal({
|
|
694
|
-
type: "platform:operation",
|
|
695
|
-
operationId,
|
|
696
|
-
operationType: "apply",
|
|
697
|
-
templateId: ctx.templateId,
|
|
698
|
-
environmentLabel: ctx.env.label,
|
|
699
|
-
hostname: ctx.env.hostname,
|
|
700
|
-
applied: 0,
|
|
701
|
-
failed: blockers.length,
|
|
702
|
-
duration,
|
|
703
|
-
});
|
|
704
|
-
if (tracker) {
|
|
705
|
-
await appendPlatformOperation(
|
|
706
|
-
tracker.finalize({
|
|
707
|
-
successCount: 0,
|
|
708
|
-
failedCount: blockers.length,
|
|
709
|
-
blockedCount: blockers.length,
|
|
710
|
-
completedAt,
|
|
711
|
-
}),
|
|
712
|
-
).catch(() => {});
|
|
713
|
-
}
|
|
714
|
-
return jsonResponse(
|
|
715
|
-
{
|
|
716
|
-
plan,
|
|
717
|
-
applied: 0,
|
|
718
|
-
failed: blockers.length,
|
|
719
|
-
error: `${blockers.length} blocker(s) found.`,
|
|
720
|
-
},
|
|
721
|
-
409,
|
|
722
|
-
);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
const result = await executePlan(ctx.hub, ctx.dt, plan, ctx.desired, applyOpts, snapshot);
|
|
726
|
-
|
|
727
|
-
if (result.failed > 0 && attempt < maxAttempts) {
|
|
728
|
-
await Bun.sleep(3000);
|
|
729
|
-
continue;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
const duration = Math.round(performance.now() - startTime);
|
|
733
|
-
const completedAt = Date.now();
|
|
734
|
-
for (const log of finalActivityLogs) {
|
|
735
|
-
eventLog?.pushExternal(log);
|
|
736
|
-
}
|
|
737
|
-
eventLog?.pushExternal({
|
|
738
|
-
type: "platform:operation",
|
|
739
|
-
operationId,
|
|
740
|
-
operationType: "apply",
|
|
741
|
-
templateId: ctx.templateId,
|
|
742
|
-
environmentLabel: ctx.env.label,
|
|
743
|
-
hostname: ctx.env.hostname,
|
|
744
|
-
applied: result.applied,
|
|
745
|
-
failed: result.failed,
|
|
746
|
-
duration,
|
|
747
|
-
});
|
|
748
|
-
if (tracker) {
|
|
749
|
-
await appendPlatformOperation(
|
|
750
|
-
tracker.finalize({
|
|
751
|
-
successCount: result.applied,
|
|
752
|
-
failedCount: result.failed,
|
|
753
|
-
completedAt,
|
|
754
|
-
...(result.error ? { error: result.error } : {}),
|
|
755
|
-
}),
|
|
756
|
-
).catch(() => {});
|
|
757
|
-
}
|
|
758
|
-
return jsonResponse(result, result.failed > 0 ? 207 : 200);
|
|
759
|
-
} catch (err) {
|
|
760
|
-
if (attempt < maxAttempts) {
|
|
761
|
-
await Bun.sleep(3000);
|
|
762
|
-
continue;
|
|
763
|
-
}
|
|
764
|
-
for (const log of finalActivityLogs) {
|
|
765
|
-
eventLog?.pushExternal(log);
|
|
766
|
-
}
|
|
767
|
-
throw err;
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
throw new Error("Unreachable");
|
|
771
|
-
} catch (err) {
|
|
772
|
-
const message = formatThrownError(err);
|
|
773
|
-
if (tracker) {
|
|
774
|
-
await appendPlatformOperation(
|
|
775
|
-
tracker.finalize({
|
|
776
|
-
successCount: 0,
|
|
777
|
-
failedCount: 1,
|
|
778
|
-
completedAt: Date.now(),
|
|
779
|
-
error: message,
|
|
780
|
-
}),
|
|
781
|
-
).catch(() => {});
|
|
782
|
-
}
|
|
783
|
-
return jsonResponse({ message: "Apply failed.", error: message }, 500);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// --- Shared cleanup context ---
|
|
788
|
-
|
|
789
|
-
interface CleanupContext {
|
|
790
|
-
readonly req: z.infer<typeof PlatformCleanupBodySchema>;
|
|
791
|
-
readonly env: LEEnvironment;
|
|
792
|
-
readonly hub: LEDeviceHub;
|
|
793
|
-
readonly dt: LEDigitalTwins;
|
|
794
|
-
readonly templateId: string;
|
|
795
|
-
readonly scope: ApplyScope[];
|
|
796
|
-
readonly allManaged: boolean;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
async function resolveCleanupContext(body: unknown): Promise<CleanupContext | Response> {
|
|
800
|
-
const parsed = PlatformCleanupBodySchema.safeParse(body);
|
|
801
|
-
if (!parsed.success) return validationError(parsed.error);
|
|
802
|
-
const req = parsed.data;
|
|
803
|
-
|
|
804
|
-
const env = await getEnvironment(req.environmentId);
|
|
805
|
-
if (!env) return jsonResponse({ message: "Environment not found." }, 404);
|
|
806
|
-
if (env.platformId !== DEFAULT_PLATFORM_ID) {
|
|
807
|
-
return jsonResponse(
|
|
808
|
-
{
|
|
809
|
-
message: `Environment "${env.label}" uses unsupported platform "${env.platformId}".`,
|
|
810
|
-
error: `Only "${DEFAULT_PLATFORM_ID}" is currently supported for cleanup.`,
|
|
811
|
-
},
|
|
812
|
-
400,
|
|
813
|
-
);
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
const allManaged = req.allManaged ?? false;
|
|
817
|
-
if (!allManaged && !req.template) {
|
|
818
|
-
return jsonResponse(
|
|
819
|
-
{ message: "Missing template.", error: "Provide 'template' or set 'allManaged: true'." },
|
|
820
|
-
400,
|
|
821
|
-
);
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
const client = new LEClient({ hostname: env.hostname, apiToken: env.apiToken });
|
|
825
|
-
const hub = new LEDeviceHub(client);
|
|
826
|
-
const dt = new LEDigitalTwins(client);
|
|
827
|
-
await client.checkAuth();
|
|
828
|
-
|
|
829
|
-
let templateId = "all-managed";
|
|
830
|
-
if (!allManaged && req.template) {
|
|
831
|
-
const catalog = createCatalog();
|
|
832
|
-
const templatePath = await resolveTemplateIdentifier(req.template, catalog);
|
|
833
|
-
templateId = extractTemplateId(templatePath);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
return { req, env, hub, dt, templateId, scope: req.scope ?? DEFAULT_CLEANUP_SCOPE, allManaged };
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
// --- Cleanup plan handler (dry-run, no mutations) ---
|
|
840
|
-
|
|
841
|
-
async function handleCleanupPlan(body: unknown): Promise<Response> {
|
|
842
|
-
try {
|
|
843
|
-
const ctx = await resolveCleanupContext(body);
|
|
844
|
-
if (ctx instanceof Response) return ctx;
|
|
845
|
-
const { hub, dt, env, templateId, scope, allManaged, req } = ctx;
|
|
846
|
-
|
|
847
|
-
const { plan } = await buildCleanupPlan(hub, dt, {
|
|
848
|
-
templateId,
|
|
849
|
-
targetHost: env.hostname,
|
|
850
|
-
scope,
|
|
851
|
-
filters: { area: req.area, line: req.line, instance: req.instance },
|
|
852
|
-
allManaged,
|
|
853
|
-
dryRun: true,
|
|
854
|
-
});
|
|
855
|
-
return jsonResponse({ plan }, 200);
|
|
856
|
-
} catch (err) {
|
|
857
|
-
return jsonResponse({ message: "Cleanup plan failed.", error: formatThrownError(err) }, 500);
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
// --- Cleanup handler ---
|
|
862
|
-
|
|
863
|
-
async function handleCleanup(body: unknown, eventLog?: EventLog | undefined): Promise<Response> {
|
|
864
|
-
let tracker: ReturnType<typeof createPlatformOperationTracker> | undefined;
|
|
865
|
-
|
|
866
|
-
const ctxOrResponse = await resolveCleanupContext(body);
|
|
867
|
-
if (ctxOrResponse instanceof Response) return ctxOrResponse;
|
|
868
|
-
const { req, env, hub, dt, templateId, scope, allManaged } = ctxOrResponse;
|
|
869
|
-
|
|
870
|
-
try {
|
|
871
|
-
const operationId = crypto.randomUUID();
|
|
872
|
-
const startTime = performance.now();
|
|
873
|
-
const startedAt = Date.now();
|
|
874
|
-
|
|
875
|
-
const maxAttempts = 3;
|
|
876
|
-
let attempt = 0;
|
|
877
|
-
let finalActivityLogs: Parameters<NonNullable<EventLog>["pushExternal"]>[0][] = [];
|
|
878
|
-
|
|
879
|
-
while (attempt < maxAttempts) {
|
|
880
|
-
attempt++;
|
|
881
|
-
finalActivityLogs = [];
|
|
882
|
-
|
|
883
|
-
tracker = createPlatformOperationTracker({
|
|
884
|
-
operationId,
|
|
885
|
-
operationType: "cleanup",
|
|
886
|
-
templateId,
|
|
887
|
-
environmentId: req.environmentId,
|
|
888
|
-
environmentLabel: env.label,
|
|
889
|
-
hostname: env.hostname,
|
|
890
|
-
scope,
|
|
891
|
-
filters: { area: req.area, line: req.line, instance: req.instance },
|
|
892
|
-
startedAt,
|
|
893
|
-
});
|
|
894
|
-
const onCleanupResource = (progress: ResourceProgress) => {
|
|
895
|
-
tracker?.onResourceComplete(progress);
|
|
896
|
-
finalActivityLogs.push({
|
|
897
|
-
type: "platform:resource",
|
|
898
|
-
operationId,
|
|
899
|
-
operationType: "cleanup",
|
|
900
|
-
templateId,
|
|
901
|
-
environmentLabel: env.label,
|
|
902
|
-
hostname: env.hostname,
|
|
903
|
-
phase: progress.phase,
|
|
904
|
-
resourceType: progress.resourceType,
|
|
905
|
-
resourceName: progress.name,
|
|
906
|
-
action: progress.action,
|
|
907
|
-
status: progress.status,
|
|
908
|
-
...(progress.error ? { error: progress.error } : {}),
|
|
909
|
-
});
|
|
910
|
-
};
|
|
911
|
-
const cleanupOpts = {
|
|
912
|
-
templateId,
|
|
913
|
-
targetHost: env.hostname,
|
|
914
|
-
scope,
|
|
915
|
-
filters: { area: req.area, line: req.line, instance: req.instance },
|
|
916
|
-
allManaged,
|
|
917
|
-
dryRun: false,
|
|
918
|
-
onResourceComplete: onCleanupResource,
|
|
919
|
-
};
|
|
920
|
-
|
|
921
|
-
try {
|
|
922
|
-
const planResult = await buildCleanupPlan(hub, dt, cleanupOpts);
|
|
923
|
-
if (planResult.plan.entries.length === 0) {
|
|
924
|
-
const duration = Math.round(performance.now() - startTime);
|
|
925
|
-
const completedAt = Date.now();
|
|
926
|
-
for (const log of finalActivityLogs) {
|
|
927
|
-
eventLog?.pushExternal(log);
|
|
928
|
-
}
|
|
929
|
-
eventLog?.pushExternal({
|
|
930
|
-
type: "platform:operation",
|
|
931
|
-
operationId,
|
|
932
|
-
operationType: "cleanup",
|
|
933
|
-
templateId,
|
|
934
|
-
environmentLabel: env.label,
|
|
935
|
-
hostname: env.hostname,
|
|
936
|
-
applied: 0,
|
|
937
|
-
failed: 0,
|
|
938
|
-
duration,
|
|
939
|
-
});
|
|
940
|
-
if (tracker) {
|
|
941
|
-
await appendPlatformOperation(
|
|
942
|
-
tracker.finalize({ successCount: 0, failedCount: 0, completedAt }),
|
|
943
|
-
).catch(() => {});
|
|
944
|
-
}
|
|
945
|
-
return jsonResponse({ plan: planResult.plan, deleted: 0, failed: 0 }, 200);
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
const result = await executeCleanup(hub, dt, planResult, cleanupOpts);
|
|
949
|
-
|
|
950
|
-
if (result.failed > 0 && attempt < maxAttempts) {
|
|
951
|
-
await Bun.sleep(3000);
|
|
952
|
-
continue;
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
const duration = Math.round(performance.now() - startTime);
|
|
956
|
-
const completedAt = Date.now();
|
|
957
|
-
for (const log of finalActivityLogs) {
|
|
958
|
-
eventLog?.pushExternal(log);
|
|
959
|
-
}
|
|
960
|
-
eventLog?.pushExternal({
|
|
961
|
-
type: "platform:operation",
|
|
962
|
-
operationId,
|
|
963
|
-
operationType: "cleanup",
|
|
964
|
-
templateId,
|
|
965
|
-
environmentLabel: env.label,
|
|
966
|
-
hostname: env.hostname,
|
|
967
|
-
applied: result.deleted,
|
|
968
|
-
failed: result.failed,
|
|
969
|
-
duration,
|
|
970
|
-
});
|
|
971
|
-
if (tracker) {
|
|
972
|
-
await appendPlatformOperation(
|
|
973
|
-
tracker.finalize({
|
|
974
|
-
successCount: result.deleted,
|
|
975
|
-
failedCount: result.failed,
|
|
976
|
-
completedAt,
|
|
977
|
-
...(result.error ? { error: result.error } : {}),
|
|
978
|
-
}),
|
|
979
|
-
).catch(() => {});
|
|
980
|
-
}
|
|
981
|
-
return jsonResponse(result, result.failed > 0 ? 207 : 200);
|
|
982
|
-
} catch (err) {
|
|
983
|
-
if (attempt < maxAttempts) {
|
|
984
|
-
await Bun.sleep(3000);
|
|
985
|
-
continue;
|
|
986
|
-
}
|
|
987
|
-
for (const log of finalActivityLogs) {
|
|
988
|
-
eventLog?.pushExternal(log);
|
|
989
|
-
}
|
|
990
|
-
throw err;
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
throw new Error("Unreachable");
|
|
994
|
-
} catch (err) {
|
|
995
|
-
const message = formatThrownError(err);
|
|
996
|
-
if (tracker) {
|
|
997
|
-
await appendPlatformOperation(
|
|
998
|
-
tracker.finalize({
|
|
999
|
-
successCount: 0,
|
|
1000
|
-
failedCount: 1,
|
|
1001
|
-
completedAt: Date.now(),
|
|
1002
|
-
error: message,
|
|
1003
|
-
}),
|
|
1004
|
-
).catch(() => {});
|
|
1005
|
-
}
|
|
1006
|
-
return jsonResponse({ message: "Cleanup failed.", error: message }, 500);
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
// --- Exported helpers for streaming module ---
|
|
1011
|
-
|
|
1012
|
-
export {
|
|
1013
|
-
DEFAULT_APPLY_SCOPE,
|
|
1014
|
-
DEFAULT_CLEANUP_SCOPE,
|
|
1015
|
-
PlatformCleanupBodySchema,
|
|
1016
|
-
resolveApplyContext,
|
|
1017
|
-
};
|