@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/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