@devicechain/dashboards 0.23.0-rc.1 → 0.24.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -57,6 +57,7 @@ var WIDGET_TYPES = [
57
57
  "image",
58
58
  "alarm-table",
59
59
  "alarm-count",
60
+ "alarm-severity",
60
61
  "command-button",
61
62
  "entity-selector",
62
63
  "map",
@@ -72,7 +73,10 @@ var WIDGET_TYPES = [
72
73
  "stat-card",
73
74
  "bar-chart",
74
75
  "pie-chart",
75
- "fleet-table"
76
+ "fleet-table",
77
+ "fleet-count",
78
+ "device-status",
79
+ "online-doughnut"
76
80
  ];
77
81
  var PLACEMENT_ANCHORS = [
78
82
  "top-left",
@@ -1381,6 +1385,81 @@ function bindingMatchesType(binding, type) {
1381
1385
  return binding.kind === type;
1382
1386
  }
1383
1387
 
1388
+ // src/deep-link.ts
1389
+ import { isValidToken } from "@devicechain/client";
1390
+ var BIND_PARAM_PREFIX = "bind.";
1391
+ function ownSlot(definition, slot) {
1392
+ const slots = definition.slots;
1393
+ return slots && Object.prototype.hasOwnProperty.call(slots, slot) ? slots[slot] : void 0;
1394
+ }
1395
+ function parseBindParams(search, definition) {
1396
+ const binds = {};
1397
+ const issues = [];
1398
+ const params = new URLSearchParams(search);
1399
+ const seen = /* @__PURE__ */ new Set();
1400
+ for (const [key, token] of params) {
1401
+ if (!key.startsWith(BIND_PARAM_PREFIX)) continue;
1402
+ const slot = key.slice(BIND_PARAM_PREFIX.length);
1403
+ if (seen.has(slot)) continue;
1404
+ seen.add(slot);
1405
+ const def = ownSlot(definition, slot);
1406
+ if (params.getAll(key).length > 1) issues.push({ kind: "duplicate-slot", slot, token });
1407
+ else if (!def) issues.push({ kind: "unknown-slot", slot, token });
1408
+ else if (def.type !== "device") issues.push({ kind: "not-a-device-slot", slot, token });
1409
+ else if (def.scope?.strategy === "first") issues.push({ kind: "derived-slot", slot, token });
1410
+ else if (!isValidToken(token)) issues.push({ kind: "invalid-token", slot, token });
1411
+ else Object.defineProperty(binds, slot, { value: token, enumerable: true, writable: true, configurable: true });
1412
+ }
1413
+ return { binds, issues };
1414
+ }
1415
+ async function validateBinds(binds, resolver) {
1416
+ const out = { ok: {}, missing: [], failed: [] };
1417
+ await Promise.all(
1418
+ Object.entries(binds).map(async ([slot, token]) => {
1419
+ try {
1420
+ if (await resolver.deviceExists(token)) Object.defineProperty(out.ok, slot, { value: token, enumerable: true, writable: true, configurable: true });
1421
+ else out.missing.push({ slot, token });
1422
+ } catch {
1423
+ out.failed.push({ slot, token });
1424
+ }
1425
+ })
1426
+ );
1427
+ return out;
1428
+ }
1429
+ function bindsToSelection(binds) {
1430
+ const out = {};
1431
+ for (const [slot, deviceToken] of Object.entries(binds)) {
1432
+ Object.defineProperty(out, slot, {
1433
+ value: { kind: "device", deviceToken },
1434
+ enumerable: true,
1435
+ writable: true,
1436
+ configurable: true
1437
+ });
1438
+ }
1439
+ return out;
1440
+ }
1441
+ function selectionToBinds(selection, definition) {
1442
+ const out = {};
1443
+ for (const slot of Object.keys(selection)) {
1444
+ const b = selection[slot];
1445
+ if (b.kind !== "device" || ownSlot(definition, slot)?.type !== "device") continue;
1446
+ Object.defineProperty(out, slot, { value: b.deviceToken, enumerable: true, writable: true, configurable: true });
1447
+ }
1448
+ return out;
1449
+ }
1450
+ function withBindParams(search, binds) {
1451
+ const next = new URLSearchParams();
1452
+ for (const [k, v] of new URLSearchParams(search)) if (!k.startsWith(BIND_PARAM_PREFIX)) next.append(k, v);
1453
+ for (const slot of Object.keys(binds).sort()) next.append(BIND_PARAM_PREFIX + slot, binds[slot]);
1454
+ return next.toString();
1455
+ }
1456
+ function bindParamsOf(search) {
1457
+ const only = new URLSearchParams();
1458
+ for (const [k, v] of new URLSearchParams(search)) if (k.startsWith(BIND_PARAM_PREFIX)) only.append(k, v);
1459
+ only.sort();
1460
+ return only.toString();
1461
+ }
1462
+
1384
1463
  // src/slots.ts
1385
1464
  function slotSelector(slot, measurements, location) {
1386
1465
  return location ? { kind: "slot", slot, measurements, location } : { kind: "slot", slot, measurements };
@@ -1621,6 +1700,9 @@ var CUSTOMER_DEVICES = `
1621
1700
  results {
1622
1701
  token
1623
1702
  name
1703
+ deviceType {
1704
+ token
1705
+ }
1624
1706
  }
1625
1707
  pagination {
1626
1708
  totalRecords
@@ -1640,6 +1722,23 @@ var DEVICE_NAMES = `
1640
1722
  devicesByToken(tokens: $tokens) {
1641
1723
  token
1642
1724
  name
1725
+ deviceType {
1726
+ token
1727
+ }
1728
+ }
1729
+ }
1730
+ `;
1731
+ var DECLARED_METRICS = `
1732
+ query DashboardDeclaredMetrics($tokens: [String!]!) {
1733
+ deviceTypesByToken(tokens: $tokens) {
1734
+ token
1735
+ profile {
1736
+ activeProfileVersion {
1737
+ metrics {
1738
+ metricKey
1739
+ }
1740
+ }
1741
+ }
1643
1742
  }
1644
1743
  }
1645
1744
  `;
@@ -1827,6 +1926,34 @@ var ConnectionTracker = class {
1827
1926
  }
1828
1927
  };
1829
1928
 
1929
+ // src/event-window.ts
1930
+ function eventWindowBounds(window2, now) {
1931
+ const end = window2.endAt ?? now;
1932
+ return { from: end - window2.spanSeconds * 1e3, to: window2.endAt };
1933
+ }
1934
+ function windowCriteria(window2, now) {
1935
+ if (!window2) return { startTime: null, endTime: null };
1936
+ const { from, to } = eventWindowBounds(window2, now);
1937
+ return { startTime: new Date(from).toISOString(), endTime: to === void 0 ? null : new Date(to).toISOString() };
1938
+ }
1939
+ var instant = (iso) => {
1940
+ if (!iso) return null;
1941
+ const t = Date.parse(iso);
1942
+ return Number.isNaN(t) ? null : t;
1943
+ };
1944
+ function alarmOverlaps(row, window2, now) {
1945
+ const { from, to } = eventWindowBounds(window2, now);
1946
+ const raised = instant(row.raisedTime);
1947
+ if (raised === null || to !== void 0 && raised > to) return false;
1948
+ const cleared = instant(row.clearedTime);
1949
+ return cleared === null || cleared >= from;
1950
+ }
1951
+ function commandInRange(row, window2, now) {
1952
+ const { from, to } = eventWindowBounds(window2, now);
1953
+ const at = instant(row.sentTime) ?? instant(row.queuedTime);
1954
+ return at !== null && at >= from && (to === void 0 || at <= to);
1955
+ }
1956
+
1830
1957
  // src/hub.ts
1831
1958
  import { gql as gql2, isForbiddenError, isScopeUnsupportedError, subscribe } from "@devicechain/client";
1832
1959
 
@@ -1855,6 +1982,37 @@ var ALARMS_QUERY = `
1855
1982
  }
1856
1983
  }
