node-red-contrib-room-tools 1.8.15 → 1.9.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.
@@ -6,10 +6,15 @@ function initializeVisualBinding () {
6
6
  const root = this.$el
7
7
  const base = config.editorEndpoint
8
8
  const saved = new Map(Object.entries(config.bindings || {}))
9
+ const meterEntries = [...saved].filter(([, entry]) => entry.readMeta?.meter)
10
+ const meterTargets = new Map()
11
+ let meterClient = null, lastMeterFrame = null
9
12
  const attached = new WeakSet()
10
13
  const feedbackModels = new Map()
11
14
  const pendingText = new Map()
12
15
  const cleanup = []
16
+ let disposed = false
17
+ cleanup.push(() => { disposed = true })
13
18
  let editing = new URL(window.location.href).searchParams.get('cp-edit') === config.panelId
14
19
  let editSession = null
15
20
  let snapshot = null
@@ -47,15 +52,15 @@ function initializeVisualBinding () {
47
52
  status.dataset.error = error ? 'true' : 'false'
48
53
  }
49
54
  }
50
- async function request (path, method = 'GET', body) {
51
- const response = await component.__panelFetch(base + path, {
55
+ async function request (path, method = 'GET', body, timeoutMs) {
56
+ const { response, result } = await component.__panelFetch(base + path, {
52
57
  method, credentials: 'same-origin',
53
58
  headers: method === 'POST'
54
59
  ? { 'Content-Type': 'application/json', 'X-Panel-CSRF': editSession?.csrf || '' }
55
60
  : undefined,
56
61
  body: method === 'POST' ? JSON.stringify(body || {}) : undefined
57
- }, path === '/apply' ? 120000 : 12000)
58
- const result = await response.json().catch(() => ({}))
62
+ }, path === '/apply' ? 120000 : (timeoutMs ?? 12000),
63
+ async response => ({ response, result: await response.json() }))
59
64
  if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`)
60
65
  return result
61
66
  }
@@ -102,25 +107,106 @@ function initializeVisualBinding () {
102
107
  }
103
108
  return String(node.value)
104
109
  }
110
+ function repeatMarkerFor (node, repeat) {
111
+ let current = node
112
+ while (current && current !== root) {
113
+ if (current.getAttribute?.('data-cp-repeat') === repeat?.locator) {
114
+ const key = current.getAttribute('data-cp-repeat-key')
115
+ return key == null ? null : String(key)
116
+ }
117
+ current = current.parentElement
118
+ }
119
+ return null
120
+ }
121
+ function bindingMatchesNode (node, binding) {
122
+ if (node.getAttribute('data-cp-auto') !== binding.locator) return false
123
+ if (!binding.repeat) return true
124
+ return repeatMarkerFor(node, binding.repeat) === String(binding.instanceKey)
125
+ }
126
+ function nodesForBinding (binding) {
127
+ return [...root.querySelectorAll('[data-cp-auto]')].filter(node => bindingMatchesNode(node, binding))
128
+ }
129
+ function nodeForBinding (binding) {
130
+ const matches = nodesForBinding(binding)
131
+ return matches.length === 1 ? matches[0] : null
132
+ }
133
+ function repeatItem (binding) {
134
+ const repeat = binding.repeat
135
+ const source = component[repeat.source]
136
+ if (!Array.isArray(source)) return undefined
137
+ if (repeat.kind === 'array') return source[Number(binding.instanceKey)]
138
+ const keyField = repeat.key.slice(repeat.value.length + 1)
139
+ return source.find(value => value && String(value[keyField]) === String(binding.instanceKey))
140
+ }
141
+ function readRepeatField (item, field) {
142
+ return field.split('.').reduce((value, name) => value == null ? undefined : value[name], item)
143
+ }
144
+ function writeRepeatField (item, field, value) {
145
+ const names = field.split('.')
146
+ const last = names.pop()
147
+ let target = item
148
+ for (const name of names) {
149
+ if (!target || typeof target !== 'object') return false
150
+ target = target[name]
151
+ }
152
+ if (!target || typeof target !== 'object') return false
153
+ if (!Object.is(target[last], value)) target[last] = value
154
+ return true
155
+ }
156
+ function repeatModelValue (binding) {
157
+ if (!binding.repeat || !binding.model) return binding.model ? component[binding.model] : undefined
158
+ const repeat = binding.repeat
159
+ const source = component[repeat.source]
160
+ if (!Array.isArray(source)) return undefined
161
+ const item = repeatItem(binding)
162
+ if (repeat.kind === 'array' && binding.model === `${repeat.source}[${repeat.index}]`) return item
163
+ const prefix = repeat.value + '.'
164
+ const indexedPrefix = repeat.index ? `${repeat.source}[${repeat.index}].` : ''
165
+ const field = binding.model.startsWith(prefix) ? binding.model.slice(prefix.length)
166
+ : indexedPrefix && binding.model.startsWith(indexedPrefix)
167
+ ? binding.model.slice(indexedPrefix.length) : ''
168
+ return item && field ? readRepeatField(item, field) : undefined
169
+ }
170
+ function setRepeatModel (binding, value) {
171
+ if (!binding.repeat || !binding.model) return false
172
+ const repeat = binding.repeat
173
+ const source = component[repeat.source]
174
+ if (!Array.isArray(source)) return false
175
+ const item = repeatItem(binding)
176
+ const prefix = repeat.value + '.'
177
+ const indexedPrefix = repeat.index ? `${repeat.source}[${repeat.index}].` : ''
178
+ const field = binding.model.startsWith(prefix) ? binding.model.slice(prefix.length)
179
+ : indexedPrefix && binding.model.startsWith(indexedPrefix)
180
+ ? binding.model.slice(indexedPrefix.length) : ''
181
+ if (field) {
182
+ return writeRepeatField(item, field, value)
183
+ }
184
+ if (repeat.kind !== 'array' || binding.model !== `${repeat.source}[${repeat.index}]`) return false
185
+ const index = Number(binding.instanceKey)
186
+ if (!Number.isInteger(index) || index < 0 || index >= source.length) return false
187
+ if (!Object.is(source[index], value)) source[index] = value
188
+ return true
189
+ }
105
190
  function showFeedback () {
106
191
  for (const [id, binding] of saved) {
107
- if (binding.mode === 'write') continue
108
- const matches = root.querySelectorAll('[data-cp-auto="' + binding.locator + '"]')
109
- if (matches.length !== 1) continue
192
+ if (binding.mode === 'write' || binding.readMeta?.meter) continue
193
+ const node = nodeForBinding(binding)
194
+ if (!node) continue
110
195
  if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
111
196
  const value = component.boundDisplayValue(id, component.boundState[id])
112
- const node = matches[0]
113
197
  const displayValue = ['range', 'meter'].includes(binding.adapter)
114
198
  ? mappedValue(Number(value), binding.readMeta || binding.writeMeta,
115
199
  numericRange(binding, node)) : value
116
200
  if (binding.model) {
117
201
  if (binding.component && binding.adapter === 'text-input' &&
118
202
  (pendingText.has(id) || node.contains?.(document.activeElement))) continue
119
- if (binding.model in component && !component.$options?.computed?.[binding.model]) {
120
- if (!Object.is(component[binding.model], displayValue)) {
203
+ const current = repeatModelValue(binding)
204
+ if (binding.repeat || (binding.model in component && !component.$options?.computed?.[binding.model])) {
205
+ if (!Object.is(current, displayValue)) {
121
206
  pendingText.delete(id)
122
207
  if (binding.component) feedbackModels.set(id, displayValue)
123
- component[binding.model] = displayValue
208
+ if (binding.repeat) setRepeatModel(binding, displayValue)
209
+ else component[binding.model] = displayValue
124
210
  if (binding.component && component.$nextTick) {
125
211
  void component.$nextTick(() => {
126
212
  if (Object.is(feedbackModels.get(id), displayValue)) feedbackModels.delete(id)
@@ -146,11 +232,12 @@ function initializeVisualBinding () {
146
232
  function attachControls () {
147
233
  for (const node of root.querySelectorAll('[data-cp-auto]')) {
148
234
  if (attached.has(node)) continue
149
- attached.add(node)
150
235
  const locator = node.getAttribute('data-cp-auto')
151
- const matched = [...saved].find(([, entry]) => entry.locator === locator)
236
+ const matched = [...saved].find(([, entry]) => bindingMatchesNode(node, entry))
152
237
  if (!matched) continue
153
238
  const [id, binding] = matched
239
+ if (nodeForBinding(binding) !== node) continue
240
+ attached.add(node)
154
241
  if (binding.mode === 'read') continue
155
242
  if (binding.component) {
156
243
  if (binding.adapter === 'text-input') {
@@ -192,8 +279,40 @@ function initializeVisualBinding () {
192
279
  })
193
280
  } else node.addEventListener('change', () => send(true))
194
281
  }
282
+ if (meterEntries.length) {
283
+ meterTargets.clear()
284
+ for (const [id, binding] of meterEntries) {
285
+ const node = nodeForBinding(binding)
286
+ if (node) meterTargets.set(id, { node, binding })
287
+ }
288
+ if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
289
+ else invalidateMeters()
290
+ }
195
291
  showFeedback()
196
292
  }
293
+ function applyMeterFrame (frame, ids) {
294
+ lastMeterFrame = { frame, ids }
295
+ for (let index = 0; index < ids.length; index++) {
296
+ const target = meterTargets.get(ids[index])
297
+ if (!target) continue
298
+ const { node, binding } = target
299
+ node.setAttribute('data-cpv-meter-valid', String(frame.valid[index]))
300
+ node.setAttribute('aria-invalid', String(!frame.valid[index]))
301
+ if (!frame.valid[index]) continue
302
+ const value = mappedValue(frame.values[index], binding.readMeta, numericRange(binding, node))
303
+ if (binding.model) {
304
+ if (binding.repeat) setRepeatModel(binding, value)
305
+ else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = value
306
+ } else node.value = value
307
+ }
308
+ }
309
+ function invalidateMeters () {
310
+ lastMeterFrame = null
311
+ for (const { node } of meterTargets.values()) {
312
+ node.setAttribute('data-cpv-meter-valid', 'false')
313
+ node.setAttribute('aria-invalid', 'true')
314
+ }
315
+ }
197
316
  function attachModelControls () {
198
317
  for (const [id, binding] of saved) {
199
318
  if (!binding.component || !binding.model || binding.mode === 'read') continue
@@ -230,7 +349,9 @@ function initializeVisualBinding () {
230
349
  console.error('ControlPanel binding command failed', error)
231
350
  }
232
351
  }
233
- const unwatch = component.$watch(binding.model, value => {
352
+ const unwatch = component.$watch(
353
+ binding.repeat ? () => repeatModelValue(binding) : binding.model,
354
+ value => {
234
355
  if (feedbackModels.has(id)) {
235
356
  const feedbackValue = feedbackModels.get(id)
236
357
  feedbackModels.delete(id)
@@ -246,21 +367,25 @@ function initializeVisualBinding () {
246
367
  clearTimeout(finishTimer)
247
368
  finishTimer = setTimeout(() => send(value, true), 160)
248
369
  } else send(value, true)
249
- })
370
+ })
250
371
  cleanup.push(() => { clearTimeout(finishTimer); unwatch() })
251
372
  }
252
373
  }
253
374
  function closeOverlay () { overlay?.remove(); overlay = null }
375
+ function sameBindingTarget (entry, candidate) {
376
+ return entry.locator === candidate.locator &&
377
+ (!candidate.repeat || String(entry.instanceKey) === String(candidate.instanceKey))
378
+ }
254
379
  function updateBindingMarkers () {
255
380
  for (const node of root.querySelectorAll('[data-cpv-bound="true"]')) {
256
381
  node.removeAttribute('data-cpv-bound')
257
382
  node.removeAttribute('data-cpv-binding-id')
258
383
  }
259
384
  if (!editing || !editSession) return
260
- const bindings = new Map(draft.map(entry => [entry.locator, entry]))
261
385
  for (const node of root.querySelectorAll('[data-cp-auto]')) {
262
- const entry = bindings.get(node.getAttribute('data-cp-auto'))
386
+ const entry = draft.find(item => bindingMatchesNode(node, item))
263
387
  if (!entry) continue
388
+ if (nodeForBinding(entry) !== node) continue
264
389
  node.setAttribute('data-cpv-bound', 'true')
265
390
  node.setAttribute('data-cpv-binding-id', entry.id)
266
391
  }
@@ -269,10 +394,10 @@ function initializeVisualBinding () {
269
394
  closeOverlay()
270
395
  overlay = element('div', null, 'cpv-menu')
271
396
  overlay.setAttribute('role', 'menu')
272
- const existing = draft.find(entry => entry.locator === candidate.locator)
397
+ const existing = draft.find(entry => sameBindingTarget(entry, candidate))
273
398
  overlay.append(button(existing ? 'Edit binding' : 'Bind control', () => bindingDialog(candidate)))
274
399
  if (existing) overlay.append(button('Remove binding', () => {
275
- draft = draft.filter(entry => entry.locator !== candidate.locator)
400
+ draft = draft.filter(entry => !sameBindingTarget(entry, candidate))
276
401
  updateBindingMarkers()
277
402
  closeOverlay()
278
403
  inform('Binding removed from draft')
@@ -330,6 +455,7 @@ function initializeVisualBinding () {
330
455
  }
331
456
  function closeLoginScreen () { loginScreen?.remove(); loginScreen = null }
332
457
  function showLoginForm (title, login, editor = false) {
458
+ if (disposed) return
333
459
  const content = coverPage(title, editor ? 'editor' : 'panel', 'login')
334
460
  const form = element('form')
335
461
  const user = field(form, editor ? 'Editor username' : 'Username', element('input'))
@@ -353,6 +479,7 @@ function initializeVisualBinding () {
353
479
  message.textContent = ''
354
480
  try {
355
481
  await login(user.value, password.value)
482
+ if (disposed) return
356
483
  closeLoginScreen()
357
484
  } catch (error) {
358
485
  message.textContent = error.message
@@ -366,13 +493,13 @@ function initializeVisualBinding () {
366
493
  }
367
494
  function loginView () {
368
495
  showLoginForm('ControlPanel editor', async (username, password) => {
369
- const response = await component.__panelFetch(base + '/login', {
496
+ const { response, result } = await component.__panelFetch(base + '/login', {
370
497
  method: 'POST', credentials: 'same-origin',
371
498
  headers: { 'Content-Type': 'application/json' },
372
499
  body: JSON.stringify({ username, password })
373
- })
374
- const result = await response.json()
500
+ }, 12000, async response => ({ response, result: await response.json() }))
375
501
  if (!response.ok) throw new Error(result.error || 'Login failed')
502
+ if (disposed) return
376
503
  editSession = result
377
504
  await loadEditor()
378
505
  }, true)
@@ -382,7 +509,9 @@ function initializeVisualBinding () {
382
509
  }
383
510
  async function loadEditor () {
384
511
  snapshot = await request('/config')
512
+ if (disposed) return
385
513
  const response = await request('/devices')
514
+ if (disposed) return
386
515
  devices = response.devices
387
516
  draft = snapshot.bindings.map(entry => ({ ...entry, onebased: numberingMode(entry) }))
388
517
  closeOverlay()
@@ -390,6 +519,7 @@ function initializeVisualBinding () {
390
519
  renderToolbar()
391
520
  inform('Choose a control on the page')
392
521
  updateBindingMarkers()
522
+ meterClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
393
523
  }
394
524
  function choose (label, options, selected) {
395
525
  const select = element('select')
@@ -411,14 +541,17 @@ function initializeVisualBinding () {
411
541
  }
412
542
  async function bindingDialog (candidate, options = {}) {
413
543
  const returnToBindings = options.returnToBindings === true
414
- const existing = draft.find(entry => entry.locator === candidate.locator)
544
+ const existing = draft.find(entry => sameBindingTarget(entry, candidate))
415
545
  const dialog = showDialog(existing ? 'Edit binding' : 'Bind control')
416
546
  const form = element('form')
417
547
  const name = field(form, 'Name', element('input'))
418
- name.value = existing?.label || candidate.label
548
+ name.value = existing?.label || (candidate.repeat ? `${candidate.label} [${candidate.instanceKey}]` : candidate.label)
419
549
  name.maxLength = 80
420
550
  const id = field(form, 'Control ID', element('input'))
421
- id.value = existing?.id || 'control.' + candidate.locator.slice(-12)
551
+ const instanceSuffix = candidate.repeat
552
+ ? '.' + String(candidate.instanceKey).replace(/[^A-Za-z0-9._/-]/g, '-').slice(0, 48)
553
+ : ''
554
+ id.value = existing?.id || 'control.' + candidate.locator.slice(-12) + instanceSuffix
422
555
  const modeOptions = ['meter', 'text-display'].includes(candidate.adapter)
423
556
  ? ['read'] : ['both', 'write', 'read']
424
557
  const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
@@ -486,7 +619,7 @@ function initializeVisualBinding () {
486
619
  const actions = element('div', null, 'cpv-actions')
487
620
  actions.append(button('Cancel', () => finishBindingEdit(returnToBindings)))
488
621
  if (existing) actions.append(button('Remove', () => {
489
- draft = draft.filter(entry => entry.locator !== candidate.locator)
622
+ draft = draft.filter(entry => !sameBindingTarget(entry, candidate))
490
623
  finishBindingEdit(returnToBindings)
491
624
  inform('Binding removed from draft')
492
625
  }))
@@ -497,7 +630,7 @@ function initializeVisualBinding () {
497
630
  form.addEventListener('submit', event => {
498
631
  event.preventDefault()
499
632
  if (!/^[A-Za-z][A-Za-z0-9._/-]{0,127}$/.test(id.value) ||
500
- draft.some(entry => entry.id === id.value && entry.locator !== candidate.locator)) {
633
+ draft.some(entry => entry.id === id.value && !sameBindingTarget(entry, candidate))) {
501
634
  message.textContent = 'Control ID must be unique and valid'; return
502
635
  }
503
636
  if (!name.value.trim() || !device.value || (mode.value !== 'read' && !write.value) ||
@@ -507,12 +640,13 @@ function initializeVisualBinding () {
507
640
  const entry = {
508
641
  id: id.value, locator: candidate.locator, adapter: candidate.adapter,
509
642
  ...(candidate.model ? { model: candidate.model } : {}),
643
+ ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: String(candidate.instanceKey) } : {}),
510
644
  label: name.value.trim(), mode: mode.value, device: device.value,
511
645
  writePath: mode.value === 'read' ? '' : write.value,
512
646
  readPath: mode.value === 'write' ? '' : read.value,
513
647
  onebased: numbering.value === 'true'
514
648
  }
515
- draft = draft.filter(item => item.locator !== candidate.locator)
649
+ draft = draft.filter(item => !sameBindingTarget(item, candidate))
516
650
  draft.push(entry)
517
651
  updateBindingMarkers()
518
652
  finishBindingEdit(returnToBindings)
@@ -566,6 +700,8 @@ function initializeVisualBinding () {
566
700
  const position = (order.get(a.locator) ?? Number.MAX_SAFE_INTEGER) -
567
701
  (order.get(b.locator) ?? Number.MAX_SAFE_INTEGER)
568
702
  if (position) return position
703
+ const instancePosition = String(a.instanceKey || '').localeCompare(String(b.instanceKey || ''))
704
+ if (instancePosition) return instancePosition
569
705
  return String(a.id).localeCompare(String(b.id))
570
706
  })
571
707
  }
@@ -580,10 +716,11 @@ function initializeVisualBinding () {
580
716
  details.append(element('span', path, 'cpv-binding-path'))
581
717
  row.append(details)
582
718
  const actions = element('div', null, 'cpv-binding-actions')
583
- const candidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
719
+ const baseCandidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
720
+ const candidate = baseCandidate?.repeat ? { ...baseCandidate, instanceKey: entry.instanceKey } : baseCandidate
584
721
  const edit = button('Edit', () => bindingDialog(candidate, { returnToBindings: true }))
585
- edit.disabled = !candidate
586
- if (!candidate) edit.title = 'This control is no longer available on the page'
722
+ edit.disabled = !candidate || !nodeForBinding(entry)
723
+ if (edit.disabled) edit.title = 'This control is no longer available on the page'
587
724
  actions.append(edit)
588
725
  actions.append(button('Remove', () => {
589
726
  draft = draft.filter(item => item.id !== entry.id)
@@ -604,9 +741,17 @@ function initializeVisualBinding () {
604
741
  function candidateFromEvent (event) {
605
742
  const node = event.target?.closest?.('[data-cp-auto]')
606
743
  if (!node || !root.contains(node) || !snapshot) return null
607
- const candidate = snapshot.candidates.find(item => item.locator === node.getAttribute('data-cp-auto'))
608
- if (!candidate?.component || !candidate.model) return candidate
609
- const value = component[candidate.model]
744
+ const baseCandidate = snapshot.candidates.find(item => item.locator === node.getAttribute('data-cp-auto'))
745
+ if (!baseCandidate) return null
746
+ const instanceKey = baseCandidate.repeat ? repeatMarkerFor(node, baseCandidate.repeat) : undefined
747
+ const candidate = baseCandidate.repeat
748
+ ? { ...baseCandidate, ...(instanceKey == null ? {} : { instanceKey }) }
749
+ : baseCandidate
750
+ if (candidate.repeat && instanceKey == null) {
751
+ return { ...candidate, supported: false, reason: 'Repeated control instance key is unavailable' }
752
+ }
753
+ if (!candidate.component || !candidate.model) return candidate
754
+ const value = candidate.repeat ? repeatModelValue(candidate) : component[candidate.model]
610
755
  if (value == null) return candidate
611
756
  if (candidate.adapter === 'switch' && typeof value !== 'boolean') {
612
757
  return { ...candidate, supported: false, reason: 'This Vuetify switch needs a boolean v-model field' }
@@ -658,6 +803,25 @@ function initializeVisualBinding () {
658
803
  }
659
804
  attachControls()
660
805
  attachModelControls()
806
+ if (this.__boundPanelConfig().meter && meterEntries.length) {
807
+ try {
808
+ const ids = meterEntries.map(([id]) => id).sort((a, b) => a.localeCompare(b))
809
+ meterClient = this.__createPanelMeterClient({ socket: this.$socket,
810
+ panelId: config.panelId, room: this.__boundPanelConfig().room, ids,
811
+ request: (...args) => component.__panelFetch(...args),
812
+ createReceiver: this.__createRoomMeterReceiver, createRenderer: this.__createMeterRenderer,
813
+ apply: applyMeterFrame, invalidate: invalidateMeters,
814
+ onUnauthorized: () => {
815
+ if (editing) { editSession = null; loginView() }
816
+ else component.clearBoundPanelSession()
817
+ } })
818
+ cleanup.push(() => meterClient.close())
819
+ if (!editing) cleanup.push(this.$watch('boundAuthenticated', authenticated => {
820
+ meterClient.setAuth(authenticated ? { endpoint: component.__boundPanelConfig().endpoint,
821
+ csrf: component.__boundPanelSession.csrf, header: 'X-Room-CSRF' } : null)
822
+ }))
823
+ } catch (error) { console.error('ControlPanel meter initialization failed', error) }
824
+ }
661
825
  observer = new MutationObserver(() => {
662
826
  attachControls()
663
827
  updateBindingMarkers()
@@ -730,6 +894,7 @@ function initializeVisualBinding () {
730
894
  .cpv-binding-actions{display:flex;gap:6px;flex-shrink:0}
731
895
  body.cpv-editing [data-cpv-bound="true"]{outline:2px solid #b526f2 !important;outline-offset:3px;box-shadow:0 0 0 4px #b526f255 !important}
732
896
  body.cpv-editing [data-cp-auto]:hover{outline:2px solid #b526f2;outline-offset:2px}
897
+ [data-cpv-meter-valid="false"]{opacity:.45}
733
898
  `
734
899
  document.head.append(style)
735
900
  cleanup.push(() => style.remove())
@@ -737,7 +902,11 @@ function initializeVisualBinding () {
737
902
  document.body.classList.add('cpv-editing')
738
903
  cleanup.push(() => document.body.classList.remove('cpv-editing'))
739
904
  coverPage('Loading...', 'editor')
740
- request('/session').then(session => { editSession = session; return loadEditor() })
905
+ request('/session', 'GET', undefined, 2500).then(session => {
906
+ if (disposed) return
907
+ editSession = session
908
+ return loadEditor()
909
+ })
741
910
  .then(closeLoginScreen).catch(() => loginView())
742
911
  } else {
743
912
  coverPage('Loading...', 'panel')
@@ -745,10 +914,13 @@ function initializeVisualBinding () {
745
914
  if (authenticated) closeLoginScreen()
746
915
  else if (!loginScreen) normalLoginView()
747
916
  }))
748
- component.restoreBoundPanelSession().then(session => {
917
+ component.restoreBoundPanelSession(2500).then(session => {
918
+ if (disposed) return
749
919
  if (session || component.boundAuthenticated) closeLoginScreen()
750
920
  else normalLoginView()
751
- }).catch(() => normalLoginView())
921
+ }).catch(() => {
922
+ normalLoginView()
923
+ })
752
924
  }
