From a8ba4a1a8e4d8c1a72c15c9461bcac9606410712 Mon Sep 17 00:00:00 2001 From: badrAZ Date: Mon, 16 Apr 2018 14:40:00 +0200 Subject: [PATCH] feat(xo-web): stats for SRs (#2847) --- packages/xo-server/src/api/sr.js | 20 ++ packages/xo-server/src/xapi-stats.js | 99 +++++++++- .../xo-server/src/xo-mixins/xen-servers.js | 4 + packages/xo-web/src/common/intl/messages.js | 8 + packages/xo-web/src/common/utils.js | 13 ++ .../xo-web/src/common/xo-line-chart/index.js | 178 +++++++++++++++++- packages/xo-web/src/common/xo/index.js | 3 + packages/xo-web/src/icons.scss | 12 ++ packages/xo-web/src/xo-app/sr/index.js | 15 +- packages/xo-web/src/xo-app/sr/tab-stats.js | 165 ++++++++++++++++ 10 files changed, 508 insertions(+), 9 deletions(-) create mode 100644 packages/xo-web/src/xo-app/sr/tab-stats.js diff --git a/packages/xo-server/src/api/sr.js b/packages/xo-server/src/api/sr.js index bd546d956..a447a302c 100644 --- a/packages/xo-server/src/api/sr.js +++ b/packages/xo-server/src/api/sr.js @@ -838,3 +838,23 @@ getUnhealthyVdiChainsLength.params = { getUnhealthyVdiChainsLength.resolve = { sr: ['id', 'SR', 'operate'], } + +// ------------------------------------------------------------------- + +export function stats ({ sr, granularity }) { + return this.getXapiSrStats(sr._xapiId, granularity) +} + +stats.description = 'returns statistic of the sr' + +stats.params = { + id: { type: 'string' }, + granularity: { + type: 'string', + optional: true, + }, +} + +stats.resolve = { + sr: ['id', 'SR', 'view'], +} diff --git a/packages/xo-server/src/xapi-stats.js b/packages/xo-server/src/xapi-stats.js index 7543a3747..7b7543e70 100644 --- a/packages/xo-server/src/xapi-stats.js +++ b/packages/xo-server/src/xapi-stats.js @@ -1,7 +1,19 @@ import JSON5 from 'json5' import limitConcurrency from 'limit-concurrency-decorator' import { BaseError } from 'make-error' -import { endsWith, findKey, forEach, get, identity, map } from 'lodash' +import { + endsWith, + findKey, + forEach, + get, + identity, + map, + mapValues, + mean, + sum, + uniq, + zipWith, +} from 'lodash' import { parseDateTime } from './xapi' @@ -62,6 +74,9 @@ const computeValues = (dataRow, legendIndex, transformValue = identity) => transformValue(convertNanToNull(values[legendIndex])) ) +const combineStats = (stats, path, combineValues) => + zipWith(...map(stats, path), (...values) => combineValues(values)) + // It browse the object in depth and initialise it's properties // The targerPath can be a string or an array containing the depth // targetPath: [a, b, c] => a.b.c @@ -141,6 +156,45 @@ const STATS = { getPath: matches => ['pifs', 'tx', matches[1]], }, }, + iops: { + r: { + test: /^iops_read_(\w+)$/, + getPath: matches => ['iops', 'r', matches[1]], + }, + w: { + test: /^iops_write_(\w+)$/, + getPath: matches => ['iops', 'w', matches[1]], + }, + }, + ioThroughput: { + r: { + test: /^io_throughput_read_(\w+)$/, + getPath: matches => ['ioThroughput', 'r', matches[1]], + transformValue: value => value * 2 ** 20, + }, + w: { + test: /^io_throughput_write_(\w+)$/, + getPath: matches => ['ioThroughput', 'w', matches[1]], + transformValue: value => value * 2 ** 20, + }, + }, + latency: { + r: { + test: /^read_latency_(\w+)$/, + getPath: matches => ['latency', 'r', matches[1]], + transformValue: value => value / 1e3, + }, + w: { + test: /^write_latency_(\w+)$/, + getPath: matches => ['latency', 'w', matches[1]], + transformValue: value => value / 1e3, + }, + }, + iowait: { + test: /^iowait_(\w+)$/, + getPath: matches => ['iowait', matches[1]], + transformValue: value => value * 1e2, + }, }, vm: { memoryFree: { @@ -361,4 +415,47 @@ export default class XapiStats { granularity, }) } + + async getSrStats (xapi, srId, granularity) { + const sr = xapi.getObject(srId) + + const hostsStats = {} + await Promise.all( + map(uniq(map(sr.$PBDs, 'host')), hostId => + this.getHostStats(xapi, hostId, granularity).then(stats => { + hostsStats[xapi.getObject(hostId).name_label] = stats + }) + ) + ) + + const srShortUUID = sr.uuid.slice(0, 8) + return { + interval: hostsStats[Object.keys(hostsStats)[0]].interval, + endTimestamp: Math.max(...map(hostsStats, 'endTimestamp')), + localTimestamp: Math.min(...map(hostsStats, 'localTimestamp')), + stats: { + iops: { + r: combineStats(hostsStats, `stats.iops.r[${srShortUUID}]`, sum), + w: combineStats(hostsStats, `stats.iops.w[${srShortUUID}]`, sum), + }, + ioThroughput: { + r: combineStats( + hostsStats, + `stats.ioThroughput.r[${srShortUUID}]`, + sum + ), + w: combineStats( + hostsStats, + `stats.ioThroughput.w[${srShortUUID}]`, + sum + ), + }, + latency: { + r: combineStats(hostsStats, `stats.latency.r[${srShortUUID}]`, mean), + w: combineStats(hostsStats, `stats.latency.w[${srShortUUID}]`, mean), + }, + iowait: mapValues(hostsStats, `stats.iowait[${srShortUUID}]`), + }, + } + } } diff --git a/packages/xo-server/src/xo-mixins/xen-servers.js b/packages/xo-server/src/xo-mixins/xen-servers.js index b4652ee07..68b0f03b6 100644 --- a/packages/xo-server/src/xo-mixins/xen-servers.js +++ b/packages/xo-server/src/xo-mixins/xen-servers.js @@ -400,6 +400,10 @@ export default class { return this._stats.getHostStats(this.getXapi(hostId), hostId, granularity) } + getXapiSrStats (srId, granularity) { + return this._stats.getSrStats(this.getXapi(srId), srId, granularity) + } + async mergeXenPools (sourceId, targetId, force = false) { const sourceXapi = this.getXapi(sourceId) const { _auth: { user, password }, _url: { hostname } } = this.getXapi( diff --git a/packages/xo-web/src/common/intl/messages.js b/packages/xo-web/src/common/intl/messages.js index 38d26b194..e9911fa74 100644 --- a/packages/xo-web/src/common/intl/messages.js +++ b/packages/xo-web/src/common/intl/messages.js @@ -543,6 +543,14 @@ const messages = { srUnhealthyVdiDepth: 'Depth', srUnhealthyVdiTitle: 'VDI to coalesce ({total, number})', + // ----- SR stats tab ----- + + srNoStats: 'No stats', + statsIops: 'IOPS', + statsIoThroughput: 'IO throughput', + statsLatency: 'Latency', + statsIowait: 'IOwait', + // ----- SR actions ----- srRescan: 'Rescan all disks', srReconnectAll: 'Connect to all hosts', diff --git a/packages/xo-web/src/common/utils.js b/packages/xo-web/src/common/utils.js index ef7967312..9a100fa41 100644 --- a/packages/xo-web/src/common/utils.js +++ b/packages/xo-web/src/common/utils.js @@ -205,6 +205,19 @@ export const formatSizeRaw = bytes => export const formatSpeed = (bytes, milliseconds) => humanFormat(bytes * 1e3 / milliseconds, { scale: 'binary', unit: 'B/s' }) +const timeScale = new humanFormat.Scale({ + ns: 1e-6, + µs: 1e-3, + ms: 1, + s: 1e3, + min: 60 * 1e3, + h: 3600 * 1e3, + d: 86400 * 1e3, + y: 2592000 * 1e3, +}) +export const formatTime = milliseconds => + humanFormat(milliseconds, { scale: timeScale, decimals: 0 }) + export const parseSize = size => { let bytes = humanFormat.parse.raw(size, { scale: 'binary' }) if (bytes.unit && bytes.unit !== 'B') { diff --git a/packages/xo-web/src/common/xo-line-chart/index.js b/packages/xo-web/src/common/xo-line-chart/index.js index 59d47b780..1b406ba73 100644 --- a/packages/xo-web/src/common/xo-line-chart/index.js +++ b/packages/xo-web/src/common/xo-line-chart/index.js @@ -4,11 +4,16 @@ import ChartistTooltip from 'chartist-plugin-tooltip' import React from 'react' import { injectIntl } from 'react-intl' import { messages } from 'intl' -import { find, flatten, floor, map, max, size, sum, values } from 'lodash' +import { find, flatten, floor, get, map, max, size, sum, values } from 'lodash' import propTypes from '../prop-types-decorator' import { computeArraysSum } from '../xo-stats' -import { formatSize, getMemoryUsedMetric } from '../utils' +import { + formatSize, + formatSpeed, + formatTime, + getMemoryUsedMetric, +} from '../utils' import styles from './index.css' @@ -555,3 +560,172 @@ export const PoolLoadLineChart = injectIntl( ) }) ) + +const buildSrSeries = ({ stats, label, addSumSeries }) => { + const series = map(stats, (data, key) => ({ + name: `${label} (${key})`, + data, + })) + + if (addSumSeries) { + series.push({ + name: `All ${label}`, + data: computeArraysSum(values(stats)), + className: styles.dashedLine, + }) + } + + return series +} + +export const IopsLineChart = injectIntl( + propTypes({ + addSumSeries: propTypes.bool, + data: propTypes.array.isRequired, + options: propTypes.object, + })(({ addSumSeries, data, options = {}, intl }) => { + const { endTimestamp, interval, stats: { iops } } = data + + const { length } = get(iops, 'r') + + if (length === 0) { + return templateError + } + + return ( + `${value.toPrecision(3)} /s`, + }), + ...options, + }} + /> + ) + }) +) + +export const IoThroughputChart = injectIntl( + propTypes({ + addSumSeries: propTypes.bool, + data: propTypes.array.isRequired, + options: propTypes.object, + })(({ addSumSeries, data, options = {}, intl }) => { + const { endTimestamp, interval, stats: { ioThroughput } } = data + + const { length } = get(ioThroughput, 'r') || [] + + if (length === 0) { + return templateError + } + + return ( + formatSpeed(value, 1e3), + }), + ...options, + }} + /> + ) + }) +) + +export const LatencyChart = injectIntl( + propTypes({ + addSumSeries: propTypes.bool, + data: propTypes.array.isRequired, + options: propTypes.object, + })(({ addSumSeries, data, options = {}, intl }) => { + const { endTimestamp, interval, stats: { latency } } = data + + const { length } = get(latency, 'r') || [] + + if (length === 0) { + return templateError + } + + return ( + formatTime(value), + }), + ...options, + }} + /> + ) + }) +) + +export const IowaitChart = injectIntl( + propTypes({ + addSumSeries: propTypes.bool, + data: propTypes.array.isRequired, + options: propTypes.object, + })(({ addSumSeries, data, options = {}, intl }) => { + const { endTimestamp, interval, stats: { iowait } } = data + + const { length } = iowait[Object.keys(iowait)[0]] || [] + + if (length === 0) { + return templateError + } + + return ( + `${value.toPrecision(2)}%`, + }), + ...options, + }} + /> + ) + }) +) diff --git a/packages/xo-web/src/common/xo/index.js b/packages/xo-web/src/common/xo/index.js index 5570dd0c6..2b7ceeb0f 100644 --- a/packages/xo-web/src/common/xo/index.js +++ b/packages/xo-web/src/common/xo/index.js @@ -1495,6 +1495,9 @@ export const deleteSr = sr => ), }).then(() => _call('sr.destroy', { id: resolveId(sr) }), noop) +export const fetchSrStats = (sr, granularity) => + _call('sr.stats', { id: resolveId(sr), granularity }) + export const forgetSr = sr => confirm({ title: _('srForgetModalTitle'), diff --git a/packages/xo-web/src/icons.scss b/packages/xo-web/src/icons.scss index 86887979e..c95bd08a3 100644 --- a/packages/xo-web/src/icons.scss +++ b/packages/xo-web/src/icons.scss @@ -237,6 +237,18 @@ @extend .fa; @extend .fa-database; } + &-iops { + @extend .fa; + @extend .fa-cogs; + } + &-latency { + @extend .fa; + @extend .fa-clock-o; + } + &-iowait { + @extend .fa; + @extend .fa-pause; + } &-delete { @extend .fa; @extend .fa-trash; diff --git a/packages/xo-web/src/xo-app/sr/index.js b/packages/xo-web/src/xo-app/sr/index.js index dc468c1f3..be2f685d4 100644 --- a/packages/xo-web/src/xo-app/sr/index.js +++ b/packages/xo-web/src/xo-app/sr/index.js @@ -20,20 +20,22 @@ import { } from 'selectors' import TabAdvanced from './tab-advanced' -import TabGeneral from './tab-general' -import TabLogs from './tab-logs' -import TabHosts from './tab-host' import TabDisks from './tab-disks' +import TabGeneral from './tab-general' +import TabHosts from './tab-host' +import TabLogs from './tab-logs' +import TabStats from './tab-stats' import TabXosan from './tab-xosan' // =================================================================== @routes('general', { advanced: TabAdvanced, - general: TabGeneral, - logs: TabLogs, - hosts: TabHosts, disks: TabDisks, + general: TabGeneral, + hosts: TabHosts, + logs: TabLogs, + stats: TabStats, xosan: TabXosan, }) @connectStore(() => { @@ -143,6 +145,7 @@ export default class Sr extends Component { {_('generalTabName')} + {_('statsTabName')} {_('disksTabName', { disks: sr.VDIs.length })} diff --git a/packages/xo-web/src/xo-app/sr/tab-stats.js b/packages/xo-web/src/xo-app/sr/tab-stats.js new file mode 100644 index 000000000..26d846332 --- /dev/null +++ b/packages/xo-web/src/xo-app/sr/tab-stats.js @@ -0,0 +1,165 @@ +import _ from 'intl' +import Component from 'base-component' +import Icon from 'icon' +import React from 'react' +import Tooltip from 'tooltip' +import Upgrade from 'xoa-upgrade' +import { Container, Row, Col } from 'grid' +import { fetchSrStats } from 'xo' +import { get } from 'lodash' +import { Toggle } from 'form' +import { + IopsLineChart, + IoThroughputChart, + IowaitChart, + LatencyChart, +} from 'xo-line-chart' + +export default class SrStats extends Component { + state = { + granularity: 'seconds', + } + + _loop (sr = get(this.props, 'sr')) { + if (sr === undefined) { + this._loop() + } + + if (this.cancel !== undefined) { + this.cancel() + } + + let cancelled = false + this.cancel = () => { + cancelled = true + } + + fetchSrStats(sr, this.state.granularity).then(data => { + if (cancelled) { + return + } + this.cancel = undefined + + clearTimeout(this.timeout) + this.setState( + { + data, + selectStatsLoading: false, + }, + () => { + this.timeout = setTimeout(this._loop, data.interval * 1e3) + } + ) + }) + } + + _loop = ::this._loop + + componentWillMount () { + this._loop() + } + + componentWillUnmount () { + clearTimeout(this.timeout) + } + + _onGranularityChange = ({ target: { value: granularity } }) => { + clearTimeout(this.timeout) + this.setState( + { + granularity, + selectStatsLoading: true, + }, + this._loop + ) + } + + render () { + const { + data, + granularity, + selectStatsLoading, + useCombinedValues, + } = this.state + + return data === undefined ? ( + {_('srNoStats')} + ) : ( + + + + +
+ + + +
+ + + {selectStatsLoading && ( +
+ +
+ )} + + +
+ +
+ +
+ + +
+ {_('statsIops')} +
+ + + +
+ {_('statsIoThroughput')} +
+ + +
+
+
+ + +
+ {_('statsLatency')} +
+ + + +
+ {_('statsIowait')} +
+ + +
+
+
+ ) + } +}