@devicechain/dashboards 0.14.0-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +112 -0
- package/dist/bindings.d.ts +8 -0
- package/dist/candidates.d.ts +3 -0
- package/dist/command-status.d.ts +6 -0
- package/dist/context.d.ts +7 -0
- package/dist/definition.d.ts +13 -0
- package/dist/editor-model.d.ts +30 -0
- package/dist/entity-lister.d.ts +2 -0
- package/dist/history.d.ts +8 -0
- package/dist/hub.d.ts +115 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +1910 -0
- package/dist/index.js.map +7 -0
- package/dist/internal/alarm-doc.d.ts +48 -0
- package/dist/internal/command-doc.d.ts +43 -0
- package/dist/internal/location-doc.d.ts +9 -0
- package/dist/internal/measurement-doc.d.ts +10 -0
- package/dist/queries.d.ts +72 -0
- package/dist/resolver.d.ts +2 -0
- package/dist/slots.d.ts +22 -0
- package/dist/synthetic.d.ts +36 -0
- package/dist/types.d.ts +190 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1910 @@
|
|
|
1
|
+
// src/command-status.ts
|
|
2
|
+
var COMMAND_STATUSES = [
|
|
3
|
+
// ── Non-terminal ───────────────────────────────────────────────────────
|
|
4
|
+
// Accepted; awaiting its first dispatch decision. Genuinely transient.
|
|
5
|
+
"QUEUED",
|
|
6
|
+
// The platform is deliberately WITHHOLDING dispatch because the device is known
|
|
7
|
+
// absent. This is where an offline fleet's backlog accumulates — it can sit for days,
|
|
8
|
+
// and it is the honest answer to "why hasn't my command arrived?".
|
|
9
|
+
"HELD",
|
|
10
|
+
// Dispatched toward a device believed reachable; awaiting its response.
|
|
11
|
+
"SENT",
|
|
12
|
+
// Published, and it went NOWHERE: the device turned out not to be reachable, so the
|
|
13
|
+
// transport had nothing to hand it to. The platform still holds the command and will
|
|
14
|
+
// deliver it when the device next wakes. Distinct from HELD (where dispatch was never
|
|
15
|
+
// attempted, because the device was already known absent) and from TIMEOUT (where the
|
|
16
|
+
// command reached a device that then said nothing).
|
|
17
|
+
"PARKED",
|
|
18
|
+
// ── Terminal ───────────────────────────────────────────────────────────
|
|
19
|
+
// The device answered.
|
|
20
|
+
"SUCCESSFUL",
|
|
21
|
+
"FAILED",
|
|
22
|
+
// Dispatched, never answered in time.
|
|
23
|
+
"TIMEOUT",
|
|
24
|
+
// The TTL elapsed before it ever went out.
|
|
25
|
+
"EXPIRED",
|
|
26
|
+
// An operator or tenant called it off. Distinct from EXPIRED because it is a
|
|
27
|
+
// different ACTOR, not a different outcome. Cancellation used to write EXPIRED, so
|
|
28
|
+
// BOTH values appear in real data — historical rows are not backfilled.
|
|
29
|
+
"CANCELLED"
|
|
30
|
+
];
|
|
31
|
+
var TERMINAL_COMMAND_STATUSES = /* @__PURE__ */ new Set([
|
|
32
|
+
"SUCCESSFUL",
|
|
33
|
+
"FAILED",
|
|
34
|
+
"TIMEOUT",
|
|
35
|
+
"EXPIRED",
|
|
36
|
+
"CANCELLED"
|
|
37
|
+
]);
|
|
38
|
+
var CANCELLABLE_COMMAND_STATUSES = /* @__PURE__ */ new Set([
|
|
39
|
+
"QUEUED",
|
|
40
|
+
"HELD",
|
|
41
|
+
"PARKED"
|
|
42
|
+
]);
|
|
43
|
+
function isTerminalCommandStatus(status) {
|
|
44
|
+
return TERMINAL_COMMAND_STATUSES.has(status);
|
|
45
|
+
}
|
|
46
|
+
function isCancellableCommandStatus(status) {
|
|
47
|
+
return CANCELLABLE_COMMAND_STATUSES.has(status);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/types.ts
|
|
51
|
+
var WIDGET_TYPES = [
|
|
52
|
+
"timeseries-chart",
|
|
53
|
+
"latest-card",
|
|
54
|
+
"gauge",
|
|
55
|
+
"table",
|
|
56
|
+
"label",
|
|
57
|
+
"image",
|
|
58
|
+
"alarm-table",
|
|
59
|
+
"alarm-count",
|
|
60
|
+
"command-button",
|
|
61
|
+
"entity-selector",
|
|
62
|
+
"map"
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// src/definition.ts
|
|
66
|
+
var BASE_BREAKPOINT = "base";
|
|
67
|
+
var WIDGET_TYPE_SET = new Set(WIDGET_TYPES);
|
|
68
|
+
var DEFAULT_GRID = { columns: 24, gap: 8, rowHeight: 40 };
|
|
69
|
+
var DEFAULT_SIZING = "fill";
|
|
70
|
+
var DashboardDefinitionError = class extends Error {
|
|
71
|
+
constructor(message) {
|
|
72
|
+
super(`invalid dashboard definition: ${message}`);
|
|
73
|
+
this.name = "DashboardDefinitionError";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
function isRecord(v) {
|
|
77
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
78
|
+
}
|
|
79
|
+
function numberAt(rec, key, fallback) {
|
|
80
|
+
const v = rec[key];
|
|
81
|
+
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
82
|
+
}
|
|
83
|
+
function parseDashboardDefinition(raw) {
|
|
84
|
+
if (!isRecord(raw)) throw new DashboardDefinitionError("not a JSON object");
|
|
85
|
+
const widgetsRaw = raw.widgets;
|
|
86
|
+
if (!Array.isArray(widgetsRaw)) throw new DashboardDefinitionError("widgets must be an array");
|
|
87
|
+
const def = {
|
|
88
|
+
schemaVersion: numberAt(raw, "schemaVersion", 1),
|
|
89
|
+
title: typeof raw.title === "string" ? raw.title : "",
|
|
90
|
+
canvas: parseCanvas(raw.canvas),
|
|
91
|
+
widgets: widgetsRaw.map((w, i) => parseWidget(w, i))
|
|
92
|
+
};
|
|
93
|
+
const slots = parseSlots(raw.slots);
|
|
94
|
+
if (slots) def.slots = slots;
|
|
95
|
+
return def;
|
|
96
|
+
}
|
|
97
|
+
function parseSlots(raw) {
|
|
98
|
+
if (!isRecord(raw)) return void 0;
|
|
99
|
+
const slots = {};
|
|
100
|
+
for (const [name, spec] of Object.entries(raw)) {
|
|
101
|
+
if (name === "__proto__") continue;
|
|
102
|
+
if (!isRecord(spec)) continue;
|
|
103
|
+
const type = spec.type === "anchor" ? "anchor" : "device";
|
|
104
|
+
const slot = { type };
|
|
105
|
+
if (typeof spec.label === "string") slot.label = spec.label;
|
|
106
|
+
const binding = parseSlotBinding(spec.defaultBinding);
|
|
107
|
+
if (binding) slot.defaultBinding = binding;
|
|
108
|
+
const scope = parseScope(spec.scope);
|
|
109
|
+
if (scope) slot.scope = scope;
|
|
110
|
+
slots[name] = slot;
|
|
111
|
+
}
|
|
112
|
+
if (Object.keys(slots).length === 0) return void 0;
|
|
113
|
+
validateScopes(slots);
|
|
114
|
+
return slots;
|
|
115
|
+
}
|
|
116
|
+
function parseScope(raw) {
|
|
117
|
+
if (!isRecord(raw)) return void 0;
|
|
118
|
+
const parent = typeof raw.parent === "string" ? raw.parent : "";
|
|
119
|
+
if (!parent) return void 0;
|
|
120
|
+
return { parent, strategy: raw.strategy === "manual" ? "manual" : "first" };
|
|
121
|
+
}
|
|
122
|
+
function validateScopes(slots) {
|
|
123
|
+
const drop = [];
|
|
124
|
+
for (const [name, slot] of Object.entries(slots)) {
|
|
125
|
+
if (!slot.scope) continue;
|
|
126
|
+
const parentName = slot.scope.parent;
|
|
127
|
+
const parent = Object.prototype.hasOwnProperty.call(slots, parentName) ? slots[parentName] : void 0;
|
|
128
|
+
if (!parent || parent.type !== "anchor" || parentName === name || inScopeCycle(slots, name)) {
|
|
129
|
+
drop.push(name);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const name of drop) delete slots[name].scope;
|
|
133
|
+
}
|
|
134
|
+
function inScopeCycle(slots, start) {
|
|
135
|
+
const seen = /* @__PURE__ */ new Set();
|
|
136
|
+
let cur = start;
|
|
137
|
+
while (cur) {
|
|
138
|
+
if (seen.has(cur)) return true;
|
|
139
|
+
seen.add(cur);
|
|
140
|
+
const slot = Object.prototype.hasOwnProperty.call(slots, cur) ? slots[cur] : void 0;
|
|
141
|
+
cur = slot?.scope?.parent;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
function canScopeSlot(slots, child, parentName) {
|
|
146
|
+
if (child === parentName) return false;
|
|
147
|
+
const parent = Object.prototype.hasOwnProperty.call(slots, parentName) ? slots[parentName] : void 0;
|
|
148
|
+
if (!parent || parent.type !== "anchor") return false;
|
|
149
|
+
const seen = /* @__PURE__ */ new Set();
|
|
150
|
+
let cur = parentName;
|
|
151
|
+
while (cur) {
|
|
152
|
+
if (cur === child) return false;
|
|
153
|
+
if (seen.has(cur)) return false;
|
|
154
|
+
seen.add(cur);
|
|
155
|
+
const slot = Object.prototype.hasOwnProperty.call(slots, cur) ? slots[cur] : void 0;
|
|
156
|
+
cur = slot?.scope?.parent;
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
function parseSlotBinding(raw) {
|
|
161
|
+
if (!isRecord(raw)) return void 0;
|
|
162
|
+
if (raw.kind === "device") {
|
|
163
|
+
const token = stringAt(raw, "deviceToken");
|
|
164
|
+
return token ? { kind: "device", deviceToken: token } : void 0;
|
|
165
|
+
}
|
|
166
|
+
if (raw.kind === "anchor") {
|
|
167
|
+
const anchorRec = isRecord(raw.anchor) ? raw.anchor : {};
|
|
168
|
+
const targetToken = stringAt(anchorRec, "targetToken");
|
|
169
|
+
if (!targetToken) return void 0;
|
|
170
|
+
return {
|
|
171
|
+
kind: "anchor",
|
|
172
|
+
anchor: {
|
|
173
|
+
relationship: stringAt(anchorRec, "relationship"),
|
|
174
|
+
targetType: stringAt(anchorRec, "targetType"),
|
|
175
|
+
targetToken
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return void 0;
|
|
180
|
+
}
|
|
181
|
+
function parseGrid(raw) {
|
|
182
|
+
const rec = isRecord(raw) ? raw : {};
|
|
183
|
+
const columns = Math.max(1, Math.round(numberAt(rec, "columns", DEFAULT_GRID.columns)));
|
|
184
|
+
const rowHeight = Math.max(1, numberAt(rec, "rowHeight", DEFAULT_GRID.rowHeight));
|
|
185
|
+
let gap = DEFAULT_GRID.gap;
|
|
186
|
+
if (typeof rec.gap === "number" && Number.isFinite(rec.gap)) {
|
|
187
|
+
gap = Math.max(0, rec.gap);
|
|
188
|
+
} else if (isRecord(rec.gap)) {
|
|
189
|
+
gap = {
|
|
190
|
+
row: Math.max(0, numberAt(rec.gap, "row", DEFAULT_GRID.gap)),
|
|
191
|
+
col: Math.max(0, numberAt(rec.gap, "col", DEFAULT_GRID.gap))
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { columns, gap, rowHeight };
|
|
195
|
+
}
|
|
196
|
+
function parseSizing(raw) {
|
|
197
|
+
if (isRecord(raw)) {
|
|
198
|
+
if (typeof raw.width === "number" && Number.isFinite(raw.width)) {
|
|
199
|
+
return { width: Math.max(1, raw.width) };
|
|
200
|
+
}
|
|
201
|
+
if (typeof raw.height === "number" && Number.isFinite(raw.height)) {
|
|
202
|
+
return { height: Math.max(1, raw.height) };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return DEFAULT_SIZING;
|
|
206
|
+
}
|
|
207
|
+
function parseCanvas(raw) {
|
|
208
|
+
const rec = isRecord(raw) ? raw : {};
|
|
209
|
+
const grid = parseGrid(rec.grid);
|
|
210
|
+
const sizing = parseSizing(rec.sizing);
|
|
211
|
+
const bpRec = isRecord(rec.breakpoints) ? rec.breakpoints : {};
|
|
212
|
+
const breakpoints = {};
|
|
213
|
+
for (const [name, width] of Object.entries(bpRec)) {
|
|
214
|
+
if (typeof width === "number" && Number.isFinite(width)) breakpoints[name] = width;
|
|
215
|
+
}
|
|
216
|
+
if (!(BASE_BREAKPOINT in breakpoints)) breakpoints[BASE_BREAKPOINT] = 0;
|
|
217
|
+
const canvas = { grid, sizing, breakpoints };
|
|
218
|
+
if (isRecord(rec.background)) {
|
|
219
|
+
const { color, imageUrl } = rec.background;
|
|
220
|
+
canvas.background = {
|
|
221
|
+
color: typeof color === "string" ? color : null,
|
|
222
|
+
imageUrl: typeof imageUrl === "string" ? imageUrl : null
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return canvas;
|
|
226
|
+
}
|
|
227
|
+
function parseWidget(raw, index) {
|
|
228
|
+
if (!isRecord(raw)) throw new DashboardDefinitionError(`widgets[${index}] is not an object`);
|
|
229
|
+
const type = raw.type;
|
|
230
|
+
if (typeof type !== "string" || !WIDGET_TYPE_SET.has(type)) {
|
|
231
|
+
throw new DashboardDefinitionError(`widgets[${index}] has unknown type ${JSON.stringify(type)}`);
|
|
232
|
+
}
|
|
233
|
+
const widget = {
|
|
234
|
+
id: typeof raw.id === "string" && raw.id.length > 0 ? raw.id : generateWidgetId(),
|
|
235
|
+
type,
|
|
236
|
+
layout: parseLayout(raw.layout, index)
|
|
237
|
+
};
|
|
238
|
+
const ds = parseDatasource(raw.datasource);
|
|
239
|
+
if (ds) widget.datasource = ds;
|
|
240
|
+
if (isRecord(raw.options)) widget.options = raw.options;
|
|
241
|
+
return widget;
|
|
242
|
+
}
|
|
243
|
+
function stringAt(rec, key) {
|
|
244
|
+
const v = rec[key];
|
|
245
|
+
return typeof v === "string" ? v : "";
|
|
246
|
+
}
|
|
247
|
+
function stringArrayAt(rec, key) {
|
|
248
|
+
const v = rec[key];
|
|
249
|
+
return Array.isArray(v) ? v.filter((m) => typeof m === "string") : [];
|
|
250
|
+
}
|
|
251
|
+
function parseLocationSelection(raw) {
|
|
252
|
+
if (!isRecord(raw)) return void 0;
|
|
253
|
+
return raw.series === "latest" ? { series: "latest" } : void 0;
|
|
254
|
+
}
|
|
255
|
+
function withLocation(selector, raw) {
|
|
256
|
+
const location = parseLocationSelection(raw.location);
|
|
257
|
+
return location ? { ...selector, location } : selector;
|
|
258
|
+
}
|
|
259
|
+
function parseDatasource(raw) {
|
|
260
|
+
if (!isRecord(raw)) return void 0;
|
|
261
|
+
const kind = raw.kind;
|
|
262
|
+
if (typeof kind !== "string" || kind.length === 0) return void 0;
|
|
263
|
+
if (kind === "device") {
|
|
264
|
+
return withLocation(
|
|
265
|
+
{ kind: "device", deviceToken: stringAt(raw, "deviceToken"), measurements: stringArrayAt(raw, "measurements") },
|
|
266
|
+
raw
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (kind === "anchor") {
|
|
270
|
+
const anchorRec = isRecord(raw.anchor) ? raw.anchor : {};
|
|
271
|
+
const selector = withLocation(
|
|
272
|
+
{
|
|
273
|
+
kind: "anchor",
|
|
274
|
+
anchor: {
|
|
275
|
+
relationship: stringAt(anchorRec, "relationship"),
|
|
276
|
+
// targetType defaults to '' (the config panel constrains it to the union;
|
|
277
|
+
// a hand-edited/empty value round-trips rather than being silently coerced).
|
|
278
|
+
targetType: stringAt(anchorRec, "targetType"),
|
|
279
|
+
targetToken: stringAt(anchorRec, "targetToken")
|
|
280
|
+
},
|
|
281
|
+
measurements: stringArrayAt(raw, "measurements")
|
|
282
|
+
},
|
|
283
|
+
raw
|
|
284
|
+
);
|
|
285
|
+
if (isRecord(raw.aggregation)) {
|
|
286
|
+
selector.aggregation = raw.aggregation;
|
|
287
|
+
}
|
|
288
|
+
return selector;
|
|
289
|
+
}
|
|
290
|
+
if (kind === "slot") {
|
|
291
|
+
return withLocation(
|
|
292
|
+
{ kind: "slot", slot: stringAt(raw, "slot"), measurements: stringArrayAt(raw, "measurements") },
|
|
293
|
+
raw
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return raw;
|
|
297
|
+
}
|
|
298
|
+
function parseLayout(raw, index) {
|
|
299
|
+
if (!isRecord(raw)) throw new DashboardDefinitionError(`widgets[${index}].layout is missing`);
|
|
300
|
+
const layout = {};
|
|
301
|
+
for (const [bp, box] of Object.entries(raw)) {
|
|
302
|
+
if (isRecord(box)) layout[bp] = parseBox(box);
|
|
303
|
+
}
|
|
304
|
+
if (!(BASE_BREAKPOINT in layout)) {
|
|
305
|
+
throw new DashboardDefinitionError(`widgets[${index}].layout has no '${BASE_BREAKPOINT}' box`);
|
|
306
|
+
}
|
|
307
|
+
return layout;
|
|
308
|
+
}
|
|
309
|
+
function parseBox(rec) {
|
|
310
|
+
const box = {
|
|
311
|
+
col: Math.max(0, Math.round(numberAt(rec, "col", 0))),
|
|
312
|
+
colSpan: Math.max(1, Math.round(numberAt(rec, "colSpan", 1))),
|
|
313
|
+
row: Math.max(0, Math.round(numberAt(rec, "row", 0))),
|
|
314
|
+
rowSpan: Math.max(1, Math.round(numberAt(rec, "rowSpan", 1))),
|
|
315
|
+
// z rounds too: a fractional zIndex is invalid CSS and silently drops to auto,
|
|
316
|
+
// so keep it an integer like every other box field.
|
|
317
|
+
z: Math.round(numberAt(rec, "z", 0))
|
|
318
|
+
};
|
|
319
|
+
if (isRecord(rec.offset)) {
|
|
320
|
+
box.offset = { x: numberAt(rec.offset, "x", 0), y: numberAt(rec.offset, "y", 0) };
|
|
321
|
+
}
|
|
322
|
+
return box;
|
|
323
|
+
}
|
|
324
|
+
function serializeDefinition(def) {
|
|
325
|
+
return JSON.stringify(def);
|
|
326
|
+
}
|
|
327
|
+
function isDirty(a, b) {
|
|
328
|
+
return serializeDefinition(a) !== serializeDefinition(b);
|
|
329
|
+
}
|
|
330
|
+
function resolveWidgetBox(layout, breakpoint) {
|
|
331
|
+
return layout[breakpoint] ?? layout[BASE_BREAKPOINT];
|
|
332
|
+
}
|
|
333
|
+
function activeBreakpoint(breakpoints, viewportWidth) {
|
|
334
|
+
let best = BASE_BREAKPOINT;
|
|
335
|
+
let bestWidth = -1;
|
|
336
|
+
for (const [name, minWidth] of Object.entries(breakpoints)) {
|
|
337
|
+
if (viewportWidth >= minWidth && minWidth > bestWidth) {
|
|
338
|
+
best = name;
|
|
339
|
+
bestWidth = minWidth;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return best;
|
|
343
|
+
}
|
|
344
|
+
var idCounter = 0;
|
|
345
|
+
function generateWidgetId() {
|
|
346
|
+
const c = globalThis.crypto;
|
|
347
|
+
if (c && typeof c.randomUUID === "function") return `w-${c.randomUUID()}`;
|
|
348
|
+
idCounter += 1;
|
|
349
|
+
return `w-${idCounter.toString(36)}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/bindings.ts
|
|
353
|
+
function effectiveBindings(definition, manifest) {
|
|
354
|
+
const out = {};
|
|
355
|
+
for (const [name, slot] of Object.entries(definition.slots ?? {})) {
|
|
356
|
+
if (slot.defaultBinding) out[name] = slot.defaultBinding;
|
|
357
|
+
}
|
|
358
|
+
if (manifest) {
|
|
359
|
+
for (const [name, binding] of Object.entries(manifest)) out[name] = binding;
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
function parseBindingManifest(raw) {
|
|
364
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
365
|
+
return { bindings: {}, dropped: [] };
|
|
366
|
+
}
|
|
367
|
+
const bindings = {};
|
|
368
|
+
const dropped = [];
|
|
369
|
+
for (const [slot, spec] of Object.entries(raw)) {
|
|
370
|
+
if (slot === "__proto__") {
|
|
371
|
+
dropped.push(slot);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const binding = parseSlotBinding(spec);
|
|
375
|
+
if (binding) bindings[slot] = binding;
|
|
376
|
+
else dropped.push(slot);
|
|
377
|
+
}
|
|
378
|
+
return { bindings, dropped };
|
|
379
|
+
}
|
|
380
|
+
function stripDefaultBindings(def) {
|
|
381
|
+
if (!def.slots) return def;
|
|
382
|
+
const slots = {};
|
|
383
|
+
for (const [name, slot] of Object.entries(def.slots)) {
|
|
384
|
+
const { defaultBinding: _drop, ...rest } = slot;
|
|
385
|
+
slots[name] = rest;
|
|
386
|
+
}
|
|
387
|
+
return { ...def, slots };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/context.ts
|
|
391
|
+
function ownGet(map, key) {
|
|
392
|
+
return map && Object.prototype.hasOwnProperty.call(map, key) ? map[key] : void 0;
|
|
393
|
+
}
|
|
394
|
+
function setBinding(map, key, value) {
|
|
395
|
+
if (key === "__proto__") return;
|
|
396
|
+
map[key] = value;
|
|
397
|
+
}
|
|
398
|
+
function hasScopedSlots(definition) {
|
|
399
|
+
const slots = definition.slots;
|
|
400
|
+
if (!slots) return false;
|
|
401
|
+
for (const name of Object.keys(slots)) if (slots[name]?.scope) return true;
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
function bindingsWithoutScopedSlots(definition, map) {
|
|
405
|
+
const slots = definition.slots;
|
|
406
|
+
if (!slots) return map;
|
|
407
|
+
const out = {};
|
|
408
|
+
for (const name of Object.keys(map)) {
|
|
409
|
+
if (!ownGet(slots, name)?.scope) setBinding(out, name, map[name]);
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
function applySelection(overlay, target) {
|
|
414
|
+
return { ...overlay, [target.slot]: target.binding };
|
|
415
|
+
}
|
|
416
|
+
function topoOrder(slots) {
|
|
417
|
+
const order = [];
|
|
418
|
+
const done = /* @__PURE__ */ new Set();
|
|
419
|
+
const visit = (name, stack) => {
|
|
420
|
+
if (done.has(name) || stack.has(name)) return;
|
|
421
|
+
const slot = ownGet(slots, name);
|
|
422
|
+
if (!slot) return;
|
|
423
|
+
stack.add(name);
|
|
424
|
+
const parent = slot.scope?.parent;
|
|
425
|
+
if (parent) visit(parent, stack);
|
|
426
|
+
stack.delete(name);
|
|
427
|
+
done.add(name);
|
|
428
|
+
order.push(name);
|
|
429
|
+
};
|
|
430
|
+
for (const name of Object.keys(slots)) visit(name, /* @__PURE__ */ new Set());
|
|
431
|
+
return order;
|
|
432
|
+
}
|
|
433
|
+
async function resolveContextBindings(definition, base, overlay, resolver) {
|
|
434
|
+
const slots = definition.slots ?? {};
|
|
435
|
+
const out = {};
|
|
436
|
+
for (const name of topoOrder(slots)) {
|
|
437
|
+
const slot = ownGet(slots, name);
|
|
438
|
+
const scope = slot?.scope;
|
|
439
|
+
if (!scope) {
|
|
440
|
+
const sel = ownGet(overlay, name);
|
|
441
|
+
const b = sel && (!slot || bindingMatchesType(sel, slot.type)) ? sel : ownGet(base, name);
|
|
442
|
+
if (b) setBinding(out, name, b);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (slot && slot.type !== "device") continue;
|
|
446
|
+
const parentBinding = ownGet(out, scope.parent);
|
|
447
|
+
if (!parentBinding || parentBinding.kind !== "anchor") continue;
|
|
448
|
+
let members;
|
|
449
|
+
try {
|
|
450
|
+
members = [...await resolver.devicesForAnchor(parentBinding.anchor)].sort();
|
|
451
|
+
} catch {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (scope.strategy === "first") {
|
|
455
|
+
if (members.length > 0) setBinding(out, name, { kind: "device", deviceToken: members[0] });
|
|
456
|
+
} else {
|
|
457
|
+
const pick = ownGet(overlay, name) ?? ownGet(base, name);
|
|
458
|
+
if (pick && pick.kind === "device" && members.includes(pick.deviceToken)) setBinding(out, name, pick);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (const name of Object.keys(base)) {
|
|
462
|
+
if (Object.prototype.hasOwnProperty.call(slots, name) || Object.prototype.hasOwnProperty.call(out, name)) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const b = ownGet(overlay, name) ?? ownGet(base, name);
|
|
466
|
+
if (b) setBinding(out, name, b);
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
function bindingMatchesType(binding, type) {
|
|
471
|
+
return binding.kind === type;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// src/slots.ts
|
|
475
|
+
function slotSelector(slot, measurements, location) {
|
|
476
|
+
return location ? { kind: "slot", slot, measurements, location } : { kind: "slot", slot, measurements };
|
|
477
|
+
}
|
|
478
|
+
function sameBinding(a, b) {
|
|
479
|
+
if (!a || !b) return a === b;
|
|
480
|
+
if (a.kind === "device" && b.kind === "device") return a.deviceToken === b.deviceToken;
|
|
481
|
+
if (a.kind === "anchor" && b.kind === "anchor") {
|
|
482
|
+
return a.anchor.relationship === b.anchor.relationship && a.anchor.targetType === b.anchor.targetType && a.anchor.targetToken === b.anchor.targetToken;
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
function bindingLabel(b) {
|
|
487
|
+
return b.kind === "device" ? b.deviceToken : b.anchor.targetToken;
|
|
488
|
+
}
|
|
489
|
+
function nextSlotName(slots) {
|
|
490
|
+
let n = 1;
|
|
491
|
+
while (slots[`slot-${n}`]) n += 1;
|
|
492
|
+
return `slot-${n}`;
|
|
493
|
+
}
|
|
494
|
+
function bindingOfSelector(ds) {
|
|
495
|
+
if (ds?.kind === "device") return ds.deviceToken ? { kind: "device", deviceToken: ds.deviceToken } : void 0;
|
|
496
|
+
if (ds?.kind === "anchor") {
|
|
497
|
+
return ds.anchor.targetToken ? { kind: "anchor", anchor: ds.anchor } : void 0;
|
|
498
|
+
}
|
|
499
|
+
return void 0;
|
|
500
|
+
}
|
|
501
|
+
function findOrAddSlot(slots, binding) {
|
|
502
|
+
const existing = Object.keys(slots).find((k) => !slots[k].scope && sameBinding(slots[k].defaultBinding, binding));
|
|
503
|
+
if (existing) return existing;
|
|
504
|
+
const name = nextSlotName(slots);
|
|
505
|
+
slots[name] = { type: binding.kind, label: bindingLabel(binding), defaultBinding: binding };
|
|
506
|
+
return name;
|
|
507
|
+
}
|
|
508
|
+
function migrateToSlots(def) {
|
|
509
|
+
const slots = { ...def.slots ?? {} };
|
|
510
|
+
let changed = false;
|
|
511
|
+
const widgets = def.widgets.map((w) => {
|
|
512
|
+
const ds = w.datasource;
|
|
513
|
+
if (ds?.kind === "anchor" && ds.aggregation) return w;
|
|
514
|
+
const binding = bindingOfSelector(ds);
|
|
515
|
+
if (!binding || !ds) return w;
|
|
516
|
+
changed = true;
|
|
517
|
+
const slot = findOrAddSlot(slots, binding);
|
|
518
|
+
return { ...w, datasource: slotSelector(slot, ds.measurements, ds.location) };
|
|
519
|
+
});
|
|
520
|
+
if (!changed) return def;
|
|
521
|
+
return { ...def, widgets, slots };
|
|
522
|
+
}
|
|
523
|
+
function bindWidgetSlot(def, widgetId, binding, measurements, location) {
|
|
524
|
+
const slots = { ...def.slots ?? {} };
|
|
525
|
+
const current = def.widgets.find((w) => w.id === widgetId)?.datasource;
|
|
526
|
+
const currentSlot = current?.kind === "slot" ? current.slot : void 0;
|
|
527
|
+
const currentDef = currentSlot ? slots[currentSlot] : void 0;
|
|
528
|
+
const slot = currentSlot && currentDef && (currentDef.scope || sameBinding(currentDef.defaultBinding, binding)) ? currentSlot : findOrAddSlot(slots, binding);
|
|
529
|
+
const widgets = def.widgets.map(
|
|
530
|
+
(w) => w.id === widgetId ? { ...w, datasource: slotSelector(slot, measurements, location) } : w
|
|
531
|
+
);
|
|
532
|
+
return { ...def, widgets, slots };
|
|
533
|
+
}
|
|
534
|
+
function clearWidgetDatasource(def, widgetId) {
|
|
535
|
+
const widgets = def.widgets.map((w) => {
|
|
536
|
+
if (w.id !== widgetId) return w;
|
|
537
|
+
const { datasource: _drop, ...rest } = w;
|
|
538
|
+
return rest;
|
|
539
|
+
});
|
|
540
|
+
return { ...def, widgets };
|
|
541
|
+
}
|
|
542
|
+
function pruneSlots(def) {
|
|
543
|
+
if (!def.slots) return def;
|
|
544
|
+
const defSlots = def.slots;
|
|
545
|
+
const used = /* @__PURE__ */ new Set();
|
|
546
|
+
for (const w of def.widgets) {
|
|
547
|
+
if (w.datasource?.kind === "slot") used.add(w.datasource.slot);
|
|
548
|
+
const target = w.options?.selectionTarget;
|
|
549
|
+
if (typeof target === "string" && target.length > 0) used.add(target);
|
|
550
|
+
}
|
|
551
|
+
for (const start of [...used]) {
|
|
552
|
+
let cur = start;
|
|
553
|
+
const guard = /* @__PURE__ */ new Set();
|
|
554
|
+
while (cur && Object.prototype.hasOwnProperty.call(defSlots, cur) && !guard.has(cur)) {
|
|
555
|
+
guard.add(cur);
|
|
556
|
+
const parent = defSlots[cur].scope?.parent;
|
|
557
|
+
if (parent) used.add(parent);
|
|
558
|
+
cur = parent;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const slots = {};
|
|
562
|
+
for (const [name, slot] of Object.entries(def.slots)) if (used.has(name)) slots[name] = slot;
|
|
563
|
+
if (Object.keys(slots).length === 0) {
|
|
564
|
+
const { slots: _drop, ...rest } = def;
|
|
565
|
+
return rest;
|
|
566
|
+
}
|
|
567
|
+
return { ...def, slots };
|
|
568
|
+
}
|
|
569
|
+
function anchorSlotNames(def) {
|
|
570
|
+
const slots = def.slots ?? {};
|
|
571
|
+
return Object.keys(slots).filter((name) => slots[name].type === "anchor");
|
|
572
|
+
}
|
|
573
|
+
function setSlotScope(def, slotName, scope) {
|
|
574
|
+
if (!def.slots || !Object.prototype.hasOwnProperty.call(def.slots, slotName)) return def;
|
|
575
|
+
const slots = { ...def.slots };
|
|
576
|
+
const current = slots[slotName];
|
|
577
|
+
if (!scope) {
|
|
578
|
+
if (!current.scope) return def;
|
|
579
|
+
const { scope: _drop, ...rest } = current;
|
|
580
|
+
slots[slotName] = rest;
|
|
581
|
+
return { ...def, slots };
|
|
582
|
+
}
|
|
583
|
+
if (!canScopeSlot(slots, slotName, scope.parent)) return def;
|
|
584
|
+
slots[slotName] = { ...current, scope: { parent: scope.parent, strategy: scope.strategy } };
|
|
585
|
+
return { ...def, slots };
|
|
586
|
+
}
|
|
587
|
+
function widgetBinding(def, widget) {
|
|
588
|
+
const ds = widget.datasource;
|
|
589
|
+
if (ds?.kind === "slot") return def.slots?.[ds.slot]?.defaultBinding;
|
|
590
|
+
return bindingOfSelector(ds);
|
|
591
|
+
}
|
|
592
|
+
function widgetSlotName(widget) {
|
|
593
|
+
return widget.datasource?.kind === "slot" ? widget.datasource.slot : void 0;
|
|
594
|
+
}
|
|
595
|
+
function resolveConcrete(def, widget) {
|
|
596
|
+
const binding = widgetBinding(def, widget);
|
|
597
|
+
if (!binding) return void 0;
|
|
598
|
+
const measurements = widget.datasource?.measurements ?? [];
|
|
599
|
+
const location = widget.datasource?.location;
|
|
600
|
+
const base = location ? { measurements, location } : { measurements };
|
|
601
|
+
return binding.kind === "device" ? { kind: "device", deviceToken: binding.deviceToken, ...base } : { kind: "anchor", anchor: binding.anchor, ...base };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/candidates.ts
|
|
605
|
+
function ownGet2(map, key) {
|
|
606
|
+
return map && Object.prototype.hasOwnProperty.call(map, key) ? map[key] : void 0;
|
|
607
|
+
}
|
|
608
|
+
function sameBinding2(a, b) {
|
|
609
|
+
if (!a) return false;
|
|
610
|
+
if (a.kind === "device" && b.kind === "device") return a.deviceToken === b.deviceToken;
|
|
611
|
+
if (a.kind === "anchor" && b.kind === "anchor") return a.anchor.targetToken === b.anchor.targetToken;
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
async function resolveSlotCandidates(definition, slot, bindings, resolver, lister) {
|
|
615
|
+
const slots = definition.slots ?? {};
|
|
616
|
+
const def = ownGet2(slots, slot);
|
|
617
|
+
if (!def) return [];
|
|
618
|
+
const current = ownGet2(bindings, slot);
|
|
619
|
+
const mark = (binding, label) => ({
|
|
620
|
+
binding,
|
|
621
|
+
label,
|
|
622
|
+
selected: sameBinding2(current, binding)
|
|
623
|
+
});
|
|
624
|
+
if (def.scope) {
|
|
625
|
+
if (def.scope.strategy === "first") return [];
|
|
626
|
+
const parentBinding = ownGet2(bindings, def.scope.parent);
|
|
627
|
+
if (!parentBinding || parentBinding.kind !== "anchor") return [];
|
|
628
|
+
let members;
|
|
629
|
+
try {
|
|
630
|
+
members = [...await resolver.devicesForAnchor(parentBinding.anchor)].sort();
|
|
631
|
+
} catch {
|
|
632
|
+
return [];
|
|
633
|
+
}
|
|
634
|
+
return members.map((token) => mark({ kind: "device", deviceToken: token }, token));
|
|
635
|
+
}
|
|
636
|
+
if (def.type === "device") {
|
|
637
|
+
let rows2;
|
|
638
|
+
try {
|
|
639
|
+
rows2 = await lister("device");
|
|
640
|
+
} catch {
|
|
641
|
+
return [];
|
|
642
|
+
}
|
|
643
|
+
return rows2.map((r) => mark({ kind: "device", deviceToken: r.token }, r.name || r.token));
|
|
644
|
+
}
|
|
645
|
+
const template = current?.kind === "anchor" ? current.anchor : def.defaultBinding?.kind === "anchor" ? def.defaultBinding.anchor : void 0;
|
|
646
|
+
if (!template) return [];
|
|
647
|
+
let rows;
|
|
648
|
+
try {
|
|
649
|
+
rows = await lister(template.targetType);
|
|
650
|
+
} catch {
|
|
651
|
+
return [];
|
|
652
|
+
}
|
|
653
|
+
return rows.map(
|
|
654
|
+
(r) => mark(
|
|
655
|
+
{
|
|
656
|
+
kind: "anchor",
|
|
657
|
+
anchor: {
|
|
658
|
+
relationship: template.relationship,
|
|
659
|
+
targetType: template.targetType,
|
|
660
|
+
targetToken: r.token
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
r.name || r.token
|
|
664
|
+
)
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// src/entity-lister.ts
|
|
669
|
+
import { gql } from "@devicechain/client";
|
|
670
|
+
|
|
671
|
+
// src/queries.ts
|
|
672
|
+
var DEVICES_FOR_ANCHOR = `
|
|
673
|
+
query DevicesForAnchor($criteria: EntityRelationshipSearchCriteria!) {
|
|
674
|
+
entityRelationships(criteria: $criteria) {
|
|
675
|
+
results {
|
|
676
|
+
source {
|
|
677
|
+
token
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
`;
|
|
683
|
+
var DEVICES_BY_TOKEN = `
|
|
684
|
+
query DashboardDevicesByToken($tokens: [String!]!) {
|
|
685
|
+
devicesByToken(tokens: $tokens) {
|
|
686
|
+
token
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
`;
|
|
690
|
+
var LIST_DEVICES = `
|
|
691
|
+
query DashboardListDevices($criteria: DeviceSearchCriteria!) {
|
|
692
|
+
devices(criteria: $criteria) {
|
|
693
|
+
results {
|
|
694
|
+
token
|
|
695
|
+
name
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
`;
|
|
700
|
+
var LIST_CUSTOMERS = `
|
|
701
|
+
query DashboardListCustomers($criteria: CustomerSearchCriteria!) {
|
|
702
|
+
customers(criteria: $criteria) {
|
|
703
|
+
results {
|
|
704
|
+
token
|
|
705
|
+
name
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
`;
|
|
710
|
+
var LIST_AREAS = `
|
|
711
|
+
query DashboardListAreas($criteria: AreaSearchCriteria!) {
|
|
712
|
+
areas(criteria: $criteria) {
|
|
713
|
+
results {
|
|
714
|
+
token
|
|
715
|
+
name
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
`;
|
|
720
|
+
var LIST_ASSETS = `
|
|
721
|
+
query DashboardListAssets($criteria: AssetSearchCriteria!) {
|
|
722
|
+
assets(criteria: $criteria) {
|
|
723
|
+
results {
|
|
724
|
+
token
|
|
725
|
+
name
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
`;
|
|
730
|
+
var BUCKETED_MEASUREMENTS = `
|
|
731
|
+
query BucketedMeasurements($criteria: MeasurementAggregationCriteria!) {
|
|
732
|
+
bucketedMeasurements(criteria: $criteria) {
|
|
733
|
+
bucketStart
|
|
734
|
+
name
|
|
735
|
+
avg
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
`;
|
|
739
|
+
|
|
740
|
+
// src/entity-lister.ts
|
|
741
|
+
var LIST_PAGE_SIZE = 500;
|
|
742
|
+
function createEntityLister() {
|
|
743
|
+
const cache = /* @__PURE__ */ new Map();
|
|
744
|
+
const criteria = { pageNumber: 1, pageSize: LIST_PAGE_SIZE };
|
|
745
|
+
return (kind) => {
|
|
746
|
+
let pending = cache.get(kind);
|
|
747
|
+
if (!pending) {
|
|
748
|
+
pending = fetchKind(kind, criteria).catch((err) => {
|
|
749
|
+
cache.delete(kind);
|
|
750
|
+
throw err;
|
|
751
|
+
});
|
|
752
|
+
cache.set(kind, pending);
|
|
753
|
+
}
|
|
754
|
+
return pending;
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
function fetchKind(kind, criteria) {
|
|
758
|
+
switch (kind) {
|
|
759
|
+
case "device":
|
|
760
|
+
return gql("device-management", LIST_DEVICES, { criteria }).then((r) => r.devices.results);
|
|
761
|
+
case "customer":
|
|
762
|
+
return gql("device-management", LIST_CUSTOMERS, { criteria }).then((r) => r.customers.results);
|
|
763
|
+
case "area":
|
|
764
|
+
return gql("device-management", LIST_AREAS, { criteria }).then((r) => r.areas.results);
|
|
765
|
+
case "asset":
|
|
766
|
+
return gql("device-management", LIST_ASSETS, { criteria }).then((r) => r.assets.results);
|
|
767
|
+
default:
|
|
768
|
+
return Promise.resolve([]);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// src/hub.ts
|
|
773
|
+
import { gql as gql2, isForbiddenError, subscribe } from "@devicechain/client";
|
|
774
|
+
|
|
775
|
+
// src/internal/alarm-doc.ts
|
|
776
|
+
var ALARMS_QUERY = `
|
|
777
|
+
query DashboardAlarms($criteria: AlarmSearchCriteria!) {
|
|
778
|
+
alarms(criteria: $criteria) {
|
|
779
|
+
results {
|
|
780
|
+
token
|
|
781
|
+
originatorType
|
|
782
|
+
originatorToken
|
|
783
|
+
alarmKey
|
|
784
|
+
metricKey
|
|
785
|
+
state
|
|
786
|
+
acknowledged
|
|
787
|
+
severity
|
|
788
|
+
raisedTime
|
|
789
|
+
clearedTime
|
|
790
|
+
acknowledgedTime
|
|
791
|
+
acknowledgedBy
|
|
792
|
+
lastValue
|
|
793
|
+
message
|
|
794
|
+
}
|
|
795
|
+
pagination {
|
|
796
|
+
totalRecords
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
`;
|
|
801
|
+
var ALARM_STREAM = `
|
|
802
|
+
subscription DashboardAlarmStream(
|
|
803
|
+
$originatorType: String
|
|
804
|
+
$originator: String
|
|
805
|
+
$state: String
|
|
806
|
+
$severity: String
|
|
807
|
+
$alarmKey: String
|
|
808
|
+
) {
|
|
809
|
+
alarmStream(
|
|
810
|
+
originatorType: $originatorType
|
|
811
|
+
originator: $originator
|
|
812
|
+
state: $state
|
|
813
|
+
severity: $severity
|
|
814
|
+
alarmKey: $alarmKey
|
|
815
|
+
) {
|
|
816
|
+
alarmToken
|
|
817
|
+
eventType
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
`;
|
|
821
|
+
var ACKNOWLEDGE_ALARM = `
|
|
822
|
+
mutation DashboardAcknowledgeAlarm($token: String!) {
|
|
823
|
+
acknowledgeAlarm(token: $token) {
|
|
824
|
+
token
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
`;
|
|
828
|
+
var CLEAR_ALARM = `
|
|
829
|
+
mutation DashboardClearAlarm($token: String!) {
|
|
830
|
+
clearAlarm(token: $token) {
|
|
831
|
+
token
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
`;
|
|
835
|
+
|
|
836
|
+
// src/internal/command-doc.ts
|
|
837
|
+
var COMMANDS_QUERY = `
|
|
838
|
+
query DashboardCommands($criteria: CommandSearchCriteria!) {
|
|
839
|
+
commands(criteria: $criteria) {
|
|
840
|
+
results {
|
|
841
|
+
token
|
|
842
|
+
name
|
|
843
|
+
status
|
|
844
|
+
payload
|
|
845
|
+
responsePayload
|
|
846
|
+
error
|
|
847
|
+
queuedTime
|
|
848
|
+
sentTime
|
|
849
|
+
respondedTime
|
|
850
|
+
}
|
|
851
|
+
pagination {
|
|
852
|
+
totalRecords
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
`;
|
|
857
|
+
var CREATE_COMMAND = `
|
|
858
|
+
mutation DashboardCreateCommand($request: CommandCreateRequest!) {
|
|
859
|
+
createCommand(request: $request) {
|
|
860
|
+
command {
|
|
861
|
+
token
|
|
862
|
+
status
|
|
863
|
+
}
|
|
864
|
+
rejection {
|
|
865
|
+
code
|
|
866
|
+
reason
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
`;
|
|
871
|
+
|
|
872
|
+
// src/internal/location-doc.ts
|
|
873
|
+
var LATEST_LOCATIONS_QUERY = `
|
|
874
|
+
query DashboardLatestLocations($deviceTokens: [String!]!) {
|
|
875
|
+
latestLocations(deviceTokens: $deviceTokens) {
|
|
876
|
+
id
|
|
877
|
+
deviceToken
|
|
878
|
+
latitude
|
|
879
|
+
longitude
|
|
880
|
+
elevation
|
|
881
|
+
accuracy
|
|
882
|
+
speed
|
|
883
|
+
heading
|
|
884
|
+
occurredTime
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
`;
|
|
888
|
+
|
|
889
|
+
// src/internal/measurement-doc.ts
|
|
890
|
+
var MEASUREMENT_STREAM = `
|
|
891
|
+
subscription MeasurementStream($deviceToken: String, $name: String) {
|
|
892
|
+
measurementStream(deviceToken: $deviceToken, name: $name) {
|
|
893
|
+
id
|
|
894
|
+
deviceToken
|
|
895
|
+
eventType
|
|
896
|
+
occurredTime
|
|
897
|
+
name
|
|
898
|
+
value
|
|
899
|
+
classifier
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
`;
|
|
903
|
+
|
|
904
|
+
// src/hub.ts
|
|
905
|
+
var EVENT_AREA = "event-management";
|
|
906
|
+
var DEVICE_AREA = "device-management";
|
|
907
|
+
var COMMAND_AREA = "command-delivery";
|
|
908
|
+
var STATE_AREA = "device-state";
|
|
909
|
+
function randomToken() {
|
|
910
|
+
const c = globalThis.crypto;
|
|
911
|
+
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
|
912
|
+
return `cmd-${Math.random().toString(16).slice(2)}-${Math.random().toString(16).slice(2)}`;
|
|
913
|
+
}
|
|
914
|
+
var ALARM_RECONCILE_DEBOUNCE_MS = 800;
|
|
915
|
+
var ALARM_POLL_MS = 3e4;
|
|
916
|
+
var COMMAND_POLL_MS = 4e3;
|
|
917
|
+
var LOCATION_POLL_MS = 15e3;
|
|
918
|
+
var DashboardHub = class {
|
|
919
|
+
constructor(config) {
|
|
920
|
+
// Per alarm-subscription reconcile triggers — invoked after an ack/clear so the
|
|
921
|
+
// affected alarm widgets refresh immediately instead of waiting for the poll/stream.
|
|
922
|
+
this.alarmReconcilers = /* @__PURE__ */ new Set();
|
|
923
|
+
// Per command-subscription reconcile triggers — invoked after an issue so the command
|
|
924
|
+
// widgets show the new command immediately instead of waiting for the next poll tick.
|
|
925
|
+
this.commandReconcilers = /* @__PURE__ */ new Set();
|
|
926
|
+
// One entry per distinct device token that has at least one subscriber.
|
|
927
|
+
this.streams = /* @__PURE__ */ new Map();
|
|
928
|
+
// Live alarm-subscription disposers. The alarm channel isn't ref-counted through
|
|
929
|
+
// `streams` (it holds a poll/debounce/trigger per subscription, not a shared device
|
|
930
|
+
// stream), so its disposers are tracked here for disposeAll() to reach — otherwise an
|
|
931
|
+
// imperative host closing the dashboard would leak every alarm widget's poll + socket.
|
|
932
|
+
this.alarmDisposers = /* @__PURE__ */ new Set();
|
|
933
|
+
// Live command-subscription disposers (same rationale as alarmDisposers — the control
|
|
934
|
+
// channel holds a poll per subscription, not a shared device stream), so disposeAll()
|
|
935
|
+
// can tear down every command widget's poll.
|
|
936
|
+
this.commandDisposers = /* @__PURE__ */ new Set();
|
|
937
|
+
// Live location-subscription disposers (same rationale as alarmDisposers/
|
|
938
|
+
// commandDisposers — the location channel holds a poll per subscription, not a shared
|
|
939
|
+
// device stream), so disposeAll() can tear down every map widget's poll.
|
|
940
|
+
this.locationDisposers = /* @__PURE__ */ new Set();
|
|
941
|
+
this.resolver = config.resolver;
|
|
942
|
+
this.bindings = config.bindings ?? {};
|
|
943
|
+
this.authorities = new Set(config.authorities ?? []);
|
|
944
|
+
}
|
|
945
|
+
// setBindings replaces the slot manifest. New subscriptions resolve through it;
|
|
946
|
+
// callers that need already-open slot streams to re-resolve should re-subscribe
|
|
947
|
+
// (the console keys the renderer on the manifest to do exactly that).
|
|
948
|
+
setBindings(bindings) {
|
|
949
|
+
this.bindings = bindings;
|
|
950
|
+
}
|
|
951
|
+
// subscribeWidget binds a widget's datasource to a sink and returns a disposer.
|
|
952
|
+
// Selector resolution is async (anchor→devices); the disposer is returned
|
|
953
|
+
// synchronously and cancels a still-pending resolution, so tearing a widget down
|
|
954
|
+
// before its streams open never attaches a leaked subscriber.
|
|
955
|
+
subscribeWidget(datasource, sink) {
|
|
956
|
+
let disposed = false;
|
|
957
|
+
const detachers = [];
|
|
958
|
+
const dispose = () => {
|
|
959
|
+
disposed = true;
|
|
960
|
+
for (const detach of detachers.splice(0)) detach();
|
|
961
|
+
};
|
|
962
|
+
this.resolveDevices(datasource).then((groups) => {
|
|
963
|
+
if (disposed) return;
|
|
964
|
+
for (const group of groups) {
|
|
965
|
+
detachers.push(this.attach(group.deviceToken, group.names, sink));
|
|
966
|
+
}
|
|
967
|
+
}).catch((err) => {
|
|
968
|
+
if (!disposed) sink.error?.(err);
|
|
969
|
+
});
|
|
970
|
+
return dispose;
|
|
971
|
+
}
|
|
972
|
+
// subscribeAlarms binds an alarm widget's scope+filters to a sink and returns a
|
|
973
|
+
// disposer. Unlike the measurement channel it is NOT multiplexed — alarm widgets are
|
|
974
|
+
// few, and each carries its own filter — so every subscription opens its own trigger
|
|
975
|
+
// stream + reconcile poll (sharing one tenant-wide trigger stream across widgets is a
|
|
976
|
+
// deferred optimization). Query-then-reconcile: an initial query, then the live
|
|
977
|
+
// ALARM_STREAM debounced into re-queries, plus a poll backstop and a reconnect
|
|
978
|
+
// re-query. Scope resolution is async (slot/anchor → devices); the disposer is
|
|
979
|
+
// returned synchronously and cancels a still-pending resolution.
|
|
980
|
+
subscribeAlarms(subscription, sink) {
|
|
981
|
+
let disposed = false;
|
|
982
|
+
let debounce;
|
|
983
|
+
let poll;
|
|
984
|
+
let unsubscribe;
|
|
985
|
+
let reconciler;
|
|
986
|
+
let generation = 0;
|
|
987
|
+
const dispose = () => {
|
|
988
|
+
disposed = true;
|
|
989
|
+
if (debounce) clearTimeout(debounce);
|
|
990
|
+
if (poll) clearInterval(poll);
|
|
991
|
+
unsubscribe?.();
|
|
992
|
+
if (reconciler) this.alarmReconcilers.delete(reconciler);
|
|
993
|
+
this.alarmDisposers.delete(dispose);
|
|
994
|
+
};
|
|
995
|
+
this.alarmDisposers.add(dispose);
|
|
996
|
+
const reconcile = (tokens, tenantWide) => {
|
|
997
|
+
const gen = ++generation;
|
|
998
|
+
this.queryAlarms(subscription, tokens, tenantWide).then((snapshot) => {
|
|
999
|
+
if (!disposed && gen === generation) sink.next(snapshot);
|
|
1000
|
+
}).catch((err) => {
|
|
1001
|
+
if (!disposed && gen === generation) sink.error?.(err);
|
|
1002
|
+
});
|
|
1003
|
+
};
|
|
1004
|
+
this.resolveAlarmScope(subscription.datasource).then((scope) => {
|
|
1005
|
+
if (disposed) return;
|
|
1006
|
+
if (!scope.tenantWide && scope.tokens.length === 0) {
|
|
1007
|
+
sink.next({ alarms: [], total: 0 });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
const trigger = () => {
|
|
1011
|
+
if (debounce) clearTimeout(debounce);
|
|
1012
|
+
debounce = setTimeout(() => reconcile(scope.tokens, scope.tenantWide), ALARM_RECONCILE_DEBOUNCE_MS);
|
|
1013
|
+
};
|
|
1014
|
+
const adapter = {
|
|
1015
|
+
next: () => trigger(),
|
|
1016
|
+
connected: (wasRetry) => {
|
|
1017
|
+
if (wasRetry) reconcile(scope.tokens, scope.tenantWide);
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
unsubscribe = subscribe(DEVICE_AREA, ALARM_STREAM, {}, adapter);
|
|
1021
|
+
poll = setInterval(() => reconcile(scope.tokens, scope.tenantWide), ALARM_POLL_MS);
|
|
1022
|
+
reconciler = () => reconcile(scope.tokens, scope.tenantWide);
|
|
1023
|
+
this.alarmReconcilers.add(reconciler);
|
|
1024
|
+
reconcile(scope.tokens, scope.tenantWide);
|
|
1025
|
+
}).catch((err) => {
|
|
1026
|
+
if (!disposed) sink.error?.(err);
|
|
1027
|
+
});
|
|
1028
|
+
return dispose;
|
|
1029
|
+
}
|
|
1030
|
+
// resolveAlarmScope turns an alarm widget's scope selector into the originator device
|
|
1031
|
+
// tokens to filter on, or tenant-wide when it carries no datasource. Reuses the same
|
|
1032
|
+
// device/anchor/slot resolution the measurement channel does.
|
|
1033
|
+
async resolveAlarmScope(datasource) {
|
|
1034
|
+
if (!datasource) return { tenantWide: true, tokens: [] };
|
|
1035
|
+
const groups = await this.resolveDevices(datasource);
|
|
1036
|
+
return { tenantWide: false, tokens: groups.map((g) => g.deviceToken) };
|
|
1037
|
+
}
|
|
1038
|
+
// queryAlarms reads the authoritative rows. Tenant-wide is one query; a scoped widget
|
|
1039
|
+
// runs one query per originator device (the alarms query filters a single originator)
|
|
1040
|
+
// and merges — deduped by token, newest first, capped to pageSize; total is the sum of
|
|
1041
|
+
// per-originator match counts.
|
|
1042
|
+
async queryAlarms(sub, tokens, tenantWide) {
|
|
1043
|
+
const base = {
|
|
1044
|
+
pageNumber: 1,
|
|
1045
|
+
// the alarms query paginates 1-based
|
|
1046
|
+
pageSize: sub.pageSize,
|
|
1047
|
+
state: sub.state ?? null,
|
|
1048
|
+
severity: sub.severity ?? null,
|
|
1049
|
+
acknowledged: sub.acknowledged ?? null
|
|
1050
|
+
};
|
|
1051
|
+
if (tenantWide) {
|
|
1052
|
+
const data = await gql2(DEVICE_AREA, ALARMS_QUERY, {
|
|
1053
|
+
criteria: { ...base, originatorType: null, originator: null }
|
|
1054
|
+
});
|
|
1055
|
+
return { alarms: data.alarms.results, total: data.alarms.pagination.totalRecords };
|
|
1056
|
+
}
|
|
1057
|
+
const pages = await Promise.all(
|
|
1058
|
+
tokens.map(
|
|
1059
|
+
(token) => gql2(DEVICE_AREA, ALARMS_QUERY, {
|
|
1060
|
+
criteria: { ...base, originatorType: "device", originator: token }
|
|
1061
|
+
})
|
|
1062
|
+
)
|
|
1063
|
+
);
|
|
1064
|
+
const byToken = /* @__PURE__ */ new Map();
|
|
1065
|
+
let total = 0;
|
|
1066
|
+
for (const page of pages) {
|
|
1067
|
+
total += page.alarms.pagination.totalRecords;
|
|
1068
|
+
for (const row of page.alarms.results) byToken.set(row.token, row);
|
|
1069
|
+
}
|
|
1070
|
+
const alarms = [...byToken.values()].sort((a, b) => (b.raisedTime ?? "").localeCompare(a.raisedTime ?? "")).slice(0, sub.pageSize);
|
|
1071
|
+
return { alarms, total };
|
|
1072
|
+
}
|
|
1073
|
+
// ── Control channel ──────────────────────────────────────────────────────
|
|
1074
|
+
// subscribeCommands binds a command widget's scope to a sink and returns a disposer.
|
|
1075
|
+
// Poll-only (command-delivery has no subscription): resolve the target device once,
|
|
1076
|
+
// then re-read its recent commands on an interval (and immediately after an issue via
|
|
1077
|
+
// the reconciler). Scope resolution is async; the disposer is returned synchronously
|
|
1078
|
+
// and cancels a still-pending resolution.
|
|
1079
|
+
subscribeCommands(subscription, sink) {
|
|
1080
|
+
let disposed = false;
|
|
1081
|
+
let poll;
|
|
1082
|
+
let reconciler;
|
|
1083
|
+
let deviceToken = null;
|
|
1084
|
+
let generation = 0;
|
|
1085
|
+
const dispose = () => {
|
|
1086
|
+
disposed = true;
|
|
1087
|
+
if (poll) clearInterval(poll);
|
|
1088
|
+
if (reconciler) this.commandReconcilers.delete(reconciler);
|
|
1089
|
+
this.commandDisposers.delete(dispose);
|
|
1090
|
+
};
|
|
1091
|
+
this.commandDisposers.add(dispose);
|
|
1092
|
+
const reconcile = () => {
|
|
1093
|
+
const gen = ++generation;
|
|
1094
|
+
this.queryCommands(subscription, deviceToken).then((snapshot) => {
|
|
1095
|
+
if (!disposed && gen === generation) sink.next(snapshot);
|
|
1096
|
+
}).catch((err) => {
|
|
1097
|
+
if (!disposed && gen === generation) sink.error?.(err);
|
|
1098
|
+
});
|
|
1099
|
+
};
|
|
1100
|
+
this.resolveCommandScope(subscription.datasource).then((token) => {
|
|
1101
|
+
if (disposed) return;
|
|
1102
|
+
deviceToken = token;
|
|
1103
|
+
if (!deviceToken) {
|
|
1104
|
+
sink.next({ deviceToken: null, commands: [], total: 0 });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
poll = setInterval(reconcile, COMMAND_POLL_MS);
|
|
1108
|
+
reconciler = reconcile;
|
|
1109
|
+
this.commandReconcilers.add(reconciler);
|
|
1110
|
+
reconcile();
|
|
1111
|
+
}).catch((err) => {
|
|
1112
|
+
if (!disposed) sink.error?.(err);
|
|
1113
|
+
});
|
|
1114
|
+
return dispose;
|
|
1115
|
+
}
|
|
1116
|
+
// resolveCommandScope turns a command widget's scope selector into its single target
|
|
1117
|
+
// device token (a command targets one device), or null when it carries no datasource
|
|
1118
|
+
// or resolves to no device. When a selector expands to several devices (an anchor), the
|
|
1119
|
+
// first is the target — the console restricts command widgets to a device scope, so
|
|
1120
|
+
// this is a defensive fallback, not the authoring path.
|
|
1121
|
+
async resolveCommandScope(datasource) {
|
|
1122
|
+
if (!datasource) return null;
|
|
1123
|
+
const groups = await this.resolveDevices(datasource);
|
|
1124
|
+
return groups[0]?.deviceToken ?? null;
|
|
1125
|
+
}
|
|
1126
|
+
// queryCommands reads the recent commands for the target device (newest first, capped
|
|
1127
|
+
// to pageSize) with their live delivery status.
|
|
1128
|
+
async queryCommands(sub, deviceToken) {
|
|
1129
|
+
if (!deviceToken) return { deviceToken: null, commands: [], total: 0 };
|
|
1130
|
+
const data = await gql2(COMMAND_AREA, COMMANDS_QUERY, {
|
|
1131
|
+
criteria: { pageNumber: 1, pageSize: sub.pageSize, deviceToken, status: null }
|
|
1132
|
+
});
|
|
1133
|
+
return {
|
|
1134
|
+
deviceToken,
|
|
1135
|
+
commands: data.commands.results,
|
|
1136
|
+
total: data.commands.pagination.totalRecords
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
// ── Location channel ─────────────────────────────────────────────────────
|
|
1140
|
+
// subscribeLocations binds a map widget's selector to a sink and returns a disposer.
|
|
1141
|
+
// Poll-only (device-state has no location subscription): resolve the bound devices
|
|
1142
|
+
// once, then re-read their last-known positions on an interval. Scope resolution is
|
|
1143
|
+
// async; the disposer is returned synchronously and cancels a still-pending
|
|
1144
|
+
// resolution, matching every other channel.
|
|
1145
|
+
subscribeLocations(subscription, sink) {
|
|
1146
|
+
let disposed = false;
|
|
1147
|
+
let poll;
|
|
1148
|
+
let deviceTokens = [];
|
|
1149
|
+
let generation = 0;
|
|
1150
|
+
const dispose = () => {
|
|
1151
|
+
disposed = true;
|
|
1152
|
+
if (poll) clearInterval(poll);
|
|
1153
|
+
this.locationDisposers.delete(dispose);
|
|
1154
|
+
};
|
|
1155
|
+
this.locationDisposers.add(dispose);
|
|
1156
|
+
const reconcile = () => {
|
|
1157
|
+
const gen = ++generation;
|
|
1158
|
+
this.queryLocations(deviceTokens).then((snapshot) => {
|
|
1159
|
+
if (!disposed && gen === generation) sink.next(snapshot);
|
|
1160
|
+
}).catch((err) => {
|
|
1161
|
+
if (!disposed && gen === generation) sink.error?.(err);
|
|
1162
|
+
});
|
|
1163
|
+
};
|
|
1164
|
+
this.resolveLocationScope(subscription.datasource).then((tokens) => {
|
|
1165
|
+
if (disposed) return;
|
|
1166
|
+
deviceTokens = tokens;
|
|
1167
|
+
if (deviceTokens.length === 0) {
|
|
1168
|
+
sink.next({ kind: "positions", deviceTokens: [], locations: [] });
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
poll = setInterval(reconcile, LOCATION_POLL_MS);
|
|
1172
|
+
reconcile();
|
|
1173
|
+
}).catch((err) => {
|
|
1174
|
+
if (!disposed) sink.error?.(err);
|
|
1175
|
+
});
|
|
1176
|
+
return dispose;
|
|
1177
|
+
}
|
|
1178
|
+
// resolveLocationScope turns a map widget's selector into the device tokens whose
|
|
1179
|
+
// positions to read.
|
|
1180
|
+
//
|
|
1181
|
+
// 🔴 It resolves NOTHING unless the selector NAMES A LOCATION SERIES. That is the
|
|
1182
|
+
// point of the separate field: a device selector carrying only `measurements` is a
|
|
1183
|
+
// telemetry binding, and quietly reading its device's position because a map widget
|
|
1184
|
+
// happens to hold it would make the location field decorative — authored or not, the
|
|
1185
|
+
// behaviour would be identical, so nothing would ever hold it. A map bound to a
|
|
1186
|
+
// measurement-only selector shows its empty state, which is the honest answer.
|
|
1187
|
+
async resolveLocationScope(datasource) {
|
|
1188
|
+
if (!datasource?.location) return [];
|
|
1189
|
+
const groups = await this.resolveDevices(datasource);
|
|
1190
|
+
return groups.map((g) => g.deviceToken);
|
|
1191
|
+
}
|
|
1192
|
+
// queryLocations reads the last-known position of each bound device in ONE batch
|
|
1193
|
+
// round trip. A device that has never been located is absent from the result (the
|
|
1194
|
+
// service's contract), so the caller reads "how many are located" from the returned
|
|
1195
|
+
// rows and "how many are bound" from the tokens.
|
|
1196
|
+
async queryLocations(deviceTokens) {
|
|
1197
|
+
if (deviceTokens.length === 0) return { kind: "positions", deviceTokens, locations: [] };
|
|
1198
|
+
try {
|
|
1199
|
+
const data = await gql2(STATE_AREA, LATEST_LOCATIONS_QUERY, { deviceTokens });
|
|
1200
|
+
return { kind: "positions", deviceTokens, locations: data.latestLocations };
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
if (isForbiddenError(err)) return { kind: "forbidden" };
|
|
1203
|
+
throw err;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
// ── WidgetActions (the write seam) ───────────────────────────────────────
|
|
1207
|
+
// can reports whether the viewer holds an authority ('*' grants all). Drives whether
|
|
1208
|
+
// a widget renders an action control; the server enforces authority regardless.
|
|
1209
|
+
can(authority) {
|
|
1210
|
+
return this.authorities.has("*") || this.authorities.has(authority);
|
|
1211
|
+
}
|
|
1212
|
+
// acknowledgeAlarm / clearAlarm mutate the alarm by token, then nudge every open alarm
|
|
1213
|
+
// widget to reconcile so the change shows at once. The mutation reaches device-management
|
|
1214
|
+
// (the acknowledging identity is taken server-side from the token).
|
|
1215
|
+
async acknowledgeAlarm(alarmToken) {
|
|
1216
|
+
await gql2(DEVICE_AREA, ACKNOWLEDGE_ALARM, { token: alarmToken });
|
|
1217
|
+
this.reconcileAlarms();
|
|
1218
|
+
}
|
|
1219
|
+
async clearAlarm(alarmToken) {
|
|
1220
|
+
await gql2(DEVICE_AREA, CLEAR_ALARM, { token: alarmToken });
|
|
1221
|
+
this.reconcileAlarms();
|
|
1222
|
+
}
|
|
1223
|
+
// sendCommand issues a command to a device, minting the dispatch token here (the
|
|
1224
|
+
// idempotency key + cancel handle), then nudges every open command widget to reconcile
|
|
1225
|
+
// so the new command shows at once. The mutation reaches command-delivery (requires
|
|
1226
|
+
// command:write, enforced server-side regardless of can()).
|
|
1227
|
+
//
|
|
1228
|
+
// A REFUSAL COMES BACK AS A VALUE, not an exception (see CommandDispatch): the server
|
|
1229
|
+
// decided the request and named the reason, so the widget can show it. Nothing was
|
|
1230
|
+
// created in that case, so the open command widgets are NOT reconciled — a re-poll
|
|
1231
|
+
// would only re-render the same history and make a refused send look like it did
|
|
1232
|
+
// something.
|
|
1233
|
+
async sendCommand(deviceToken, name, payload) {
|
|
1234
|
+
const token = randomToken();
|
|
1235
|
+
const result = await gql2(COMMAND_AREA, CREATE_COMMAND, {
|
|
1236
|
+
request: { token, deviceToken, name, payload: payload ?? null }
|
|
1237
|
+
});
|
|
1238
|
+
const rejection = result.createCommand?.rejection;
|
|
1239
|
+
if (rejection) {
|
|
1240
|
+
return { status: "rejected", code: rejection.code, reason: rejection.reason };
|
|
1241
|
+
}
|
|
1242
|
+
if (!result.createCommand?.command) {
|
|
1243
|
+
throw new Error("The command could not be issued: the platform returned no answer for it.");
|
|
1244
|
+
}
|
|
1245
|
+
this.reconcileCommands();
|
|
1246
|
+
return { status: "sent", token };
|
|
1247
|
+
}
|
|
1248
|
+
// reconcileAlarms re-queries every open alarm subscription hub-wide (after a mutation).
|
|
1249
|
+
// Hub-wide is deliberate: one alarm can appear in several widgets (different scopes),
|
|
1250
|
+
// and the acked/cleared row must refresh in all of them; scoping the nudge would need
|
|
1251
|
+
// per-reconciler token knowledge for no real saving. Iterate a copy for safety.
|
|
1252
|
+
reconcileAlarms() {
|
|
1253
|
+
for (const reconcile of [...this.alarmReconcilers]) reconcile();
|
|
1254
|
+
}
|
|
1255
|
+
// reconcileCommands re-polls every open command subscription (after an issue). Iterate
|
|
1256
|
+
// a copy for safety.
|
|
1257
|
+
reconcileCommands() {
|
|
1258
|
+
for (const reconcile of [...this.commandReconcilers]) reconcile();
|
|
1259
|
+
}
|
|
1260
|
+
// disposeAll tears down every upstream stream (e.g. on dashboard close): the
|
|
1261
|
+
// ref-counted measurement device streams AND every alarm/command/location
|
|
1262
|
+
// subscription's poll + trigger. Iterate a copy of the disposer sets since each
|
|
1263
|
+
// removes itself as it runs.
|
|
1264
|
+
disposeAll() {
|
|
1265
|
+
for (const stream of this.streams.values()) stream.unsubscribe();
|
|
1266
|
+
this.streams.clear();
|
|
1267
|
+
for (const dispose of [...this.alarmDisposers]) dispose();
|
|
1268
|
+
this.alarmDisposers.clear();
|
|
1269
|
+
for (const dispose of [...this.commandDisposers]) dispose();
|
|
1270
|
+
this.commandDisposers.clear();
|
|
1271
|
+
for (const dispose of [...this.locationDisposers]) dispose();
|
|
1272
|
+
this.locationDisposers.clear();
|
|
1273
|
+
}
|
|
1274
|
+
// The number of distinct upstream device streams currently open (observability
|
|
1275
|
+
// + test hook — proves multiplexing collapses shared devices to one stream).
|
|
1276
|
+
get openStreamCount() {
|
|
1277
|
+
return this.streams.size;
|
|
1278
|
+
}
|
|
1279
|
+
// isDatasourceAvailable reports whether a widget's bound device still exists. Only a
|
|
1280
|
+
// device selector (or a slot bound to a device) is validated — an anchor, an unbound
|
|
1281
|
+
// slot, or no datasource has a legitimate empty state and is always "available". Fails
|
|
1282
|
+
// open: an existence-check outage returns true (never falsely mark a live device gone).
|
|
1283
|
+
async isDatasourceAvailable(datasource) {
|
|
1284
|
+
const deviceToken = this.availabilityToken(datasource);
|
|
1285
|
+
if (deviceToken === void 0) return true;
|
|
1286
|
+
try {
|
|
1287
|
+
return await this.resolver.deviceExists(deviceToken);
|
|
1288
|
+
} catch {
|
|
1289
|
+
return true;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
// availabilityToken returns the single device token whose existence gates a widget's
|
|
1293
|
+
// availability, or undefined when there is nothing device-specific to validate (an
|
|
1294
|
+
// anchor's membership is self-validating; an unbound slot is a placeholder; a reserved
|
|
1295
|
+
// kind isn't resolved yet).
|
|
1296
|
+
availabilityToken(datasource) {
|
|
1297
|
+
if (!datasource) return void 0;
|
|
1298
|
+
if (datasource.kind === "device") return datasource.deviceToken || void 0;
|
|
1299
|
+
if (datasource.kind === "slot") {
|
|
1300
|
+
const binding = Object.prototype.hasOwnProperty.call(this.bindings, datasource.slot) ? this.bindings[datasource.slot] : void 0;
|
|
1301
|
+
return binding && binding.kind === "device" ? binding.deviceToken || void 0 : void 0;
|
|
1302
|
+
}
|
|
1303
|
+
return void 0;
|
|
1304
|
+
}
|
|
1305
|
+
// resolveDevices turns a selector into the devices to stream, each with the
|
|
1306
|
+
// measurement names the widget wants (empty = all). Reserved selector kinds are
|
|
1307
|
+
// rejected here, mirroring the backend (Phase 1 ships device + anchor).
|
|
1308
|
+
async resolveDevices(datasource) {
|
|
1309
|
+
switch (datasource.kind) {
|
|
1310
|
+
case "device":
|
|
1311
|
+
return this.resolveBinding(
|
|
1312
|
+
{ kind: "device", deviceToken: datasource.deviceToken },
|
|
1313
|
+
new Set(datasource.measurements)
|
|
1314
|
+
);
|
|
1315
|
+
case "anchor":
|
|
1316
|
+
return this.resolveBinding(
|
|
1317
|
+
{ kind: "anchor", anchor: datasource.anchor },
|
|
1318
|
+
new Set(datasource.measurements)
|
|
1319
|
+
);
|
|
1320
|
+
case "slot": {
|
|
1321
|
+
const binding = Object.prototype.hasOwnProperty.call(this.bindings, datasource.slot) ? this.bindings[datasource.slot] : void 0;
|
|
1322
|
+
if (!binding) return [];
|
|
1323
|
+
return this.resolveBinding(binding, new Set(datasource.measurements));
|
|
1324
|
+
}
|
|
1325
|
+
default:
|
|
1326
|
+
throw new Error(
|
|
1327
|
+
`dashboard selector kind '${datasource.kind}' is not supported yet`
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
// resolveBinding turns a concrete entity binding (device or anchor) into the
|
|
1332
|
+
// device streams to open, each carrying the given measurement names. Shared by the
|
|
1333
|
+
// device/anchor selectors and by slot resolution (whose binding is either kind).
|
|
1334
|
+
// A device binding streams its token directly (measurementStream is keyed by token,
|
|
1335
|
+
// per ADR-044); an anchor expands to its member device tokens.
|
|
1336
|
+
async resolveBinding(binding, names) {
|
|
1337
|
+
if (binding.kind === "device") {
|
|
1338
|
+
return [{ deviceToken: binding.deviceToken, names }];
|
|
1339
|
+
}
|
|
1340
|
+
const tokens = await this.resolver.devicesForAnchor(binding.anchor);
|
|
1341
|
+
return tokens.map((deviceToken) => ({ deviceToken, names }));
|
|
1342
|
+
}
|
|
1343
|
+
// attach registers a subscriber on a device's stream (opening the upstream on
|
|
1344
|
+
// the first subscriber) and returns a detacher that drops it and closes the
|
|
1345
|
+
// upstream once the last subscriber leaves.
|
|
1346
|
+
attach(deviceToken, names, sink) {
|
|
1347
|
+
const stream = this.ensureStream(deviceToken);
|
|
1348
|
+
const subscriber = { names, sink };
|
|
1349
|
+
stream.subscribers.add(subscriber);
|
|
1350
|
+
return () => {
|
|
1351
|
+
if (!stream.subscribers.delete(subscriber)) return;
|
|
1352
|
+
if (stream.subscribers.size === 0 && this.streams.get(deviceToken) === stream) {
|
|
1353
|
+
stream.unsubscribe();
|
|
1354
|
+
this.streams.delete(deviceToken);
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
ensureStream(deviceToken) {
|
|
1359
|
+
const existing = this.streams.get(deviceToken);
|
|
1360
|
+
if (existing) return existing;
|
|
1361
|
+
const stream = { subscribers: /* @__PURE__ */ new Set(), unsubscribe: () => {
|
|
1362
|
+
} };
|
|
1363
|
+
this.streams.set(deviceToken, stream);
|
|
1364
|
+
const adapter = {
|
|
1365
|
+
next: (data) => this.fanout(deviceToken, data.measurementStream),
|
|
1366
|
+
error: (err) => {
|
|
1367
|
+
if (this.streams.get(deviceToken) === stream) this.streams.delete(deviceToken);
|
|
1368
|
+
stream.unsubscribe();
|
|
1369
|
+
for (const subscriber of stream.subscribers) subscriber.sink.error?.(err);
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
const variables = { deviceToken, name: null };
|
|
1373
|
+
stream.unsubscribe = subscribe(EVENT_AREA, MEASUREMENT_STREAM, variables, adapter);
|
|
1374
|
+
return stream;
|
|
1375
|
+
}
|
|
1376
|
+
fanout(deviceToken, sample) {
|
|
1377
|
+
const stream = this.streams.get(deviceToken);
|
|
1378
|
+
if (!stream) return;
|
|
1379
|
+
for (const subscriber of stream.subscribers) {
|
|
1380
|
+
if (subscriber.names.size === 0 || subscriber.names.has(sample.name)) {
|
|
1381
|
+
subscriber.sink.next(sample);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
// src/synthetic.ts
|
|
1388
|
+
var SYNTHETIC_GENERATORS = [
|
|
1389
|
+
{ value: "sine", label: "Sine wave" },
|
|
1390
|
+
{ value: "ramp", label: "Ramp" },
|
|
1391
|
+
{ value: "random-walk", label: "Random walk" }
|
|
1392
|
+
];
|
|
1393
|
+
var DEFAULT_NAME = "value";
|
|
1394
|
+
var SYNTHETIC_ALARMS = [
|
|
1395
|
+
{ severity: "CRITICAL", state: "ACTIVE", acknowledged: false, alarmKey: "over-temperature", metricKey: "temperature", lastValue: 87.4, originatorToken: "thermostat-01", message: "Temperature above 85\xB0C" },
|
|
1396
|
+
{ severity: "MAJOR", state: "ACTIVE", acknowledged: true, alarmKey: "low-battery", metricKey: "battery", lastValue: 12, originatorToken: "sensor-14", message: "Battery below 15%" },
|
|
1397
|
+
{ severity: "MINOR", state: "ACTIVE", acknowledged: false, alarmKey: "humidity-high", metricKey: "humidity", lastValue: 78, originatorToken: "sensor-03", message: "Relative humidity above threshold" },
|
|
1398
|
+
{ severity: "WARNING", state: "CLEARED", acknowledged: true, alarmKey: "signal-weak", metricKey: "rssi", lastValue: -89, originatorToken: "gateway-02", message: "Weak uplink signal" },
|
|
1399
|
+
{ severity: "INDETERMINATE", state: "ACTIVE", acknowledged: false, alarmKey: "self-test", metricKey: "status", lastValue: null, originatorToken: "device-99", message: null }
|
|
1400
|
+
];
|
|
1401
|
+
var SYNTHETIC_COMMANDS = [
|
|
1402
|
+
{ name: "reboot", status: "SENT", payload: '{"delaySeconds":5}', responsePayload: null, error: null, dispatched: true },
|
|
1403
|
+
// Early in the list on purpose: an author can cap the widget's row count, and a
|
|
1404
|
+
// withheld command is the state a preview most needs to show.
|
|
1405
|
+
{ name: "self-test", status: "HELD", payload: null, responsePayload: null, error: null, dispatched: false },
|
|
1406
|
+
// The other way a command ends up waiting on an absent device: this one was published
|
|
1407
|
+
// and found nobody there, so it is parked until the device wakes. Included next to HELD
|
|
1408
|
+
// because the two look alike in a list and an author laying out a command-button needs
|
|
1409
|
+
// to see that they are distinguishable. `dispatched` is false, matching the service: it
|
|
1410
|
+
// CLEARS sent_time when it parks a command, because a dispatch that reached nobody sent
|
|
1411
|
+
// nothing. That is also why a parked command is still cancellable — nothing has taken
|
|
1412
|
+
// delivery of it.
|
|
1413
|
+
{ name: "sync-clock", status: "PARKED", payload: null, responsePayload: null, error: null, dispatched: false },
|
|
1414
|
+
{ name: "set-interval", status: "SUCCESSFUL", payload: '{"seconds":30}', responsePayload: '{"ok":true}', error: null, dispatched: true },
|
|
1415
|
+
{ name: "calibrate", status: "QUEUED", payload: null, responsePayload: null, error: null, dispatched: false },
|
|
1416
|
+
{ name: "firmware-update", status: "FAILED", payload: '{"version":"2.1.0"}', responsePayload: null, error: "device offline", dispatched: true },
|
|
1417
|
+
{ name: "open-valve", status: "CANCELLED", payload: '{"percent":100}', responsePayload: null, error: null, dispatched: false }
|
|
1418
|
+
];
|
|
1419
|
+
var SYNTHETIC_COMMAND_DEVICE = "synthetic-device";
|
|
1420
|
+
var SYNTHETIC_LOCATIONS = [
|
|
1421
|
+
{ deviceToken: "sp-dozer-01", latitude: 33.749, longitude: -84.388, elevation: 320.5, accuracy: 4.2, speed: 0, heading: 271.5 },
|
|
1422
|
+
{ deviceToken: "sp-excavator-02", latitude: 33.7512, longitude: -84.3858, elevation: 318.1, accuracy: 3.1, speed: 1.4, heading: 88 },
|
|
1423
|
+
{ deviceToken: "sp-loader-03", latitude: 33.7468, longitude: -84.3903, elevation: null, accuracy: 9.8, speed: null, heading: null },
|
|
1424
|
+
{ deviceToken: "sp-truck-04", latitude: 33.7481, longitude: -84.3841, elevation: 315.9, accuracy: 2.5, speed: 8.3, heading: 12.25 }
|
|
1425
|
+
];
|
|
1426
|
+
function hashName(name) {
|
|
1427
|
+
let h = 0;
|
|
1428
|
+
for (let i = 0; i < name.length; i++) h = Math.imul(h, 31) + name.charCodeAt(i) | 0;
|
|
1429
|
+
return h >>> 0;
|
|
1430
|
+
}
|
|
1431
|
+
function clamp(v, min, max) {
|
|
1432
|
+
return v < min ? min : v > max ? max : v;
|
|
1433
|
+
}
|
|
1434
|
+
var SyntheticDataSource = class {
|
|
1435
|
+
constructor(config = {}) {
|
|
1436
|
+
// Live timers, tracked so disposeAll() can stop every widget's stream at once.
|
|
1437
|
+
this.timers = /* @__PURE__ */ new Set();
|
|
1438
|
+
this.generator = config.generator ?? "sine";
|
|
1439
|
+
this.intervalMs = Math.max(1, config.intervalMs ?? 1e3);
|
|
1440
|
+
this.backfill = Math.max(0, config.backfill ?? 60);
|
|
1441
|
+
this.min = config.min ?? 0;
|
|
1442
|
+
this.max = config.max ?? 100;
|
|
1443
|
+
this.periodMs = Math.max(1, config.periodMs ?? 6e4);
|
|
1444
|
+
}
|
|
1445
|
+
subscribeWidget(datasource, sink) {
|
|
1446
|
+
const names = datasource.measurements.length > 0 ? datasource.measurements : [DEFAULT_NAME];
|
|
1447
|
+
const walk = /* @__PURE__ */ new Map();
|
|
1448
|
+
let seq = 0;
|
|
1449
|
+
const emit = (name, tMs) => {
|
|
1450
|
+
const value = this.valueFor(name, tMs, walk);
|
|
1451
|
+
const s = {
|
|
1452
|
+
id: `syn-${seq++}`,
|
|
1453
|
+
deviceToken: "synthetic",
|
|
1454
|
+
eventType: 0,
|
|
1455
|
+
occurredTime: new Date(tMs).toISOString(),
|
|
1456
|
+
name,
|
|
1457
|
+
value,
|
|
1458
|
+
classifier: null
|
|
1459
|
+
};
|
|
1460
|
+
sink.next(s);
|
|
1461
|
+
};
|
|
1462
|
+
const now = Date.now();
|
|
1463
|
+
for (let i = this.backfill - 1; i >= 0; i--) {
|
|
1464
|
+
const tMs = now - i * this.intervalMs;
|
|
1465
|
+
for (const name of names) emit(name, tMs);
|
|
1466
|
+
}
|
|
1467
|
+
const timer = setInterval(() => {
|
|
1468
|
+
const tMs = Date.now();
|
|
1469
|
+
for (const name of names) emit(name, tMs);
|
|
1470
|
+
}, this.intervalMs);
|
|
1471
|
+
this.timers.add(timer);
|
|
1472
|
+
return () => {
|
|
1473
|
+
if (this.timers.delete(timer)) clearInterval(timer);
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
// subscribeAlarms emits a synthetic alarm snapshot for preview. The canonical set is
|
|
1477
|
+
// filtered by the subscription (state/severity/ack) so the preview reflects what the
|
|
1478
|
+
// widget is configured to show; scope (datasource) is ignored — preview never resolves
|
|
1479
|
+
// a device. Re-emits on the same cadence with advancing raised times so the table
|
|
1480
|
+
// looks live. Returns whole snapshots, matching the live hub's contract.
|
|
1481
|
+
subscribeAlarms(subscription, sink) {
|
|
1482
|
+
const matches = SYNTHETIC_ALARMS.filter(
|
|
1483
|
+
(a) => (!subscription.state || a.state === subscription.state) && (!subscription.severity || a.severity === subscription.severity) && (subscription.acknowledged == null || a.acknowledged === subscription.acknowledged)
|
|
1484
|
+
);
|
|
1485
|
+
const emit = () => {
|
|
1486
|
+
const now = Date.now();
|
|
1487
|
+
const rows = matches.map((a, i) => {
|
|
1488
|
+
const raised = new Date(now - i * 45e3).toISOString();
|
|
1489
|
+
return {
|
|
1490
|
+
token: `syn-alarm-${i}`,
|
|
1491
|
+
originatorType: "device",
|
|
1492
|
+
alarmKey: a.alarmKey,
|
|
1493
|
+
metricKey: a.metricKey,
|
|
1494
|
+
state: a.state,
|
|
1495
|
+
acknowledged: a.acknowledged,
|
|
1496
|
+
severity: a.severity,
|
|
1497
|
+
originatorToken: a.originatorToken,
|
|
1498
|
+
lastValue: a.lastValue,
|
|
1499
|
+
message: a.message,
|
|
1500
|
+
raisedTime: raised,
|
|
1501
|
+
clearedTime: a.state === "CLEARED" ? raised : null,
|
|
1502
|
+
acknowledgedTime: a.acknowledged ? raised : null,
|
|
1503
|
+
acknowledgedBy: a.acknowledged ? "preview@devicechain" : null
|
|
1504
|
+
};
|
|
1505
|
+
});
|
|
1506
|
+
sink.next({ alarms: rows.slice(0, subscription.pageSize), total: rows.length });
|
|
1507
|
+
};
|
|
1508
|
+
emit();
|
|
1509
|
+
const timer = setInterval(emit, this.intervalMs);
|
|
1510
|
+
this.timers.add(timer);
|
|
1511
|
+
return () => {
|
|
1512
|
+
if (this.timers.delete(timer)) clearInterval(timer);
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
// subscribeCommands emits a synthetic command history for preview so a command-button
|
|
1516
|
+
// shows a populated, lifecycle-varied list (and a bound target device, so its Send
|
|
1517
|
+
// control renders). Re-emits on the same cadence with advancing queued times. Scope
|
|
1518
|
+
// (datasource) is ignored — preview never resolves a device. Returns whole snapshots,
|
|
1519
|
+
// matching the live hub's contract.
|
|
1520
|
+
subscribeCommands(subscription, sink) {
|
|
1521
|
+
const emit = () => {
|
|
1522
|
+
const now = Date.now();
|
|
1523
|
+
const commands = SYNTHETIC_COMMANDS.map((c, i) => {
|
|
1524
|
+
const queued = new Date(now - i * 2e4).toISOString();
|
|
1525
|
+
const answered = c.status === "SUCCESSFUL" || c.status === "FAILED";
|
|
1526
|
+
return {
|
|
1527
|
+
token: `syn-command-${i}`,
|
|
1528
|
+
name: c.name,
|
|
1529
|
+
status: c.status,
|
|
1530
|
+
payload: c.payload,
|
|
1531
|
+
responsePayload: c.responsePayload,
|
|
1532
|
+
error: c.error,
|
|
1533
|
+
queuedTime: queued,
|
|
1534
|
+
// Likewise: only a command that actually reached a device has a sentTime. QUEUED,
|
|
1535
|
+
// HELD and PARKED never did, and neither did the cancelled one (called off while
|
|
1536
|
+
// held).
|
|
1537
|
+
sentTime: c.dispatched ? queued : null,
|
|
1538
|
+
respondedTime: answered ? queued : null
|
|
1539
|
+
};
|
|
1540
|
+
});
|
|
1541
|
+
sink.next({
|
|
1542
|
+
deviceToken: SYNTHETIC_COMMAND_DEVICE,
|
|
1543
|
+
commands: commands.slice(0, subscription.pageSize),
|
|
1544
|
+
total: commands.length
|
|
1545
|
+
});
|
|
1546
|
+
};
|
|
1547
|
+
emit();
|
|
1548
|
+
const timer = setInterval(emit, this.intervalMs);
|
|
1549
|
+
this.timers.add(timer);
|
|
1550
|
+
return () => {
|
|
1551
|
+
if (this.timers.delete(timer)) clearInterval(timer);
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
// subscribeLocations emits a synthetic position snapshot for preview so a map shows a
|
|
1555
|
+
// populated, representative fleet before any device has reported one. Scope
|
|
1556
|
+
// (datasource) is ignored — preview never resolves a device — but the LOCATION SERIES
|
|
1557
|
+
// is honored: a selector that names none gets the empty snapshot, exactly as the live
|
|
1558
|
+
// hub gives it, so an author who has not bound the map sees preview agree with
|
|
1559
|
+
// production rather than paper over the omission with fake markers.
|
|
1560
|
+
//
|
|
1561
|
+
// Preview NEVER reports `forbidden`: it reaches no backend, so there is no authority
|
|
1562
|
+
// to be refused, and inventing a permission state would show an author a wall their
|
|
1563
|
+
// viewers may not actually hit.
|
|
1564
|
+
subscribeLocations(subscription, sink) {
|
|
1565
|
+
if (!subscription.datasource?.location) {
|
|
1566
|
+
sink.next({ kind: "positions", deviceTokens: [], locations: [] });
|
|
1567
|
+
return () => {
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
const emit = () => {
|
|
1571
|
+
const now = Date.now();
|
|
1572
|
+
const locations = SYNTHETIC_LOCATIONS.map((l, i) => ({
|
|
1573
|
+
id: `syn-location-${i}`,
|
|
1574
|
+
deviceToken: l.deviceToken,
|
|
1575
|
+
latitude: l.latitude,
|
|
1576
|
+
longitude: l.longitude,
|
|
1577
|
+
elevation: l.elevation,
|
|
1578
|
+
accuracy: l.accuracy,
|
|
1579
|
+
speed: l.speed,
|
|
1580
|
+
heading: l.heading,
|
|
1581
|
+
occurredTime: new Date(now - i * 3e4).toISOString()
|
|
1582
|
+
}));
|
|
1583
|
+
sink.next({
|
|
1584
|
+
kind: "positions",
|
|
1585
|
+
deviceTokens: locations.map((l) => l.deviceToken),
|
|
1586
|
+
locations
|
|
1587
|
+
});
|
|
1588
|
+
};
|
|
1589
|
+
emit();
|
|
1590
|
+
const timer = setInterval(emit, this.intervalMs);
|
|
1591
|
+
this.timers.add(timer);
|
|
1592
|
+
return () => {
|
|
1593
|
+
if (this.timers.delete(timer)) clearInterval(timer);
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
// isDatasourceAvailable — preview always resolves data (it generates it), so every
|
|
1597
|
+
// datasource is "available"; an author previewing a template never sees the
|
|
1598
|
+
// deleted-device state.
|
|
1599
|
+
async isDatasourceAvailable() {
|
|
1600
|
+
return true;
|
|
1601
|
+
}
|
|
1602
|
+
// ── WidgetActions (preview stubs) ────────────────────────────────────────
|
|
1603
|
+
// Preview shows action controls (so an author sees the real layout), so can() is
|
|
1604
|
+
// always true; the actions themselves are no-ops — preview never mutates the backend.
|
|
1605
|
+
can() {
|
|
1606
|
+
return true;
|
|
1607
|
+
}
|
|
1608
|
+
async acknowledgeAlarm() {
|
|
1609
|
+
}
|
|
1610
|
+
async clearAlarm() {
|
|
1611
|
+
}
|
|
1612
|
+
async sendCommand() {
|
|
1613
|
+
return { status: "sent", token: "syn-dispatch" };
|
|
1614
|
+
}
|
|
1615
|
+
// disposeAll stops every live stream (e.g. when preview is turned off). Individual
|
|
1616
|
+
// widget disposers already clear their own timer; this is the belt-and-braces
|
|
1617
|
+
// teardown for the whole source.
|
|
1618
|
+
disposeAll() {
|
|
1619
|
+
for (const timer of this.timers) clearInterval(timer);
|
|
1620
|
+
this.timers.clear();
|
|
1621
|
+
}
|
|
1622
|
+
valueFor(name, tMs, walk) {
|
|
1623
|
+
const span = this.max - this.min;
|
|
1624
|
+
const phase = hashName(name) % 1e3 / 1e3;
|
|
1625
|
+
switch (this.generator) {
|
|
1626
|
+
case "ramp": {
|
|
1627
|
+
const frac = ((tMs / this.periodMs + phase) % 1 + 1) % 1;
|
|
1628
|
+
return this.min + span * frac;
|
|
1629
|
+
}
|
|
1630
|
+
case "random-walk": {
|
|
1631
|
+
const prev = walk.get(name) ?? this.min + span / 2;
|
|
1632
|
+
const next = clamp(prev + (Math.random() - 0.5) * span * 0.1, this.min, this.max);
|
|
1633
|
+
walk.set(name, next);
|
|
1634
|
+
return next;
|
|
1635
|
+
}
|
|
1636
|
+
case "sine":
|
|
1637
|
+
default: {
|
|
1638
|
+
const angle = 2 * Math.PI * (tMs / this.periodMs + phase);
|
|
1639
|
+
return this.min + span * (0.5 + 0.5 * Math.sin(angle));
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
};
|
|
1644
|
+
|
|
1645
|
+
// src/editor-model.ts
|
|
1646
|
+
function baseBox(widget) {
|
|
1647
|
+
return widget.layout[BASE_BREAKPOINT];
|
|
1648
|
+
}
|
|
1649
|
+
function setWidgetBox(def, id, box) {
|
|
1650
|
+
return {
|
|
1651
|
+
...def,
|
|
1652
|
+
widgets: def.widgets.map(
|
|
1653
|
+
(w) => w.id === id ? { ...w, layout: { ...w.layout, [BASE_BREAKPOINT]: box } } : w
|
|
1654
|
+
)
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
function deleteWidget(def, id) {
|
|
1658
|
+
return { ...def, widgets: def.widgets.filter((w) => w.id !== id) };
|
|
1659
|
+
}
|
|
1660
|
+
function bringToFront(def, id) {
|
|
1661
|
+
const widget = def.widgets.find((w) => w.id === id);
|
|
1662
|
+
if (!widget) return def;
|
|
1663
|
+
const box = baseBox(widget);
|
|
1664
|
+
const maxOther = Math.max(-Infinity, ...def.widgets.filter((w) => w.id !== id).map((w) => baseBox(w).z));
|
|
1665
|
+
if (box.z > maxOther) return def;
|
|
1666
|
+
return setWidgetBox(def, id, { ...box, z: maxOther + 1 });
|
|
1667
|
+
}
|
|
1668
|
+
function setTitle(def, title) {
|
|
1669
|
+
return { ...def, title };
|
|
1670
|
+
}
|
|
1671
|
+
function setCanvasGrid(def, patch) {
|
|
1672
|
+
const grid = { ...def.canvas.grid, ...patch };
|
|
1673
|
+
if (patch.columns !== void 0) grid.columns = Math.max(1, Math.round(patch.columns));
|
|
1674
|
+
if (patch.rowHeight !== void 0) grid.rowHeight = Math.max(1, Math.round(patch.rowHeight));
|
|
1675
|
+
const widgets = grid.columns < def.canvas.grid.columns ? def.widgets.map((w) => clampWidgetColumns(w, grid.columns)) : def.widgets;
|
|
1676
|
+
return { ...def, widgets, canvas: { ...def.canvas, grid } };
|
|
1677
|
+
}
|
|
1678
|
+
function clampWidgetColumns(widget, columns) {
|
|
1679
|
+
const layout = {};
|
|
1680
|
+
for (const [bp, box] of Object.entries(widget.layout)) {
|
|
1681
|
+
const col = Math.min(box.col, columns - 1);
|
|
1682
|
+
layout[bp] = { ...box, col, colSpan: Math.min(box.colSpan, columns - col) };
|
|
1683
|
+
}
|
|
1684
|
+
return { ...widget, layout };
|
|
1685
|
+
}
|
|
1686
|
+
function setCanvasSizing(def, sizing) {
|
|
1687
|
+
return { ...def, canvas: { ...def.canvas, sizing } };
|
|
1688
|
+
}
|
|
1689
|
+
function updateWidget(def, id, next) {
|
|
1690
|
+
return { ...def, widgets: def.widgets.map((w) => w.id === id ? next : w) };
|
|
1691
|
+
}
|
|
1692
|
+
function humanizeType(type) {
|
|
1693
|
+
const words = type.replace(/-/g, " ");
|
|
1694
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
1695
|
+
}
|
|
1696
|
+
function defaultOptions(type) {
|
|
1697
|
+
if (type === "label") return { text: "New label" };
|
|
1698
|
+
if (type === "alarm-table" || type === "alarm-count") {
|
|
1699
|
+
return { title: humanizeType(type), state: "ACTIVE" };
|
|
1700
|
+
}
|
|
1701
|
+
return { title: humanizeType(type) };
|
|
1702
|
+
}
|
|
1703
|
+
function addWidget(def, type) {
|
|
1704
|
+
const maxZ = def.widgets.reduce((m, w) => Math.max(m, baseBox(w).z), 0);
|
|
1705
|
+
const id = generateWidgetId();
|
|
1706
|
+
const box = { col: 0, colSpan: 8, row: 0, rowSpan: 4, z: maxZ + 1 };
|
|
1707
|
+
const widget = {
|
|
1708
|
+
id,
|
|
1709
|
+
type,
|
|
1710
|
+
layout: { [BASE_BREAKPOINT]: box },
|
|
1711
|
+
options: defaultOptions(type)
|
|
1712
|
+
};
|
|
1713
|
+
return { definition: { ...def, widgets: [...def.widgets, widget] }, id };
|
|
1714
|
+
}
|
|
1715
|
+
function gridBoxToPx(box, geom) {
|
|
1716
|
+
const colStride = geom.colWidth + geom.colGap;
|
|
1717
|
+
const rowStride = geom.rowHeight + geom.rowGap;
|
|
1718
|
+
const dx = box.offset?.x ?? 0;
|
|
1719
|
+
const dy = box.offset?.y ?? 0;
|
|
1720
|
+
return {
|
|
1721
|
+
x: box.col * colStride + dx,
|
|
1722
|
+
y: box.row * rowStride + dy,
|
|
1723
|
+
w: box.colSpan * geom.colWidth + (box.colSpan - 1) * geom.colGap,
|
|
1724
|
+
h: box.rowSpan * geom.rowHeight + (box.rowSpan - 1) * geom.rowGap
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
function pxToGridBox(px, geom, z, offset, columns) {
|
|
1728
|
+
const colStride = Math.max(1, geom.colWidth + geom.colGap);
|
|
1729
|
+
const rowStride = Math.max(1, geom.rowHeight + geom.rowGap);
|
|
1730
|
+
const x = px.x - (offset?.x ?? 0);
|
|
1731
|
+
const y = px.y - (offset?.y ?? 0);
|
|
1732
|
+
let col = Math.max(0, Math.round(x / colStride));
|
|
1733
|
+
let colSpan = Math.max(1, Math.round((px.w + geom.colGap) / colStride));
|
|
1734
|
+
if (columns !== void 0) {
|
|
1735
|
+
col = Math.min(col, columns - 1);
|
|
1736
|
+
colSpan = Math.min(colSpan, columns - col);
|
|
1737
|
+
}
|
|
1738
|
+
const box = {
|
|
1739
|
+
col,
|
|
1740
|
+
colSpan,
|
|
1741
|
+
row: Math.max(0, Math.round(y / rowStride)),
|
|
1742
|
+
rowSpan: Math.max(1, Math.round((px.h + geom.rowGap) / rowStride)),
|
|
1743
|
+
z
|
|
1744
|
+
};
|
|
1745
|
+
if (offset) box.offset = offset;
|
|
1746
|
+
return box;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// src/resolver.ts
|
|
1750
|
+
import { gql as gql3 } from "@devicechain/client";
|
|
1751
|
+
var ANCHOR_PAGE_SIZE = 500;
|
|
1752
|
+
function createDeviceResolver() {
|
|
1753
|
+
const anchorCache = /* @__PURE__ */ new Map();
|
|
1754
|
+
const existsCache = /* @__PURE__ */ new Map();
|
|
1755
|
+
return {
|
|
1756
|
+
devicesForAnchor(anchor) {
|
|
1757
|
+
const key = `${anchor.relationship}|${anchor.targetType}|${anchor.targetToken}`;
|
|
1758
|
+
let pending = anchorCache.get(key);
|
|
1759
|
+
if (!pending) {
|
|
1760
|
+
pending = gql3("device-management", DEVICES_FOR_ANCHOR, {
|
|
1761
|
+
criteria: {
|
|
1762
|
+
pageNumber: 1,
|
|
1763
|
+
pageSize: ANCHOR_PAGE_SIZE,
|
|
1764
|
+
sourceType: "device",
|
|
1765
|
+
targetType: anchor.targetType,
|
|
1766
|
+
target: anchor.targetToken,
|
|
1767
|
+
relationshipType: anchor.relationship
|
|
1768
|
+
}
|
|
1769
|
+
}).then(
|
|
1770
|
+
(r) => r.entityRelationships.results.map((rel) => rel.source.token)
|
|
1771
|
+
).catch((err) => {
|
|
1772
|
+
anchorCache.delete(key);
|
|
1773
|
+
throw err;
|
|
1774
|
+
});
|
|
1775
|
+
anchorCache.set(key, pending);
|
|
1776
|
+
}
|
|
1777
|
+
return pending;
|
|
1778
|
+
},
|
|
1779
|
+
// deviceExists caches only a POSITIVE result (a live device is stable for the
|
|
1780
|
+
// session); a negative and an error both drop the entry so a later check re-queries.
|
|
1781
|
+
// ADR-042 frees a token on delete, so a deleted-then-recreated device must be able to
|
|
1782
|
+
// recover on a long-lived viewer (the availability hook re-checks on a timer while a
|
|
1783
|
+
// widget shows unavailable) rather than staying stuck "gone" for the whole session.
|
|
1784
|
+
// The in-flight promise is still shared, so concurrent checks for one token coalesce.
|
|
1785
|
+
// (Batching distinct tokens into one devicesByToken call is a deferred optimization —
|
|
1786
|
+
// Phase-1 dashboards bind a handful of devices.)
|
|
1787
|
+
deviceExists(deviceToken) {
|
|
1788
|
+
let pending = existsCache.get(deviceToken);
|
|
1789
|
+
if (!pending) {
|
|
1790
|
+
pending = gql3("device-management", DEVICES_BY_TOKEN, { tokens: [deviceToken] }).then((r) => {
|
|
1791
|
+
const exists = r.devicesByToken.some((d) => d.token === deviceToken);
|
|
1792
|
+
if (!exists) existsCache.delete(deviceToken);
|
|
1793
|
+
return exists;
|
|
1794
|
+
}).catch((err) => {
|
|
1795
|
+
existsCache.delete(deviceToken);
|
|
1796
|
+
throw err;
|
|
1797
|
+
});
|
|
1798
|
+
existsCache.set(deviceToken, pending);
|
|
1799
|
+
}
|
|
1800
|
+
return pending;
|
|
1801
|
+
}
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// src/history.ts
|
|
1806
|
+
import { gql as gql4 } from "@devicechain/client";
|
|
1807
|
+
function defaultHistoryWindow() {
|
|
1808
|
+
const now = Date.now();
|
|
1809
|
+
return {
|
|
1810
|
+
startTime: new Date(now - 60 * 60 * 1e3).toISOString(),
|
|
1811
|
+
endTime: new Date(now).toISOString(),
|
|
1812
|
+
intervalSeconds: 60
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
async function fetchWidgetHistory(widget, window, bindings) {
|
|
1816
|
+
const ds = resolveHistorySelector(widget.datasource, bindings);
|
|
1817
|
+
if (!ds || ds.kind !== "device") return [];
|
|
1818
|
+
try {
|
|
1819
|
+
const deviceToken = ds.deviceToken;
|
|
1820
|
+
const names = ds.measurements.length ? ds.measurements : [void 0];
|
|
1821
|
+
const pages = await Promise.all(
|
|
1822
|
+
names.map(
|
|
1823
|
+
(name) => gql4("event-management", BUCKETED_MEASUREMENTS, {
|
|
1824
|
+
criteria: {
|
|
1825
|
+
deviceToken,
|
|
1826
|
+
name,
|
|
1827
|
+
startTime: window.startTime,
|
|
1828
|
+
endTime: window.endTime,
|
|
1829
|
+
intervalSeconds: window.intervalSeconds
|
|
1830
|
+
}
|
|
1831
|
+
}).then((r) => r.bucketedMeasurements)
|
|
1832
|
+
)
|
|
1833
|
+
);
|
|
1834
|
+
return pages.flat().filter((b) => b.avg != null).map((b) => ({
|
|
1835
|
+
id: `${deviceToken}-${b.name}-${b.bucketStart}`,
|
|
1836
|
+
deviceToken,
|
|
1837
|
+
eventType: 0,
|
|
1838
|
+
occurredTime: b.bucketStart,
|
|
1839
|
+
name: b.name,
|
|
1840
|
+
value: b.avg,
|
|
1841
|
+
classifier: null
|
|
1842
|
+
})).sort((a, b) => a.occurredTime < b.occurredTime ? -1 : 1);
|
|
1843
|
+
} catch {
|
|
1844
|
+
return [];
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
function resolveHistorySelector(ds, bindings) {
|
|
1848
|
+
if (!ds || ds.kind !== "slot") return ds;
|
|
1849
|
+
const binding = bindings && Object.prototype.hasOwnProperty.call(bindings, ds.slot) ? bindings[ds.slot] : void 0;
|
|
1850
|
+
if (binding?.kind === "device") {
|
|
1851
|
+
return { kind: "device", deviceToken: binding.deviceToken, measurements: ds.measurements };
|
|
1852
|
+
}
|
|
1853
|
+
if (binding?.kind === "anchor") {
|
|
1854
|
+
return { kind: "anchor", anchor: binding.anchor, measurements: ds.measurements };
|
|
1855
|
+
}
|
|
1856
|
+
return void 0;
|
|
1857
|
+
}
|
|
1858
|
+
export {
|
|
1859
|
+
BASE_BREAKPOINT,
|
|
1860
|
+
COMMAND_STATUSES,
|
|
1861
|
+
DashboardDefinitionError,
|
|
1862
|
+
DashboardHub,
|
|
1863
|
+
SYNTHETIC_GENERATORS,
|
|
1864
|
+
SyntheticDataSource,
|
|
1865
|
+
WIDGET_TYPES,
|
|
1866
|
+
activeBreakpoint,
|
|
1867
|
+
addWidget,
|
|
1868
|
+
anchorSlotNames,
|
|
1869
|
+
applySelection,
|
|
1870
|
+
baseBox,
|
|
1871
|
+
bindWidgetSlot,
|
|
1872
|
+
bindingsWithoutScopedSlots,
|
|
1873
|
+
bringToFront,
|
|
1874
|
+
canScopeSlot,
|
|
1875
|
+
clearWidgetDatasource,
|
|
1876
|
+
createDeviceResolver,
|
|
1877
|
+
createEntityLister,
|
|
1878
|
+
defaultHistoryWindow,
|
|
1879
|
+
deleteWidget,
|
|
1880
|
+
effectiveBindings,
|
|
1881
|
+
fetchWidgetHistory,
|
|
1882
|
+
generateWidgetId,
|
|
1883
|
+
gridBoxToPx,
|
|
1884
|
+
hasScopedSlots,
|
|
1885
|
+
isCancellableCommandStatus,
|
|
1886
|
+
isDirty,
|
|
1887
|
+
isTerminalCommandStatus,
|
|
1888
|
+
migrateToSlots,
|
|
1889
|
+
parseBindingManifest,
|
|
1890
|
+
parseDashboardDefinition,
|
|
1891
|
+
parseSlotBinding,
|
|
1892
|
+
pruneSlots,
|
|
1893
|
+
pxToGridBox,
|
|
1894
|
+
resolveConcrete,
|
|
1895
|
+
resolveContextBindings,
|
|
1896
|
+
resolveSlotCandidates,
|
|
1897
|
+
resolveWidgetBox,
|
|
1898
|
+
sameBinding,
|
|
1899
|
+
serializeDefinition,
|
|
1900
|
+
setCanvasGrid,
|
|
1901
|
+
setCanvasSizing,
|
|
1902
|
+
setSlotScope,
|
|
1903
|
+
setTitle,
|
|
1904
|
+
setWidgetBox,
|
|
1905
|
+
stripDefaultBindings,
|
|
1906
|
+
updateWidget,
|
|
1907
|
+
widgetBinding,
|
|
1908
|
+
widgetSlotName
|
|
1909
|
+
};
|
|
1910
|
+
//# sourceMappingURL=index.js.map
|