mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
518 lines
13 KiB
TypeScript
518 lines
13 KiB
TypeScript
import { synchronized } from "decorator-synchronized";
|
|
import JSON5 from "json5";
|
|
import { limitConcurrency } from "limit-concurrency-decorator";
|
|
import { defaults, findKey, forEach, identity, map } from "lodash-es";
|
|
import { BaseError } from "make-error";
|
|
import type XenApi from "@/libs/xen-api/xen-api";
|
|
import type { XenApiHost } from "@/libs/xen-api/xen-api.types";
|
|
|
|
class FaultyGranularity extends BaseError {}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
// according to https://xapi-project.github.io/xen-api/metrics.html
|
|
// The values are stored at intervals of:
|
|
// - 5 seconds for the past 10 minutes
|
|
// - one minute for the past 2 hours
|
|
// - one hour for the past week
|
|
// - one day for the past year
|
|
enum RRD_STEP {
|
|
Seconds = 5,
|
|
Minutes = 60,
|
|
Hours = 3600,
|
|
Days = 86400,
|
|
}
|
|
|
|
export enum GRANULARITY {
|
|
Seconds = "seconds",
|
|
Minutes = "minutes",
|
|
Hours = "hours",
|
|
Days = "days",
|
|
}
|
|
|
|
export const RRD_STEP_FROM_STRING: { [key in GRANULARITY]: RRD_STEP } = {
|
|
[GRANULARITY.Seconds]: RRD_STEP.Seconds,
|
|
[GRANULARITY.Minutes]: RRD_STEP.Minutes,
|
|
[GRANULARITY.Hours]: RRD_STEP.Hours,
|
|
[GRANULARITY.Days]: RRD_STEP.Days,
|
|
};
|
|
|
|
// points = intervalInSeconds / step
|
|
const RRD_POINTS_PER_STEP: { [key in RRD_STEP]: number } = {
|
|
[RRD_STEP.Seconds]: 120,
|
|
[RRD_STEP.Minutes]: 120,
|
|
[RRD_STEP.Hours]: 168,
|
|
[RRD_STEP.Days]: 366,
|
|
};
|
|
|
|
// -------------------------------------------------------------------
|
|
// Utils
|
|
// -------------------------------------------------------------------
|
|
|
|
function convertNanToNull(value: number) {
|
|
return isNaN(value) ? null : value;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Stats
|
|
// -------------------------------------------------------------------
|
|
|
|
const computeValues = (
|
|
dataRow: any,
|
|
legendIndex: number,
|
|
transformValue = identity
|
|
) =>
|
|
map(dataRow, ({ values }) =>
|
|
transformValue(convertNanToNull(values[legendIndex]))
|
|
);
|
|
|
|
const createGetProperty = (
|
|
obj: object,
|
|
property: string,
|
|
defaultValue: unknown
|
|
) => defaults(obj, { [property]: defaultValue })[property] as any;
|
|
|
|
const testMetric = (
|
|
test:
|
|
| string
|
|
| { exec: (type: string) => boolean }
|
|
| { (type: string): boolean },
|
|
type: string
|
|
): boolean =>
|
|
typeof test === "string"
|
|
? test === type
|
|
: typeof test === "function"
|
|
? test(type)
|
|
: test.exec(type);
|
|
|
|
const findMetric = (metrics: any, metricType: string) => {
|
|
let testResult;
|
|
let metric;
|
|
|
|
forEach(metrics, (current) => {
|
|
if (current.test === undefined) {
|
|
const newValues = findMetric(current, metricType);
|
|
|
|
metric = newValues.metric;
|
|
if (metric !== undefined) {
|
|
testResult = newValues.testResult;
|
|
return false;
|
|
}
|
|
} else if ((testResult = testMetric(current.test, metricType))) {
|
|
metric = current;
|
|
return false;
|
|
}
|
|
});
|
|
|
|
return { metric, testResult };
|
|
};
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
// The metrics:
|
|
// test: can be a function, regexp or string, default to: currentKey
|
|
// getPath: default to: () => currentKey
|
|
// transformValue: default to: identity
|
|
const STATS: { [key: string]: object } = {
|
|
host: {
|
|
load: {
|
|
test: "loadavg",
|
|
},
|
|
memoryFree: {
|
|
test: "memory_free_kib",
|
|
transformValue: (value: number) => value * 1024,
|
|
},
|
|
memory: {
|
|
test: "memory_total_kib",
|
|
transformValue: (value: number) => value * 1024,
|
|
},
|
|
cpus: {
|
|
test: /^cpu(\d+)$/,
|
|
getPath: (matches: any) => ["cpus", matches[1]],
|
|
transformValue: (value: number) => value * 1e2,
|
|
},
|
|
pifs: {
|
|
rx: {
|
|
test: /^pif_eth(\d+)_rx$/,
|
|
getPath: (matches: unknown[]) => ["pifs", "rx", matches[1]],
|
|
},
|
|
tx: {
|
|
test: /^pif_eth(\d+)_tx$/,
|
|
getPath: (matches: unknown[]) => ["pifs", "tx", matches[1]],
|
|
},
|
|
},
|
|
iops: {
|
|
r: {
|
|
test: /^iops_read_(\w+)$/,
|
|
getPath: (matches: unknown[]) => ["iops", "r", matches[1]],
|
|
},
|
|
w: {
|
|
test: /^iops_write_(\w+)$/,
|
|
getPath: (matches: unknown[]) => ["iops", "w", matches[1]],
|
|
},
|
|
},
|
|
ioThroughput: {
|
|
r: {
|
|
test: /^io_throughput_read_(\w+)$/,
|
|
getPath: (matches: unknown[]) => ["ioThroughput", "r", matches[1]],
|
|
transformValue: (value: number) => value * 2 ** 20,
|
|
},
|
|
w: {
|
|
test: /^io_throughput_write_(\w+)$/,
|
|
getPath: (matches: unknown[]) => ["ioThroughput", "w", matches[1]],
|
|
transformValue: (value: number) => value * 2 ** 20,
|
|
},
|
|
},
|
|
latency: {
|
|
r: {
|
|
test: /^read_latency_(\w+)$/,
|
|
getPath: (matches: unknown[]) => ["latency", "r", matches[1]],
|
|
transformValue: (value: number) => value / 1e3,
|
|
},
|
|
w: {
|
|
test: /^write_latency_(\w+)$/,
|
|
getPath: (matches: unknown[]) => ["latency", "w", matches[1]],
|
|
transformValue: (value: number) => value / 1e3,
|
|
},
|
|
},
|
|
iowait: {
|
|
test: /^iowait_(\w+)$/,
|
|
getPath: (matches: unknown[]) => ["iowait", matches[1]],
|
|
},
|
|
},
|
|
vm: {
|
|
memoryFree: {
|
|
test: "memory_internal_free",
|
|
transformValue: (value: number) => value * 1024,
|
|
},
|
|
memory: {
|
|
test: (metricType: string) => metricType.endsWith("memory"),
|
|
},
|
|
cpus: {
|
|
test: /^cpu(\d+)$/,
|
|
getPath: (matches: unknown[]) => ["cpus", matches[1]],
|
|
transformValue: (value: number) => value * 1e2,
|
|
},
|
|
vifs: {
|
|
rx: {
|
|
test: /^vif_(\d+)_rx$/,
|
|
getPath: (matches: unknown[]) => ["vifs", "rx", matches[1]],
|
|
},
|
|
tx: {
|
|
test: /^vif_(\d+)_tx$/,
|
|
getPath: (matches: unknown[]) => ["vifs", "tx", matches[1]],
|
|
},
|
|
},
|
|
xvds: {
|
|
r: {
|
|
test: /^vbd_xvd(.)_read$/,
|
|
getPath: (matches: unknown[]) => ["xvds", "r", matches[1]],
|
|
},
|
|
w: {
|
|
test: /^vbd_xvd(.)_write$/,
|
|
getPath: (matches: unknown[]) => ["xvds", "w", matches[1]],
|
|
},
|
|
},
|
|
iops: {
|
|
r: {
|
|
test: /^vbd_xvd(.)_iops_read$/,
|
|
getPath: (matches: unknown[]) => ["iops", "r", matches[1]],
|
|
},
|
|
w: {
|
|
test: /^vbd_xvd(.)_iops_write$/,
|
|
getPath: (matches: unknown[]) => ["iops", "w", matches[1]],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
// RRD
|
|
// json: {
|
|
// meta: {
|
|
// start: Number,
|
|
// step: Number,
|
|
// end: Number,
|
|
// rows: Number,
|
|
// columns: Number,
|
|
// legend: String[rows]
|
|
// },
|
|
// data: Item[columns] // Item = { t: Number, values: Number[rows] }
|
|
// }
|
|
|
|
// Local cache
|
|
// _statsByObject : {
|
|
// [uuid]: {
|
|
// [step]: {
|
|
// endTimestamp: Number, // the timestamp of the last statistic point
|
|
// interval: Number, // step
|
|
// stats: T
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
export type VmStats = {
|
|
cpus: Record<string, number[]>;
|
|
iops: {
|
|
r: Record<string, number[]>;
|
|
w: Record<string, number[]>;
|
|
};
|
|
memory: number[];
|
|
memoryFree?: number[];
|
|
vifs: {
|
|
rx: Record<string, number[]>;
|
|
tx: Record<string, number[]>;
|
|
};
|
|
xvds: {
|
|
w: Record<string, number[]>;
|
|
r: Record<string, number[]>;
|
|
};
|
|
};
|
|
|
|
export type HostStats = {
|
|
cpus: Record<string, number[]>;
|
|
ioThroughput: {
|
|
r: Record<string, number[]>;
|
|
w: Record<string, number[]>;
|
|
};
|
|
iops: {
|
|
r: Record<string, number[]>;
|
|
w: Record<string, number[]>;
|
|
};
|
|
iowait: Record<string, number[]>;
|
|
latency: {
|
|
r: Record<string, number[]>;
|
|
w: Record<string, number[]>;
|
|
};
|
|
load: number[];
|
|
memory: number[];
|
|
memoryFree: number[];
|
|
pifs: {
|
|
rx: Record<string, number[]>;
|
|
tx: Record<string, number[]>;
|
|
};
|
|
};
|
|
|
|
export type XapiStatsResponse<T> = {
|
|
canBeExpired: boolean;
|
|
endTimestamp: number;
|
|
interval: number;
|
|
stats: T;
|
|
};
|
|
|
|
type StatsByObject = {
|
|
[uuid: string]: {
|
|
[step: string]: XapiStatsResponse<HostStats | VmStats>;
|
|
};
|
|
};
|
|
|
|
export default class XapiStats {
|
|
#xapi;
|
|
#statsByObject: StatsByObject = {};
|
|
#cachedStatsByObject: StatsByObject = {};
|
|
constructor(xapi: XenApi) {
|
|
this.#xapi = xapi;
|
|
}
|
|
|
|
// Execute one http request on a XenServer for get stats
|
|
// Return stats (Json format) or throws got exception
|
|
@limitConcurrency(3)
|
|
async _getJson(
|
|
host: XenApiHost,
|
|
timestamp: number,
|
|
step: RRD_STEP,
|
|
{ abortSignal }: { abortSignal?: AbortSignal } = {}
|
|
) {
|
|
const resp = await this.#xapi.getResource("/rrd_updates", {
|
|
host,
|
|
query: {
|
|
cf: "AVERAGE",
|
|
host: "true",
|
|
interval: step,
|
|
json: "true",
|
|
start: timestamp,
|
|
},
|
|
abortSignal,
|
|
});
|
|
return JSON5.parse(await resp.text());
|
|
}
|
|
|
|
// To avoid multiple requests, we keep a cache for the stats and
|
|
// only return it if we not exceed a step
|
|
#getCachedStats(
|
|
uuid: string,
|
|
step: RRD_STEP,
|
|
currentTimeStamp: number,
|
|
ignoreExpired = false
|
|
) {
|
|
if (ignoreExpired) {
|
|
return this.#cachedStatsByObject[uuid]?.[step];
|
|
}
|
|
|
|
const statsByObject = this.#statsByObject;
|
|
|
|
const stats = statsByObject[uuid]?.[step];
|
|
if (stats === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (stats.endTimestamp + step < currentTimeStamp) {
|
|
delete statsByObject[uuid][step];
|
|
return;
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
@synchronized.withKey(({ host }: { host: XenApiHost }) => host.uuid)
|
|
async _getAndUpdateStats<T extends VmStats | HostStats>({
|
|
abortSignal,
|
|
host,
|
|
ignoreExpired = false,
|
|
uuid,
|
|
granularity,
|
|
}: {
|
|
abortSignal?: AbortSignal;
|
|
host: XenApiHost;
|
|
ignoreExpired?: boolean;
|
|
uuid: any;
|
|
granularity: GRANULARITY;
|
|
}) {
|
|
const step =
|
|
granularity === undefined
|
|
? RRD_STEP.Seconds
|
|
: RRD_STEP_FROM_STRING[granularity];
|
|
if (step === undefined) {
|
|
throw new FaultyGranularity(
|
|
`Unknown granularity: '${granularity}'. Use 'seconds', 'minutes', 'hours', or 'days'.`
|
|
);
|
|
}
|
|
const currentTimeStamp = Math.floor(new Date().getTime() / 1000);
|
|
|
|
const stats = this.#getCachedStats(
|
|
uuid,
|
|
step,
|
|
currentTimeStamp,
|
|
ignoreExpired
|
|
) as XapiStatsResponse<T>;
|
|
|
|
if (stats !== undefined) {
|
|
return stats;
|
|
}
|
|
|
|
const maxDuration = step * RRD_POINTS_PER_STEP[step];
|
|
|
|
// To avoid crossing over the boundary, we ask for one less step
|
|
const optimumTimestamp = currentTimeStamp - maxDuration + step;
|
|
|
|
try {
|
|
const json = await this._getJson(host, optimumTimestamp, step, {
|
|
abortSignal,
|
|
});
|
|
|
|
const actualStep = json.meta.step as number;
|
|
|
|
if (json.data.length > 0) {
|
|
// fetched data is organized from the newest to the oldest
|
|
// but this implementation requires it in the other direction
|
|
json.data.reverse();
|
|
json.meta.legend.forEach((legend: any, index: number) => {
|
|
const [, type, uuid, metricType] = /^AVERAGE:([^:]+):(.+):(.+)$/.exec(
|
|
legend
|
|
) as any;
|
|
|
|
const metrics = STATS[type] as any;
|
|
if (metrics === undefined) {
|
|
return;
|
|
}
|
|
|
|
const { metric, testResult } = findMetric(metrics, metricType) as any;
|
|
if (metric === undefined) {
|
|
return;
|
|
}
|
|
|
|
const xoObjectStats = createGetProperty(
|
|
this.#statsByObject,
|
|
uuid,
|
|
{}
|
|
);
|
|
const cacheXoObjectStats = createGetProperty(
|
|
this.#cachedStatsByObject,
|
|
uuid,
|
|
{}
|
|
);
|
|
|
|
let stepStats = xoObjectStats[actualStep];
|
|
let cacheStepStats = cacheXoObjectStats[actualStep];
|
|
if (
|
|
stepStats === undefined ||
|
|
stepStats.endTimestamp !== json.meta.end
|
|
) {
|
|
stepStats = xoObjectStats[actualStep] = {
|
|
endTimestamp: json.meta.end,
|
|
interval: actualStep,
|
|
canBeExpired: false,
|
|
};
|
|
cacheStepStats = cacheXoObjectStats[actualStep] = {
|
|
endTimestamp: json.meta.end,
|
|
interval: actualStep,
|
|
canBeExpired: true,
|
|
};
|
|
}
|
|
|
|
const path =
|
|
metric.getPath !== undefined
|
|
? metric.getPath(testResult)
|
|
: [findKey(metrics, metric)];
|
|
|
|
const lastKey = path.length - 1;
|
|
let metricStats = createGetProperty(stepStats, "stats", {});
|
|
let cacheMetricStats = createGetProperty(cacheStepStats, "stats", {});
|
|
|
|
path.forEach((property: any, key: number) => {
|
|
if (key === lastKey) {
|
|
metricStats[property] = computeValues(
|
|
json.data,
|
|
index,
|
|
metric.transformValue
|
|
);
|
|
cacheMetricStats[property] = computeValues(
|
|
json.data,
|
|
index,
|
|
metric.transformValue
|
|
);
|
|
return;
|
|
}
|
|
|
|
metricStats = createGetProperty(metricStats, property, {});
|
|
cacheMetricStats = createGetProperty(
|
|
cacheMetricStats,
|
|
property,
|
|
{}
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
if (actualStep !== step) {
|
|
throw new FaultyGranularity(
|
|
`Unable to get the true granularity: ${actualStep}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
return;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
return (this.#statsByObject[uuid]?.[step] ?? {
|
|
endTimestamp: currentTimeStamp,
|
|
interval: step,
|
|
stats: {},
|
|
}) as XapiStatsResponse<T>;
|
|
}
|
|
}
|