1857
1984
  `;
1985
+ var ALARM_DEVICE_TYPES = `
1986
+ query DashboardAlarmDeviceTypes($tokens: [String!]!) {
1987
+ devicesByToken(tokens: $tokens) {
1988
+ token
1989
+ deviceType {
1990
+ token
1991
+ name
1992
+ }
1993
+ }
1994
+ }
1995
+ `;
1996
+ var ALARM_TYPE_COUNTS = `
1997
+ query DashboardAlarmTypeCounts($criteria: AlarmCountCriteria!) {
1998
+ alarmCountsByDeviceType(criteria: $criteria) {
1999
+ deviceType {
2000
+ token
2001
+ name
2002
+ }
2003
+ severity
2004
+ count
2005
+ }
2006
+ }
2007
+ `;
2008
+ var ALARM_SEVERITY_COUNTS = `
2009
+ query DashboardAlarmSeverityCounts($criteria: AlarmCountCriteria!, $deviceType: String) {
2010
+ alarmCountsBySeverity(criteria: $criteria, deviceType: $deviceType) {
2011
+ severity
2012
+ count
2013
+ }
2014
+ }
2015
+ `;
1858
2016
  var ALARM_STREAM = `
1859
2017
  subscription DashboardAlarmStream(
1860
2018
  $originatorType: String
@@ -1943,6 +2101,18 @@ var LATEST_LOCATIONS_QUERY = `
1943
2101
  }
1944
2102
  `;
1945
2103
 
2104
+ // src/internal/presence-doc.ts
2105
+ var DEVICE_PRESENCE_QUERY = `
2106
+ query DashboardDevicePresence($deviceTokens: [String!]!) {
2107
+ deviceStatesByDeviceToken(deviceTokens: $deviceTokens) {
2108
+ deviceToken
2109
+ active
2110
+ lastActivityTime
2111
+ inactivityTimeout
2112
+ }
2113
+ }
2114
+ `;
2115
+
1946
2116
  // src/members.ts
1947
2117
  var MEMBER_SEED_CONCURRENCY = 6;
1948
2118
  var SEED_CANCELLED = /* @__PURE__ */ Symbol("member seed cancelled");
@@ -1998,8 +2168,11 @@ function randomToken() {
1998
2168
  }
1999
2169
  var ALARM_RECONCILE_DEBOUNCE_MS = 800;
2000
2170
  var ALARM_POLL_MS = 3e4;
2171
+ var ALARM_DEVICE_TYPES_CHUNK = 1e3;
2001
2172
  var COMMAND_POLL_MS = 4e3;
2002
2173
  var LOCATION_POLL_MS = 15e3;
2174
+ var PRESENCE_POLL_MS = 15e3;
2175
+ var PRESENCE_CHUNK = 1e3;
2003
2176
  var DashboardHub = class {
2004
2177
  constructor(config) {
2005
2178
  // Per alarm-subscription reconcile triggers — invoked after an ack/clear so the
@@ -2023,11 +2196,17 @@ var DashboardHub = class {
2023
2196
  // commandDisposers — the location channel holds a poll per subscription, not a shared
2024
2197
  // device stream), so disposeAll() can tear down every map widget's poll.
2025
2198
  this.locationDisposers = /* @__PURE__ */ new Set();
2199
+ // Live presence-subscription disposers (same rationale: a poll per subscription).
2200
+ this.presenceDisposers = /* @__PURE__ */ new Set();
2026
2201
  // Which of this hub's sockets are down, folded into one state a header can show.
2027
2202
  this.connection = new ConnectionTracker();
2028
2203
  // Streams evicted by an upstream error that still have subscribers: out of `streams`, but
2029
2204
  // still holding their connection handle (see ensureStream), so disposeAll() can reach them.
2030
2205
  this.deadStreams = /* @__PURE__ */ new Set();
2206
+ // One read per distinct device type, whoever asks and however many devices have it. A settled
2207
+ // answer (names, or null for "no published profile") is kept for the session; a failed read is
2208
+ // not, so a later ask tries again, and that ask is answered null meanwhile.
2209
+ this.declared = /* @__PURE__ */ new Map();
2031
2210
  // The hub-wide cap on in-flight member seed reads, and the reads currently in flight (so a second
2032
2211
  // widget asking the same question of the same device shares one request).
2033
2212
  this.seedLimiter = createLimiter(MEMBER_SEED_CONCURRENCY);
@@ -2036,6 +2215,12 @@ var DashboardHub = class {
2036
2215
  // that ask the same question while a read is in flight share it: stat cards on one measurement
2037
2216
  // and span read on the same minute boundaries, so a board of them costs one read per question.
2038
2217
  this.bucketReads = /* @__PURE__ */ new Map();
2218
+ // Each device's type, for the lifetime of the hub: a device's type is fixed for a viewing session,
2219
+ // and the same originators come back every poll. ONE devicesByToken read per chunk of uncached
2220
+ // tokens (the server takes 1000), never one per alarm. A failed read leaves those rows with no
2221
+ // `deviceType` (unknown, so a grouping widget lists them under All only) instead of failing the
2222
+ // whole alarm table, and is retried on the next reconcile because a failure is not cached.
2223
+ this.alarmDeviceTypes = /* @__PURE__ */ new Map();
2039
2224
  this.resolver = config.resolver;
2040
2225
  this.authorities = new Set(config.authorities ?? []);
2041
2226
  }
@@ -2111,6 +2296,19 @@ var DashboardHub = class {
2111
2296
  const members = groups.map((g) => ({ token: g.deviceToken, name: null }));
2112
2297
  return { roster: { members, total: members.length, unbound: datasource.kind === "unbound" }, names };
2113
2298
  }
2299
+ declaredMetrics(deviceType) {
2300
+ const read = this.resolver.declaredMetrics;
2301
+ if (!read) return Promise.resolve(null);
2302
+ let pending = this.declared.get(deviceType);
2303
+ if (!pending) {
2304
+ pending = read.call(this.resolver, deviceType).catch(() => {
2305
+ this.declared.delete(deviceType);
2306
+ return null;
2307
+ });
2308
+ this.declared.set(deviceType, pending);
2309
+ }
2310
+ return pending;
2311
+ }
2114
2312
  async readMemberSeeds(deviceTokens, plan, onSeed, cancelled = () => false) {
2115
2313
  await Promise.all(
2116
2314
  deviceTokens.map(async (token) => {
@@ -2248,26 +2446,50 @@ var DashboardHub = class {
2248
2446
  // and merges — deduped by token, newest first, capped to pageSize; total is the sum of
2249
2447
  // per-originator match counts.
2250
2448
  async queryAlarms(sub, tokens, tenantWide) {
2251
- const base = {
2252
- pageNumber: 1,
2253
- // the alarms query paginates 1-based
2254
- pageSize: sub.pageSize,
2449
+ const filters = {
2255
2450
  state: sub.state ?? null,
2256
2451
  severity: sub.severity ?? null,
2257
2452
  acknowledged: sub.acknowledged ?? null
2258
2453
  };
2259
- if (tenantWide) {
2260
- const data = await gql2(DEVICE_AREA, ALARMS_QUERY, {
2261
- criteria: { ...base, originatorType: null, originator: null }
2262
- });
2263
- return { alarms: data.alarms.results, total: data.alarms.pagination.totalRecords };
2454
+ const originators = tenantWide ? [{ originatorType: null, originator: null }] : tokens.map((token) => ({ originatorType: "device", originator: token }));
2455
+ if (sub.countBySeverity) {
2456
+ const pages2 = await Promise.all(
2457
+ originators.map(
2458
+ (o) => gql2(DEVICE_AREA, ALARM_SEVERITY_COUNTS, { criteria: { ...filters, ...o }, deviceType: sub.deviceType ?? null })
2459
+ )
2460
+ );
2461
+ const merged = /* @__PURE__ */ new Map();
2462
+ for (const page of pages2) {
2463
+ for (const c of page.alarmCountsBySeverity) merged.set(c.severity, (merged.get(c.severity) ?? 0) + c.count);
2464
+ }
2465
+ const severityCounts = [...merged].map(([severity, count]) => ({ severity, count }));
2466
+ return { alarms: [], total: severityCounts.reduce((n, c) => n + c.count, 0), severityCounts };
2467
+ }
2468
+ if (sub.countByDeviceType) {
2469
+ const pages2 = await Promise.all(
2470
+ originators.map((o) => gql2(DEVICE_AREA, ALARM_TYPE_COUNTS, { criteria: { ...filters, ...o } }))
2471
+ );
2472
+ const merged = /* @__PURE__ */ new Map();
2473
+ for (const page of pages2) {
2474
+ for (const c of page.alarmCountsByDeviceType) {
2475
+ const key = `${c.deviceType.token}\0${c.severity}`;
2476
+ const prior = merged.get(key);
2477
+ merged.set(key, prior ? { ...prior, count: prior.count + c.count } : { ...c, deviceType: { ...c.deviceType } });
2478
+ }
2479
+ }
2480
+ const typeCounts = [...merged.values()];
2481
+ return { alarms: [], total: typeCounts.reduce((n, c) => n + c.count, 0), typeCounts };
2264
2482
  }
2483
+ const base = {
2484
+ pageNumber: 1,
2485
+ // the alarms query paginates 1-based
2486
+ pageSize: sub.pageSize,
2487
+ ...filters,
2488
+ deviceType: sub.deviceType ?? null,
2489
+ ...windowCriteria(sub.window, Date.now())
2490
+ };
2265
2491
  const pages = await Promise.all(
2266
- tokens.map(
2267
- (token) => gql2(DEVICE_AREA, ALARMS_QUERY, {
2268
- criteria: { ...base, originatorType: "device", originator: token }
2269
- })
2270
- )
2492
+ originators.map((o) => gql2(DEVICE_AREA, ALARMS_QUERY, { criteria: { ...base, ...o } }))
2271
2493
  );
2272
2494
  const byToken = /* @__PURE__ */ new Map();
2273
2495
  let total = 0;
@@ -2275,9 +2497,29 @@ var DashboardHub = class {
2275
2497
  total += page.alarms.pagination.totalRecords;
2276
2498
  for (const row of page.alarms.results) byToken.set(row.token, row);
2277
2499
  }
2278
- const alarms = [...byToken.values()].sort((a, b) => (b.raisedTime ?? "").localeCompare(a.raisedTime ?? "")).slice(0, sub.pageSize);
2500
+ let alarms = [...byToken.values()].sort((a, b) => (b.raisedTime ?? "").localeCompare(a.raisedTime ?? "")).slice(0, sub.pageSize);
2501
+ if (sub.enrichDeviceType === true) alarms = await this.withDeviceTypes(alarms);
2279
2502
  return { alarms, total };
2280
2503
  }
2504
+ async withDeviceTypes(rows) {
2505
+ const missing = [
2506
+ ...new Set(
2507
+ rows.filter((r) => r.originatorType === "device" && r.originatorToken && !this.alarmDeviceTypes.has(r.originatorToken)).map((r) => r.originatorToken)
2508
+ )
2509
+ ];
2510
+ for (let i = 0; i < missing.length; i += ALARM_DEVICE_TYPES_CHUNK) {
2511
+ try {
2512
+ const r = await gql2(DEVICE_AREA, ALARM_DEVICE_TYPES, { tokens: missing.slice(i, i + ALARM_DEVICE_TYPES_CHUNK) });
2513
+ for (const d of r.devicesByToken) this.alarmDeviceTypes.set(d.token, d.deviceType);
2514
+ } catch {
2515
+ }
2516
+ }
2517
+ return rows.map((r) => {
2518
+ if (r.originatorType !== "device" || !r.originatorToken) return { ...r, deviceType: null };
2519
+ const deviceType = this.alarmDeviceTypes.get(r.originatorToken);
2520
+ return deviceType ? { ...r, deviceType } : r;
2521
+ });
2522
+ }
2281
2523
  // ── Control channel ──────────────────────────────────────────────────────
2282
2524
  // subscribeCommands binds a command widget's scope to a sink and returns a disposer.
2283
2525
  // Poll-only (command-delivery has no subscription): resolve the target device once,
@@ -2336,7 +2578,7 @@ var DashboardHub = class {
2336
2578
  async queryCommands(sub, deviceToken) {
2337
2579
  if (!deviceToken) return { deviceToken: null, commands: [], total: 0 };
2338
2580
  const data = await gql2(COMMAND_AREA, COMMANDS_QUERY, {
2339
- criteria: { pageNumber: 1, pageSize: sub.pageSize, deviceToken, status: null }
2581
+ criteria: { pageNumber: 1, pageSize: sub.pageSize, deviceToken, status: null, ...windowCriteria(sub.window, Date.now()) }
2340
2582
  });
2341
2583
  return {
2342
2584
  deviceToken,
@@ -2411,6 +2653,70 @@ var DashboardHub = class {
2411
2653
  throw err;
2412
2654
  }
2413
2655
  }
2656
+ // subscribePresence: the roster, then the roster's device-state on an interval. Poll-only (see
2657
+ // PRESENCE_POLL_MS). A refusal (`state:read` is outside some members' grants) becomes `forbidden`,
2658
+ // and every other failure stays an error, as for the location channel. A slow read that lands after
2659
+ // a newer one is dropped, so an old answer never overwrites a fresher one.
2660
+ subscribePresence(datasource, sink) {
2661
+ let disposed = false;
2662
+ let poll;
2663
+ let shown;
2664
+ let generation = 0;
2665
+ const dispose = () => {
2666
+ disposed = true;
2667
+ if (poll) clearInterval(poll);
2668
+ if (shown) document.removeEventListener("visibilitychange", shown);
2669
+ this.presenceDisposers.delete(dispose);
2670
+ };
2671
+ this.presenceDisposers.add(dispose);
2672
+ const reconcile = (tokens) => {
2673
+ const gen = ++generation;
2674
+ this.queryPresence(tokens).then(
2675
+ (states) => {
2676
+ if (!disposed && gen === generation) sink.next(states);
2677
+ },
2678
+ (err) => {
2679
+ if (disposed || gen !== generation) return;
2680
+ if (isForbiddenError(err)) sink.forbidden?.();
2681
+ else sink.error?.(err);
2682
+ }
2683
+ );
2684
+ };
2685
+ this.resolveRoster(datasource).then(
2686
+ ({ roster }) => {
2687
+ if (disposed) return;
2688
+ sink.roster(roster);
2689
+ const tokens = roster.members.map((m) => m.token);
2690
+ if (tokens.length === 0) {
2691
+ sink.next([]);
2692
+ return;
2693
+ }
2694
+ const hidden = () => typeof document !== "undefined" && document.hidden;
2695
+ poll = setInterval(() => {
2696
+ if (!hidden()) reconcile(tokens);
2697
+ }, PRESENCE_POLL_MS);
2698
+ if (typeof document !== "undefined") {
2699
+ shown = () => {
2700
+ if (!hidden()) reconcile(tokens);
2701
+ };
2702
+ document.addEventListener("visibilitychange", shown);
2703
+ }
2704
+ reconcile(tokens);
2705
+ },
2706
+ (err) => {
2707
+ if (disposed) return;
2708
+ if (isForbiddenError(err)) sink.forbidden?.();
2709
+ else sink.error?.(err);
2710
+ }
2711
+ );
2712
+ return dispose;
2713
+ }
2714
+ async queryPresence(tokens) {
2715
+ const chunks = [];
2716
+ for (let i = 0; i < tokens.length; i += PRESENCE_CHUNK) chunks.push(tokens.slice(i, i + PRESENCE_CHUNK));
2717
+ const reads = await Promise.all(chunks.map((deviceTokens) => gql2(STATE_AREA, DEVICE_PRESENCE_QUERY, { deviceTokens })));
2718
+ return reads.flatMap((r) => r.deviceStatesByDeviceToken);
2719
+ }
2414
2720
  // ── WidgetActions (the write seam) ───────────────────────────────────────
2415
2721
  // can reports whether the viewer holds an authority ('*' grants all). Drives whether
2416
2722
  // a widget renders an action control; the server enforces authority regardless.
@@ -2480,6 +2786,8 @@ var DashboardHub = class {
2480
2786
  this.commandDisposers.clear();
2481
2787
  for (const dispose of [...this.locationDisposers]) dispose();
2482
2788
  this.locationDisposers.clear();
2789
+ for (const dispose of [...this.presenceDisposers]) dispose();
2790
+ this.presenceDisposers.clear();
2483
2791
  }
2484
2792
  // The number of distinct upstream device streams currently open (observability
2485
2793
  // + test hook — proves multiplexing collapses shared devices to one stream).
@@ -2663,6 +2971,16 @@ var DashboardHub = class {
2663
2971
  // src/synthetic.ts
2664
2972
  var SYNTHETIC_GENERATORS = ["sine", "ramp", "random-walk"];
2665
2973
  var DEFAULT_NAME = "value";
2974
+ var SYNTHETIC_DEVICE_TYPES = {
2975
+ "thermostat-01": { token: "thermostat", name: "Thermostat" },
2976
+ "sensor-14": { token: "sensor", name: "Sensor" },
2977
+ "sensor-03": { token: "sensor", name: "Sensor" },
2978
+ "gateway-02": { token: "gateway", name: "Gateway" }
2979
+ };
2980
+ function withType(token) {
2981
+ const deviceType = token ? SYNTHETIC_DEVICE_TYPES[token] : void 0;
2982
+ return deviceType ? { deviceType } : {};
2983
+ }
2666
2984
  var SYNTHETIC_ALARMS = [
2667
2985
  { severity: "CRITICAL", state: "ACTIVE", acknowledged: false, alarmKey: "over-temperature", metricKey: "temperature", lastValue: 87.4, originatorToken: "thermostat-01" },
2668
2986
  { severity: "MAJOR", state: "ACTIVE", acknowledged: true, alarmKey: "low-battery", metricKey: "battery", lastValue: 12, originatorToken: "sensor-14" },
@@ -2751,8 +3069,9 @@ var SyntheticDataSource = class {
2751
3069
  // a device. Re-emits on the same cadence with advancing raised times so the table
2752
3070
  // looks live. Returns whole snapshots, matching the live hub's contract.
2753
3071
  subscribeAlarms(subscription, sink) {
3072
+ const typed = subscription.enrichDeviceType === true;
2754
3073
  const matches = SYNTHETIC_ALARMS.filter(
2755
- (a) => (!subscription.state || a.state === subscription.state) && (!subscription.severity || a.severity === subscription.severity) && (subscription.acknowledged == null || a.acknowledged === subscription.acknowledged)
3074
+ (a) => (subscription.deviceType === void 0 || SYNTHETIC_DEVICE_TYPES[a.originatorToken ?? ""]?.token === subscription.deviceType) && (!subscription.state || a.state === subscription.state) && (!subscription.severity || a.severity === subscription.severity) && (subscription.acknowledged == null || a.acknowledged === subscription.acknowledged)
2756
3075
  );
2757
3076
  const emit = () => {
2758
3077
  const now = Date.now();
@@ -2771,10 +3090,32 @@ var SyntheticDataSource = class {
2771
3090
  raisedTime: raised,
2772
3091
  clearedTime: a.state === "CLEARED" ? raised : null,
2773
3092
  acknowledgedTime: a.acknowledged ? raised : null,
2774
- acknowledgedBy: a.acknowledged ? "preview@devicechain" : null
3093
+ acknowledgedBy: a.acknowledged ? "preview@devicechain" : null,
3094
+ ...typed ? withType(a.originatorToken) : {}
2775
3095
  };
2776
3096
  });
2777
- sink.next({ alarms: rows.slice(0, subscription.pageSize), total: rows.length });
3097
+ if (subscription.countBySeverity) {
3098
+ const bySeverity = /* @__PURE__ */ new Map();
3099
+ for (const r of rows) bySeverity.set(r.severity, (bySeverity.get(r.severity) ?? 0) + 1);
3100
+ const severityCounts = [...bySeverity].map(([severity, count]) => ({ severity, count }));
3101
+ sink.next({ alarms: [], total: rows.length, severityCounts });
3102
+ return;
3103
+ }
3104
+ if (subscription.countByDeviceType) {
3105
+ const counts = /* @__PURE__ */ new Map();
3106
+ for (const r of rows) {
3107
+ const deviceType = SYNTHETIC_DEVICE_TYPES[r.originatorToken ?? ""];
3108
+ if (!deviceType) continue;
3109
+ const key = `${deviceType.token}/${r.severity}`;
3110
+ const prior = counts.get(key);
3111
+ counts.set(key, { deviceType, severity: r.severity, count: (prior?.count ?? 0) + 1 });
3112
+ }
3113
+ const typeCounts = [...counts.values()];
3114
+ sink.next({ alarms: [], total: typeCounts.reduce((n, c) => n + c.count, 0), typeCounts });
3115
+ return;
3116
+ }
3117
+ const inRange = subscription.window ? rows.filter((r) => alarmOverlaps(r, subscription.window, now)) : rows;
3118
+ sink.next({ alarms: inRange.slice(0, subscription.pageSize), total: inRange.length });
2778
3119
  };
2779
3120
  emit();
2780
3121
  const timer = setInterval(emit, this.intervalMs);
@@ -2809,10 +3150,11 @@ var SyntheticDataSource = class {
2809
3150
  respondedTime: answered ? queued : null
2810
3151
  };
2811
3152
  });
3153
+ const inRange = subscription.window ? commands.filter((c) => commandInRange(c, subscription.window, now)) : commands;
2812
3154
  sink.next({
2813
3155
  deviceToken: SYNTHETIC_COMMAND_DEVICE,
2814
- commands: commands.slice(0, subscription.pageSize),
2815
- total: commands.length
3156
+ commands: inRange.slice(0, subscription.pageSize),
3157
+ total: inRange.length
2816
3158
  });
2817
3159
  };
2818
3160
  emit();
@@ -2941,6 +3283,34 @@ var SyntheticDataSource = class {
2941
3283
  if (this.timers.delete(timer)) clearInterval(timer);
2942
3284
  };
2943
3285
  }
3286
+ // A preview fleet's connectivity: three online, one offline (silent for hours) and one that never
3287
+ // reported, with no state row, so every state a presence widget draws is previewed. Last-active
3288
+ // times are offsets from now, so the preview always reads as just-seen for the online ones.
3289
+ subscribePresence(datasource, sink) {
3290
+ const members = datasource.kind === "device" ? [{ token: datasource.deviceToken, name: null }] : this.previewMembers(datasource);
3291
+ sink.roster({ members, total: members.length, unbound: datasource.kind === "unbound" });
3292
+ const emit = () => {
3293
+ const now = Date.now();
3294
+ const states = [];
3295
+ members.forEach((m, i) => {
3296
+ if (m.token === "synthetic-5") return;
3297
+ const offline = m.token === "synthetic-4";
3298
+ states.push({
3299
+ deviceToken: m.token,
3300
+ active: !offline,
3301
+ lastActivityTime: new Date(now - (offline ? 2 * 36e5 : (i + 1) * 2e4)).toISOString(),
3302
+ inactivityTimeout: 600
3303
+ });
3304
+ });
3305
+ sink.next(states);
3306
+ };
3307
+ emit();
3308
+ const timer = setInterval(emit, this.intervalMs);
3309
+ this.timers.add(timer);
3310
+ return () => {
3311
+ if (this.timers.delete(timer)) clearInterval(timer);
3312
+ };
3313
+ }
2944
3314
  // A preview has no history to read: its members report the moment they are subscribed, so the
2945
3315
  // seed settles empty and the live feed alone fills the cells.
2946
3316
  async readMemberSeeds(deviceTokens, _plan, onSeed) {
@@ -3048,7 +3418,7 @@ var HEX64 = /^[0-9a-f]{64}$/;
3048
3418
  var HEX40 = /^[0-9a-f]{40}$/;
3049
3419
  var HEX_SHORT = /^[0-9a-f]{7,40}$/;
3050
3420
  var INSTANT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?Z$/;
3051
- function instant(v, path) {
3421
+ function instant2(v, path) {
3052
3422
  const s = str(v, path);
3053
3423
  const m = INSTANT.exec(s);
3054
3424
  if (m) {
@@ -3109,7 +3479,7 @@ function parseBoardRecording(input) {
3109
3479
  "alarms",
3110
3480
  "locations"
3111
3481
  ],
3112
- ["excerpt"]
3482
+ ["excerpt", "declaredMetrics"]
3113
3483
  );
3114
3484
  if (root.kind !== RECORDING_KIND) {
3115
3485
  throw new RecordingFormatError("$.kind", `expected ${JSON.stringify(RECORDING_KIND)}`);
@@ -3145,6 +3515,22 @@ function parseBoardRecording(input) {
3145
3515
  if (tokens.size !== devices.length || new Set(devices.map((d) => d.id)).size !== devices.length) {
3146
3516
  throw new RecordingFormatError("$.devices", "duplicate device id or token");
3147
3517
  }
3518
+ let declaredMetrics;
3519
+ if (root.declaredMetrics !== void 0) {
3520
+ if (version === 1) throw new RecordingFormatError("$.declaredMetrics", "version 1 carries no declarations");
3521
+ const dm = root.declaredMetrics;
3522
+ if (!isObj(dm)) throw new RecordingFormatError("$.declaredMetrics", "expected an object");
3523
+ const kinds = new Set(devices.map((d) => d.kind));
3524
+ declaredMetrics = {};
3525
+ for (const kind of Object.keys(dm)) {
3526
+ const p = `$.declaredMetrics.${kind}`;
3527
+ if (!kinds.has(kind)) throw new RecordingFormatError(p, `no device in $.devices is of kind '${kind}'`);
3528
+ const names = column(dm[kind], p, str);
3529
+ if (names.length === 0) throw new RecordingFormatError(p, "declares nothing; leave the kind out instead");
3530
+ if (new Set(names).size !== names.length) throw new RecordingFormatError(p, "duplicate measurement name");
3531
+ declaredMetrics[kind] = names;
3532
+ }
3533
+ }
3148
3534
  const ch = strict(root.channels, "$.channels", ["measurements", "alarms", "locations", "commands"]);
3149
3535
  if (version === 1 && ch.locations !== false) {
3150
3536
  throw new RecordingFormatError("$.channels.locations", "version 1 records no positions; must be false");
@@ -3245,7 +3631,7 @@ function parseBoardRecording(input) {
3245
3631
  metric: str(o.metric, `${p}.metric`),
3246
3632
  state: oneOf2(o.state, `${p}.state`, ALARM_STATES),
3247
3633
  sev: oneOf2(o.sev, `${p}.sev`, ALARM_SEVERITIES),
3248
- occ: instant(o.occ, `${p}.occ`)
3634
+ occ: instant2(o.occ, `${p}.occ`)
3249
3635
  };
3250
3636
  if (o.ack === true) out.ack = true;
3251
3637
  if (typeof o.val === "number") out.val = o.val;
@@ -3330,7 +3716,7 @@ function parseBoardRecording(input) {
3330
3716
  kind: RECORDING_KIND,
3331
3717
  formatVersion: version,
3332
3718
  runId: str(root.runId, "$.runId"),
3333
- startedAtUtc: instant(root.startedAtUtc, "$.startedAtUtc"),
3719
+ startedAtUtc: instant2(root.startedAtUtc, "$.startedAtUtc"),
3334
3720
  durationMs,
3335
3721
  platformVersion: str(root.platformVersion, "$.platformVersion"),
3336
3722
  instance: str(root.instance, "$.instance"),
@@ -3348,6 +3734,7 @@ function parseBoardRecording(input) {
3348
3734
  locations
3349
3735
  };
3350
3736
  if (excerpt) rec.excerpt = excerpt;
3737
+ if (declaredMetrics) rec.declaredMetrics = declaredMetrics;
3351
3738
  return rec;
3352
3739
  }
3353
3740
 
@@ -3597,6 +3984,7 @@ var RecordedDataSource = class {
3597
3984
  this.shiftMs = shift;
3598
3985
  this.startMs = Date.parse(rec.startedAtUtc);
3599
3986
  this.devices = new Set(rec.devices.map((d) => d.token));
3987
+ this.kinds = new Map(rec.devices.map((d) => [d.token, d.kind]));
3600
3988
  this.unlisten = clock.subscribe((ev) => this.onClock(ev));
3601
3989
  }
3602
3990
  // True once the clock has been seeked since this view was built. A stale view emits
@@ -3629,9 +4017,9 @@ var RecordedDataSource = class {
3629
4017
  }
3630
4018
  // A recorded instant (an alarm's raise or clear time), moved by the shift. Unshifted, the
3631
4019
  // recording's own string is kept, digits and all.
3632
- shifted(instant2) {
3633
- if (instant2 === null || this.shiftMs === 0) return instant2;
3634
- return new Date(Date.parse(instant2) + this.shiftMs).toISOString();
4020
+ shifted(instant3) {
4021
+ if (instant3 === null || this.shiftMs === 0) return instant3;
4022
+ return new Date(Date.parse(instant3) + this.shiftMs).toISOString();
3635
4023
  }
3636
4024
  // open tracks a subscription and returns its disposer. `start` runs once, in a
3637
4025
  // microtask, so the disposer is in the caller's hands before anything is emitted (the
@@ -3758,10 +4146,13 @@ var RecordedDataSource = class {
3758
4146
  // other surface of the board (a gauge's subject line, the machine selector, an alarm's
3759
4147
  // originator) names a device by its token. Labelling the table's rows by external id made one
3760
4148
  // machine "SP-HL-0003" in the table and "sp-hauler-03" in the alarm beside it.
4149
+ //
4150
+ // A member's `deviceType` is its recorded KIND ("Hauler"), the key declaredMetrics answers by:
4151
+ // a recording names no device-type tokens, and nothing but the declarations reads it.
3761
4152
  subscribeMembers(datasource, sink) {
3762
4153
  let members;
3763
4154
  try {
3764
- members = this.resolve(datasource).map((g) => ({ token: g.deviceToken, name: null }));
4155
+ members = this.resolve(datasource).map((g) => ({ token: g.deviceToken, name: null, deviceType: this.kinds.get(g.deviceToken) }));
3765
4156
  } catch (err) {
3766
4157
  return this.failLater(err, sink);
3767
4158
  }
@@ -3779,6 +4170,14 @@ var RecordedDataSource = class {
3779
4170
  stop?.();
3780
4171
  };
3781
4172
  }
4173
+ // What a kind's profile declares, as the recording carries it (recording.ts, `declaredMetrics`),
4174
+ // or null when it does not say: a recording made before it carried declarations, or a kind it
4175
+ // has no entry for. Null is "unknown", which the fleet widgets read as declared -- never as
4176
+ // absent, so an old recording shows "no data in range" rather than a confident "does not report".
4177
+ async declaredMetrics(kind) {
4178
+ const declared = this.rec.declaredMetrics;
4179
+ return declared && Object.prototype.hasOwnProperty.call(declared, kind) ? declared[kind] : null;
4180
+ }
3782
4181
  // Each device's seed comes from the recording itself, with no read to make: its newest reading per
3783
4182
  // measurement inside the history window, and every measurement it reported in that window, which
3784
4183
  // is how "does not report this" (a dozer's tyres) is told from "sent nothing".
@@ -3809,6 +4208,9 @@ var RecordedDataSource = class {
3809
4208
  let scope = null;
3810
4209
  try {
3811
4210
  if (!this.rec.channels.alarms) throw new NotInRecordingError("alarms");
4211
+ if (subscription.deviceType !== void 0 || subscription.countByDeviceType) {
4212
+ throw new NotInRecordingError("alarms", "device types");
4213
+ }
3812
4214
  if (subscription.datasource) {
3813
4215
  scope = new Set(this.resolve(subscription.datasource).map((g) => g.deviceToken));
3814
4216
  }
@@ -3857,7 +4259,9 @@ var RecordedDataSource = class {
3857
4259
  }
3858
4260
  }
3859
4261
  const matches = [...alarms.values()].filter(
3860
- (a) => (scope === null || scope.has(a.dev)) && (!subscription.state || a.state === subscription.state) && (!subscription.severity || a.sev === subscription.severity) && (subscription.acknowledged == null || a.ack === subscription.acknowledged)
4262
+ (a) => (scope === null || scope.has(a.dev)) && (!subscription.state || a.state === subscription.state) && (!subscription.severity || a.sev === subscription.severity) && (subscription.acknowledged == null || a.ack === subscription.acknowledged) && // The range is applied before paging, as device-management applies it, so `total`
4263
+ // counts the range. Measured against the view's now(), in the view's (shifted) time.
4264
+ (!subscription.window || alarmOverlaps({ raisedTime: this.shifted(a.raised), clearedTime: this.shifted(a.cleared) }, subscription.window, this.now()))
3861
4265
  );
3862
4266
  const when = (a) => a.raised ?? a.cleared;
3863
4267
  const byTime = (a, b) => {
@@ -3867,6 +4271,16 @@ var RecordedDataSource = class {
3867
4271
  return compareInstants(wb, wa);
3868
4272
  };
3869
4273
  matches.sort((a, b) => byTime(a, b) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
4274
+ if (subscription.countBySeverity) {
4275
+ const bySeverity = /* @__PURE__ */ new Map();
4276
+ for (const a of matches) bySeverity.set(a.sev, (bySeverity.get(a.sev) ?? 0) + 1);
4277
+ const severityCounts = [...bySeverity].map(([severity, count]) => ({ severity, count }));
4278
+ const signature2 = JSON.stringify(severityCounts);
4279
+ if (signature2 === lastSignature || isDisposed()) return;
4280
+ lastSignature = signature2;
4281
+ sink.next({ alarms: [], total: matches.length, severityCounts });
4282
+ return;
4283
+ }
3870
4284
  const page = matches.slice(0, subscription.pageSize);
3871
4285
  const signature = JSON.stringify([matches.length, page]);
3872
4286
  if (signature === lastSignature || isDisposed()) return;
@@ -4717,6 +5131,7 @@ function duplicateWidget(def, id) {
4717
5131
  // src/resolver.ts
4718
5132
  import { gql as gql3 } from "@devicechain/client";
4719
5133
  var ANCHOR_PAGE_SIZE = 500;
5134
+ var DECLARED_BATCH_SIZE = 100;
4720
5135
  var trueSize = (reported, page) => Math.max(reported ?? page, page);
4721
5136
  function readAnchorPage(anchor) {
4722
5137
  if (anchor.targetType === "customer") {
@@ -4732,7 +5147,8 @@ function readAnchorPage(anchor) {
4732
5147
  return {
4733
5148
  tokens: rows.map((d) => d.token),
4734
5149
  total: trueSize(r.customerDevices.pagination?.totalRecords, rows.length),
4735
- names: new Map(rows.map((d) => [d.token, d.name]))
5150
+ names: new Map(rows.map((d) => [d.token, d.name])),
5151
+ types: new Map(rows.map((d) => [d.token, d.deviceType?.token ?? null]))
4736
5152
  };
4737
5153
  });
4738
5154
  }
@@ -4754,6 +5170,7 @@ function createDeviceResolver() {
4754
5170
  const anchorCache = /* @__PURE__ */ new Map();
4755
5171
  const memberCache = /* @__PURE__ */ new Map();
4756
5172
  const existsCache = /* @__PURE__ */ new Map();
5173
+ let declaredBatch = null;
4757
5174
  function anchorKey(anchor) {
4758
5175
  return `${anchor.relationship}|${anchor.targetType}|${anchor.targetToken}`;
4759
5176
  }
@@ -4781,16 +5198,20 @@ function createDeviceResolver() {
4781
5198
  const key = anchorKey(anchor);
4782
5199
  let pending = memberCache.get(key);
4783
5200
  if (!pending) {
4784
- pending = anchorPage(anchor).then(async ({ tokens, total, names: listed }) => {
5201
+ pending = anchorPage(anchor).then(async ({ tokens, total, names: listed, types: listedTypes }) => {
4785
5202
  const names = listed ?? /* @__PURE__ */ new Map();
5203
+ const types = listedTypes ?? /* @__PURE__ */ new Map();
4786
5204
  if (!listed && tokens.length > 0) {
4787
5205
  try {
4788
5206
  const r = await gql3("device-management", DEVICE_NAMES, { tokens });
4789
- for (const d of r.devicesByToken) names.set(d.token, d.name);
5207
+ for (const d of r.devicesByToken) {
5208
+ names.set(d.token, d.name);
5209
+ types.set(d.token, d.deviceType?.token ?? null);
5210
+ }
4790
5211
  } catch {
4791
5212
  }
4792
5213
  }
4793
- return { members: tokens.map((token) => ({ token, name: names.get(token) ?? null })), total };
5214
+ return { members: tokens.map((token) => ({ token, name: names.get(token) ?? null, deviceType: types.get(token) ?? null })), total };
4794
5215
  }).catch((err) => {
4795
5216
  memberCache.delete(key);
4796
5217
  throw err;
@@ -4799,6 +5220,43 @@ function createDeviceResolver() {
4799
5220
  }
4800
5221
  return pending;
4801
5222
  },
5223
+ // The names a device type's active profile version declares, or null when it has nothing to
5224
+ // say: no profile, never published, not found, or a published version that declares no metric
5225
+ // at all (an undeclared measurement is still accepted, so an empty declaration rules nothing
5226
+ // out). A failed read rejects: the caller treats that as unknown too, and does not cache it.
5227
+ // Every type asked in the same tick goes in ONE read, so a board of many types costs one
5228
+ // request rather than one per type.
5229
+ declaredMetrics(deviceType) {
5230
+ return new Promise((resolve, reject) => {
5231
+ let batch = declaredBatch;
5232
+ if (!batch) {
5233
+ batch = declaredBatch = /* @__PURE__ */ new Map();
5234
+ queueMicrotask(() => {
5235
+ const asked = declaredBatch;
5236
+ declaredBatch = null;
5237
+ const tokens = [...asked.keys()];
5238
+ for (let i = 0; i < tokens.length; i += DECLARED_BATCH_SIZE) {
5239
+ const chunk = tokens.slice(i, i + DECLARED_BATCH_SIZE);
5240
+ gql3("device-management", DECLARED_METRICS, { tokens: chunk }).then(
5241
+ (r) => {
5242
+ for (const token of chunk) {
5243
+ const version = r.deviceTypesByToken.find((t) => t.token === token)?.profile?.activeProfileVersion;
5244
+ const names = version && version.metrics.length > 0 ? version.metrics.map((m) => m.metricKey) : null;
5245
+ for (const w of asked.get(token)) w.resolve(names);
5246
+ }
5247
+ },
5248
+ (err) => {
5249
+ for (const token of chunk) for (const w of asked.get(token)) w.reject(err);
5250
+ }
5251
+ );
5252
+ }
5253
+ });
5254
+ }
5255
+ const waiting = batch.get(deviceType) ?? [];
5256
+ waiting.push({ resolve, reject });
5257
+ batch.set(deviceType, waiting);
5258
+ });
5259
+ },
4802
5260
  // deviceExists caches only a POSITIVE result (a live device is stable for the
4803
5261
  // session); a negative and an error both drop the entry so a later check re-queries.
4804
5262
  // ADR-042 frees a token on delete, so a deleted-then-recreated device must be able to
@@ -4869,7 +5327,7 @@ async function fetchWidgetHistory(widget, window2, options = {}) {
4869
5327
  }
4870
5328
 
4871
5329
  // src/option-schema.ts
4872
- var HOST_OPTION_CONTROLS = ["command-vocabulary", "slot-target", "basemap"];
5330
+ var HOST_OPTION_CONTROLS = ["command-vocabulary", "slot-target", "basemap", "device-type"];
4873
5331
  function hasOwn(obj, key) {
4874
5332
  return Object.prototype.hasOwnProperty.call(obj, key);
4875
5333
  }
@@ -5083,8 +5541,8 @@ var CHANNELS = ["measurement", "alarm", "control", "selection", "location", "agg
5083
5541
  var SEMVER2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
5084
5542
  var SELECTOR_KINDS = ["device", "anchor", "slot"];
5085
5543
  var BINDING_KEYS = {
5086
- measurement: ["window", "windowSeconds", "members"],
5087
- alarm: ["pageSize", "state", "severity", "acknowledged"],
5544
+ measurement: ["window", "windowSeconds", "members", "presence"],
5545
+ alarm: ["pageSize", "state", "severity", "acknowledged", "groupBy", "countBySeverity", "deviceType"],
5088
5546
  control: ["pageSize"],
5089
5547
  location: [],
5090
5548
  aggregate: ["windowSeconds"],
@@ -5138,7 +5596,7 @@ function collectDescriptorIssues(d, id) {
5138
5596
  }
5139
5597
  const channel = CHANNELS.find((c) => c === d.channel);
5140
5598
  if (!channel) add("channel", `channel must be one of ${CHANNELS.join(", ")}, got ${JSON.stringify(d.channel)}`);
5141
- const specs = checkOptions(d.options, add);
5599
+ const specs = checkOptions(d.options, !isBuiltinWidgetId(d.id), add);
5142
5600
  const constraints = checkConstraints(d.constraints, specs, add);
5143
5601
  if (specs) checkDefaults(d.defaults, specs, constraints, typeof d.id === "string" ? d.id : id, add);
5144
5602
  else if (!isRecord(d.defaults)) add("defaults", "defaults must be an object");
@@ -5187,14 +5645,14 @@ function jsonOnly(value, path, add, ancestors) {
5187
5645
  }
5188
5646
  ancestors.delete(obj);
5189
5647
  }
5190
- function checkOptions(options, add) {
5648
+ function checkOptions(options, requireDoc, add) {
5191
5649
  if (!isRecord(options)) {
5192
5650
  add("options", "options must be an object mapping option keys to specs");
5193
5651
  return void 0;
5194
5652
  }
5195
- return checkOptionsAt(options, "options.", add) ? options : void 0;
5653
+ return checkOptionsAt(options, "options.", requireDoc, add) ? options : void 0;
5196
5654
  }
5197
- function checkOptionsAt(options, prefix, add) {
5655
+ function checkOptionsAt(options, prefix, requireDoc, add) {
5198
5656
  let sound = true;
5199
5657
  for (const [key, spec] of Object.entries(options)) {
5200
5658
  const at = `${prefix}${key}`;
@@ -5203,7 +5661,7 @@ function checkOptionsAt(options, prefix, add) {
5203
5661
  sound = false;
5204
5662
  continue;
5205
5663
  }
5206
- if (typeof spec.doc !== "string") {
5664
+ if (typeof spec.doc !== "string" && (requireDoc || spec.doc !== void 0)) {
5207
5665
  add(`${at}.doc`, "doc must be a string");
5208
5666
  }
5209
5667
  for (const text of ["label", "help", "labelId", "helpId", "placeholder", "placeholderId"]) {
@@ -5245,6 +5703,9 @@ function checkOptionsAt(options, prefix, add) {
5245
5703
  if (spec.maxItems !== void 0 && (typeof spec.maxItems !== "number" || !Number.isInteger(spec.maxItems) || spec.maxItems < 1)) {
5246
5704
  add(`${at}.maxItems`, "maxItems must be a whole number of at least 1");
5247
5705
  }
5706
+ if (spec.ordered !== void 0 && typeof spec.ordered !== "boolean") {
5707
+ add(`${at}.ordered`, "ordered must be true or false");
5708
+ }
5248
5709
  if (!isRecord(spec.item) || Object.keys(spec.item).length === 0) {
5249
5710
  add(`${at}.item`, "a list option needs an item object declaring at least one field");
5250
5711
  sound = false;
@@ -5259,7 +5720,7 @@ function checkOptionsAt(options, prefix, add) {
5259
5720
  sound = false;
5260
5721
  break;
5261
5722
  }
5262
- if (!checkOptionsAt(flat, `${at}.item.`, add)) sound = false;
5723
+ if (!checkOptionsAt(flat, `${at}.item.`, requireDoc, add)) sound = false;
5263
5724
  break;
5264
5725
  }
5265
5726
  default:
@@ -5394,23 +5855,37 @@ function checkBinding(binding, channel, specs, add) {
5394
5855
  ref("pageSize", isNumber, "a number option", true);
5395
5856
  ref("state", isText, "a string or enum option", false);
5396
5857
  ref("severity", isText, "a string or enum option", false);
5858
+ ref("deviceType", isText, "a string or enum option", false);
5397
5859
  ref("acknowledged", isBoolEnum, 'an enum option whose values are only "true" and "false"', false);
5398
- const members = binding.members;
5399
- if (members !== void 0) {
5400
- const m = isRecord(members) ? members : {};
5860
+ const modeBinding = (key) => {
5861
+ const mode = binding[key];
5862
+ if (mode === void 0) return;
5863
+ const m = isRecord(mode) ? mode : {};
5864
+ const at = `binding.${key}`;
5865
+ const allowConst = key === "members";
5401
5866
  const isConst = m.const !== void 0;
5402
- if (isConst ? m.option !== void 0 || m.equals !== void 0 : typeof m.option !== "string" || typeof m.equals !== "string") {
5403
- add("binding.members", "members must be { const: true } or { option, equals } naming a declared option and a value of it");
5867
+ if (isConst ? !allowConst || m.option !== void 0 || m.equals !== void 0 : typeof m.option !== "string" || typeof m.equals !== "string") {
5868
+ add(at, `${key} must be ${allowConst ? "{ const: true } or " : ""}{ option, equals } naming a declared option and a value of it`);
5404
5869
  } else if (isConst) {
5405
- if (m.const !== true) add("binding.members.const", `const must be true, got ${JSON.stringify(m.const)}`);
5870
+ if (m.const !== true) add(`${at}.const`, `const must be true, got ${JSON.stringify(m.const)}`);
5406
5871
  } else if (specs) {
5407
5872
  const spec = hasOwn(specs, m.option) ? specs[m.option] : void 0;
5408
- if (!spec) add("binding.members.option", `option ${JSON.stringify(m.option)} is not a declared option key`);
5409
- else if (!isText(spec)) add("binding.members.option", `option ${JSON.stringify(m.option)} must be a string or enum option`);
5873
+ if (!spec) add(`${at}.option`, `option ${JSON.stringify(m.option)} is not a declared option key`);
5874
+ else if (!isText(spec)) add(`${at}.option`, `option ${JSON.stringify(m.option)} must be a string or enum option`);
5410
5875
  else if (spec.kind === "enum" && !spec.values.includes(m.equals)) {
5411
- add("binding.members.equals", `${JSON.stringify(m.equals)} is not one of the option's values`);
5876
+ add(`${at}.equals`, `${JSON.stringify(m.equals)} is not one of the option's values`);
5412
5877
  }
5413
5878
  }
5879
+ };
5880
+ const presence = binding.presence;
5881
+ if (presence !== void 0 && !(isRecord(presence) && presence.const === true && Object.keys(presence).length === 1)) {
5882
+ add("binding.presence", "must be { const: true }");
5883
+ }
5884
+ modeBinding("members");
5885
+ modeBinding("groupBy");
5886
+ const counts = binding.countBySeverity;
5887
+ if (counts !== void 0 && !(isRecord(counts) && counts.const === true && Object.keys(counts).length === 1)) {
5888
+ add("binding.countBySeverity", "must be { const: true }");
5414
5889
  }
5415
5890
  }
5416
5891
  function readSpan(value, path, add) {
@@ -5588,6 +6063,7 @@ function effectiveLayout(def, containerWidth, options = {}) {
5588
6063
  export {
5589
6064
  ANCHOR_PAGE_SIZE,
5590
6065
  BASE_BREAKPOINT,
6066
+ BIND_PARAM_PREFIX,
5591
6067
  CANVAS_DENSITIES,
5592
6068
  COMMAND_STATUSES,
5593
6069
  CURRENT_SCHEMA_VERSION,
@@ -5645,8 +6121,10 @@ export {
5645
6121
  applyOptionPatch,
5646
6122
  applySelection,
5647
6123
  basePlacement,
6124
+ bindParamsOf,
5648
6125
  bindWidgetSlot,
5649
6126
  bindingsWithoutScopedSlots,
6127
+ bindsToSelection,
5650
6128
  blockingLayoutFindings,
5651
6129
  breakpointColumns,
5652
6130
  breakpointMinWidth,
@@ -5679,6 +6157,7 @@ export {
5679
6157
  effectiveBindings,
5680
6158
  effectiveLayout,
5681
6159
  enumValues,
6160
+ eventWindowBounds,
5682
6161
  fetchWidgetHistory,
5683
6162
  firstFreeSlot,
5684
6163
  fitToLimits,
@@ -5711,6 +6190,7 @@ export {
5711
6190
  moveWidget,
5712
6191
  numberSpec,
5713
6192
  paddingPx,
6193
+ parseBindParams,
5714
6194
  parseBindingManifest,
5715
6195
  parseBoardRecording,
5716
6196
  parseDashboardDefinition,
@@ -5750,6 +6230,7 @@ export {
5750
6230
  sameLabel,
5751
6231
  sealHistory,
5752
6232
  seedBucketCount,
6233
+ selectionToBinds,
5753
6234
  serializeDefinition,
5754
6235
  serializeWidgetClipboard,
5755
6236
  setBoardTimeRange,
@@ -5772,11 +6253,13 @@ export {
5772
6253
  undoEdit,
5773
6254
  unresolvedWidgets,
5774
6255
  updateWidget,
6256
+ validateBinds,
5775
6257
  validateOptions,
5776
6258
  validateWidgetDescriptor,
5777
6259
  widgetBinding,
5778
6260
  widgetSlotName,
5779
6261
  widgetVersionNotice,
6262
+ withBindParams,
5780
6263
  withSpan,
5781
6264
  withoutConversionMark
5782
6265
  };