@devicechain/dashboards 0.19.0-rc.1 → 0.20.0-rc.4
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 +18 -6
- package/dist/bindings.d.ts +2 -1
- package/dist/channel-snapshots.d.ts +26 -0
- package/dist/definition.d.ts +10 -2
- package/dist/editor-model.d.ts +3 -2
- package/dist/history.d.ts +2 -2
- package/dist/hub.d.ts +1 -4
- package/dist/index.d.ts +11 -4
- package/dist/index.js +1701 -80
- package/dist/index.js.map +4 -4
- package/dist/option-schema.d.ts +86 -0
- package/dist/recorded.d.ts +80 -0
- package/dist/recording.d.ts +104 -0
- package/dist/slots.d.ts +1 -1
- package/dist/types.d.ts +45 -2
- package/dist/widget-catalog.d.ts +19 -0
- package/dist/widget-compat.d.ts +16 -0
- package/dist/widget-descriptor.d.ts +91 -0
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -61,6 +61,85 @@ var WIDGET_TYPES = [
|
|
|
61
61
|
"entity-selector",
|
|
62
62
|
"map"
|
|
63
63
|
];
|
|
64
|
+
var UNKNOWN_WIDGET_TYPE = "unknown-widget";
|
|
65
|
+
|
|
66
|
+
// src/widget-compat.ts
|
|
67
|
+
var BUILTIN_TYPES = new Set(WIDGET_TYPES);
|
|
68
|
+
function isBuiltinWidget(widget) {
|
|
69
|
+
return BUILTIN_TYPES.has(widget.type);
|
|
70
|
+
}
|
|
71
|
+
function isResolvedWidget(widget) {
|
|
72
|
+
return widget.type !== UNKNOWN_WIDGET_TYPE;
|
|
73
|
+
}
|
|
74
|
+
var SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
75
|
+
function isRecord(v) {
|
|
76
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
77
|
+
}
|
|
78
|
+
function parseWidgetRef(raw) {
|
|
79
|
+
if (raw === void 0) return void 0;
|
|
80
|
+
if (!isRecord(raw)) return null;
|
|
81
|
+
const { version, optionsVersion, sdkApiVersion } = raw;
|
|
82
|
+
if (typeof version !== "string" || !SEMVER.test(version)) return null;
|
|
83
|
+
if (typeof optionsVersion !== "number" || !Number.isInteger(optionsVersion) || optionsVersion < 1) return null;
|
|
84
|
+
const ref = { version, optionsVersion };
|
|
85
|
+
if (sdkApiVersion !== void 0) {
|
|
86
|
+
if (typeof sdkApiVersion !== "number" || !Number.isInteger(sdkApiVersion) || sdkApiVersion < 1) return null;
|
|
87
|
+
ref.sdkApiVersion = sdkApiVersion;
|
|
88
|
+
}
|
|
89
|
+
return ref;
|
|
90
|
+
}
|
|
91
|
+
function checkWidgetCompatibility(id, ref, descriptor) {
|
|
92
|
+
if (ref.optionsVersion !== descriptor.optionsVersion) {
|
|
93
|
+
return {
|
|
94
|
+
code: "options-version",
|
|
95
|
+
message: `"${id}" was saved with settings version ${ref.optionsVersion}; the installed widget uses settings version ${descriptor.optionsVersion}`
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (ref.sdkApiVersion !== void 0 && ref.sdkApiVersion !== descriptor.sdkApiVersion) {
|
|
99
|
+
return {
|
|
100
|
+
code: "sdk-incompatible",
|
|
101
|
+
message: `"${id}" was authored for widget SDK API ${ref.sdkApiVersion}; the installed widget uses API ${descriptor.sdkApiVersion}`
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return void 0;
|
|
105
|
+
}
|
|
106
|
+
function widgetVersionNotice(id, ref, descriptor) {
|
|
107
|
+
if (!ref || ref.version === descriptor.version) return void 0;
|
|
108
|
+
return `"${id}" was authored with version ${ref.version}; rendering with version ${descriptor.version}`;
|
|
109
|
+
}
|
|
110
|
+
function unresolvedReason(type, rawRef, catalog, builtin) {
|
|
111
|
+
const ref = parseWidgetRef(rawRef);
|
|
112
|
+
if (ref === null) {
|
|
113
|
+
return { code: "invalid-metadata", message: `"${type}" carries widget version metadata that is not valid` };
|
|
114
|
+
}
|
|
115
|
+
if (!catalog) {
|
|
116
|
+
return builtin ? void 0 : { code: "unregistered", message: `"${type}" is not available: no widget catalog was supplied` };
|
|
117
|
+
}
|
|
118
|
+
const descriptor = catalog.get(type);
|
|
119
|
+
if (!descriptor) {
|
|
120
|
+
return {
|
|
121
|
+
code: "unregistered",
|
|
122
|
+
message: builtin ? `"${type}" is not registered in this host` : `"${type}" is not installed in this host; add its package to the host's widget registry`
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (ref === void 0) {
|
|
126
|
+
return builtin ? void 0 : { code: "unversioned", message: `"${type}" is stored without version metadata, so the installed widget cannot be matched to it` };
|
|
127
|
+
}
|
|
128
|
+
return checkWidgetCompatibility(type, ref, descriptor);
|
|
129
|
+
}
|
|
130
|
+
function unresolvedWidgets(definition) {
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const w of definition.widgets) {
|
|
133
|
+
if (w.type !== UNKNOWN_WIDGET_TYPE) continue;
|
|
134
|
+
const storedType = typeof w.raw.type === "string" ? w.raw.type : "";
|
|
135
|
+
out.push({
|
|
136
|
+
widgetId: w.id,
|
|
137
|
+
type: storedType,
|
|
138
|
+
reason: w.reason ?? { code: "unregistered", message: `"${storedType}" is not available in this host` }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
64
143
|
|
|
65
144
|
// src/definition.ts
|
|
66
145
|
var BASE_BREAKPOINT = "base";
|
|
@@ -73,33 +152,33 @@ var DashboardDefinitionError = class extends Error {
|
|
|
73
152
|
this.name = "DashboardDefinitionError";
|
|
74
153
|
}
|
|
75
154
|
};
|
|
76
|
-
function
|
|
155
|
+
function isRecord2(v) {
|
|
77
156
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
78
157
|
}
|
|
79
158
|
function numberAt(rec, key, fallback) {
|
|
80
159
|
const v = rec[key];
|
|
81
160
|
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
82
161
|
}
|
|
83
|
-
function parseDashboardDefinition(raw) {
|
|
84
|
-
if (!
|
|
162
|
+
function parseDashboardDefinition(raw, options = {}) {
|
|
163
|
+
if (!isRecord2(raw)) throw new DashboardDefinitionError("not a JSON object");
|
|
85
164
|
const widgetsRaw = raw.widgets;
|
|
86
165
|
if (!Array.isArray(widgetsRaw)) throw new DashboardDefinitionError("widgets must be an array");
|
|
87
166
|
const def = {
|
|
88
167
|
schemaVersion: numberAt(raw, "schemaVersion", 1),
|
|
89
168
|
title: typeof raw.title === "string" ? raw.title : "",
|
|
90
169
|
canvas: parseCanvas(raw.canvas),
|
|
91
|
-
widgets: widgetsRaw.map((w, i) => parseWidget(w, i))
|
|
170
|
+
widgets: widgetsRaw.map((w, i) => parseWidget(w, i, options.catalog))
|
|
92
171
|
};
|
|
93
172
|
const slots = parseSlots(raw.slots);
|
|
94
173
|
if (slots) def.slots = slots;
|
|
95
174
|
return def;
|
|
96
175
|
}
|
|
97
176
|
function parseSlots(raw) {
|
|
98
|
-
if (!
|
|
177
|
+
if (!isRecord2(raw)) return void 0;
|
|
99
178
|
const slots = {};
|
|
100
179
|
for (const [name, spec] of Object.entries(raw)) {
|
|
101
180
|
if (name === "__proto__") continue;
|
|
102
|
-
if (!
|
|
181
|
+
if (!isRecord2(spec)) continue;
|
|
103
182
|
const type = spec.type === "anchor" ? "anchor" : "device";
|
|
104
183
|
const slot = { type };
|
|
105
184
|
if (typeof spec.label === "string") slot.label = spec.label;
|
|
@@ -114,7 +193,7 @@ function parseSlots(raw) {
|
|
|
114
193
|
return slots;
|
|
115
194
|
}
|
|
116
195
|
function parseScope(raw) {
|
|
117
|
-
if (!
|
|
196
|
+
if (!isRecord2(raw)) return void 0;
|
|
118
197
|
const parent = typeof raw.parent === "string" ? raw.parent : "";
|
|
119
198
|
if (!parent) return void 0;
|
|
120
199
|
return { parent, strategy: raw.strategy === "manual" ? "manual" : "first" };
|
|
@@ -158,13 +237,13 @@ function canScopeSlot(slots, child, parentName) {
|
|
|
158
237
|
return true;
|
|
159
238
|
}
|
|
160
239
|
function parseSlotBinding(raw) {
|
|
161
|
-
if (!
|
|
240
|
+
if (!isRecord2(raw)) return void 0;
|
|
162
241
|
if (raw.kind === "device") {
|
|
163
242
|
const token = stringAt(raw, "deviceToken");
|
|
164
243
|
return token ? { kind: "device", deviceToken: token } : void 0;
|
|
165
244
|
}
|
|
166
245
|
if (raw.kind === "anchor") {
|
|
167
|
-
const anchorRec =
|
|
246
|
+
const anchorRec = isRecord2(raw.anchor) ? raw.anchor : {};
|
|
168
247
|
const targetToken = stringAt(anchorRec, "targetToken");
|
|
169
248
|
if (!targetToken) return void 0;
|
|
170
249
|
return {
|
|
@@ -179,13 +258,13 @@ function parseSlotBinding(raw) {
|
|
|
179
258
|
return void 0;
|
|
180
259
|
}
|
|
181
260
|
function parseGrid(raw) {
|
|
182
|
-
const rec =
|
|
261
|
+
const rec = isRecord2(raw) ? raw : {};
|
|
183
262
|
const columns = Math.max(1, Math.round(numberAt(rec, "columns", DEFAULT_GRID.columns)));
|
|
184
263
|
const rowHeight = Math.max(1, numberAt(rec, "rowHeight", DEFAULT_GRID.rowHeight));
|
|
185
264
|
let gap = DEFAULT_GRID.gap;
|
|
186
265
|
if (typeof rec.gap === "number" && Number.isFinite(rec.gap)) {
|
|
187
266
|
gap = Math.max(0, rec.gap);
|
|
188
|
-
} else if (
|
|
267
|
+
} else if (isRecord2(rec.gap)) {
|
|
189
268
|
gap = {
|
|
190
269
|
row: Math.max(0, numberAt(rec.gap, "row", DEFAULT_GRID.gap)),
|
|
191
270
|
col: Math.max(0, numberAt(rec.gap, "col", DEFAULT_GRID.gap))
|
|
@@ -194,7 +273,7 @@ function parseGrid(raw) {
|
|
|
194
273
|
return { columns, gap, rowHeight };
|
|
195
274
|
}
|
|
196
275
|
function parseSizing(raw) {
|
|
197
|
-
if (
|
|
276
|
+
if (isRecord2(raw)) {
|
|
198
277
|
if (typeof raw.width === "number" && Number.isFinite(raw.width)) {
|
|
199
278
|
return { width: Math.max(1, raw.width) };
|
|
200
279
|
}
|
|
@@ -205,17 +284,17 @@ function parseSizing(raw) {
|
|
|
205
284
|
return DEFAULT_SIZING;
|
|
206
285
|
}
|
|
207
286
|
function parseCanvas(raw) {
|
|
208
|
-
const rec =
|
|
287
|
+
const rec = isRecord2(raw) ? raw : {};
|
|
209
288
|
const grid = parseGrid(rec.grid);
|
|
210
289
|
const sizing = parseSizing(rec.sizing);
|
|
211
|
-
const bpRec =
|
|
290
|
+
const bpRec = isRecord2(rec.breakpoints) ? rec.breakpoints : {};
|
|
212
291
|
const breakpoints = {};
|
|
213
292
|
for (const [name, width] of Object.entries(bpRec)) {
|
|
214
293
|
if (typeof width === "number" && Number.isFinite(width)) breakpoints[name] = width;
|
|
215
294
|
}
|
|
216
295
|
if (!(BASE_BREAKPOINT in breakpoints)) breakpoints[BASE_BREAKPOINT] = 0;
|
|
217
296
|
const canvas = { grid, sizing, breakpoints };
|
|
218
|
-
if (
|
|
297
|
+
if (isRecord2(rec.background)) {
|
|
219
298
|
const { color, imageUrl } = rec.background;
|
|
220
299
|
canvas.background = {
|
|
221
300
|
color: typeof color === "string" ? color : null,
|
|
@@ -224,22 +303,81 @@ function parseCanvas(raw) {
|
|
|
224
303
|
}
|
|
225
304
|
return canvas;
|
|
226
305
|
}
|
|
227
|
-
function parseWidget(raw, index) {
|
|
228
|
-
if (!
|
|
306
|
+
function parseWidget(raw, index, catalog) {
|
|
307
|
+
if (!isRecord2(raw)) throw new DashboardDefinitionError(`widgets[${index}] is not an object`);
|
|
229
308
|
const type = raw.type;
|
|
230
|
-
if (typeof type !== "string" ||
|
|
309
|
+
if (typeof type !== "string" || type.length === 0) {
|
|
231
310
|
throw new DashboardDefinitionError(`widgets[${index}] has unknown type ${JSON.stringify(type)}`);
|
|
232
311
|
}
|
|
233
|
-
|
|
234
|
-
|
|
312
|
+
if (type === UNKNOWN_WIDGET_TYPE && isRecord2(raw.raw)) return reparseUnknownWidget(raw, index, catalog);
|
|
313
|
+
const builtin = WIDGET_TYPE_SET.has(type);
|
|
314
|
+
const reason = unresolvedReason(type, raw.widget, catalog, builtin) ?? (builtin ? void 0 : reservedFieldReason(type, raw));
|
|
315
|
+
if (reason) return parseUnknownWidget(raw, index, reason);
|
|
316
|
+
const ref = parseWidgetRef(raw.widget) ?? void 0;
|
|
317
|
+
const widget = builtin ? {
|
|
318
|
+
id: storedWidgetId(raw) ?? generateWidgetId(),
|
|
235
319
|
type,
|
|
320
|
+
...ref ? { widget: ref } : {},
|
|
321
|
+
layout: parseLayout(raw.layout, index)
|
|
322
|
+
} : {
|
|
323
|
+
id: storedWidgetId(raw) ?? generateWidgetId(),
|
|
324
|
+
type,
|
|
325
|
+
widget: ref,
|
|
326
|
+
// unresolvedReason guarantees a valid ref here
|
|
236
327
|
layout: parseLayout(raw.layout, index)
|
|
237
328
|
};
|
|
238
|
-
const ds = parseDatasource(raw.datasource);
|
|
329
|
+
const ds = parseDatasource(raw.datasource, index);
|
|
239
330
|
if (ds) widget.datasource = ds;
|
|
240
|
-
if (
|
|
331
|
+
if (isRecord2(raw.options)) widget.options = raw.options;
|
|
332
|
+
if (!builtin) return withStoredFields(raw, widget);
|
|
241
333
|
return widget;
|
|
242
334
|
}
|
|
335
|
+
var OWNED_WIDGET_KEYS = /* @__PURE__ */ new Set(["id", "type", "widget", "layout", "datasource", "options"]);
|
|
336
|
+
function withStoredFields(raw, parsed) {
|
|
337
|
+
const owned = parsed;
|
|
338
|
+
const out = {};
|
|
339
|
+
const put = (key, value) => Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
|
|
340
|
+
for (const key of Object.keys(raw)) {
|
|
341
|
+
if (!OWNED_WIDGET_KEYS.has(key)) put(key, JSON.parse(JSON.stringify(raw[key])));
|
|
342
|
+
else if (key === "widget") put(key, { ...raw.widget, ...parsed.widget });
|
|
343
|
+
else if (owned[key] !== void 0) put(key, owned[key]);
|
|
344
|
+
}
|
|
345
|
+
for (const key of OWNED_WIDGET_KEYS) {
|
|
346
|
+
if (!Object.prototype.hasOwnProperty.call(out, key) && owned[key] !== void 0) put(key, owned[key]);
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
function reservedFieldReason(type, raw) {
|
|
351
|
+
const clash = ["raw", "reason"].find((k) => Object.prototype.hasOwnProperty.call(raw, k));
|
|
352
|
+
return clash === void 0 ? void 0 : { code: "invalid-metadata", message: `"${type}" carries a top-level "${clash}" field, which the dashboard format reserves` };
|
|
353
|
+
}
|
|
354
|
+
function storedWidgetId(raw) {
|
|
355
|
+
return typeof raw.id === "string" && raw.id.length > 0 ? raw.id : void 0;
|
|
356
|
+
}
|
|
357
|
+
function parseUnknownWidget(raw, index, reason) {
|
|
358
|
+
return {
|
|
359
|
+
id: storedWidgetId(raw) ?? generateWidgetId(),
|
|
360
|
+
type: UNKNOWN_WIDGET_TYPE,
|
|
361
|
+
layout: parseLayout(raw.layout, index),
|
|
362
|
+
raw: JSON.parse(JSON.stringify(raw)),
|
|
363
|
+
reason
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function reparseUnknownWidget(raw, index, catalog) {
|
|
367
|
+
const stored = JSON.parse(JSON.stringify(raw.raw));
|
|
368
|
+
const storedType = typeof stored.type === "string" ? stored.type : "";
|
|
369
|
+
const reason = unresolvedReason(storedType, stored.widget, catalog, WIDGET_TYPE_SET.has(storedType)) ?? {
|
|
370
|
+
code: "unregistered",
|
|
371
|
+
message: `"${storedType}" is not available in this host`
|
|
372
|
+
};
|
|
373
|
+
return {
|
|
374
|
+
id: storedWidgetId(raw) ?? generateWidgetId(),
|
|
375
|
+
type: UNKNOWN_WIDGET_TYPE,
|
|
376
|
+
layout: parseLayout(raw.layout, index),
|
|
377
|
+
raw: stored,
|
|
378
|
+
reason
|
|
379
|
+
};
|
|
380
|
+
}
|
|
243
381
|
function stringAt(rec, key) {
|
|
244
382
|
const v = rec[key];
|
|
245
383
|
return typeof v === "string" ? v : "";
|
|
@@ -249,17 +387,22 @@ function stringArrayAt(rec, key) {
|
|
|
249
387
|
return Array.isArray(v) ? v.filter((m) => typeof m === "string") : [];
|
|
250
388
|
}
|
|
251
389
|
function parseLocationSelection(raw) {
|
|
252
|
-
if (!
|
|
390
|
+
if (!isRecord2(raw)) return void 0;
|
|
253
391
|
return raw.series === "latest" ? { series: "latest" } : void 0;
|
|
254
392
|
}
|
|
255
393
|
function withLocation(selector, raw) {
|
|
256
394
|
const location = parseLocationSelection(raw.location);
|
|
257
395
|
return location ? { ...selector, location } : selector;
|
|
258
396
|
}
|
|
259
|
-
function parseDatasource(raw) {
|
|
260
|
-
if (!
|
|
397
|
+
function parseDatasource(raw, index) {
|
|
398
|
+
if (!isRecord2(raw)) return void 0;
|
|
261
399
|
const kind = raw.kind;
|
|
262
400
|
if (typeof kind !== "string" || kind.length === 0) return void 0;
|
|
401
|
+
if (kind === "unbound") {
|
|
402
|
+
throw new DashboardDefinitionError(
|
|
403
|
+
`widgets[${index}].datasource has kind 'unbound', which is never stored; bind the widget through a slot`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
263
406
|
if (kind === "device") {
|
|
264
407
|
return withLocation(
|
|
265
408
|
{ kind: "device", deviceToken: stringAt(raw, "deviceToken"), measurements: stringArrayAt(raw, "measurements") },
|
|
@@ -267,7 +410,7 @@ function parseDatasource(raw) {
|
|
|
267
410
|
);
|
|
268
411
|
}
|
|
269
412
|
if (kind === "anchor") {
|
|
270
|
-
const anchorRec =
|
|
413
|
+
const anchorRec = isRecord2(raw.anchor) ? raw.anchor : {};
|
|
271
414
|
const selector = withLocation(
|
|
272
415
|
{
|
|
273
416
|
kind: "anchor",
|
|
@@ -282,7 +425,7 @@ function parseDatasource(raw) {
|
|
|
282
425
|
},
|
|
283
426
|
raw
|
|
284
427
|
);
|
|
285
|
-
if (
|
|
428
|
+
if (isRecord2(raw.aggregation)) {
|
|
286
429
|
selector.aggregation = raw.aggregation;
|
|
287
430
|
}
|
|
288
431
|
return selector;
|
|
@@ -296,10 +439,10 @@ function parseDatasource(raw) {
|
|
|
296
439
|
return raw;
|
|
297
440
|
}
|
|
298
441
|
function parseLayout(raw, index) {
|
|
299
|
-
if (!
|
|
442
|
+
if (!isRecord2(raw)) throw new DashboardDefinitionError(`widgets[${index}].layout is missing`);
|
|
300
443
|
const layout = {};
|
|
301
444
|
for (const [bp, box] of Object.entries(raw)) {
|
|
302
|
-
if (
|
|
445
|
+
if (isRecord2(box)) layout[bp] = parseBox(box);
|
|
303
446
|
}
|
|
304
447
|
if (!(BASE_BREAKPOINT in layout)) {
|
|
305
448
|
throw new DashboardDefinitionError(`widgets[${index}].layout has no '${BASE_BREAKPOINT}' box`);
|
|
@@ -316,13 +459,64 @@ function parseBox(rec) {
|
|
|
316
459
|
// so keep it an integer like every other box field.
|
|
317
460
|
z: Math.round(numberAt(rec, "z", 0))
|
|
318
461
|
};
|
|
319
|
-
if (
|
|
462
|
+
if (isRecord2(rec.offset)) {
|
|
320
463
|
box.offset = { x: numberAt(rec.offset, "x", 0), y: numberAt(rec.offset, "y", 0) };
|
|
321
464
|
}
|
|
322
465
|
return box;
|
|
323
466
|
}
|
|
467
|
+
function storedDefinition(def) {
|
|
468
|
+
if (!def.widgets.some((w) => w.type === UNKNOWN_WIDGET_TYPE)) return def;
|
|
469
|
+
return {
|
|
470
|
+
...def,
|
|
471
|
+
widgets: def.widgets.map((w) => w.type === UNKNOWN_WIDGET_TYPE ? storedUnknownWidget(w) : w)
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function storedUnknownWidget(w) {
|
|
475
|
+
let out = w.raw;
|
|
476
|
+
const rawId = storedWidgetId(w.raw);
|
|
477
|
+
if (rawId !== void 0 && rawId !== w.id) out = { ...out, id: w.id };
|
|
478
|
+
const layout = storedUnknownLayout(w.layout, w.raw.layout);
|
|
479
|
+
if (layout !== w.raw.layout) out = { ...out, layout };
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
function storedUnknownLayout(layout, rawLayout) {
|
|
483
|
+
const stored = isRecord2(rawLayout) ? rawLayout : {};
|
|
484
|
+
const out = {};
|
|
485
|
+
let changed = !isRecord2(rawLayout);
|
|
486
|
+
for (const [bp, box] of Object.entries(stored)) {
|
|
487
|
+
if (!isRecord2(box)) {
|
|
488
|
+
out[bp] = box;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const current = Object.prototype.hasOwnProperty.call(layout, bp) ? layout[bp] : void 0;
|
|
492
|
+
if (!current) {
|
|
493
|
+
changed = true;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (sameBox(parseBox(box), current)) {
|
|
497
|
+
out[bp] = box;
|
|
498
|
+
} else {
|
|
499
|
+
out[bp] = movedBox(box, current);
|
|
500
|
+
changed = true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
for (const [bp, box] of Object.entries(layout)) {
|
|
504
|
+
if (Object.prototype.hasOwnProperty.call(stored, bp) && isRecord2(stored[bp])) continue;
|
|
505
|
+
out[bp] = box;
|
|
506
|
+
changed = true;
|
|
507
|
+
}
|
|
508
|
+
return changed ? out : rawLayout;
|
|
509
|
+
}
|
|
510
|
+
function movedBox(stored, current) {
|
|
511
|
+
const out = { ...stored, ...current };
|
|
512
|
+
if (current.offset === void 0) delete out.offset;
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
function sameBox(a, b) {
|
|
516
|
+
return a.col === b.col && a.colSpan === b.colSpan && a.row === b.row && a.rowSpan === b.rowSpan && a.z === b.z && a.offset?.x === b.offset?.x && a.offset?.y === b.offset?.y;
|
|
517
|
+
}
|
|
324
518
|
function serializeDefinition(def) {
|
|
325
|
-
return JSON.stringify(def);
|
|
519
|
+
return JSON.stringify(storedDefinition(def));
|
|
326
520
|
}
|
|
327
521
|
function isDirty(a, b) {
|
|
328
522
|
return serializeDefinition(a) !== serializeDefinition(b);
|
|
@@ -386,6 +580,14 @@ function stripDefaultBindings(def) {
|
|
|
386
580
|
}
|
|
387
581
|
return { ...def, slots };
|
|
388
582
|
}
|
|
583
|
+
function resolveWidgetDatasource(datasource, bindings) {
|
|
584
|
+
if (!datasource || datasource.kind !== "slot") return datasource;
|
|
585
|
+
const binding = bindings && Object.prototype.hasOwnProperty.call(bindings, datasource.slot) ? bindings[datasource.slot] : void 0;
|
|
586
|
+
const series = datasource.location ? { measurements: datasource.measurements, location: datasource.location } : { measurements: datasource.measurements };
|
|
587
|
+
if (binding?.kind === "device") return { kind: "device", deviceToken: binding.deviceToken, ...series };
|
|
588
|
+
if (binding?.kind === "anchor") return { kind: "anchor", anchor: binding.anchor, ...series };
|
|
589
|
+
return { kind: "unbound", ...series };
|
|
590
|
+
}
|
|
389
591
|
|
|
390
592
|
// src/context.ts
|
|
391
593
|
function ownGet(map, key) {
|
|
@@ -527,7 +729,11 @@ function bindWidgetSlot(def, widgetId, binding, measurements, location) {
|
|
|
527
729
|
const currentDef = currentSlot ? slots[currentSlot] : void 0;
|
|
528
730
|
const slot = currentSlot && currentDef && (currentDef.scope || sameBinding(currentDef.defaultBinding, binding)) ? currentSlot : findOrAddSlot(slots, binding);
|
|
529
731
|
const widgets = def.widgets.map(
|
|
530
|
-
(w) =>
|
|
732
|
+
(w) => (
|
|
733
|
+
// A placeholder for an unknown widget type binds nothing this build can see, so it is
|
|
734
|
+
// never given a datasource here: its bindings live in its raw object.
|
|
735
|
+
w.id === widgetId && w.type !== UNKNOWN_WIDGET_TYPE ? { ...w, datasource: slotSelector(slot, measurements, location) } : w
|
|
736
|
+
)
|
|
531
737
|
);
|
|
532
738
|
return { ...def, widgets, slots };
|
|
533
739
|
}
|
|
@@ -539,6 +745,18 @@ function clearWidgetDatasource(def, widgetId) {
|
|
|
539
745
|
});
|
|
540
746
|
return { ...def, widgets };
|
|
541
747
|
}
|
|
748
|
+
var RAW_SLOT_SCAN_DEPTH = 32;
|
|
749
|
+
function collectRawSlotReferences(value, used, depth) {
|
|
750
|
+
if (depth > RAW_SLOT_SCAN_DEPTH || typeof value !== "object" || value === null) return;
|
|
751
|
+
if (Array.isArray(value)) {
|
|
752
|
+
for (const item of value) collectRawSlotReferences(item, used, depth + 1);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const rec = value;
|
|
756
|
+
if (rec.kind === "slot" && typeof rec.slot === "string" && rec.slot.length > 0) used.add(rec.slot);
|
|
757
|
+
if (typeof rec.selectionTarget === "string" && rec.selectionTarget.length > 0) used.add(rec.selectionTarget);
|
|
758
|
+
for (const child of Object.values(rec)) collectRawSlotReferences(child, used, depth + 1);
|
|
759
|
+
}
|
|
542
760
|
function pruneSlots(def) {
|
|
543
761
|
if (!def.slots) return def;
|
|
544
762
|
const defSlots = def.slots;
|
|
@@ -547,6 +765,7 @@ function pruneSlots(def) {
|
|
|
547
765
|
if (w.datasource?.kind === "slot") used.add(w.datasource.slot);
|
|
548
766
|
const target = w.options?.selectionTarget;
|
|
549
767
|
if (typeof target === "string" && target.length > 0) used.add(target);
|
|
768
|
+
if (w.raw) collectRawSlotReferences(w.raw, used, 0);
|
|
550
769
|
}
|
|
551
770
|
for (const start of [...used]) {
|
|
552
771
|
let cur = start;
|
|
@@ -594,10 +813,15 @@ function widgetSlotName(widget) {
|
|
|
594
813
|
}
|
|
595
814
|
function resolveConcrete(def, widget) {
|
|
596
815
|
const binding = widgetBinding(def, widget);
|
|
597
|
-
if (!binding) return void 0;
|
|
598
816
|
const measurements = widget.datasource?.measurements ?? [];
|
|
599
817
|
const location = widget.datasource?.location;
|
|
600
818
|
const base = location ? { measurements, location } : { measurements };
|
|
819
|
+
if (!binding) {
|
|
820
|
+
const ds = widget.datasource;
|
|
821
|
+
if (ds?.kind === "device") return { kind: "device", deviceToken: ds.deviceToken, ...base };
|
|
822
|
+
if (ds?.kind === "anchor") return { kind: "anchor", anchor: ds.anchor, ...base };
|
|
823
|
+
return void 0;
|
|
824
|
+
}
|
|
601
825
|
return binding.kind === "device" ? { kind: "device", deviceToken: binding.deviceToken, ...base } : { kind: "anchor", anchor: binding.anchor, ...base };
|
|
602
826
|
}
|
|
603
827
|
|
|
@@ -938,15 +1162,8 @@ var DashboardHub = class {
|
|
|
938
1162
|
// device stream), so disposeAll() can tear down every map widget's poll.
|
|
939
1163
|
this.locationDisposers = /* @__PURE__ */ new Set();
|
|
940
1164
|
this.resolver = config.resolver;
|
|
941
|
-
this.bindings = config.bindings ?? {};
|
|
942
1165
|
this.authorities = new Set(config.authorities ?? []);
|
|
943
1166
|
}
|
|
944
|
-
// setBindings replaces the slot manifest. New subscriptions resolve through it;
|
|
945
|
-
// callers that need already-open slot streams to re-resolve should re-subscribe
|
|
946
|
-
// (the console keys the renderer on the manifest to do exactly that).
|
|
947
|
-
setBindings(bindings) {
|
|
948
|
-
this.bindings = bindings;
|
|
949
|
-
}
|
|
950
1167
|
// subscribeWidget binds a widget's datasource to a sink and returns a disposer.
|
|
951
1168
|
// Selector resolution is async (anchor→devices); the disposer is returned
|
|
952
1169
|
// synchronously and cancels a still-pending resolution, so tearing a widget down
|
|
@@ -1028,7 +1245,10 @@ var DashboardHub = class {
|
|
|
1028
1245
|
}
|
|
1029
1246
|
// resolveAlarmScope turns an alarm widget's scope selector into the originator device
|
|
1030
1247
|
// tokens to filter on, or tenant-wide when it carries no datasource. Reuses the same
|
|
1031
|
-
// device/anchor/
|
|
1248
|
+
// device/anchor/unbound resolution the measurement channel does.
|
|
1249
|
+
//
|
|
1250
|
+
// 🔴 Only an ABSENT datasource is tenant-wide. `{kind:'unbound'}` is a datasource, and
|
|
1251
|
+
// it resolves to zero devices like any other empty scope.
|
|
1032
1252
|
async resolveAlarmScope(datasource) {
|
|
1033
1253
|
if (!datasource) return { tenantWide: true, tokens: [] };
|
|
1034
1254
|
const groups = await this.resolveDevices(datasource);
|
|
@@ -1276,9 +1496,10 @@ var DashboardHub = class {
|
|
|
1276
1496
|
return this.streams.size;
|
|
1277
1497
|
}
|
|
1278
1498
|
// isDatasourceAvailable reports whether a widget's bound device still exists. Only a
|
|
1279
|
-
// device selector (
|
|
1280
|
-
// slot, or no datasource has a legitimate empty state and is always "available".
|
|
1281
|
-
// open: an existence-check outage returns true (never falsely mark a live device
|
|
1499
|
+
// device selector is validated (a slot bound to a device arrives as one) — an anchor, an
|
|
1500
|
+
// unbound slot, or no datasource has a legitimate empty state and is always "available".
|
|
1501
|
+
// Fails open: an existence-check outage returns true (never falsely mark a live device
|
|
1502
|
+
// gone).
|
|
1282
1503
|
async isDatasourceAvailable(datasource) {
|
|
1283
1504
|
const deviceToken = this.availabilityToken(datasource);
|
|
1284
1505
|
if (deviceToken === void 0) return true;
|
|
@@ -1291,14 +1512,10 @@ var DashboardHub = class {
|
|
|
1291
1512
|
// availabilityToken returns the single device token whose existence gates a widget's
|
|
1292
1513
|
// availability, or undefined when there is nothing device-specific to validate (an
|
|
1293
1514
|
// anchor's membership is self-validating; an unbound slot is a placeholder; a reserved
|
|
1294
|
-
// kind isn't resolved yet).
|
|
1515
|
+
// kind isn't resolved yet; an unresolved slot fails on its data channel instead).
|
|
1295
1516
|
availabilityToken(datasource) {
|
|
1296
1517
|
if (!datasource) return void 0;
|
|
1297
1518
|
if (datasource.kind === "device") return datasource.deviceToken || void 0;
|
|
1298
|
-
if (datasource.kind === "slot") {
|
|
1299
|
-
const binding = Object.prototype.hasOwnProperty.call(this.bindings, datasource.slot) ? this.bindings[datasource.slot] : void 0;
|
|
1300
|
-
return binding && binding.kind === "device" ? binding.deviceToken || void 0 : void 0;
|
|
1301
|
-
}
|
|
1302
1519
|
return void 0;
|
|
1303
1520
|
}
|
|
1304
1521
|
// resolveDevices turns a selector into the devices to stream, each with the
|
|
@@ -1316,11 +1533,12 @@ var DashboardHub = class {
|
|
|
1316
1533
|
{ kind: "anchor", anchor: datasource.anchor },
|
|
1317
1534
|
new Set(datasource.measurements)
|
|
1318
1535
|
);
|
|
1319
|
-
case "
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1536
|
+
case "unbound":
|
|
1537
|
+
return [];
|
|
1538
|
+
case "slot":
|
|
1539
|
+
throw new Error(
|
|
1540
|
+
`dashboard slot '${datasource.slot}' reached the data source unresolved; resolve it through the bindings first (resolveWidgetDatasource)`
|
|
1541
|
+
);
|
|
1324
1542
|
default:
|
|
1325
1543
|
throw new Error(
|
|
1326
1544
|
`dashboard selector kind '${datasource.kind}' is not supported yet`
|
|
@@ -1640,6 +1858,853 @@ var SyntheticDataSource = class {
|
|
|
1640
1858
|
}
|
|
1641
1859
|
};
|
|
1642
1860
|
|
|
1861
|
+
// src/recording.ts
|
|
1862
|
+
var RECORDING_FORMAT_VERSION = 1;
|
|
1863
|
+
var RECORDING_KIND = "sitepulse-board-recording";
|
|
1864
|
+
var ALARM_STATES = ["ACTIVE", "CLEARED"];
|
|
1865
|
+
var ALARM_SEVERITIES = ["CRITICAL", "MAJOR", "MINOR", "WARNING"];
|
|
1866
|
+
var RecordingFormatError = class extends Error {
|
|
1867
|
+
constructor(path, reason) {
|
|
1868
|
+
super(`invalid recording at ${path}: ${reason}`);
|
|
1869
|
+
this.path = path;
|
|
1870
|
+
this.name = "RecordingFormatError";
|
|
1871
|
+
}
|
|
1872
|
+
};
|
|
1873
|
+
function recordingBounds(rec) {
|
|
1874
|
+
return rec.excerpt ? { ...rec.excerpt } : { fromMs: 0, toMs: rec.durationMs };
|
|
1875
|
+
}
|
|
1876
|
+
function recordedWallTimeMs(rec, tMs) {
|
|
1877
|
+
return Date.parse(rec.startedAtUtc) + tMs;
|
|
1878
|
+
}
|
|
1879
|
+
function simulationScaleAt(rec, tMs) {
|
|
1880
|
+
let hit = null;
|
|
1881
|
+
for (const seg of rec.clock) {
|
|
1882
|
+
if (seg.fromMs <= tMs) hit = seg;
|
|
1883
|
+
}
|
|
1884
|
+
return hit ? { mode: hit.mode, scale: hit.scale } : null;
|
|
1885
|
+
}
|
|
1886
|
+
function isObj(v) {
|
|
1887
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1888
|
+
}
|
|
1889
|
+
function strict(v, path, required, optional = []) {
|
|
1890
|
+
if (!isObj(v)) throw new RecordingFormatError(path, "expected an object");
|
|
1891
|
+
const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
|
|
1892
|
+
for (const key of Object.keys(v)) {
|
|
1893
|
+
if (!allowed.has(key)) throw new RecordingFormatError(`${path}.${key}`, "unknown key");
|
|
1894
|
+
}
|
|
1895
|
+
for (const key of required) {
|
|
1896
|
+
if (!Object.prototype.hasOwnProperty.call(v, key)) {
|
|
1897
|
+
throw new RecordingFormatError(`${path}.${key}`, "missing required key");
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
return v;
|
|
1901
|
+
}
|
|
1902
|
+
function str(v, path) {
|
|
1903
|
+
if (typeof v !== "string" || v === "") throw new RecordingFormatError(path, "expected a non-empty string");
|
|
1904
|
+
return v;
|
|
1905
|
+
}
|
|
1906
|
+
function bool(v, path) {
|
|
1907
|
+
if (typeof v !== "boolean") throw new RecordingFormatError(path, "expected a boolean");
|
|
1908
|
+
return v;
|
|
1909
|
+
}
|
|
1910
|
+
function num(v, path) {
|
|
1911
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new RecordingFormatError(path, "expected a finite number");
|
|
1912
|
+
return v;
|
|
1913
|
+
}
|
|
1914
|
+
function arr(v, path) {
|
|
1915
|
+
if (!Array.isArray(v)) throw new RecordingFormatError(path, "expected an array");
|
|
1916
|
+
return v;
|
|
1917
|
+
}
|
|
1918
|
+
function oneOf(v, path, allowed) {
|
|
1919
|
+
if (typeof v !== "string" || !allowed.includes(v)) {
|
|
1920
|
+
throw new RecordingFormatError(path, `expected one of ${allowed.join(", ")}`);
|
|
1921
|
+
}
|
|
1922
|
+
return v;
|
|
1923
|
+
}
|
|
1924
|
+
function matching(v, path, re, what) {
|
|
1925
|
+
if (typeof v !== "string" || !re.test(v)) throw new RecordingFormatError(path, `expected ${what}`);
|
|
1926
|
+
return v;
|
|
1927
|
+
}
|
|
1928
|
+
var HEX64 = /^[0-9a-f]{64}$/;
|
|
1929
|
+
var HEX40 = /^[0-9a-f]{40}$/;
|
|
1930
|
+
var HEX_SHORT = /^[0-9a-f]{7,40}$/;
|
|
1931
|
+
var INSTANT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?Z$/;
|
|
1932
|
+
function instant(v, path) {
|
|
1933
|
+
const s = str(v, path);
|
|
1934
|
+
const m = INSTANT.exec(s);
|
|
1935
|
+
if (m) {
|
|
1936
|
+
const [y, mo, d, h, mi, sec] = m.slice(1, 7).map(Number);
|
|
1937
|
+
const t = new Date(Date.UTC(y, mo - 1, d, h, mi, sec));
|
|
1938
|
+
if (t.getUTCFullYear() === y && t.getUTCMonth() === mo - 1 && t.getUTCDate() === d && t.getUTCHours() === h && t.getUTCMinutes() === mi && t.getUTCSeconds() === sec) {
|
|
1939
|
+
return s;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
throw new RecordingFormatError(path, "expected an RFC 3339 UTC instant ending in Z that exists on the calendar");
|
|
1943
|
+
}
|
|
1944
|
+
function compareInstants(a, b) {
|
|
1945
|
+
const pa = INSTANT.exec(a);
|
|
1946
|
+
const pb = INSTANT.exec(b);
|
|
1947
|
+
if (!pa || !pb) throw new RangeError(`compareInstants: not an instant: ${pa ? b : a}`);
|
|
1948
|
+
const whole = (m) => Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`);
|
|
1949
|
+
const wa = whole(pa);
|
|
1950
|
+
const wb = whole(pb);
|
|
1951
|
+
if (wa !== wb) return wa < wb ? -1 : 1;
|
|
1952
|
+
const fa = (pa[7] ?? "").padEnd(12, "0");
|
|
1953
|
+
const fb = (pb[7] ?? "").padEnd(12, "0");
|
|
1954
|
+
return fa < fb ? -1 : fa > fb ? 1 : 0;
|
|
1955
|
+
}
|
|
1956
|
+
function column(v, path, item, length) {
|
|
1957
|
+
const a = arr(v, path);
|
|
1958
|
+
if (length !== void 0 && a.length !== length) {
|
|
1959
|
+
throw new RecordingFormatError(path, `length ${a.length} does not match the time column (${length})`);
|
|
1960
|
+
}
|
|
1961
|
+
return a.map((x, i) => item(x, `${path}[${i}]`));
|
|
1962
|
+
}
|
|
1963
|
+
function nonNegative(v, path) {
|
|
1964
|
+
const n = num(v, path);
|
|
1965
|
+
if (n < 0) throw new RecordingFormatError(path, "must not be negative");
|
|
1966
|
+
return n;
|
|
1967
|
+
}
|
|
1968
|
+
function parseBoardRecording(input) {
|
|
1969
|
+
const root = strict(
|
|
1970
|
+
input,
|
|
1971
|
+
"$",
|
|
1972
|
+
[
|
|
1973
|
+
"kind",
|
|
1974
|
+
"formatVersion",
|
|
1975
|
+
"runId",
|
|
1976
|
+
"startedAtUtc",
|
|
1977
|
+
"durationMs",
|
|
1978
|
+
"platformVersion",
|
|
1979
|
+
"instance",
|
|
1980
|
+
"tenant",
|
|
1981
|
+
"build",
|
|
1982
|
+
"clock",
|
|
1983
|
+
"devices",
|
|
1984
|
+
"channels",
|
|
1985
|
+
"board",
|
|
1986
|
+
"sourceHashes",
|
|
1987
|
+
"converter",
|
|
1988
|
+
"chapters",
|
|
1989
|
+
"measurements",
|
|
1990
|
+
"alarms",
|
|
1991
|
+
"locations"
|
|
1992
|
+
],
|
|
1993
|
+
["excerpt"]
|
|
1994
|
+
);
|
|
1995
|
+
if (root.kind !== RECORDING_KIND) {
|
|
1996
|
+
throw new RecordingFormatError("$.kind", `expected ${JSON.stringify(RECORDING_KIND)}`);
|
|
1997
|
+
}
|
|
1998
|
+
if (root.formatVersion !== RECORDING_FORMAT_VERSION) {
|
|
1999
|
+
throw new RecordingFormatError(
|
|
2000
|
+
"$.formatVersion",
|
|
2001
|
+
`unsupported version ${JSON.stringify(root.formatVersion)}; this reader understands ${RECORDING_FORMAT_VERSION}`
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
const durationMs = num(root.durationMs, "$.durationMs");
|
|
2005
|
+
if (durationMs <= 0) throw new RecordingFormatError("$.durationMs", "must be positive");
|
|
2006
|
+
const b = strict(root.build, "$.build", ["gitSha", "sdkCommit"]);
|
|
2007
|
+
const build = {
|
|
2008
|
+
gitSha: matching(b.gitSha, "$.build.gitSha", HEX_SHORT, "a lowercase hex commit id"),
|
|
2009
|
+
sdkCommit: matching(b.sdkCommit, "$.build.sdkCommit", HEX_SHORT, "a lowercase hex commit id")
|
|
2010
|
+
};
|
|
2011
|
+
const clock = column(root.clock, "$.clock", (x, p) => {
|
|
2012
|
+
const o = strict(x, p, ["fromMs", "mode", "scale"]);
|
|
2013
|
+
const scale = num(o.scale, `${p}.scale`);
|
|
2014
|
+
if (scale <= 0) throw new RecordingFormatError(`${p}.scale`, "must be positive");
|
|
2015
|
+
return { fromMs: nonNegative(o.fromMs, `${p}.fromMs`), mode: str(o.mode, `${p}.mode`), scale };
|
|
2016
|
+
});
|
|
2017
|
+
clock.forEach((seg, i) => {
|
|
2018
|
+
if (i > 0 && seg.fromMs <= clock[i - 1].fromMs) throw new RecordingFormatError(`$.clock[${i}].fromMs`, "must increase");
|
|
2019
|
+
});
|
|
2020
|
+
const devices = column(root.devices, "$.devices", (x, p) => {
|
|
2021
|
+
const o = strict(x, p, ["id", "token", "kind"]);
|
|
2022
|
+
return { id: str(o.id, `${p}.id`), token: str(o.token, `${p}.token`), kind: str(o.kind, `${p}.kind`) };
|
|
2023
|
+
});
|
|
2024
|
+
const tokens = new Set(devices.map((d) => d.token));
|
|
2025
|
+
if (tokens.size !== devices.length || new Set(devices.map((d) => d.id)).size !== devices.length) {
|
|
2026
|
+
throw new RecordingFormatError("$.devices", "duplicate device id or token");
|
|
2027
|
+
}
|
|
2028
|
+
const ch = strict(root.channels, "$.channels", ["measurements", "alarms", "locations", "commands"]);
|
|
2029
|
+
if (ch.locations !== false) {
|
|
2030
|
+
throw new RecordingFormatError("$.channels.locations", "this version records no positions; must be false");
|
|
2031
|
+
}
|
|
2032
|
+
if (ch.commands !== false) {
|
|
2033
|
+
throw new RecordingFormatError("$.channels.commands", "this version records no command data; must be false");
|
|
2034
|
+
}
|
|
2035
|
+
const channels = {
|
|
2036
|
+
measurements: column(ch.measurements, "$.channels.measurements", str),
|
|
2037
|
+
alarms: bool(ch.alarms, "$.channels.alarms"),
|
|
2038
|
+
locations: false,
|
|
2039
|
+
commands: false
|
|
2040
|
+
};
|
|
2041
|
+
if (new Set(channels.measurements).size !== channels.measurements.length) {
|
|
2042
|
+
throw new RecordingFormatError("$.channels.measurements", "duplicate measurement name");
|
|
2043
|
+
}
|
|
2044
|
+
const bd = strict(root.board, "$.board", ["path", "sourceCommit", "sha256"]);
|
|
2045
|
+
const board = {
|
|
2046
|
+
path: str(bd.path, "$.board.path"),
|
|
2047
|
+
sourceCommit: matching(bd.sourceCommit, "$.board.sourceCommit", HEX40, "a full lowercase hex commit id"),
|
|
2048
|
+
sha256: matching(bd.sha256, "$.board.sha256", HEX64, "a lowercase hex SHA-256")
|
|
2049
|
+
};
|
|
2050
|
+
const sh = root.sourceHashes;
|
|
2051
|
+
if (!isObj(sh)) throw new RecordingFormatError("$.sourceHashes", "expected an object");
|
|
2052
|
+
const sourceHashes = {};
|
|
2053
|
+
for (const key of Object.keys(sh)) {
|
|
2054
|
+
if (key === "__proto__") throw new RecordingFormatError(`$.sourceHashes.${key}`, "reserved key");
|
|
2055
|
+
sourceHashes[key] = matching(sh[key], `$.sourceHashes.${key}`, HEX64, "a lowercase hex SHA-256");
|
|
2056
|
+
}
|
|
2057
|
+
const cv = strict(root.converter, "$.converter", ["version"]);
|
|
2058
|
+
const converter = { version: num(cv.version, "$.converter.version") };
|
|
2059
|
+
let excerpt;
|
|
2060
|
+
if (root.excerpt !== void 0) {
|
|
2061
|
+
const e = strict(root.excerpt, "$.excerpt", ["fromMs", "toMs"]);
|
|
2062
|
+
excerpt = { fromMs: nonNegative(e.fromMs, "$.excerpt.fromMs"), toMs: nonNegative(e.toMs, "$.excerpt.toMs") };
|
|
2063
|
+
if (excerpt.fromMs >= excerpt.toMs || excerpt.toMs > durationMs) {
|
|
2064
|
+
throw new RecordingFormatError("$.excerpt", "must be a non-empty window inside the run");
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
const lo = excerpt ? excerpt.fromMs : 0;
|
|
2068
|
+
const hi = excerpt ? excerpt.toMs : durationMs;
|
|
2069
|
+
const chapters = column(root.chapters, "$.chapters", (x, p) => {
|
|
2070
|
+
const o = strict(x, p, ["tMs", "name", "note"]);
|
|
2071
|
+
const tMs = num(o.tMs, `${p}.tMs`);
|
|
2072
|
+
if (tMs < 0 || tMs > durationMs) throw new RecordingFormatError(`${p}.tMs`, "is outside the run");
|
|
2073
|
+
if (excerpt && (tMs < lo || tMs > hi)) throw new RecordingFormatError(`${p}.tMs`, "is outside the excerpt window");
|
|
2074
|
+
return { tMs, name: str(o.name, `${p}.name`), note: str(o.note, `${p}.note`) };
|
|
2075
|
+
});
|
|
2076
|
+
chapters.forEach((c, i) => {
|
|
2077
|
+
if (i > 0 && c.tMs < chapters[i - 1].tMs) throw new RecordingFormatError(`$.chapters[${i}].tMs`, "chapters must not decrease in time");
|
|
2078
|
+
});
|
|
2079
|
+
const m = strict(root.measurements, "$.measurements", ["d", "n", "t", "v", "s"]);
|
|
2080
|
+
const t = column(m.t, "$.measurements.t", num);
|
|
2081
|
+
let prev = lo;
|
|
2082
|
+
t.forEach((x, i) => {
|
|
2083
|
+
if (x < lo || x > hi) throw new RecordingFormatError(`$.measurements.t[${i}]`, `${x} is outside [${lo}, ${hi}]`);
|
|
2084
|
+
if (x < prev) throw new RecordingFormatError(`$.measurements.t[${i}]`, "time column must not decrease");
|
|
2085
|
+
prev = x;
|
|
2086
|
+
});
|
|
2087
|
+
const index = (name, limit) => (x, p) => {
|
|
2088
|
+
const n = num(x, p);
|
|
2089
|
+
if (!Number.isInteger(n) || n < 0 || n >= limit) throw new RecordingFormatError(p, `${name} index out of range`);
|
|
2090
|
+
return n;
|
|
2091
|
+
};
|
|
2092
|
+
const measurements = {
|
|
2093
|
+
d: column(m.d, "$.measurements.d", index("device", devices.length), t.length),
|
|
2094
|
+
n: column(m.n, "$.measurements.n", index("measurement", channels.measurements.length), t.length),
|
|
2095
|
+
t,
|
|
2096
|
+
v: column(m.v, "$.measurements.v", num, t.length),
|
|
2097
|
+
s: column(
|
|
2098
|
+
m.s,
|
|
2099
|
+
"$.measurements.s",
|
|
2100
|
+
(x, p) => {
|
|
2101
|
+
if (x !== 0 && x !== 1) throw new RecordingFormatError(p, "expected 0 or 1");
|
|
2102
|
+
return x;
|
|
2103
|
+
},
|
|
2104
|
+
t.length
|
|
2105
|
+
)
|
|
2106
|
+
};
|
|
2107
|
+
const al = strict(root.alarms, "$.alarms", ["snapshots", "events"]);
|
|
2108
|
+
const alarm = (o, p) => {
|
|
2109
|
+
const id = matching(o.id, `${p}.id`, /^alarm-\d+$/, "a re-minted id (alarm-N)");
|
|
2110
|
+
const dev = str(o.dev, `${p}.dev`);
|
|
2111
|
+
if (!tokens.has(dev)) throw new RecordingFormatError(`${p}.dev`, `device '${dev}' is not in $.devices`);
|
|
2112
|
+
if (o.ack !== void 0 && o.ack !== true) throw new RecordingFormatError(`${p}.ack`, "present only when true");
|
|
2113
|
+
const out = {
|
|
2114
|
+
id,
|
|
2115
|
+
dev,
|
|
2116
|
+
key: str(o.key, `${p}.key`),
|
|
2117
|
+
metric: str(o.metric, `${p}.metric`),
|
|
2118
|
+
state: oneOf(o.state, `${p}.state`, ALARM_STATES),
|
|
2119
|
+
sev: oneOf(o.sev, `${p}.sev`, ALARM_SEVERITIES),
|
|
2120
|
+
occ: instant(o.occ, `${p}.occ`)
|
|
2121
|
+
};
|
|
2122
|
+
if (o.ack === true) out.ack = true;
|
|
2123
|
+
return out;
|
|
2124
|
+
};
|
|
2125
|
+
const ALARM_KEYS = ["id", "dev", "key", "metric", "state", "sev", "occ"];
|
|
2126
|
+
const checkTime = (tMs, p) => {
|
|
2127
|
+
if (tMs < 0 || tMs > hi) throw new RecordingFormatError(p, `${tMs} is outside [0, ${hi}]`);
|
|
2128
|
+
return tMs;
|
|
2129
|
+
};
|
|
2130
|
+
const snapshots = column(al.snapshots, "$.alarms.snapshots", (x, p) => {
|
|
2131
|
+
const o = strict(x, p, ["tMs", "requestedAtMs", "total", "alarms"]);
|
|
2132
|
+
const alarms = column(o.alarms, `${p}.alarms`, (a, ap) => alarm(strict(a, ap, ALARM_KEYS, ["ack"]), ap));
|
|
2133
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2134
|
+
alarms.forEach((a, i) => {
|
|
2135
|
+
if (ids.has(a.id)) throw new RecordingFormatError(`${p}.alarms[${i}].id`, `duplicate alarm ${a.id} in one snapshot`);
|
|
2136
|
+
ids.add(a.id);
|
|
2137
|
+
});
|
|
2138
|
+
const total = nonNegative(o.total, `${p}.total`);
|
|
2139
|
+
if (total !== alarms.length) {
|
|
2140
|
+
throw new RecordingFormatError(`${p}.total`, `is ${total} but ${alarms.length} alarms are listed (truncated snapshot)`);
|
|
2141
|
+
}
|
|
2142
|
+
const tMs = checkTime(num(o.tMs, `${p}.tMs`), `${p}.tMs`);
|
|
2143
|
+
const requestedAtMs = nonNegative(o.requestedAtMs, `${p}.requestedAtMs`);
|
|
2144
|
+
if (!Number.isInteger(requestedAtMs)) throw new RecordingFormatError(`${p}.requestedAtMs`, "expected an integer");
|
|
2145
|
+
if (requestedAtMs > tMs) throw new RecordingFormatError(`${p}.requestedAtMs`, "a query cannot be sent after its answer");
|
|
2146
|
+
return { tMs, requestedAtMs, total, alarms };
|
|
2147
|
+
});
|
|
2148
|
+
const events = column(al.events, "$.alarms.events", (x, p) => {
|
|
2149
|
+
const o = strict(x, p, [...ALARM_KEYS, "tMs"], ["ack"]);
|
|
2150
|
+
return { ...alarm(o, p), tMs: checkTime(num(o.tMs, `${p}.tMs`), `${p}.tMs`) };
|
|
2151
|
+
});
|
|
2152
|
+
for (const [name, list] of [["snapshots", snapshots], ["events", events]]) {
|
|
2153
|
+
list.forEach((e, i) => {
|
|
2154
|
+
if (i > 0 && e.tMs < list[i - 1].tMs) throw new RecordingFormatError(`$.alarms.${name}[${i}].tMs`, "must not decrease");
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
const deviceOf = /* @__PURE__ */ new Map();
|
|
2158
|
+
const everyAlarm = [
|
|
2159
|
+
...snapshots.flatMap((s, i) => s.alarms.map((a, j) => [`$.alarms.snapshots[${i}].alarms[${j}]`, a])),
|
|
2160
|
+
...events.map((e, i) => [`$.alarms.events[${i}]`, e])
|
|
2161
|
+
];
|
|
2162
|
+
for (const [path, a] of everyAlarm) {
|
|
2163
|
+
const seen = deviceOf.get(a.id);
|
|
2164
|
+
if (seen !== void 0 && seen !== a.dev) {
|
|
2165
|
+
throw new RecordingFormatError(`${path}.dev`, `alarm ${a.id} was on '${seen}' and cannot move to '${a.dev}'`);
|
|
2166
|
+
}
|
|
2167
|
+
deviceOf.set(a.id, a.dev);
|
|
2168
|
+
}
|
|
2169
|
+
if (!channels.alarms && (snapshots.length > 0 || events.length > 0)) {
|
|
2170
|
+
throw new RecordingFormatError("$.alarms", "history present but $.channels.alarms is false");
|
|
2171
|
+
}
|
|
2172
|
+
if (!Array.isArray(root.locations) || root.locations.length !== 0) {
|
|
2173
|
+
throw new RecordingFormatError("$.locations", "this version carries no positions; must be an empty array");
|
|
2174
|
+
}
|
|
2175
|
+
const rec = {
|
|
2176
|
+
kind: RECORDING_KIND,
|
|
2177
|
+
formatVersion: RECORDING_FORMAT_VERSION,
|
|
2178
|
+
runId: str(root.runId, "$.runId"),
|
|
2179
|
+
startedAtUtc: instant(root.startedAtUtc, "$.startedAtUtc"),
|
|
2180
|
+
durationMs,
|
|
2181
|
+
platformVersion: str(root.platformVersion, "$.platformVersion"),
|
|
2182
|
+
instance: str(root.instance, "$.instance"),
|
|
2183
|
+
tenant: str(root.tenant, "$.tenant"),
|
|
2184
|
+
build,
|
|
2185
|
+
clock,
|
|
2186
|
+
devices,
|
|
2187
|
+
channels,
|
|
2188
|
+
board,
|
|
2189
|
+
sourceHashes,
|
|
2190
|
+
converter,
|
|
2191
|
+
chapters,
|
|
2192
|
+
measurements,
|
|
2193
|
+
alarms: { snapshots, events },
|
|
2194
|
+
locations: []
|
|
2195
|
+
};
|
|
2196
|
+
if (excerpt) rec.excerpt = excerpt;
|
|
2197
|
+
return rec;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// src/recorded.ts
|
|
2201
|
+
var RECORDED_HISTORY_WINDOW_MS = 60 * 60 * 1e3;
|
|
2202
|
+
var NotInRecordingError = class extends Error {
|
|
2203
|
+
constructor(channel, detail = "") {
|
|
2204
|
+
super(`Not in this recording${detail ? `: ${detail}` : ""}`);
|
|
2205
|
+
this.name = "NotInRecordingError";
|
|
2206
|
+
this.channel = channel;
|
|
2207
|
+
this.detail = detail;
|
|
2208
|
+
}
|
|
2209
|
+
// The widget error frame prints String(error). The default would read
|
|
2210
|
+
// "NotInRecordingError: Not in this recording", which says the name twice; the message
|
|
2211
|
+
// alone is what a viewer should see.
|
|
2212
|
+
toString() {
|
|
2213
|
+
return this.message;
|
|
2214
|
+
}
|
|
2215
|
+
};
|
|
2216
|
+
var PLAYBACK_RATES = [1, 4, 16];
|
|
2217
|
+
function defaultTicker() {
|
|
2218
|
+
return {
|
|
2219
|
+
start(onFrame) {
|
|
2220
|
+
const now = () => typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
2221
|
+
let last = now();
|
|
2222
|
+
const step = () => {
|
|
2223
|
+
const t = now();
|
|
2224
|
+
const dt = t - last;
|
|
2225
|
+
last = t;
|
|
2226
|
+
onFrame(dt);
|
|
2227
|
+
};
|
|
2228
|
+
if (typeof requestAnimationFrame === "function") {
|
|
2229
|
+
let handle = 0;
|
|
2230
|
+
let stopped = false;
|
|
2231
|
+
const loop = () => {
|
|
2232
|
+
if (stopped) return;
|
|
2233
|
+
step();
|
|
2234
|
+
handle = requestAnimationFrame(loop);
|
|
2235
|
+
};
|
|
2236
|
+
handle = requestAnimationFrame(loop);
|
|
2237
|
+
return () => {
|
|
2238
|
+
stopped = true;
|
|
2239
|
+
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(handle);
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
const timer = setInterval(step, 100);
|
|
2243
|
+
return () => clearInterval(timer);
|
|
2244
|
+
}
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
var RecordedClock = class {
|
|
2248
|
+
constructor(durationMs, options = {}) {
|
|
2249
|
+
this.isPlaying = false;
|
|
2250
|
+
this.playbackRate = 1;
|
|
2251
|
+
this.stopTicker = null;
|
|
2252
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
2253
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
|
2254
|
+
throw new RangeError(`RecordedClock: durationMs must be positive and finite, got ${durationMs}`);
|
|
2255
|
+
}
|
|
2256
|
+
this.durationMs = durationMs;
|
|
2257
|
+
this.minMs = options.minMs ?? 0;
|
|
2258
|
+
if (!Number.isFinite(this.minMs) || this.minMs < 0 || this.minMs >= durationMs) {
|
|
2259
|
+
throw new RangeError(`RecordedClock: minMs must be in [0, ${durationMs}), got ${this.minMs}`);
|
|
2260
|
+
}
|
|
2261
|
+
this.ticker = options.ticker ?? defaultTicker();
|
|
2262
|
+
this.time = this.clampTime(options.startMs ?? this.minMs);
|
|
2263
|
+
}
|
|
2264
|
+
get timeMs() {
|
|
2265
|
+
return this.time;
|
|
2266
|
+
}
|
|
2267
|
+
get playing() {
|
|
2268
|
+
return this.isPlaying;
|
|
2269
|
+
}
|
|
2270
|
+
get rate() {
|
|
2271
|
+
return this.playbackRate;
|
|
2272
|
+
}
|
|
2273
|
+
get ended() {
|
|
2274
|
+
return this.time >= this.durationMs;
|
|
2275
|
+
}
|
|
2276
|
+
// play starts the cursor moving. At the end of the run it does nothing: playback stops
|
|
2277
|
+
// there (no loop seam) and a host that wants to start over seeks to 0 first.
|
|
2278
|
+
play() {
|
|
2279
|
+
if (this.isPlaying || this.ended) return;
|
|
2280
|
+
this.isPlaying = true;
|
|
2281
|
+
this.stopTicker = this.ticker.start((dt) => this.advance(dt));
|
|
2282
|
+
this.emit("state");
|
|
2283
|
+
}
|
|
2284
|
+
pause() {
|
|
2285
|
+
if (!this.isPlaying) return;
|
|
2286
|
+
this.halt();
|
|
2287
|
+
this.emit("state");
|
|
2288
|
+
}
|
|
2289
|
+
// seek moves the cursor, clamped to [minMs, durationMs]. Playback state is unchanged, except
|
|
2290
|
+
// that seeking to the very end stops it (there is nothing left to play).
|
|
2291
|
+
seek(tMs) {
|
|
2292
|
+
if (!Number.isFinite(tMs)) throw new RangeError(`RecordedClock.seek: not a finite time: ${tMs}`);
|
|
2293
|
+
this.time = this.clampTime(tMs);
|
|
2294
|
+
if (this.isPlaying && this.ended) this.halt();
|
|
2295
|
+
this.emit("seek");
|
|
2296
|
+
}
|
|
2297
|
+
setRate(rate) {
|
|
2298
|
+
if (!PLAYBACK_RATES.includes(rate)) {
|
|
2299
|
+
throw new RangeError(`RecordedClock.setRate: ${rate} is not one of ${PLAYBACK_RATES.join(", ")}`);
|
|
2300
|
+
}
|
|
2301
|
+
this.playbackRate = rate;
|
|
2302
|
+
this.emit("state");
|
|
2303
|
+
}
|
|
2304
|
+
// advance moves the cursor by wallDeltaMs of real time (scaled by the playback rate).
|
|
2305
|
+
// The ticker calls this each frame; a test calls it directly. It does nothing while
|
|
2306
|
+
// paused, and stops at the end of the run.
|
|
2307
|
+
advance(wallDeltaMs) {
|
|
2308
|
+
if (!this.isPlaying) return;
|
|
2309
|
+
if (!Number.isFinite(wallDeltaMs) || wallDeltaMs < 0) {
|
|
2310
|
+
throw new RangeError(`RecordedClock.advance: expected a non-negative finite delta, got ${wallDeltaMs}`);
|
|
2311
|
+
}
|
|
2312
|
+
this.time = this.clampTime(this.time + wallDeltaMs * this.playbackRate);
|
|
2313
|
+
if (this.ended) this.halt();
|
|
2314
|
+
this.emit("tick");
|
|
2315
|
+
}
|
|
2316
|
+
subscribe(listener) {
|
|
2317
|
+
this.listeners.add(listener);
|
|
2318
|
+
return () => {
|
|
2319
|
+
this.listeners.delete(listener);
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
halt() {
|
|
2323
|
+
this.isPlaying = false;
|
|
2324
|
+
this.stopTicker?.();
|
|
2325
|
+
this.stopTicker = null;
|
|
2326
|
+
}
|
|
2327
|
+
clampTime(t) {
|
|
2328
|
+
return t < this.minMs ? this.minMs : t > this.durationMs ? this.durationMs : t;
|
|
2329
|
+
}
|
|
2330
|
+
emit(reason) {
|
|
2331
|
+
for (const listener of [...this.listeners]) listener({ timeMs: this.time, reason });
|
|
2332
|
+
}
|
|
2333
|
+
};
|
|
2334
|
+
function lowerBound(a, x) {
|
|
2335
|
+
let lo = 0;
|
|
2336
|
+
let hi = a.length;
|
|
2337
|
+
while (lo < hi) {
|
|
2338
|
+
const mid = lo + hi >>> 1;
|
|
2339
|
+
if (a[mid] < x) lo = mid + 1;
|
|
2340
|
+
else hi = mid;
|
|
2341
|
+
}
|
|
2342
|
+
return lo;
|
|
2343
|
+
}
|
|
2344
|
+
function upperBound(a, x) {
|
|
2345
|
+
let lo = 0;
|
|
2346
|
+
let hi = a.length;
|
|
2347
|
+
while (lo < hi) {
|
|
2348
|
+
const mid = lo + hi >>> 1;
|
|
2349
|
+
if (a[mid] <= x) lo = mid + 1;
|
|
2350
|
+
else hi = mid;
|
|
2351
|
+
}
|
|
2352
|
+
return lo;
|
|
2353
|
+
}
|
|
2354
|
+
function createRecordedClock(rec, options = {}) {
|
|
2355
|
+
const { fromMs, toMs } = recordingBounds(rec);
|
|
2356
|
+
return new RecordedClock(toMs, { minMs: fromMs, ticker: options.ticker });
|
|
2357
|
+
}
|
|
2358
|
+
function sameAnchor(a, b) {
|
|
2359
|
+
return a.relationship === b.relationship && a.targetType === b.targetType && a.targetToken === b.targetToken;
|
|
2360
|
+
}
|
|
2361
|
+
function declaredSite(options) {
|
|
2362
|
+
const anchors = options.siteAnchors;
|
|
2363
|
+
if (anchors === void 0) return null;
|
|
2364
|
+
if (anchors.length !== 1) {
|
|
2365
|
+
throw new Error(`RecordedSiteOptions.siteAnchors must declare exactly one anchor, got ${anchors.length}`);
|
|
2366
|
+
}
|
|
2367
|
+
if (anchors[0].targetType !== "customer") {
|
|
2368
|
+
throw new Error(
|
|
2369
|
+
`RecordedSiteOptions.siteAnchors must be a customer anchor, got '${anchors[0].targetType}': a recording cannot say that every device belongs to an area or an asset`
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
return anchors[0];
|
|
2373
|
+
}
|
|
2374
|
+
function anchorMembers(rec, site, anchor) {
|
|
2375
|
+
if (!site || !sameAnchor(site, anchor)) {
|
|
2376
|
+
throw new Error(
|
|
2377
|
+
`anchor ${anchor.targetType} '${anchor.targetToken}' (${anchor.relationship}) is not in this recording`
|
|
2378
|
+
);
|
|
2379
|
+
}
|
|
2380
|
+
return rec.devices.map((d) => d.token);
|
|
2381
|
+
}
|
|
2382
|
+
function createRecordedResolver(rec, options = {}) {
|
|
2383
|
+
const devices = new Set(rec.devices.map((d) => d.token));
|
|
2384
|
+
const site = declaredSite(options);
|
|
2385
|
+
return {
|
|
2386
|
+
devicesForAnchor: async (anchor) => anchorMembers(rec, site, anchor),
|
|
2387
|
+
deviceExists: async (token) => devices.has(token)
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
function createRecordedLister(rec, options = {}) {
|
|
2391
|
+
const site = declaredSite(options);
|
|
2392
|
+
return async (kind) => {
|
|
2393
|
+
if (kind === "device") return rec.devices.map((d) => ({ token: d.token, name: null }));
|
|
2394
|
+
return site && site.targetType === kind ? [{ token: site.targetToken, name: null }] : [];
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
var indexCache = /* @__PURE__ */ new WeakMap();
|
|
2398
|
+
function indexOf(rec) {
|
|
2399
|
+
const cached = indexCache.get(rec);
|
|
2400
|
+
if (cached) return cached;
|
|
2401
|
+
const bySeries = /* @__PURE__ */ new Map();
|
|
2402
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
2403
|
+
const { d, n, t } = rec.measurements;
|
|
2404
|
+
for (let i = 0; i < t.length; i++) {
|
|
2405
|
+
const key = d[i] * rec.channels.measurements.length + n[i];
|
|
2406
|
+
let s = bySeries.get(String(key));
|
|
2407
|
+
if (!s) {
|
|
2408
|
+
s = { device: rec.devices[d[i]].token, name: rec.channels.measurements[n[i]], rows: [], times: [] };
|
|
2409
|
+
bySeries.set(String(key), s);
|
|
2410
|
+
const list = byDevice.get(s.device);
|
|
2411
|
+
if (list) list.push(s);
|
|
2412
|
+
else byDevice.set(s.device, [s]);
|
|
2413
|
+
}
|
|
2414
|
+
s.rows.push(i);
|
|
2415
|
+
s.times.push(t[i]);
|
|
2416
|
+
}
|
|
2417
|
+
const timeline = [
|
|
2418
|
+
...rec.alarms.snapshots.map((snapshot) => ({ tMs: snapshot.tMs, snapshot })),
|
|
2419
|
+
...rec.alarms.events.map((event) => ({ tMs: event.tMs, event }))
|
|
2420
|
+
];
|
|
2421
|
+
timeline.sort((a, b) => a.tMs - b.tMs);
|
|
2422
|
+
const indexed = { byDevice, timeline, timelineTimes: timeline.map((e) => e.tMs) };
|
|
2423
|
+
indexCache.set(rec, indexed);
|
|
2424
|
+
return indexed;
|
|
2425
|
+
}
|
|
2426
|
+
var RecordedDataSource = class {
|
|
2427
|
+
// `clock` is the shared replay clock; the view starts at its current time. `options`
|
|
2428
|
+
// names the site anchors this recording's devices belong to (see RecordedSiteOptions).
|
|
2429
|
+
constructor(rec, clock, options = {}) {
|
|
2430
|
+
this.subs = /* @__PURE__ */ new Set();
|
|
2431
|
+
this.stale = false;
|
|
2432
|
+
this.rec = rec;
|
|
2433
|
+
this.site = declaredSite(options);
|
|
2434
|
+
this.index = indexOf(rec);
|
|
2435
|
+
this.cursor = clock.timeMs;
|
|
2436
|
+
this.startMs = Date.parse(rec.startedAtUtc);
|
|
2437
|
+
this.devices = new Set(rec.devices.map((d) => d.token));
|
|
2438
|
+
this.unlisten = clock.subscribe((ev) => this.onClock(ev));
|
|
2439
|
+
}
|
|
2440
|
+
// True once the clock has been seeked since this view was built. A stale view emits
|
|
2441
|
+
// nothing further; the host builds a new view at the new cursor.
|
|
2442
|
+
get isStale() {
|
|
2443
|
+
return this.stale;
|
|
2444
|
+
}
|
|
2445
|
+
onClock(ev) {
|
|
2446
|
+
if (this.stale) return;
|
|
2447
|
+
if (ev.reason === "seek") {
|
|
2448
|
+
this.stale = true;
|
|
2449
|
+
this.unlisten();
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
if (ev.reason !== "tick") return;
|
|
2453
|
+
this.cursor = ev.timeMs;
|
|
2454
|
+
for (const sub of [...this.subs]) sub.advance(this.cursor);
|
|
2455
|
+
}
|
|
2456
|
+
iso(offsetMs) {
|
|
2457
|
+
return new Date(this.startMs + offsetMs).toISOString();
|
|
2458
|
+
}
|
|
2459
|
+
// open tracks a subscription and returns its disposer. `start` runs once, in a
|
|
2460
|
+
// microtask, so the disposer is in the caller's hands before anything is emitted (the
|
|
2461
|
+
// contract every source keeps: a synchronous disposer, and nothing emitted after it).
|
|
2462
|
+
// `advance` runs on each forward tick after that.
|
|
2463
|
+
open(handlers) {
|
|
2464
|
+
let disposed = false;
|
|
2465
|
+
let started = false;
|
|
2466
|
+
const isDisposed = () => disposed;
|
|
2467
|
+
const sub = {
|
|
2468
|
+
advance: (cursor) => {
|
|
2469
|
+
if (started && !disposed) handlers.advance(cursor, isDisposed);
|
|
2470
|
+
},
|
|
2471
|
+
dispose: () => {
|
|
2472
|
+
disposed = true;
|
|
2473
|
+
this.subs.delete(sub);
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
this.subs.add(sub);
|
|
2477
|
+
queueMicrotask(() => {
|
|
2478
|
+
if (disposed || this.stale) return;
|
|
2479
|
+
started = true;
|
|
2480
|
+
handlers.start(this.cursor, isDisposed);
|
|
2481
|
+
});
|
|
2482
|
+
return sub.dispose;
|
|
2483
|
+
}
|
|
2484
|
+
// Resolve a selector to the devices it names, synchronously and from the recording.
|
|
2485
|
+
// Mirrors the hub: unbound is zero devices, an unresolved slot is a loud error.
|
|
2486
|
+
resolve(ds) {
|
|
2487
|
+
switch (ds.kind) {
|
|
2488
|
+
case "device":
|
|
2489
|
+
return [{ deviceToken: ds.deviceToken, names: new Set(ds.measurements) }];
|
|
2490
|
+
case "anchor":
|
|
2491
|
+
return anchorMembers(this.rec, this.site, ds.anchor).map((deviceToken) => ({
|
|
2492
|
+
deviceToken,
|
|
2493
|
+
names: new Set(ds.measurements)
|
|
2494
|
+
}));
|
|
2495
|
+
case "unbound":
|
|
2496
|
+
return [];
|
|
2497
|
+
case "slot":
|
|
2498
|
+
throw new Error(
|
|
2499
|
+
`dashboard slot '${ds.slot}' reached the data source unresolved; resolve it through the bindings first (resolveWidgetDatasource)`
|
|
2500
|
+
);
|
|
2501
|
+
default:
|
|
2502
|
+
throw new Error(`dashboard selector kind '${ds.kind}' is not supported yet`);
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
// Report a failure on a sink, asynchronously and only if the subscription is still live.
|
|
2506
|
+
failLater(err, sink) {
|
|
2507
|
+
let disposed = false;
|
|
2508
|
+
queueMicrotask(() => {
|
|
2509
|
+
if (!disposed) sink.error?.(err);
|
|
2510
|
+
});
|
|
2511
|
+
return () => {
|
|
2512
|
+
disposed = true;
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2515
|
+
// ── measurements ──────────────────────────────────────────────────────────
|
|
2516
|
+
subscribeWidget(datasource, sink) {
|
|
2517
|
+
let groups;
|
|
2518
|
+
try {
|
|
2519
|
+
groups = this.resolve(datasource);
|
|
2520
|
+
for (const g of groups) {
|
|
2521
|
+
for (const name of g.names) {
|
|
2522
|
+
if (!this.rec.channels.measurements.includes(name)) {
|
|
2523
|
+
throw new NotInRecordingError("measurements", `no '${name}' series was recorded`);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
} catch (err) {
|
|
2528
|
+
return this.failLater(err, sink);
|
|
2529
|
+
}
|
|
2530
|
+
const picked = [];
|
|
2531
|
+
for (const g of groups) {
|
|
2532
|
+
for (const s of this.index.byDevice.get(g.deviceToken) ?? []) {
|
|
2533
|
+
if (g.names.size === 0 || g.names.has(s.name)) picked.push({ s, next: 0 });
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
const { t, v } = this.rec.measurements;
|
|
2537
|
+
const flush = (cursor, isDisposed) => {
|
|
2538
|
+
const due = [];
|
|
2539
|
+
for (const p of picked) {
|
|
2540
|
+
const { s } = p;
|
|
2541
|
+
while (p.next < s.times.length && s.times[p.next] <= cursor) {
|
|
2542
|
+
const row = s.rows[p.next++];
|
|
2543
|
+
due.push({
|
|
2544
|
+
t: t[row],
|
|
2545
|
+
sample: {
|
|
2546
|
+
id: `${s.device}|${s.name}|${row}`,
|
|
2547
|
+
deviceToken: s.device,
|
|
2548
|
+
eventType: 0,
|
|
2549
|
+
// The time the viewer applied the row: the recording holds no separate
|
|
2550
|
+
// occurred time for measurements.
|
|
2551
|
+
occurredTime: this.iso(t[row]),
|
|
2552
|
+
name: s.name,
|
|
2553
|
+
value: v[row],
|
|
2554
|
+
classifier: null
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
due.sort((a, b) => a.t - b.t);
|
|
2560
|
+
for (const d of due) {
|
|
2561
|
+
if (isDisposed()) return;
|
|
2562
|
+
sink.next(d.sample);
|
|
2563
|
+
}
|
|
2564
|
+
};
|
|
2565
|
+
return this.open({
|
|
2566
|
+
// Back-fill: rows from the preceding window up to the cursor, then play on.
|
|
2567
|
+
start: (cursor, isDisposed) => {
|
|
2568
|
+
for (const p of picked) p.next = lowerBound(p.s.times, cursor - RECORDED_HISTORY_WINDOW_MS);
|
|
2569
|
+
flush(cursor, isDisposed);
|
|
2570
|
+
},
|
|
2571
|
+
advance: flush
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
// ── alarms ────────────────────────────────────────────────────────────────
|
|
2575
|
+
subscribeAlarms(subscription, sink) {
|
|
2576
|
+
let scope = null;
|
|
2577
|
+
try {
|
|
2578
|
+
if (!this.rec.channels.alarms) throw new NotInRecordingError("alarms");
|
|
2579
|
+
if (subscription.datasource) {
|
|
2580
|
+
scope = new Set(this.resolve(subscription.datasource).map((g) => g.deviceToken));
|
|
2581
|
+
}
|
|
2582
|
+
} catch (err) {
|
|
2583
|
+
return this.failLater(err, sink);
|
|
2584
|
+
}
|
|
2585
|
+
const { timeline, timelineTimes } = this.index;
|
|
2586
|
+
const alarms = /* @__PURE__ */ new Map();
|
|
2587
|
+
let applied = 0;
|
|
2588
|
+
let lastSignature = null;
|
|
2589
|
+
const apply = (a, asOfMs) => {
|
|
2590
|
+
const prior = alarms.get(a.id);
|
|
2591
|
+
alarms.set(a.id, {
|
|
2592
|
+
id: a.id,
|
|
2593
|
+
dev: a.dev,
|
|
2594
|
+
key: a.key,
|
|
2595
|
+
metric: a.metric,
|
|
2596
|
+
state: a.state,
|
|
2597
|
+
sev: a.sev,
|
|
2598
|
+
// `occ` is the time of THIS state: the raise time for an ACTIVE row, the clear time
|
|
2599
|
+
// for a CLEARED one. A cleared alarm keeps the raise time its ACTIVE row gave it
|
|
2600
|
+
// (null if the history never showed it being raised).
|
|
2601
|
+
raised: a.state === "ACTIVE" ? a.occ : prior?.raised ?? null,
|
|
2602
|
+
cleared: a.state === "CLEARED" ? a.occ : null,
|
|
2603
|
+
// Each record's own `ack` decides; acknowledgement is not carried over from an
|
|
2604
|
+
// earlier record of the same alarm.
|
|
2605
|
+
ack: a.ack === true,
|
|
2606
|
+
asOfMs
|
|
2607
|
+
});
|
|
2608
|
+
};
|
|
2609
|
+
const emit = (cursor, isDisposed) => {
|
|
2610
|
+
const upTo = upperBound(timelineTimes, cursor);
|
|
2611
|
+
for (; applied < upTo; applied++) {
|
|
2612
|
+
const entry = timeline[applied];
|
|
2613
|
+
if ("snapshot" in entry) {
|
|
2614
|
+
const { requestedAtMs } = entry.snapshot;
|
|
2615
|
+
const newer = [...alarms.values()].filter((a) => a.asOfMs > requestedAtMs);
|
|
2616
|
+
alarms.clear();
|
|
2617
|
+
for (const a of entry.snapshot.alarms) apply(a, requestedAtMs);
|
|
2618
|
+
for (const a of newer) alarms.set(a.id, a);
|
|
2619
|
+
} else {
|
|
2620
|
+
apply(entry.event, entry.event.tMs);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
const matches = [...alarms.values()].filter(
|
|
2624
|
+
(a) => (scope === null || scope.has(a.dev)) && (!subscription.state || a.state === subscription.state) && (!subscription.severity || a.sev === subscription.severity) && (subscription.acknowledged == null || a.ack === subscription.acknowledged)
|
|
2625
|
+
);
|
|
2626
|
+
const when = (a) => a.raised ?? a.cleared;
|
|
2627
|
+
const byTime = (a, b) => {
|
|
2628
|
+
const wa = when(a);
|
|
2629
|
+
const wb = when(b);
|
|
2630
|
+
if (wa === null || wb === null) return wa === wb ? 0 : wa === null ? 1 : -1;
|
|
2631
|
+
return compareInstants(wb, wa);
|
|
2632
|
+
};
|
|
2633
|
+
matches.sort((a, b) => byTime(a, b) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
2634
|
+
const page = matches.slice(0, subscription.pageSize);
|
|
2635
|
+
const signature = JSON.stringify([matches.length, page]);
|
|
2636
|
+
if (signature === lastSignature || isDisposed()) return;
|
|
2637
|
+
lastSignature = signature;
|
|
2638
|
+
const snapshot = { alarms: page.map(toAlarmRow), total: matches.length };
|
|
2639
|
+
sink.next(snapshot);
|
|
2640
|
+
};
|
|
2641
|
+
return this.open({ start: emit, advance: emit });
|
|
2642
|
+
}
|
|
2643
|
+
// ── commands ──────────────────────────────────────────────────────────────
|
|
2644
|
+
// The recording holds no command data, so this channel is never available.
|
|
2645
|
+
subscribeCommands(_subscription, sink) {
|
|
2646
|
+
return this.failLater(new NotInRecordingError("commands"), sink);
|
|
2647
|
+
}
|
|
2648
|
+
// ── locations ─────────────────────────────────────────────────────────────
|
|
2649
|
+
// This format version records no positions. A selector that names no location series
|
|
2650
|
+
// asks for nothing, and one that resolves to no device has nothing to place: both get the
|
|
2651
|
+
// empty snapshot, as from the hub. Naming a location series for devices that exist is a
|
|
2652
|
+
// request for data the recording does not have, which is an error, never an empty map.
|
|
2653
|
+
subscribeLocations(subscription, sink) {
|
|
2654
|
+
const ds = subscription.datasource;
|
|
2655
|
+
let tokens = [];
|
|
2656
|
+
if (ds?.location) {
|
|
2657
|
+
try {
|
|
2658
|
+
tokens = this.resolve(ds).map((g) => g.deviceToken);
|
|
2659
|
+
} catch (err) {
|
|
2660
|
+
return this.failLater(err, sink);
|
|
2661
|
+
}
|
|
2662
|
+
if (tokens.length > 0) {
|
|
2663
|
+
return this.failLater(new NotInRecordingError("locations", "no positions were recorded"), sink);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
return this.open({
|
|
2667
|
+
start: (_cursor, isDisposed) => {
|
|
2668
|
+
if (!isDisposed()) sink.next({ kind: "positions", deviceTokens: [], locations: [] });
|
|
2669
|
+
},
|
|
2670
|
+
advance: () => {
|
|
2671
|
+
}
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
2674
|
+
// ── availability ──────────────────────────────────────────────────────────
|
|
2675
|
+
// A device selector is available iff the run has that device. Anchors, unbound slots and
|
|
2676
|
+
// no datasource have a legitimate empty state and are available, as on the hub.
|
|
2677
|
+
async isDatasourceAvailable(datasource) {
|
|
2678
|
+
if (datasource?.kind === "device" && datasource.deviceToken) return this.devices.has(datasource.deviceToken);
|
|
2679
|
+
return true;
|
|
2680
|
+
}
|
|
2681
|
+
// Stop every subscription and leave the clock. Call when the view is replaced.
|
|
2682
|
+
disposeAll() {
|
|
2683
|
+
this.unlisten();
|
|
2684
|
+
for (const sub of [...this.subs]) sub.dispose();
|
|
2685
|
+
}
|
|
2686
|
+
};
|
|
2687
|
+
function toAlarmRow(a) {
|
|
2688
|
+
return {
|
|
2689
|
+
token: a.id,
|
|
2690
|
+
originatorType: "device",
|
|
2691
|
+
originatorToken: a.dev,
|
|
2692
|
+
alarmKey: a.key,
|
|
2693
|
+
metricKey: a.metric,
|
|
2694
|
+
state: a.state,
|
|
2695
|
+
acknowledged: a.ack,
|
|
2696
|
+
severity: a.sev,
|
|
2697
|
+
raisedTime: a.raised,
|
|
2698
|
+
clearedTime: a.cleared,
|
|
2699
|
+
// The recording notes THAT an alarm was acknowledged, not when or by whom.
|
|
2700
|
+
acknowledgedTime: null,
|
|
2701
|
+
acknowledgedBy: null,
|
|
2702
|
+
// The stored alarm's last value is not recorded, and a nearby measurement would
|
|
2703
|
+
// invent the integrator's state. Null renders as an em dash.
|
|
2704
|
+
lastValue: null
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
|
|
1643
2708
|
// src/editor-model.ts
|
|
1644
2709
|
function baseBox(widget) {
|
|
1645
2710
|
return widget.layout[BASE_BREAKPOINT];
|
|
@@ -1687,27 +2752,21 @@ function setCanvasSizing(def, sizing) {
|
|
|
1687
2752
|
function updateWidget(def, id, next) {
|
|
1688
2753
|
return { ...def, widgets: def.widgets.map((w) => w.id === id ? next : w) };
|
|
1689
2754
|
}
|
|
1690
|
-
function
|
|
1691
|
-
const words = type.replace(/-/g, " ");
|
|
1692
|
-
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
1693
|
-
}
|
|
1694
|
-
function defaultOptions(type) {
|
|
1695
|
-
if (type === "label") return { text: "New label" };
|
|
1696
|
-
if (type === "alarm-table" || type === "alarm-count") {
|
|
1697
|
-
return { title: humanizeType(type), state: "ACTIVE" };
|
|
1698
|
-
}
|
|
1699
|
-
return { title: humanizeType(type) };
|
|
1700
|
-
}
|
|
1701
|
-
function addWidget(def, type) {
|
|
2755
|
+
function addWidget(def, descriptor) {
|
|
1702
2756
|
const maxZ = def.widgets.reduce((m, w) => Math.max(m, baseBox(w).z), 0);
|
|
1703
2757
|
const id = generateWidgetId();
|
|
1704
2758
|
const box = { col: 0, colSpan: 8, row: 0, rowSpan: 4, z: maxZ + 1 };
|
|
1705
|
-
const
|
|
2759
|
+
const common = {
|
|
1706
2760
|
id,
|
|
1707
|
-
|
|
2761
|
+
widget: {
|
|
2762
|
+
version: descriptor.version,
|
|
2763
|
+
optionsVersion: descriptor.optionsVersion,
|
|
2764
|
+
sdkApiVersion: descriptor.sdkApiVersion
|
|
2765
|
+
},
|
|
1708
2766
|
layout: { [BASE_BREAKPOINT]: box },
|
|
1709
|
-
options:
|
|
2767
|
+
options: { ...descriptor.defaults }
|
|
1710
2768
|
};
|
|
2769
|
+
const widget = { ...common, type: descriptor.id };
|
|
1711
2770
|
return { definition: { ...def, widgets: [...def.widgets, widget] }, id };
|
|
1712
2771
|
}
|
|
1713
2772
|
function gridBoxToPx(box, geom) {
|
|
@@ -1810,8 +2869,8 @@ function defaultHistoryWindow() {
|
|
|
1810
2869
|
intervalSeconds: 60
|
|
1811
2870
|
};
|
|
1812
2871
|
}
|
|
1813
|
-
async function fetchWidgetHistory(widget, window
|
|
1814
|
-
const ds =
|
|
2872
|
+
async function fetchWidgetHistory(widget, window) {
|
|
2873
|
+
const ds = widget.datasource;
|
|
1815
2874
|
if (!ds || ds.kind !== "device") return [];
|
|
1816
2875
|
try {
|
|
1817
2876
|
const deviceToken = ds.deviceToken;
|
|
@@ -1842,57 +2901,613 @@ async function fetchWidgetHistory(widget, window, bindings) {
|
|
|
1842
2901
|
return [];
|
|
1843
2902
|
}
|
|
1844
2903
|
}
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2904
|
+
|
|
2905
|
+
// src/option-schema.ts
|
|
2906
|
+
var HOST_OPTION_CONTROLS = ["command-vocabulary", "slot-target", "basemap"];
|
|
2907
|
+
function hasOwn(obj, key) {
|
|
2908
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
2909
|
+
}
|
|
2910
|
+
function validateOptions(specs, options, context) {
|
|
2911
|
+
const { label: widgetId } = context;
|
|
2912
|
+
const issues = [];
|
|
2913
|
+
const bag = options ?? {};
|
|
2914
|
+
for (const [key, spec] of Object.entries(specs)) {
|
|
2915
|
+
const value = bag[key];
|
|
2916
|
+
if (value === void 0 || spec.required && value === "") {
|
|
2917
|
+
if (spec.required) {
|
|
2918
|
+
issues.push({ widgetId, key, code: "missing", message: `${key} is required but not set` });
|
|
2919
|
+
}
|
|
2920
|
+
continue;
|
|
2921
|
+
}
|
|
2922
|
+
issues.push(...checkValue(widgetId, key, spec, value));
|
|
2923
|
+
}
|
|
2924
|
+
for (const key of Object.keys(bag)) {
|
|
2925
|
+
if (hasOwn(specs, key)) continue;
|
|
2926
|
+
issues.push({
|
|
2927
|
+
widgetId,
|
|
2928
|
+
key,
|
|
2929
|
+
code: "unknown",
|
|
2930
|
+
message: `${key} is not read by a ${widgetId} widget`
|
|
2931
|
+
});
|
|
1850
2932
|
}
|
|
1851
|
-
|
|
1852
|
-
|
|
2933
|
+
for (const constraint of context.constraints ?? []) {
|
|
2934
|
+
const issue = checkConstraint(constraint, bag);
|
|
2935
|
+
if (issue) issues.push({ widgetId, ...issue });
|
|
2936
|
+
}
|
|
2937
|
+
return issues;
|
|
2938
|
+
}
|
|
2939
|
+
function checkConstraint(constraint, bag) {
|
|
2940
|
+
switch (constraint.kind) {
|
|
2941
|
+
case "greater-than": {
|
|
2942
|
+
const value = bag[constraint.key];
|
|
2943
|
+
const than = bag[constraint.than];
|
|
2944
|
+
if (typeof value !== "number" || typeof than !== "number") return void 0;
|
|
2945
|
+
if (!Number.isFinite(value) || !Number.isFinite(than)) return void 0;
|
|
2946
|
+
if (value > than) return void 0;
|
|
2947
|
+
return {
|
|
2948
|
+
key: constraint.key,
|
|
2949
|
+
code: "invariant",
|
|
2950
|
+
message: `${constraint.key} (${value}) must be greater than ${constraint.than} (${than})`
|
|
2951
|
+
};
|
|
2952
|
+
}
|
|
1853
2953
|
}
|
|
1854
|
-
|
|
2954
|
+
}
|
|
2955
|
+
function checkValue(widgetId, key, spec, value) {
|
|
2956
|
+
const issue = (code, message) => [{ widgetId, key, code, message }];
|
|
2957
|
+
switch (spec.kind) {
|
|
2958
|
+
case "string":
|
|
2959
|
+
if (typeof value !== "string") {
|
|
2960
|
+
return issue("type", `${key} must be a string, got ${describe(value)}`);
|
|
2961
|
+
}
|
|
2962
|
+
if (spec.format === "json" && !parsesAsJson(value)) {
|
|
2963
|
+
return issue("json", `${key} must be valid JSON`);
|
|
2964
|
+
}
|
|
2965
|
+
return [];
|
|
2966
|
+
case "number":
|
|
2967
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2968
|
+
return issue("type", `${key} must be a finite number, got ${describe(value)}`);
|
|
2969
|
+
}
|
|
2970
|
+
if (spec.integer && !Number.isInteger(value)) {
|
|
2971
|
+
return issue("range", `${key} must be a whole number, got ${value}`);
|
|
2972
|
+
}
|
|
2973
|
+
if (spec.min !== void 0 && value < spec.min) {
|
|
2974
|
+
return issue("range", `${key} must be at least ${spec.min}, got ${value}`);
|
|
2975
|
+
}
|
|
2976
|
+
if (spec.max !== void 0 && value > spec.max) {
|
|
2977
|
+
return issue("range", `${key} must be at most ${spec.max}, got ${value}`);
|
|
2978
|
+
}
|
|
2979
|
+
return [];
|
|
2980
|
+
case "boolean":
|
|
2981
|
+
if (typeof value !== "boolean") {
|
|
2982
|
+
return issue("type", `${key} must be a boolean, got ${describe(value)}`);
|
|
2983
|
+
}
|
|
2984
|
+
return [];
|
|
2985
|
+
case "enum":
|
|
2986
|
+
if (typeof value !== "string") {
|
|
2987
|
+
return issue("type", `${key} must be a string, got ${describe(value)}`);
|
|
2988
|
+
}
|
|
2989
|
+
if (!spec.values.includes(value)) {
|
|
2990
|
+
return issue("enum", `${key} must be one of ${spec.values.join(", ")}, got ${JSON.stringify(value)}`);
|
|
2991
|
+
}
|
|
2992
|
+
return [];
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
function parsesAsJson(value) {
|
|
2996
|
+
try {
|
|
2997
|
+
JSON.parse(value);
|
|
2998
|
+
return true;
|
|
2999
|
+
} catch {
|
|
3000
|
+
return false;
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
function describe(value) {
|
|
3004
|
+
if (value === null) return "null";
|
|
3005
|
+
if (typeof value === "string") return `string ${JSON.stringify(value)}`;
|
|
3006
|
+
if (Array.isArray(value)) return "an array";
|
|
3007
|
+
return `${typeof value} ${JSON.stringify(value)}`;
|
|
3008
|
+
}
|
|
3009
|
+
function numberSpec(specs, key) {
|
|
3010
|
+
if (!hasOwn(specs, key)) return void 0;
|
|
3011
|
+
const spec = specs[key];
|
|
3012
|
+
return spec.kind === "number" ? spec : void 0;
|
|
3013
|
+
}
|
|
3014
|
+
function clampNumberOption(spec, value) {
|
|
3015
|
+
if (!Number.isFinite(value)) return void 0;
|
|
3016
|
+
if (!spec) return value;
|
|
3017
|
+
let next = spec.integer ? Math.trunc(value) : value;
|
|
3018
|
+
if (spec.min !== void 0) next = Math.max(spec.min, next);
|
|
3019
|
+
if (spec.max !== void 0) next = Math.min(spec.max, next);
|
|
3020
|
+
return next;
|
|
3021
|
+
}
|
|
3022
|
+
function enumValues(specs, key) {
|
|
3023
|
+
if (!hasOwn(specs, key)) return void 0;
|
|
3024
|
+
const spec = specs[key];
|
|
3025
|
+
return spec.kind === "enum" ? spec.values : void 0;
|
|
3026
|
+
}
|
|
3027
|
+
function applyOptionPatch(specs, options, patch, label = "widget") {
|
|
3028
|
+
const next = { ...options ?? {} };
|
|
3029
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
3030
|
+
if (!hasOwn(specs, key)) throw new Error(`${label}: "${key}" is not a declared option`);
|
|
3031
|
+
const spec = specs[key];
|
|
3032
|
+
if (value === void 0 || value === "" || spec.kind === "boolean" && value === false) {
|
|
3033
|
+
delete next[key];
|
|
3034
|
+
continue;
|
|
3035
|
+
}
|
|
3036
|
+
const refuse = (expected) => {
|
|
3037
|
+
throw new TypeError(`${label}: option "${key}" takes ${expected}, got ${JSON.stringify(value)}`);
|
|
3038
|
+
};
|
|
3039
|
+
switch (spec.kind) {
|
|
3040
|
+
case "string":
|
|
3041
|
+
if (typeof value !== "string") refuse("a string");
|
|
3042
|
+
if (spec.format === "json" && !parsesAsJson(value)) refuse("a JSON string");
|
|
3043
|
+
next[key] = value;
|
|
3044
|
+
break;
|
|
3045
|
+
case "boolean":
|
|
3046
|
+
if (typeof value !== "boolean") refuse("a boolean");
|
|
3047
|
+
next[key] = value;
|
|
3048
|
+
break;
|
|
3049
|
+
case "number": {
|
|
3050
|
+
if (typeof value !== "number") refuse("a finite number");
|
|
3051
|
+
const clamped = clampNumberOption(spec, value);
|
|
3052
|
+
if (clamped === void 0) refuse("a finite number");
|
|
3053
|
+
next[key] = clamped;
|
|
3054
|
+
break;
|
|
3055
|
+
}
|
|
3056
|
+
case "enum":
|
|
3057
|
+
if (typeof value !== "string" || !spec.values.includes(value)) {
|
|
3058
|
+
refuse(`one of ${spec.values.map((v) => JSON.stringify(v)).join(", ")}`);
|
|
3059
|
+
}
|
|
3060
|
+
next[key] = value;
|
|
3061
|
+
break;
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
return next;
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
// src/widget-descriptor.ts
|
|
3068
|
+
var BUILTIN_IDS = new Set(WIDGET_TYPES);
|
|
3069
|
+
function isBuiltinWidgetId(id) {
|
|
3070
|
+
return typeof id === "string" && BUILTIN_IDS.has(id);
|
|
3071
|
+
}
|
|
3072
|
+
var NAMESPACED_ID = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
|
|
3073
|
+
function isNamespacedWidgetId(id) {
|
|
3074
|
+
return typeof id === "string" && NAMESPACED_ID.test(id);
|
|
3075
|
+
}
|
|
3076
|
+
var SDK_API_VERSION = 1;
|
|
3077
|
+
function defineWidgetDescriptor(descriptor) {
|
|
3078
|
+
return descriptor;
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
// src/widget-catalog.ts
|
|
3082
|
+
var WidgetCatalogError = class extends Error {
|
|
3083
|
+
constructor(issues, subject = "widget catalog") {
|
|
3084
|
+
super(describeIssues(subject, issues));
|
|
3085
|
+
this.name = "WidgetCatalogError";
|
|
3086
|
+
this.issues = Object.freeze([...issues]);
|
|
3087
|
+
}
|
|
3088
|
+
};
|
|
3089
|
+
function describeIssues(subject, issues) {
|
|
3090
|
+
const lines = issues.map((i) => ` - ${i.id}${i.path ? `.${i.path}` : ""}: ${i.message}`);
|
|
3091
|
+
return `invalid ${subject} (${issues.length} problem${issues.length === 1 ? "" : "s"}):
|
|
3092
|
+
${lines.join("\n")}`;
|
|
3093
|
+
}
|
|
3094
|
+
var CHANNELS = ["measurement", "alarm", "control", "selection", "location", "none"];
|
|
3095
|
+
var SEMVER2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
3096
|
+
var SELECTOR_KINDS = ["device", "anchor", "slot"];
|
|
3097
|
+
var BINDING_KEYS = {
|
|
3098
|
+
measurement: ["window"],
|
|
3099
|
+
alarm: ["pageSize", "state", "severity", "acknowledged"],
|
|
3100
|
+
control: ["pageSize"],
|
|
3101
|
+
location: [],
|
|
3102
|
+
selection: [],
|
|
3103
|
+
none: []
|
|
3104
|
+
};
|
|
3105
|
+
function isRecord3(value) {
|
|
3106
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3107
|
+
}
|
|
3108
|
+
function isPlainObject(value) {
|
|
3109
|
+
if (!isRecord3(value)) return false;
|
|
3110
|
+
const proto = Object.getPrototypeOf(value);
|
|
3111
|
+
return proto === Object.prototype || proto === null;
|
|
3112
|
+
}
|
|
3113
|
+
var join = (base, key) => base ? `${base}.${key}` : key;
|
|
3114
|
+
function validateWidgetDescriptor(descriptor) {
|
|
3115
|
+
const rawId = isRecord3(descriptor) && typeof descriptor.id === "string" ? descriptor.id : "";
|
|
3116
|
+
return collectDescriptorIssues(descriptor, rawId || "#?");
|
|
3117
|
+
}
|
|
3118
|
+
function collectDescriptorIssues(d, id) {
|
|
3119
|
+
const issues = [];
|
|
3120
|
+
const add = (path, message) => {
|
|
3121
|
+
issues.push({ id, path, message });
|
|
3122
|
+
};
|
|
3123
|
+
if (!isPlainObject(d)) {
|
|
3124
|
+
add("", "a descriptor must be a plain object");
|
|
3125
|
+
return issues;
|
|
3126
|
+
}
|
|
3127
|
+
jsonOnly(d, "", add, /* @__PURE__ */ new Set());
|
|
3128
|
+
if (typeof d.id !== "string" || d.id === "") {
|
|
3129
|
+
add("id", "id must be a non-empty string");
|
|
3130
|
+
} else if (!isBuiltinWidgetId(d.id) && !isNamespacedWidgetId(d.id)) {
|
|
3131
|
+
add("id", `id ${JSON.stringify(d.id)} must be a reserved built-in id or a namespaced "scope/name" id`);
|
|
3132
|
+
}
|
|
3133
|
+
if (typeof d.version !== "string" || !SEMVER2.test(d.version)) {
|
|
3134
|
+
add("version", `version must be MAJOR.MINOR.PATCH, got ${JSON.stringify(d.version)}`);
|
|
3135
|
+
}
|
|
3136
|
+
if (d.sdkApiVersion !== SDK_API_VERSION) {
|
|
3137
|
+
add("sdkApiVersion", `unsupported sdkApiVersion ${JSON.stringify(d.sdkApiVersion)}; this build supports ${SDK_API_VERSION}`);
|
|
3138
|
+
}
|
|
3139
|
+
if (typeof d.optionsVersion !== "number" || !Number.isInteger(d.optionsVersion) || d.optionsVersion < 1) {
|
|
3140
|
+
add("optionsVersion", `optionsVersion must be a positive integer, got ${JSON.stringify(d.optionsVersion)}`);
|
|
3141
|
+
}
|
|
3142
|
+
if (!isRecord3(d.display)) {
|
|
3143
|
+
add("display", "display must be an object");
|
|
3144
|
+
} else {
|
|
3145
|
+
if (typeof d.display.title !== "string" || d.display.title === "") {
|
|
3146
|
+
add("display.title", "display.title must be a non-empty string");
|
|
3147
|
+
}
|
|
3148
|
+
for (const text of ["description", "category", "titleId", "categoryId"]) {
|
|
3149
|
+
const value = d.display[text];
|
|
3150
|
+
if (value !== void 0 && typeof value !== "string") add(`display.${text}`, `display.${text} must be a string`);
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
const channel = CHANNELS.find((c) => c === d.channel);
|
|
3154
|
+
if (!channel) add("channel", `channel must be one of ${CHANNELS.join(", ")}, got ${JSON.stringify(d.channel)}`);
|
|
3155
|
+
const specs = checkOptions(d.options, add);
|
|
3156
|
+
const constraints = checkConstraints(d.constraints, specs, add);
|
|
3157
|
+
if (specs) checkDefaults(d.defaults, specs, constraints, typeof d.id === "string" ? d.id : id, add);
|
|
3158
|
+
else if (!isRecord3(d.defaults)) add("defaults", "defaults must be an object");
|
|
3159
|
+
checkDatasource(d.datasource, channel, add);
|
|
3160
|
+
if (channel) checkBinding(d.binding, channel, specs, add);
|
|
3161
|
+
checkInteractions(d.interactions, channel, add);
|
|
3162
|
+
return issues;
|
|
3163
|
+
}
|
|
3164
|
+
function jsonOnly(value, path, add, ancestors) {
|
|
3165
|
+
const here = path || "(descriptor)";
|
|
3166
|
+
switch (typeof value) {
|
|
3167
|
+
case "string":
|
|
3168
|
+
case "boolean":
|
|
3169
|
+
return;
|
|
3170
|
+
case "number":
|
|
3171
|
+
if (!Number.isFinite(value)) add(path, `${here} is ${value}; descriptors hold JSON values only`);
|
|
3172
|
+
return;
|
|
3173
|
+
case "undefined":
|
|
3174
|
+
add(path, `${here} is undefined; omit the key instead (descriptors hold JSON values only)`);
|
|
3175
|
+
return;
|
|
3176
|
+
case "function":
|
|
3177
|
+
case "symbol":
|
|
3178
|
+
case "bigint":
|
|
3179
|
+
add(path, `${here} is a ${typeof value}; descriptors hold JSON values only`);
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
if (value === null) return;
|
|
3183
|
+
const obj = value;
|
|
3184
|
+
if (ancestors.has(obj)) {
|
|
3185
|
+
add(path, `${here} is a circular reference; descriptors hold JSON values only`);
|
|
3186
|
+
return;
|
|
3187
|
+
}
|
|
3188
|
+
ancestors.add(obj);
|
|
3189
|
+
if (Array.isArray(obj)) {
|
|
3190
|
+
obj.forEach((item, i) => jsonOnly(item, `${path}[${i}]`, add, ancestors));
|
|
3191
|
+
} else if (!isPlainObject(obj)) {
|
|
3192
|
+
add(path, `${here} is not a plain object; descriptors hold JSON values only`);
|
|
3193
|
+
} else {
|
|
3194
|
+
if (Object.getOwnPropertySymbols(obj).length > 0) add(path, `${here} has a symbol key; descriptors hold JSON values only`);
|
|
3195
|
+
for (const [key, item] of Object.entries(obj)) jsonOnly(item, join(path, key), add, ancestors);
|
|
3196
|
+
}
|
|
3197
|
+
ancestors.delete(obj);
|
|
3198
|
+
}
|
|
3199
|
+
function checkOptions(options, add) {
|
|
3200
|
+
if (!isRecord3(options)) {
|
|
3201
|
+
add("options", "options must be an object mapping option keys to specs");
|
|
3202
|
+
return void 0;
|
|
3203
|
+
}
|
|
3204
|
+
let sound = true;
|
|
3205
|
+
for (const [key, spec] of Object.entries(options)) {
|
|
3206
|
+
const at = `options.${key}`;
|
|
3207
|
+
if (!isRecord3(spec)) {
|
|
3208
|
+
add(at, "an option spec must be an object");
|
|
3209
|
+
sound = false;
|
|
3210
|
+
continue;
|
|
3211
|
+
}
|
|
3212
|
+
if (typeof spec.doc !== "string") {
|
|
3213
|
+
add(`${at}.doc`, "doc must be a string");
|
|
3214
|
+
}
|
|
3215
|
+
for (const text of ["label", "help", "labelId", "helpId", "placeholder", "placeholderId"]) {
|
|
3216
|
+
if (spec[text] !== void 0 && typeof spec[text] !== "string") add(`${at}.${text}`, `${text} must be a string`);
|
|
3217
|
+
}
|
|
3218
|
+
if (spec.control !== void 0 && (typeof spec.control !== "string" || spec.control === "")) {
|
|
3219
|
+
add(`${at}.control`, `control must be a non-empty string, got ${JSON.stringify(spec.control)}`);
|
|
3220
|
+
}
|
|
3221
|
+
switch (spec.kind) {
|
|
3222
|
+
case "string":
|
|
3223
|
+
case "boolean":
|
|
3224
|
+
break;
|
|
3225
|
+
case "number":
|
|
3226
|
+
for (const bound of ["min", "max"]) {
|
|
3227
|
+
if (spec[bound] !== void 0 && typeof spec[bound] !== "number") add(`${at}.${bound}`, `${bound} must be a number`);
|
|
3228
|
+
}
|
|
3229
|
+
break;
|
|
3230
|
+
case "enum":
|
|
3231
|
+
if (!Array.isArray(spec.values) || spec.values.length === 0 || !spec.values.every((v) => typeof v === "string")) {
|
|
3232
|
+
add(`${at}.values`, "an enum option needs a non-empty list of string values");
|
|
3233
|
+
sound = false;
|
|
3234
|
+
}
|
|
3235
|
+
for (const table of ["valueLabels", "valueLabelIds"]) {
|
|
3236
|
+
const labels = spec[table];
|
|
3237
|
+
if (labels === void 0) continue;
|
|
3238
|
+
if (!isRecord3(labels)) {
|
|
3239
|
+
add(`${at}.${table}`, `${table} must be an object mapping enum values to strings`);
|
|
3240
|
+
continue;
|
|
3241
|
+
}
|
|
3242
|
+
for (const [value, label] of Object.entries(labels)) {
|
|
3243
|
+
if (typeof label !== "string") add(`${at}.${table}.${value}`, `${table} entries must be strings`);
|
|
3244
|
+
else if (Array.isArray(spec.values) && !spec.values.includes(value)) {
|
|
3245
|
+
add(`${at}.${table}.${value}`, `${JSON.stringify(value)} is not one of this option's values`);
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
break;
|
|
3250
|
+
default:
|
|
3251
|
+
add(`${at}.kind`, `kind must be string, number, boolean or enum, got ${JSON.stringify(spec.kind)}`);
|
|
3252
|
+
sound = false;
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
return sound ? options : void 0;
|
|
3256
|
+
}
|
|
3257
|
+
function checkConstraints(constraints, specs, add) {
|
|
3258
|
+
if (constraints === void 0) return [];
|
|
3259
|
+
if (!Array.isArray(constraints)) {
|
|
3260
|
+
add("constraints", "constraints must be a list");
|
|
3261
|
+
return [];
|
|
3262
|
+
}
|
|
3263
|
+
const valid = [];
|
|
3264
|
+
constraints.forEach((c, i) => {
|
|
3265
|
+
const at = `constraints[${i}]`;
|
|
3266
|
+
if (!isRecord3(c) || c.kind !== "greater-than") {
|
|
3267
|
+
add(`${at}.kind`, `constraint kind must be "greater-than", got ${JSON.stringify(isRecord3(c) ? c.kind : c)}`);
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
let ok = true;
|
|
3271
|
+
for (const field of ["key", "than"]) {
|
|
3272
|
+
const name = c[field];
|
|
3273
|
+
if (typeof name !== "string") {
|
|
3274
|
+
add(`${at}.${field}`, `${field} must name a declared option`);
|
|
3275
|
+
ok = false;
|
|
3276
|
+
} else if (specs && (!hasOwn(specs, name) || specs[name].kind !== "number")) {
|
|
3277
|
+
add(`${at}.${field}`, `${field} ${JSON.stringify(name)} must name a declared number option`);
|
|
3278
|
+
ok = false;
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
if (ok) valid.push({ kind: "greater-than", key: c.key, than: c.than });
|
|
3282
|
+
});
|
|
3283
|
+
return valid;
|
|
3284
|
+
}
|
|
3285
|
+
function checkDefaults(defaults, specs, constraints, id, add) {
|
|
3286
|
+
if (!isRecord3(defaults)) {
|
|
3287
|
+
add("defaults", "defaults must be an object");
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
3290
|
+
const found = validateOptions(specs, defaults, { constraints, label: id }).filter(
|
|
3291
|
+
(issue) => issue.code !== "missing"
|
|
3292
|
+
);
|
|
3293
|
+
for (const issue of found) add(`defaults.${issue.key}`, issue.message);
|
|
3294
|
+
}
|
|
3295
|
+
function checkDatasource(datasource, channel, add) {
|
|
3296
|
+
if (!isRecord3(datasource)) {
|
|
3297
|
+
add("datasource", "datasource must be an object");
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
const mode = datasource.mode;
|
|
3301
|
+
if (mode !== "none" && mode !== "optional" && mode !== "required") {
|
|
3302
|
+
add("datasource.mode", `mode must be none, optional or required, got ${JSON.stringify(mode)}`);
|
|
3303
|
+
return;
|
|
3304
|
+
}
|
|
3305
|
+
if ((channel === "none" || channel === "selection") && mode !== "none") {
|
|
3306
|
+
add("datasource.mode", `a ${channel}-channel widget opens no data channel, so its datasource mode must be "none"`);
|
|
3307
|
+
}
|
|
3308
|
+
if (mode === "none") {
|
|
3309
|
+
if (datasource.selectors !== void 0) add("datasource.selectors", 'a datasource mode of "none" takes no selectors');
|
|
3310
|
+
return;
|
|
3311
|
+
}
|
|
3312
|
+
const selectors = datasource.selectors;
|
|
3313
|
+
if (!Array.isArray(selectors) || selectors.length === 0) {
|
|
3314
|
+
add("datasource.selectors", `a datasource mode of "${mode}" needs a non-empty list of selector kinds`);
|
|
3315
|
+
return;
|
|
3316
|
+
}
|
|
3317
|
+
selectors.forEach((s, i) => {
|
|
3318
|
+
if (typeof s !== "string" || !SELECTOR_KINDS.includes(s)) {
|
|
3319
|
+
add(`datasource.selectors[${i}]`, `selector must be one of ${SELECTOR_KINDS.join(", ")}, got ${JSON.stringify(s)}`);
|
|
3320
|
+
} else if (channel === "control" && s !== "device" && s !== "slot") {
|
|
3321
|
+
add(`datasource.selectors[${i}]`, "a command target must be a device, so a control widget accepts only device and slot selectors");
|
|
3322
|
+
}
|
|
3323
|
+
});
|
|
3324
|
+
}
|
|
3325
|
+
function checkBinding(binding, channel, specs, add) {
|
|
3326
|
+
if (!isRecord3(binding)) {
|
|
3327
|
+
add("binding", "binding must be an object");
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
3330
|
+
const allowed = BINDING_KEYS[channel];
|
|
3331
|
+
for (const key of Object.keys(binding)) {
|
|
3332
|
+
if (!allowed.includes(key)) add(`binding.${key}`, `a ${channel}-channel widget has no binding policy "${key}"`);
|
|
3333
|
+
}
|
|
3334
|
+
if (allowed.includes("pageSize") && binding.pageSize === void 0) {
|
|
3335
|
+
add("binding.pageSize", `a ${channel}-channel widget must declare binding.pageSize`);
|
|
3336
|
+
}
|
|
3337
|
+
const ref = (key, accept, expect, allowConst) => {
|
|
3338
|
+
const policy = binding[key];
|
|
3339
|
+
if (policy === void 0) return;
|
|
3340
|
+
const at = `binding.${key}`;
|
|
3341
|
+
if (!isRecord3(policy)) {
|
|
3342
|
+
add(at, `${key} must be an object`);
|
|
3343
|
+
return;
|
|
3344
|
+
}
|
|
3345
|
+
const hasConst = policy.const !== void 0;
|
|
3346
|
+
const hasOption = policy.option !== void 0;
|
|
3347
|
+
if (hasConst) {
|
|
3348
|
+
if (!allowConst) add(`${at}.const`, `${key} takes an option reference, not a constant`);
|
|
3349
|
+
else if (typeof policy.const !== "number" || !Number.isInteger(policy.const) || policy.const < 1) {
|
|
3350
|
+
add(`${at}.const`, `const must be a positive integer, got ${JSON.stringify(policy.const)}`);
|
|
3351
|
+
}
|
|
3352
|
+
if (hasOption) add(at, `${key} must be a constant or an option reference, not both`);
|
|
3353
|
+
return;
|
|
3354
|
+
}
|
|
3355
|
+
if (!hasOption) {
|
|
3356
|
+
add(at, `${key} must be a constant or an option reference`);
|
|
3357
|
+
return;
|
|
3358
|
+
}
|
|
3359
|
+
const name = policy.option;
|
|
3360
|
+
if (typeof name !== "string") {
|
|
3361
|
+
add(`${at}.option`, "option must name a declared option key");
|
|
3362
|
+
} else if (specs) {
|
|
3363
|
+
if (!hasOwn(specs, name)) add(`${at}.option`, `option ${JSON.stringify(name)} is not a declared option key`);
|
|
3364
|
+
else if (!accept(specs[name])) add(`${at}.option`, `option ${JSON.stringify(name)} must be ${expect}`);
|
|
3365
|
+
}
|
|
3366
|
+
if (policy.min !== void 0 && typeof policy.min !== "number") add(`${at}.min`, "min must be a number");
|
|
3367
|
+
if (policy.default !== void 0) {
|
|
3368
|
+
const wantsNumber = key === "window" || key === "pageSize";
|
|
3369
|
+
if (typeof policy.default !== (wantsNumber ? "number" : "string")) {
|
|
3370
|
+
add(`${at}.default`, `default must be a ${wantsNumber ? "number" : "string"}`);
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
};
|
|
3374
|
+
const isNumber = (s) => s.kind === "number";
|
|
3375
|
+
const isText = (s) => s.kind === "string" || s.kind === "enum";
|
|
3376
|
+
const isBoolEnum = (s) => s.kind === "enum" && s.values.every((v) => v === "true" || v === "false");
|
|
3377
|
+
ref("window", isNumber, "a number option", false);
|
|
3378
|
+
ref("pageSize", isNumber, "a number option", true);
|
|
3379
|
+
ref("state", isText, "a string or enum option", false);
|
|
3380
|
+
ref("severity", isText, "a string or enum option", false);
|
|
3381
|
+
ref("acknowledged", isBoolEnum, 'an enum option whose values are only "true" and "false"', false);
|
|
3382
|
+
}
|
|
3383
|
+
function checkInteractions(interactions, channel, add) {
|
|
3384
|
+
if (!isRecord3(interactions)) {
|
|
3385
|
+
add("interactions", "interactions must be an object");
|
|
3386
|
+
return;
|
|
3387
|
+
}
|
|
3388
|
+
const only = { alarmActions: "alarm", commandSend: "control" };
|
|
3389
|
+
for (const [key, value] of Object.entries(interactions)) {
|
|
3390
|
+
const at = `interactions.${key}`;
|
|
3391
|
+
if (key !== "alarmActions" && key !== "commandSend" && key !== "selection" && key !== "candidates") {
|
|
3392
|
+
add(at, `unknown interaction "${key}"`);
|
|
3393
|
+
} else if (value !== true) {
|
|
3394
|
+
add(at, `${key} must be true when declared (omit it otherwise)`);
|
|
3395
|
+
} else if (channel && only[key] && only[key] !== channel) {
|
|
3396
|
+
add(at, `${key} is only available to ${only[key]}-channel widgets, not ${channel}`);
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
function deepFreeze(value) {
|
|
3401
|
+
if (typeof value === "object" && value !== null) {
|
|
3402
|
+
Object.freeze(value);
|
|
3403
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
3404
|
+
}
|
|
3405
|
+
return value;
|
|
3406
|
+
}
|
|
3407
|
+
function createWidgetCatalog(descriptors) {
|
|
3408
|
+
const issues = collectCatalogIssues(descriptors);
|
|
3409
|
+
if (issues.length > 0) throw new WidgetCatalogError(issues);
|
|
3410
|
+
return sealCatalog(descriptors);
|
|
3411
|
+
}
|
|
3412
|
+
function collectCatalogIssues(descriptors) {
|
|
3413
|
+
if (!Array.isArray(descriptors)) {
|
|
3414
|
+
return [{ id: "#?", path: "", message: "descriptors must be an array" }];
|
|
3415
|
+
}
|
|
3416
|
+
const issues = [];
|
|
3417
|
+
const seen = /* @__PURE__ */ new Map();
|
|
3418
|
+
descriptors.forEach((d, index) => {
|
|
3419
|
+
const id = isRecord3(d) && typeof d.id === "string" && d.id !== "" ? d.id : `#${index}`;
|
|
3420
|
+
issues.push(...collectDescriptorIssues(d, id));
|
|
3421
|
+
if (id.startsWith("#")) return;
|
|
3422
|
+
const first = seen.get(id);
|
|
3423
|
+
if (first === void 0) seen.set(id, index);
|
|
3424
|
+
else issues.push({ id, path: "id", message: `duplicate widget id (first registered at position ${first}, again at ${index})` });
|
|
3425
|
+
});
|
|
3426
|
+
return issues;
|
|
3427
|
+
}
|
|
3428
|
+
function sealCatalog(descriptors) {
|
|
3429
|
+
const byId = /* @__PURE__ */ new Map();
|
|
3430
|
+
for (const d of descriptors) byId.set(d.id, deepFreeze(d));
|
|
3431
|
+
const ordered = Object.freeze([...byId.values()]);
|
|
3432
|
+
return Object.freeze({
|
|
3433
|
+
has: (id) => byId.has(id),
|
|
3434
|
+
get: (id) => byId.get(id),
|
|
3435
|
+
list: () => ordered
|
|
3436
|
+
});
|
|
1855
3437
|
}
|
|
1856
3438
|
export {
|
|
1857
3439
|
BASE_BREAKPOINT,
|
|
1858
3440
|
COMMAND_STATUSES,
|
|
1859
3441
|
DashboardDefinitionError,
|
|
1860
3442
|
DashboardHub,
|
|
3443
|
+
HOST_OPTION_CONTROLS,
|
|
3444
|
+
NotInRecordingError,
|
|
3445
|
+
PLAYBACK_RATES,
|
|
3446
|
+
RECORDED_HISTORY_WINDOW_MS,
|
|
3447
|
+
RECORDING_FORMAT_VERSION,
|
|
3448
|
+
RECORDING_KIND,
|
|
3449
|
+
RecordedClock,
|
|
3450
|
+
RecordedDataSource,
|
|
3451
|
+
RecordingFormatError,
|
|
3452
|
+
SDK_API_VERSION,
|
|
1861
3453
|
SYNTHETIC_GENERATORS,
|
|
1862
3454
|
SyntheticDataSource,
|
|
3455
|
+
UNKNOWN_WIDGET_TYPE,
|
|
1863
3456
|
WIDGET_TYPES,
|
|
3457
|
+
WidgetCatalogError,
|
|
1864
3458
|
activeBreakpoint,
|
|
1865
3459
|
addWidget,
|
|
1866
3460
|
anchorSlotNames,
|
|
3461
|
+
applyOptionPatch,
|
|
1867
3462
|
applySelection,
|
|
1868
3463
|
baseBox,
|
|
1869
3464
|
bindWidgetSlot,
|
|
1870
3465
|
bindingsWithoutScopedSlots,
|
|
1871
3466
|
bringToFront,
|
|
1872
3467
|
canScopeSlot,
|
|
3468
|
+
checkWidgetCompatibility,
|
|
3469
|
+
clampNumberOption,
|
|
1873
3470
|
clearWidgetDatasource,
|
|
3471
|
+
collectCatalogIssues,
|
|
1874
3472
|
createDeviceResolver,
|
|
1875
3473
|
createEntityLister,
|
|
3474
|
+
createRecordedClock,
|
|
3475
|
+
createRecordedLister,
|
|
3476
|
+
createRecordedResolver,
|
|
3477
|
+
createWidgetCatalog,
|
|
1876
3478
|
defaultHistoryWindow,
|
|
3479
|
+
defineWidgetDescriptor,
|
|
1877
3480
|
deleteWidget,
|
|
3481
|
+
describeIssues,
|
|
1878
3482
|
effectiveBindings,
|
|
3483
|
+
enumValues,
|
|
1879
3484
|
fetchWidgetHistory,
|
|
1880
3485
|
generateWidgetId,
|
|
1881
3486
|
gridBoxToPx,
|
|
1882
3487
|
hasScopedSlots,
|
|
3488
|
+
isBuiltinWidget,
|
|
3489
|
+
isBuiltinWidgetId,
|
|
1883
3490
|
isCancellableCommandStatus,
|
|
1884
3491
|
isDirty,
|
|
3492
|
+
isNamespacedWidgetId,
|
|
3493
|
+
isResolvedWidget,
|
|
1885
3494
|
isTerminalCommandStatus,
|
|
1886
3495
|
migrateToSlots,
|
|
3496
|
+
numberSpec,
|
|
1887
3497
|
parseBindingManifest,
|
|
3498
|
+
parseBoardRecording,
|
|
1888
3499
|
parseDashboardDefinition,
|
|
1889
3500
|
parseSlotBinding,
|
|
3501
|
+
parseWidgetRef,
|
|
1890
3502
|
pruneSlots,
|
|
1891
3503
|
pxToGridBox,
|
|
3504
|
+
recordedWallTimeMs,
|
|
3505
|
+
recordingBounds,
|
|
1892
3506
|
resolveConcrete,
|
|
1893
3507
|
resolveContextBindings,
|
|
1894
3508
|
resolveSlotCandidates,
|
|
1895
3509
|
resolveWidgetBox,
|
|
3510
|
+
resolveWidgetDatasource,
|
|
1896
3511
|
sameBinding,
|
|
1897
3512
|
serializeDefinition,
|
|
1898
3513
|
setCanvasGrid,
|
|
@@ -1900,9 +3515,15 @@ export {
|
|
|
1900
3515
|
setSlotScope,
|
|
1901
3516
|
setTitle,
|
|
1902
3517
|
setWidgetBox,
|
|
3518
|
+
simulationScaleAt,
|
|
3519
|
+
storedDefinition,
|
|
1903
3520
|
stripDefaultBindings,
|
|
3521
|
+
unresolvedWidgets,
|
|
1904
3522
|
updateWidget,
|
|
3523
|
+
validateOptions,
|
|
3524
|
+
validateWidgetDescriptor,
|
|
1905
3525
|
widgetBinding,
|
|
1906
|
-
widgetSlotName
|
|
3526
|
+
widgetSlotName,
|
|
3527
|
+
widgetVersionNotice
|
|
1907
3528
|
};
|
|
1908
3529
|
//# sourceMappingURL=index.js.map
|