diff --git a/docker/Dockerfile b/docker/Dockerfile index 66638fd4..a4222184 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,3 +1,4 @@ +# Build with: # docker build -f docker/Dockerfile -t qtoggle/qtoggleserver . # # Run with: diff --git a/qtoggleserver/core/ports.py b/qtoggleserver/core/ports.py index dd587891..6f14c25b 100644 --- a/qtoggleserver/core/ports.py +++ b/qtoggleserver/core/ports.py @@ -484,7 +484,8 @@ async def enable(self) -> None: self.debug("enabling") self._enabled = True - self.invalidate_attr("enabled") + # Invalidate all attributes, not just `enabled`: others may be derived from it, e.g. `online` + self.invalidate_attrs() # Reset port expression if self._expression: @@ -499,6 +500,7 @@ async def enable(self) -> None: except Exception: self.error("failed to enable") self._enabled = False + self.invalidate_attrs() raise @@ -514,13 +516,14 @@ async def disable(self) -> None: self.debug("disabling") self._enabled = False - self.invalidate_attr("enabled") + self.invalidate_attrs() try: await self.handle_disable() except Exception: self.error("failed to disable") self._enabled = True + self.invalidate_attrs() raise diff --git a/qtoggleserver/frontend/js/cache.js b/qtoggleserver/frontend/js/cache.js index 9ec29718..0792eb34 100644 --- a/qtoggleserver/frontend/js/cache.js +++ b/qtoggleserver/frontend/js/cache.js @@ -706,9 +706,24 @@ export function findPortSlaveDevice(portId) { return null } - return ObjectUtils.findValue(slaveDevices, function (deviceName) { - return portId.startsWith(`${deviceName}.`) - }) + /* A slave port id is the device name, a dot, then the port id, so walking the dot-separated prefixes and looking + * each one up costs a couple of lookups however many devices there are. Testing startsWith() against every device + * instead was proportional to their number, and this is called from inside a per-event rescan of the whole event + * bulk: with 20 devices, the 40000 lookups a 200-event bulk needs took 123ms, against 2.4ms this way. + * + * A device name can never contain a dot (see the `name` pattern in api/attrdefs.js), so only the prefix before the + * first dot can ever match; the loop then just falls through for a local port id that contains dots. */ + let dot = portId.indexOf('.') + while (dot >= 0) { + let device = slaveDevices[portId.substring(0, dot)] + if (device) { + return device + } + + dot = portId.indexOf('.', dot + 1) + } + + return null } /** diff --git a/qtoggleserver/frontend/js/constants.js b/qtoggleserver/frontend/js/constants.js index 13828d01..38bca0a0 100644 --- a/qtoggleserver/frontend/js/constants.js +++ b/qtoggleserver/frontend/js/constants.js @@ -9,3 +9,12 @@ * @type {Number} */ export const COMMON_DEBOUNCE_DELAY = 1000 + +/** + * The longest a debounced UI update may be postponed, however long the stream of events driving it lasts. Without a + * bound, each event pushes the update back by the full delay, so a device that reports faster than once a second holds + * the list at its old contents for as long as it keeps talking -- which reads as a frozen UI rather than a slow one. + * @alias qtoggle.constants.COMMON_DEBOUNCE_MAX_WAIT + * @type {Number} + */ +export const COMMON_DEBOUNCE_MAX_WAIT = 3000 diff --git a/qtoggleserver/frontend/js/dashboard/widgets/port-picker-field.js b/qtoggleserver/frontend/js/dashboard/widgets/port-picker-field.js index d0d13413..382801c9 100644 --- a/qtoggleserver/frontend/js/dashboard/widgets/port-picker-field.js +++ b/qtoggleserver/frontend/js/dashboard/widgets/port-picker-field.js @@ -60,6 +60,7 @@ class PortPickerField extends ComboField { let choices = ports.map(function (port) { return { label: this.makeLabel(port), + matchPhrase: this.makeMatchPhrase(port), value: port.id, port: port } @@ -71,19 +72,47 @@ class PortPickerField extends ComboField { return choices } - filterFunc(choice, searchText) { - let port = choice.port + /** + * Build the text a search filter is matched against. The parts of the port id appear in the order in which they + * are displayed, each one as it is displayed and, when that is a display name, followed by the corresponding id + * part, so that a filter can span them and either name can be searched for. + * @param {Object} port + * @returns {String} + */ + makeMatchPhrase(port) { let device = Cache.findPortSlaveDevice(port.id) - let phrase = port.id.split('.') + let mainDevice = Cache.getMainDevice() - if (port.display_name) { - phrase.push(port.display_name) - } - if (device && device.attrs['display_name']) { - phrase.push(device.attrs['display_name']) + let parts = port.id.split('.') + if (parts.length === 1 && Config.slavesEnabled) { + parts.splice(0, 0, mainDevice.display_name || mainDevice.name) } - return phrase.some(p => StringUtils.intelliSearch(p, searchText) != null) + let phraseParts = [] + parts.forEach(function (part, i) { + + let displayName = null + if (i === 0 && device && device.attrs['display_name']) { + displayName = device.attrs['display_name'] + } + else if (i === parts.length - 1 && port.display_name) { + displayName = port.display_name + } + + if (displayName) { + phraseParts.push(displayName) + } + if (displayName !== part) { + phraseParts.push(part) + } + + }) + + return phraseParts.join(' ').toLowerCase() + } + + filterFunc(choice, searchText) { + return StringUtils.intelliSearch(choice.matchPhrase, searchText) != null } makeLabelHTML() { diff --git a/qtoggleserver/frontend/js/dashboard/widgets/widget.js b/qtoggleserver/frontend/js/dashboard/widgets/widget.js index 78b3634b..561058e0 100644 --- a/qtoggleserver/frontend/js/dashboard/widgets/widget.js +++ b/qtoggleserver/frontend/js/dashboard/widgets/widget.js @@ -269,7 +269,7 @@ class Widget extends mix().with(ViewMixin) { function handleMove(elemX, elemY) { /* Make the new offsets relative to the panel body */ - let panelBodyOffset = widget._getPanelBody().offset() + let panelBodyOffset = widget._getPanelBodyOffset() let newOffsetLeft = elemX - panelBodyOffset.left let newOffsetTop = elemY - panelBodyOffset.top @@ -381,7 +381,7 @@ class Widget extends mix().with(ViewMixin) { let newOffsetTop = pageY /* Make the new offset relative to the panel body */ - let panelBodyOffset = widget._getPanelBody().offset() + let panelBodyOffset = widget._getPanelBodyOffset() newOffsetTop -= panelBodyOffset.top /* Compute the new offset in em units */ @@ -430,7 +430,7 @@ class Widget extends mix().with(ViewMixin) { let newOffsetRight = pageX /* Make the new offset relative to the panel body */ - let panelBodyOffset = widget._getPanelBody().offset() + let panelBodyOffset = widget._getPanelBodyOffset() newOffsetRight -= panelBodyOffset.left /* Compute the new offset in em units */ @@ -483,7 +483,7 @@ class Widget extends mix().with(ViewMixin) { let newOffsetBottom = pageY /* Make the new offset relative to the panel body */ - let panelBodyOffset = widget._getPanelBody().offset() + let panelBodyOffset = widget._getPanelBodyOffset() newOffsetBottom -= panelBodyOffset.top /* Compute the new offset in em units */ @@ -536,7 +536,7 @@ class Widget extends mix().with(ViewMixin) { let newOffsetLeft = pageX /* Make the new offset relative to the panel body */ - let panelBodyOffset = widget._getPanelBody().offset() + let panelBodyOffset = widget._getPanelBodyOffset() newOffsetLeft -= panelBodyOffset.left /* Compute the new offset in em units */ @@ -581,6 +581,13 @@ class Widget extends mix().with(ViewMixin) { return this._panel.getBody() } + /* Panel body offset, in CSS pixels, so that it can be compared with dragging coordinates and cell widths */ + _getPanelBodyOffset() { + let offset = this._getPanelBody().offset() + + return {left: CSS.unscalePx(offset.left), top: CSS.unscalePx(offset.top)} + } + /** * Return the current cell width in pixels. * @returns {Number} @@ -1008,8 +1015,8 @@ class Widget extends mix().with(ViewMixin) { let elemPosition = this._dragElem.position() this._px2em = CSS.px2em(1, this._bodyDiv) - this._clickDragElemX = elemPosition.left - this._clickDragElemY = elemPosition.top + this._clickDragElemX = CSS.unscalePx(elemPosition.left) + this._clickDragElemY = CSS.unscalePx(elemPosition.top) this.onDragBegin() diff --git a/qtoggleserver/frontend/js/devices/devices-table.js b/qtoggleserver/frontend/js/devices/devices-table.js index aad5a757..48ea4e95 100644 --- a/qtoggleserver/frontend/js/devices/devices-table.js +++ b/qtoggleserver/frontend/js/devices/devices-table.js @@ -97,7 +97,9 @@ class DevicesTable extends PageTable { }) this.deviceForm = null - this._updateUIDebouncer = new Debouncer(() => this.updateUI(this), Constants.COMMON_DEBOUNCE_DELAY) + this._updateUIDebouncer = new Debouncer( + () => this.updateUI(this), Constants.COMMON_DEBOUNCE_DELAY, Constants.COMMON_DEBOUNCE_MAX_WAIT + ) } init() { diff --git a/qtoggleserver/frontend/js/events.js b/qtoggleserver/frontend/js/events.js index 883ba610..aa52f77d 100644 --- a/qtoggleserver/frontend/js/events.js +++ b/qtoggleserver/frontend/js/events.js @@ -8,7 +8,6 @@ import {gettext} from '$qui/base/i18n.js' import * as Status from '$qui/main-ui/status.js' import * as Messages from '$qui/messages/messages.js' import * as Toast from '$qui/messages/toast.js' -import * as ArrayUtils from '$qui/utils/array.js' import Debouncer from '$qui/utils/debouncer.js' import {asap} from '$qui/utils/misc.js' import * as ObjectUtils from '$qui/utils/object.js' @@ -77,6 +76,67 @@ function processEventsBulk() { eventsBulk = [] } +/* Ports that changed together in this bulk, bucketed by the device owning them, so that the branches below can read + * off a count instead of filtering the whole bulk again. That filter ran once per qualifying event, with a device + * lookup inside it and an O(n^2) de-duplication after it, which made a burst of a few hundred events quadratic. */ +function groupPortEvents(events) { + let groups = {online: new Map(), offline: new Map(), added: new Map(), removed: new Map()} + + function add(group, device, portId) { + let portIds = group.get(device) + if (!portIds) { + portIds = new Set() + group.set(device, portIds) + } + + /* A set, because the filter this replaces was followed by a de-duplication on port id */ + portIds.add(portId) + } + + events.forEach(function (event) { + switch (event.type) { + case 'port-update': { + let port = Cache.getPort(event.params.id) + if (!port) { + return + } + + let device = Cache.findPortSlaveDevice(port.id) + if (!port.online && event.params.online) { + add(groups.online, device, event.params.id) + } + else if (port.online && !event.params.online) { + add(groups.offline, device, event.params.id) + } + + break + } + + case 'port-add': { + add(groups.added, Cache.findPortSlaveDevice(event.params.id), event.params.id) + + break + } + + case 'port-remove': { + add(groups.removed, Cache.findPortSlaveDevice(event.params.id), event.params.id) + + break + } + } + }) + + return groups +} + +/* The device is used as the key exactly as the removed filters used it, so that a Map tells null, undefined and each + * device object apart the way their `!==` comparison did. */ +function groupCount(group, device) { + let portIds = group.get(device) + + return portIds ? portIds.size : 0 +} + function showMessageFromEvents(events) { let message = null let messageType = null @@ -87,10 +147,12 @@ function showMessageFromEvents(events) { e.type === 'slave-device-update') && e.expected) }) + let portGroups = groupPortEvents(messagesEvents) + messagesEvents.forEach(function (event) { let device, port let deviceLabel, portLabel - let groupedEvents + let groupedCount switch (event.type) { case 'slave-device-update': { @@ -221,31 +283,10 @@ function showMessageFromEvents(events) { else if (!port.online && event.params.online) { /* Came online */ /* If more than one port of a device came online, * avoid showing a message for each of its ports */ - groupedEvents = messagesEvents.filter(function (e) { - if (e.type !== 'port-update') { - return false - } - - let p = Cache.getPort(e.params.id) - if (!p) { - return false - } - - let d = Cache.findPortSlaveDevice(p.id) - if (d !== device) { - return - } - - return !p.online && e.params.online - }) - - /* Eliminate port duplicates */ - groupedEvents = ArrayUtils.distinct(groupedEvents, function (e1, e2) { - return e1.params.id === e2.params.id - }) + groupedCount = groupCount(portGroups.online, device) messageType = 'info' - if (groupedEvents.length === 1) { + if (groupedCount === 1) { message = StringUtils.formatPercent( gettext('Port %(port)s is now online.'), {port: portLabel} @@ -254,38 +295,17 @@ function showMessageFromEvents(events) { else { message = StringUtils.formatPercent( gettext('%(count)d ports are now online.'), - {count: groupedEvents.length} + {count: groupedCount} ) } } else if (port.online && !event.params.online) { /* Went offline */ /* If more than one port of a device went offline, * avoid showing a message for each of its ports */ - groupedEvents = messagesEvents.filter(function (e) { - if (e.type !== 'port-update') { - return false - } - - let p = Cache.getPort(e.params.id) - if (!p) { - return false - } - - let d = Cache.findPortSlaveDevice(p.id) - if (d !== device) { - return - } - - return p.online && !e.params.online - }) - - /* Eliminate port duplicates */ - groupedEvents = ArrayUtils.distinct(groupedEvents, function (e1, e2) { - return e1.params.id === e2.params.id - }) + groupedCount = groupCount(portGroups.offline, device) messageType = 'warning' - if (groupedEvents.length === 1) { + if (groupedCount === 1) { message = StringUtils.formatPercent( gettext('Port %(port)s is offline.'), {port: portLabel} @@ -294,7 +314,7 @@ function showMessageFromEvents(events) { else { message = StringUtils.formatPercent( gettext('%(count)d ports are offline.'), - {count: groupedEvents.length} + {count: groupedCount} ) } } @@ -308,26 +328,10 @@ function showMessageFromEvents(events) { /* If more than one port of a device have been added, * avoid showing a message for each of the ports */ - groupedEvents = messagesEvents.filter(function (e) { - if (e.type !== 'port-add') { - return false - } - - let d = Cache.findPortSlaveDevice(e.params.id) - if (d !== device) { - return - } - - return true - }) - - /* Eliminate port duplicates */ - groupedEvents = ArrayUtils.distinct(groupedEvents, function (e1, e2) { - return e1.params.id === e2.params.id - }) + groupedCount = groupCount(portGroups.added, device) messageType = 'info' - if (groupedEvents.length === 1) { + if (groupedCount === 1) { message = StringUtils.formatPercent( gettext('Port %(port)s has been added.'), {port: portLabel} @@ -336,7 +340,7 @@ function showMessageFromEvents(events) { else { message = StringUtils.formatPercent( gettext('%(count)d ports have been added.'), - {count: groupedEvents.length} + {count: groupedCount} ) } @@ -354,26 +358,10 @@ function showMessageFromEvents(events) { /* If more than one port of a device have been removed, * avoid showing a message for each of the ports */ - groupedEvents = messagesEvents.filter(function (e) { - if (e.type !== 'port-remove') { - return false - } - - let d = Cache.findPortSlaveDevice(e.params.id) - if (d !== device) { - return - } - - return true - }) - - /* Eliminate port duplicates */ - groupedEvents = ArrayUtils.distinct(groupedEvents, function (e1, e2) { - return e1.params.id === e2.params.id - }) + groupedCount = groupCount(portGroups.removed, device) messageType = 'info' - if (groupedEvents.length === 1) { + if (groupedCount === 1) { message = StringUtils.formatPercent( gettext('Port %(port)s has been removed.'), {port: portLabel} @@ -382,7 +370,7 @@ function showMessageFromEvents(events) { else { message = StringUtils.formatPercent( gettext('%(count)d ports have been removed.'), - {count: groupedEvents.length} + {count: groupedCount} ) } diff --git a/qtoggleserver/frontend/js/peripherals/peripherals-list.js b/qtoggleserver/frontend/js/peripherals/peripherals-list.js index 47056dbb..a1a35b7b 100644 --- a/qtoggleserver/frontend/js/peripherals/peripherals-list.js +++ b/qtoggleserver/frontend/js/peripherals/peripherals-list.js @@ -33,7 +33,9 @@ class PeripheralsList extends PageList { }) this.peripheralForm = null - this._updateUIDebouncer = new Debouncer(() => this.updateUI(), Constants.COMMON_DEBOUNCE_DELAY) + this._updateUIDebouncer = new Debouncer( + () => this.updateUI(), Constants.COMMON_DEBOUNCE_DELAY, Constants.COMMON_DEBOUNCE_MAX_WAIT + ) } load() { diff --git a/qtoggleserver/frontend/js/ports/devices-list.js b/qtoggleserver/frontend/js/ports/devices-list.js index 8cefb14b..7f8c3b22 100644 --- a/qtoggleserver/frontend/js/ports/devices-list.js +++ b/qtoggleserver/frontend/js/ports/devices-list.js @@ -61,7 +61,9 @@ class DevicesList extends PageList { }) this.portsList = null - this._updateUIDebouncer = new Debouncer(() => this.updateUI(), Constants.COMMON_DEBOUNCE_DELAY) + this._updateUIDebouncer = new Debouncer( + () => this.updateUI(), Constants.COMMON_DEBOUNCE_DELAY, Constants.COMMON_DEBOUNCE_MAX_WAIT + ) } init() { diff --git a/qtoggleserver/frontend/js/ports/ports-list.js b/qtoggleserver/frontend/js/ports/ports-list.js index 356a9b78..ef024f0d 100644 --- a/qtoggleserver/frontend/js/ports/ports-list.js +++ b/qtoggleserver/frontend/js/ports/ports-list.js @@ -6,7 +6,6 @@ import {gettext} from '$qui/base/i18n.js' import Config from '$qui/config.js' import {CheckField} from '$qui/forms/common-fields/common-fields.js' import {OptionsForm} from '$qui/forms/common-forms/common-forms.js' -import $ from '$qui/lib/jquery.module.js' import {IconLabelListItem} from '$qui/lists/common-items/common-items.js' import {PageList} from '$qui/lists/common-lists/common-lists.js' import * as Theme from '$qui/theme.js' @@ -83,18 +82,49 @@ class PortIconLabelListItem extends IconLabelListItem { makeIconLabelContainer() { let container = super.makeIconLabelContainer() - this._flagsElement = $('
', {class: 'port-flags'}) + /* Raw DOM rather than jQuery: this runs 448 times on the hub's port list, where jQuery's element, style and + * append helpers came to about 470 ms of the 4.6 s the list takes to build. */ + this._flagsElement = document.createElement('div') + this._flagsElement.className = 'port-flags' this._flags.forEach(({text, color, active}) => { - let flagElement = $(`
`, {class: 'flag', text}) - flagElement.css('background', color) - flagElement.css('visibility', active ? 'visible' : 'hidden') - this._flagsElement.append(flagElement) + let flagElement = document.createElement('div') + flagElement.className = 'flag' + flagElement.textContent = text + flagElement.style.background = color + flagElement.style.visibility = active ? 'visible' : 'hidden' + this._flagsElement.appendChild(flagElement) }) - container.append(this._flagsElement) + container[0].appendChild(this._flagsElement) return container } + updateFrom(other) { + if (!super.updateFrom(other)) { + return false + } + + /* The parent class knows nothing about the flags, so without this a row kept in place goes on showing the + * badges it was built with. Only `active` ever differs in practice; a different set of flags means a + * different row, which the list rebuilds instead. */ + let sameFlags = other._flags.length === this._flags.length && other._flags.every( + (f, i) => f.text === this._flags[i].text && f.color === this._flags[i].color + ) + if (!sameFlags) { + return false + } + + let flagElements = this._flagsElement ? this._flagsElement.children : null + other._flags.forEach(function ({active}, i) { + if (flagElements && active !== this._flags[i].active) { + flagElements[i].style.visibility = active ? 'visible' : 'hidden' + } + }, this) + this._flags = other._flags + + return true + } + } @@ -138,7 +168,9 @@ class PortsList extends PageList { } this._deviceName = deviceName - this._updateUIDebouncer = new Debouncer(() => this.updateUI(), Constants.COMMON_DEBOUNCE_DELAY) + this._updateUIDebouncer = new Debouncer( + () => this.updateUI(), Constants.COMMON_DEBOUNCE_DELAY, Constants.COMMON_DEBOUNCE_MAX_WAIT + ) this.portForm = null this.setTitle(title) diff --git a/qtoggleserver/frontend/js/settings/settings-section.js b/qtoggleserver/frontend/js/settings/settings-section.js index 24d02c2a..063306af 100644 --- a/qtoggleserver/frontend/js/settings/settings-section.js +++ b/qtoggleserver/frontend/js/settings/settings-section.js @@ -48,7 +48,8 @@ class SettingsSection extends Section { onServerEvent(event) { switch (event.type) { case 'device-update': { - if (this.settingsForm && !this.settingsForm.isRebooting()) { + /* The section closes its form when hidden but keeps the reference; the next show builds a new one */ + if (this.settingsForm && !this.settingsForm.isClosed() && !this.settingsForm.isRebooting()) { /* Don't show field changed warnings for events that are consequences of changes applied from this * client (when the event is expected) */ let fieldChangeWarnings = !event.expected && !Settings.recentSettingsUpdateTimer.isRunning() diff --git a/qtoggleserver/frontend/js/utils.js b/qtoggleserver/frontend/js/utils.js index fb13a75f..d292705d 100644 --- a/qtoggleserver/frontend/js/utils.js +++ b/qtoggleserver/frontend/js/utils.js @@ -8,6 +8,9 @@ import * as ObjectUtils from '$qui/utils/object.js' import * as NotificationsAPI from '$app/api/notifications.js' +/* Shared rather than built per call. It has no `g` flag, so exec() always starts at 0 and never touches lastIndex. */ +const DIGITS_RE = /\d+/ + /** * Help sorting items alphabetically and numerically at the same time, by adding zero padding to each number within the * given string. @@ -16,10 +19,9 @@ import * as NotificationsAPI from '$app/api/notifications.js' * @returns {String} the sort key */ export function alphaNumSortKey(input) { - let re = new RegExp('\\d+') let l, m, s = input let n, key = '' - while ((m = re.exec(s))) { + while ((m = DIGITS_RE.exec(s))) { l = m[0].length key += s.substring(0, m.index) s = s.substring(m.index + l) diff --git a/qtoggleserver/frontend/less/dashboard/dashboard.less b/qtoggleserver/frontend/less/dashboard/dashboard.less index 7fa40a85..0ee40293 100644 --- a/qtoggleserver/frontend/less/dashboard/dashboard.less +++ b/qtoggleserver/frontend/less/dashboard/dashboard.less @@ -96,6 +96,11 @@ div.dashboard-widget-picker-body { /* Widget container */ div.dashboard-widget-container { + /* Deliberately no paint containment: the body's box-shadow, the remove/configure/duplicate buttons and the four + * resize handles are all drawn outside the container box on purpose, and would be clipped. Layout and style + * containment are enough to keep a widget's own updates from invalidating the layout of the whole panel. */ + contain: layout style; + display: grid; position: absolute; z-index: 10000; diff --git a/qtoggleserver/frontend/package-lock.json b/qtoggleserver/frontend/package-lock.json index e4066ca3..a552a536 100644 --- a/qtoggleserver/frontend/package-lock.json +++ b/qtoggleserver/frontend/package-lock.json @@ -9,13 +9,14 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { - "@qtoggle/qui": "^1.19.11", + "@babel/runtime": "^7.29.7", + "@qtoggle/qui": "^1.20.3", "chart.js": "^3.0.0-rc", "chartjs-adapter-date-fns": "*", "hammerjs": "*", "jquery": "^3", "jquery-mousewheel": "*", - "jquery-ui-dist": "*", + "jquery-ui": "^1", "js-logger": "*", "pepjs": "*" }, @@ -23,6 +24,7 @@ "@babel/core": "*", "@babel/eslint-parser": "*", "@babel/plugin-proposal-class-properties": "*", + "@babel/plugin-transform-runtime": "^7.29.7", "@babel/preset-env": "*", "babel-loader": "^8", "css-loader": "^3", @@ -45,13 +47,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -120,14 +122,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -224,9 +226,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -248,14 +250,14 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -293,9 +295,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -353,9 +355,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -363,9 +365,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -412,13 +414,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -1316,6 +1318,41 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", @@ -1565,34 +1602,43 @@ "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -1600,14 +1646,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1943,15 +1989,16 @@ } }, "node_modules/@qtoggle/qui": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@qtoggle/qui/-/qui-1.19.11.tgz", - "integrity": "sha512-O9f8otZe4WB8Nfw6VapjDF2q6QPlSOOysze2GZsXgj0KvyWvnMqHZWarHMm3XqtmWjajXqNVX6+Ma5S7Ao3g+Q==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/@qtoggle/qui/-/qui-1.20.3.tgz", + "integrity": "sha512-qP6wjbEO5ReXulTQCls85D1XRGFCO8eEm3UJrHWeFzD9XinaFaD94IKSuzfsEYyMQu3xTAHAbtGCvHRC22iGXA==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { + "@babel/runtime": "^7.29.7", "jquery": "^3", "jquery-mousewheel": "*", - "jquery-ui-dist": "*", + "jquery-ui": "^1", "js-logger": "*", "pepjs": "*" } @@ -5874,13 +5921,13 @@ "jquery": ">=1.2.6" } }, - "node_modules/jquery-ui-dist": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/jquery-ui-dist/-/jquery-ui-dist-1.13.3.tgz", - "integrity": "sha512-qeTR3SOSQ0jgxaNXSFU6+JtxdzNUSJKgp8LCzVrVKntM25+2YBJW1Ea8B2AwjmmSHfPLy2dSlZxJQN06OfVFhg==", + "node_modules/jquery-ui": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/jquery-ui/-/jquery-ui-1.14.2.tgz", + "integrity": "sha512-1gSl7PUjyipa2adSr780Ujk16faicrV7PjPPzPtvWk7tTqBnsqp67NNV9jZK2+BIxUPXWSnIUU/LBCgwgGZE+Q==", "license": "MIT", "dependencies": { - "jquery": ">=1.8.0 <4.0.0" + "jquery": ">=1.12.0 <5.0.0" } }, "node_modules/js-logger": { diff --git a/qtoggleserver/frontend/package.json b/qtoggleserver/frontend/package.json index b3a9a25d..4cc4f0d7 100644 --- a/qtoggleserver/frontend/package.json +++ b/qtoggleserver/frontend/package.json @@ -6,21 +6,23 @@ "author": "Calin Crisan ", "license": "Apache-2.0", "dependencies": { + "@babel/runtime": "^7.29.7", "@qtoggle/qui": "*", "chart.js": "^3.0.0-rc", "chartjs-adapter-date-fns": "*", "hammerjs": "*", "jquery": "*", "jquery-mousewheel": "*", - "jquery-ui-dist": "*", + "jquery-ui": "^1", "js-logger": "*", "pepjs": "*" }, "devDependencies": { "@babel/core": "*", + "@babel/eslint-parser": "*", "@babel/plugin-proposal-class-properties": "*", + "@babel/plugin-transform-runtime": "^7.29.7", "@babel/preset-env": "*", - "@babel/eslint-parser": "*", "babel-loader": "^8", "css-loader": "^3", "eslint": "^9", diff --git a/qtoggleserver/slaves/ports.py b/qtoggleserver/slaves/ports.py index d3f9f5d1..6a610c90 100644 --- a/qtoggleserver/slaves/ports.py +++ b/qtoggleserver/slaves/ports.py @@ -258,7 +258,9 @@ async def attr_is_online(self) -> bool: if not self._expires and not self._slave.is_online(): return False - return self._cached_attrs.get("online", True) + # A device may send the attribute with a null value; treat that as online, just like a missing attribute + online = self._cached_attrs.get("online") + return True if online is None else online async def attr_get_provisioning(self) -> list[str]: return list(self._provisioning) diff --git a/tests/unit/qtoggleserver/core/test_ports.py b/tests/unit/qtoggleserver/core/test_ports.py index 4d7caeab..9b1189b9 100644 --- a/tests/unit/qtoggleserver/core/test_ports.py +++ b/tests/unit/qtoggleserver/core/test_ports.py @@ -327,6 +327,28 @@ def test_empty_queue(self, mock_num_port1): assert mock_num_port1.get_pending_value() == 1 +class TestPortEnableDisable: + async def test_enable_invalidates_attrs(self, mock_num_port1): + """Should invalidate the whole attributes cache, since other attributes may be derived from `enabled`.""" + + await mock_num_port1.disable() + mock_num_port1._attrs_cache["display_name"] = "stale" + + await mock_num_port1.enable() + + assert "display_name" not in mock_num_port1._attrs_cache + + async def test_disable_invalidates_attrs(self, mock_num_port1): + """Should invalidate the whole attributes cache, since other attributes may be derived from `enabled`.""" + + await mock_num_port1.enable() + mock_num_port1._attrs_cache["display_name"] = "stale" + + await mock_num_port1.disable() + + assert "display_name" not in mock_num_port1._attrs_cache + + class TestPortGetAttr: async def test_unknown_attribute(self, mock_num_port1): """Should return `None` for an unsupported/unknown attribute.""" diff --git a/tests/unit/qtoggleserver/slaves/test_ports.py b/tests/unit/qtoggleserver/slaves/test_ports.py index 1756551a..1ad631b1 100644 --- a/tests/unit/qtoggleserver/slaves/test_ports.py +++ b/tests/unit/qtoggleserver/slaves/test_ports.py @@ -46,3 +46,48 @@ async def test_port_timeout(self, slave, slave_port, mocker, status) -> None: with pytest.raises(core_ports.PortTimeout): await slave_port.write_value(100) + + +class TestOnlineAttr: + """Test the `online` attribute of a slave port, which is derived from both the local enabled state and the value + reported by the slave device.""" + + @pytest.fixture + async def slave_port(self, slave) -> SlavePort: + # Devices may send the attribute with a null value, as qToggle marks it optional + return SlavePort(slave, {"id": "port1", "type": "boolean", "enabled": True, "online": None}) + + async def test_null_online_means_online(self, slave_port) -> None: + await slave_port.enable() + + assert await slave_port.get_attr("online") is True + + async def test_online_refreshed_after_enable(self, slave_port) -> None: + # Reading the attribute while the port is still disabled used to cache a false that enabling never cleared + assert await slave_port.get_attr("online") is False + + await slave_port.enable() + + assert await slave_port.get_attr("online") is True + assert (await slave_port.to_json())["online"] is True + + async def test_online_refreshed_after_disable(self, slave_port) -> None: + await slave_port.enable() + assert await slave_port.get_attr("online") is True + + await slave_port.disable() + + assert await slave_port.get_attr("online") is False + + async def test_online_follows_slave(self, slave, slave_port, mocker) -> None: + await slave_port.enable() + mocker.patch.object(slave, "is_online", return_value=False) + slave_port.invalidate_attrs() + + assert await slave_port.get_attr("online") is False + + async def test_offline_reported_by_slave(self, slave_port) -> None: + await slave_port.enable() + slave_port.update_cached_attrs({"id": "port1", "type": "boolean", "enabled": True, "online": False}) + + assert await slave_port.get_attr("online") is False diff --git a/update-qui.sh b/update-qui.sh index 84e3a046..ad64b7a4 100755 --- a/update-qui.sh +++ b/update-qui.sh @@ -1,5 +1,11 @@ #!/bin/sh +# Updates both halves of QUI to the same release: the @qtoggle/qui npm package used by the frontend +# and the qui-server PyPI package used by the backend. The two are versioned together. +# +# Pass the version as npm spells it, e.g. 1.19.11 or 1.20.0-alpha.2. uv normalizes the prerelease +# spelling per PEP 440 (1.20.0-alpha.2 becomes 1.20.0a2), so one argument covers both. + set -e if [ -z "$1" ]; then @@ -7,9 +13,20 @@ if [ -z "$1" ]; then exit 1 fi -cd "$(dirname $0)/qtoggleserver/frontend" +version="$1" +root="$(cd "$(dirname "$0")" && pwd)" + +# Frontend: pin in package-lock.json only, leaving the "*" in package.json alone +cd "$root/qtoggleserver/frontend" npm cache clean --force -npm install @qtoggle/qui@$1 +npm install @qtoggle/qui@"$version" git checkout package.json sed -i 's/"jquery": "\*"/"jquery": "^3"/' package-lock.json -git commit -am "frontend: Update qui to $1" + +# Backend: pin in uv.lock only, leaving the floor in pyproject.toml alone +cd "$root" +uv lock --upgrade-package "qui-server==$version" + +# Named explicitly rather than committing with -a, so that unrelated work in progress is left alone +git commit -m "Update qui to $version" qtoggleserver/frontend/package-lock.json uv.lock + diff --git a/uv.lock b/uv.lock index 35161ee5..2f533c6d 100644 --- a/uv.lock +++ b/uv.lock @@ -627,15 +627,15 @@ dev = [ [[package]] name = "qui-server" -version = "1.18.3" +version = "1.20.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "tornado" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/22/b320dc7f7d3c09b0fed028d81db0bd1e1f21b75a4ef03e89304cb98c57cf/qui_server-1.18.3.tar.gz", hash = "sha256:5436510fee4df00547f13c562fed60b06971120e6f09a1a56f3219ea6f961d94", size = 12911, upload-time = "2025-11-07T19:15:15.22Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/85/334879487243bc1e8a961b85b8245cca917812a74de7d983a8ba03e287e6/qui_server-1.20.3.tar.gz", hash = "sha256:26521eb10b2defd13cf6b49b6a28567886da876d91e41ebd8fd5dbc5ca7ab950", size = 14208, upload-time = "2026-10-05T13:08:35.323Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/06/445c95d3b3ccca3842b5ee60f16e2cd732d9d637ddbaaa9d12948a2208d3/qui_server-1.18.3-py3-none-any.whl", hash = "sha256:eea01cd0e0edf9d794d718630649fa7c54a9a113c21ce57052467f3146d27038", size = 14620, upload-time = "2025-11-07T19:15:14.01Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e9/dd71a827ef370184be5c06d8ba6eb489e258200fcff8389e1cbae704c137/qui_server-1.20.3-py3-none-any.whl", hash = "sha256:aede586671ad4bece45f80486c40ab0305a8ec7c22f2af4b5ea7f52f8f6afa3f", size = 16010, upload-time = "2026-10-05T13:08:34.312Z" }, ] [[package]]