diff --git a/src/modules/node_info/files/PveMod_pvemanagerlib.js b/src/modules/node_info/files/PveMod_pvemanagerlib.js index 0d4f013..e170d2e 100644 --- a/src/modules/node_info/files/PveMod_pvemanagerlib.js +++ b/src/modules/node_info/files/PveMod_pvemanagerlib.js @@ -906,187 +906,297 @@ Ext.define('PVE.node.StatusView', { }, }, { - itemId: 'upsc', - colspan: 2, - printBar: false, - title: gettext('UPS Status'), - iconCls: 'fa fa-fw fa-battery-three-quarters', - textField: 'PveMod_upsInfo', - renderer: function(value) { + itemId: 'upsc', + colspan: 2, + title: gettext('UPS Status'), + iconCls: 'fa fa-fw fa-battery-three-quarters', + valueField: 'PveMod_upsInfo', + printBar: true, + warningThreshold: 1.1, + criticalThreshold: 1.2, + layout: { + type: 'vbox', + align: 'stretch', + }, + items: [ + { + xtype: 'component', + itemId: 'label', + data: { + title: '', + usage: '', + iconCls: undefined, + }, + tpl: [ + '
', + '', + ' ', + '', + '{title}
', + ], + }, + { + xtype: 'container', + layout: { + type: 'hbox', + align: 'middle', + }, + items: [ + { + xtype: 'component', + itemId: 'usageText', + flex: 1, + minWidth: 0, + margin: '0 16 0 0', + }, + { + xtype: 'container', + flex: 1, + minWidth: 0, + layout: { + type: 'vbox', + align: 'stretch', + }, + items: [ + { + xtype: 'component', + itemId: 'loadText', + margin: '0 0 2 0', + }, + { + xtype: 'progressbar', + itemId: 'progress', + height: 5, + value: 0, + animate: true, + }, + ], + }, + ], + }, + ], + initComponent: function() { + var me = this; + if (!me.title) { + throw 'no title defined'; + } + Ext.container.Container.prototype.initComponent.call(me); + var progress = me.down('#progress'); + if (progress) { + progress.setVisible(!!me.printBar); + } + me.updateValue(me.text, me.value); + me.setIconCls(me.iconCls); + }, + setPrintBar: function(enable) { + var me = this; + me.printBar = enable; + var progress = me.down('#progress'); + if (progress) { + progress.setVisible(enable); + } + }, + // Single source of truth for all DOM writes — called once per refresh + // cycle, right after renderer() returns. renderer() only computes data; + // it never touches the DOM, so there's no race/flash between the two. + updateValue: function(text, usage) { + var me = this; + var loadText = me._pendingLoadText || ''; + + if (me.lastText === text && me.lastUsage === usage && me.lastLoadText === loadText) { + return; + } + me.lastText = text; + me.lastUsage = usage; + me.lastLoadText = loadText; + + var label = me.getComponent('label'); + if (label) { + label.update(Ext.apply(label.data, { title: me.title, usage: '' })); + } + + var usageText = me.down('#usageText'); + if (usageText) { + if (usageText.setHtml) { + usageText.setHtml(text || ''); + } else { + usageText.update(text || ''); + } + } + + var loadTextCmp = me.down('#loadText'); + if (loadTextCmp) { + if (loadTextCmp.setHtml) { + loadTextCmp.setHtml(loadText); + } else { + loadTextCmp.update(loadText); + } + } + + var progressBar = me.down('#progress'); + if (usage !== undefined && me.printBar && Ext.isNumeric(usage) && usage >= 0 && progressBar) { + progressBar.updateProgress(usage, ''); + if (usage > me.criticalThreshold) { + progressBar.removeCls('warning'); + progressBar.addCls('critical'); + } else if (usage > me.warningThreshold) { + progressBar.removeCls('critical'); + progressBar.addCls('warning'); + } else { + progressBar.removeCls('warning'); + progressBar.removeCls('critical'); + } + } + }, + // Drives the progress bar's fill — based on battery.charge. + calculate: function(used) { + if (!used || used.disabled === true || typeof used !== 'object') { + return 0; + } + let charge = NaN; + Object.keys(used).forEach(function(k) { + const row = used[k]; + if (row && typeof row === 'object' && row['battery.charge'] != null) { + charge = parseFloat(row['battery.charge']); + } + }); + if (isNaN(charge)) { + return 0; + } + return Math.max(0, Math.min(1, charge / 100)); + }, + // Pure computation — no DOM writes. Stashes the "Battery capacity ... + // X% (Runtime: ...)" line for updateValue to place above the bar, and + // returns a 30/70 table (model | other info incl. Load) wrapped in the + // standard indent div, matching the other widgets in this panel. + renderer: function(value) { let objValue; try { objValue = value || {}; - } catch(e) { + } catch (e) { objValue = {}; } if (objValue.disabled === true) { this.hide(); + this.setPrintBar(false); return ''; - } else if (!objValue || Object.keys(objValue).length === 0) { - return 'N/A'; } - // Helper function to get status color - function getStatusColor(status) { - if (!status) return '#999'; - const statusUpper = status.toUpperCase(); - if (statusUpper.includes('OL')) return null; - if (statusUpper.includes('OB')) return '#d9534f'; - if (statusUpper.includes('LB')) return '#d9534f'; - return '#f0ad4e'; + const upsKeys = Object.keys(objValue).filter(function(k) { + return objValue[k] && typeof objValue[k] === 'object' && !Array.isArray(objValue[k]); + }); + if (!upsKeys.length) { + this.hide(); + this.setPrintBar(false); + return ''; } + this.show(); + this.setPrintBar(true); - // Helper function to get load/charge color - function getPercentageColor(value, isLoad = false) { - if (!value || isNaN(value)) return '#999'; - const num = parseFloat(value); - if (isLoad) { - if (num >= 80) return '#d9534f'; - if (num >= 60) return '#f0ad4e'; - return null; - } else { - if (num <= 20) return '#d9534f'; - if (num <= 50) return '#f0ad4e'; - return null; - } - } - - // Helper function to format runtime function formatRuntime(seconds) { - if (!seconds || isNaN(seconds)) return 'N/A'; - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}m ${secs}s`; + const s = parseInt(seconds, 10); + if (!s || isNaN(s)) { + return null; + } + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + if (h > 0) { + return h + 'h ' + m + 'm'; + } + return m + 'm'; } - // Process each UPS in the data - let allDisplayItems = []; - - Object.keys(objValue).forEach(upsKey => { - const upsData = objValue[upsKey]; - - // Extract key UPS information - const batteryCharge = upsData['battery.charge']; - const batteryRuntime = upsData['battery.runtime']; - const inputVoltage = upsData['input.voltage']; - const upsLoad = upsData['ups.load']; - const upsStatus = upsData['ups.status']; - const upsModel = upsData['ups.model'] || upsData['device.model']; + function statusText(upsStatus) { + const u = String(upsStatus || '').toUpperCase(); + if (u.indexOf('LB') >= 0) { + return { text: 'Low Battery', color: '#d9534f' }; + } + if (u.indexOf('OB') >= 0) { + return { text: 'On Battery', color: '#d9534f' }; + } + if (u.indexOf('FSD') >= 0) { + return { text: 'Shutdown', color: '#d9534f' }; + } + if (u.indexOf('OL') >= 0) { + return { + text: u.indexOf('CHRG') >= 0 ? 'Online, charging' : 'Online', + color: null, + }; + } + return { text: upsStatus || 'Unknown', color: '#f0ad4e' }; + } + + function colorize(label, color) { + if (!color) { + return label; + } + return '' + label + ''; + } + + let aboveBarText = ''; + const rows = []; + + upsKeys.forEach(function(upsKey) { + const upsData = objValue[upsKey] || {}; + const charge = parseFloat(upsData['battery.charge']); + const runtime = formatRuntime(upsData['battery.runtime']); + const inputVoltage = parseFloat(upsData['input.voltage']); + const load = parseFloat(upsData['ups.load']); + const watts = parseFloat(upsData['ups.realpower']); + const model = upsData['ups.model'] || upsData['device.model'] || upsKey; + const st = statusText(upsData['ups.status']); const testResult = upsData['ups.test.result']; - const batteryChargeLow = upsData['battery.charge.low']; - const batteryRuntimeLow = upsData['battery.runtime.low']; - const upsRealPower = upsData['ups.realpower']; - const batteryMfrDate = upsData['battery.mfr.date']; + const manufacturingDate = upsData['battery.mfr.date']; - // Main status line with all metrics - let statusLine = ''; + // Above the bar: "Battery capacity" on the left, charge% (Runtime: ...) on the right. + let rightSide = !isNaN(charge) ? (Math.round(charge) + '%') : ''; + if (runtime) { + rightSide += (rightSide ? ' ' : '') + '(Runtime: ' + runtime + ' left)'; + } + aboveBarText = + '
' + + 'Battery capacity' + + '' + rightSide + '' + + '
'; - // Status - if (upsStatus) { - const statusUpper = upsStatus.toUpperCase(); - let statusText = 'Unknown'; - let statusColor = '#f0ad4e'; - - if (statusUpper.includes('OL')) { - statusText = 'Online'; - statusColor = null; - } else if (statusUpper.includes('OB')) { - statusText = 'On Battery'; - statusColor = '#d9534f'; - } else if (statusUpper.includes('LB')) { - statusText = 'Low Battery'; - statusColor = '#d9534f'; - } else { - statusText = upsStatus; - statusColor = '#f0ad4e'; - } - - let statusStyle = statusColor ? ('color: ' + statusColor + ';') : ''; - statusLine += 'Status: ' + statusText + ''; - } else { - statusLine += 'Status: N/A'; + // General information table: Status, Output, Input, Load, Test. + const infoBits = []; + if (st.text) { + infoBits.push('Status: ' + colorize(st.text, st.color)); + } + if (!isNaN(watts)) { + infoBits.push('Output: ' + Math.round(watts) + 'W'); + } + if (!isNaN(inputVoltage)) { + const places = inputVoltage >= 50 ? 0 : 1; + infoBits.push('Input: ' + inputVoltage.toFixed(places) + ' V'); + } + if (!isNaN(load)) { + infoBits.push('Load: ' + Math.round(load) + '%'); + } + if (manufacturingDate) { + infoBits.push('Manufacturing Date: ' + manufacturingDate); + } + if (testResult) { + infoBits.push('Test: ' + testResult); } - // Battery charge - if (statusLine) statusLine += ' | '; - if (batteryCharge) { - const chargeColor = getPercentageColor(batteryCharge, false); - let chargeStyle = chargeColor ? ('color: ' + chargeColor + ';') : ''; - statusLine += 'Battery: ' + batteryCharge + '%'; - } else { - statusLine += 'Battery: N/A'; - } - - // Load percentage - if (statusLine) statusLine += ' | '; - if (upsLoad) { - const loadColor = getPercentageColor(upsLoad, true); - let loadStyle = loadColor ? ('color: ' + loadColor + ';') : ''; - statusLine += 'Load: ' + upsLoad + '%'; - } else { - statusLine += 'Load: N/A'; - } - - // Runtime - if (statusLine) statusLine += ' | '; - if (batteryRuntime) { - const runtime = parseInt(batteryRuntime); - const runtimeLowThreshold = batteryRuntimeLow ? parseInt(batteryRuntimeLow) : 600; - let runtimeColor = null; - if (runtime <= runtimeLowThreshold / 2) runtimeColor = '#d9534f'; - else if (runtime <= runtimeLowThreshold) runtimeColor = '#f0ad4e'; - let runtimeStyle = runtimeColor ? ('color: ' + runtimeColor + ';') : ''; - statusLine += 'Runtime: ' + formatRuntime(runtime) + ''; - } else { - statusLine += 'Runtime: N/A'; - } - - // Input voltage - if (statusLine) statusLine += ' | '; - if (inputVoltage) { - statusLine += 'Input: ' + parseFloat(inputVoltage).toFixed(0) + 'V'; - } else { - statusLine += 'Input: N/A'; - } - - // Calculate actual watt usage - if (statusLine) statusLine += ' | '; - let actualWattage = null; - if (upsRealPower && !isNaN(parseFloat(upsRealPower))) { - actualWattage = Math.round(parseFloat(upsRealPower)); - } - - // Real power (calculated watt usage) - if (actualWattage !== null) { - statusLine += 'Output: ' + actualWattage + 'W'; - } else { - statusLine += 'Output: N/A'; - } - - // Append battery MFD + last test to the same line (single-line UPS summary) - statusLine += ' | Battery MFD: ' + (batteryMfrDate || 'N/A'); - if (testResult && !testResult.toLowerCase().includes('no test')) { - const testColor = testResult.toLowerCase().includes('passed') ? null : '#d9534f'; - let testStyle = testColor ? ('color: ' + testColor + ';') : ''; - statusLine += ' | Test: ' + testResult + ''; - } else { - statusLine += ' | Test: N/A'; - } - - // Build UPS display with model on left, details on right - let upsHtml = ''; - upsHtml += '' + (upsModel || upsKey) + ''; - upsHtml += '' + statusLine + ''; - upsHtml += ''; - - allDisplayItems.push(upsHtml); + rows.push( + '' + + '' + model + '' + + '' + infoBits.join(' | ') + '' + + '' + ); }); - // Format the final output for all UPS devices - return '
' + allDisplayItems.join('') + '
'; + // Stash for updateValue to consume — no DOM writes here. + this._pendingLoadText = aboveBarText; + + // Becomes the `text` argument passed to updateValue (left column table). + return '
' + rows.join('') + '
'; } - }, + }, { xtype: 'box', colspan: 2,