homebridge-plugin-utils 1.34.0 → 2.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/README.md +130 -2
- package/build/eslint-plugin/README.md +164 -0
- package/build/eslint-plugin/config.mjs +308 -0
- package/build/eslint-plugin/index.mjs +7 -0
- package/build/eslint-plugin/plugin.mjs +46 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
- package/build/eslint-plugin/rules/comment-style.mjs +190 -0
- package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
- package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
- package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
- package/build/eslint-plugin/test-setup.mjs +21 -0
- package/build/fs-ops.mjs +184 -0
- package/build/tsconfig.json +18 -3
- package/dist/backpressure.d.ts +128 -0
- package/dist/backpressure.js +273 -0
- package/dist/backpressure.js.map +1 -0
- package/dist/cli/index.d.ts +95 -0
- package/dist/cli/index.js +355 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/clock-double.d.ts +93 -0
- package/dist/clock-double.js +141 -0
- package/dist/clock-double.js.map +1 -0
- package/dist/clock.d.ts +39 -0
- package/dist/clock.js +34 -0
- package/dist/clock.js.map +1 -0
- package/dist/eslint-plugin/config.d.mts +193 -0
- package/dist/eslint-plugin/index.d.mts +2 -0
- package/dist/eslint-plugin/plugin.d.mts +87 -0
- package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
- package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
- package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
- package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
- package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
- package/dist/featureOptions-docs.d.ts +96 -0
- package/dist/featureOptions-docs.js +193 -0
- package/dist/featureOptions-docs.js.map +1 -0
- package/dist/featureOptions.d.ts +674 -0
- package/dist/featureOptions.js +870 -0
- package/dist/featureOptions.js.map +1 -0
- package/dist/ffmpeg/codecs.d.ts +256 -72
- package/dist/ffmpeg/codecs.js +477 -257
- package/dist/ffmpeg/codecs.js.map +1 -1
- package/dist/ffmpeg/dgram-util.d.ts +46 -0
- package/dist/ffmpeg/dgram-util.js +38 -0
- package/dist/ffmpeg/dgram-util.js.map +1 -0
- package/dist/ffmpeg/exec.d.ts +83 -64
- package/dist/ffmpeg/exec.js +77 -86
- package/dist/ffmpeg/exec.js.map +1 -1
- package/dist/ffmpeg/fmp4.d.ts +70 -4
- package/dist/ffmpeg/fmp4.js +100 -11
- package/dist/ffmpeg/fmp4.js.map +1 -1
- package/dist/ffmpeg/hap-enums.d.ts +214 -0
- package/dist/ffmpeg/hap-enums.js +92 -0
- package/dist/ffmpeg/hap-enums.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +16 -9
- package/dist/ffmpeg/index.js +6 -0
- package/dist/ffmpeg/index.js.map +1 -1
- package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
- package/dist/ffmpeg/mp4-assembler.js +387 -0
- package/dist/ffmpeg/mp4-assembler.js.map +1 -0
- package/dist/ffmpeg/mp4-parser.d.ts +94 -0
- package/dist/ffmpeg/mp4-parser.js +130 -0
- package/dist/ffmpeg/mp4-parser.js.map +1 -0
- package/dist/ffmpeg/options.d.ts +62 -149
- package/dist/ffmpeg/options.js +608 -499
- package/dist/ffmpeg/options.js.map +1 -1
- package/dist/ffmpeg/process.d.ts +143 -91
- package/dist/ffmpeg/process.js +406 -265
- package/dist/ffmpeg/process.js.map +1 -1
- package/dist/ffmpeg/record.d.ts +302 -188
- package/dist/ffmpeg/record.js +408 -560
- package/dist/ffmpeg/record.js.map +1 -1
- package/dist/ffmpeg/recording-process-double.d.ts +144 -0
- package/dist/ffmpeg/recording-process-double.js +168 -0
- package/dist/ffmpeg/recording-process-double.js.map +1 -0
- package/dist/ffmpeg/rtp-parser.d.ts +70 -0
- package/dist/ffmpeg/rtp-parser.js +77 -0
- package/dist/ffmpeg/rtp-parser.js.map +1 -0
- package/dist/ffmpeg/rtp.d.ts +198 -141
- package/dist/ffmpeg/rtp.js +473 -244
- package/dist/ffmpeg/rtp.js.map +1 -1
- package/dist/ffmpeg/settings.d.ts +5 -1
- package/dist/ffmpeg/settings.js +20 -2
- package/dist/ffmpeg/settings.js.map +1 -1
- package/dist/ffmpeg/stream.d.ts +57 -107
- package/dist/ffmpeg/stream.js +121 -148
- package/dist/ffmpeg/stream.js.map +1 -1
- package/dist/formatters.d.ts +106 -0
- package/dist/formatters.js +174 -0
- package/dist/formatters.js.map +1 -0
- package/dist/homebridge-enums.d.ts +30 -0
- package/dist/homebridge-enums.js +17 -0
- package/dist/homebridge-enums.js.map +1 -0
- package/dist/index.d.ts +12 -5
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/logclient/auth.d.ts +114 -0
- package/dist/logclient/auth.js +199 -0
- package/dist/logclient/auth.js.map +1 -0
- package/dist/logclient/cli-run.d.ts +76 -0
- package/dist/logclient/cli-run.js +639 -0
- package/dist/logclient/cli-run.js.map +1 -0
- package/dist/logclient/cli.d.ts +3 -0
- package/dist/logclient/cli.js +97 -0
- package/dist/logclient/cli.js.map +1 -0
- package/dist/logclient/client.d.ts +145 -0
- package/dist/logclient/client.js +600 -0
- package/dist/logclient/client.js.map +1 -0
- package/dist/logclient/config.d.ts +173 -0
- package/dist/logclient/config.js +199 -0
- package/dist/logclient/config.js.map +1 -0
- package/dist/logclient/endpoints.d.ts +54 -0
- package/dist/logclient/endpoints.js +73 -0
- package/dist/logclient/endpoints.js.map +1 -0
- package/dist/logclient/filter.d.ts +45 -0
- package/dist/logclient/filter.js +51 -0
- package/dist/logclient/filter.js.map +1 -0
- package/dist/logclient/frame.d.ts +93 -0
- package/dist/logclient/frame.js +203 -0
- package/dist/logclient/frame.js.map +1 -0
- package/dist/logclient/index.d.ts +31 -0
- package/dist/logclient/index.js +12 -0
- package/dist/logclient/index.js.map +1 -0
- package/dist/logclient/parser.d.ts +211 -0
- package/dist/logclient/parser.js +393 -0
- package/dist/logclient/parser.js.map +1 -0
- package/dist/logclient/rest.d.ts +41 -0
- package/dist/logclient/rest.js +111 -0
- package/dist/logclient/rest.js.map +1 -0
- package/dist/logclient/settings.d.ts +15 -0
- package/dist/logclient/settings.js +64 -0
- package/dist/logclient/settings.js.map +1 -0
- package/dist/logclient/socket-double.d.ts +201 -0
- package/dist/logclient/socket-double.js +384 -0
- package/dist/logclient/socket-double.js.map +1 -0
- package/dist/logclient/socket.d.ts +257 -0
- package/dist/logclient/socket.js +620 -0
- package/dist/logclient/socket.js.map +1 -0
- package/dist/logclient/stitch.d.ts +83 -0
- package/dist/logclient/stitch.js +146 -0
- package/dist/logclient/stitch.js.map +1 -0
- package/dist/logclient/time-expression.d.ts +42 -0
- package/dist/logclient/time-expression.js +181 -0
- package/dist/logclient/time-expression.js.map +1 -0
- package/dist/logclient/time-window.d.ts +38 -0
- package/dist/logclient/time-window.js +53 -0
- package/dist/logclient/time-window.js.map +1 -0
- package/dist/logclient/types.d.ts +107 -0
- package/dist/logclient/types.js +6 -0
- package/dist/logclient/types.js.map +1 -0
- package/dist/mqttClient.d.ts +287 -0
- package/dist/mqttClient.js +433 -0
- package/dist/mqttClient.js.map +1 -0
- package/dist/service.d.ts +64 -15
- package/dist/service.js +93 -66
- package/dist/service.js.map +1 -1
- package/dist/ui/featureOptions.js +870 -0
- package/dist/ui/featureOptions.js.map +1 -0
- package/dist/ui/formatters.js +174 -0
- package/dist/ui/formatters.js.map +1 -0
- package/dist/ui/pluginConfigSession.mjs +141 -0
- package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
- package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
- package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
- package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
- package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
- package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
- package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
- package/dist/ui/webUi-featureOptions/state.mjs +319 -0
- package/dist/ui/webUi-featureOptions/store.mjs +181 -0
- package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
- package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
- package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
- package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
- package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
- package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
- package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
- package/dist/ui/webUi-featureOptions.mjs +677 -0
- package/dist/ui/webUi.mjs +192 -87
- package/dist/util.d.ts +597 -45
- package/dist/util.js +787 -68
- package/dist/util.js.map +1 -1
- package/package.json +33 -15
- package/build/eslint-rules.mjs +0 -511
- package/dist/featureoptions.d.ts +0 -264
- package/dist/featureoptions.js +0 -480
- package/dist/featureoptions.js.map +0 -1
- package/dist/mqttclient.d.ts +0 -178
- package/dist/mqttclient.js +0 -310
- package/dist/mqttclient.js.map +0 -1
- package/dist/ui/featureoptions.js +0 -480
- package/dist/ui/featureoptions.js.map +0 -1
- package/dist/ui/webUi-featureoptions.mjs +0 -3765
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* webUi-featureOptions/store.mjs: Reactive state primitive for the feature options webUI.
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
// Capture the platform's CustomEvent constructor at module load. `extends EventTarget` below resolves at the same moment - both come from the same DOM
|
|
8
|
+
// implementation, so the dispatched event matches the EventTarget's expected event family. Without this capture, a test environment that swaps `globalThis.CustomEvent`
|
|
9
|
+
// to a different implementation after module load would dispatch events that the originally-resolved EventTarget rejects (silently or throwing); pinning both at
|
|
10
|
+
// the same load timestamp keeps the implementation family coherent.
|
|
11
|
+
const ModuleCustomEvent = CustomEvent;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* FeatureOptionsStore - The event-emitting state container at the heart of the feature options webUI.
|
|
15
|
+
*
|
|
16
|
+
* Architectural contract:
|
|
17
|
+
*
|
|
18
|
+
* - **One source of truth.** A single state object replaces ad-hoc cross-component synchronization. Components do not hold parallel state; they subscribe to the
|
|
19
|
+
* store and re-derive their view from {@link state} on dispatch.
|
|
20
|
+
* - **Unidirectional flow.** Mutations enter through {@link dispatch}, which feeds the action to the pure reducer and atomically replaces the state. The store
|
|
21
|
+
* then fires a {@link CustomEvent} naming the action's type, with the full action as `event.detail` so subscribers receive the payload without reaching back
|
|
22
|
+
* into the store for "what changed."
|
|
23
|
+
* - **Platform-native subscription.** The class extends {@link EventTarget} so subscribers register via the standard `addEventListener({ signal })` idiom -
|
|
24
|
+
* cleanup follows the caller's {@link AbortSignal} automatically, no `destroy()` ceremony required. The signal-keyed lifecycle is the same primitive every
|
|
25
|
+
* other part of the webUI already uses for its DOM listeners, so the store's subscription model integrates without a new lifecycle concept.
|
|
26
|
+
* - **Read-then-dispatch ordering.** The reducer applies before the event fires, so a subscriber reading `store.state` from inside its handler always sees the
|
|
27
|
+
* post-dispatch state. The ordering is load-bearing: views that re-derive their DOM in response to an event must see the new state, not the pre-mutation
|
|
28
|
+
* state that triggered the dispatch.
|
|
29
|
+
*
|
|
30
|
+
* The store carries no domain logic itself - the reducer (passed at construction) and the action vocabulary (consumed by subscribers) are the application-specific
|
|
31
|
+
* surface. The store is the platform-shaped wire between them.
|
|
32
|
+
*/
|
|
33
|
+
export class FeatureOptionsStore extends EventTarget {
|
|
34
|
+
|
|
35
|
+
#reducer;
|
|
36
|
+
#state;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a new store seeded with initial state and a reducer.
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} args
|
|
42
|
+
* @param {Object} args.initialState - The starting state. Treated as the first value of the immutable state sequence; subsequent values come from the reducer.
|
|
43
|
+
* @param {(state: Object, action: { type: string }) => Object} args.reducer - Pure (state, action) => state function. Should be an exhaustive switch on
|
|
44
|
+
* `action.type` so adding a new action type forces a corresponding reducer case.
|
|
45
|
+
*/
|
|
46
|
+
constructor({ initialState, reducer }) {
|
|
47
|
+
|
|
48
|
+
super();
|
|
49
|
+
this.#reducer = reducer;
|
|
50
|
+
this.#state = initialState;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read the current state. Returned by reference - callers MUST treat the result as readonly. The reducer's structural-sharing contract guarantees that unchanged
|
|
55
|
+
* slices retain their reference across dispatches, so subscribers can use reference equality (`===`) to detect what changed without a deep comparison.
|
|
56
|
+
*
|
|
57
|
+
* @returns {Object} The current state.
|
|
58
|
+
*/
|
|
59
|
+
get state() {
|
|
60
|
+
|
|
61
|
+
return this.#state;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Apply an action through the reducer and notify subscribers.
|
|
66
|
+
*
|
|
67
|
+
* Sequence:
|
|
68
|
+
*
|
|
69
|
+
* 1. The reducer is invoked with the current state and the action; its return value becomes the new state.
|
|
70
|
+
* 2. A {@link CustomEvent} named after `action.type` is dispatched on the store, with the full action as `event.detail`.
|
|
71
|
+
*
|
|
72
|
+
* The state replacement happens BEFORE the event dispatches, so subscribers reading `store.state` from inside their handler see the post-dispatch state. Reversing
|
|
73
|
+
* this order would force subscribers to either read from `event.detail` only (losing access to other state slices the dispatch did not touch) or to compute their
|
|
74
|
+
* view from stale state (a correctness hazard). The current order is the only one that satisfies "subscribers see consistent state every time they read."
|
|
75
|
+
*
|
|
76
|
+
* @param {{ type: string }} action - The action to dispatch. Must carry a string `type` field naming a past-tense domain event. Additional payload fields are
|
|
77
|
+
* forwarded verbatim as `event.detail` to subscribers.
|
|
78
|
+
*/
|
|
79
|
+
dispatch(action) {
|
|
80
|
+
|
|
81
|
+
this.#state = this.#reducer(this.#state, action);
|
|
82
|
+
this.dispatchEvent(new ModuleCustomEvent(action.type, { detail: action }));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Subscribe a function to a set of action types on a store, with automatic teardown.
|
|
88
|
+
*
|
|
89
|
+
* Two phases:
|
|
90
|
+
*
|
|
91
|
+
* 1. **Immediate run.** The function is invoked once synchronously at registration so the caller's initial view derives from the current state without waiting
|
|
92
|
+
* for a dispatch. This eliminates the "render on mount AND on every event" boilerplate every subscriber would otherwise repeat. {@link fn} receives
|
|
93
|
+
* `undefined` for the action argument at the immediate run - the marker for "no triggering action."
|
|
94
|
+
* 2. **Subscription.** The function is registered as a listener for every event type in `events`. Every subsequent {@link FeatureOptionsStore.dispatch} of a
|
|
95
|
+
* listed type fires the function with the action as its argument (extracted from the {@link CustomEvent}'s `detail`). Cleanup is automatic: when the
|
|
96
|
+
* supplied {@link AbortSignal} aborts, every registered listener is removed in one operation.
|
|
97
|
+
*
|
|
98
|
+
* A pre-aborted signal short-circuits the entire effect: neither the immediate run nor the subscription registration takes effect. This protects views that
|
|
99
|
+
* subscribed during a mount sequence from running against a torn-down DOM when an abort races the mount.
|
|
100
|
+
*
|
|
101
|
+
* The handler receives the action as its argument so subscribers that care about the dispatched payload can read it directly; subscribers that only need the
|
|
102
|
+
* post-mutation state ignore the argument and read `store.state` as usual. JavaScript's parameter semantics mean a handler declared as `() => void` silently
|
|
103
|
+
* ignores the action, so consumers pay no cost for the extra capability. Action-aware consumers declare `(action) => void` (or `(action) => { if(action) ... }`
|
|
104
|
+
* to gate on subscription-only logic) without needing a parallel API.
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} args
|
|
107
|
+
* @param {readonly string[]} args.events - The action types this effect subscribes to.
|
|
108
|
+
* @param {(action?: { type: string }) => void} args.fn - The effect body. Runs once immediately with `action === undefined`, then on every dispatch of a listed
|
|
109
|
+
* action type with the dispatched action as its argument.
|
|
110
|
+
* @param {AbortSignal} args.signal - The lifecycle signal. Every listener registered by this effect is bound to this signal and cleaned up on abort.
|
|
111
|
+
* @param {FeatureOptionsStore} args.store - The store to subscribe against.
|
|
112
|
+
*/
|
|
113
|
+
export const effect = ({ events, fn, signal, store }) => {
|
|
114
|
+
|
|
115
|
+
// Respect a pre-aborted signal. Without this short-circuit, a mount sequence that races against an abort can land the immediate-run pass on a torn-down DOM.
|
|
116
|
+
// The subscription registrations after this point would be no-ops anyway (addEventListener with an aborted signal silently skips), but the immediate-run pass
|
|
117
|
+
// can have side effects that need to be skipped.
|
|
118
|
+
if(signal.aborted) {
|
|
119
|
+
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
fn(undefined);
|
|
124
|
+
|
|
125
|
+
// One wrapper closure shared across every listener registration extracts the action from the event's detail and hands it to the handler. Centralizing the
|
|
126
|
+
// extraction here means each handler sees the same "action or undefined" contract regardless of which event type triggered it, and the addEventListener
|
|
127
|
+
// registrations themselves stay symmetric.
|
|
128
|
+
const dispatch = (event) => fn(event.detail);
|
|
129
|
+
|
|
130
|
+
for(const type of events) {
|
|
131
|
+
|
|
132
|
+
store.addEventListener(type, dispatch, { signal });
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Memoize a pure selector over a set of state slices.
|
|
138
|
+
*
|
|
139
|
+
* Pattern: the caller provides `compute(state)` (the derivation) and an array of `slices` (functions that pull cache-key inputs from state). On each call, the
|
|
140
|
+
* memoized selector re-runs the slice accessors and compares their results to the previous call's; if every slice returns the same reference (or primitive value)
|
|
141
|
+
* as before, the cached result is returned. Otherwise `compute` runs against the current state, and the new result is cached for next time.
|
|
142
|
+
*
|
|
143
|
+
* Reference equality is the cache key, which matches the structural-sharing contract the reducer follows: unchanged slices retain their reference across
|
|
144
|
+
* dispatches, so selectors that depend on them return cached results without recomputation. A selector that reads `state.scope` and `state.filter` is
|
|
145
|
+
* automatically cached across any dispatch that does not modify those slices, regardless of whether the dispatch touched some other unrelated slice.
|
|
146
|
+
*
|
|
147
|
+
* Single shared helper for every memoized selector. The alternative - hand-rolling closure-cached selectors at each site - duplicates the memoization shape and
|
|
148
|
+
* makes the cache key less discoverable. Using this helper means every memoized selector reads as "what does it compute" + "what does it depend on," with the
|
|
149
|
+
* machinery factored out.
|
|
150
|
+
*
|
|
151
|
+
* @param {Object} args
|
|
152
|
+
* @param {(state: Object) => unknown} args.compute - The pure derivation. Invoked when any slice's reference changes; the result is cached until the next change.
|
|
153
|
+
* @param {readonly ((state: Object) => unknown)[]} args.slices - The state-slice accessors. Their return values are compared by `===` against the previous call's.
|
|
154
|
+
* Order matters - the comparison is positional.
|
|
155
|
+
* @returns {(state: Object) => unknown} The memoized selector. Call it with the current state to read the derivation.
|
|
156
|
+
*/
|
|
157
|
+
export const memoize = ({ compute, slices }) => {
|
|
158
|
+
|
|
159
|
+
// The cache's "no value yet" state is encoded by `lastKeys === null`. Once the selector has run once, lastKeys is a non-null array (possibly empty when the
|
|
160
|
+
// selector takes no slices) and the comparison branch can proceed unconditionally. Two closure variables instead of a wrapper object - one fewer level of
|
|
161
|
+
// indirection on every cache check, which matters because cache-hit is the hot path for every selector called per dispatch.
|
|
162
|
+
let lastKeys = null;
|
|
163
|
+
let lastResult;
|
|
164
|
+
|
|
165
|
+
return (state) => {
|
|
166
|
+
|
|
167
|
+
const keys = slices.map((slice) => slice(state));
|
|
168
|
+
|
|
169
|
+
// Cache hit when every slice returns a value identical to its prior return. `Array.every` short-circuits on the first mismatch so the comparison cost is
|
|
170
|
+
// bounded by "how many slices match before one diverges," not by the slice count.
|
|
171
|
+
if(lastKeys && (keys.length === lastKeys.length) && keys.every((k, i) => k === lastKeys[i])) {
|
|
172
|
+
|
|
173
|
+
return lastResult;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
lastKeys = keys;
|
|
177
|
+
lastResult = compute(state);
|
|
178
|
+
|
|
179
|
+
return lastResult;
|
|
180
|
+
};
|
|
181
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* webUi-featureOptions/utils.mjs: Shared utilities for the feature options webUI components.
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sleep for the given duration and resolve, or reject early when the supplied signal aborts.
|
|
9
|
+
*
|
|
10
|
+
* This is the browser-side counterpart to the server-side `onAbort` disposable pattern: every lifecycle-bound async pause in the webUI funnels through one
|
|
11
|
+
* helper, so listener cleanup, pre-aborted fast-path semantics, and rejection-reason propagation live in exactly one place. Callers express "wait, but only while
|
|
12
|
+
* this view is alive" by composing a delay with their lifecycle signal - the rest is mechanical.
|
|
13
|
+
*
|
|
14
|
+
* @param {number} ms - Duration to sleep, in milliseconds.
|
|
15
|
+
* @param {AbortSignal} [signal] - Optional lifecycle signal. When provided, an abort cancels the timer and rejects the promise with the signal's reason. A signal
|
|
16
|
+
* that is already aborted at call time rejects synchronously on the next microtask without scheduling a timer.
|
|
17
|
+
* @returns {Promise<void>} Resolves after `ms` milliseconds, or rejects with `signal.reason` on abort.
|
|
18
|
+
*/
|
|
19
|
+
export function delay(ms, signal) {
|
|
20
|
+
|
|
21
|
+
if(signal?.aborted) {
|
|
22
|
+
|
|
23
|
+
return Promise.reject(signal.reason);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
27
|
+
|
|
28
|
+
// `onAbort` references `timer` lexically; the const is initialized before any addEventListener fires it, so the TDZ is never touched at call time. Both
|
|
29
|
+
// closures only run later, after both consts are initialized, so `onAbort` is declared first purely for readability - either declaration order is safe.
|
|
30
|
+
const onAbort = () => {
|
|
31
|
+
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
reject(signal.reason);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const timer = setTimeout(() => {
|
|
37
|
+
|
|
38
|
+
signal?.removeEventListener("abort", onAbort);
|
|
39
|
+
resolve();
|
|
40
|
+
}, ms);
|
|
41
|
+
|
|
42
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
43
|
+
|
|
44
|
+
return promise;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a DOM element with optional properties and children.
|
|
49
|
+
*
|
|
50
|
+
* This utility is shared across all feature options components. It reduces the verbosity of DOM manipulation by handling common patterns like setting classes,
|
|
51
|
+
* styles, and adding children in a functional style.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} tag - The HTML tag name to create.
|
|
54
|
+
* @param {Object} [props={}] - Properties to set on the element.
|
|
55
|
+
* @param {string|string[]|Array} [props.classList] - CSS classes to add.
|
|
56
|
+
* @param {Object} [props.style] - Inline styles to apply.
|
|
57
|
+
* @param {Array<string|Node>} [children=[]] - Child nodes or text content.
|
|
58
|
+
* @returns {HTMLElement} The created DOM element.
|
|
59
|
+
*/
|
|
60
|
+
export function createElement(tag, props = {}, children = []) {
|
|
61
|
+
|
|
62
|
+
const element = document.createElement(tag);
|
|
63
|
+
|
|
64
|
+
// Destructure classList and style off the props object so the remaining `attrs` can be iterated as plain DOM property/attribute assignments. We destructure
|
|
65
|
+
// (rather than mutate) so callers can safely reuse the same props record across multiple createElement invocations.
|
|
66
|
+
const { classList, style, ...attrs } = props;
|
|
67
|
+
|
|
68
|
+
if(classList) {
|
|
69
|
+
|
|
70
|
+
const classes = Array.isArray(classList) ? classList : classList.split(" ");
|
|
71
|
+
|
|
72
|
+
element.classList.add(...classes);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if(style) {
|
|
76
|
+
|
|
77
|
+
Object.assign(element.style, style);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for(const [ key, value ] of Object.entries(attrs)) {
|
|
81
|
+
|
|
82
|
+
// Use setAttribute for two cases that don't reflect via property assignment:
|
|
83
|
+
// 1. Dashed attributes (`data-*`, `aria-*`, etc.) - no corresponding JS property to set.
|
|
84
|
+
// 2. JS reserved words mapped to non-obvious DOM property names. `for` is the canonical case: setting `label.for = id` creates a JS expando property
|
|
85
|
+
// with no effect on the HTML attribute (the reflective property is `htmlFor`, not `for`). Without this branch, `<label for="...">` ends up without
|
|
86
|
+
// its `for` attribute and native label-for click-to-toggle silently doesn't work. Other reserved-word collisions (`class` -> className) are handled
|
|
87
|
+
// via the `classList` destructuring above; `for` is the only remaining case the renderer relies on.
|
|
88
|
+
if(key.includes("-") || (key === "for")) {
|
|
89
|
+
|
|
90
|
+
element.setAttribute(key, value);
|
|
91
|
+
} else {
|
|
92
|
+
|
|
93
|
+
element[key] = value;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for(const child of children) {
|
|
98
|
+
|
|
99
|
+
element.appendChild((typeof child === "string") ? document.createTextNode(child) : child);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return element;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Capture the current expansion state of every category in the supplied container as a plain `{ [categoryName]: isCollapsed }` map. Symmetric counterpart to
|
|
107
|
+
* {@link applyCategoryStates}; both sit alongside {@link setCategoryExpanded} as the SSOT for category reads and writes, so the persistence layer can stay
|
|
108
|
+
* DOM-agnostic.
|
|
109
|
+
*
|
|
110
|
+
* Reads `details.open` rather than any JS-mirrored expand state - the `<details>` element's own `open` attribute is the SSOT for "is this category currently
|
|
111
|
+
* expanded?"
|
|
112
|
+
*
|
|
113
|
+
* @param {HTMLElement} configTable - Container holding category `<details>` elements (each `<details data-category="...">` whose `open` attribute marks it expanded).
|
|
114
|
+
* @returns {Object<string, boolean>} Map of category name to collapsed boolean.
|
|
115
|
+
*/
|
|
116
|
+
export function captureCategoryStates(configTable) {
|
|
117
|
+
|
|
118
|
+
const states = {};
|
|
119
|
+
|
|
120
|
+
for(const details of configTable.querySelectorAll("details[data-category]")) {
|
|
121
|
+
|
|
122
|
+
states[details.getAttribute("data-category")] = !details.open;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return states;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Apply a previously-captured `{ [categoryName]: isCollapsed }` map onto the matching category `<details>` elements in the supplied container. Categories absent
|
|
130
|
+
* from the map are left at their current state. Symmetric counterpart to {@link captureCategoryStates}; routes every write through {@link setCategoryExpanded}
|
|
131
|
+
* so each programmatic toggle fires the `toggle` event the orchestrator listens for (which materializes lazy rows on expand and coalesces post-toggle sync).
|
|
132
|
+
*
|
|
133
|
+
* @param {HTMLElement} configTable - Container holding category `<details>` elements to apply state to.
|
|
134
|
+
* @param {Object<string, boolean>} states - Map of category name to collapsed boolean (the shape returned by captureCategoryStates).
|
|
135
|
+
*/
|
|
136
|
+
export function applyCategoryStates(configTable, states) {
|
|
137
|
+
|
|
138
|
+
for(const details of configTable.querySelectorAll("details[data-category]")) {
|
|
139
|
+
|
|
140
|
+
const categoryName = details.getAttribute("data-category");
|
|
141
|
+
|
|
142
|
+
if(categoryName in states) {
|
|
143
|
+
|
|
144
|
+
setCategoryExpanded(details, !states[categoryName]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Set the expansion state of a category. This is the single source of truth for programmatic category-state writes: it mutates `details.open`, which the browser
|
|
151
|
+
* propagates to the visible disclosure state (header arrow rotation via CSS keyed on `[open]`, content visibility via the native disclosure widget) and fires
|
|
152
|
+
* the `toggle` event the orchestrator's capture-phase delegated handler intercepts.
|
|
153
|
+
*
|
|
154
|
+
* Used by the search component for bulk expand/collapse and auto-expand during search, and by the orchestrator for restoring saved category states. User-driven
|
|
155
|
+
* toggles via summary click happen natively in the browser - no code path here.
|
|
156
|
+
*
|
|
157
|
+
* @param {HTMLDetailsElement} details - The category `<details>` element.
|
|
158
|
+
* @param {boolean} expanded - True to expand the category, false to collapse it.
|
|
159
|
+
*/
|
|
160
|
+
export function setCategoryExpanded(details, expanded) {
|
|
161
|
+
|
|
162
|
+
details.open = expanded;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Show a transient toast below the status bar (success styling by default; pass a variant for other alert types).
|
|
167
|
+
*
|
|
168
|
+
* The toast auto-dismisses after 3 seconds with a fade-out transition. Uses Bootstrap's alert component for consistent styling.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} message - The bold message text to display.
|
|
171
|
+
* @param {string} [variant="alert-success"] - The Bootstrap alert variant class.
|
|
172
|
+
*/
|
|
173
|
+
export function showToast(message, variant = "alert-success") {
|
|
174
|
+
|
|
175
|
+
const statusBar = document.getElementById("featureStatusBar");
|
|
176
|
+
|
|
177
|
+
if(!statusBar) {
|
|
178
|
+
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Construct the toast body as DOM nodes so the message string flows through textContent in <strong>, never as HTML. The dismiss button's static attributes are
|
|
183
|
+
// declared on the createElement props, including the `aria-label` and `data-bs-dismiss` attributes the helper routes to setAttribute.
|
|
184
|
+
const toast = createElement("div", { classList: "alert " + variant + " alert-dismissible fade show mt-2", role: "alert" }, [
|
|
185
|
+
|
|
186
|
+
createElement("strong", {}, [message]),
|
|
187
|
+
createElement("button", { "aria-label": "Close", classList: "btn-close", "data-bs-dismiss": "alert", type: "button" })
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
statusBar.insertAdjacentElement("afterend", toast);
|
|
191
|
+
|
|
192
|
+
// Auto-dismiss after 3 seconds. This keeps the UI clean while still providing sufficient time to read the message.
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
|
|
195
|
+
toast.classList.remove("show");
|
|
196
|
+
|
|
197
|
+
// Remove the node only after Bootstrap's `.fade` transition (150ms) has visually completed, so the toast fades out rather than vanishing abruptly.
|
|
198
|
+
setTimeout(() => toast.remove(), 150);
|
|
199
|
+
}, 3000);
|
|
200
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* webUi-featureOptions/views/connectionError.mjs: The connection-error state - error message + retry button + progress bar.
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
import { createElement, delay } from "../utils.mjs";
|
|
8
|
+
import { effect } from "../store.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Mount the connection-error view.
|
|
12
|
+
*
|
|
13
|
+
* Subscribes to `connection:error` and `model:loaded`. On `model:loaded` this view yields - it aborts its retry window and stops rendering - and the shared
|
|
14
|
+
* `#headerInfo` container is reclaimed by the header view; it does not itself clear the error display.
|
|
15
|
+
*
|
|
16
|
+
* Renders into the same `#headerInfo` container the priority-chain header uses. The two views coordinate via the `state.status` discriminator: header yields when
|
|
17
|
+
* status is connection-error; this view yields when status is anything else.
|
|
18
|
+
*
|
|
19
|
+
* Renders:
|
|
20
|
+
*
|
|
21
|
+
* - An error message block with the user-facing message from `state.status.message`.
|
|
22
|
+
* - A retry button, initially disabled, that becomes enabled after `retryDelayMs` milliseconds. The delay is a brief throttle so the user does not retry-bash a
|
|
23
|
+
* recovering controller.
|
|
24
|
+
* - A progress bar that fills during the retry-delay window so the user has visual feedback that the retry button is coming alive.
|
|
25
|
+
*
|
|
26
|
+
* The retry button's click invokes the caller-supplied `onRetry` callback (typically the orchestrator's cleanup + show() sequence).
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} args
|
|
29
|
+
* @param {() => Promise<void>} args.onRetry - Callback invoked when the retry button is clicked. The orchestrator passes its restart routine.
|
|
30
|
+
* @param {number} [args.retryDelayMs=5000] - Milliseconds before the retry button becomes enabled.
|
|
31
|
+
* @param {HTMLElement} args.root - The `#headerInfo` container.
|
|
32
|
+
* @param {AbortSignal} args.signal - Lifecycle signal.
|
|
33
|
+
* @param {import("../store.mjs").FeatureOptionsStore} args.store - The store.
|
|
34
|
+
*/
|
|
35
|
+
export const mountConnectionErrorView = ({ onRetry, retryDelayMs = 5000, root, signal, store }) => {
|
|
36
|
+
|
|
37
|
+
// One active retry-window controller at a time. Aborting the parent signal aborts it; transitioning out of the error state also aborts it so a partially-armed
|
|
38
|
+
// retry button does not linger after the user navigates away.
|
|
39
|
+
let retryAbort = null;
|
|
40
|
+
|
|
41
|
+
effect({
|
|
42
|
+
|
|
43
|
+
events: [ "connection:error", "model:loaded" ],
|
|
44
|
+
fn: () => {
|
|
45
|
+
|
|
46
|
+
const { status } = store.state;
|
|
47
|
+
|
|
48
|
+
// Tear down any prior retry window before either rendering a new one or yielding back to the header view.
|
|
49
|
+
retryAbort?.abort();
|
|
50
|
+
retryAbort = null;
|
|
51
|
+
|
|
52
|
+
if(status.kind !== "connection-error") {
|
|
53
|
+
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
retryAbort = new AbortController();
|
|
58
|
+
renderError({ message: status.message, onRetry, retryDelayMs, retrySignal: retryAbort.signal, root });
|
|
59
|
+
},
|
|
60
|
+
signal,
|
|
61
|
+
store
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Cleanup on parent abort. The effect's signal handles its own teardown; this ensures the inner retry-window controller is also aborted if it is still active.
|
|
65
|
+
signal.addEventListener("abort", () => {
|
|
66
|
+
|
|
67
|
+
retryAbort?.abort();
|
|
68
|
+
}, { once: true });
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Render the error block into the root container. Builds the structural pieces (error text, retry button, progress bar) and arms the retry window via the
|
|
72
|
+
// supplied retry signal.
|
|
73
|
+
const renderError = ({ message, onRetry, retryDelayMs, retrySignal, root }) => {
|
|
74
|
+
|
|
75
|
+
const errorBlock = createElement("div", {}, [
|
|
76
|
+
|
|
77
|
+
"Unable to connect to the controller.",
|
|
78
|
+
createElement("br"),
|
|
79
|
+
"Check the Settings tab to verify the controller details are correct.",
|
|
80
|
+
createElement("br"),
|
|
81
|
+
createElement("code", { classList: ["text-danger"] }, [message]),
|
|
82
|
+
createElement("br")
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const retryButton = createElement("button", {
|
|
86
|
+
|
|
87
|
+
classList: [ "btn", "btn-warning", "btn-sm", "mt-3" ],
|
|
88
|
+
textContent: "↻ Retry",
|
|
89
|
+
type: "button"
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
retryButton.disabled = true;
|
|
93
|
+
|
|
94
|
+
const barWrap = createElement("div", {
|
|
95
|
+
|
|
96
|
+
classList: [ "progress", "mt-1", "w-100" ],
|
|
97
|
+
style: { height: "4px" }
|
|
98
|
+
}, [
|
|
99
|
+
|
|
100
|
+
createElement("div", {
|
|
101
|
+
|
|
102
|
+
classList: ["progress-bar"],
|
|
103
|
+
role: "progressbar",
|
|
104
|
+
style: { transition: "width " + retryDelayMs + "ms linear", width: "0%" }
|
|
105
|
+
})
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const retryWrap = createElement("div", { classList: [ "d-inline-block", "w-auto" ] }, [ retryButton, barWrap ]);
|
|
109
|
+
|
|
110
|
+
errorBlock.appendChild(retryWrap);
|
|
111
|
+
root.replaceChildren(errorBlock);
|
|
112
|
+
root.style.display = "";
|
|
113
|
+
|
|
114
|
+
// Kick the progress bar animation on the next animation frame so the transition has a starting value to interpolate from.
|
|
115
|
+
window.requestAnimationFrame(() => {
|
|
116
|
+
|
|
117
|
+
const bar = barWrap.querySelector(".progress-bar");
|
|
118
|
+
|
|
119
|
+
if(bar) {
|
|
120
|
+
|
|
121
|
+
bar.style.width = "100%";
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Arm the retry window. The retry button enables after the delay and removes the progress bar; an abort cancels both.
|
|
126
|
+
void armRetry({ barWrap, onRetry, retryButton, retryDelayMs, retrySignal });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Arm the retry button after the configured delay. The button enables, the progress bar removes itself, and a click handler is wired up that invokes the supplied
|
|
130
|
+
// onRetry callback. An abort mid-window cancels both the delay and any wired click handler.
|
|
131
|
+
const armRetry = async ({ barWrap, onRetry, retryButton, retryDelayMs, retrySignal }) => {
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
|
|
135
|
+
await delay(retryDelayMs, retrySignal);
|
|
136
|
+
} catch {
|
|
137
|
+
|
|
138
|
+
// Retry window was aborted before completion - either by a state transition out of connection-error or by the parent lifecycle signal. Nothing to clean up;
|
|
139
|
+
// the DOM detaches with the surrounding view.
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
retryButton.disabled = false;
|
|
144
|
+
barWrap.remove();
|
|
145
|
+
|
|
146
|
+
retryButton.addEventListener("click", () => {
|
|
147
|
+
|
|
148
|
+
retryButton.disabled = true;
|
|
149
|
+
retryButton.textContent = "Retrying...";
|
|
150
|
+
void onRetry();
|
|
151
|
+
}, { once: true, signal: retrySignal });
|
|
152
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* webUi-featureOptions/views/deviceInfo.mjs: The device-stats panel in the sidebar.
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
import { createElement } from "../utils.mjs";
|
|
8
|
+
import { effect } from "../store.mjs";
|
|
9
|
+
import { selectedDevice } from "../selectors.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Mount the device-info view.
|
|
13
|
+
*
|
|
14
|
+
* Re-renders on every scope change so the stats panel reflects the currently-selected device. The view delegates the actual stats rendering to a caller-supplied
|
|
15
|
+
* `infoPanel` callback - plugins override this to surface plugin-specific device metadata (firmware version, model, status indicators). When no override is
|
|
16
|
+
* supplied, the default callback ({@link defaultInfoPanel}) renders a four-column grid of firmware / serial / model / manufacturer.
|
|
17
|
+
*
|
|
18
|
+
* The container is shown when any device is in scope (controller-as-device or regular device) and cleared when the scope is global (no specific device to show
|
|
19
|
+
* stats for - the global view aggregates options across every device).
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} args
|
|
22
|
+
* @param {((root: HTMLElement, device: import("../state.mjs").Device | undefined) => void) | undefined} args.infoPanel - Optional plugin-provided renderer. When
|
|
23
|
+
* omitted, {@link defaultInfoPanel} is used.
|
|
24
|
+
* @param {HTMLElement} args.root - The `#deviceStatsContainer` element.
|
|
25
|
+
* @param {AbortSignal} args.signal - Lifecycle signal.
|
|
26
|
+
* @param {import("../store.mjs").FeatureOptionsStore} args.store - The store.
|
|
27
|
+
*/
|
|
28
|
+
export const mountDeviceInfoView = ({ infoPanel = defaultInfoPanel, root, signal, store }) => {
|
|
29
|
+
|
|
30
|
+
const render = (panelFn) => (panelFn ?? defaultInfoPanel)(root, selectedDevice(store.state));
|
|
31
|
+
|
|
32
|
+
effect({
|
|
33
|
+
|
|
34
|
+
events: [ "scope:changed", "devices:loaded", "model:loaded" ],
|
|
35
|
+
fn: () => {
|
|
36
|
+
|
|
37
|
+
// The view populates its region but never reveals it; the orchestrator owns region visibility (revealRegions on the success path), so the device-stats panel
|
|
38
|
+
// appears together with the rest of the populated UI rather than the moment this view mounts.
|
|
39
|
+
render(infoPanel);
|
|
40
|
+
},
|
|
41
|
+
signal,
|
|
42
|
+
store
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default device-info renderer. Renders a labeled grid of device identity fields (firmware / serial number / model / manufacturer), each cell carrying a small
|
|
48
|
+
* uppercase label above the value. Clears the container entirely when no device is in scope.
|
|
49
|
+
*
|
|
50
|
+
* Untrusted device fields flow through `textContent` (via createElement's text-node path) so any markup-shaped fragments surface as literal text rather than
|
|
51
|
+
* rendered HTML. Container is rebuilt on every call - replaceChildren handles both the initial render and any subsequent device switch.
|
|
52
|
+
*
|
|
53
|
+
* @param {HTMLElement} root - The container element.
|
|
54
|
+
* @param {import("../state.mjs").Device | undefined} device - The device to render stats for, or undefined for global view.
|
|
55
|
+
*/
|
|
56
|
+
export const defaultInfoPanel = (root, device) => {
|
|
57
|
+
|
|
58
|
+
if(!device) {
|
|
59
|
+
|
|
60
|
+
root.textContent = "";
|
|
61
|
+
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const stats = [
|
|
66
|
+
|
|
67
|
+
[ "Firmware", device.firmwareRevision ?? "N/A" ],
|
|
68
|
+
[ "Serial Number", device.serialNumber ?? "N/A" ],
|
|
69
|
+
[ "Model", device.model ?? "N/A" ],
|
|
70
|
+
[ "Manufacturer", device.manufacturer ?? "N/A" ]
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const grid = createElement("div", { classList: ["device-stats-grid"] }, stats.map(([ label, value ]) => createElement("div", { classList: ["stat-item"] }, [
|
|
74
|
+
|
|
75
|
+
createElement("span", { classList: ["stat-label"] }, [label]),
|
|
76
|
+
createElement("span", { classList: ["stat-value"] }, [value])
|
|
77
|
+
])));
|
|
78
|
+
|
|
79
|
+
root.replaceChildren(grid);
|
|
80
|
+
};
|