@devicechain/dashboards 0.24.0-rc.1 → 0.25.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,7 +124,11 @@ overlap, so there is no stacking order to manage.
124
124
 
125
125
  A board has a time range: `TIME_RANGE_PRESETS` (15 minutes to 7 days),
126
126
  `initialTimeRange`, `pauseRange` / `resumeRange` and `rangeWindow` manage it, and
127
- `timeRangeFromSearch` reads the `range` and `paused` URL parameters. Its density
127
+ `timeRangeLinkFromSearch` reads the `range`, `paused` and `from` / `to` URL parameters
128
+ (`timeRangeFromSearch` reads only `range` and `paused`, for a host that knows no fixed window). A fixed
129
+ from-to window is an additive variant of the same state: `absoluteRange` builds it and
130
+ `TimeRangeState.absolute` marks it, while `paused`, `pausedAt` and `spanSeconds` still describe the
131
+ window as a frozen rolling range, so code that only knows rolling ranges keeps working. Its density
128
132
  (`CANVAS_DENSITIES`: `compact`, `comfortable`, `wall`) scales every text and
129
133
  spacing size by `DENSITY_SCALE`; a host may override the board's own value at
130
134
  view time.
package/dist/hub.d.ts CHANGED
@@ -12,6 +12,7 @@ export interface DeviceResolver {
12
12
  total: number;
13
13
  }>;
14
14
  declaredMetrics?(deviceType: string): Promise<string[] | null>;
15
+ deviceType?(deviceToken: string): Promise<string | null>;
15
16
  }
