Initial commit — ProxMorph with TheRaiwy Dark cyber theme
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* ProxMorph GitHub Dark — Checkbox/Radio Init Patch
|
||||
*
|
||||
* Problem: ExtJS pre-selects checkboxes/radios by setting background-position
|
||||
* to "0px -15px" (sprite selected state) WITHOUT adding the .x-form-cb-checked
|
||||
* class to the .x-form-item ancestor. Our CSS relies on that class.
|
||||
*
|
||||
* Fix: Scan all .x-form-cb spans, detect the -15px sprite offset, and add
|
||||
* .x-form-cb-checked to the nearest .x-form-item ancestor so our CSS rules fire.
|
||||
*
|
||||
* Runs on load + via MutationObserver whenever new dialogs are added to the DOM.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function isGithubDarkActive() {
|
||||
return !!document.querySelector('link[href*="theme-github-dark.css"]');
|
||||
}
|
||||
|
||||
function fixInitialCbState() {
|
||||
if (!isGithubDarkActive()) return;
|
||||
|
||||
document.querySelectorAll('.x-form-cb').forEach(function (span) {
|
||||
var bgPos = window.getComputedStyle(span).backgroundPosition;
|
||||
if (bgPos && bgPos.indexOf('-15px') !== -1) {
|
||||
var fieldItem = span.closest('.x-form-item');
|
||||
if (fieldItem && !fieldItem.classList.contains('x-form-cb-checked')) {
|
||||
fieldItem.classList.add('x-form-cb-checked');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run once after the page is fully loaded
|
||||
if (document.readyState === 'complete') {
|
||||
setTimeout(fixInitialCbState, 100);
|
||||
} else {
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(fixInitialCbState, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Re-run whenever new nodes are added (dialog opens, panel navigation, etc.)
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
var hasNew = false;
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
if (mutations[i].addedNodes.length) {
|
||||
hasNew = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNew) {
|
||||
setTimeout(fixInitialCbState, 50);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* ProxMorph PDM Theme Selector Patch
|
||||
* Version: 1.0.0
|
||||
*
|
||||
* Injects ProxMorph theme options into PDM's native Theme dialog dropdown.
|
||||
* Uses MutationObserver to detect when the dialog opens and adds custom
|
||||
* theme entries alongside the built-in Desktop/Crisp options.
|
||||
*
|
||||
* Theme guard: only runs if proxmorph-theme CSS links are present.
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
if (!document.querySelector('link.proxmorph-theme')) return;
|
||||
|
||||
var themeLinks = document.querySelectorAll('link.proxmorph-theme');
|
||||
var themes = [];
|
||||
for (var i = 0; i < themeLinks.length; i++) {
|
||||
var href = themeLinks[i].getAttribute('href');
|
||||
var filename = href.split('/').pop();
|
||||
var name = filename.replace('theme-', '').replace('.css', '')
|
||||
.split('-').map(function(w) { return w.charAt(0).toUpperCase() + w.slice(1); }).join(' ');
|
||||
themes.push({ filename: filename, display: 'PM: ' + name, link: themeLinks[i] });
|
||||
}
|
||||
|
||||
function activateTheme(filename) {
|
||||
for (var i = 0; i < themeLinks.length; i++) {
|
||||
themeLinks[i].setAttribute('disabled', '');
|
||||
}
|
||||
if (filename) {
|
||||
localStorage.setItem('proxmorph-theme', filename);
|
||||
var base = document.querySelector('link.proxmorph-base');
|
||||
if (base) base.removeAttribute('disabled');
|
||||
for (var j = 0; j < themeLinks.length; j++) {
|
||||
if (themeLinks[j].href.indexOf(filename) !== -1) {
|
||||
themeLinks[j].removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('proxmorph-theme');
|
||||
var base = document.querySelector('link.proxmorph-base');
|
||||
if (base) base.setAttribute('disabled', '');
|
||||
}
|
||||
}
|
||||
|
||||
function deactivateProxmorph() {
|
||||
for (var i = 0; i < themeLinks.length; i++) {
|
||||
themeLinks[i].setAttribute('disabled', '');
|
||||
}
|
||||
var base = document.querySelector('link.proxmorph-base');
|
||||
if (base) base.setAttribute('disabled', '');
|
||||
localStorage.removeItem('proxmorph-theme');
|
||||
}
|
||||
|
||||
var patched = false;
|
||||
|
||||
function setupObserver() {
|
||||
var observer = new MutationObserver(function() {
|
||||
if (patched) return;
|
||||
tryInject();
|
||||
});
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupObserver);
|
||||
} else {
|
||||
setupObserver();
|
||||
}
|
||||
|
||||
function tryInject() {
|
||||
var dialog = document.querySelector('dialog');
|
||||
if (!dialog) { patched = false; return; }
|
||||
|
||||
var combo = dialog.querySelector('[aria-label="Select Theme"]');
|
||||
if (!combo) return;
|
||||
|
||||
/* Early combo fix: override value as soon as dialog appears, before dropdown is expanded */
|
||||
var currentThemeEarly = localStorage.getItem('proxmorph-theme');
|
||||
if (currentThemeEarly && combo.tagName === 'INPUT' && !combo.__proxmorphPatched) {
|
||||
for (var ei = 0; ei < themes.length; ei++) {
|
||||
if (themes[ei].filename === currentThemeEarly) {
|
||||
var origDesc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
|
||||
origDesc.set.call(combo, themes[ei].display);
|
||||
Object.defineProperty(combo, 'value', {
|
||||
get: function() { return origDesc.get.call(this); },
|
||||
set: function(v) {
|
||||
var active = localStorage.getItem('proxmorph-theme');
|
||||
if (active && (v === 'Desktop' || v === 'Crisp' || v === 'Material')) {
|
||||
return;
|
||||
}
|
||||
origDesc.set.call(this, v);
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
combo.__proxmorphPatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tables = dialog.querySelectorAll('.pwt-datatable-content');
|
||||
var themeTable = null;
|
||||
|
||||
for (var t = 0; t < tables.length; t++) {
|
||||
var rows = tables[t].querySelectorAll('tr[role="row"]');
|
||||
for (var r = 0; r < rows.length; r++) {
|
||||
var cellText = rows[r].textContent.trim();
|
||||
if (cellText === 'Desktop' || cellText === 'Crisp' || cellText === 'Material') {
|
||||
themeTable = tables[t];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (themeTable) break;
|
||||
}
|
||||
|
||||
if (!themeTable) return;
|
||||
if (themeTable.querySelector('[data-proxmorph-theme]')) { patched = true; return; }
|
||||
|
||||
var nativeRows = themeTable.querySelectorAll('tr[role="row"]');
|
||||
var existingCount = nativeRows.length;
|
||||
|
||||
var idPrefix = 'ProxMorph';
|
||||
if (nativeRows.length > 0 && nativeRows[0].id) {
|
||||
idPrefix = nativeRows[0].id.split('-item-')[0];
|
||||
}
|
||||
|
||||
var sepRow = document.createElement('tr');
|
||||
sepRow.setAttribute('role', 'none');
|
||||
sepRow.className = 'proxmorph-separator';
|
||||
var sepTd = document.createElement('td');
|
||||
sepTd.setAttribute('role', 'none');
|
||||
sepTd.setAttribute('colspan', '2');
|
||||
sepTd.style.cssText = 'padding:4px 0;border-top:1px solid var(--pwt-color-neutral-40,#555);';
|
||||
var sepDiv = document.createElement('div');
|
||||
sepDiv.style.cssText = 'font-size:11px;color:var(--pwt-color-neutral-60,#999);padding:2px 8px;';
|
||||
sepDiv.textContent = 'ProxMorph Themes';
|
||||
sepTd.appendChild(sepDiv);
|
||||
sepRow.appendChild(sepTd);
|
||||
themeTable.appendChild(sepRow);
|
||||
|
||||
var currentTheme = localStorage.getItem('proxmorph-theme');
|
||||
|
||||
for (var i = 0; i < themes.length; i++) {
|
||||
var theme = themes[i];
|
||||
var rowIndex = existingCount + i + 1;
|
||||
var isSelected = currentTheme === theme.filename;
|
||||
|
||||
var row = document.createElement('tr');
|
||||
row.setAttribute('role', 'row');
|
||||
row.setAttribute('aria-rowindex', String(rowIndex));
|
||||
row.setAttribute('aria-selected', isSelected ? 'true' : 'false');
|
||||
row.id = idPrefix + '-item-PM-' + theme.filename;
|
||||
row.className = isSelected ? 'row-cursor selected' : '';
|
||||
row.setAttribute('data-proxmorph-theme', theme.filename);
|
||||
|
||||
var td = document.createElement('td');
|
||||
td.setAttribute('role', 'gridcell');
|
||||
td.setAttribute('data-column-num', '0');
|
||||
td.setAttribute('tabindex', '-1');
|
||||
td.className = 'pwt-datatable-cell pwt-pointer' + (isSelected ? ' cell-cursor' : '');
|
||||
td.style.cssText = 'vertical-align:baseline;text-align:start;';
|
||||
|
||||
var div = document.createElement('div');
|
||||
div.setAttribute('role', 'none');
|
||||
div.textContent = theme.display;
|
||||
td.appendChild(div);
|
||||
|
||||
var td2 = document.createElement('td');
|
||||
td2.setAttribute('role', 'none');
|
||||
td2.style.cssText = 'vertical-align:top;height:22px;';
|
||||
|
||||
row.appendChild(td);
|
||||
row.appendChild(td2);
|
||||
themeTable.appendChild(row);
|
||||
|
||||
(function(themeObj, rowEl) {
|
||||
rowEl.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var allRows = themeTable.querySelectorAll('tr[role="row"]');
|
||||
for (var k = 0; k < allRows.length; k++) {
|
||||
allRows[k].className = '';
|
||||
allRows[k].setAttribute('aria-selected', 'false');
|
||||
var cells = allRows[k].querySelectorAll('.pwt-datatable-cell');
|
||||
for (var c = 0; c < cells.length; c++) {
|
||||
cells[c].classList.remove('cell-cursor');
|
||||
}
|
||||
}
|
||||
rowEl.className = 'row-cursor selected';
|
||||
rowEl.setAttribute('aria-selected', 'true');
|
||||
var myCell = rowEl.querySelector('.pwt-datatable-cell');
|
||||
if (myCell) myCell.classList.add('cell-cursor');
|
||||
|
||||
activateTheme(themeObj.filename);
|
||||
|
||||
var combo = dialog.querySelector('[aria-label="Select Theme"]');
|
||||
if (combo) {
|
||||
if (combo.tagName === 'INPUT') {
|
||||
/* Remove override temporarily to set the new value */
|
||||
var origDesc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
|
||||
delete combo.value;
|
||||
origDesc.set.call(combo, themeObj.display);
|
||||
/* Re-install override to block PWT resets */
|
||||
Object.defineProperty(combo, 'value', {
|
||||
get: function() { return origDesc.get.call(this); },
|
||||
set: function(v) {
|
||||
var active = localStorage.getItem('proxmorph-theme');
|
||||
if (active && (v === 'Desktop' || v === 'Crisp' || v === 'Material')) {
|
||||
return;
|
||||
}
|
||||
origDesc.set.call(this, v);
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
} else {
|
||||
var valueSpan = combo.querySelector('.pwt-text-truncate') || combo;
|
||||
if (valueSpan) valueSpan.textContent = themeObj.display;
|
||||
}
|
||||
}
|
||||
});
|
||||
})(theme, row);
|
||||
}
|
||||
|
||||
/* If a ProxMorph theme is active, deselect native rows and update combo */
|
||||
if (currentTheme) {
|
||||
for (var d = 0; d < nativeRows.length; d++) {
|
||||
nativeRows[d].className = '';
|
||||
nativeRows[d].setAttribute('aria-selected', 'false');
|
||||
var dCells = nativeRows[d].querySelectorAll('.pwt-datatable-cell');
|
||||
for (var dc = 0; dc < dCells.length; dc++) {
|
||||
dCells[dc].classList.remove('cell-cursor');
|
||||
}
|
||||
}
|
||||
/* Update combo display text to active PM theme name */
|
||||
var activeCombo = dialog.querySelector('[aria-label="Select Theme"]');
|
||||
if (activeCombo) {
|
||||
for (var at = 0; at < themes.length; at++) {
|
||||
if (themes[at].filename === currentTheme) {
|
||||
if (activeCombo.tagName === 'INPUT') {
|
||||
activeCombo.value = themes[at].display;
|
||||
/* Override value setter to prevent PWT from resetting to native theme name */
|
||||
var pmDisplay = themes[at].display;
|
||||
var origDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
|
||||
Object.defineProperty(activeCombo, 'value', {
|
||||
get: function() { return origDescriptor.get.call(this); },
|
||||
set: function(v) {
|
||||
var active = localStorage.getItem('proxmorph-theme');
|
||||
if (active && (v === 'Desktop' || v === 'Crisp' || v === 'Material')) {
|
||||
return;
|
||||
}
|
||||
origDescriptor.set.call(this, v);
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
} else {
|
||||
var valSpan = activeCombo.querySelector('.pwt-text-truncate') || activeCombo;
|
||||
if (valSpan) valSpan.textContent = themes[at].display;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var nr = 0; nr < nativeRows.length; nr++) {
|
||||
(function(nativeRow) {
|
||||
nativeRow.addEventListener('click', function() {
|
||||
deactivateProxmorph();
|
||||
/* Remove value override so PWT can set native theme name */
|
||||
var cmb = dialog.querySelector('[aria-label="Select Theme"]');
|
||||
if (cmb && cmb.tagName === 'INPUT') {
|
||||
delete cmb.value;
|
||||
}
|
||||
var pmRows = themeTable.querySelectorAll('[data-proxmorph-theme]');
|
||||
for (var p = 0; p < pmRows.length; p++) {
|
||||
pmRows[p].className = '';
|
||||
pmRows[p].setAttribute('aria-selected', 'false');
|
||||
var cells = pmRows[p].querySelectorAll('.pwt-datatable-cell');
|
||||
for (var c = 0; c < cells.length; c++) {
|
||||
cells[c].classList.remove('cell-cursor');
|
||||
}
|
||||
}
|
||||
});
|
||||
})(nativeRows[nr]);
|
||||
}
|
||||
|
||||
patched = true;
|
||||
|
||||
var closeCheck = setInterval(function() {
|
||||
if (!document.contains(themeTable)) {
|
||||
patched = false;
|
||||
clearInterval(closeCheck);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* ProxMorph Chart Patcher
|
||||
* Applies custom colors to Proxmox RRD charts
|
||||
*
|
||||
* Features:
|
||||
* - Custom UniFi color palette for chart lines/areas
|
||||
* - Special handling for Network Traffic (blue/green layering)
|
||||
* - Subtle white border on hover dots for visibility
|
||||
*
|
||||
* Color Palette:
|
||||
* Primary: #30AD55 (UniFi green)
|
||||
* Secondary: #006EFF (UniFi blue)
|
||||
* Tertiary: #5DC0E0 (Cyan/teal)
|
||||
* Warning: #D08D1E (Amber/orange)
|
||||
* Critical: #CC3135 (Red)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const PROXMORPH_CHART_COLORS = [
|
||||
'#30AD55', // UniFi green (PRIMARY)
|
||||
'#006EFF', // UniFi blue (SECONDARY)
|
||||
'#5DC0E0', // UniFi cyan/teal
|
||||
'#D08D1E', // UniFi amber/orange
|
||||
'#CC3135', // UniFi red
|
||||
'#4797FF', // UniFi light blue
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply custom colors to a single chart
|
||||
*/
|
||||
function patchChart(chart) {
|
||||
if (!chart || !chart.getSeries) return;
|
||||
|
||||
try {
|
||||
const series = chart.getSeries();
|
||||
if (!series || series.length === 0) return;
|
||||
|
||||
// Special handling for Network Traffic chart to avoid color blending
|
||||
if (chart.title === 'Network Traffic') {
|
||||
// Swap colors: Blue (bottom layer), Green (top layer)
|
||||
chart.setColors(['#006EFF', '#30AD55']);
|
||||
series.forEach((s, idx) => {
|
||||
if (idx === 0) {
|
||||
// Incoming - Blue area (bottom)
|
||||
s.setStyle({
|
||||
fillStyle: 'rgba(0, 110, 255, 0.7)',
|
||||
strokeStyle: '#006EFF',
|
||||
lineWidth: 2
|
||||
});
|
||||
} else if (idx === 1) {
|
||||
// Outgoing - Green area (top)
|
||||
s.setStyle({
|
||||
fillStyle: 'rgba(48, 173, 85, 0.8)',
|
||||
strokeStyle: '#30AD55',
|
||||
lineWidth: 2
|
||||
});
|
||||
}
|
||||
// Add subtle white border to hover dots
|
||||
s.setHighlight({
|
||||
opacity: 1,
|
||||
scaling: 1.5,
|
||||
strokeStyle: '#FFFFFF',
|
||||
lineWidth: 1
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Standard color application for all other charts
|
||||
chart.setColors(PROXMORPH_CHART_COLORS);
|
||||
series.forEach((s, idx) => {
|
||||
const color = PROXMORPH_CHART_COLORS[idx % PROXMORPH_CHART_COLORS.length];
|
||||
s.setStyle({
|
||||
fillStyle: color,
|
||||
strokeStyle: color
|
||||
});
|
||||
// Add subtle white border to hover dots
|
||||
s.setHighlight({
|
||||
opacity: 1,
|
||||
scaling: 1.5,
|
||||
strokeStyle: '#FFFFFF',
|
||||
lineWidth: 1
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
chart.redraw();
|
||||
} catch (e) {
|
||||
console.warn('[ProxMorph] Chart patch error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if UniFi theme is active
|
||||
*/
|
||||
function isUnifiThemeActive() {
|
||||
// Check for the presence of the UniFi theme stylesheet
|
||||
return !!document.querySelector('link[href*="theme-unifi.css"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and patch all RRD charts on the page
|
||||
*/
|
||||
function patchAllCharts() {
|
||||
// Only patch if UniFi theme is active
|
||||
if (!isUnifiThemeActive()) return;
|
||||
|
||||
if (typeof Ext === 'undefined' || !Ext.ComponentQuery) return;
|
||||
|
||||
const charts = Ext.ComponentQuery.query('proxmoxRRDChart');
|
||||
if (charts && charts.length > 0) {
|
||||
charts.forEach(patchChart);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the chart patcher with delayed start and periodic refresh
|
||||
*/
|
||||
function init() {
|
||||
// Initial patch after page load
|
||||
if (document.readyState === 'complete') {
|
||||
setTimeout(patchAllCharts, 500);
|
||||
} else {
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(patchAllCharts, 500);
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic re-patch to catch dynamically loaded charts
|
||||
// Charts can be reloaded when switching views or refreshing data
|
||||
setInterval(patchAllCharts, 2000);
|
||||
|
||||
console.log('[ProxMorph] Chart patcher initialized');
|
||||
}
|
||||
|
||||
// Start initialization
|
||||
init();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* ProxMorph Chart Patcher (UniFi Light)
|
||||
* Applies custom colors to Proxmox RRD charts for light theme
|
||||
*
|
||||
* Features:
|
||||
* - Custom UniFi color palette for chart lines/areas
|
||||
* - Special handling for Network Traffic (blue/green layering)
|
||||
* - Dark border on hover dots for visibility on light backgrounds
|
||||
*
|
||||
* Color Palette (same as dark - UniFi uses identical colors):
|
||||
* Primary: #3ACC65 (UniFi green)
|
||||
* Secondary: #006FFF (UniFi blue)
|
||||
* Tertiary: #5DC0E0 (Cyan/teal)
|
||||
* Warning: #F6B94F (Amber/orange)
|
||||
* Critical: #F36165 (Red)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const PROXMORPH_CHART_COLORS = [
|
||||
'#3ACC65', // UniFi green (PRIMARY)
|
||||
'#006FFF', // UniFi blue (SECONDARY)
|
||||
'#5DC0E0', // UniFi cyan/teal
|
||||
'#F6B94F', // UniFi amber/orange
|
||||
'#F36165', // UniFi red
|
||||
'#4797FF', // UniFi light blue
|
||||
];
|
||||
|
||||
// Hover dot stroke color - dark for visibility on light backgrounds
|
||||
const HOVER_STROKE_COLOR = '#1A1C21';
|
||||
|
||||
/**
|
||||
* Apply custom colors to a single chart
|
||||
*/
|
||||
function patchChart(chart) {
|
||||
if (!chart || !chart.getSeries) return;
|
||||
|
||||
try {
|
||||
const series = chart.getSeries();
|
||||
if (!series || series.length === 0) return;
|
||||
|
||||
// Special handling for Network Traffic chart to avoid color blending
|
||||
if (chart.title === 'Network Traffic') {
|
||||
// Swap colors: Blue (bottom layer), Green (top layer)
|
||||
chart.setColors(['#006FFF', '#3ACC65']);
|
||||
series.forEach((s, idx) => {
|
||||
if (idx === 0) {
|
||||
// Incoming - Blue area (bottom)
|
||||
// Slightly lower opacity for light backgrounds
|
||||
s.setStyle({
|
||||
fillStyle: 'rgba(0, 111, 255, 0.4)',
|
||||
strokeStyle: '#006FFF',
|
||||
lineWidth: 2
|
||||
});
|
||||
} else if (idx === 1) {
|
||||
// Outgoing - Green area (top)
|
||||
s.setStyle({
|
||||
fillStyle: 'rgba(58, 204, 101, 0.5)',
|
||||
strokeStyle: '#3ACC65',
|
||||
lineWidth: 2
|
||||
});
|
||||
}
|
||||
// Add dark border to hover dots for visibility on light background
|
||||
s.setHighlight({
|
||||
opacity: 1,
|
||||
scaling: 1.5,
|
||||
strokeStyle: HOVER_STROKE_COLOR,
|
||||
lineWidth: 1
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Standard color application for all other charts
|
||||
chart.setColors(PROXMORPH_CHART_COLORS);
|
||||
series.forEach((s, idx) => {
|
||||
const color = PROXMORPH_CHART_COLORS[idx % PROXMORPH_CHART_COLORS.length];
|
||||
s.setStyle({
|
||||
fillStyle: color,
|
||||
strokeStyle: color
|
||||
});
|
||||
// Add dark border to hover dots for visibility on light background
|
||||
s.setHighlight({
|
||||
opacity: 1,
|
||||
scaling: 1.5,
|
||||
strokeStyle: HOVER_STROKE_COLOR,
|
||||
lineWidth: 1
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
chart.redraw();
|
||||
} catch (e) {
|
||||
console.warn('[ProxMorph] Chart patch error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if UniFi Light theme is active
|
||||
*/
|
||||
function isUnifiLightThemeActive() {
|
||||
// Check for the presence of the UniFi Light theme stylesheet
|
||||
return !!document.querySelector('link[href*="theme-unifi-light.css"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and patch all RRD charts on the page
|
||||
*/
|
||||
function patchAllCharts() {
|
||||
// Only patch if UniFi Light theme is active
|
||||
if (!isUnifiLightThemeActive()) return;
|
||||
|
||||
if (typeof Ext === 'undefined' || !Ext.ComponentQuery) return;
|
||||
|
||||
const charts = Ext.ComponentQuery.query('proxmoxRRDChart');
|
||||
if (charts && charts.length > 0) {
|
||||
charts.forEach(patchChart);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the chart patcher with delayed start and periodic refresh
|
||||
*/
|
||||
function init() {
|
||||
// Initial patch after page load
|
||||
if (document.readyState === 'complete') {
|
||||
setTimeout(patchAllCharts, 500);
|
||||
} else {
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(patchAllCharts, 500);
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic re-patch to catch dynamically loaded charts
|
||||
// Charts can be reloaded when switching views or refreshing data
|
||||
setInterval(patchAllCharts, 2000);
|
||||
|
||||
console.log('[ProxMorph] UniFi Light chart patcher initialized');
|
||||
}
|
||||
|
||||
// Start initialization
|
||||
init();
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user