feat(xo-server-ipmi-sensors) : Migrated the old ipmi feature to a new plugin and added user defined sensors (#9724)

Move ipmi sensor discovery to it's own plugin and removed old logic, ships with a default configuration with the same behaviour as the old system.
The plugin can be configured via the UI, accepts an exact match for the vendor and regexes for the ipmi matching.

Tested via unit tests.

Tested with R620-L1 host on xo-lab by adding and removing Dell from configured vendors
This commit is contained in:
Grandalf
2026-05-20 10:58:53 +02:00
committed by GitHub
parent 60fbce0df9
commit 7c9c7efb77
19 changed files with 731 additions and 196 deletions

View File

@@ -491,7 +491,9 @@ export interface XenApiDrTask {
uuid: string
}
type XenApiHostCallMethods = TagCallMethods & {}
type XenApiHostCallMethods = TagCallMethods & {
<T>(method: 'call_plugin',plugin:string, fn: string, args: Record<string, string>): Promise<T>
}
export interface XenApiHost {
$ref: Branded<'host'>
address: string

View File

@@ -56,7 +56,6 @@ export type XapiConnection = Xapi & {
sessionId: string
_url?: { protocol: string; hostname: string; port?: string }
}
type XapiRecordByXapiXoRecord = {
gpuGroup: XenApiGpuGroupWrapped
host: XenApiHostWrapped
@@ -170,6 +169,15 @@ export type XoApp = {
addAclV2GroupRole(groupId: XoGroup['id'], roleId: XoAclRole['id']): Promise<XoGroupRole>
addAclV2UserRole(userId: XoUser['id'], roleId: XoAclRole['id']): Promise<XoUserRole>
addUserToGroup: (userId: XoUser['id'], groupId: XoGroup['id']) => Promise<void>
addApiMethod: <A extends unknown[], R>(
name: string,
method: (...args: A) => Promise<R>,
//we use any since it is a legacy call, might be better typed in another pr
info: {
resolve?: any
params?: any
}
) => () => void // eslint-disable-line @typescript-eslint/no-explicit-any
authenticateUser: (
credentials: { token?: string; username?: string; password?: string },
userData?: { ip?: string },

View File

@@ -5,13 +5,6 @@ import { defer } from 'golike-defer'
import { incorrectState, operationFailed } from 'xo-common/api-errors.js'
import { getCurrentVmUuid } from './_XenStore.mjs'
import {
addCustomIpmiSensors,
addIpmiSensorDataType,
containsDigit,
IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME,
isRelevantIpmiSensor,
} from './host/_ipmi.mjs'
const waitAgentRestart = (xapi, hostRef, prevAgentStartTime) =>
new Promise(resolve => {
@@ -146,64 +139,6 @@ class Host {
await waitAgentRestart(this, ref, agentStartTime)
}
async getIpmiSensors(ref, { cache } = {}) {
const biosStrings = await this.call(cache, 'host.get_bios_strings', ref)
let productName = biosStrings['system-product-name']?.toLowerCase()
// Olivier.L request: consider all DELL servers in the same way
if (biosStrings['system-manufacturer']?.toLowerCase().includes('dell')) {
productName = 'dell'
}
// Olivier.L request: consider all Lenovo servers in the same way
if (biosStrings['system-manufacturer']?.toLowerCase().includes('lenovo')) {
productName = 'lenovo'
}
const callIpmiPlugin = fn => this.call(cache, 'host.call_plugin', ref, 'ipmitool.py', fn, {})
if (
IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME[productName] === undefined ||
(await callIpmiPlugin('is_ipmi_device_available')) === 'false'
) {
return {}
}
const [stringifiedIpmiSensors, stringifiedIpmiLan] = await Promise.all([
callIpmiPlugin('get_all_sensors'),
callIpmiPlugin('get_ipmi_lan'),
])
const ipmiSensors = JSON.parse(stringifiedIpmiSensors)
const ipmiLan = JSON.parse(stringifiedIpmiLan)
const ipmiSensorsByDataType = {}
for (const ipmiSensor of [...ipmiSensors, ...ipmiLan]) {
if (!isRelevantIpmiSensor(ipmiSensor, productName)) {
continue
}
addIpmiSensorDataType(ipmiSensor, productName)
const dataType = ipmiSensor.dataType
const ipmiSensors = ipmiSensorsByDataType[dataType]
if (ipmiSensors === undefined) {
ipmiSensorsByDataType[dataType] = containsDigit(ipmiSensor.name) ? [] : ipmiSensor
} else if (!Array.isArray(ipmiSensors)) {
// it can happen various sensors have the same name (e.g. temp for dell cpu temp)
// in such case, consider it as an array instead of single value
ipmiSensorsByDataType[dataType] = [ipmiSensors]
}
if (Array.isArray(ipmiSensorsByDataType[dataType])) {
ipmiSensorsByDataType[dataType].push(ipmiSensor)
}
}
addCustomIpmiSensors(ipmiSensorsByDataType, productName)
return ipmiSensorsByDataType
}
async getMdadmHealth(ref) {
try {
const result = await this.callAsync('host.call_plugin', ref, 'raid.py', 'check_raid_pool', {})

View File

@@ -1,110 +0,0 @@
export const IPMI_SENSOR_DATA_TYPE = {
totalPower: 'totalPower',
outletTemp: 'outletTemp',
bmcStatus: 'bmcStatus',
inletTemp: 'inletTemp',
cpuTemp: 'cpuTemp',
fanStatus: 'fanStatus',
fanSpeed: 'fanSpeed',
psuStatus: 'psuStatus',
psuPower: 'psuPower',
ip: 'ip',
unknown: 'unknown',
}
export const IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME = {
'mona_1.44gg': {
[IPMI_SENSOR_DATA_TYPE.totalPower]: /^total_power$/i,
[IPMI_SENSOR_DATA_TYPE.outletTemp]: /^outlet_temp$/i,
[IPMI_SENSOR_DATA_TYPE.bmcStatus]: /^bmc_status$/i,
[IPMI_SENSOR_DATA_TYPE.inletTemp]: /^psu_inlet_temp$/i,
[IPMI_SENSOR_DATA_TYPE.cpuTemp]: /^cpu[0-9]+_temp$/i,
[IPMI_SENSOR_DATA_TYPE.fanStatus]: /^fan[0-9]+_status$/i,
[IPMI_SENSOR_DATA_TYPE.fanSpeed]: /^fan[0-9]+_r_speed$/i,
[IPMI_SENSOR_DATA_TYPE.psuStatus]: /^psu[0-9]+_status$/i,
[IPMI_SENSOR_DATA_TYPE.psuPower]: /^psu[0-9]+_pin$/i,
[IPMI_SENSOR_DATA_TYPE.ip]: /^ip address$/i,
},
dell: {
[IPMI_SENSOR_DATA_TYPE.fanSpeed]: /^fan[0-9]+(a|b)$/i,
[IPMI_SENSOR_DATA_TYPE.psuPower]: /^voltage [0-9]+$/i,
[IPMI_SENSOR_DATA_TYPE.psuStatus]: /^ps[0-9]+ pg fail$/i,
[IPMI_SENSOR_DATA_TYPE.cpuTemp]: /^temp$/i,
[IPMI_SENSOR_DATA_TYPE.totalPower]: /^pwr consumption$/i,
[IPMI_SENSOR_DATA_TYPE.inletTemp]: /^inlet temp$/i,
[IPMI_SENSOR_DATA_TYPE.ip]: /^ip address$/i,
},
lenovo: {
[IPMI_SENSOR_DATA_TYPE.fanSpeed]: /^fan\s*\d+(?:\s+(?:front|rear))?\s+tach$/i,
[IPMI_SENSOR_DATA_TYPE.outletTemp]: /^exhaust temp$/i,
[IPMI_SENSOR_DATA_TYPE.psuPower]: /^psu\s*\d+\s+(ac in pwr|dc out pwr)$/i,
[IPMI_SENSOR_DATA_TYPE.psuStatus]:
/^(psu\s*\d+(?:\s+(?:failure|pf failure|in failure|i2c dc fai))?|power supply\s*\d+)$/i,
[IPMI_SENSOR_DATA_TYPE.cpuTemp]: /^cpu\s*\d+\s*temp$/i,
[IPMI_SENSOR_DATA_TYPE.totalPower]: /^(sys power|system power|power consumption)$/i,
[IPMI_SENSOR_DATA_TYPE.inletTemp]: /^(ambient temp|inlet temp)$/i,
[IPMI_SENSOR_DATA_TYPE.ip]: /^ip address$/i,
},
}
export const IPMI_SENSOR_CUSTOM_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME = {
dell: {
[IPMI_SENSOR_DATA_TYPE.bmcStatus]: ipmiSensorsByDataType => ({
event: Object.keys(ipmiSensorsByDataType).length > 0 ? 'ok' : 'ko',
name: 'bmc status',
value: '0x00',
}),
[IPMI_SENSOR_DATA_TYPE.fanStatus]: ipmiSensorsByDataType => ipmiSensorsByDataType[IPMI_SENSOR_DATA_TYPE.fanSpeed],
},
lenovo: {
[IPMI_SENSOR_DATA_TYPE.bmcStatus]: d => ({
event: Object.keys(d).length > 0 ? 'ok' : 'ko',
name: 'bmc status',
value: '0x00',
}),
[IPMI_SENSOR_DATA_TYPE.fanStatus]: d =>
(d[IPMI_SENSOR_DATA_TYPE.fanSpeed] ?? [])
.filter(s => s.event === 'ok' && /\d+\s*rpm/i.test(s.value))
.map(s => ({ ...s, rpm: parseInt(s.value, 10) })),
},
}
const IPMI_SENSOR_REGEX_BY_PRODUCT_NAME = Object.keys(IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME).reduce(
(acc, productName) => {
const regexes = Object.values(IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME[productName])
const combinedRegex = new RegExp(regexes.map(regex => regex.source).join('|'), 'i')
acc[productName] = combinedRegex
return acc
},
{}
)
export const isRelevantIpmiSensor = (data, productName) =>
IPMI_SENSOR_REGEX_BY_PRODUCT_NAME[productName].test(data.name)
export const containsDigit = str => /\d/.test(str)
export const addIpmiSensorDataType = (data, productName) => {
const name = data.name
const ipmiRegexByDataType = IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME[productName]
for (const dataType in ipmiRegexByDataType) {
const regex = ipmiRegexByDataType[dataType]
if (regex.test(name)) {
data.dataType = dataType
return
}
}
data.dataType = IPMI_SENSOR_DATA_TYPE.unknown
}
export const addCustomIpmiSensors = (ipmiSensorsByDataType, productName) => {
const customSensorsByDataType = IPMI_SENSOR_CUSTOM_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME[productName]
for (const dataType in customSensorsByDataType) {
const fn = customSensorsByDataType[dataType]
const sensors = fn(ipmiSensorsByDataType)
ipmiSensorsByDataType[dataType] = Array.isArray(sensors)
? sensors.map(sensor => ({ ...sensor, dataType }))
: { ...sensors, dataType }
}
}

View File

@@ -23,6 +23,7 @@
- [Export config] Hide passphrase by default (PR [#9824](https://github.com/vatesfr/xen-orchestra/pull/9824))
- [Backups] Refactor clean phase for incremental and full backups ([#9765](https://github.com/vatesfr/xen-orchestra/pull/9765))
- [REST API] Expose `DELETE /rest/v0/srs/:id` (PR [#9464](https://github.com/vatesfr/xen-orchestra/pull/9464))
- [xo-server-ipmi-sensors] Migrated the old ipmi feature to a new plugin and added user defined sensors (PR [#9724](https://github.com/vatesfr/xen-orchestra/pull/9724))
- [MCP] Read `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` so an internal XOA stays reachable when the AI assistant goes through a corporate proxy (PR [#9820](https://github.com/vatesfr/xen-orchestra/pull/9820))
### Bug fixes
@@ -64,6 +65,7 @@
- @xen-orchestra/web-core minor
- @xen-orchestra/xapi minor
- xo-server minor
- xo-server-ipmi-sensors major
- xo-web minor
<!--packages-end-->

View File

@@ -0,0 +1,33 @@
'use strict'
const { resolve } = require('path')
module.exports = {
ignorePatterns: ['src/*.d.ts'],
overrides: [
{
files: ['src/**/*.mts'],
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
project: './packages/xo-server-ipmi-sensors/tsconfig.json',
tsconfigRootDir: resolve(__dirname, '../..'),
},
plugins: ['@typescript-eslint'],
rules: {
// Native module requires file extensions
'n/no-missing-import': 'off',
'n/no-unsupported-features/es-syntax': 'off',
// Allow TypeScript-specific patterns
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
},
},
],
}

View File

@@ -0,0 +1 @@
../../scripts/npmignore

View File

@@ -0,0 +1,58 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# xo-server-ipmi-sensors
> XO Server plugin exposing and configuring IPMI sensors
## Usage
Provides access to IPMI (Intelligent Platform Management Interface) sensor data from XenServer/XCP-ng hosts.
Like all other xo-server plugins, it can be configured directly via
the web interface.
This plugin provides an API method (`ipmi-sensors.get_ipmi_sensors`) that retrieves and categorizes IPMI sensor data from supported hosts. Sensor data is automatically categorized by type (temperature, fan speed, power consumption, etc.) using configurable regex patterns for different server vendors.
**Features:**
- Automatic detection of supported server vendors (Dell, Lenovo, etc.)
- Configurable regex patterns for sensor categorization
- Support for multiple sensor data types: temperature, fan speed, power consumption, voltage, status indicators
- Custom computed sensors based on raw IPMI data
- Integration with XO's host management system
- Filtering of relevant sensors based on vendor-specific rules
**Supported sensor data types:**
- Total power consumption
- Inlet/outlet temperatures
- CPU temperatures
- Fan status and speed
- PSU (Power Supply Unit) status and power
- BMC (Baseboard Management Controller) status
- IP address information
## Configuration
The plugin supports configuration of vendor-specific regex patterns for sensor matching. Default configurations are provided for common server vendors including Dell, Lenovo, and others.
Configuration can be customized through the XO web interface to:
- Add support for new server vendors
- Modify sensor categorization rules
- Adjust regex patterns for specific hardware models
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[AGPL-3.0-or-later](https://spdx.org/licenses/AGPL-3.0-or-later) © [Vates SAS](https://vates.fr)

View File

@@ -0,0 +1,48 @@
{
"name": "xo-server-ipmi-sensors",
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"description": "XO Server plugin exposing and configuring IPMI sensors",
"type": "module",
"keywords": [
"xen-orchestra",
"xo-server",
"plugin",
"ipmi",
"monitoring"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-ipmi-sensors",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "packages/xo-server-ipmi-sensors",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"main": "dist/index.mjs",
"engines": {
"node": ">=20"
},
"dependencies": {
"@isaacs/ttlcache": "^1.4.1",
"@vates/types": "^1.22.0",
"@xen-orchestra/log": "^0.7.1"
},
"devDependencies": {
"@types/node": "^20.0.0",
"rimraf": "^6.0.1",
"typescript": "^5.0.0"
},
"scripts": {
"build": "tsc",
"clean": "rimraf dist/",
"dev": "yarn run clean && tsc --watch",
"prebuild": "yarn run clean",
"prepublishOnly": "yarn run build",
"test": "cd dist && node --test"
},
"private": true
}

View File

@@ -0,0 +1,61 @@
import { SensorRegexByProduct, IPMI_SENSOR_DATA_TYPE } from './types.mjs'
export const DEFAULT_IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME: SensorRegexByProduct[] = [
{
vendor: 'mona_1.44gg',
sensorRegexps: {
[IPMI_SENSOR_DATA_TYPE.totalPower]: /^total_power$/i,
[IPMI_SENSOR_DATA_TYPE.outletTemp]: /^outlet_temp$/i,
[IPMI_SENSOR_DATA_TYPE.bmcStatus]: /^bmc_status$/i,
[IPMI_SENSOR_DATA_TYPE.inletTemp]: /^psu_inlet_temp$/i,
[IPMI_SENSOR_DATA_TYPE.cpuTemp]: /^cpu[0-9]+_temp$/i,
[IPMI_SENSOR_DATA_TYPE.fanStatus]: /^fan[0-9]+_status$/i,
[IPMI_SENSOR_DATA_TYPE.fanSpeed]: /^fan[0-9]+_r_speed$/i,
[IPMI_SENSOR_DATA_TYPE.psuStatus]: /^psu[0-9]+_status$/i,
[IPMI_SENSOR_DATA_TYPE.psuPower]: /^psu[0-9]+_pin$/i,
[IPMI_SENSOR_DATA_TYPE.ip]: /^ip address$/i,
},
},
{
vendor: 'dell',
sensorRegexps: {
[IPMI_SENSOR_DATA_TYPE.fanSpeed]: /^fan[0-9]+(a|b)$/i,
[IPMI_SENSOR_DATA_TYPE.psuPower]: /^voltage [0-9]+$/i,
[IPMI_SENSOR_DATA_TYPE.psuStatus]: /^ps[0-9]+ pg fail$/i,
[IPMI_SENSOR_DATA_TYPE.cpuTemp]: /^temp$/i,
[IPMI_SENSOR_DATA_TYPE.totalPower]: /^pwr consumption$/i,
[IPMI_SENSOR_DATA_TYPE.inletTemp]: /^inlet temp$/i,
[IPMI_SENSOR_DATA_TYPE.ip]: /^ip address$/i,
},
},
{
vendor: 'lenovo',
sensorRegexps: {
[IPMI_SENSOR_DATA_TYPE.fanSpeed]: /^fan\s*\d+(?:\s+(?:front|rear))?\s+tach$/i,
[IPMI_SENSOR_DATA_TYPE.outletTemp]: /^exhaust temp$/i,
[IPMI_SENSOR_DATA_TYPE.psuPower]: /^psu\s*\d+\s+(ac in pwr|dc out pwr)$/i,
[IPMI_SENSOR_DATA_TYPE.psuStatus]:
/^(psu\s*\d+(?:\s+(?:failure|pf failure|in failure|i2c dc fai))?|power supply\s*\d+)$/i,
[IPMI_SENSOR_DATA_TYPE.cpuTemp]: /^cpu\s*\d+\s*temp$/i,
[IPMI_SENSOR_DATA_TYPE.totalPower]: /^(sys power|system power|power consumption)$/i,
[IPMI_SENSOR_DATA_TYPE.inletTemp]: /^(ambient temp|inlet temp)$/i,
[IPMI_SENSOR_DATA_TYPE.ip]: /^ip address$/i,
},
},
]
function stringifyRegexConfigFull(config: SensorRegexByProduct[]) {
return config.map(product => ({
vendor: product.vendor,
sensorRegexps: Object.fromEntries(
Object.entries(product.sensorRegexps).map(([key, value]) => [
key,
value instanceof RegExp ? value.toString() : value,
])
),
}))
}
export const DEFAULT_IPMI_SENSOR_REGEX_CONFIG_STRINGIFIED = stringifyRegexConfigFull(
DEFAULT_IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME
)

View File

@@ -0,0 +1,208 @@
/**
* XO Server IPMI Sensors Plugin
*
* This plugin exposes IPMI sensor data from XenServer/XCP-ng hosts.
* It collects vendor-specific sensor readings and categorizes them
* using configurable regex rules for supported products.
*/
import type { XoApp, XoHost } from '@vates/types'
import { createLogger } from '@xen-orchestra/log'
import { DEFAULT_IPMI_SENSOR_REGEX_CONFIG_STRINGIFIED } from './default-rules.mjs'
import { addIpmiSensorDataType, containsDigit, isRelevantIpmiSensor, parseRegexConfig } from './ipmi-rules.mjs'
import { addCustomIpmiSensors } from './legacy-computed-rules.mjs'
import {
IPMI_SENSOR_DATA_TYPE,
SensorRegexByProduct,
ReturnedSensorData,
FinalSensorData,
PluginConfiguration,
} from './types.mjs'
import TTLCache from '@isaacs/ttlcache'
// ============================================================================
// Constants
// ============================================================================
const IPMI_CACHE_TTL = 6e4
const logger = createLogger('xo:xo-server-ipmi-sensors')
// ============================================================================
// Configuration Schema (exported for xo-server)
// ============================================================================
export const sensorRegexProperties = Object.fromEntries(
Object.values(IPMI_SENSOR_DATA_TYPE).map(key => [key, { type: 'string', default: '' }])
)
export const configurationSchema = {
type: 'object',
properties: {
vendors: {
type: 'array',
title: 'Supported vendors',
description: 'List of supported product names for IPMI sensor regex matching',
default: DEFAULT_IPMI_SENSOR_REGEX_CONFIG_STRINGIFIED,
items: {
type: 'object',
title: 'Sensor regex by product',
description: 'Map of product name > sensor data type > regex pattern (string)',
properties: {
vendor: {
type: 'string',
default: 'generic',
},
sensorRegexps: {
type: 'object',
description: 'Map of sensor data type > regex pattern with format /pattern/flags (e.g. /^fan[0-9]+$/i)',
properties: sensorRegexProperties,
},
},
},
},
},
additionalProperties: false,
} as const
export const configurationPresets = {
default: {
vendors: DEFAULT_IPMI_SENSOR_REGEX_CONFIG_STRINGIFIED,
},
} as const
// ============================================================================
// Plugin Class
// ============================================================================
class IpmiSensorsPlugin {
#configuredRulesByProduct: SensorRegexByProduct[]
readonly #xo: XoApp
// cache is type any because it handles itself
#cache: TTLCache<any, any> = new TTLCache({
ttl: IPMI_CACHE_TTL,
max: 1000,
})
#unloadApiMethods: (() => void)[] = []
constructor(xo: XoApp) {
this.#xo = xo
this.#configuredRulesByProduct = []
logger.info('Plugin initialized')
}
/**
* Configure the plugin with the provided configuration.
*/
async configure(configuration: PluginConfiguration): Promise<void> {
this.#configuredRulesByProduct = parseRegexConfig(configuration.vendors)
logger.debug('Plugin configured')
}
/**
* Load and start the plugin.
*/
async load(): Promise<void> {
this.#unloadApiMethods.push(
this.#xo.addApiMethod<[{ host: XoHost }], FinalSensorData>(
'ipmi-sensors.get_ipmi_sensors',
this.getIpmiSensors.bind(this),
{
resolve: { host: ['id', 'host', 'administrate'] },
params: { id: { type: 'string' } },
}
),
// replacement for the method in packages/xo-server/src/api/host.mjs
this.#xo.addApiMethod<[{ host: XoHost }], FinalSensorData>(
'host.getIpmiSensors',
this.getIpmiSensors.bind(this),
{
resolve: { host: ['id', 'host', 'administrate'] },
params: { id: { type: 'string' } },
}
)
)
}
async getIpmiSensors({ host }: { host: XoHost }): Promise<FinalSensorData> {
const xApiHost = this.#xo.getXapiObject<XoHost>(host, 'host')
const biosStrings = xApiHost.bios_strings
let productName = biosStrings['system-product-name']?.toLowerCase() || ''
const systemManufacturer = biosStrings['system-manufacturer']?.toLowerCase() || ''
// Olivier.L request: consider all DELL and Lenovo servers in the same way
if (systemManufacturer.includes('dell')) productName = 'dell'
if (systemManufacturer.includes('lenovo')) productName = 'lenovo'
const data = this.#configuredRulesByProduct
const callIpmiPlugin = async <T,>(fn: string): Promise<T> => {
return await xApiHost.$xapi.call<T>(this.#cache, 'host.call_plugin', xApiHost.$ref, 'ipmitool.py', fn, {})
}
const ipmiDeviceAvailable = await callIpmiPlugin<string>('is_ipmi_device_available')
if (!data.some(s => s.vendor.toLowerCase() === productName) || ipmiDeviceAvailable === 'false') {
logger.info('No IPMI device available or no rules configured for this product, skipping IPMI sensor fetching', {
productName,
ipmiDeviceAvailable,
})
return {}
}
const [stringifiedIpmiSensors, stringifiedIpmiLan] = await Promise.all([
callIpmiPlugin<string>('get_all_sensors'),
callIpmiPlugin<string>('get_ipmi_lan'),
])
const ipmiSensors = JSON.parse(stringifiedIpmiSensors) as ReturnedSensorData[]
const ipmiLan = JSON.parse(stringifiedIpmiLan) as ReturnedSensorData[]
const ipmiSensorsByDataType: FinalSensorData = {}
for (const ipmiSensor of [...ipmiSensors, ...ipmiLan]) {
if (!isRelevantIpmiSensor(ipmiSensor, productName, this.#configuredRulesByProduct)) {
continue
}
addIpmiSensorDataType(ipmiSensor, productName, this.#configuredRulesByProduct)
const dataType = ipmiSensor.dataType
const sensorsByDataType = ipmiSensorsByDataType[dataType]
if (sensorsByDataType === undefined) {
ipmiSensorsByDataType[dataType] = containsDigit(ipmiSensor.name) ? [] : ipmiSensor
} else if (!Array.isArray(sensorsByDataType)) {
// it can happen various sensors have the same name (e.g. temp for dell cpu temp)
// in such case, consider it as an array instead of single value
ipmiSensorsByDataType[dataType] = [sensorsByDataType]
}
const bucket = ipmiSensorsByDataType[dataType]!
if (Array.isArray(bucket)) {
bucket.push(ipmiSensor)
}
}
addCustomIpmiSensors(ipmiSensorsByDataType, productName)
return ipmiSensorsByDataType
}
/**
* Unload and stop the plugin.
*/
async unload(): Promise<void> {
logger.info('Stopping IPMI sensors server')
for (const unloadApiMethod of this.#unloadApiMethods) {
unloadApiMethod()
}
this.#unloadApiMethods = []
}
}
// ============================================================================
// Plugin Factory (exported for xo-server)
// ============================================================================
interface PluginOptions {
xo: XoApp
}
function pluginFactory({ xo }: PluginOptions): IpmiSensorsPlugin {
return new IpmiSensorsPlugin(xo)
}
pluginFactory.configurationSchema = configurationSchema
pluginFactory.configurationPresets = configurationPresets
export default pluginFactory

View File

@@ -0,0 +1,62 @@
import {
SensorRegexByProduct,
SensorRegexByDataType,
ReturnedSensorData,
IPMI_SENSOR_DATA_TYPE_STRINGS,
IPMI_SENSOR_DATA_TYPE,
SensorRegexByProductRaw,
} from './types.mjs'
function getRulesForProduct(productName: string, rulesByProduct: SensorRegexByProduct[]): SensorRegexByDataType {
const productRules = rulesByProduct.find(rules => rules.vendor.toLowerCase() === productName.toLowerCase())
return productRules ? productRules.sensorRegexps : {}
}
export function isRelevantIpmiSensor(
data: ReturnedSensorData,
productName: string,
configuredRules: SensorRegexByProduct[]
): boolean {
const rules = getRulesForProduct(productName, configuredRules)
return Object.values(rules).some(regex => regex && regex.test(data.name))
}
export function addIpmiSensorDataType(
data: ReturnedSensorData,
productName: string,
configuredRules: SensorRegexByProduct[]
): asserts data is ReturnedSensorData & { dataType: IPMI_SENSOR_DATA_TYPE_STRINGS } {
const rules = getRulesForProduct(productName, configuredRules)
for (const [name, regex] of Object.entries(rules)) {
if (regex && regex.test(data.name)) {
data.dataType = name as IPMI_SENSOR_DATA_TYPE_STRINGS
return
}
}
data.dataType = IPMI_SENSOR_DATA_TYPE.unknown as IPMI_SENSOR_DATA_TYPE_STRINGS
}
export function containsDigit(str: string): boolean {
return /\d/.test(str)
}
function parseRegex(str: string): RegExp | undefined {
if (str === '') return undefined
const match = str.match(/^\/(.+)\/([a-z]*)$/i)
if (!match) throw new Error(`Invalid regex string: ${str}`)
const [, pattern, flags] = match
if (pattern === undefined) throw new Error(`Invalid regex string: ${str}`)
return new RegExp(pattern, flags)
}
export function parseRegexConfig(config: SensorRegexByProductRaw[]): SensorRegexByProduct[] {
return config.map(product => ({
vendor: product.vendor,
sensorRegexps: Object.fromEntries(
Object.entries(product.sensorRegexps).map(([key, value]) => [
key,
typeof value === 'string' ? parseRegex(value) : undefined,
])
) as SensorRegexByDataType,
}))
}

View File

@@ -0,0 +1,106 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { addIpmiSensorDataType, isRelevantIpmiSensor, parseRegexConfig } from './ipmi-rules.mjs'
import { DEFAULT_IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME } from './default-rules.mjs'
import { SensorRegexByProductRaw, ReturnedSensorData, IPMI_SENSOR_DATA_TYPE } from './types.mjs'
describe('ipmi-rules', () => {
describe('parseRegexConfig', () => {
it('should parse raw config into RegExp config', () => {
const rawConfig: SensorRegexByProductRaw[] = [
{
vendor: 'test',
sensorRegexps: {
fanSpeed: '/^fan[0-9]+$/i',
cpuTemp: '/^cpu_temp$/',
},
},
]
const parsed = parseRegexConfig(rawConfig)
assert.strictEqual(parsed.length, 1)
const firstProduct = parsed[0]!
assert.strictEqual(firstProduct.vendor, 'test')
assert(firstProduct.sensorRegexps.fanSpeed instanceof RegExp)
assert.strictEqual(firstProduct.sensorRegexps.fanSpeed?.source, '^fan[0-9]+$')
assert.strictEqual(firstProduct.sensorRegexps.fanSpeed?.flags, 'i')
assert(firstProduct.sensorRegexps.cpuTemp instanceof RegExp)
assert.strictEqual(firstProduct.sensorRegexps.cpuTemp?.source, '^cpu_temp$')
})
it('should handle undefined regex values', () => {
const rawConfig: SensorRegexByProductRaw[] = [
{
vendor: 'test',
sensorRegexps: {
fanSpeed: undefined,
},
},
]
const parsed = parseRegexConfig(rawConfig)
const firstProduct = parsed[0]!
assert.strictEqual(firstProduct.sensorRegexps.fanSpeed, undefined)
})
})
describe('isRelevantIpmiSensor', () => {
const mockRules = DEFAULT_IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME
it('should return true for relevant sensors', () => {
const sensor: ReturnedSensorData = { name: 'fan1_r_speed', value: '1000', event: 'ok' }
assert.strictEqual(isRelevantIpmiSensor(sensor, 'mona_1.44gg', mockRules), true)
})
it('should return false for irrelevant sensors', () => {
const sensor: ReturnedSensorData = { name: 'unknown_sensor', value: '0', event: 'ok' }
assert.strictEqual(isRelevantIpmiSensor(sensor, 'mona_1.44gg', mockRules), false)
})
it('should return false for unknown product', () => {
const sensor: ReturnedSensorData = { name: 'fan1_r_speed', value: '1000', event: 'ok' }
assert.strictEqual(isRelevantIpmiSensor(sensor, 'unknown', mockRules), false)
})
it('should be case insensitive for product name', () => {
const sensor: ReturnedSensorData = { name: 'fan1_r_speed', value: '1000', event: 'ok' }
assert.strictEqual(isRelevantIpmiSensor(sensor, 'MONA_1.44GG', mockRules), true)
})
})
describe('addIpmiSensorDataType', () => {
const mockRules = DEFAULT_IPMI_SENSOR_REGEX_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME
it('should add correct dataType for matching sensors', () => {
const sensor: ReturnedSensorData = { name: 'fan1_r_speed', value: '1000', event: 'ok' }
addIpmiSensorDataType(sensor, 'mona_1.44gg', mockRules)
assert.strictEqual(sensor.dataType, IPMI_SENSOR_DATA_TYPE.fanSpeed)
})
it('should add unknown dataType for non-matching sensors', () => {
const sensor: ReturnedSensorData = { name: 'unknown_sensor', value: '0', event: 'ok' }
addIpmiSensorDataType(sensor, 'mona_1.44gg', mockRules)
assert.strictEqual(sensor.dataType, IPMI_SENSOR_DATA_TYPE.unknown)
})
it('should add unknown dataType for unknown product', () => {
const sensor: ReturnedSensorData = { name: 'fan1_r_speed', value: '1000', event: 'ok' }
addIpmiSensorDataType(sensor, 'unknown', mockRules)
assert.strictEqual(sensor.dataType, IPMI_SENSOR_DATA_TYPE.unknown)
})
it('should match first regex in order', () => {
// For dell, both fanSpeed and psuPower could potentially match, but fanSpeed comes first
const sensor: ReturnedSensorData = { name: 'fan1a', value: '1000', event: 'ok' }
addIpmiSensorDataType(sensor, 'dell', mockRules)
assert.strictEqual(sensor.dataType, IPMI_SENSOR_DATA_TYPE.fanSpeed)
})
it('should handle case insensitive matching', () => {
const sensor: ReturnedSensorData = { name: 'FAN1_R_SPEED', value: '1000', event: 'ok' }
addIpmiSensorDataType(sensor, 'mona_1.44gg', mockRules)
assert.strictEqual(sensor.dataType, IPMI_SENSOR_DATA_TYPE.fanSpeed)
})
})
})

View File

@@ -0,0 +1,49 @@
import { FinalSensorData, IPMI_SENSOR_DATA_TYPE_STRINGS, IPMI_SENSOR_DATA_TYPE, ReturnedSensorData } from './types.mjs'
type CUSTOM_SENSOR_BY_DATA_TYPE_FN = (
ipmiSensorsByDataType: FinalSensorData
) => Partial<ReturnedSensorData | ReturnedSensorData[]>
type CUSTOM_SENSOR_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME = Record<
string,
Partial<Record<IPMI_SENSOR_DATA_TYPE_STRINGS, CUSTOM_SENSOR_BY_DATA_TYPE_FN>>
>
export const IPMI_SENSOR_CUSTOM_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME: CUSTOM_SENSOR_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME =
{
dell: {
[IPMI_SENSOR_DATA_TYPE.bmcStatus]: (ipmiSensorsByDataType: FinalSensorData) => ({
event: Object.keys(ipmiSensorsByDataType).length > 0 ? 'ok' : 'ko',
name: 'bmc status',
value: '0x00',
}),
[IPMI_SENSOR_DATA_TYPE.fanStatus]: (ipmiSensorsByDataType: FinalSensorData) =>
ipmiSensorsByDataType[IPMI_SENSOR_DATA_TYPE.fanSpeed as IPMI_SENSOR_DATA_TYPE_STRINGS],
},
lenovo: {
[IPMI_SENSOR_DATA_TYPE.bmcStatus]: (ipmiSensorsByDataType: FinalSensorData) => ({
event: Object.keys(ipmiSensorsByDataType).length > 0 ? 'ok' : 'ko',
name: 'bmc status',
value: '0x00',
}),
[IPMI_SENSOR_DATA_TYPE.fanStatus]: (ipmiSensorsByDataType: FinalSensorData) => {
let fanStatuses = ipmiSensorsByDataType[IPMI_SENSOR_DATA_TYPE.fanSpeed as IPMI_SENSOR_DATA_TYPE_STRINGS] ?? []
if (!Array.isArray(fanStatuses)) fanStatuses = [fanStatuses]
return fanStatuses
.filter((s: { event: string; value: string }) => s.event === 'ok' && /\d+\s*rpm/i.test(s.value))
.map((s: { event: string; value: string }) => ({ ...s, rpm: parseInt(s.value, 10) }))
},
},
}
export const addCustomIpmiSensors = (ipmiSensorsByDataType: FinalSensorData, productName: string) => {
const customSensorsByDataType = IPMI_SENSOR_CUSTOM_BY_DATA_TYPE_BY_SUPPORTED_PRODUCT_NAME[productName]
for (const dataType in customSensorsByDataType) {
const dataTypeKey = dataType as IPMI_SENSOR_DATA_TYPE_STRINGS
const fn = customSensorsByDataType[dataTypeKey]! // we know this exists since we're iterating over the keys of customSensorsByDataType
const sensors = fn(ipmiSensorsByDataType)
const parsedSensor = Array.isArray(sensors)
? sensors.map(sensor => ({ ...sensor, dataType }))
: { ...sensors, dataType }
ipmiSensorsByDataType[dataTypeKey] = parsedSensor as FinalSensorData[IPMI_SENSOR_DATA_TYPE_STRINGS]
}
}

View File

@@ -0,0 +1,46 @@
// ============================================================================
// Types
// ============================================================================
export const IPMI_SENSOR_DATA_TYPE = {
totalPower: 'totalPower',
outletTemp: 'outletTemp',
bmcStatus: 'bmcStatus',
inletTemp: 'inletTemp',
cpuTemp: 'cpuTemp',
fanStatus: 'fanStatus',
fanSpeed: 'fanSpeed',
psuStatus: 'psuStatus',
psuPower: 'psuPower',
ip: 'ip',
unknown: 'unknown',
}
export type IPMI_SENSOR_DATA_TYPE_STRINGS = keyof typeof IPMI_SENSOR_DATA_TYPE
export type SensorRegexByDataType = {
[key in keyof typeof IPMI_SENSOR_DATA_TYPE]?: RegExp
}
export type SensorRegexByDataTypeRaw = {
[key in keyof typeof IPMI_SENSOR_DATA_TYPE]?: string
}
export type SensorRegexByProductRaw = {
vendor: string
sensorRegexps: SensorRegexByDataTypeRaw
}
export interface PluginConfiguration {
vendors: SensorRegexByProductRaw[]
}
export type SensorRegexByProduct = {
vendor: string
sensorRegexps: SensorRegexByDataType
}
export type ReturnedSensorData = {
name: string
value: string
event: string
dataType?: IPMI_SENSOR_DATA_TYPE_STRINGS
}
export type FinalSensorData = Partial<Record<IPMI_SENSOR_DATA_TYPE_STRINGS, ReturnedSensorData | ReturnedSensorData[]>>

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/*.mts", "src/*.d.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -17,11 +17,6 @@ import { debounceWithKey } from '../_pDebounceWithKey.mjs'
/** @typedef {import('@vates/types').XoPif} XoPif */
const CERT_PUBKEY_MIN_SIZE = 2048
const IPMI_CACHE_TTL = 6e4
const IPMI_CACHE = new TTLCache({
ttl: IPMI_CACHE_TTL,
max: 1000,
})
const CACHE_2CRSI = new TTLCache({
ttl: 6e4,
@@ -667,16 +662,7 @@ getBlockdevices.params = {
getBlockdevices.resolve = {
host: ['id', 'host', 'administrate'],
}
export function getIpmiSensors({ host }) {
return this.getXapi(host).host_getIpmiSensors(host._xapiRef, { cache: IPMI_CACHE })
}
getIpmiSensors.params = {
id: { type: 'string' },
}
getIpmiSensors.resolve = {
host: ['id', 'host', 'administrate'],
}
// host.getIpmiSensors moved to packages/xo-server-ipmi-sensors/src/index.mts
export function getBiosInfo({ host }) {
return this.getXapi(host).getHostBiosInfo(host._xapiRef, { cache: CACHE_2CRSI })

View File

@@ -695,7 +695,7 @@ export const subscribeIpmiSensors = host => {
subscribeHostsIpmiSensors[hostId] = createSubscription(
async () =>
_isAdmin
? await _call('host.getIpmiSensors', {
? await _call('ipmi-sensors.get_ipmi_sensors', {
id: hostId,
})
: undefined,

View File

@@ -55,10 +55,33 @@ class Plugin extends Component {
_getUiSchema = createSelector(() => this.props.configurationSchema, generateUiSchema)
getSchemaDefaults(schema) {
if (schema?.type !== 'object' || !schema.properties) {
return undefined
}
const result = {}
let hasDefaults = false
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (propSchema.default !== undefined) {
result[key] = propSchema.default
hasDefaults = true
} else if (propSchema.type === 'object') {
const nested = this.getSchemaDefaults(propSchema)
if (nested !== undefined) {
result[key] = nested
hasDefaults = true
}
}
}
return hasDefaults ? result : undefined
}
_updateExpanded = () => {
this.setState({
expanded: !this.state.expanded,
})
const newExpanded = !this.state.expanded
const editedConfig =
newExpanded && !this.props.configuration && this.state.editedConfig === undefined
? this.getSchemaDefaults(this.props.configurationSchema)
: this.state.editedConfig
this.setState({ expanded: newExpanded, editedConfig })
}
_setAutoload = event => {