@devicechain/widgets 0.14.0-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +159 -0
- package/dist/basemap-context.d.ts +27 -0
- package/dist/chart-options.d.ts +14 -0
- package/dist/chunks/natural-earth-data-VMKQ6LY3.js +8 -0
- package/dist/chunks/natural-earth-data-VMKQ6LY3.js.map +7 -0
- package/dist/connected-widget.d.ts +8 -0
- package/dist/dashboard-renderer.d.ts +17 -0
- package/dist/definition-options.d.ts +9 -0
- package/dist/echart.d.ts +8 -0
- package/dist/flash.d.ts +13 -0
- package/dist/format.d.ts +3 -0
- package/dist/frame.d.ts +25 -0
- package/dist/hooks.d.ts +54 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +2671 -0
- package/dist/index.js.map +7 -0
- package/dist/map-runtime-context.d.ts +91 -0
- package/dist/options.d.ts +312 -0
- package/dist/registry.d.ts +44 -0
- package/dist/theme.d.ts +9 -0
- package/dist/vite.d.ts +19 -0
- package/dist/vite.js +10 -0
- package/dist/vite.js.map +7 -0
- package/dist/widget.d.ts +16 -0
- package/dist/widgets/alarm-count.d.ts +3 -0
- package/dist/widgets/alarm-table.d.ts +3 -0
- package/dist/widgets/command-button.d.ts +3 -0
- package/dist/widgets/command-params.d.ts +7 -0
- package/dist/widgets/command-status.d.ts +3 -0
- package/dist/widgets/entity-selector.d.ts +2 -0
- package/dist/widgets/gauge.d.ts +2 -0
- package/dist/widgets/image.d.ts +2 -0
- package/dist/widgets/label.d.ts +2 -0
- package/dist/widgets/latest-card.d.ts +2 -0
- package/dist/widgets/map-geometry.d.ts +17 -0
- package/dist/widgets/map.d.ts +3 -0
- package/dist/widgets/natural-earth-data.d.ts +5 -0
- package/dist/widgets/severity.d.ts +4 -0
- package/dist/widgets/table.d.ts +2 -0
- package/dist/widgets/time-series-chart.d.ts +2 -0
- package/package.json +77 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2671 @@
|
|
|
1
|
+
// src/dashboard-renderer.tsx
|
|
2
|
+
import {
|
|
3
|
+
activeBreakpoint,
|
|
4
|
+
defaultHistoryWindow,
|
|
5
|
+
fetchWidgetHistory,
|
|
6
|
+
resolveWidgetBox
|
|
7
|
+
} from "@devicechain/dashboards";
|
|
8
|
+
import { useEffect as useEffect7, useState as useState7 } from "react";
|
|
9
|
+
|
|
10
|
+
// src/frame.tsx
|
|
11
|
+
import { createContext, useContext } from "react";
|
|
12
|
+
|
|
13
|
+
// src/theme.ts
|
|
14
|
+
function css(name) {
|
|
15
|
+
return `hsl(var(--${name}))`;
|
|
16
|
+
}
|
|
17
|
+
var FALLBACK = {
|
|
18
|
+
foreground: "hsl(240 10% 3.9%)",
|
|
19
|
+
mutedForeground: "hsl(240 3.8% 46.1%)",
|
|
20
|
+
border: "hsl(240 5.9% 90%)",
|
|
21
|
+
series: [
|
|
22
|
+
"hsl(220 70% 50%)",
|
|
23
|
+
"hsl(160 60% 45%)",
|
|
24
|
+
"hsl(30 80% 55%)",
|
|
25
|
+
"hsl(280 65% 60%)",
|
|
26
|
+
"hsl(340 75% 55%)"
|
|
27
|
+
]
|
|
28
|
+
};
|
|
29
|
+
function readVar(style, name, fallback) {
|
|
30
|
+
const raw = style.getPropertyValue(`--${name}`).trim();
|
|
31
|
+
return raw ? `hsl(${raw})` : fallback;
|
|
32
|
+
}
|
|
33
|
+
function resolveChartTheme(el) {
|
|
34
|
+
const style = getComputedStyle(el);
|
|
35
|
+
return {
|
|
36
|
+
foreground: readVar(style, "foreground", FALLBACK.foreground),
|
|
37
|
+
mutedForeground: readVar(style, "muted-foreground", FALLBACK.mutedForeground),
|
|
38
|
+
border: readVar(style, "border", FALLBACK.border),
|
|
39
|
+
series: FALLBACK.series.map((fallback, i) => readVar(style, `chart-${i + 1}`, fallback))
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/frame.tsx
|
|
44
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
45
|
+
var WidgetSubjectContext = createContext(void 0);
|
|
46
|
+
function WidgetSubjectProvider({
|
|
47
|
+
label,
|
|
48
|
+
children
|
|
49
|
+
}) {
|
|
50
|
+
return /* @__PURE__ */ jsx(WidgetSubjectContext.Provider, { value: label, children });
|
|
51
|
+
}
|
|
52
|
+
var WidgetSelectContext = createContext(void 0);
|
|
53
|
+
function WidgetSelectProvider({
|
|
54
|
+
select,
|
|
55
|
+
children
|
|
56
|
+
}) {
|
|
57
|
+
return /* @__PURE__ */ jsx(WidgetSelectContext.Provider, { value: select, children });
|
|
58
|
+
}
|
|
59
|
+
function useWidgetSelect() {
|
|
60
|
+
return useContext(WidgetSelectContext);
|
|
61
|
+
}
|
|
62
|
+
var WidgetCandidatesContext = createContext(void 0);
|
|
63
|
+
function WidgetCandidatesProvider({
|
|
64
|
+
candidates,
|
|
65
|
+
children
|
|
66
|
+
}) {
|
|
67
|
+
return /* @__PURE__ */ jsx(WidgetCandidatesContext.Provider, { value: candidates, children });
|
|
68
|
+
}
|
|
69
|
+
function useWidgetCandidates() {
|
|
70
|
+
return useContext(WidgetCandidatesContext);
|
|
71
|
+
}
|
|
72
|
+
function WidgetFrame({ title, subtitle, children, bodyStyle }) {
|
|
73
|
+
const subject = useContext(WidgetSubjectContext);
|
|
74
|
+
const sub = subtitle ?? subject;
|
|
75
|
+
return /* @__PURE__ */ jsxs(
|
|
76
|
+
"div",
|
|
77
|
+
{
|
|
78
|
+
style: {
|
|
79
|
+
display: "flex",
|
|
80
|
+
flexDirection: "column",
|
|
81
|
+
width: "100%",
|
|
82
|
+
height: "100%",
|
|
83
|
+
boxSizing: "border-box",
|
|
84
|
+
background: css("card"),
|
|
85
|
+
color: css("card-foreground"),
|
|
86
|
+
border: `1px solid ${css("border")}`,
|
|
87
|
+
borderRadius: "var(--radius, 8px)",
|
|
88
|
+
overflow: "hidden"
|
|
89
|
+
},
|
|
90
|
+
children: [
|
|
91
|
+
title || sub ? /* @__PURE__ */ jsxs(
|
|
92
|
+
"div",
|
|
93
|
+
{
|
|
94
|
+
style: {
|
|
95
|
+
padding: "8px 12px",
|
|
96
|
+
borderBottom: `1px solid ${css("border")}`
|
|
97
|
+
},
|
|
98
|
+
children: [
|
|
99
|
+
title ? /* @__PURE__ */ jsx("div", { style: { fontSize: 12, fontWeight: 600, color: css("muted-foreground") }, children: title }) : null,
|
|
100
|
+
sub ? /* @__PURE__ */ jsx(
|
|
101
|
+
"div",
|
|
102
|
+
{
|
|
103
|
+
title: sub,
|
|
104
|
+
style: {
|
|
105
|
+
fontSize: 11,
|
|
106
|
+
fontWeight: 400,
|
|
107
|
+
color: css("muted-foreground"),
|
|
108
|
+
opacity: 0.75,
|
|
109
|
+
marginTop: title ? 2 : 0,
|
|
110
|
+
whiteSpace: "nowrap",
|
|
111
|
+
overflow: "hidden",
|
|
112
|
+
textOverflow: "ellipsis"
|
|
113
|
+
},
|
|
114
|
+
children: sub
|
|
115
|
+
}
|
|
116
|
+
) : null
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
) : null,
|
|
120
|
+
/* @__PURE__ */ jsx("div", { style: { flex: 1, minHeight: 0, ...bodyStyle }, children })
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/hooks.ts
|
|
127
|
+
import {
|
|
128
|
+
bindingsWithoutScopedSlots,
|
|
129
|
+
hasScopedSlots,
|
|
130
|
+
resolveContextBindings,
|
|
131
|
+
resolveSlotCandidates
|
|
132
|
+
} from "@devicechain/dashboards";
|
|
133
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
134
|
+
function useResolvedBindings(definition, base, selection, resolver) {
|
|
135
|
+
const scoped = useMemo(() => hasScopedSlots(definition), [definition]);
|
|
136
|
+
const overlaid = useMemo(() => ({ ...base, ...selection }), [base, selection]);
|
|
137
|
+
const [resolved, setResolved] = useState(() => bindingsWithoutScopedSlots(definition, overlaid));
|
|
138
|
+
const genRef = useRef(0);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!scoped) {
|
|
141
|
+
setResolved((prev) => sameBindings(prev, overlaid) ? prev : overlaid);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const gen = ++genRef.current;
|
|
145
|
+
let live = true;
|
|
146
|
+
resolveContextBindings(definition, base, selection, resolver).then((next) => {
|
|
147
|
+
if (!live || gen !== genRef.current) return;
|
|
148
|
+
setResolved((prev) => sameBindings(prev, next) ? prev : next);
|
|
149
|
+
}).catch(() => {
|
|
150
|
+
});
|
|
151
|
+
return () => {
|
|
152
|
+
live = false;
|
|
153
|
+
};
|
|
154
|
+
}, [scoped, definition, base, selection, resolver, overlaid]);
|
|
155
|
+
return scoped ? resolved : overlaid;
|
|
156
|
+
}
|
|
157
|
+
function sameBindings(a, b) {
|
|
158
|
+
const ak = Object.keys(a).sort();
|
|
159
|
+
const bk = Object.keys(b).sort();
|
|
160
|
+
if (ak.length !== bk.length) return false;
|
|
161
|
+
for (let i = 0; i < ak.length; i += 1) {
|
|
162
|
+
if (ak[i] !== bk[i]) return false;
|
|
163
|
+
if (JSON.stringify(a[ak[i]]) !== JSON.stringify(b[bk[i]])) return false;
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
function useSlotCandidates(definition, bindings, resolver, lister) {
|
|
168
|
+
const bindingsKey = JSON.stringify(bindings);
|
|
169
|
+
return useMemo(
|
|
170
|
+
() => {
|
|
171
|
+
if (!lister) return void 0;
|
|
172
|
+
return (slot) => resolveSlotCandidates(definition, slot, bindings, resolver, lister);
|
|
173
|
+
},
|
|
174
|
+
// bindings read via bindingsKey (value identity), not reference.
|
|
175
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
176
|
+
[definition, bindingsKey, resolver, lister]
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
function useCandidates(slot) {
|
|
180
|
+
const provider = useWidgetCandidates();
|
|
181
|
+
const wired = !!provider;
|
|
182
|
+
const [state, setState] = useState(() => ({
|
|
183
|
+
candidates: [],
|
|
184
|
+
loading: Boolean(provider && slot),
|
|
185
|
+
error: null
|
|
186
|
+
}));
|
|
187
|
+
const genRef = useRef(0);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!provider || !slot) {
|
|
190
|
+
setState({ candidates: [], loading: false, error: null });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const gen = ++genRef.current;
|
|
194
|
+
let live = true;
|
|
195
|
+
setState((prev) => ({ ...prev, loading: true, error: null }));
|
|
196
|
+
provider(slot).then((candidates) => {
|
|
197
|
+
if (live && gen === genRef.current) setState({ candidates, loading: false, error: null });
|
|
198
|
+
}).catch((error) => {
|
|
199
|
+
if (live && gen === genRef.current) setState({ candidates: [], loading: false, error });
|
|
200
|
+
});
|
|
201
|
+
return () => {
|
|
202
|
+
live = false;
|
|
203
|
+
};
|
|
204
|
+
}, [provider, slot]);
|
|
205
|
+
return { ...state, wired };
|
|
206
|
+
}
|
|
207
|
+
var EMPTY = { latest: {}, samples: [], error: null };
|
|
208
|
+
function latestOf(samples) {
|
|
209
|
+
const latest = {};
|
|
210
|
+
for (const s of samples) latest[s.name] = s;
|
|
211
|
+
return latest;
|
|
212
|
+
}
|
|
213
|
+
function useMeasurementStream(hub, datasource, options = {}) {
|
|
214
|
+
const windowSize = options.window ?? 300;
|
|
215
|
+
const initialSamples = options.initialSamples;
|
|
216
|
+
const [live, setLive] = useState(EMPTY);
|
|
217
|
+
const key = datasource ? JSON.stringify(datasource) : null;
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
setLive(EMPTY);
|
|
220
|
+
if (!datasource) return;
|
|
221
|
+
return hub.subscribeWidget(datasource, {
|
|
222
|
+
next: (sample) => setLive((prev) => {
|
|
223
|
+
const samples = prev.samples.concat(sample);
|
|
224
|
+
if (samples.length > windowSize) samples.splice(0, samples.length - windowSize);
|
|
225
|
+
return { latest: { ...prev.latest, [sample.name]: sample }, samples, error: null };
|
|
226
|
+
}),
|
|
227
|
+
error: (err) => setLive((prev) => ({ ...prev, error: err }))
|
|
228
|
+
});
|
|
229
|
+
}, [hub, key, windowSize]);
|
|
230
|
+
return useMemo(() => {
|
|
231
|
+
if (!initialSamples || initialSamples.length === 0) return live;
|
|
232
|
+
const merged = initialSamples.concat(live.samples);
|
|
233
|
+
const samples = merged.length > windowSize ? merged.slice(merged.length - windowSize) : merged;
|
|
234
|
+
return {
|
|
235
|
+
latest: { ...latestOf(initialSamples), ...live.latest },
|
|
236
|
+
samples,
|
|
237
|
+
error: live.error
|
|
238
|
+
};
|
|
239
|
+
}, [initialSamples, live, windowSize]);
|
|
240
|
+
}
|
|
241
|
+
var EMPTY_ALARMS = { alarms: [], total: 0, loading: true, error: null };
|
|
242
|
+
function useAlarmStream(hub, subscription) {
|
|
243
|
+
const [state, setState] = useState(EMPTY_ALARMS);
|
|
244
|
+
const key = JSON.stringify(subscription);
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
setState(EMPTY_ALARMS);
|
|
247
|
+
return hub.subscribeAlarms(subscription, {
|
|
248
|
+
next: (snapshot) => setState({ alarms: snapshot.alarms, total: snapshot.total, loading: false, error: null }),
|
|
249
|
+
error: (err) => setState((prev) => ({ ...prev, loading: false, error: err }))
|
|
250
|
+
});
|
|
251
|
+
}, [hub, key]);
|
|
252
|
+
return state;
|
|
253
|
+
}
|
|
254
|
+
var EMPTY_COMMANDS = {
|
|
255
|
+
deviceToken: null,
|
|
256
|
+
commands: [],
|
|
257
|
+
total: 0,
|
|
258
|
+
loading: true,
|
|
259
|
+
error: null
|
|
260
|
+
};
|
|
261
|
+
function useCommandStream(hub, subscription) {
|
|
262
|
+
const [state, setState] = useState(EMPTY_COMMANDS);
|
|
263
|
+
const key = JSON.stringify(subscription);
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
setState(EMPTY_COMMANDS);
|
|
266
|
+
return hub.subscribeCommands(subscription, {
|
|
267
|
+
next: (snapshot) => setState({
|
|
268
|
+
deviceToken: snapshot.deviceToken,
|
|
269
|
+
commands: snapshot.commands,
|
|
270
|
+
total: snapshot.total,
|
|
271
|
+
loading: false,
|
|
272
|
+
error: null
|
|
273
|
+
}),
|
|
274
|
+
error: (err) => setState((prev) => ({ ...prev, loading: false, error: err }))
|
|
275
|
+
});
|
|
276
|
+
}, [hub, key]);
|
|
277
|
+
return state;
|
|
278
|
+
}
|
|
279
|
+
var EMPTY_LOCATIONS = {
|
|
280
|
+
locations: [],
|
|
281
|
+
deviceTokens: [],
|
|
282
|
+
forbidden: false,
|
|
283
|
+
loading: true,
|
|
284
|
+
error: null
|
|
285
|
+
};
|
|
286
|
+
function useLocationStream(hub, subscription) {
|
|
287
|
+
const [state, setState] = useState(EMPTY_LOCATIONS);
|
|
288
|
+
const key = JSON.stringify(subscription);
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
setState(EMPTY_LOCATIONS);
|
|
291
|
+
return hub.subscribeLocations(subscription, {
|
|
292
|
+
next: (snapshot) => setState(
|
|
293
|
+
snapshot.kind === "forbidden" ? { locations: [], deviceTokens: [], forbidden: true, loading: false, error: null } : {
|
|
294
|
+
locations: snapshot.locations,
|
|
295
|
+
deviceTokens: snapshot.deviceTokens,
|
|
296
|
+
forbidden: false,
|
|
297
|
+
loading: false,
|
|
298
|
+
error: null
|
|
299
|
+
}
|
|
300
|
+
),
|
|
301
|
+
error: (err) => setState((prev) => ({ ...prev, loading: false, error: err }))
|
|
302
|
+
});
|
|
303
|
+
}, [hub, key]);
|
|
304
|
+
return state;
|
|
305
|
+
}
|
|
306
|
+
var AVAILABILITY_REVALIDATE_MS = 6e4;
|
|
307
|
+
function useDatasourceAvailability(hub, datasource) {
|
|
308
|
+
const [state, setState] = useState("unknown");
|
|
309
|
+
const key = datasource ? JSON.stringify(datasource) : null;
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
if (!datasource) {
|
|
312
|
+
setState("available");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
setState("unknown");
|
|
316
|
+
let cancelled = false;
|
|
317
|
+
let timer;
|
|
318
|
+
const stopTimer = () => {
|
|
319
|
+
if (timer) {
|
|
320
|
+
clearInterval(timer);
|
|
321
|
+
timer = void 0;
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const check = () => {
|
|
325
|
+
hub.isDatasourceAvailable(datasource).then((ok) => {
|
|
326
|
+
if (cancelled) return;
|
|
327
|
+
setState(ok ? "available" : "unavailable");
|
|
328
|
+
if (ok) stopTimer();
|
|
329
|
+
}).catch(() => {
|
|
330
|
+
if (cancelled) return;
|
|
331
|
+
setState("available");
|
|
332
|
+
stopTimer();
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
check();
|
|
336
|
+
timer = setInterval(check, AVAILABILITY_REVALIDATE_MS);
|
|
337
|
+
return () => {
|
|
338
|
+
cancelled = true;
|
|
339
|
+
stopTimer();
|
|
340
|
+
};
|
|
341
|
+
}, [hub, key]);
|
|
342
|
+
return state;
|
|
343
|
+
}
|
|
344
|
+
function useElementSize() {
|
|
345
|
+
const ref = useRef(null);
|
|
346
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
const el = ref.current;
|
|
349
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
350
|
+
const observer = new ResizeObserver((entries) => {
|
|
351
|
+
const rect = entries[0]?.contentRect;
|
|
352
|
+
if (!rect) return;
|
|
353
|
+
setSize(
|
|
354
|
+
(prev) => prev.width === rect.width && prev.height === rect.height ? prev : { width: rect.width, height: rect.height }
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
observer.observe(el);
|
|
358
|
+
return () => observer.disconnect();
|
|
359
|
+
}, []);
|
|
360
|
+
return [ref, size];
|
|
361
|
+
}
|
|
362
|
+
var sharedTheme = null;
|
|
363
|
+
var themeListeners = /* @__PURE__ */ new Set();
|
|
364
|
+
var themeObserver = null;
|
|
365
|
+
function recomputeTheme() {
|
|
366
|
+
sharedTheme = resolveChartTheme(document.documentElement);
|
|
367
|
+
}
|
|
368
|
+
function subscribeTheme(listener) {
|
|
369
|
+
if (!themeObserver) {
|
|
370
|
+
recomputeTheme();
|
|
371
|
+
themeObserver = new MutationObserver(() => {
|
|
372
|
+
recomputeTheme();
|
|
373
|
+
for (const l of themeListeners) l();
|
|
374
|
+
});
|
|
375
|
+
themeObserver.observe(document.documentElement, {
|
|
376
|
+
attributes: true,
|
|
377
|
+
attributeFilter: ["class", "style"]
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
themeListeners.add(listener);
|
|
381
|
+
return () => {
|
|
382
|
+
themeListeners.delete(listener);
|
|
383
|
+
if (themeListeners.size === 0 && themeObserver) {
|
|
384
|
+
themeObserver.disconnect();
|
|
385
|
+
themeObserver = null;
|
|
386
|
+
sharedTheme = null;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function getThemeSnapshot() {
|
|
391
|
+
if (!sharedTheme) recomputeTheme();
|
|
392
|
+
return sharedTheme;
|
|
393
|
+
}
|
|
394
|
+
function useChartTheme() {
|
|
395
|
+
return useSyncExternalStore(subscribeTheme, getThemeSnapshot, getThemeSnapshot);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// src/widget.ts
|
|
399
|
+
function pickSample(latest, name) {
|
|
400
|
+
return name ? latest[name] : Object.values(latest)[0];
|
|
401
|
+
}
|
|
402
|
+
function optString(options, key) {
|
|
403
|
+
const value = options?.[key];
|
|
404
|
+
return typeof value === "string" ? value : void 0;
|
|
405
|
+
}
|
|
406
|
+
function optNumber(options, key) {
|
|
407
|
+
const value = options?.[key];
|
|
408
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
409
|
+
}
|
|
410
|
+
function optBoolean(options, key) {
|
|
411
|
+
return options?.[key] === true;
|
|
412
|
+
}
|
|
413
|
+
function primaryMeasurementName(widget) {
|
|
414
|
+
const explicit = optString(widget.options, "measurement");
|
|
415
|
+
if (explicit) return explicit;
|
|
416
|
+
const ds = widget.datasource;
|
|
417
|
+
if (ds && ds.measurements.length > 0) return ds.measurements[0];
|
|
418
|
+
return void 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/widgets/severity.ts
|
|
422
|
+
var SEVERITY_COLORS = {
|
|
423
|
+
CRITICAL: "#dc2626",
|
|
424
|
+
// red-600
|
|
425
|
+
MAJOR: "#f97316",
|
|
426
|
+
// orange-500
|
|
427
|
+
MINOR: "#d97706",
|
|
428
|
+
// amber-600 (readable as foreground; console uses amber-400 as a fill)
|
|
429
|
+
WARNING: "#0ea5e9",
|
|
430
|
+
// sky-500 — cool end of the ramp, matching the console
|
|
431
|
+
INDETERMINATE: "#64748b"
|
|
432
|
+
// slate-500
|
|
433
|
+
};
|
|
434
|
+
var SEVERITY_ORDER = {
|
|
435
|
+
CRITICAL: 0,
|
|
436
|
+
MAJOR: 1,
|
|
437
|
+
MINOR: 2,
|
|
438
|
+
WARNING: 3,
|
|
439
|
+
INDETERMINATE: 4
|
|
440
|
+
};
|
|
441
|
+
var UNKNOWN_COLOR = "#64748b";
|
|
442
|
+
function severityColor(severity) {
|
|
443
|
+
return SEVERITY_COLORS[severity] ?? UNKNOWN_COLOR;
|
|
444
|
+
}
|
|
445
|
+
function severityRank(severity) {
|
|
446
|
+
return SEVERITY_ORDER[severity] ?? 99;
|
|
447
|
+
}
|
|
448
|
+
function severityLabel(severity) {
|
|
449
|
+
if (!severity) return "";
|
|
450
|
+
return severity.charAt(0).toUpperCase() + severity.slice(1).toLowerCase();
|
|
451
|
+
}
|
|
452
|
+
function highestSeverity(severities) {
|
|
453
|
+
let best;
|
|
454
|
+
for (const s of severities) {
|
|
455
|
+
if (best === void 0 || severityRank(s) < severityRank(best)) best = s;
|
|
456
|
+
}
|
|
457
|
+
return best;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// src/widgets/alarm-count.tsx
|
|
461
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
462
|
+
var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
463
|
+
function AlarmCount({ widget, data }) {
|
|
464
|
+
const [sizeRef, size] = useElementSize();
|
|
465
|
+
const { total, alarms, loading } = data;
|
|
466
|
+
const worst = highestSeverity(alarms.map((a) => a.severity));
|
|
467
|
+
const alerting = total > 0 && worst != null;
|
|
468
|
+
const color = alerting ? severityColor(worst) : css("muted-foreground");
|
|
469
|
+
const valueFont = size.height > 0 ? clamp(Math.round(size.height / 2.8), 20, 56) : 40;
|
|
470
|
+
const display = loading && total === 0 ? "\u2014" : String(total);
|
|
471
|
+
const unit = total === 1 ? "alarm" : "alarms";
|
|
472
|
+
const ariaLabel = alerting ? `${total} ${unit}, worst severity ${severityLabel(worst)}` : `${total} ${unit}`;
|
|
473
|
+
return /* @__PURE__ */ jsx2(WidgetFrame, { title: optString(widget.options, "title"), children: /* @__PURE__ */ jsxs2(
|
|
474
|
+
"div",
|
|
475
|
+
{
|
|
476
|
+
ref: sizeRef,
|
|
477
|
+
style: {
|
|
478
|
+
display: "flex",
|
|
479
|
+
flexDirection: "column",
|
|
480
|
+
justifyContent: "center",
|
|
481
|
+
alignItems: "center",
|
|
482
|
+
height: "100%",
|
|
483
|
+
padding: "12px 16px",
|
|
484
|
+
textAlign: "center"
|
|
485
|
+
},
|
|
486
|
+
children: [
|
|
487
|
+
/* @__PURE__ */ jsx2("div", { "aria-label": ariaLabel, style: { fontSize: valueFont, fontWeight: 700, lineHeight: 1, color }, children: display }),
|
|
488
|
+
/* @__PURE__ */ jsxs2(
|
|
489
|
+
"div",
|
|
490
|
+
{
|
|
491
|
+
style: {
|
|
492
|
+
fontSize: 11,
|
|
493
|
+
color: css("muted-foreground"),
|
|
494
|
+
marginTop: 6,
|
|
495
|
+
textTransform: "uppercase",
|
|
496
|
+
letterSpacing: "0.05em"
|
|
497
|
+
},
|
|
498
|
+
children: [
|
|
499
|
+
/* @__PURE__ */ jsx2("span", { children: unit }),
|
|
500
|
+
alerting ? /* @__PURE__ */ jsx2("span", { children: ` \xB7 worst ${severityLabel(worst)}` }) : null
|
|
501
|
+
]
|
|
502
|
+
}
|
|
503
|
+
)
|
|
504
|
+
]
|
|
505
|
+
}
|
|
506
|
+
) });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/widgets/alarm-table.tsx
|
|
510
|
+
import { useState as useState3 } from "react";
|
|
511
|
+
|
|
512
|
+
// src/flash.tsx
|
|
513
|
+
import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
|
|
514
|
+
|
|
515
|
+
// src/format.ts
|
|
516
|
+
function formatTimestamp(iso) {
|
|
517
|
+
if (!iso) return "";
|
|
518
|
+
const date = new Date(iso);
|
|
519
|
+
return Number.isNaN(date.getTime()) ? iso : date.toLocaleTimeString();
|
|
520
|
+
}
|
|
521
|
+
function formatDateTime(iso) {
|
|
522
|
+
if (!iso) return "";
|
|
523
|
+
const date = new Date(iso);
|
|
524
|
+
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
|
|
525
|
+
}
|
|
526
|
+
function formatValue(value, precision) {
|
|
527
|
+
if (value == null) return "\u2014";
|
|
528
|
+
if (precision == null) return String(value);
|
|
529
|
+
const places = Math.min(100, Math.max(0, Math.trunc(precision)));
|
|
530
|
+
return value.toFixed(places);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// src/flash.tsx
|
|
534
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
535
|
+
var UP = {
|
|
536
|
+
fg: "var(--flash-up, hsl(142 72% 40%))",
|
|
537
|
+
bg: "var(--flash-up-bg, hsl(142 72% 45% / 0.16))"
|
|
538
|
+
};
|
|
539
|
+
var DOWN = {
|
|
540
|
+
fg: "var(--flash-down, hsl(0 72% 51%))",
|
|
541
|
+
bg: "var(--flash-down-bg, hsl(0 72% 55% / 0.16))"
|
|
542
|
+
};
|
|
543
|
+
var HOLD_MS = 400;
|
|
544
|
+
var FADE_MS = 600;
|
|
545
|
+
function useFlashOnChange(value, enabled, identity) {
|
|
546
|
+
const prev = useRef2(value);
|
|
547
|
+
const prevIdentity = useRef2(identity);
|
|
548
|
+
const [direction, setDirection] = useState2(null);
|
|
549
|
+
useEffect2(() => {
|
|
550
|
+
const before = prev.current;
|
|
551
|
+
const sameEntity = prevIdentity.current === identity;
|
|
552
|
+
prev.current = value;
|
|
553
|
+
prevIdentity.current = identity;
|
|
554
|
+
if (!enabled || !sameEntity || typeof value !== "number" || typeof before !== "number" || !Number.isFinite(value) || !Number.isFinite(before) || value === before) {
|
|
555
|
+
setDirection(null);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
setDirection(value > before ? "up" : "down");
|
|
559
|
+
const timer = setTimeout(() => setDirection(null), HOLD_MS);
|
|
560
|
+
return () => clearTimeout(timer);
|
|
561
|
+
}, [value, enabled, identity]);
|
|
562
|
+
return enabled ? direction : null;
|
|
563
|
+
}
|
|
564
|
+
function flashTextStyle(direction, fadeMs = FADE_MS) {
|
|
565
|
+
if (direction) return { color: direction === "up" ? UP.fg : DOWN.fg, transition: "none" };
|
|
566
|
+
return { transition: `color ${fadeMs}ms ease-out` };
|
|
567
|
+
}
|
|
568
|
+
function flashBackgroundStyle(direction, fadeMs = FADE_MS) {
|
|
569
|
+
if (direction) {
|
|
570
|
+
return { backgroundColor: direction === "up" ? UP.bg : DOWN.bg, transition: "none" };
|
|
571
|
+
}
|
|
572
|
+
return { transition: `background-color ${fadeMs}ms ease-out` };
|
|
573
|
+
}
|
|
574
|
+
function FlashValue({
|
|
575
|
+
value,
|
|
576
|
+
precision,
|
|
577
|
+
enabled,
|
|
578
|
+
identity,
|
|
579
|
+
baseColor = "inherit",
|
|
580
|
+
style
|
|
581
|
+
}) {
|
|
582
|
+
const direction = useFlashOnChange(value, enabled, identity);
|
|
583
|
+
return /* @__PURE__ */ jsx3("span", { style: { color: baseColor, ...style, ...flashTextStyle(direction) }, children: formatValue(value, precision) });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// src/widgets/alarm-table.tsx
|
|
587
|
+
import { Fragment, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
588
|
+
var cell = { padding: "6px 10px", textAlign: "left", whiteSpace: "nowrap" };
|
|
589
|
+
var headCell = {
|
|
590
|
+
...cell,
|
|
591
|
+
position: "sticky",
|
|
592
|
+
top: 0,
|
|
593
|
+
background: css("card"),
|
|
594
|
+
fontSize: 11,
|
|
595
|
+
fontWeight: 600,
|
|
596
|
+
textTransform: "uppercase",
|
|
597
|
+
letterSpacing: "0.04em",
|
|
598
|
+
color: css("muted-foreground"),
|
|
599
|
+
borderBottom: `1px solid ${css("border")}`
|
|
600
|
+
};
|
|
601
|
+
function statusLabel(alarm) {
|
|
602
|
+
if (alarm.state === "CLEARED") return "Cleared";
|
|
603
|
+
return alarm.acknowledged ? "Acknowledged" : "Active";
|
|
604
|
+
}
|
|
605
|
+
function AlarmTable({ widget, data, actions }) {
|
|
606
|
+
const { alarms, total, loading } = data;
|
|
607
|
+
const canAct = actions?.can("alarm:write") ?? false;
|
|
608
|
+
const columns = canAct ? 8 : 7;
|
|
609
|
+
const precision = optNumber(widget.options, "precision");
|
|
610
|
+
const flash = optBoolean(widget.options, "flashOnChange");
|
|
611
|
+
const select = useWidgetSelect();
|
|
612
|
+
const selectionTarget = optString(widget.options, "selectionTarget");
|
|
613
|
+
const drillTo = select && selectionTarget ? { select, slot: selectionTarget } : void 0;
|
|
614
|
+
return /* @__PURE__ */ jsx4(WidgetFrame, { title: optString(widget.options, "title"), children: /* @__PURE__ */ jsxs3("div", { style: { display: "flex", flexDirection: "column", height: "100%" }, children: [
|
|
615
|
+
/* @__PURE__ */ jsx4("div", { style: { flex: 1, minHeight: 0, overflow: "auto" }, children: /* @__PURE__ */ jsxs3(
|
|
616
|
+
"table",
|
|
617
|
+
{
|
|
618
|
+
style: {
|
|
619
|
+
width: "100%",
|
|
620
|
+
borderCollapse: "collapse",
|
|
621
|
+
fontSize: 13,
|
|
622
|
+
color: css("foreground"),
|
|
623
|
+
fontVariantNumeric: "tabular-nums"
|
|
624
|
+
},
|
|
625
|
+
children: [
|
|
626
|
+
/* @__PURE__ */ jsx4("thead", { children: /* @__PURE__ */ jsxs3("tr", { children: [
|
|
627
|
+
/* @__PURE__ */ jsx4("th", { style: { ...headCell, width: 4, padding: 0 }, "aria-hidden": "true" }),
|
|
628
|
+
/* @__PURE__ */ jsx4("th", { scope: "col", style: headCell, children: "Severity" }),
|
|
629
|
+
/* @__PURE__ */ jsx4("th", { scope: "col", style: headCell, children: "Status" }),
|
|
630
|
+
/* @__PURE__ */ jsx4("th", { scope: "col", style: headCell, children: "Originator" }),
|
|
631
|
+
/* @__PURE__ */ jsx4("th", { scope: "col", style: headCell, children: "Alarm" }),
|
|
632
|
+
/* @__PURE__ */ jsx4("th", { scope: "col", style: { ...headCell, textAlign: "right" }, children: "Value" }),
|
|
633
|
+
/* @__PURE__ */ jsx4("th", { scope: "col", style: headCell, children: "Raised" }),
|
|
634
|
+
canAct ? /* @__PURE__ */ jsx4("th", { scope: "col", style: { ...headCell, textAlign: "right" }, children: "Actions" }) : null
|
|
635
|
+
] }) }),
|
|
636
|
+
/* @__PURE__ */ jsx4("tbody", { children: alarms.length === 0 ? /* @__PURE__ */ jsx4("tr", { children: /* @__PURE__ */ jsx4("td", { colSpan: columns, style: { ...cell, textAlign: "center", color: css("muted-foreground") }, children: loading ? "Loading\u2026" : "No alarms" }) }) : alarms.map((alarm) => {
|
|
637
|
+
const color = severityColor(alarm.severity);
|
|
638
|
+
return /* @__PURE__ */ jsxs3("tr", { style: { borderBottom: `1px solid ${css("border")}` }, children: [
|
|
639
|
+
/* @__PURE__ */ jsx4("td", { "aria-hidden": "true", style: { padding: 0, width: 4, background: color } }),
|
|
640
|
+
/* @__PURE__ */ jsx4("td", { style: cell, children: /* @__PURE__ */ jsxs3("span", { style: { display: "inline-flex", alignItems: "center", gap: 6 }, children: [
|
|
641
|
+
/* @__PURE__ */ jsx4(
|
|
642
|
+
"span",
|
|
643
|
+
{
|
|
644
|
+
"aria-hidden": "true",
|
|
645
|
+
style: { width: 8, height: 8, borderRadius: "50%", background: color, flexShrink: 0 }
|
|
646
|
+
}
|
|
647
|
+
),
|
|
648
|
+
severityLabel(alarm.severity)
|
|
649
|
+
] }) }),
|
|
650
|
+
/* @__PURE__ */ jsx4("td", { style: { ...cell, color: css("muted-foreground") }, children: statusLabel(alarm) }),
|
|
651
|
+
/* @__PURE__ */ jsx4("td", { style: cell, children: /* @__PURE__ */ jsx4(OriginatorCell, { alarm, drillTo }) }),
|
|
652
|
+
/* @__PURE__ */ jsx4("td", { style: cell, title: alarm.message ?? void 0, children: alarm.alarmKey }),
|
|
653
|
+
/* @__PURE__ */ jsx4("td", { style: { ...cell, textAlign: "right" }, children: /* @__PURE__ */ jsx4(
|
|
654
|
+
FlashValue,
|
|
655
|
+
{
|
|
656
|
+
value: alarm.lastValue,
|
|
657
|
+
precision,
|
|
658
|
+
enabled: flash,
|
|
659
|
+
identity: alarm.token
|
|
660
|
+
}
|
|
661
|
+
) }),
|
|
662
|
+
/* @__PURE__ */ jsx4("td", { style: { ...cell, color: css("muted-foreground") }, children: formatDateTime(alarm.raisedTime) }),
|
|
663
|
+
canAct ? /* @__PURE__ */ jsx4("td", { style: { ...cell, textAlign: "right" }, children: /* @__PURE__ */ jsx4(AlarmRowActions, { alarm, actions }) }) : null
|
|
664
|
+
] }, alarm.token);
|
|
665
|
+
}) })
|
|
666
|
+
]
|
|
667
|
+
}
|
|
668
|
+
) }),
|
|
669
|
+
total > alarms.length ? /* @__PURE__ */ jsxs3(
|
|
670
|
+
"div",
|
|
671
|
+
{
|
|
672
|
+
style: {
|
|
673
|
+
padding: "4px 10px",
|
|
674
|
+
fontSize: 11,
|
|
675
|
+
color: css("muted-foreground"),
|
|
676
|
+
borderTop: `1px solid ${css("border")}`
|
|
677
|
+
},
|
|
678
|
+
children: [
|
|
679
|
+
"Showing ",
|
|
680
|
+
alarms.length,
|
|
681
|
+
" of ",
|
|
682
|
+
total
|
|
683
|
+
]
|
|
684
|
+
}
|
|
685
|
+
) : null
|
|
686
|
+
] }) });
|
|
687
|
+
}
|
|
688
|
+
function OriginatorCell({
|
|
689
|
+
alarm,
|
|
690
|
+
drillTo
|
|
691
|
+
}) {
|
|
692
|
+
if (drillTo && alarm.originatorType === "device" && alarm.originatorToken) {
|
|
693
|
+
const token = alarm.originatorToken;
|
|
694
|
+
return /* @__PURE__ */ jsx4(
|
|
695
|
+
"button",
|
|
696
|
+
{
|
|
697
|
+
type: "button",
|
|
698
|
+
onClick: () => drillTo.select({ slot: drillTo.slot, binding: { kind: "device", deviceToken: token } }),
|
|
699
|
+
title: `Show ${token} in the linked widgets`,
|
|
700
|
+
style: {
|
|
701
|
+
padding: 0,
|
|
702
|
+
border: "none",
|
|
703
|
+
background: "none",
|
|
704
|
+
font: "inherit",
|
|
705
|
+
color: css("primary"),
|
|
706
|
+
cursor: "pointer",
|
|
707
|
+
textDecoration: "underline",
|
|
708
|
+
textUnderlineOffset: 2
|
|
709
|
+
},
|
|
710
|
+
children: token
|
|
711
|
+
}
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
return /* @__PURE__ */ jsx4(Fragment, { children: alarm.originatorToken ?? alarm.originatorType });
|
|
715
|
+
}
|
|
716
|
+
var actionButton = {
|
|
717
|
+
padding: "2px 8px",
|
|
718
|
+
fontSize: 12,
|
|
719
|
+
lineHeight: 1.4,
|
|
720
|
+
borderRadius: 4,
|
|
721
|
+
border: `1px solid ${css("border")}`,
|
|
722
|
+
background: css("card"),
|
|
723
|
+
color: css("foreground"),
|
|
724
|
+
cursor: "pointer"
|
|
725
|
+
};
|
|
726
|
+
function AlarmRowActions({ alarm, actions }) {
|
|
727
|
+
const [pending, setPending] = useState3(null);
|
|
728
|
+
const [failed, setFailed] = useState3(false);
|
|
729
|
+
if (alarm.state !== "ACTIVE") {
|
|
730
|
+
return /* @__PURE__ */ jsx4("span", { style: { color: css("muted-foreground") }, children: "\u2014" });
|
|
731
|
+
}
|
|
732
|
+
const run = (kind, op) => {
|
|
733
|
+
setPending(kind);
|
|
734
|
+
setFailed(false);
|
|
735
|
+
let promise;
|
|
736
|
+
try {
|
|
737
|
+
promise = op();
|
|
738
|
+
} catch {
|
|
739
|
+
setFailed(true);
|
|
740
|
+
setPending(null);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
promise.catch(() => setFailed(true)).finally(() => setPending(null));
|
|
744
|
+
};
|
|
745
|
+
return /* @__PURE__ */ jsxs3("span", { style: { display: "inline-flex", gap: 6, justifyContent: "flex-end", alignItems: "center" }, children: [
|
|
746
|
+
failed ? /* @__PURE__ */ jsx4(
|
|
747
|
+
"span",
|
|
748
|
+
{
|
|
749
|
+
role: "img",
|
|
750
|
+
"aria-label": "Action failed \u2014 retry",
|
|
751
|
+
title: "Action failed \u2014 retry",
|
|
752
|
+
style: { color: severityColor("CRITICAL"), fontSize: 12 },
|
|
753
|
+
children: "\u26A0"
|
|
754
|
+
}
|
|
755
|
+
) : null,
|
|
756
|
+
!alarm.acknowledged ? /* @__PURE__ */ jsx4(
|
|
757
|
+
"button",
|
|
758
|
+
{
|
|
759
|
+
type: "button",
|
|
760
|
+
style: { ...actionButton, opacity: pending ? 0.6 : 1 },
|
|
761
|
+
disabled: pending !== null,
|
|
762
|
+
"aria-busy": pending === "ack",
|
|
763
|
+
"aria-label": `Acknowledge alarm ${alarm.alarmKey}`,
|
|
764
|
+
onClick: () => run("ack", () => actions.acknowledgeAlarm(alarm.token)),
|
|
765
|
+
children: pending === "ack" ? "\u2026" : "Ack"
|
|
766
|
+
}
|
|
767
|
+
) : null,
|
|
768
|
+
/* @__PURE__ */ jsx4(
|
|
769
|
+
"button",
|
|
770
|
+
{
|
|
771
|
+
type: "button",
|
|
772
|
+
style: { ...actionButton, opacity: pending ? 0.6 : 1 },
|
|
773
|
+
disabled: pending !== null,
|
|
774
|
+
"aria-busy": pending === "clear",
|
|
775
|
+
"aria-label": `Clear alarm ${alarm.alarmKey}`,
|
|
776
|
+
onClick: () => run("clear", () => actions.clearAlarm(alarm.token)),
|
|
777
|
+
children: pending === "clear" ? "\u2026" : "Clear"
|
|
778
|
+
}
|
|
779
|
+
)
|
|
780
|
+
] });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/widgets/command-button.tsx
|
|
784
|
+
import { useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useState as useState4 } from "react";
|
|
785
|
+
|
|
786
|
+
// src/widgets/command-status.ts
|
|
787
|
+
import { isTerminalCommandStatus } from "@devicechain/dashboards";
|
|
788
|
+
var STATUS_COLORS = {
|
|
789
|
+
SUCCESSFUL: "#16a34a",
|
|
790
|
+
// green-600 — acknowledged by the device
|
|
791
|
+
FAILED: "#dc2626",
|
|
792
|
+
// red-600
|
|
793
|
+
TIMEOUT: "#dc2626",
|
|
794
|
+
// red-600 — never acknowledged in time
|
|
795
|
+
EXPIRED: "#64748b",
|
|
796
|
+
// slate-500 — lapsed before it was ever sent
|
|
797
|
+
CANCELLED: "#7c3aed",
|
|
798
|
+
// violet-600 — deliberately called off; a decision, not a failure
|
|
799
|
+
QUEUED: "#0284c7",
|
|
800
|
+
// sky-600 — in flight
|
|
801
|
+
SENT: "#0284c7",
|
|
802
|
+
HELD: "#d97706",
|
|
803
|
+
// amber-600 — withheld: the device is absent, the command still stands
|
|
804
|
+
PARKED: "#f59e0b"
|
|
805
|
+
// amber-500 — published, nobody home; held for the device's return
|
|
806
|
+
};
|
|
807
|
+
var UNKNOWN_COLOR2 = "#64748b";
|
|
808
|
+
function commandStatusColor(status) {
|
|
809
|
+
return STATUS_COLORS[status] ?? UNKNOWN_COLOR2;
|
|
810
|
+
}
|
|
811
|
+
function isTerminalStatus(status) {
|
|
812
|
+
return isTerminalCommandStatus(status);
|
|
813
|
+
}
|
|
814
|
+
function commandStatusLabel(status) {
|
|
815
|
+
if (!status) return "";
|
|
816
|
+
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/widgets/command-params.ts
|
|
820
|
+
var DATA_TYPES = /* @__PURE__ */ new Set(["DOUBLE", "INT", "BOOLEAN", "STRING"]);
|
|
821
|
+
function parseParameterSchema(schema) {
|
|
822
|
+
if (!schema) return [];
|
|
823
|
+
let parsed;
|
|
824
|
+
try {
|
|
825
|
+
parsed = JSON.parse(schema);
|
|
826
|
+
} catch {
|
|
827
|
+
return [];
|
|
828
|
+
}
|
|
829
|
+
if (!Array.isArray(parsed)) return [];
|
|
830
|
+
const out = [];
|
|
831
|
+
for (const raw of parsed) {
|
|
832
|
+
const norm = normalizeParam(raw);
|
|
833
|
+
if (norm) out.push(norm);
|
|
834
|
+
}
|
|
835
|
+
return out;
|
|
836
|
+
}
|
|
837
|
+
function normalizeParam(raw) {
|
|
838
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
839
|
+
const r = raw;
|
|
840
|
+
if (typeof r.name !== "string") return null;
|
|
841
|
+
const param = { name: r.name };
|
|
842
|
+
if (typeof r.description === "string") param.description = r.description;
|
|
843
|
+
if (r.kind === "OBJECT") param.kind = "OBJECT";
|
|
844
|
+
else if (r.kind === "SCALAR") param.kind = "SCALAR";
|
|
845
|
+
if (typeof r.dataType === "string" && DATA_TYPES.has(r.dataType)) {
|
|
846
|
+
param.dataType = r.dataType;
|
|
847
|
+
}
|
|
848
|
+
if (typeof r.unit === "string") param.unit = r.unit;
|
|
849
|
+
if (r.required === true) param.required = true;
|
|
850
|
+
if (typeof r.default === "string") param.default = r.default;
|
|
851
|
+
if (typeof r.minValue === "number" && Number.isFinite(r.minValue)) param.minValue = r.minValue;
|
|
852
|
+
if (typeof r.maxValue === "number" && Number.isFinite(r.maxValue)) param.maxValue = r.maxValue;
|
|
853
|
+
if (Array.isArray(r.enum)) {
|
|
854
|
+
const values = r.enum.filter((v) => typeof v === "string");
|
|
855
|
+
if (values.length > 0) param.enum = values;
|
|
856
|
+
}
|
|
857
|
+
return param;
|
|
858
|
+
}
|
|
859
|
+
function isScalar(param) {
|
|
860
|
+
return param.kind !== "OBJECT";
|
|
861
|
+
}
|
|
862
|
+
function parseBool(raw) {
|
|
863
|
+
const s = raw.trim().toLowerCase();
|
|
864
|
+
return s === "true" || s === "1" || s === "t" || s === "yes" || s === "on";
|
|
865
|
+
}
|
|
866
|
+
function defaultValues(params) {
|
|
867
|
+
const values = {};
|
|
868
|
+
for (const p of params) {
|
|
869
|
+
if (!isScalar(p)) continue;
|
|
870
|
+
if (p.dataType === "BOOLEAN") values[p.name] = parseBool(p.default ?? "") ? "true" : "false";
|
|
871
|
+
else if (p.default != null) values[p.name] = p.default;
|
|
872
|
+
}
|
|
873
|
+
return values;
|
|
874
|
+
}
|
|
875
|
+
function validateParams(params, values) {
|
|
876
|
+
const errors = {};
|
|
877
|
+
for (const p of params) {
|
|
878
|
+
if (!isScalar(p)) {
|
|
879
|
+
if (p.required) errors[p.name] = "Structured parameter \u2014 not supported here";
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
if (p.dataType === "BOOLEAN") continue;
|
|
883
|
+
const raw = (values[p.name] ?? "").trim();
|
|
884
|
+
if (raw === "") {
|
|
885
|
+
if (p.required) errors[p.name] = "Required";
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (p.dataType === "INT" || p.dataType === "DOUBLE") {
|
|
889
|
+
const n = Number(raw);
|
|
890
|
+
if (!Number.isFinite(n)) {
|
|
891
|
+
errors[p.name] = "Must be a number";
|
|
892
|
+
} else if (p.dataType === "INT" && !Number.isInteger(n)) {
|
|
893
|
+
errors[p.name] = "Must be a whole number";
|
|
894
|
+
} else if (p.minValue != null && n < p.minValue) {
|
|
895
|
+
errors[p.name] = `Must be \u2265 ${p.minValue}`;
|
|
896
|
+
} else if (p.maxValue != null && n > p.maxValue) {
|
|
897
|
+
errors[p.name] = `Must be \u2264 ${p.maxValue}`;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return errors;
|
|
902
|
+
}
|
|
903
|
+
function buildPayload(params, values) {
|
|
904
|
+
const payload = {};
|
|
905
|
+
for (const p of params) {
|
|
906
|
+
if (!isScalar(p)) continue;
|
|
907
|
+
const raw = (values[p.name] ?? "").trim();
|
|
908
|
+
if (p.dataType === "BOOLEAN") {
|
|
909
|
+
payload[p.name] = parseBool(raw);
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
if (raw === "") continue;
|
|
913
|
+
payload[p.name] = coerce(p, raw);
|
|
914
|
+
}
|
|
915
|
+
return Object.keys(payload).length === 0 ? void 0 : JSON.stringify(payload);
|
|
916
|
+
}
|
|
917
|
+
function coerce(param, raw) {
|
|
918
|
+
switch (param.dataType) {
|
|
919
|
+
case "INT":
|
|
920
|
+
case "DOUBLE": {
|
|
921
|
+
const n = Number(raw);
|
|
922
|
+
return Number.isFinite(n) ? n : raw;
|
|
923
|
+
}
|
|
924
|
+
case "BOOLEAN":
|
|
925
|
+
return parseBool(raw);
|
|
926
|
+
default:
|
|
927
|
+
return raw;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// src/widgets/command-button.tsx
|
|
932
|
+
import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
933
|
+
function CommandButton({ widget, data, actions }) {
|
|
934
|
+
const commandName = optString(widget.options, "commandName");
|
|
935
|
+
const commandLabel = optString(widget.options, "commandLabel") ?? commandName;
|
|
936
|
+
const parameterSchema = optString(widget.options, "parameterSchema") ?? null;
|
|
937
|
+
const params = useMemo2(() => parseParameterSchema(parameterSchema), [parameterSchema]);
|
|
938
|
+
const schemaKey = `${commandName ?? ""}::${parameterSchema ?? ""}`;
|
|
939
|
+
const [values, setValues] = useState4(() => defaultValues(params));
|
|
940
|
+
const [errors, setErrors] = useState4({});
|
|
941
|
+
const [pending, setPending] = useState4(false);
|
|
942
|
+
const [sendError, setSendError] = useState4(null);
|
|
943
|
+
const [sentToken, setSentToken] = useState4(null);
|
|
944
|
+
const sendGen = useRef3(0);
|
|
945
|
+
useEffect3(() => {
|
|
946
|
+
sendGen.current += 1;
|
|
947
|
+
setValues(defaultValues(params));
|
|
948
|
+
setErrors({});
|
|
949
|
+
setSendError(null);
|
|
950
|
+
setSentToken(null);
|
|
951
|
+
}, [schemaKey]);
|
|
952
|
+
const canWrite = actions?.can("command:write") ?? false;
|
|
953
|
+
const hasSeam = typeof actions?.sendCommand === "function";
|
|
954
|
+
const deviceToken = data.deviceToken;
|
|
955
|
+
const formDisabled = !canWrite || !hasSeam || pending;
|
|
956
|
+
const canSend = !formDisabled && !!deviceToken && !!commandName;
|
|
957
|
+
const setField = (name, value) => {
|
|
958
|
+
setValues((prev) => ({ ...prev, [name]: value }));
|
|
959
|
+
setErrors((prev) => prev[name] ? { ...prev, [name]: "" } : prev);
|
|
960
|
+
setSendError(null);
|
|
961
|
+
};
|
|
962
|
+
const send = () => {
|
|
963
|
+
if (!deviceToken || !commandName || !actions?.sendCommand) return;
|
|
964
|
+
const found = validateParams(params, values);
|
|
965
|
+
const active = Object.fromEntries(Object.entries(found).filter(([, v]) => v));
|
|
966
|
+
if (Object.keys(active).length > 0) {
|
|
967
|
+
setErrors(active);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const payload = buildPayload(params, values);
|
|
971
|
+
const gen = sendGen.current;
|
|
972
|
+
setPending(true);
|
|
973
|
+
setSendError(null);
|
|
974
|
+
let promise;
|
|
975
|
+
try {
|
|
976
|
+
promise = actions.sendCommand(deviceToken, commandName, payload);
|
|
977
|
+
} catch {
|
|
978
|
+
setSendError(SEND_FAILED);
|
|
979
|
+
setPending(false);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
promise.then((dispatch) => {
|
|
983
|
+
if (gen !== sendGen.current) return;
|
|
984
|
+
if (dispatch.status === "rejected") {
|
|
985
|
+
setSendError(dispatch.reason);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
setSentToken(dispatch.token);
|
|
989
|
+
}).catch(() => {
|
|
990
|
+
if (gen === sendGen.current) setSendError(SEND_FAILED);
|
|
991
|
+
}).finally(() => {
|
|
992
|
+
if (gen === sendGen.current) setPending(false);
|
|
993
|
+
});
|
|
994
|
+
};
|
|
995
|
+
return /* @__PURE__ */ jsx5(WidgetFrame, { title: optString(widget.options, "title"), children: /* @__PURE__ */ jsx5("div", { style: { display: "flex", flexDirection: "column", height: "100%", gap: 10 }, children: !commandName ? /* @__PURE__ */ jsx5("div", { style: { ...hint, margin: "auto" }, children: "No command configured." }) : /* @__PURE__ */ jsxs4(Fragment2, { children: [
|
|
996
|
+
/* @__PURE__ */ jsxs4("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [
|
|
997
|
+
params.map(
|
|
998
|
+
(param) => isScalar(param) ? /* @__PURE__ */ jsx5(
|
|
999
|
+
ParamField,
|
|
1000
|
+
{
|
|
1001
|
+
widgetId: widget.id,
|
|
1002
|
+
param,
|
|
1003
|
+
value: values[param.name] ?? "",
|
|
1004
|
+
error: errors[param.name],
|
|
1005
|
+
disabled: formDisabled,
|
|
1006
|
+
onChange: (v) => setField(param.name, v)
|
|
1007
|
+
},
|
|
1008
|
+
param.name
|
|
1009
|
+
) : (
|
|
1010
|
+
// A structured (OBJECT) parameter isn't form-editable. A REQUIRED one
|
|
1011
|
+
// blocks sending (validateParams flags it); flag that in the note.
|
|
1012
|
+
/* @__PURE__ */ jsxs4("div", { style: { ...hint, color: param.required ? commandStatusColor("FAILED") : css("muted-foreground") }, children: [
|
|
1013
|
+
param.name,
|
|
1014
|
+
": ",
|
|
1015
|
+
param.required ? "required " : "",
|
|
1016
|
+
"structured parameter \u2014 not supported here."
|
|
1017
|
+
] }, param.name)
|
|
1018
|
+
)
|
|
1019
|
+
),
|
|
1020
|
+
/* @__PURE__ */ jsx5(
|
|
1021
|
+
"button",
|
|
1022
|
+
{
|
|
1023
|
+
type: "button",
|
|
1024
|
+
style: { ...sendButton, opacity: canSend ? 1 : 0.55, cursor: canSend ? "pointer" : "not-allowed" },
|
|
1025
|
+
disabled: !canSend,
|
|
1026
|
+
"aria-busy": pending,
|
|
1027
|
+
onClick: send,
|
|
1028
|
+
children: pending ? "Sending\u2026" : `Send ${commandLabel}`
|
|
1029
|
+
}
|
|
1030
|
+
),
|
|
1031
|
+
!canWrite ? /* @__PURE__ */ jsx5("div", { style: hint, children: "You don\u2019t have permission to issue commands." }) : !hasSeam ? /* @__PURE__ */ jsx5("div", { style: hint, children: "Commands aren\u2019t available in this view." }) : !deviceToken ? /* @__PURE__ */ jsx5("div", { style: hint, children: "No device is selected for this command." }) : null,
|
|
1032
|
+
sendError ? /* @__PURE__ */ jsx5("div", { role: "alert", style: { ...hint, color: commandStatusColor("FAILED") }, children: sendError }) : null
|
|
1033
|
+
] }),
|
|
1034
|
+
/* @__PURE__ */ jsx5(CommandHistory, { commands: data.commands, sentToken, loading: data.loading, error: data.error })
|
|
1035
|
+
] }) }) });
|
|
1036
|
+
}
|
|
1037
|
+
function ParamField({
|
|
1038
|
+
widgetId,
|
|
1039
|
+
param,
|
|
1040
|
+
value,
|
|
1041
|
+
error,
|
|
1042
|
+
disabled,
|
|
1043
|
+
onChange
|
|
1044
|
+
}) {
|
|
1045
|
+
const labelId = `cmd-${widgetId}-${param.name}`;
|
|
1046
|
+
const unit = param.unit ? ` (${param.unit})` : "";
|
|
1047
|
+
return /* @__PURE__ */ jsxs4("label", { htmlFor: labelId, style: { display: "flex", flexDirection: "column", gap: 3 }, children: [
|
|
1048
|
+
/* @__PURE__ */ jsxs4("span", { style: { fontSize: 12, color: css("muted-foreground") }, children: [
|
|
1049
|
+
param.name,
|
|
1050
|
+
unit,
|
|
1051
|
+
param.required ? /* @__PURE__ */ jsx5("span", { style: { color: commandStatusColor("FAILED") }, children: " *" }) : null
|
|
1052
|
+
] }),
|
|
1053
|
+
/* @__PURE__ */ jsx5(ParamInput, { id: labelId, param, value, disabled, onChange }),
|
|
1054
|
+
error ? /* @__PURE__ */ jsx5("span", { style: { fontSize: 11, color: commandStatusColor("FAILED") }, children: error }) : null
|
|
1055
|
+
] });
|
|
1056
|
+
}
|
|
1057
|
+
function ParamInput({
|
|
1058
|
+
id,
|
|
1059
|
+
param,
|
|
1060
|
+
value,
|
|
1061
|
+
disabled,
|
|
1062
|
+
onChange
|
|
1063
|
+
}) {
|
|
1064
|
+
if (param.enum && param.enum.length > 0) {
|
|
1065
|
+
return /* @__PURE__ */ jsxs4("select", { id, style: inputStyle, value, disabled, onChange: (e) => onChange(e.target.value), children: [
|
|
1066
|
+
!param.required ? /* @__PURE__ */ jsx5("option", { value: "", children: "\u2014" }) : null,
|
|
1067
|
+
param.enum.map((opt) => /* @__PURE__ */ jsx5("option", { value: opt, children: opt }, opt))
|
|
1068
|
+
] });
|
|
1069
|
+
}
|
|
1070
|
+
if (param.dataType === "BOOLEAN") {
|
|
1071
|
+
return /* @__PURE__ */ jsx5(
|
|
1072
|
+
"input",
|
|
1073
|
+
{
|
|
1074
|
+
id,
|
|
1075
|
+
type: "checkbox",
|
|
1076
|
+
style: { width: 16, height: 16 },
|
|
1077
|
+
checked: parseBool(value),
|
|
1078
|
+
disabled,
|
|
1079
|
+
onChange: (e) => onChange(e.target.checked ? "true" : "false")
|
|
1080
|
+
}
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
const numeric = param.dataType === "INT" || param.dataType === "DOUBLE";
|
|
1084
|
+
return /* @__PURE__ */ jsx5(
|
|
1085
|
+
"input",
|
|
1086
|
+
{
|
|
1087
|
+
id,
|
|
1088
|
+
type: numeric ? "number" : "text",
|
|
1089
|
+
style: inputStyle,
|
|
1090
|
+
value,
|
|
1091
|
+
disabled,
|
|
1092
|
+
min: param.minValue ?? void 0,
|
|
1093
|
+
max: param.maxValue ?? void 0,
|
|
1094
|
+
step: param.dataType === "INT" ? 1 : "any",
|
|
1095
|
+
placeholder: param.default ?? void 0,
|
|
1096
|
+
onChange: (e) => onChange(e.target.value)
|
|
1097
|
+
}
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
function CommandHistory({
|
|
1101
|
+
commands,
|
|
1102
|
+
sentToken,
|
|
1103
|
+
loading,
|
|
1104
|
+
error
|
|
1105
|
+
}) {
|
|
1106
|
+
const empty = commands.length === 0;
|
|
1107
|
+
return /* @__PURE__ */ jsx5(
|
|
1108
|
+
"div",
|
|
1109
|
+
{
|
|
1110
|
+
style: {
|
|
1111
|
+
flex: 1,
|
|
1112
|
+
minHeight: 0,
|
|
1113
|
+
overflow: "auto",
|
|
1114
|
+
borderTop: `1px solid ${css("border")}`,
|
|
1115
|
+
paddingTop: 6
|
|
1116
|
+
},
|
|
1117
|
+
children: empty ? /* @__PURE__ */ jsx5("div", { style: { ...hint, textAlign: "center", color: error ? commandStatusColor("FAILED") : css("muted-foreground") }, children: error ? "Couldn\u2019t load command history." : loading ? "Loading\u2026" : "No commands issued yet." }) : /* @__PURE__ */ jsx5("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 4 }, children: commands.map((command) => /* @__PURE__ */ jsxs4(
|
|
1118
|
+
"li",
|
|
1119
|
+
{
|
|
1120
|
+
style: {
|
|
1121
|
+
display: "flex",
|
|
1122
|
+
alignItems: "center",
|
|
1123
|
+
gap: 8,
|
|
1124
|
+
fontSize: 12,
|
|
1125
|
+
// Highlight the command just issued from this widget.
|
|
1126
|
+
fontWeight: command.token === sentToken ? 600 : 400
|
|
1127
|
+
},
|
|
1128
|
+
children: [
|
|
1129
|
+
/* @__PURE__ */ jsx5(StatusBadge, { status: command.status }),
|
|
1130
|
+
/* @__PURE__ */ jsx5("span", { style: { color: css("foreground"), whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, children: command.name }),
|
|
1131
|
+
/* @__PURE__ */ jsx5("span", { style: { marginLeft: "auto", color: css("muted-foreground"), whiteSpace: "nowrap" }, children: formatDateTime(command.queuedTime) })
|
|
1132
|
+
]
|
|
1133
|
+
},
|
|
1134
|
+
command.token
|
|
1135
|
+
)) })
|
|
1136
|
+
}
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
function StatusBadge({ status }) {
|
|
1140
|
+
const color = commandStatusColor(status);
|
|
1141
|
+
return /* @__PURE__ */ jsxs4(
|
|
1142
|
+
"span",
|
|
1143
|
+
{
|
|
1144
|
+
style: {
|
|
1145
|
+
display: "inline-flex",
|
|
1146
|
+
alignItems: "center",
|
|
1147
|
+
gap: 4,
|
|
1148
|
+
flexShrink: 0,
|
|
1149
|
+
fontSize: 11,
|
|
1150
|
+
fontWeight: 600,
|
|
1151
|
+
color
|
|
1152
|
+
},
|
|
1153
|
+
children: [
|
|
1154
|
+
/* @__PURE__ */ jsx5("span", { "aria-hidden": "true", style: { width: 7, height: 7, borderRadius: "50%", background: color } }),
|
|
1155
|
+
commandStatusLabel(status)
|
|
1156
|
+
]
|
|
1157
|
+
}
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
var hint = { fontSize: 11, color: css("muted-foreground") };
|
|
1161
|
+
var SEND_FAILED = "Couldn\u2019t issue the command. Try again.";
|
|
1162
|
+
var inputStyle = {
|
|
1163
|
+
width: "100%",
|
|
1164
|
+
boxSizing: "border-box",
|
|
1165
|
+
padding: "4px 8px",
|
|
1166
|
+
fontSize: 13,
|
|
1167
|
+
borderRadius: 4,
|
|
1168
|
+
border: `1px solid ${css("border")}`,
|
|
1169
|
+
background: css("background"),
|
|
1170
|
+
color: css("foreground")
|
|
1171
|
+
};
|
|
1172
|
+
var sendButton = {
|
|
1173
|
+
padding: "6px 12px",
|
|
1174
|
+
fontSize: 13,
|
|
1175
|
+
fontWeight: 600,
|
|
1176
|
+
borderRadius: 4,
|
|
1177
|
+
border: "none",
|
|
1178
|
+
background: css("primary"),
|
|
1179
|
+
color: css("background"),
|
|
1180
|
+
marginTop: 2
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
// src/widgets/entity-selector.tsx
|
|
1184
|
+
import { useEffect as useEffect4, useState as useState5 } from "react";
|
|
1185
|
+
import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
1186
|
+
function tokenOf(b) {
|
|
1187
|
+
return b.kind === "device" ? b.deviceToken : b.anchor.targetToken;
|
|
1188
|
+
}
|
|
1189
|
+
var control = {
|
|
1190
|
+
width: "100%",
|
|
1191
|
+
fontSize: 14,
|
|
1192
|
+
padding: "8px 10px",
|
|
1193
|
+
borderRadius: 6,
|
|
1194
|
+
border: `1px solid ${css("border")}`,
|
|
1195
|
+
background: css("background"),
|
|
1196
|
+
color: css("foreground")
|
|
1197
|
+
};
|
|
1198
|
+
var hint2 = {
|
|
1199
|
+
fontSize: 12,
|
|
1200
|
+
color: css("muted-foreground")
|
|
1201
|
+
};
|
|
1202
|
+
function EntitySelector({ widget }) {
|
|
1203
|
+
const select = useWidgetSelect();
|
|
1204
|
+
const target = optString(widget.options, "selectionTarget");
|
|
1205
|
+
const { candidates, loading, error, wired } = useCandidates(target);
|
|
1206
|
+
const title = optString(widget.options, "title");
|
|
1207
|
+
const [pending, setPending] = useState5(null);
|
|
1208
|
+
const selected = candidates.find((c) => c.selected);
|
|
1209
|
+
const selectedToken = selected ? tokenOf(selected.binding) : "";
|
|
1210
|
+
useEffect4(() => {
|
|
1211
|
+
if (pending !== null && (selectedToken === pending || !candidates.some((c) => tokenOf(c.binding) === pending))) {
|
|
1212
|
+
setPending(null);
|
|
1213
|
+
}
|
|
1214
|
+
}, [candidates, selectedToken, pending]);
|
|
1215
|
+
const inert = !select || !target || !wired;
|
|
1216
|
+
const value = pending ?? selectedToken;
|
|
1217
|
+
const onChange = (e) => {
|
|
1218
|
+
const token = e.target.value;
|
|
1219
|
+
if (!token) return;
|
|
1220
|
+
const candidate = candidates.find((c) => tokenOf(c.binding) === token);
|
|
1221
|
+
if (select && target && candidate) {
|
|
1222
|
+
setPending(token);
|
|
1223
|
+
select({ slot: target, binding: candidate.binding });
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
return /* @__PURE__ */ jsx6(WidgetFrame, { title, children: /* @__PURE__ */ jsx6("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "12px 16px", height: "100%" }, children: inert ? /* @__PURE__ */ jsx6("span", { style: hint2, children: select && target ? "Selection isn\u2019t available here." : select ? "No target slot configured." : "Selection is available on the live dashboard." }) : /* @__PURE__ */ jsxs5(Fragment3, { children: [
|
|
1227
|
+
/* @__PURE__ */ jsxs5(
|
|
1228
|
+
"select",
|
|
1229
|
+
{
|
|
1230
|
+
style: { ...control, opacity: loading ? 0.6 : 1 },
|
|
1231
|
+
value,
|
|
1232
|
+
disabled: candidates.length === 0,
|
|
1233
|
+
onChange,
|
|
1234
|
+
"aria-label": title || `Select ${target}`,
|
|
1235
|
+
children: [
|
|
1236
|
+
/* @__PURE__ */ jsx6("option", { value: "", disabled: true, children: loading ? "Loading\u2026" : candidates.length === 0 ? "No options" : "Select\u2026" }),
|
|
1237
|
+
candidates.map((c) => /* @__PURE__ */ jsx6("option", { value: tokenOf(c.binding), children: c.label }, tokenOf(c.binding)))
|
|
1238
|
+
]
|
|
1239
|
+
}
|
|
1240
|
+
),
|
|
1241
|
+
error ? /* @__PURE__ */ jsx6("span", { style: hint2, children: "Couldn\u2019t load options." }) : null
|
|
1242
|
+
] }) }) });
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// src/widgets/gauge.tsx
|
|
1246
|
+
import { useMemo as useMemo3 } from "react";
|
|
1247
|
+
|
|
1248
|
+
// src/chart-options.ts
|
|
1249
|
+
function distinctNames(samples) {
|
|
1250
|
+
const seen = [];
|
|
1251
|
+
for (const s of samples) {
|
|
1252
|
+
if (!seen.includes(s.name)) seen.push(s.name);
|
|
1253
|
+
}
|
|
1254
|
+
return seen;
|
|
1255
|
+
}
|
|
1256
|
+
var clamp2 = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
1257
|
+
function shortSide(size) {
|
|
1258
|
+
const s = Math.min(size?.width ?? 0, size?.height ?? 0);
|
|
1259
|
+
return s > 0 ? s : 200;
|
|
1260
|
+
}
|
|
1261
|
+
function buildLineOption(samples, theme, options = {}, size) {
|
|
1262
|
+
const names = options.measurements?.length ? options.measurements : distinctNames(samples);
|
|
1263
|
+
const axisFont = clamp2(Math.round(shortSide(size) / 22), 8, 12);
|
|
1264
|
+
const series = names.map((name, i) => {
|
|
1265
|
+
const color = theme.series[i % theme.series.length];
|
|
1266
|
+
return {
|
|
1267
|
+
name,
|
|
1268
|
+
type: "line",
|
|
1269
|
+
showSymbol: false,
|
|
1270
|
+
lineStyle: { color },
|
|
1271
|
+
itemStyle: { color },
|
|
1272
|
+
data: samples.filter((s) => s.name === name && s.value != null && s.occurredTime != null).map((s) => [s.occurredTime, s.value])
|
|
1273
|
+
};
|
|
1274
|
+
});
|
|
1275
|
+
return {
|
|
1276
|
+
animation: false,
|
|
1277
|
+
textStyle: { color: theme.foreground, fontSize: axisFont },
|
|
1278
|
+
grid: { left: 8, right: 12, top: names.length > 1 ? 28 : 12, bottom: 8, containLabel: true },
|
|
1279
|
+
tooltip: { trigger: "axis" },
|
|
1280
|
+
legend: names.length > 1 ? { textStyle: { color: theme.mutedForeground, fontSize: axisFont }, top: 0 } : void 0,
|
|
1281
|
+
xAxis: {
|
|
1282
|
+
type: "time",
|
|
1283
|
+
axisLine: { lineStyle: { color: theme.border } },
|
|
1284
|
+
axisLabel: { color: theme.mutedForeground, fontSize: axisFont, hideOverlap: true }
|
|
1285
|
+
},
|
|
1286
|
+
yAxis: {
|
|
1287
|
+
type: "value",
|
|
1288
|
+
splitLine: { lineStyle: { color: theme.border } },
|
|
1289
|
+
axisLabel: { color: theme.mutedForeground, fontSize: axisFont }
|
|
1290
|
+
},
|
|
1291
|
+
series
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function buildGaugeOption(value, theme, options = {}, size) {
|
|
1295
|
+
const min = options.min ?? 0;
|
|
1296
|
+
const max = options.max ?? 100;
|
|
1297
|
+
const accent = theme.series[0];
|
|
1298
|
+
const unitSuffix = options.unit ? ` ${options.unit}` : "";
|
|
1299
|
+
const s = shortSide(size);
|
|
1300
|
+
const arcWidth = clamp2(Math.round(s / 26), 4, 12);
|
|
1301
|
+
const labelFont = clamp2(Math.round(s / 20), 8, 13);
|
|
1302
|
+
const valueFont = clamp2(Math.round(s / 7), 14, 40);
|
|
1303
|
+
return {
|
|
1304
|
+
animation: false,
|
|
1305
|
+
series: [
|
|
1306
|
+
{
|
|
1307
|
+
type: "gauge",
|
|
1308
|
+
min,
|
|
1309
|
+
max,
|
|
1310
|
+
splitNumber: s < 160 ? 4 : 5,
|
|
1311
|
+
// fewer ticks than the default 10 → no cram
|
|
1312
|
+
radius: "95%",
|
|
1313
|
+
center: ["50%", "58%"],
|
|
1314
|
+
progress: { show: true, width: arcWidth, itemStyle: { color: accent } },
|
|
1315
|
+
pointer: { itemStyle: { color: accent }, length: "55%", width: clamp2(Math.round(s / 50), 2, 6) },
|
|
1316
|
+
anchor: { show: false },
|
|
1317
|
+
axisLine: { lineStyle: { width: arcWidth, color: [[1, theme.border]] } },
|
|
1318
|
+
axisTick: { show: false },
|
|
1319
|
+
splitLine: { length: arcWidth, lineStyle: { color: theme.border, width: 1 } },
|
|
1320
|
+
axisLabel: { color: theme.mutedForeground, fontSize: labelFont, distance: arcWidth + 2 },
|
|
1321
|
+
detail: {
|
|
1322
|
+
valueAnimation: false,
|
|
1323
|
+
color: theme.foreground,
|
|
1324
|
+
fontSize: valueFont,
|
|
1325
|
+
offsetCenter: [0, "30%"],
|
|
1326
|
+
formatter: (v) => value == null ? "\u2014" : `${v}${unitSuffix}`
|
|
1327
|
+
},
|
|
1328
|
+
data: [{ value: value ?? min }]
|
|
1329
|
+
}
|
|
1330
|
+
]
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// src/echart.tsx
|
|
1335
|
+
import { GaugeChart, LineChart } from "echarts/charts";
|
|
1336
|
+
import { GridComponent, TooltipComponent } from "echarts/components";
|
|
1337
|
+
import * as echarts from "echarts/core";
|
|
1338
|
+
import { CanvasRenderer } from "echarts/renderers";
|
|
1339
|
+
import { useEffect as useEffect5, useRef as useRef4 } from "react";
|
|
1340
|
+
import { jsx as jsx7 } from "react/jsx-runtime";
|
|
1341
|
+
echarts.use([LineChart, GaugeChart, GridComponent, TooltipComponent, CanvasRenderer]);
|
|
1342
|
+
function EChart({ option, className, style }) {
|
|
1343
|
+
const containerRef = useRef4(null);
|
|
1344
|
+
const chartRef = useRef4(null);
|
|
1345
|
+
useEffect5(() => {
|
|
1346
|
+
const el = containerRef.current;
|
|
1347
|
+
if (!el) return;
|
|
1348
|
+
const chart = echarts.init(el);
|
|
1349
|
+
chartRef.current = chart;
|
|
1350
|
+
const observer = new ResizeObserver(() => chart.resize());
|
|
1351
|
+
observer.observe(el);
|
|
1352
|
+
return () => {
|
|
1353
|
+
observer.disconnect();
|
|
1354
|
+
chart.dispose();
|
|
1355
|
+
chartRef.current = null;
|
|
1356
|
+
};
|
|
1357
|
+
}, []);
|
|
1358
|
+
useEffect5(() => {
|
|
1359
|
+
chartRef.current?.setOption(option, { notMerge: true });
|
|
1360
|
+
}, [option]);
|
|
1361
|
+
return /* @__PURE__ */ jsx7("div", { ref: containerRef, className, style: { width: "100%", height: "100%", ...style } });
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// src/widgets/gauge.tsx
|
|
1365
|
+
import { jsx as jsx8 } from "react/jsx-runtime";
|
|
1366
|
+
function Gauge({ widget, data }) {
|
|
1367
|
+
const theme = useChartTheme();
|
|
1368
|
+
const [sizeRef, size] = useElementSize();
|
|
1369
|
+
const name = primaryMeasurementName(widget);
|
|
1370
|
+
const sample = pickSample(data.latest, name);
|
|
1371
|
+
const min = optNumber(widget.options, "min");
|
|
1372
|
+
const max = optNumber(widget.options, "max");
|
|
1373
|
+
const unit = optString(widget.options, "unit");
|
|
1374
|
+
const option = useMemo3(
|
|
1375
|
+
() => buildGaugeOption(sample?.value ?? null, theme, { min, max, unit }, size),
|
|
1376
|
+
[sample?.value, theme, min, max, unit, size.width, size.height]
|
|
1377
|
+
);
|
|
1378
|
+
const flashDirection = useFlashOnChange(
|
|
1379
|
+
sample?.value,
|
|
1380
|
+
optBoolean(widget.options, "flashOnChange"),
|
|
1381
|
+
sample && `${sample.deviceToken}:${sample.name}`
|
|
1382
|
+
);
|
|
1383
|
+
return /* @__PURE__ */ jsx8(WidgetFrame, { title: optString(widget.options, "title") ?? name, children: /* @__PURE__ */ jsx8("div", { ref: sizeRef, style: { width: "100%", height: "100%", ...flashBackgroundStyle(flashDirection) }, children: /* @__PURE__ */ jsx8(EChart, { option }) }) });
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// src/widgets/image.tsx
|
|
1387
|
+
import { jsx as jsx9 } from "react/jsx-runtime";
|
|
1388
|
+
var FITS = /* @__PURE__ */ new Set(["contain", "cover", "fill"]);
|
|
1389
|
+
function Image({ widget }) {
|
|
1390
|
+
const url = optString(widget.options, "url");
|
|
1391
|
+
const alt = optString(widget.options, "alt") ?? "";
|
|
1392
|
+
const fitOption = optString(widget.options, "fit");
|
|
1393
|
+
const objectFit = fitOption && FITS.has(fitOption) ? fitOption : "contain";
|
|
1394
|
+
return /* @__PURE__ */ jsx9(WidgetFrame, { title: optString(widget.options, "title"), children: url ? /* @__PURE__ */ jsx9("img", { src: url, alt, style: { width: "100%", height: "100%", objectFit, display: "block" } }) : /* @__PURE__ */ jsx9(
|
|
1395
|
+
"div",
|
|
1396
|
+
{
|
|
1397
|
+
style: {
|
|
1398
|
+
display: "flex",
|
|
1399
|
+
alignItems: "center",
|
|
1400
|
+
justifyContent: "center",
|
|
1401
|
+
height: "100%",
|
|
1402
|
+
color: css("muted-foreground"),
|
|
1403
|
+
fontSize: 13
|
|
1404
|
+
},
|
|
1405
|
+
children: "No image URL"
|
|
1406
|
+
}
|
|
1407
|
+
) });
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// src/widgets/label.tsx
|
|
1411
|
+
import { jsx as jsx10 } from "react/jsx-runtime";
|
|
1412
|
+
var justify = {
|
|
1413
|
+
left: "flex-start",
|
|
1414
|
+
center: "center",
|
|
1415
|
+
right: "flex-end"
|
|
1416
|
+
};
|
|
1417
|
+
function Label({ widget }) {
|
|
1418
|
+
const text = optString(widget.options, "text") ?? "";
|
|
1419
|
+
const align = optString(widget.options, "align") ?? "center";
|
|
1420
|
+
const fontSize = optNumber(widget.options, "fontSize") ?? 16;
|
|
1421
|
+
return /* @__PURE__ */ jsx10(WidgetFrame, { title: optString(widget.options, "title"), children: /* @__PURE__ */ jsx10(
|
|
1422
|
+
"div",
|
|
1423
|
+
{
|
|
1424
|
+
style: {
|
|
1425
|
+
display: "flex",
|
|
1426
|
+
alignItems: "center",
|
|
1427
|
+
justifyContent: justify[align] ?? "center",
|
|
1428
|
+
height: "100%",
|
|
1429
|
+
padding: 12
|
|
1430
|
+
},
|
|
1431
|
+
children: /* @__PURE__ */ jsx10(
|
|
1432
|
+
"span",
|
|
1433
|
+
{
|
|
1434
|
+
style: {
|
|
1435
|
+
fontSize,
|
|
1436
|
+
color: css("foreground"),
|
|
1437
|
+
textAlign: align,
|
|
1438
|
+
whiteSpace: "pre-wrap"
|
|
1439
|
+
},
|
|
1440
|
+
children: text
|
|
1441
|
+
}
|
|
1442
|
+
)
|
|
1443
|
+
}
|
|
1444
|
+
) });
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// src/widgets/latest-card.tsx
|
|
1448
|
+
import { jsx as jsx11, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
1449
|
+
var clamp3 = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
1450
|
+
function LatestCard({ widget, data }) {
|
|
1451
|
+
const [sizeRef, size] = useElementSize();
|
|
1452
|
+
const name = primaryMeasurementName(widget);
|
|
1453
|
+
const sample = pickSample(data.latest, name);
|
|
1454
|
+
const title = optString(widget.options, "title") ?? name;
|
|
1455
|
+
const unit = optString(widget.options, "unit");
|
|
1456
|
+
const precision = optNumber(widget.options, "precision");
|
|
1457
|
+
const display = formatValue(sample?.value, precision);
|
|
1458
|
+
const flashDirection = useFlashOnChange(
|
|
1459
|
+
sample?.value,
|
|
1460
|
+
optBoolean(widget.options, "flashOnChange"),
|
|
1461
|
+
sample && `${sample.deviceToken}:${sample.name}`
|
|
1462
|
+
);
|
|
1463
|
+
const valueFont = size.height > 0 ? clamp3(Math.round(size.height / 3.5), 16, 44) : 32;
|
|
1464
|
+
return /* @__PURE__ */ jsx11(WidgetFrame, { title, children: /* @__PURE__ */ jsxs6(
|
|
1465
|
+
"div",
|
|
1466
|
+
{
|
|
1467
|
+
ref: sizeRef,
|
|
1468
|
+
style: {
|
|
1469
|
+
display: "flex",
|
|
1470
|
+
flexDirection: "column",
|
|
1471
|
+
justifyContent: "center",
|
|
1472
|
+
height: "100%",
|
|
1473
|
+
padding: "12px 16px"
|
|
1474
|
+
},
|
|
1475
|
+
children: [
|
|
1476
|
+
/* @__PURE__ */ jsxs6(
|
|
1477
|
+
"div",
|
|
1478
|
+
{
|
|
1479
|
+
style: {
|
|
1480
|
+
fontSize: valueFont,
|
|
1481
|
+
fontWeight: 600,
|
|
1482
|
+
lineHeight: 1.1,
|
|
1483
|
+
color: css("foreground"),
|
|
1484
|
+
...flashTextStyle(flashDirection)
|
|
1485
|
+
},
|
|
1486
|
+
children: [
|
|
1487
|
+
display,
|
|
1488
|
+
sample?.value != null && unit ? /* @__PURE__ */ jsx11("span", { style: { fontSize: Math.round(valueFont / 2), color: css("muted-foreground"), marginLeft: 6 }, children: unit }) : null
|
|
1489
|
+
]
|
|
1490
|
+
}
|
|
1491
|
+
),
|
|
1492
|
+
sample?.occurredTime ? /* @__PURE__ */ jsx11("div", { style: { fontSize: 11, color: css("muted-foreground"), marginTop: 4 }, children: formatTimestamp(sample.occurredTime) }) : null
|
|
1493
|
+
]
|
|
1494
|
+
}
|
|
1495
|
+
) });
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// src/widgets/map.tsx
|
|
1499
|
+
import { useEffect as useEffect6, useRef as useRef5, useState as useState6 } from "react";
|
|
1500
|
+
import { renderableBasemap, resolveBasemap } from "@devicechain/client";
|
|
1501
|
+
|
|
1502
|
+
// src/basemap-context.tsx
|
|
1503
|
+
import { createContext as createContext2, useContext as useContext2 } from "react";
|
|
1504
|
+
import { jsx as jsx12 } from "react/jsx-runtime";
|
|
1505
|
+
var TenantBasemapContext = createContext2(null);
|
|
1506
|
+
function TenantBasemapProvider({
|
|
1507
|
+
basemap,
|
|
1508
|
+
children
|
|
1509
|
+
}) {
|
|
1510
|
+
return /* @__PURE__ */ jsx12(TenantBasemapContext.Provider, { value: basemap, children });
|
|
1511
|
+
}
|
|
1512
|
+
function useTenantBasemap() {
|
|
1513
|
+
return useContext2(TenantBasemapContext);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// src/map-runtime-context.tsx
|
|
1517
|
+
import { createContext as createContext3, useContext as useContext3 } from "react";
|
|
1518
|
+
import { jsx as jsx13 } from "react/jsx-runtime";
|
|
1519
|
+
var MapRuntimeContext = createContext3(null);
|
|
1520
|
+
function MapRuntimeProvider({
|
|
1521
|
+
runtime,
|
|
1522
|
+
children
|
|
1523
|
+
}) {
|
|
1524
|
+
return /* @__PURE__ */ jsx13(MapRuntimeContext.Provider, { value: runtime, children });
|
|
1525
|
+
}
|
|
1526
|
+
function useMapRuntime() {
|
|
1527
|
+
return useContext3(MapRuntimeContext);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// src/widgets/map-geometry.ts
|
|
1531
|
+
function placeable(samples) {
|
|
1532
|
+
return samples.filter(
|
|
1533
|
+
(s) => s.latitude != null && s.longitude != null
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
var PANEL_PADDING = 0.1;
|
|
1537
|
+
function projectToPanel(positions) {
|
|
1538
|
+
if (positions.length === 0) return [];
|
|
1539
|
+
let minLat = Infinity;
|
|
1540
|
+
let maxLat = -Infinity;
|
|
1541
|
+
let minLon = Infinity;
|
|
1542
|
+
let maxLon = -Infinity;
|
|
1543
|
+
for (const p of positions) {
|
|
1544
|
+
if (p.latitude < minLat) minLat = p.latitude;
|
|
1545
|
+
if (p.latitude > maxLat) maxLat = p.latitude;
|
|
1546
|
+
if (p.longitude < minLon) minLon = p.longitude;
|
|
1547
|
+
if (p.longitude > maxLon) maxLon = p.longitude;
|
|
1548
|
+
}
|
|
1549
|
+
const lonSpan = maxLon - minLon;
|
|
1550
|
+
const latSpan = maxLat - minLat;
|
|
1551
|
+
const scale = 1 - 2 * PANEL_PADDING;
|
|
1552
|
+
return positions.map((p) => ({
|
|
1553
|
+
x: PANEL_PADDING + scale * (lonSpan === 0 ? 0.5 : (p.longitude - minLon) / lonSpan),
|
|
1554
|
+
// maxLat at the TOP: screen y grows downward, latitude grows northward.
|
|
1555
|
+
y: PANEL_PADDING + scale * (latSpan === 0 ? 0.5 : (maxLat - p.latitude) / latSpan)
|
|
1556
|
+
}));
|
|
1557
|
+
}
|
|
1558
|
+
function formatDegrees(value) {
|
|
1559
|
+
return value.toFixed(6);
|
|
1560
|
+
}
|
|
1561
|
+
function describePosition(sample) {
|
|
1562
|
+
const parts = [
|
|
1563
|
+
sample.deviceToken,
|
|
1564
|
+
`${formatDegrees(sample.latitude)}, ${formatDegrees(sample.longitude)}`
|
|
1565
|
+
];
|
|
1566
|
+
if (sample.elevation != null) parts.push(`${sample.elevation} m`);
|
|
1567
|
+
if (sample.accuracy != null) parts.push(`\xB1${sample.accuracy} m`);
|
|
1568
|
+
if (sample.speed != null) parts.push(`${sample.speed} m/s`);
|
|
1569
|
+
if (sample.heading != null) parts.push(`${sample.heading}\xB0`);
|
|
1570
|
+
return parts.join(" \xB7 ");
|
|
1571
|
+
}
|
|
1572
|
+
function rasterStyleFor(tileUrl, attribution) {
|
|
1573
|
+
return {
|
|
1574
|
+
version: 8,
|
|
1575
|
+
projection: PROJECTION,
|
|
1576
|
+
sources: {
|
|
1577
|
+
basemap: {
|
|
1578
|
+
type: "raster",
|
|
1579
|
+
tiles: [tileUrl],
|
|
1580
|
+
tileSize: 256,
|
|
1581
|
+
...attribution ? { attribution } : {}
|
|
1582
|
+
}
|
|
1583
|
+
},
|
|
1584
|
+
layers: [{ id: "basemap", type: "raster", source: "basemap" }]
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
var PROJECTION = { type: "mercator" };
|
|
1588
|
+
var BUNDLED_BASEMAP_ATTRIBUTION = "Made with Natural Earth";
|
|
1589
|
+
var OCEAN = "#0b1220";
|
|
1590
|
+
var LAND = "#1e293b";
|
|
1591
|
+
var BOUNDARY = "#475569";
|
|
1592
|
+
function landStyleFrom(land, boundaries) {
|
|
1593
|
+
return {
|
|
1594
|
+
version: 8,
|
|
1595
|
+
projection: PROJECTION,
|
|
1596
|
+
sources: {
|
|
1597
|
+
land: { type: "geojson", data: land, attribution: BUNDLED_BASEMAP_ATTRIBUTION },
|
|
1598
|
+
boundaries: { type: "geojson", data: boundaries }
|
|
1599
|
+
},
|
|
1600
|
+
layers: [
|
|
1601
|
+
{ id: "ocean", type: "background", paint: { "background-color": OCEAN } },
|
|
1602
|
+
{ id: "land", type: "fill", source: "land", paint: { "fill-color": LAND } },
|
|
1603
|
+
{
|
|
1604
|
+
id: "boundaries",
|
|
1605
|
+
type: "line",
|
|
1606
|
+
source: "boundaries",
|
|
1607
|
+
paint: { "line-color": BOUNDARY, "line-width": 0.5 }
|
|
1608
|
+
}
|
|
1609
|
+
]
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
var landStylePromise;
|
|
1613
|
+
function loadLandStyle() {
|
|
1614
|
+
if (!landStylePromise) {
|
|
1615
|
+
landStylePromise = import("./chunks/natural-earth-data-VMKQ6LY3.js").then(
|
|
1616
|
+
(mod) => landStyleFrom(mod.NATURAL_EARTH.land, mod.NATURAL_EARTH.boundaries)
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
return landStylePromise;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// src/widgets/map.tsx
|
|
1623
|
+
import { jsx as jsx14, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
1624
|
+
var mapLibrePromise;
|
|
1625
|
+
function loadMapLibre(runtime) {
|
|
1626
|
+
if (!mapLibrePromise) {
|
|
1627
|
+
mapLibrePromise = Promise.all([
|
|
1628
|
+
import("maplibre-gl"),
|
|
1629
|
+
Promise.resolve().then(() => runtime.loadStyles?.())
|
|
1630
|
+
]).then(([mod]) => {
|
|
1631
|
+
mod.setWorkerUrl(runtime.workerUrl);
|
|
1632
|
+
return mod;
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
return mapLibrePromise;
|
|
1636
|
+
}
|
|
1637
|
+
var SINGLE_DEVICE_ZOOM = 14;
|
|
1638
|
+
function MapWidget({ widget, data }) {
|
|
1639
|
+
const title = optString(widget.options, "title");
|
|
1640
|
+
const positions = placeable(data.locations);
|
|
1641
|
+
const runtime = useMapRuntime();
|
|
1642
|
+
const basemap = renderableBasemap(
|
|
1643
|
+
resolveBasemap(
|
|
1644
|
+
{
|
|
1645
|
+
tileUrl: optString(widget.options, "tileUrl"),
|
|
1646
|
+
attribution: optString(widget.options, "attribution")
|
|
1647
|
+
},
|
|
1648
|
+
useTenantBasemap()
|
|
1649
|
+
)
|
|
1650
|
+
);
|
|
1651
|
+
if (data.forbidden) {
|
|
1652
|
+
return /* @__PURE__ */ jsx14(WidgetFrame, { title, children: /* @__PURE__ */ jsx14(
|
|
1653
|
+
MapNotice,
|
|
1654
|
+
{
|
|
1655
|
+
glyph: "\u{1F512}",
|
|
1656
|
+
heading: "Location not permitted",
|
|
1657
|
+
detail: "You do not have permission to view device locations."
|
|
1658
|
+
}
|
|
1659
|
+
) });
|
|
1660
|
+
}
|
|
1661
|
+
if (!runtime) {
|
|
1662
|
+
return /* @__PURE__ */ jsx14(WidgetFrame, { title, children: /* @__PURE__ */ jsx14(
|
|
1663
|
+
MapNotice,
|
|
1664
|
+
{
|
|
1665
|
+
glyph: "\u{1F9E9}",
|
|
1666
|
+
heading: "Map runtime not configured",
|
|
1667
|
+
detail: "This host must install MapRuntimeProvider with its bundler's MapLibre worker URL."
|
|
1668
|
+
}
|
|
1669
|
+
) });
|
|
1670
|
+
}
|
|
1671
|
+
if (data.loading && positions.length === 0) {
|
|
1672
|
+
return /* @__PURE__ */ jsx14(WidgetFrame, { title, children: /* @__PURE__ */ jsx14(MapNotice, { heading: "Loading\u2026" }) });
|
|
1673
|
+
}
|
|
1674
|
+
if (positions.length === 0) {
|
|
1675
|
+
return /* @__PURE__ */ jsx14(WidgetFrame, { title, children: /* @__PURE__ */ jsx14(
|
|
1676
|
+
MapNotice,
|
|
1677
|
+
{
|
|
1678
|
+
heading: data.deviceTokens.length === 0 ? "No devices selected" : "No positions reported",
|
|
1679
|
+
detail: data.deviceTokens.length === 0 ? "Bind this widget to a device or anchor and select its location series." : "None of the selected devices has reported a position yet."
|
|
1680
|
+
}
|
|
1681
|
+
) });
|
|
1682
|
+
}
|
|
1683
|
+
return /* @__PURE__ */ jsx14(WidgetFrame, { title, bodyStyle: { padding: 0 }, children: /* @__PURE__ */ jsx14(LiveMap, { basemap, positions, runtime }) });
|
|
1684
|
+
}
|
|
1685
|
+
function PlainPanel({ positions, note }) {
|
|
1686
|
+
const points = projectToPanel(positions);
|
|
1687
|
+
return /* @__PURE__ */ jsxs7(
|
|
1688
|
+
"div",
|
|
1689
|
+
{
|
|
1690
|
+
"data-testid": "map-plain-panel",
|
|
1691
|
+
style: {
|
|
1692
|
+
position: "relative",
|
|
1693
|
+
width: "100%",
|
|
1694
|
+
height: "100%",
|
|
1695
|
+
overflow: "hidden",
|
|
1696
|
+
background: css("muted")
|
|
1697
|
+
},
|
|
1698
|
+
children: [
|
|
1699
|
+
positions.map((position, i) => /* @__PURE__ */ jsx14(
|
|
1700
|
+
Marker,
|
|
1701
|
+
{
|
|
1702
|
+
position,
|
|
1703
|
+
style: {
|
|
1704
|
+
position: "absolute",
|
|
1705
|
+
left: `${points[i].x * 100}%`,
|
|
1706
|
+
top: `${points[i].y * 100}%`,
|
|
1707
|
+
transform: "translate(-50%, -50%)"
|
|
1708
|
+
}
|
|
1709
|
+
},
|
|
1710
|
+
position.deviceToken
|
|
1711
|
+
)),
|
|
1712
|
+
/* @__PURE__ */ jsx14(
|
|
1713
|
+
"div",
|
|
1714
|
+
{
|
|
1715
|
+
"data-testid": "map-no-tiles-note",
|
|
1716
|
+
style: {
|
|
1717
|
+
position: "absolute",
|
|
1718
|
+
left: 0,
|
|
1719
|
+
right: 0,
|
|
1720
|
+
bottom: 0,
|
|
1721
|
+
padding: "4px 8px",
|
|
1722
|
+
fontSize: 10,
|
|
1723
|
+
lineHeight: 1.3,
|
|
1724
|
+
textAlign: "center",
|
|
1725
|
+
color: css("muted-foreground"),
|
|
1726
|
+
background: css("card"),
|
|
1727
|
+
opacity: 0.9
|
|
1728
|
+
},
|
|
1729
|
+
children: note
|
|
1730
|
+
}
|
|
1731
|
+
)
|
|
1732
|
+
]
|
|
1733
|
+
}
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
function LiveMap({
|
|
1737
|
+
basemap,
|
|
1738
|
+
positions,
|
|
1739
|
+
runtime
|
|
1740
|
+
}) {
|
|
1741
|
+
const containerRef = useRef5(null);
|
|
1742
|
+
const mapRef = useRef5(null);
|
|
1743
|
+
const markersRef = useRef5([]);
|
|
1744
|
+
const [status, setStatus] = useState6("loading");
|
|
1745
|
+
const tileUrl = basemap?.tileUrl ?? null;
|
|
1746
|
+
const attribution = basemap?.attribution ?? null;
|
|
1747
|
+
useEffect6(() => {
|
|
1748
|
+
let cancelled = false;
|
|
1749
|
+
setStatus("loading");
|
|
1750
|
+
Promise.all([
|
|
1751
|
+
loadMapLibre(runtime),
|
|
1752
|
+
tileUrl ? rasterStyleFor(tileUrl, attribution ?? void 0) : loadLandStyle()
|
|
1753
|
+
]).then(([maplibre, style]) => {
|
|
1754
|
+
const el = containerRef.current;
|
|
1755
|
+
if (cancelled || !el) return;
|
|
1756
|
+
const map = new maplibre.Map({
|
|
1757
|
+
container: el,
|
|
1758
|
+
style,
|
|
1759
|
+
center: [0, 0],
|
|
1760
|
+
zoom: 1
|
|
1761
|
+
});
|
|
1762
|
+
mapRef.current = map;
|
|
1763
|
+
setStatus("ready");
|
|
1764
|
+
}).catch(() => {
|
|
1765
|
+
if (!cancelled) setStatus("unavailable");
|
|
1766
|
+
});
|
|
1767
|
+
return () => {
|
|
1768
|
+
cancelled = true;
|
|
1769
|
+
for (const marker of markersRef.current) marker.remove();
|
|
1770
|
+
markersRef.current = [];
|
|
1771
|
+
mapRef.current?.remove();
|
|
1772
|
+
mapRef.current = null;
|
|
1773
|
+
};
|
|
1774
|
+
}, [tileUrl, attribution, runtime.workerUrl]);
|
|
1775
|
+
useEffect6(() => {
|
|
1776
|
+
const map = mapRef.current;
|
|
1777
|
+
if (status !== "ready" || !map) return;
|
|
1778
|
+
let live = true;
|
|
1779
|
+
loadMapLibre(runtime).then((maplibre) => {
|
|
1780
|
+
if (!live || mapRef.current !== map) return;
|
|
1781
|
+
for (const marker of markersRef.current) marker.remove();
|
|
1782
|
+
markersRef.current = positions.map((position) => {
|
|
1783
|
+
const el = document.createElement("div");
|
|
1784
|
+
el.setAttribute("data-testid", "map-marker");
|
|
1785
|
+
el.setAttribute("data-device", position.deviceToken);
|
|
1786
|
+
el.setAttribute("title", describePosition(position));
|
|
1787
|
+
el.setAttribute("aria-label", describePosition(position));
|
|
1788
|
+
Object.assign(el.style, MARKER_DOT_STYLE);
|
|
1789
|
+
return new maplibre.Marker({ element: el }).setLngLat([position.longitude, position.latitude]).addTo(map);
|
|
1790
|
+
});
|
|
1791
|
+
if (positions.length === 1) {
|
|
1792
|
+
map.jumpTo({ center: [positions[0].longitude, positions[0].latitude], zoom: SINGLE_DEVICE_ZOOM });
|
|
1793
|
+
} else if (positions.length > 1) {
|
|
1794
|
+
const bounds = new maplibre.LngLatBounds();
|
|
1795
|
+
for (const p of positions) bounds.extend([p.longitude, p.latitude]);
|
|
1796
|
+
map.fitBounds(bounds, { padding: 40, animate: false });
|
|
1797
|
+
}
|
|
1798
|
+
});
|
|
1799
|
+
return () => {
|
|
1800
|
+
live = false;
|
|
1801
|
+
};
|
|
1802
|
+
}, [status, positionsKey(positions)]);
|
|
1803
|
+
if (status === "unavailable") {
|
|
1804
|
+
return /* @__PURE__ */ jsx14(
|
|
1805
|
+
PlainPanel,
|
|
1806
|
+
{
|
|
1807
|
+
positions,
|
|
1808
|
+
note: "Map renderer unavailable \u2014 positions are shown without a basemap."
|
|
1809
|
+
}
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
return /* @__PURE__ */ jsx14("div", { ref: containerRef, "data-testid": "map-canvas", style: { width: "100%", height: "100%" } });
|
|
1813
|
+
}
|
|
1814
|
+
function positionsKey(positions) {
|
|
1815
|
+
return positions.map((p) => `${p.deviceToken}:${p.latitude}:${p.longitude}:${p.heading ?? ""}`).join("|");
|
|
1816
|
+
}
|
|
1817
|
+
var MARKER_DOT_STYLE = {
|
|
1818
|
+
width: "12px",
|
|
1819
|
+
height: "12px",
|
|
1820
|
+
borderRadius: "50%",
|
|
1821
|
+
background: css("primary"),
|
|
1822
|
+
border: `2px solid ${css("card")}`,
|
|
1823
|
+
boxShadow: "0 0 0 1px rgba(0,0,0,0.25)",
|
|
1824
|
+
cursor: "default"
|
|
1825
|
+
};
|
|
1826
|
+
function Marker({
|
|
1827
|
+
position,
|
|
1828
|
+
style
|
|
1829
|
+
}) {
|
|
1830
|
+
const label = describePosition(position);
|
|
1831
|
+
return /* @__PURE__ */ jsx14(
|
|
1832
|
+
"div",
|
|
1833
|
+
{
|
|
1834
|
+
"data-testid": "map-marker",
|
|
1835
|
+
"data-device": position.deviceToken,
|
|
1836
|
+
title: label,
|
|
1837
|
+
"aria-label": label,
|
|
1838
|
+
style: { ...MARKER_DOT_STYLE, ...style }
|
|
1839
|
+
}
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
function MapNotice({ glyph, heading, detail }) {
|
|
1843
|
+
return /* @__PURE__ */ jsxs7(
|
|
1844
|
+
"div",
|
|
1845
|
+
{
|
|
1846
|
+
style: {
|
|
1847
|
+
display: "flex",
|
|
1848
|
+
flexDirection: "column",
|
|
1849
|
+
alignItems: "center",
|
|
1850
|
+
justifyContent: "center",
|
|
1851
|
+
gap: 4,
|
|
1852
|
+
width: "100%",
|
|
1853
|
+
height: "100%",
|
|
1854
|
+
padding: 12,
|
|
1855
|
+
boxSizing: "border-box",
|
|
1856
|
+
textAlign: "center",
|
|
1857
|
+
color: css("muted-foreground")
|
|
1858
|
+
},
|
|
1859
|
+
children: [
|
|
1860
|
+
glyph ? /* @__PURE__ */ jsx14("div", { style: { fontSize: 20, lineHeight: 1 }, "aria-hidden": "true", children: glyph }) : null,
|
|
1861
|
+
/* @__PURE__ */ jsx14("div", { style: { fontSize: 13, fontWeight: 600 }, children: heading }),
|
|
1862
|
+
detail ? /* @__PURE__ */ jsx14("div", { style: { fontSize: 11, opacity: 0.8 }, children: detail }) : null
|
|
1863
|
+
]
|
|
1864
|
+
}
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// src/widgets/table.tsx
|
|
1869
|
+
import { jsx as jsx15, jsxs as jsxs8 } from "react/jsx-runtime";
|
|
1870
|
+
var cell2 = { padding: "6px 12px", textAlign: "left" };
|
|
1871
|
+
var headCell2 = {
|
|
1872
|
+
...cell2,
|
|
1873
|
+
fontSize: 11,
|
|
1874
|
+
fontWeight: 600,
|
|
1875
|
+
textTransform: "uppercase",
|
|
1876
|
+
letterSpacing: "0.04em",
|
|
1877
|
+
color: css("muted-foreground"),
|
|
1878
|
+
borderBottom: `1px solid ${css("border")}`
|
|
1879
|
+
};
|
|
1880
|
+
function Table({ widget, data }) {
|
|
1881
|
+
const rows = Object.values(data.latest).sort((a, b) => a.name.localeCompare(b.name));
|
|
1882
|
+
const precision = optNumber(widget.options, "precision");
|
|
1883
|
+
const flash = optBoolean(widget.options, "flashOnChange");
|
|
1884
|
+
return /* @__PURE__ */ jsx15(WidgetFrame, { title: optString(widget.options, "title"), children: /* @__PURE__ */ jsx15("div", { style: { height: "100%", overflow: "auto" }, children: /* @__PURE__ */ jsxs8("table", { style: { width: "100%", borderCollapse: "collapse", fontSize: 13, color: css("foreground") }, children: [
|
|
1885
|
+
/* @__PURE__ */ jsx15("thead", { children: /* @__PURE__ */ jsxs8("tr", { children: [
|
|
1886
|
+
/* @__PURE__ */ jsx15("th", { style: headCell2, children: "Measurement" }),
|
|
1887
|
+
/* @__PURE__ */ jsx15("th", { style: headCell2, children: "Value" }),
|
|
1888
|
+
/* @__PURE__ */ jsx15("th", { style: headCell2, children: "Time" })
|
|
1889
|
+
] }) }),
|
|
1890
|
+
/* @__PURE__ */ jsx15("tbody", { children: rows.length === 0 ? /* @__PURE__ */ jsx15("tr", { children: /* @__PURE__ */ jsx15("td", { colSpan: 3, style: { ...cell2, textAlign: "center", color: css("muted-foreground") }, children: "No data yet" }) }) : rows.map((row) => /* @__PURE__ */ jsxs8("tr", { children: [
|
|
1891
|
+
/* @__PURE__ */ jsx15("td", { style: cell2, children: row.name }),
|
|
1892
|
+
/* @__PURE__ */ jsx15("td", { style: { ...cell2, fontVariantNumeric: "tabular-nums" }, children: /* @__PURE__ */ jsx15(
|
|
1893
|
+
FlashValue,
|
|
1894
|
+
{
|
|
1895
|
+
value: row.value,
|
|
1896
|
+
precision,
|
|
1897
|
+
enabled: flash,
|
|
1898
|
+
identity: `${row.deviceToken}:${row.name}`
|
|
1899
|
+
}
|
|
1900
|
+
) }),
|
|
1901
|
+
/* @__PURE__ */ jsx15("td", { style: { ...cell2, color: css("muted-foreground") }, children: formatTimestamp(row.occurredTime) })
|
|
1902
|
+
] }, row.name)) })
|
|
1903
|
+
] }) }) });
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// src/widgets/time-series-chart.tsx
|
|
1907
|
+
import { useMemo as useMemo4 } from "react";
|
|
1908
|
+
import { jsx as jsx16 } from "react/jsx-runtime";
|
|
1909
|
+
function TimeSeriesChart({ widget, data }) {
|
|
1910
|
+
const theme = useChartTheme();
|
|
1911
|
+
const [sizeRef, size] = useElementSize();
|
|
1912
|
+
const measurements = widget.datasource?.measurements;
|
|
1913
|
+
const option = useMemo4(
|
|
1914
|
+
() => buildLineOption(data.samples, theme, { measurements }, size),
|
|
1915
|
+
[data.samples, theme, measurements, size.width, size.height]
|
|
1916
|
+
);
|
|
1917
|
+
return /* @__PURE__ */ jsx16(WidgetFrame, { title: optString(widget.options, "title"), children: /* @__PURE__ */ jsx16("div", { ref: sizeRef, style: { width: "100%", height: "100%" }, children: /* @__PURE__ */ jsx16(EChart, { option }) }) });
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// src/registry.ts
|
|
1921
|
+
var WIDGET_CHANNEL = {
|
|
1922
|
+
"timeseries-chart": "measurement",
|
|
1923
|
+
"latest-card": "measurement",
|
|
1924
|
+
gauge: "measurement",
|
|
1925
|
+
table: "measurement",
|
|
1926
|
+
label: "measurement",
|
|
1927
|
+
image: "measurement",
|
|
1928
|
+
"alarm-table": "alarm",
|
|
1929
|
+
"alarm-count": "alarm",
|
|
1930
|
+
"command-button": "control",
|
|
1931
|
+
"entity-selector": "selection",
|
|
1932
|
+
map: "location"
|
|
1933
|
+
};
|
|
1934
|
+
var WIDGET_REGISTRY = {
|
|
1935
|
+
"timeseries-chart": TimeSeriesChart,
|
|
1936
|
+
"latest-card": LatestCard,
|
|
1937
|
+
gauge: Gauge,
|
|
1938
|
+
table: Table,
|
|
1939
|
+
label: Label,
|
|
1940
|
+
image: Image
|
|
1941
|
+
};
|
|
1942
|
+
var ALARM_WIDGET_REGISTRY = {
|
|
1943
|
+
"alarm-table": AlarmTable,
|
|
1944
|
+
"alarm-count": AlarmCount
|
|
1945
|
+
};
|
|
1946
|
+
var CONTROL_WIDGET_REGISTRY = {
|
|
1947
|
+
"command-button": CommandButton
|
|
1948
|
+
};
|
|
1949
|
+
var SELECTION_WIDGET_REGISTRY = {
|
|
1950
|
+
"entity-selector": EntitySelector
|
|
1951
|
+
};
|
|
1952
|
+
var LOCATION_WIDGET_REGISTRY = {
|
|
1953
|
+
map: MapWidget
|
|
1954
|
+
};
|
|
1955
|
+
var WIDGET_BINDS_DATASOURCE = {
|
|
1956
|
+
"timeseries-chart": true,
|
|
1957
|
+
"latest-card": true,
|
|
1958
|
+
gauge: true,
|
|
1959
|
+
table: true,
|
|
1960
|
+
label: false,
|
|
1961
|
+
image: false,
|
|
1962
|
+
"alarm-table": true,
|
|
1963
|
+
"alarm-count": true,
|
|
1964
|
+
"command-button": true,
|
|
1965
|
+
"entity-selector": false,
|
|
1966
|
+
// A map binds a datasource, and needs it: the selector names both the devices whose
|
|
1967
|
+
// positions to plot and (in its own field) the location series to read.
|
|
1968
|
+
map: true
|
|
1969
|
+
};
|
|
1970
|
+
|
|
1971
|
+
// src/connected-widget.tsx
|
|
1972
|
+
import { jsx as jsx17, jsxs as jsxs9 } from "react/jsx-runtime";
|
|
1973
|
+
var DEFAULT_ALARM_ROWS = 50;
|
|
1974
|
+
var DEFAULT_COMMAND_ROWS = 20;
|
|
1975
|
+
function ConnectedWidget({ widget, hub, actions, initialSamples }) {
|
|
1976
|
+
const availability = useDatasourceAvailability(hub, widget.datasource);
|
|
1977
|
+
if (availability === "unavailable") {
|
|
1978
|
+
return /* @__PURE__ */ jsx17(WidgetUnavailableFrame, { widget });
|
|
1979
|
+
}
|
|
1980
|
+
const channel = WIDGET_CHANNEL[widget.type];
|
|
1981
|
+
if (channel === "alarm") {
|
|
1982
|
+
return /* @__PURE__ */ jsx17(AlarmConnectedWidget, { widget, hub, actions });
|
|
1983
|
+
}
|
|
1984
|
+
if (channel === "control") {
|
|
1985
|
+
return /* @__PURE__ */ jsx17(ControlConnectedWidget, { widget, hub, actions });
|
|
1986
|
+
}
|
|
1987
|
+
if (channel === "selection") {
|
|
1988
|
+
return /* @__PURE__ */ jsx17(SelectionConnectedWidget, { widget });
|
|
1989
|
+
}
|
|
1990
|
+
if (channel === "location") {
|
|
1991
|
+
return /* @__PURE__ */ jsx17(LocationConnectedWidget, { widget, hub });
|
|
1992
|
+
}
|
|
1993
|
+
return /* @__PURE__ */ jsx17(MeasurementConnectedWidget, { widget, hub, initialSamples });
|
|
1994
|
+
}
|
|
1995
|
+
function SelectionConnectedWidget({ widget }) {
|
|
1996
|
+
const Component = SELECTION_WIDGET_REGISTRY[widget.type];
|
|
1997
|
+
if (!Component) return null;
|
|
1998
|
+
return /* @__PURE__ */ jsx17(Component, { widget, data: null });
|
|
1999
|
+
}
|
|
2000
|
+
function MeasurementConnectedWidget({ widget, hub, initialSamples }) {
|
|
2001
|
+
const data = useMeasurementStream(hub, widget.datasource, {
|
|
2002
|
+
window: optNumber(widget.options, "window"),
|
|
2003
|
+
initialSamples
|
|
2004
|
+
});
|
|
2005
|
+
if (data.error) return /* @__PURE__ */ jsx17(WidgetErrorFrame, { widget, error: data.error });
|
|
2006
|
+
const Component = WIDGET_REGISTRY[widget.type];
|
|
2007
|
+
if (!Component) return null;
|
|
2008
|
+
return /* @__PURE__ */ jsx17(Component, { widget, data });
|
|
2009
|
+
}
|
|
2010
|
+
function AlarmConnectedWidget({
|
|
2011
|
+
widget,
|
|
2012
|
+
hub,
|
|
2013
|
+
actions
|
|
2014
|
+
}) {
|
|
2015
|
+
const data = useAlarmStream(hub, alarmSubscription(widget));
|
|
2016
|
+
if (data.error && data.alarms.length === 0 && data.total === 0) {
|
|
2017
|
+
return /* @__PURE__ */ jsx17(WidgetErrorFrame, { widget, error: data.error });
|
|
2018
|
+
}
|
|
2019
|
+
const Component = ALARM_WIDGET_REGISTRY[widget.type];
|
|
2020
|
+
if (!Component) return null;
|
|
2021
|
+
return /* @__PURE__ */ jsx17(Component, { widget, data, actions });
|
|
2022
|
+
}
|
|
2023
|
+
function ControlConnectedWidget({
|
|
2024
|
+
widget,
|
|
2025
|
+
hub,
|
|
2026
|
+
actions
|
|
2027
|
+
}) {
|
|
2028
|
+
const data = useCommandStream(hub, commandSubscription(widget));
|
|
2029
|
+
const Component = CONTROL_WIDGET_REGISTRY[widget.type];
|
|
2030
|
+
if (!Component) return null;
|
|
2031
|
+
return /* @__PURE__ */ jsx17(Component, { widget, data, actions });
|
|
2032
|
+
}
|
|
2033
|
+
function LocationConnectedWidget({
|
|
2034
|
+
widget,
|
|
2035
|
+
hub
|
|
2036
|
+
}) {
|
|
2037
|
+
const data = useLocationStream(hub, locationSubscription(widget));
|
|
2038
|
+
if (data.error && data.locations.length === 0) {
|
|
2039
|
+
return /* @__PURE__ */ jsx17(WidgetErrorFrame, { widget, error: data.error });
|
|
2040
|
+
}
|
|
2041
|
+
const Component = LOCATION_WIDGET_REGISTRY[widget.type];
|
|
2042
|
+
if (!Component) return null;
|
|
2043
|
+
return /* @__PURE__ */ jsx17(Component, { widget, data });
|
|
2044
|
+
}
|
|
2045
|
+
function locationSubscription(widget) {
|
|
2046
|
+
return { datasource: widget.datasource };
|
|
2047
|
+
}
|
|
2048
|
+
function commandSubscription(widget) {
|
|
2049
|
+
return {
|
|
2050
|
+
datasource: widget.datasource,
|
|
2051
|
+
pageSize: Math.max(1, optNumber(widget.options, "maxRows") ?? DEFAULT_COMMAND_ROWS)
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
function alarmSubscription(widget) {
|
|
2055
|
+
const pageSize = widget.type === "alarm-count" ? DEFAULT_ALARM_ROWS : Math.max(1, optNumber(widget.options, "maxRows") ?? DEFAULT_ALARM_ROWS);
|
|
2056
|
+
return {
|
|
2057
|
+
datasource: widget.datasource,
|
|
2058
|
+
state: optString(widget.options, "state"),
|
|
2059
|
+
severity: optString(widget.options, "severity"),
|
|
2060
|
+
acknowledged: parseAcknowledged(optString(widget.options, "acknowledged")),
|
|
2061
|
+
pageSize
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
2064
|
+
function parseAcknowledged(value) {
|
|
2065
|
+
if (value === "true") return true;
|
|
2066
|
+
if (value === "false") return false;
|
|
2067
|
+
return void 0;
|
|
2068
|
+
}
|
|
2069
|
+
function WidgetUnavailableFrame({ widget }) {
|
|
2070
|
+
return /* @__PURE__ */ jsx17(WidgetFrame, { title: optString(widget.options, "title"), children: /* @__PURE__ */ jsxs9(
|
|
2071
|
+
"div",
|
|
2072
|
+
{
|
|
2073
|
+
style: {
|
|
2074
|
+
display: "flex",
|
|
2075
|
+
flexDirection: "column",
|
|
2076
|
+
alignItems: "center",
|
|
2077
|
+
justifyContent: "center",
|
|
2078
|
+
gap: 4,
|
|
2079
|
+
width: "100%",
|
|
2080
|
+
height: "100%",
|
|
2081
|
+
padding: 12,
|
|
2082
|
+
boxSizing: "border-box",
|
|
2083
|
+
textAlign: "center",
|
|
2084
|
+
color: "hsl(var(--muted-foreground))"
|
|
2085
|
+
},
|
|
2086
|
+
children: [
|
|
2087
|
+
/* @__PURE__ */ jsx17("div", { style: { fontSize: 20, lineHeight: 1 }, "aria-hidden": "true", children: "\u2298" }),
|
|
2088
|
+
/* @__PURE__ */ jsx17("div", { style: { fontSize: 13, fontWeight: 600 }, children: "Device unavailable" }),
|
|
2089
|
+
/* @__PURE__ */ jsx17("div", { style: { fontSize: 11, opacity: 0.8 }, children: "The bound device no longer exists." })
|
|
2090
|
+
]
|
|
2091
|
+
}
|
|
2092
|
+
) });
|
|
2093
|
+
}
|
|
2094
|
+
function WidgetErrorFrame({ widget, error }) {
|
|
2095
|
+
return /* @__PURE__ */ jsx17(WidgetFrame, { title: optString(widget.options, "title"), children: /* @__PURE__ */ jsxs9(
|
|
2096
|
+
"div",
|
|
2097
|
+
{
|
|
2098
|
+
style: {
|
|
2099
|
+
display: "flex",
|
|
2100
|
+
flexDirection: "column",
|
|
2101
|
+
alignItems: "center",
|
|
2102
|
+
justifyContent: "center",
|
|
2103
|
+
gap: 4,
|
|
2104
|
+
width: "100%",
|
|
2105
|
+
height: "100%",
|
|
2106
|
+
padding: 12,
|
|
2107
|
+
boxSizing: "border-box",
|
|
2108
|
+
textAlign: "center",
|
|
2109
|
+
color: "hsl(var(--muted-foreground))"
|
|
2110
|
+
},
|
|
2111
|
+
children: [
|
|
2112
|
+
/* @__PURE__ */ jsx17("div", { style: { fontSize: 20, lineHeight: 1 }, children: "\u26A0" }),
|
|
2113
|
+
/* @__PURE__ */ jsx17("div", { style: { fontSize: 13, fontWeight: 600 }, children: "Data unavailable" }),
|
|
2114
|
+
/* @__PURE__ */ jsx17("div", { style: { fontSize: 11, opacity: 0.8, wordBreak: "break-word" }, children: String(error) })
|
|
2115
|
+
]
|
|
2116
|
+
}
|
|
2117
|
+
) });
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// src/dashboard-renderer.tsx
|
|
2121
|
+
import { jsx as jsx18 } from "react/jsx-runtime";
|
|
2122
|
+
function DashboardRenderer({
|
|
2123
|
+
definition,
|
|
2124
|
+
hub,
|
|
2125
|
+
actions,
|
|
2126
|
+
seedHistory = true,
|
|
2127
|
+
bindings,
|
|
2128
|
+
sizing,
|
|
2129
|
+
select,
|
|
2130
|
+
candidates
|
|
2131
|
+
}) {
|
|
2132
|
+
const breakpoint = useActiveBreakpoint(definition.canvas.breakpoints);
|
|
2133
|
+
const histories = useWidgetHistories(definition.widgets, seedHistory, bindings);
|
|
2134
|
+
const { grid, background: bg } = definition.canvas;
|
|
2135
|
+
const rowGap = typeof grid.gap === "number" ? grid.gap : grid.gap.row;
|
|
2136
|
+
const colGap = typeof grid.gap === "number" ? grid.gap : grid.gap.col;
|
|
2137
|
+
const effectiveSizing = sizing ?? definition.canvas.sizing;
|
|
2138
|
+
return /* @__PURE__ */ jsx18("div", { style: sizingStyle(effectiveSizing, bg), children: /* @__PURE__ */ jsx18(WidgetSelectProvider, { select, children: /* @__PURE__ */ jsx18(WidgetCandidatesProvider, { candidates, children: /* @__PURE__ */ jsx18(
|
|
2139
|
+
"div",
|
|
2140
|
+
{
|
|
2141
|
+
style: {
|
|
2142
|
+
display: "grid",
|
|
2143
|
+
gridTemplateColumns: `repeat(${grid.columns}, minmax(0, 1fr))`,
|
|
2144
|
+
gridAutoRows: `${grid.rowHeight}px`,
|
|
2145
|
+
columnGap: colGap,
|
|
2146
|
+
rowGap,
|
|
2147
|
+
width: "100%"
|
|
2148
|
+
},
|
|
2149
|
+
children: definition.widgets.map((widget) => {
|
|
2150
|
+
const box = resolveWidgetBox(widget.layout, breakpoint);
|
|
2151
|
+
return /* @__PURE__ */ jsx18("div", { style: gridItemStyle(box, grid.columns), children: /* @__PURE__ */ jsx18(WidgetSubjectProvider, { label: widgetSubjectLabel(widget, definition.slots, bindings), children: /* @__PURE__ */ jsx18(
|
|
2152
|
+
ConnectedWidget,
|
|
2153
|
+
{
|
|
2154
|
+
widget,
|
|
2155
|
+
hub,
|
|
2156
|
+
actions,
|
|
2157
|
+
initialSamples: histories[widget.id]
|
|
2158
|
+
}
|
|
2159
|
+
) }) }, widget.id);
|
|
2160
|
+
})
|
|
2161
|
+
}
|
|
2162
|
+
) }) }) });
|
|
2163
|
+
}
|
|
2164
|
+
function gridItemStyle(box, columns) {
|
|
2165
|
+
const col = Math.min(Math.max(0, box.col), columns - 1);
|
|
2166
|
+
const colSpan = Math.min(Math.max(1, box.colSpan), columns - col);
|
|
2167
|
+
return {
|
|
2168
|
+
gridColumn: `${col + 1} / span ${colSpan}`,
|
|
2169
|
+
gridRow: `${box.row + 1} / span ${box.rowSpan}`,
|
|
2170
|
+
zIndex: box.z,
|
|
2171
|
+
transform: box.offset ? `translate(${box.offset.x}px, ${box.offset.y}px)` : void 0,
|
|
2172
|
+
// Let a grid item shrink below its content's intrinsic size so a wide chart/table
|
|
2173
|
+
// fits its track instead of overflowing it.
|
|
2174
|
+
minWidth: 0,
|
|
2175
|
+
minHeight: 0
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
function sizingStyle(sizing, bg) {
|
|
2179
|
+
const base = {
|
|
2180
|
+
overflow: "auto",
|
|
2181
|
+
backgroundColor: bg?.color ?? void 0,
|
|
2182
|
+
backgroundImage: bg?.imageUrl ? `url(${bg.imageUrl})` : void 0,
|
|
2183
|
+
backgroundSize: "cover"
|
|
2184
|
+
};
|
|
2185
|
+
if (sizing === "fill") return { ...base, width: "100%", height: "100%" };
|
|
2186
|
+
if ("width" in sizing) return { ...base, width: sizing.width, maxWidth: "100%", height: "100%" };
|
|
2187
|
+
return { ...base, width: "100%", height: sizing.height };
|
|
2188
|
+
}
|
|
2189
|
+
function ownGet(map, key) {
|
|
2190
|
+
return map && Object.prototype.hasOwnProperty.call(map, key) ? map[key] : void 0;
|
|
2191
|
+
}
|
|
2192
|
+
function widgetSubjectLabel(widget, slots, bindings) {
|
|
2193
|
+
const ds = widget.datasource;
|
|
2194
|
+
if (!ds) return void 0;
|
|
2195
|
+
let binding;
|
|
2196
|
+
if (ds.kind === "device") {
|
|
2197
|
+
binding = ds.deviceToken ? { kind: "device", deviceToken: ds.deviceToken } : void 0;
|
|
2198
|
+
} else if (ds.kind === "anchor") {
|
|
2199
|
+
binding = { kind: "anchor", anchor: ds.anchor };
|
|
2200
|
+
} else if (ds.kind === "slot") {
|
|
2201
|
+
const slot = ownGet(slots, ds.slot);
|
|
2202
|
+
binding = slot?.scope ? ownGet(bindings, ds.slot) : ownGet(bindings, ds.slot) ?? slot?.defaultBinding;
|
|
2203
|
+
}
|
|
2204
|
+
if (binding?.kind === "device") return binding.deviceToken;
|
|
2205
|
+
if (binding?.kind === "anchor") return binding.anchor.targetToken;
|
|
2206
|
+
return void 0;
|
|
2207
|
+
}
|
|
2208
|
+
function useActiveBreakpoint(breakpoints) {
|
|
2209
|
+
const [width, setWidth] = useState7(
|
|
2210
|
+
() => typeof window === "undefined" ? 0 : window.innerWidth
|
|
2211
|
+
);
|
|
2212
|
+
useEffect7(() => {
|
|
2213
|
+
const onResize = () => setWidth(window.innerWidth);
|
|
2214
|
+
window.addEventListener("resize", onResize);
|
|
2215
|
+
return () => window.removeEventListener("resize", onResize);
|
|
2216
|
+
}, []);
|
|
2217
|
+
return activeBreakpoint(breakpoints, width);
|
|
2218
|
+
}
|
|
2219
|
+
function useWidgetHistories(widgets, enabled, bindings) {
|
|
2220
|
+
const [histories, setHistories] = useState7({});
|
|
2221
|
+
const bindingsKey = bindings ? JSON.stringify(bindings) : null;
|
|
2222
|
+
useEffect7(() => {
|
|
2223
|
+
if (!enabled) {
|
|
2224
|
+
setHistories({});
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
let cancelled = false;
|
|
2228
|
+
const historyWindow = defaultHistoryWindow();
|
|
2229
|
+
Promise.all(
|
|
2230
|
+
widgets.map(
|
|
2231
|
+
// Only measurement widgets consume a history seed; a non-measurement widget
|
|
2232
|
+
// (alarm, command) reconciles from its own channel, so backfilling it would fire
|
|
2233
|
+
// a wasted (and all-measurements) bucketedMeasurements query it ignores.
|
|
2234
|
+
async (w) => [
|
|
2235
|
+
w.id,
|
|
2236
|
+
WIDGET_CHANNEL[w.type] === "measurement" ? await fetchWidgetHistory(w, historyWindow, bindings) : []
|
|
2237
|
+
]
|
|
2238
|
+
)
|
|
2239
|
+
).then((entries) => {
|
|
2240
|
+
if (!cancelled) setHistories(Object.fromEntries(entries));
|
|
2241
|
+
});
|
|
2242
|
+
return () => {
|
|
2243
|
+
cancelled = true;
|
|
2244
|
+
};
|
|
2245
|
+
}, [widgets, enabled, bindingsKey]);
|
|
2246
|
+
return histories;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
// src/options.ts
|
|
2250
|
+
var TITLE = {
|
|
2251
|
+
kind: "string",
|
|
2252
|
+
doc: "Heading shown in the widget frame. Blank hides the frame heading."
|
|
2253
|
+
};
|
|
2254
|
+
var TITLE_OR_MEASUREMENT = {
|
|
2255
|
+
kind: "string",
|
|
2256
|
+
doc: "Heading shown in the widget frame. Defaults to the displayed measurement name."
|
|
2257
|
+
};
|
|
2258
|
+
var PRECISION = {
|
|
2259
|
+
kind: "number",
|
|
2260
|
+
integer: true,
|
|
2261
|
+
min: 0,
|
|
2262
|
+
max: 100,
|
|
2263
|
+
doc: "Decimal places to round displayed values to. Unset shows the raw value."
|
|
2264
|
+
};
|
|
2265
|
+
var FLASH_ON_CHANGE = {
|
|
2266
|
+
kind: "boolean",
|
|
2267
|
+
doc: "Briefly tint a value green on a rise and red on a fall when it changes."
|
|
2268
|
+
};
|
|
2269
|
+
var WINDOW = {
|
|
2270
|
+
kind: "number",
|
|
2271
|
+
integer: true,
|
|
2272
|
+
min: 1,
|
|
2273
|
+
doc: "Samples retained across ALL of the widget's measurements before the oldest are dropped. Bounds memory; only the chart draws them. Default 300."
|
|
2274
|
+
};
|
|
2275
|
+
var UNIT = {
|
|
2276
|
+
kind: "string",
|
|
2277
|
+
doc: "Unit suffix shown beside the value."
|
|
2278
|
+
};
|
|
2279
|
+
var ALARM_STATE = {
|
|
2280
|
+
kind: "enum",
|
|
2281
|
+
values: ["ACTIVE", "CLEARED"],
|
|
2282
|
+
doc: "Show only alarms in this state. Unset shows every state."
|
|
2283
|
+
};
|
|
2284
|
+
var ALARM_SEVERITY = {
|
|
2285
|
+
kind: "enum",
|
|
2286
|
+
values: ["CRITICAL", "MAJOR", "MINOR", "WARNING", "INDETERMINATE"],
|
|
2287
|
+
doc: "Show only alarms at this severity. Unset shows every severity."
|
|
2288
|
+
};
|
|
2289
|
+
var ALARM_ACKNOWLEDGED = {
|
|
2290
|
+
kind: "enum",
|
|
2291
|
+
values: ["true", "false"],
|
|
2292
|
+
doc: "Show only acknowledged ('true') or unacknowledged ('false') alarms. Unset shows both."
|
|
2293
|
+
};
|
|
2294
|
+
var SELECTION_TARGET = {
|
|
2295
|
+
kind: "string",
|
|
2296
|
+
doc: "Name of the slot this widget re-points when the viewer picks an entity."
|
|
2297
|
+
};
|
|
2298
|
+
var WIDGET_OPTIONS = {
|
|
2299
|
+
"timeseries-chart": {
|
|
2300
|
+
title: TITLE,
|
|
2301
|
+
window: WINDOW
|
|
2302
|
+
},
|
|
2303
|
+
"latest-card": {
|
|
2304
|
+
title: TITLE_OR_MEASUREMENT,
|
|
2305
|
+
measurement: {
|
|
2306
|
+
kind: "string",
|
|
2307
|
+
doc: "Which measurement to display. Defaults to the first the datasource selects."
|
|
2308
|
+
},
|
|
2309
|
+
unit: UNIT,
|
|
2310
|
+
precision: PRECISION,
|
|
2311
|
+
flashOnChange: FLASH_ON_CHANGE,
|
|
2312
|
+
window: WINDOW
|
|
2313
|
+
},
|
|
2314
|
+
gauge: {
|
|
2315
|
+
title: TITLE_OR_MEASUREMENT,
|
|
2316
|
+
measurement: {
|
|
2317
|
+
kind: "string",
|
|
2318
|
+
doc: "Which measurement to display. Defaults to the first the datasource selects."
|
|
2319
|
+
},
|
|
2320
|
+
min: {
|
|
2321
|
+
kind: "number",
|
|
2322
|
+
doc: "Low end of the gauge scale. Default 0."
|
|
2323
|
+
},
|
|
2324
|
+
max: {
|
|
2325
|
+
kind: "number",
|
|
2326
|
+
doc: "High end of the gauge scale. Default 100."
|
|
2327
|
+
},
|
|
2328
|
+
unit: UNIT,
|
|
2329
|
+
flashOnChange: FLASH_ON_CHANGE,
|
|
2330
|
+
window: WINDOW
|
|
2331
|
+
},
|
|
2332
|
+
table: {
|
|
2333
|
+
title: TITLE,
|
|
2334
|
+
precision: PRECISION,
|
|
2335
|
+
flashOnChange: FLASH_ON_CHANGE,
|
|
2336
|
+
window: WINDOW
|
|
2337
|
+
},
|
|
2338
|
+
// label and image carry no datasource: they sit on the measurement channel only
|
|
2339
|
+
// because that is ConnectedWidget's default branch, and read no stream data at all.
|
|
2340
|
+
label: {
|
|
2341
|
+
title: TITLE,
|
|
2342
|
+
text: {
|
|
2343
|
+
kind: "string",
|
|
2344
|
+
required: true,
|
|
2345
|
+
doc: "The text to show. Rendered as plain text \u2014 markup is never interpreted."
|
|
2346
|
+
},
|
|
2347
|
+
align: {
|
|
2348
|
+
kind: "enum",
|
|
2349
|
+
values: ["left", "center", "right"],
|
|
2350
|
+
doc: "Horizontal alignment of the text. Default center."
|
|
2351
|
+
},
|
|
2352
|
+
fontSize: {
|
|
2353
|
+
kind: "number",
|
|
2354
|
+
min: 1,
|
|
2355
|
+
doc: "Text size in pixels. Default 16."
|
|
2356
|
+
}
|
|
2357
|
+
},
|
|
2358
|
+
image: {
|
|
2359
|
+
title: TITLE,
|
|
2360
|
+
url: {
|
|
2361
|
+
kind: "string",
|
|
2362
|
+
required: true,
|
|
2363
|
+
doc: "Source URL of the image."
|
|
2364
|
+
},
|
|
2365
|
+
alt: {
|
|
2366
|
+
kind: "string",
|
|
2367
|
+
doc: "Alternative text describing the image for assistive technology."
|
|
2368
|
+
},
|
|
2369
|
+
fit: {
|
|
2370
|
+
kind: "enum",
|
|
2371
|
+
values: ["contain", "cover", "fill"],
|
|
2372
|
+
doc: "How the image fills its box. Default contain."
|
|
2373
|
+
}
|
|
2374
|
+
},
|
|
2375
|
+
"alarm-table": {
|
|
2376
|
+
title: TITLE,
|
|
2377
|
+
state: ALARM_STATE,
|
|
2378
|
+
severity: ALARM_SEVERITY,
|
|
2379
|
+
acknowledged: ALARM_ACKNOWLEDGED,
|
|
2380
|
+
maxRows: {
|
|
2381
|
+
kind: "number",
|
|
2382
|
+
integer: true,
|
|
2383
|
+
min: 1,
|
|
2384
|
+
doc: "Alarms loaded into the table. Default 50."
|
|
2385
|
+
},
|
|
2386
|
+
precision: PRECISION,
|
|
2387
|
+
flashOnChange: FLASH_ON_CHANGE,
|
|
2388
|
+
selectionTarget: SELECTION_TARGET
|
|
2389
|
+
},
|
|
2390
|
+
// No maxRows: alarmSubscription() forces the default page size for a count, because the
|
|
2391
|
+
// number it shows is the SERVER total and the page is read only to sample the worst
|
|
2392
|
+
// loaded severity. An authored maxRows here would be read by nothing.
|
|
2393
|
+
"alarm-count": {
|
|
2394
|
+
title: TITLE,
|
|
2395
|
+
state: ALARM_STATE,
|
|
2396
|
+
severity: ALARM_SEVERITY,
|
|
2397
|
+
acknowledged: ALARM_ACKNOWLEDGED
|
|
2398
|
+
},
|
|
2399
|
+
"command-button": {
|
|
2400
|
+
title: TITLE,
|
|
2401
|
+
commandName: {
|
|
2402
|
+
kind: "string",
|
|
2403
|
+
required: true,
|
|
2404
|
+
doc: "Command key issued to the target device. Without it the widget cannot send."
|
|
2405
|
+
},
|
|
2406
|
+
commandLabel: {
|
|
2407
|
+
kind: "string",
|
|
2408
|
+
doc: "Text on the send button. Defaults to the command key."
|
|
2409
|
+
},
|
|
2410
|
+
parameterSchema: {
|
|
2411
|
+
kind: "string",
|
|
2412
|
+
format: "json",
|
|
2413
|
+
doc: "The command's parameter descriptors, baked in as JSON at author time. Drives the typed form."
|
|
2414
|
+
},
|
|
2415
|
+
maxRows: {
|
|
2416
|
+
kind: "number",
|
|
2417
|
+
integer: true,
|
|
2418
|
+
min: 1,
|
|
2419
|
+
doc: "Recent commands shown in the history. Default 20."
|
|
2420
|
+
}
|
|
2421
|
+
},
|
|
2422
|
+
"entity-selector": {
|
|
2423
|
+
title: TITLE,
|
|
2424
|
+
selectionTarget: { ...SELECTION_TARGET, required: true }
|
|
2425
|
+
},
|
|
2426
|
+
// 🔴 tileUrl is NOT `required`, and that is the decision rather than an oversight.
|
|
2427
|
+
// `required` means "absent leaves the widget with nothing to show", and a map without
|
|
2428
|
+
// a basemap still shows every position it was asked to — on a plain panel, saying why
|
|
2429
|
+
// there is no map behind them. Marking it required would report a correctly-rendering
|
|
2430
|
+
// widget as broken on every board that names no provider of its own — which is most
|
|
2431
|
+
// of them, because the usual and recommended answer is to inherit the tenant's
|
|
2432
|
+
// basemap rather than to configure tiles per board.
|
|
2433
|
+
map: {
|
|
2434
|
+
title: TITLE,
|
|
2435
|
+
tileUrl: {
|
|
2436
|
+
kind: "string",
|
|
2437
|
+
doc: "Raster tile URL template, e.g. https://tiles.example.com/{z}/{x}/{y}.png. Leave empty to inherit the tenant's basemap; setting one overrides it for this board only."
|
|
2438
|
+
},
|
|
2439
|
+
attribution: {
|
|
2440
|
+
kind: "string",
|
|
2441
|
+
doc: "Credit line the tile source requires, shown on the map. Set whatever the provider you chose asks for."
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
};
|
|
2445
|
+
var CROSS_FIELD_RULES = {
|
|
2446
|
+
gauge: [
|
|
2447
|
+
(options) => {
|
|
2448
|
+
const min = options.min;
|
|
2449
|
+
const max = options.max;
|
|
2450
|
+
if (typeof min !== "number" || typeof max !== "number") return void 0;
|
|
2451
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) return void 0;
|
|
2452
|
+
if (max > min) return void 0;
|
|
2453
|
+
return {
|
|
2454
|
+
key: "max",
|
|
2455
|
+
code: "invariant",
|
|
2456
|
+
message: `max (${max}) must be greater than min (${min})`
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
]
|
|
2460
|
+
};
|
|
2461
|
+
function validateWidgetOptions(widgetType, options) {
|
|
2462
|
+
const specs = WIDGET_OPTIONS[widgetType];
|
|
2463
|
+
const issues = [];
|
|
2464
|
+
const bag = options ?? {};
|
|
2465
|
+
for (const [key, spec] of Object.entries(specs)) {
|
|
2466
|
+
const value = bag[key];
|
|
2467
|
+
if (value === void 0 || spec.required && value === "") {
|
|
2468
|
+
if (spec.required) {
|
|
2469
|
+
issues.push({ widgetType, key, code: "missing", message: `${key} is required but not set` });
|
|
2470
|
+
}
|
|
2471
|
+
continue;
|
|
2472
|
+
}
|
|
2473
|
+
issues.push(...checkValue(widgetType, key, spec, value));
|
|
2474
|
+
}
|
|
2475
|
+
for (const key of Object.keys(bag)) {
|
|
2476
|
+
if (Object.prototype.hasOwnProperty.call(specs, key)) continue;
|
|
2477
|
+
issues.push({
|
|
2478
|
+
widgetType,
|
|
2479
|
+
key,
|
|
2480
|
+
code: "unknown",
|
|
2481
|
+
message: `${key} is not read by a ${widgetType} widget`
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
for (const rule of CROSS_FIELD_RULES[widgetType] ?? []) {
|
|
2485
|
+
const issue = rule(bag);
|
|
2486
|
+
if (issue) issues.push({ widgetType, ...issue });
|
|
2487
|
+
}
|
|
2488
|
+
return issues;
|
|
2489
|
+
}
|
|
2490
|
+
function checkValue(widgetType, key, spec, value) {
|
|
2491
|
+
const issue = (code, message) => [{ widgetType, key, code, message }];
|
|
2492
|
+
switch (spec.kind) {
|
|
2493
|
+
case "string":
|
|
2494
|
+
if (typeof value !== "string") {
|
|
2495
|
+
return issue("type", `${key} must be a string, got ${describe(value)}`);
|
|
2496
|
+
}
|
|
2497
|
+
if (spec.format === "json" && !parsesAsJson(value)) {
|
|
2498
|
+
return issue("json", `${key} must be valid JSON`);
|
|
2499
|
+
}
|
|
2500
|
+
return [];
|
|
2501
|
+
case "number":
|
|
2502
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2503
|
+
return issue("type", `${key} must be a finite number, got ${describe(value)}`);
|
|
2504
|
+
}
|
|
2505
|
+
if (spec.integer && !Number.isInteger(value)) {
|
|
2506
|
+
return issue("range", `${key} must be a whole number, got ${value}`);
|
|
2507
|
+
}
|
|
2508
|
+
if (spec.min !== void 0 && value < spec.min) {
|
|
2509
|
+
return issue("range", `${key} must be at least ${spec.min}, got ${value}`);
|
|
2510
|
+
}
|
|
2511
|
+
if (spec.max !== void 0 && value > spec.max) {
|
|
2512
|
+
return issue("range", `${key} must be at most ${spec.max}, got ${value}`);
|
|
2513
|
+
}
|
|
2514
|
+
return [];
|
|
2515
|
+
case "boolean":
|
|
2516
|
+
if (typeof value !== "boolean") {
|
|
2517
|
+
return issue("type", `${key} must be a boolean, got ${describe(value)}`);
|
|
2518
|
+
}
|
|
2519
|
+
return [];
|
|
2520
|
+
case "enum":
|
|
2521
|
+
if (typeof value !== "string") {
|
|
2522
|
+
return issue("type", `${key} must be a string, got ${describe(value)}`);
|
|
2523
|
+
}
|
|
2524
|
+
if (!spec.values.includes(value)) {
|
|
2525
|
+
return issue("enum", `${key} must be one of ${spec.values.join(", ")}, got ${JSON.stringify(value)}`);
|
|
2526
|
+
}
|
|
2527
|
+
return [];
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
function parsesAsJson(value) {
|
|
2531
|
+
try {
|
|
2532
|
+
JSON.parse(value);
|
|
2533
|
+
return true;
|
|
2534
|
+
} catch {
|
|
2535
|
+
return false;
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
function describe(value) {
|
|
2539
|
+
if (value === null) return "null";
|
|
2540
|
+
if (typeof value === "string") return `string ${JSON.stringify(value)}`;
|
|
2541
|
+
if (Array.isArray(value)) return "an array";
|
|
2542
|
+
return `${typeof value} ${JSON.stringify(value)}`;
|
|
2543
|
+
}
|
|
2544
|
+
function numberOptionSpec(type, key) {
|
|
2545
|
+
const specs = WIDGET_OPTIONS[type];
|
|
2546
|
+
if (!Object.prototype.hasOwnProperty.call(specs, key)) return void 0;
|
|
2547
|
+
const spec = specs[key];
|
|
2548
|
+
return spec.kind === "number" ? spec : void 0;
|
|
2549
|
+
}
|
|
2550
|
+
function clampNumberOption(spec, value) {
|
|
2551
|
+
if (!Number.isFinite(value)) return void 0;
|
|
2552
|
+
if (!spec) return value;
|
|
2553
|
+
let next = spec.integer ? Math.trunc(value) : value;
|
|
2554
|
+
if (spec.min !== void 0) next = Math.max(spec.min, next);
|
|
2555
|
+
if (spec.max !== void 0) next = Math.min(spec.max, next);
|
|
2556
|
+
return next;
|
|
2557
|
+
}
|
|
2558
|
+
function enumOptionValues(type, key) {
|
|
2559
|
+
const specs = WIDGET_OPTIONS[type];
|
|
2560
|
+
if (!Object.prototype.hasOwnProperty.call(specs, key)) return void 0;
|
|
2561
|
+
const spec = specs[key];
|
|
2562
|
+
return spec.kind === "enum" ? spec.values : void 0;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
// src/definition-options.ts
|
|
2566
|
+
function validateDefinitionOptions(definition) {
|
|
2567
|
+
return definition.widgets.flatMap(
|
|
2568
|
+
(widget) => validateWidgetOptions(widget.type, widget.options).map((issue) => ({ ...issue, widgetId: widget.id }))
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
function stripUnknownOptions(definition) {
|
|
2572
|
+
let changed = false;
|
|
2573
|
+
const widgets = definition.widgets.map((widget) => {
|
|
2574
|
+
const stripped = stripUnknownWidgetOptions(widget);
|
|
2575
|
+
if (stripped !== widget) changed = true;
|
|
2576
|
+
return stripped;
|
|
2577
|
+
});
|
|
2578
|
+
return changed ? { ...definition, widgets } : definition;
|
|
2579
|
+
}
|
|
2580
|
+
function stripUnknownWidgetOptions(widget) {
|
|
2581
|
+
const bag = widget.options;
|
|
2582
|
+
if (!bag) return widget;
|
|
2583
|
+
const specs = WIDGET_OPTIONS[widget.type];
|
|
2584
|
+
const keys = Object.keys(bag);
|
|
2585
|
+
const kept = keys.filter((key) => Object.prototype.hasOwnProperty.call(specs, key));
|
|
2586
|
+
if (kept.length === keys.length) return widget;
|
|
2587
|
+
const options = {};
|
|
2588
|
+
for (const key of kept) options[key] = bag[key];
|
|
2589
|
+
return { ...widget, options };
|
|
2590
|
+
}
|
|
2591
|
+
export {
|
|
2592
|
+
ALARM_WIDGET_REGISTRY,
|
|
2593
|
+
AlarmCount,
|
|
2594
|
+
AlarmTable,
|
|
2595
|
+
BUNDLED_BASEMAP_ATTRIBUTION,
|
|
2596
|
+
CONTROL_WIDGET_REGISTRY,
|
|
2597
|
+
CommandButton,
|
|
2598
|
+
ConnectedWidget,
|
|
2599
|
+
DashboardRenderer,
|
|
2600
|
+
EChart,
|
|
2601
|
+
EntitySelector,
|
|
2602
|
+
FlashValue,
|
|
2603
|
+
Gauge,
|
|
2604
|
+
Image,
|
|
2605
|
+
LOCATION_WIDGET_REGISTRY,
|
|
2606
|
+
Label,
|
|
2607
|
+
LatestCard,
|
|
2608
|
+
MapRuntimeProvider,
|
|
2609
|
+
MapWidget,
|
|
2610
|
+
SELECTION_WIDGET_REGISTRY,
|
|
2611
|
+
Table,
|
|
2612
|
+
TenantBasemapProvider,
|
|
2613
|
+
TimeSeriesChart,
|
|
2614
|
+
WIDGET_BINDS_DATASOURCE,
|
|
2615
|
+
WIDGET_CHANNEL,
|
|
2616
|
+
WIDGET_OPTIONS,
|
|
2617
|
+
WIDGET_REGISTRY,
|
|
2618
|
+
WidgetCandidatesProvider,
|
|
2619
|
+
WidgetFrame,
|
|
2620
|
+
WidgetSelectProvider,
|
|
2621
|
+
WidgetSubjectProvider,
|
|
2622
|
+
buildGaugeOption,
|
|
2623
|
+
buildLineOption,
|
|
2624
|
+
buildPayload,
|
|
2625
|
+
clampNumberOption,
|
|
2626
|
+
commandStatusColor,
|
|
2627
|
+
commandStatusLabel,
|
|
2628
|
+
css,
|
|
2629
|
+
defaultValues,
|
|
2630
|
+
describePosition,
|
|
2631
|
+
enumOptionValues,
|
|
2632
|
+
flashBackgroundStyle,
|
|
2633
|
+
flashTextStyle,
|
|
2634
|
+
formatDegrees,
|
|
2635
|
+
highestSeverity,
|
|
2636
|
+
isScalar,
|
|
2637
|
+
isTerminalStatus,
|
|
2638
|
+
landStyleFrom,
|
|
2639
|
+
loadLandStyle,
|
|
2640
|
+
numberOptionSpec,
|
|
2641
|
+
optBoolean,
|
|
2642
|
+
parseParameterSchema,
|
|
2643
|
+
pickSample,
|
|
2644
|
+
placeable,
|
|
2645
|
+
projectToPanel,
|
|
2646
|
+
rasterStyleFor,
|
|
2647
|
+
resolveChartTheme,
|
|
2648
|
+
severityColor,
|
|
2649
|
+
severityLabel,
|
|
2650
|
+
severityRank,
|
|
2651
|
+
stripUnknownOptions,
|
|
2652
|
+
useAlarmStream,
|
|
2653
|
+
useCandidates,
|
|
2654
|
+
useChartTheme,
|
|
2655
|
+
useCommandStream,
|
|
2656
|
+
useDatasourceAvailability,
|
|
2657
|
+
useFlashOnChange,
|
|
2658
|
+
useLocationStream,
|
|
2659
|
+
useMapRuntime,
|
|
2660
|
+
useMeasurementStream,
|
|
2661
|
+
useResolvedBindings,
|
|
2662
|
+
useSlotCandidates,
|
|
2663
|
+
useTenantBasemap,
|
|
2664
|
+
useWidgetCandidates,
|
|
2665
|
+
useWidgetSelect,
|
|
2666
|
+
validateDefinitionOptions,
|
|
2667
|
+
validateParams,
|
|
2668
|
+
validateWidgetOptions,
|
|
2669
|
+
widgetSubjectLabel
|
|
2670
|
+
};
|
|
2671
|
+
//# sourceMappingURL=index.js.map
|