469 lines
20 KiB
JavaScript
469 lines
20 KiB
JavaScript
/**
|
|
* ProxMorph Hardware Sensors
|
|
* Adds hardware sensor monitoring to the Proxmox VE node status panel.
|
|
* Displays CPU, NVMe, HDD temperatures, fan speeds, and UPS status
|
|
* in a compact single-row layout.
|
|
*
|
|
* Requires:
|
|
* - lm-sensors installed on the Proxmox host
|
|
* - Nodes.pm patched to expose sensor data via API
|
|
* - Enabled via ProxMorph installer (--sensors flag)
|
|
*
|
|
* Data flow:
|
|
* 1. Nodes.pm runs `sensors -j` and exposes sensorsOutput in API
|
|
* 2. PVE.node.StatusView fetches /api2/json/nodes/{node}/status
|
|
* 3. Our override injects a single "Sensors" item reading that field
|
|
* 4. Combined renderer parses JSON and produces compact themed HTML
|
|
*
|
|
* Supports:
|
|
* - Intel (coretemp-isa-*) and AMD (k10temp-pci-*) CPU temperatures
|
|
* - NVMe drive temperatures (nvme-pci-*)
|
|
* - HDD/SATA drive temperatures (drivetemp-scsi-*)
|
|
* - Fan speeds (recursive detection of fan*_input keys)
|
|
* - UPS status via NUT (upsc) — optional, shown inline when present
|
|
*
|
|
* Version: 1.2.0
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ─── Configuration ─────────────────────────────────────────────
|
|
var SENSOR_UNIT = 'C'; // 'C' or 'F'
|
|
|
|
// ─── Sensor Filter (populated from API on store load) ──────────
|
|
// null = no filter (show all), otherwise object with chip:label keys
|
|
var activeSensorFilter = null;
|
|
|
|
function isSensorAllowed(chipKey, label) {
|
|
if (!activeSensorFilter) return true;
|
|
return activeSensorFilter[chipKey + ':' + label] === true;
|
|
}
|
|
|
|
function parseSensorFilter(raw) {
|
|
if (!raw || typeof raw !== 'string' || raw.trim() === '') return null;
|
|
var filter = {};
|
|
raw.split('\n').forEach(function (line) {
|
|
line = line.trim();
|
|
if (line !== '') filter[line] = true;
|
|
});
|
|
return Object.keys(filter).length > 0 ? filter : null;
|
|
}
|
|
|
|
// ─── CSS Variable Reader ───────────────────────────────────────
|
|
function getThemeColors() {
|
|
var cs = getComputedStyle(document.documentElement);
|
|
return {
|
|
text: cs.getPropertyValue('--pm-text').trim() || '#e5e7eb',
|
|
textDim: cs.getPropertyValue('--pm-text-dim').trim() || '#9ca3af',
|
|
warning: cs.getPropertyValue('--pm-warning').trim() || '#f5a623',
|
|
error: cs.getPropertyValue('--pm-error').trim() || '#f03a3e',
|
|
success: cs.getPropertyValue('--pm-success').trim() || '#37be5f',
|
|
accent: cs.getPropertyValue('--pm-accent').trim() || '#006eff'
|
|
};
|
|
}
|
|
|
|
// ─── Temperature Helpers ───────────────────────────────────────
|
|
function formatTemp(celsius) {
|
|
if (celsius === null || celsius === undefined || isNaN(celsius)) return '—';
|
|
var val = SENSOR_UNIT === 'F' ? (celsius * 9 / 5) + 32 : celsius;
|
|
return Ext.util.Format.number(val, '0.#') + '°' + SENSOR_UNIT;
|
|
}
|
|
|
|
function tempColor(temp, max, crit, colors) {
|
|
if (crit !== null && temp >= crit) return colors.error;
|
|
if (max !== null && temp >= max) return colors.warning;
|
|
return colors.text;
|
|
}
|
|
|
|
// ─── JSON Parser ───────────────────────────────────────────────
|
|
function parseSensorsJSON(raw) {
|
|
if (!raw || typeof raw !== 'string') return null;
|
|
try {
|
|
var cleaned = raw
|
|
.replace(/,(\s*[}\]])/g, '$1')
|
|
.replace(/\bNaN\b/g, 'null')
|
|
.replace(/\bERROR\b[^\n]*/g, '');
|
|
return JSON.parse(cleaned);
|
|
} catch (e) {
|
|
console.warn('[ProxMorph Sensors] JSON parse error:', e.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Inline HTML Helpers ───────────────────────────────────────
|
|
function tag(text, color, bold) {
|
|
var s = 'color:' + color + ';';
|
|
if (bold) s += 'font-weight:600;';
|
|
return '<span style="' + s + '">' + text + '</span>';
|
|
}
|
|
|
|
function sep(colors) {
|
|
return '<span style="color:' + colors.textDim + ';opacity:0.4;margin:0 6px;">|</span>';
|
|
}
|
|
|
|
// ─── Combined Sensors Renderer ─────────────────────────────────
|
|
// Produces a compact single-line output: CPU | NVMe | Fan
|
|
// Only sections with data are shown — no "No X detected" clutter.
|
|
function renderSensors(value, record) {
|
|
// Read filter from the StatusView's store (resolves timing issue where
|
|
// our store.on('load') fires after ExtJS already called this renderer)
|
|
try {
|
|
var store = (record && record.store) ? record.store : null;
|
|
if (!store) {
|
|
var widget = Ext.ComponentQuery.query('#sensors')[0];
|
|
if (widget && widget.ownerCt && widget.ownerCt.getStore) {
|
|
store = widget.ownerCt.getStore();
|
|
}
|
|
}
|
|
if (store) {
|
|
var filterRec = store.findRecord('key', 'sensorsFilter');
|
|
activeSensorFilter = filterRec ? parseSensorFilter(filterRec.get('value')) : null;
|
|
}
|
|
} catch (e) { /* filter stays as-is */ }
|
|
|
|
var data = parseSensorsJSON(value);
|
|
var colors = getThemeColors();
|
|
if (!data) return tag('N/A', colors.textDim);
|
|
|
|
var sections = [];
|
|
|
|
// ── CPU Temperature ─────────────────────────────────────
|
|
var cpuChips = Object.keys(data).filter(function (k) {
|
|
return k.indexOf('coretemp-isa-') === 0 ||
|
|
k.indexOf('k10temp-pci-') === 0;
|
|
});
|
|
|
|
cpuChips.forEach(function (chipKey) {
|
|
var chip = data[chipKey];
|
|
var pkgTemp = null, pkgMax = null, pkgCrit = null;
|
|
var coreTemps = [];
|
|
|
|
Object.keys(chip).forEach(function (label) {
|
|
if (label === 'Adapter') return;
|
|
if (!isSensorAllowed(chipKey, label)) return;
|
|
var sensor = chip[label];
|
|
if (!sensor || typeof sensor !== 'object') return;
|
|
|
|
var inputKey = null, maxKey = null, critKey = null;
|
|
Object.keys(sensor).forEach(function (k) {
|
|
if (/^temp\d+_input$/.test(k)) inputKey = k;
|
|
else if (/^temp\d+_max$/.test(k)) maxKey = k;
|
|
else if (/^temp\d+_crit$/.test(k)) critKey = k;
|
|
});
|
|
if (!inputKey) return;
|
|
|
|
var temp = sensor[inputKey];
|
|
if (temp === null || temp === undefined) return;
|
|
|
|
if (/Package|Tctl|Tdie/i.test(label)) {
|
|
pkgTemp = temp;
|
|
pkgMax = maxKey ? sensor[maxKey] : null;
|
|
pkgCrit = critKey ? sensor[critKey] : null;
|
|
} else {
|
|
coreTemps.push({
|
|
label: label,
|
|
temp: temp,
|
|
max: maxKey ? sensor[maxKey] : null,
|
|
crit: critKey ? sensor[critKey] : null
|
|
});
|
|
}
|
|
});
|
|
|
|
if (pkgTemp !== null) {
|
|
var color = tempColor(pkgTemp, pkgMax, pkgCrit, colors);
|
|
var txt = 'CPU: ' + formatTemp(pkgTemp);
|
|
if (coreTemps.length > 0) txt += ' (' + coreTemps.length + ' cores)';
|
|
sections.push(tag(txt, color));
|
|
} else if (coreTemps.length > 0) {
|
|
if (activeSensorFilter) {
|
|
// Filter active: show each selected core individually
|
|
coreTemps.forEach(function (core) {
|
|
var color = tempColor(core.temp, core.max, core.crit, colors);
|
|
sections.push(tag(core.label + ': ' + formatTemp(core.temp), color));
|
|
});
|
|
} else {
|
|
var maxT = Math.max.apply(null, coreTemps.map(function (c) { return c.temp; }));
|
|
var color = tempColor(maxT, 80, 95, colors);
|
|
sections.push(tag('CPU: ' + formatTemp(maxT) + ' peak (' + coreTemps.length + ' cores)', color));
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── NVMe Temperature (composite only) ───────────────────
|
|
var nvmeKeys = Object.keys(data).filter(function (k) {
|
|
return k.indexOf('nvme-pci-') === 0;
|
|
});
|
|
nvmeKeys.forEach(function (chipKey, idx) {
|
|
var chip = data[chipKey];
|
|
var compositeTemp = null, compMax = null, compCrit = null;
|
|
|
|
Object.keys(chip).forEach(function (label) {
|
|
if (label !== 'Composite') return;
|
|
if (!isSensorAllowed(chipKey, label)) return;
|
|
var sensor = chip[label];
|
|
if (!sensor || typeof sensor !== 'object') return;
|
|
|
|
Object.keys(sensor).forEach(function (k) {
|
|
if (/^temp\d+_input$/.test(k) && compositeTemp === null) {
|
|
compositeTemp = sensor[k];
|
|
}
|
|
if (/^temp\d+_max$/.test(k)) {
|
|
var m = sensor[k];
|
|
if (m !== null && m < 200) compMax = m;
|
|
}
|
|
if (/^temp\d+_crit$/.test(k)) compCrit = sensor[k];
|
|
});
|
|
});
|
|
|
|
if (compositeTemp !== null) {
|
|
var color = tempColor(compositeTemp, compMax || 70, compCrit || 80, colors);
|
|
var lbl = nvmeKeys.length > 1 ? 'NVMe' + idx : 'NVMe';
|
|
sections.push(tag(lbl + ': ' + formatTemp(compositeTemp), color));
|
|
}
|
|
});
|
|
|
|
// ── SATA/HDD Temperature ────────────────────────────────
|
|
var hddKeys = Object.keys(data).filter(function (k) {
|
|
return k.indexOf('drivetemp-scsi-') === 0;
|
|
});
|
|
hddKeys.forEach(function (chipKey, idx) {
|
|
var chip = data[chipKey];
|
|
Object.keys(chip).forEach(function (label) {
|
|
if (label === 'Adapter') return;
|
|
if (!isSensorAllowed(chipKey, label)) return;
|
|
var sensor = chip[label];
|
|
if (!sensor || typeof sensor !== 'object') return;
|
|
|
|
var inputKey = null;
|
|
Object.keys(sensor).forEach(function (k) {
|
|
if (/^temp\d+_input$/.test(k)) inputKey = k;
|
|
});
|
|
if (!inputKey) return;
|
|
|
|
var temp = sensor[inputKey];
|
|
if (temp !== null && temp !== undefined) {
|
|
var color = tempColor(temp, 45, 55, colors);
|
|
var lbl = hddKeys.length > 1 ? 'HDD' + idx : 'HDD';
|
|
sections.push(tag(lbl + ': ' + formatTemp(temp), color));
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Fan Speeds ──────────────────────────────────────────
|
|
var fans = [];
|
|
function findFans(obj, parentLabel, chipKey) {
|
|
if (!obj || typeof obj !== 'object') return;
|
|
Object.keys(obj).forEach(function (key) {
|
|
if (/^fan\d+_input$/.test(key)) {
|
|
var label = parentLabel || key.replace('_input', '');
|
|
if (isSensorAllowed(chipKey, label)) {
|
|
fans.push({
|
|
label: label,
|
|
rpm: obj[key]
|
|
});
|
|
}
|
|
} else if (typeof obj[key] === 'object' && key !== 'Adapter') {
|
|
findFans(obj[key], key, chipKey);
|
|
}
|
|
});
|
|
}
|
|
Object.keys(data).forEach(function (chipKey) {
|
|
findFans(data[chipKey], '', chipKey);
|
|
});
|
|
|
|
if (fans.length > 0) {
|
|
fans.forEach(function (fan) {
|
|
var rpm = fan.rpm || 0;
|
|
var color = rpm === 0 ? colors.warning : colors.text;
|
|
sections.push(tag(fan.label + ': ' + rpm + ' RPM', color));
|
|
});
|
|
}
|
|
|
|
// ── Join all sections ───────────────────────────────────
|
|
if (sections.length === 0) return tag('No sensors detected', colors.textDim);
|
|
return sections.join(sep(colors));
|
|
}
|
|
|
|
// ─── UPS Renderer (compact inline) ─────────────────────────────
|
|
function renderUps(value) {
|
|
var colors = getThemeColors();
|
|
if (!value || typeof value !== 'string' || value.trim() === '') {
|
|
return '';
|
|
}
|
|
|
|
var fields = {};
|
|
value.split('\n').forEach(function (line) {
|
|
var idx = line.indexOf(':');
|
|
if (idx > 0) {
|
|
fields[line.substring(0, idx).trim()] = line.substring(idx + 1).trim();
|
|
}
|
|
});
|
|
if (Object.keys(fields).length === 0) return '';
|
|
|
|
var parts = [];
|
|
|
|
// Status
|
|
var status = fields['ups.status'] || '';
|
|
var sColor = colors.success, sText = 'Online';
|
|
if (status.indexOf('OB') !== -1) { sColor = colors.error; sText = 'On Battery'; }
|
|
else if (status.indexOf('LB') !== -1) { sColor = colors.error; sText = 'Low Battery'; }
|
|
else if (status.indexOf('CHRG') !== -1) { sColor = colors.warning; sText = 'Charging'; }
|
|
else if (status.indexOf('OL') === -1 && status) { sColor = colors.warning; sText = status; }
|
|
parts.push(tag(sText, sColor, true));
|
|
|
|
// Battery
|
|
var charge = parseFloat(fields['battery.charge']);
|
|
if (!isNaN(charge)) {
|
|
var cColor = charge < 20 ? colors.error : charge < 50 ? colors.warning : colors.success;
|
|
parts.push(tag(charge + '%', cColor));
|
|
}
|
|
|
|
// Load
|
|
var load = parseFloat(fields['ups.load']);
|
|
if (!isNaN(load)) {
|
|
var lColor = load > 80 ? colors.error : load > 60 ? colors.warning : colors.text;
|
|
parts.push(tag('Load ' + load + '%', lColor));
|
|
}
|
|
|
|
// Runtime
|
|
var runtime = parseFloat(fields['battery.runtime']);
|
|
if (!isNaN(runtime)) {
|
|
var mins = Math.floor(runtime / 60);
|
|
var secs = Math.floor(runtime % 60);
|
|
var rText = mins > 0 ? mins + 'm' : secs + 's';
|
|
parts.push(tag(rText, mins < 5 ? colors.warning : colors.textDim));
|
|
}
|
|
|
|
return parts.join(sep(colors));
|
|
}
|
|
|
|
// ─── StatusView Override ───────────────────────────────────────
|
|
function applyOverride() {
|
|
if (typeof Ext === 'undefined' || !Ext.ClassManager) {
|
|
setTimeout(applyOverride, 500);
|
|
return;
|
|
}
|
|
|
|
var cls = Ext.ClassManager.get('PVE.node.StatusView');
|
|
if (!cls) {
|
|
setTimeout(applyOverride, 500);
|
|
return;
|
|
}
|
|
|
|
var origInitComponent = cls.prototype.initComponent;
|
|
|
|
cls.prototype.initComponent = function () {
|
|
// Single compact sensor row — CPU + Storage + Fan inline
|
|
var sensorItems = [
|
|
{
|
|
itemId: 'sensors',
|
|
colspan: 2,
|
|
printBar: false,
|
|
title: gettext('Sensors'),
|
|
textField: 'sensorsOutput',
|
|
renderer: renderSensors
|
|
}
|
|
];
|
|
|
|
// Inject after 'cpus'
|
|
if (Ext.isArray(this.items)) {
|
|
var insertIdx = -1;
|
|
for (var i = 0; i < this.items.length; i++) {
|
|
if (this.items[i].itemId === 'cpus') {
|
|
insertIdx = i + 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (insertIdx >= 0) {
|
|
Ext.Array.insert(this.items, insertIdx, sensorItems);
|
|
} else {
|
|
this.items = this.items.concat(sensorItems);
|
|
}
|
|
}
|
|
|
|
// Call original initComponent
|
|
if (origInitComponent) {
|
|
origInitComponent.apply(this, arguments);
|
|
}
|
|
|
|
// After render: conditionally add UPS row if data exists,
|
|
// then trigger layout recalculation so the panel expands.
|
|
var me = this;
|
|
me.on('afterrender', function () {
|
|
var store = me.getStore ? me.getStore() : null;
|
|
if (!store) return;
|
|
|
|
store.on('load', function (s, records) {
|
|
if (!records || !records.length) return;
|
|
|
|
// Update sensor filter from API data
|
|
var filterRec = s.findRecord('key', 'sensorsFilter');
|
|
activeSensorFilter = filterRec ? parseSensorFilter(filterRec.get('value')) : null;
|
|
|
|
// Hide sensor row entirely when API doesn't include sensorsOutput
|
|
var sensorWidget = me.down('#sensors');
|
|
if (sensorWidget) {
|
|
var sensorsRec = s.findRecord('key', 'sensorsOutput');
|
|
var hasSensorData = sensorsRec !== null;
|
|
sensorWidget.setVisible(hasSensorData);
|
|
|
|
// Force re-render with filter applied (our handler fires
|
|
// after StatusView's internal renderer, need to update)
|
|
if (hasSensorData && sensorWidget.setText) {
|
|
sensorWidget.setText(renderSensors(sensorsRec.get('value')));
|
|
}
|
|
}
|
|
|
|
// Check for UPS data in the store
|
|
var upsRec = s.findRecord('key', 'upsData');
|
|
var upsData = upsRec ? upsRec.get('value') : null;
|
|
|
|
// Only inject UPS item once, and only if there is data
|
|
if (upsData && upsData.trim() !== '' && !me.down('#upsStatus')) {
|
|
me.add({
|
|
xtype: 'pmxInfoWidget',
|
|
itemId: 'upsStatus',
|
|
colspan: 2,
|
|
printBar: false,
|
|
title: gettext('UPS'),
|
|
iconCls: 'fa fa-fw fa-battery-half',
|
|
textField: 'upsData',
|
|
renderer: renderUps
|
|
});
|
|
me.updateLayout();
|
|
}
|
|
|
|
// Recalculate layout to accommodate sensor row
|
|
Ext.defer(function () {
|
|
me.updateLayout();
|
|
var parent = me.ownerCt;
|
|
if (parent && parent.updateLayout) {
|
|
parent.updateLayout();
|
|
}
|
|
}, 100);
|
|
});
|
|
});
|
|
};
|
|
|
|
console.log('[ProxMorph] Sensor widget initialized (v1.1.0)');
|
|
}
|
|
|
|
// ─── Init ──────────────────────────────────────────────────────
|
|
function init() {
|
|
try {
|
|
applyOverride();
|
|
window.ProxMorphSensors = { version: '1.1.0' };
|
|
} catch (e) {
|
|
console.error('[ProxMorph Sensors] Init error:', e);
|
|
}
|
|
}
|
|
|
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
|
init();
|
|
} else {
|
|
window.addEventListener('DOMContentLoaded', init);
|
|
}
|
|
|
|
})();
|