753
925
  }
754
926
 
@@ -1,7 +1,7 @@
1
1
  const crypto = require('node:crypto')
2
2
  const { readJsonBody, replyJson: reply, sameOrigin } = require('./panel-http')
3
3
 
4
- function createPanelEditor ({ panelId, username, password, secureCookie, getConfig, getDevices, getTargets, apply, restoredSessions }) {
4
+ function createPanelEditor ({ panelId, username, password, secureCookie, getConfig, getDevices, getTargets, apply, restoredSessions, onAuthenticatedRequest }) {
5
5
  if (!username || !password) return null
6
6
  const prefix = `/control-panel-edit/panels/${panelId}`
7
7
  const cookieName = `cp_edit_${panelId.replace(/[^a-zA-Z0-9]/g, '_')}`
@@ -71,6 +71,8 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
71
71
  reply(res, 403, { error: 'CSRF rejected' })
72
72
  return true
73
73
  }
74
+ if (onAuthenticatedRequest && await onAuthenticatedRequest(req, res,
75
+ { token, expiry: session.expiry })) return true
74
76
  if (path === prefix + '/session' && req.method === 'GET') {
75
77
  reply(res, 200, { csrf: session.csrf })
76
78
  } else if (path === prefix + '/logout' && req.method === 'POST') {
@@ -84,7 +86,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
84
86
  const device = new URL(req.url, 'http://localhost').searchParams.get('device')
85
87
  reply(res, 200, { targets: await getTargets(req, device) })
86
88
  } else if (path === prefix + '/apply' && req.method === 'POST') {
87
- reply(res, 200, await apply(req, await readJsonBody(req, 65536)))
89
+ reply(res, 200, await apply(req, await readJsonBody(req, 524288)))
88
90
  } else {
89
91
  reply(res, 404, { error: 'Not found' })
90
92
  }
@@ -97,6 +99,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
97
99
 
98
100
  return {
99
101
  handler,
102
+ sessionActive: token => !closed && (sessions.get(token)?.expiry || 0) > Date.now(),
100
103
  sessionSnapshot () {
101
104
  return new Map([...sessions].filter(([, session]) => session.expiry > Date.now())
102
105
  .map(([token, session]) => [token, { ...session }]))
package/panel-http.js CHANGED
@@ -56,12 +56,13 @@ async function readJsonBody (req, maxBytes) {
56
56
  }
57
57
 
58
58
  // Serialized into the Vue component, so keep this function browser-only and self-contained.
59
- async function fetchWithTimeout (url, options = {}, timeoutMs = 12000) {
59
+ async function fetchWithTimeout (url, options = {}, timeoutMs = 12000, readResponse) {
60
60
  const controller = new AbortController()
61
61
  let timedOut = false
62
62
  const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
63
63
  try {
64
- return await fetch(url, { ...options, signal: controller.signal })
64
+ const response = await fetch(url, { ...options, signal: controller.signal })
65
+ return readResponse ? await readResponse(response) : response
65
66
  } catch (error) {
66
67
  if (timedOut) throw new Error('Request timed out; your binding draft is still available')
67
68
  throw error
@@ -0,0 +1,84 @@
1
+ // Self-contained: serialized as a Vue method, with factories supplied by the node.
2
+ function createPanelMeterClient ({ socket, panelId, room, ids, request, createReceiver,
3
+ createRenderer, apply, invalidate, onUnauthorized }) {
4
+ if (!socket) throw Error('Dashboard socket is unavailable for meter telemetry')
5
+ let auth = null, renderer = null, generation = 0, disposed = false
6
+ let retry = null, stale = null, connecting = false, delay = 500
7
+ const stopRendering = () => {
8
+ renderer?.close(); renderer = null
9
+ clearTimeout(stale); stale = null
10
+ invalidate()
11
+ }
12
+ const pause = () => {
13
+ generation++
14
+ connecting = false
15
+ clearTimeout(retry); retry = null
16
+ if (socket.connected) socket.emit('control-panel:meter-unsubscribe', panelId)
17
+ stopRendering()
18
+ }
19
+ const connect = async () => {
20
+ if (disposed || !auth || document.hidden || !socket.connected || connecting) return
21
+ clearTimeout(retry); retry = null
22
+ connecting = true
23
+ const expected = generation
24
+ try {
25
+ const { response, result } = await request(auth.endpoint + '/meter-ticket', {
26
+ method: 'POST', credentials: 'same-origin',
27
+ headers: { 'Content-Type': 'application/json', [auth.header]: auth.csrf },
28
+ body: JSON.stringify({ room, socketId: socket.id })
29
+ }, 5000, async response => ({ response, result: await response.json() }))
30
+ if (disposed || expected !== generation || document.hidden) return
31
+ if (response.status === 401) { pause(); auth = null; onUnauthorized(); return }
32
+ if (!response.ok) throw Error(result.error || 'Meter subscription failed')
33
+ delay = 500
34
+ socket.emit('control-panel:meter-subscribe', panelId, result.ticket)
35
+ } catch (_) {
36
+ if (!disposed && expected === generation && auth) {
37
+ retry = setTimeout(connect, delay)
38
+ delay = Math.min(delay * 2, 5000)
39
+ }
40
+ } finally { if (expected === generation) connecting = false }
41
+ }
42
+ const ready = layout => {
43
+ if (disposed || !auth || document.hidden || layout?.panelId !== panelId || layout.room !== room ||
44
+ !Array.isArray(layout.ids) || JSON.stringify(layout.ids) !== JSON.stringify(ids)) return
45
+ stopRendering()
46
+ try {
47
+ renderer = createRenderer({ receiver: createReceiver(layout),
48
+ requestFrame: callback => requestAnimationFrame(callback), cancelFrame: cancelAnimationFrame,
49
+ apply: frame => { apply(frame, ids) } })
50
+ } catch (_) { stopRendering() }
51
+ }
52
+ const frame = message => {
53
+ if (message?.panelId !== panelId || !auth || document.hidden || !renderer?.receive(message)) return
54
+ clearTimeout(stale)
55
+ stale = setTimeout(invalidate, 1500)
56
+ }
57
+ const ended = message => {
58
+ if (message?.panelId !== panelId) return
59
+ stopRendering()
60
+ if (auth && !disposed && !retry) retry = setTimeout(connect, 500)
61
+ }
62
+ const disconnected = () => { generation++; connecting = false; stopRendering() }
63
+ const visibility = () => { if (document.hidden) pause(); else void connect() }
64
+ socket.on('control-panel:meter-ready', ready)
65
+ socket.on('control-panel:meter-frame', frame)
66
+ socket.on('control-panel:meter-ended', ended)
67
+ socket.on('connect', connect)
68
+ socket.on('disconnect', disconnected)
69
+ document.addEventListener('visibilitychange', visibility)
70
+ return {
71
+ setAuth (next) { pause(); auth = next; delay = 500; if (auth) void connect() },
72
+ close () {
73
+ disposed = true; pause(); auth = null
74
+ socket.off('control-panel:meter-ready', ready)
75
+ socket.off('control-panel:meter-frame', frame)
76
+ socket.off('control-panel:meter-ended', ended)
77
+ socket.off('connect', connect)
78
+ socket.off('disconnect', disconnected)
79
+ document.removeEventListener('visibilitychange', visibility)
80
+ }
81
+ }
82
+ }
83
+
84
+ module.exports = { createPanelMeterClient }