@devicechain/dashboards 0.23.0 → 0.24.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/channel-snapshots.d.ts +34 -1
- package/dist/deep-link.d.ts +45 -0
- package/dist/event-window.d.ts +15 -0
- package/dist/hub.d.ts +21 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.js +536 -53
- package/dist/index.js.map +4 -4
- package/dist/internal/alarm-doc.d.ts +42 -0
- package/dist/internal/command-doc.d.ts +2 -0
- package/dist/internal/presence-doc.d.ts +9 -0
- package/dist/members.d.ts +8 -1
- package/dist/option-schema.d.ts +2 -1
- package/dist/queries.d.ts +22 -0
- package/dist/recorded.d.ts +2 -0
- package/dist/recording.d.ts +2 -0
- package/dist/synthetic.d.ts +2 -1
- package/dist/types.d.ts +21 -1
- package/dist/widget-descriptor.d.ts +6 -0
- package/package.json +4 -4
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
|
|
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
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
2815
|
-
total:
|
|
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
|
|
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:
|
|
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:
|
|
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(
|
|
3633
|
-
if (
|
|
3634
|
-
return new Date(Date.parse(
|
|
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)
|
|
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
|
|
5399
|
-
|
|
5400
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
5409
|
-
else if (!isText(spec)) add(
|
|
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(
|
|
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
|
};
|