16
17
  export interface WidgetStreamSink {
17
18
  next: (sample: MeasurementSample) => void;
@@ -98,6 +99,7 @@ export interface WidgetDataSource {
98
99
  subscribeMembers?(datasource: DatasourceSelector, sink: MemberSink): () => void;
99
100
  readMemberSeeds?(deviceTokens: readonly string[], plan: MemberSeedPlan, onSeed: (deviceToken: string, seed: MemberSeed | null, failure?: SeedFailure) => void, cancelled?: () => boolean): Promise<void>;
100
101
  declaredMetrics?(deviceType: string): Promise<readonly string[] | null>;
102
+ declaredMetricsForDevice?(deviceToken: string): Promise<readonly string[] | null>;
101
103
  subscribePresence?(datasource: DatasourceSelector, sink: PresenceSink): () => void;
102
104
  }
103
105
  export declare class DashboardHub implements WidgetDataSource, WidgetActions {
@@ -120,6 +122,7 @@ export declare class DashboardHub implements WidgetDataSource, WidgetActions {
120
122
  private resolveRoster;
121
123
  private readonly declared;
122
124
  declaredMetrics(deviceType: string): Promise<readonly string[] | null>;
125
+ declaredMetricsForDevice(deviceToken: string): Promise<readonly string[] | null>;
123
126
  private readonly seedLimiter;
124
127
  private readonly seedReads;
125
128
  readMemberSeeds(deviceTokens: readonly string[], plan: MemberSeedPlan, onSeed: (deviceToken: string, seed: MemberSeed | null, failure?: SeedFailure) => void, cancelled?: () => boolean): Promise<void>;
package/dist/index.d.ts CHANGED
@@ -23,7 +23,7 @@ export type { MeasurementBucket } from './queries';
23
23
  export { ANCHOR_PAGE_SIZE } from './resolver';
24
24
  export type { MemberBucketCriteria, MemberBucketRead, MemberRef, MemberRoster, MemberSeed, MemberSeedPlan, MemberSink, PresenceSink, SeedFailure, } from './members';
25
25
  export { defaultHistoryWindow, fetchWidgetHistory, historyWindowFor, type HistoryWindow } from './history';
26
- export { DEFAULT_TIME_RANGE_SECONDS, MAX_SEED_BUCKETS, MAX_TIME_RANGE_SECONDS, MIN_TIME_RANGE_SECONDS, TIME_RANGE_PRESETS, bucketSecondsFor, initialTimeRange, isValidSpanSeconds, parsePausedParam, parseRangeParam, pauseRange, presetIdOf, rangeEnd, rangeWindow, resumeRange, seedBucketCount, setBoardTimeRange, spanLabel, timeRangeFromSearch, withSpan, type RangeWindow, type TimeRangePresetId, type TimeRangeState, } from './time-range';
26
+ export { ABSOLUTE_FUTURE_SKEW_MS, DEFAULT_TIME_RANGE_SECONDS, EARLIEST_ABSOLUTE_MS, MAX_ABSOLUTE_SPAN_SECONDS, MIN_ABSOLUTE_SPAN_SECONDS, MAX_SEED_BUCKETS, MAX_TIME_RANGE_SECONDS, MIN_TIME_RANGE_SECONDS, TIME_RANGE_PRESETS, absoluteRange, absoluteRangeProblem, absoluteToParams, bucketSecondsFor, isAbsolute, leaveAbsolute, parseAbsoluteParams, parseInstantParam, initialTimeRange, isValidSpanSeconds, parsePausedParam, parseRangeParam, pauseRange, presetIdOf, rangeEnd, rangeWindow, resumeRange, seedBucketCount, setBoardTimeRange, spanLabel, timeRangeFromSearch, timeRangeLinkFromSearch, withSpan, type AbsoluteLinkRejection, type AbsoluteRange, type AbsoluteRangeProblem, type RangeWindow, type TimeRangeLink, type TimeRangePresetId, type TimeRangeState, } from './time-range';
27
27
  export type { AggregateResult, AggregateSpan, AggregateTrendPoint, AggregateStreamState, AlarmStreamState, AlarmTypeTab, AlarmTypeTabs, CommandStreamState, FleetMember, FleetState, MemberPresence, LocationStreamState, MeasurementAggregate, MeasurementStreamState, } from './channel-snapshots';
28
28
  export { applyOptionPatch, clampNumberOption, enumValues, numberSpec, validateOptions, HOST_OPTION_CONTROLS, type HostOptionControl, type BooleanOptionSpec, type DefaultsOf, type EnumOptionSpec, type ListItemFieldSpec, type ListOptionSpec, type GreaterThanConstraint, type NumberOptionSpec, type OptionConstraint, type OptionIssue, type OptionIssueCode, type OptionKeysOfKind, type OptionSpec, type OptionsOf, type RequiredOptionKeys, type SpecValue, type StringOptionSpec, type ValidateOptionsContext, type WidgetOptionSpecs, } from './option-schema';
29
29
  export { SDK_API_VERSION, defineWidgetDescriptor, isBuiltinWidgetId, isNamespacedWidgetId, type AckOptionRef, type BuiltinWidgetId, type ChannelBindingPolicy, type ChannelSnapshot, type ConstBinding, type DatasourcePolicy, type DatasourcePolicyFor, type DatasourceSelectorKind, type InteractionsFor, type NamespacedWidgetId, type NumberOptionRef, type StringOptionRef, type WidgetChannel, type WidgetDescriptor, type WidgetDisplay, type WidgetId, type WidgetInteractionDeclaration, FALLBACK_DEFAULT_SPAN, pinnedAxes, type WidgetLayoutSpec, type WidgetResizeMode, type WidgetSpan, } from './widget-descriptor';
package/dist/index.js CHANGED
@@ -76,7 +76,8 @@ var WIDGET_TYPES = [
76
76
  "fleet-table",
77
77
  "fleet-count",
78
78
  "device-status",
79
- "online-doughnut"
79
+ "online-doughnut",
80
+ "history-table"
80
81
  ];
81
82
  var PLACEMENT_ANCHORS = [
82
83
  "top-left",
@@ -536,7 +537,41 @@ var MAX_TIME_RANGE_SECONDS = 7 * 24 * 60 * 60;
536
537
  function isValidSpanSeconds(v) {
537
538
  return typeof v === "number" && Number.isInteger(v) && v >= MIN_TIME_RANGE_SECONDS && v <= MAX_TIME_RANGE_SECONDS;
538
539
  }
540
+ var MIN_ABSOLUTE_SPAN_SECONDS = 60;
541
+ var MAX_ABSOLUTE_SPAN_SECONDS = 31 * 24 * 60 * 60;
542
+ var EARLIEST_ABSOLUTE_MS = Date.UTC(2e3, 0, 1);
543
+ var ABSOLUTE_FUTURE_SKEW_MS = 60 * 1e3;
544
+ function absoluteRangeProblem(fromMs, toMs, now) {
545
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return "unparseable";
546
+ if (fromMs >= toMs) return "order";
547
+ if (fromMs < EARLIEST_ABSOLUTE_MS) return "tooEarly";
548
+ if (toMs > now + ABSOLUTE_FUTURE_SKEW_MS) return "future";
549
+ const seconds = (Math.floor(toMs / 1e3) * 1e3 - Math.floor(fromMs / 1e3) * 1e3) / 1e3;
550
+ if (seconds < MIN_ABSOLUTE_SPAN_SECONDS) return "tooShort";
551
+ if (seconds > MAX_ABSOLUTE_SPAN_SECONDS) return "tooLong";
552
+ return void 0;
553
+ }
554
+ function isAbsolute(state) {
555
+ return state.absolute !== void 0;
556
+ }
557
+ function absoluteRange(fromMs, toMs, now = Date.now()) {
558
+ const problem = absoluteRangeProblem(fromMs, toMs, now);
559
+ if (problem) return { problem };
560
+ const from = Math.floor(fromMs / 1e3) * 1e3;
561
+ const to = Math.floor(toMs / 1e3) * 1e3;
562
+ return {
563
+ state: { spanSeconds: (to - from) / 1e3, paused: true, pausedAt: to, absolute: { fromMs: from, toMs: to } }
564
+ };
565
+ }
566
+ function leaveAbsolute(state) {
567
+ if (!state.absolute) return state;
568
+ return { spanSeconds: Math.min(state.spanSeconds, MAX_TIME_RANGE_SECONDS), paused: false };
569
+ }
539
570
  function initialTimeRange(defaultSeconds, link = {}, now = Date.now()) {
571
+ if (link.absolute) {
572
+ const made = absoluteRange(link.absolute.fromMs, link.absolute.toMs, now);
573
+ if ("state" in made) return made.state;
574
+ }
540
575
  const spanSeconds = link.spanSeconds ?? (isValidSpanSeconds(defaultSeconds) ? defaultSeconds : DEFAULT_TIME_RANGE_SECONDS);
541
576
  return link.paused ? { spanSeconds, paused: true, pausedAt: now } : { spanSeconds, paused: false };
542
577
  }
@@ -544,9 +579,11 @@ function pauseRange(state, now) {
544
579
  return state.paused ? state : { spanSeconds: state.spanSeconds, paused: true, pausedAt: now };
545
580
  }
546
581
  function resumeRange(state) {
582
+ if (state.absolute) return leaveAbsolute(state);
547
583
  return state.paused ? { spanSeconds: state.spanSeconds, paused: false } : state;
548
584
  }
549
585
  function withSpan(state, spanSeconds) {
586
+ if (state.absolute) return state;
550
587
  return isValidSpanSeconds(spanSeconds) ? { ...state, spanSeconds } : state;
551
588
  }
552
589
  function rangeEnd(state, now) {
@@ -575,6 +612,26 @@ function parseRangeParam(value) {
575
612
  function parsePausedParam(value) {
576
613
  return value === "1";
577
614
  }
615
+ var RFC3339_WITH_ZONE = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?([Zz]|[+-]\d{2}:\d{2})$/;
616
+ function parseInstantParam(value) {
617
+ if (!value || !RFC3339_WITH_ZONE.test(value)) return void 0;
618
+ const ms = Date.parse(value);
619
+ return Number.isNaN(ms) ? void 0 : ms;
620
+ }
621
+ function parseAbsoluteParams(from, to, now = Date.now()) {
622
+ if (from == null && to == null) return {};
623
+ const raw = { from: from ?? "", to: to ?? "" };
624
+ if (!from || !to) return { rejected: { reason: "incomplete", ...raw } };
625
+ const fromMs = parseInstantParam(from);
626
+ const toMs = parseInstantParam(to);
627
+ if (fromMs === void 0 || toMs === void 0) return { rejected: { reason: "unparseable", ...raw } };
628
+ const made = absoluteRange(fromMs, toMs, now);
629
+ if ("problem" in made) return { rejected: { reason: made.problem, ...raw } };
630
+ return { absolute: made.state.absolute };
631
+ }
632
+ function absoluteToParams(a) {
633
+ return { from: new Date(a.fromMs).toISOString(), to: new Date(a.toMs).toISOString() };
634
+ }
578
635
  function timeRangeFromSearch(search) {
579
636
  let params;
580
637
  try {
@@ -588,6 +645,19 @@ function timeRangeFromSearch(search) {
588
645
  if (parsePausedParam(params.get("paused"))) link.paused = true;
589
646
  return link;
590
647
  }
648
+ function timeRangeLinkFromSearch(search, now = Date.now()) {
649
+ let params;
650
+ try {
651
+ params = new URLSearchParams(search);
652
+ } catch {
653
+ return {};
654
+ }
655
+ const abs = parseAbsoluteParams(params.get("from"), params.get("to"), now);
656
+ if (abs.absolute) return { absolute: abs.absolute };
657
+ const link = timeRangeFromSearch(search);
658
+ if (abs.rejected) link.rejected = abs.rejected;
659
+ return link;
660
+ }
591
661
  function presetIdOf(spanSeconds) {
592
662
  return TIME_RANGE_PRESETS.find((p) => p.seconds === spanSeconds)?.id;
593
663
  }
@@ -2309,6 +2379,16 @@ var DashboardHub = class {
2309
2379
  }
2310
2380
  return pending;
2311
2381
  }
2382
+ async declaredMetricsForDevice(deviceToken) {
2383
+ const typeOf = this.resolver.deviceType;
2384
+ if (!typeOf) return null;
2385
+ try {
2386
+ const type = await typeOf.call(this.resolver, deviceToken);
2387
+ return type ? await this.declaredMetrics(type) : null;
2388
+ } catch {
2389
+ return null;
2390
+ }
2391
+ }
2312
2392
  async readMemberSeeds(deviceTokens, plan, onSeed, cancelled = () => false) {
2313
2393
  await Promise.all(
2314
2394
  deviceTokens.map(async (token) => {
@@ -2971,6 +3051,7 @@ var DashboardHub = class {
2971
3051
  // src/synthetic.ts
2972
3052
  var SYNTHETIC_GENERATORS = ["sine", "ramp", "random-walk"];
2973
3053
  var DEFAULT_NAME = "value";
3054
+ var MAX_PREVIEW_BUCKETS = 500;
2974
3055
  var SYNTHETIC_DEVICE_TYPES = {
2975
3056
  "thermostat-01": { token: "thermostat", name: "Thermostat" },
2976
3057
  "sensor-14": { token: "sensor", name: "Sensor" },
@@ -3232,6 +3313,22 @@ var SyntheticDataSource = class {
3232
3313
  const names = datasource.measurements.length > 0 ? datasource.measurements : [DEFAULT_NAME];
3233
3314
  const from = Date.parse(window2.startTime);
3234
3315
  const to = Date.parse(window2.endTime);
3316
+ const width = window2.intervalSeconds * 1e3;
3317
+ if (width > 0 && width < to - from) {
3318
+ const aligned = Math.floor(from / width) * width;
3319
+ const total = Math.ceil((to - aligned) / width);
3320
+ const count = Math.min(total, MAX_PREVIEW_BUCKETS);
3321
+ const first = aligned + (total - count) * width;
3322
+ return names.flatMap((name) => {
3323
+ const walk = /* @__PURE__ */ new Map();
3324
+ return Array.from({ length: count }, (_, i) => {
3325
+ const start = first + i * width;
3326
+ const values = Array.from({ length: 6 }, (_v, j) => this.valueFor(name, start + width * j / 6, walk));
3327
+ const sum = values.reduce((a, v) => a + v, 0);
3328
+ return { bucketStart: new Date(start).toISOString(), name, avg: sum / values.length, min: Math.min(...values), max: Math.max(...values), sum, count: values.length };
3329
+ });
3330
+ });
3331
+ }
3235
3332
  return names.map((name) => {
3236
3333
  const walk = /* @__PURE__ */ new Map();
3237
3334
  const values = Array.from({ length: 120 }, (_, i) => this.valueFor(name, from + (to - from) * i / 119, walk));
@@ -4178,6 +4275,11 @@ var RecordedDataSource = class {
4178
4275
  const declared = this.rec.declaredMetrics;
4179
4276
  return declared && Object.prototype.hasOwnProperty.call(declared, kind) ? declared[kind] : null;
4180
4277
  }
4278
+ // The same for one device, through its recorded kind.
4279
+ async declaredMetricsForDevice(deviceToken) {
4280
+ const kind = this.kinds.get(deviceToken);
4281
+ return kind === void 0 ? null : this.declaredMetrics(kind);
4282
+ }
4181
4283
  // Each device's seed comes from the recording itself, with no read to make: its newest reading per
4182
4284
  // measurement inside the history window, and every measurement it reported in that window, which
4183
4285
  // is how "does not report this" (a dozer's tyres) is told from "sent nothing".
@@ -5170,6 +5272,7 @@ function createDeviceResolver() {
5170
5272
  const anchorCache = /* @__PURE__ */ new Map();
5171
5273
  const memberCache = /* @__PURE__ */ new Map();
5172
5274
  const existsCache = /* @__PURE__ */ new Map();
5275
+ const typeCache = /* @__PURE__ */ new Map();
5173
5276
  let declaredBatch = null;
5174
5277
  function anchorKey(anchor) {
5175
5278
  return `${anchor.relationship}|${anchor.targetType}|${anchor.targetToken}`;
@@ -5257,6 +5360,19 @@ function createDeviceResolver() {
5257
5360
  batch.set(deviceType, waiting);
5258
5361
  });
5259
5362
  },
5363
+ // The device type's token, from the same names read the roster uses. A failed read rejects (the
5364
+ // caller treats that as unknown) and is not cached.
5365
+ deviceType(deviceToken) {
5366
+ let pending = typeCache.get(deviceToken);
5367
+ if (!pending) {
5368
+ pending = gql3("device-management", DEVICE_NAMES, { tokens: [deviceToken] }).then((r) => r.devicesByToken.find((d) => d.token === deviceToken)?.deviceType?.token ?? null).catch((err) => {
5369
+ typeCache.delete(deviceToken);
5370
+ throw err;
5371
+ });
5372
+ typeCache.set(deviceToken, pending);
5373
+ }
5374
+ return pending;
5375
+ },
5260
5376
  // deviceExists caches only a POSITIVE result (a live device is stable for the
5261
5377
  // session); a negative and an error both drop the entry so a later check re-queries.
5262
5378
  // ADR-042 frees a token on delete, so a deleted-then-recreated device must be able to
@@ -5318,7 +5434,8 @@ async function fetchWidgetHistory(widget, window2, options = {}) {
5318
5434
  occurredTime: b.bucketStart,
5319
5435
  name: b.name,
5320
5436
  value: b.avg,
5321
- classifier: null
5437
+ classifier: null,
5438
+ bucketSeconds: window2.intervalSeconds
5322
5439
  })).sort((a, b) => a.occurredTime < b.occurredTime ? -1 : 1);
5323
5440
  } catch (err) {
5324
5441
  if (options.rethrow) throw err;
@@ -5541,7 +5658,7 @@ var CHANNELS = ["measurement", "alarm", "control", "selection", "location", "agg
5541
5658
  var SEMVER2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
5542
5659
  var SELECTOR_KINDS = ["device", "anchor", "slot"];
5543
5660
  var BINDING_KEYS = {
5544
- measurement: ["window", "windowSeconds", "members", "presence"],
5661
+ measurement: ["window", "windowSeconds", "members", "presence", "history"],
5545
5662
  alarm: ["pageSize", "state", "severity", "acknowledged", "groupBy", "countBySeverity", "deviceType"],
5546
5663
  control: ["pageSize"],
5547
5664
  location: [],
@@ -5877,9 +5994,11 @@ function checkBinding(binding, channel, specs, add) {
5877
5994
  }
5878
5995
  }
5879
5996
  };
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 }");
5997
+ for (const key of ["presence", "history"]) {
5998
+ const flag = binding[key];
5999
+ if (flag !== void 0 && !(isRecord(flag) && flag.const === true && Object.keys(flag).length === 1)) {
6000
+ add(`binding.${key}`, "must be { const: true }");
6001
+ }
5883
6002
  }
5884
6003
  modeBinding("members");
5885
6004
  modeBinding("groupBy");
@@ -6061,6 +6180,7 @@ function effectiveLayout(def, containerWidth, options = {}) {
6061
6180
  return { ...shape, grid };
6062
6181
  }
6063
6182
  export {
6183
+ ABSOLUTE_FUTURE_SKEW_MS,
6064
6184
  ANCHOR_PAGE_SIZE,
6065
6185
  BASE_BREAKPOINT,
6066
6186
  BIND_PARAM_PREFIX,
@@ -6075,6 +6195,7 @@ export {
6075
6195
  DENSITY_SCALE,
6076
6196
  DashboardDefinitionError,
6077
6197
  DashboardHub,
6198
+ EARLIEST_ABSOLUTE_MS,
6078
6199
  FALLBACK_DEFAULT_SPAN,
6079
6200
  FRAME_BORDER_WIDTHS,
6080
6201
  FRAME_HEADERS,
@@ -6082,8 +6203,10 @@ export {
6082
6203
  FRAME_VARIANTS,
6083
6204
  HOST_OPTION_CONTROLS,
6084
6205
  LEGACY_REFERENCE_WIDTH,
6206
+ MAX_ABSOLUTE_SPAN_SECONDS,
6085
6207
  MAX_SEED_BUCKETS,
6086
6208
  MAX_TIME_RANGE_SECONDS,
6209
+ MIN_ABSOLUTE_SPAN_SECONDS,
6087
6210
  MIN_TIME_RANGE_SECONDS,
6088
6211
  NotInRecordingError,
6089
6212
  OFFLINE_AFTER_MS,
@@ -6114,6 +6237,9 @@ export {
6114
6237
  WIDGET_CLIPBOARD_VERSION,
6115
6238
  WIDGET_TYPES,
6116
6239
  WidgetCatalogError,
6240
+ absoluteRange,
6241
+ absoluteRangeProblem,
6242
+ absoluteToParams,
6117
6243
  activeBreakpoint,
6118
6244
  addWidget,
6119
6245
  anchorFraction,
@@ -6171,6 +6297,7 @@ export {
6171
6297
  hasScopedSlots,
6172
6298
  historyWindowFor,
6173
6299
  initialTimeRange,
6300
+ isAbsolute,
6174
6301
  isBuiltinWidget,
6175
6302
  isBuiltinWidgetId,
6176
6303
  isCancellableCommandStatus,
@@ -6184,16 +6311,19 @@ export {
6184
6311
  layoutBreakpoints,
6185
6312
  layoutRects,
6186
6313
  layoutShape,
6314
+ leaveAbsolute,
6187
6315
  lintLayout,
6188
6316
  liveColumns,
6189
6317
  migrateToSlots,
6190
6318
  moveWidget,
6191
6319
  numberSpec,
6192
6320
  paddingPx,
6321
+ parseAbsoluteParams,
6193
6322
  parseBindParams,
6194
6323
  parseBindingManifest,
6195
6324
  parseBoardRecording,
6196
6325
  parseDashboardDefinition,
6326
+ parseInstantParam,
6197
6327
  parsePausedParam,
6198
6328
  parseRangeParam,
6199
6329
  parseSlotBinding,
@@ -6250,6 +6380,7 @@ export {
6250
6380
  storedDefinition,
6251
6381
  stripDefaultBindings,
6252
6382
  timeRangeFromSearch,
6383
+ timeRangeLinkFromSearch,
6253
6384
  undoEdit,
6254
6385
  unresolvedWidgets,
6255
6386
  updateWidget,