feat: Charts support time comparisons

This commit is contained in:
jsers 2020-04-14 14:21:31 +08:00
parent 500afafdf3
commit 0769b02549
11 changed files with 335 additions and 30 deletions

View File

@ -3,6 +3,7 @@ import { Table, Input, Button, Modal } from 'antd';
import { ColumnProps, TableRowSelection } from 'antd/es/table';
import Color from 'color';
import _ from 'lodash';
import { injectIntl } from 'react-intl';
import clipboard from '@common/clipboard';
import ContextMenu from '@cpts/ContextMenu';
import { SerieInterface, PointInterface } from '../interface';
@ -35,7 +36,7 @@ interface LegendDataItem extends SerieInterface {
last: number | null,
}
export default class Legend extends Component<Props, State> {
class Legend extends Component<Props, State> {
static defaultProps = {
style: {},
series: [],
@ -125,7 +126,7 @@ export default class Legend extends Component<Props, State> {
}
render() {
const { onSelectedChange } = this.props;
const { comparisonOptions, onSelectedChange } = this.props;
const { searchText, selectedKeys, highlightedKeys } = this.state;
const counterSelectedKeys = highlightedKeys;
const data = this.filterData();
@ -148,7 +149,7 @@ export default class Legend extends Component<Props, State> {
filterDropdownVisible: this.state.filterDropdownVisible,
onFilterDropdownVisibleChange: (visible: boolean) => this.setState({ filterDropdownVisible: visible }),
render: (text, record) => {
const legendName = getLengendName(record);
const legendName = getLengendName(record, comparisonOptions, this.props.intl);
return (
<span
title={text}
@ -258,12 +259,13 @@ export default class Legend extends Component<Props, State> {
export function normalizeLegendData(series: SerieInterface[] = []) {
const tableData = _.map(series, (serie) => {
const { id, metric, tags, data } = serie;
const { id, metric, tags, data, comparison } = serie;
const { last, avg, max, min, sum } = getLegendNums(data);
return {
id,
metric,
tags,
comparison,
last,
avg,
max,
@ -348,9 +350,17 @@ function getLegendNums(points: PointInterface[]) {
return { last, avg, max, min, sum };
}
function getLengendName(serie: SerieInterface) {
const { tags } = serie;
function getLengendName(serie: SerieInterface, comparisonOptions: any, intl: any) {
const { tags, comparison } = serie;
let lname = tags;
// display comparison
if (comparison && typeof comparison === 'number') {
const currentComparison = _.find(comparisonOptions, { value: `${comparison}000` });
if (currentComparison && currentComparison.label) {
const postfix = intl.locale === 'en' ? currentComparison.labelEn : `环比${currentComparison.label}`;
lname += ` ${postfix}`;
}
}
// shorten name
if (lname.length > 80) {
const leftStr = lname.substr(0, 40);
@ -369,3 +379,5 @@ function isEqualSeries(series: SerieInterface[], nextSeries: SerieInterface[]) {
});
return _.isEqual(pureSeries, pureNextSeries);
}
export default injectIntl(Legend);

View File

@ -93,6 +93,7 @@ export default class Graph extends Component<Props, State> {
|| aggrFuncChanged
|| aggrGroupChanged
|| consolFuncChanged
|| !_.isEqual(nextData.comparison, thisData.comparison)
) {
const isFetchCounter = selectedNsChanged || selectedMetricChanged || selectedTagkvChanged;
this.fetchData(nextProps.data, isFetchCounter, (series: SerieInterface[]) => {
@ -256,6 +257,8 @@ export default class Graph extends Component<Props, State> {
return util.getTooltipsContent({
points,
chartWidth: this.graphWrapEle.offsetWidth - 40,
comparison: graphConfig.comparison,
isComparison: !!_.get(graphConfig.comparison, 'length'),
});
},
},
@ -292,6 +295,8 @@ export default class Graph extends Component<Props, State> {
return util.getTooltipsContent({
points,
chartWidth: this.graphWrapEle.offsetWidth - 40,
comparison: graphConfig.comparison,
isComparison: !!_.get(graphConfig.comparison, 'length'),
});
},
},
@ -391,6 +396,7 @@ export default class Graph extends Component<Props, State> {
style={{ display: graphConfig.legend ? 'block' : 'none' }}
series={this.getZoomedSeries()}
onSelectedChange={this.handleLegendRowSelectedChange}
comparisonOptions={graphConfig.comparisonOptions}
/>
</div>
);

View File

@ -0,0 +1,206 @@
import React, { Component } from 'react';
import _ from 'lodash';
import moment from 'moment';
import { Icon, Button, Select, Popover, Input, InputNumber } from 'antd';
import { CheckboxChangeEvent } from 'antd/lib/checkbox';
import { injectIntl } from 'react-intl';
interface Props {
size: 'small' | 'default' | 'large' | undefined,
comparison: string[],
relativeTimeComparison: boolean,
comparisonOptions: any[],
graphConfig: any,
onChange: (values: any) => void,
}
interface State {
customValue?: number,
customType: string,
errorText: string,
}
const Option = Select.Option;
const customTypeOptions = [
{
value: 'hour',
label: '小时',
labelEn: 'hour',
ms: 3600000,
}, {
value: 'day',
label: '天',
labelEn: 'day',
ms: 86400000,
},
];
class Comparison extends Component<Props, State> {
static defaultProps = {
size: 'small',
comparison: [],
relativeTimeComparison: false,
comparisonOptions: [],
graphConfig: null,
onChange: _.noop,
};
constructor(props: Props) {
super(props);
this.state = {
customValue: undefined, // 自定义环比值(不带单位)
customType: 'hour', // 自定义环比值单位 hour | day
errorText: '', // 错误提示文本
};
}
refresh = () => {
const { graphConfig } = this.props;
if (graphConfig) {
const now = moment();
const start = (Number(now.format('x')) - Number(graphConfig.end)) + Number(graphConfig.start) + '';
const end = now.format('x');
return { now: end, start, end };
}
return {};
}
handleComparisonChange = (value: string[]) => {
const { onChange, relativeTimeComparison, comparisonOptions } = this.props;
onChange({
...this.refresh(),
comparison: value,
relativeTimeComparison,
comparisonOptions,
});
}
handleRelativeTimeComparisonChange = (e: CheckboxChangeEvent) => {
const { onChange, comparison, comparisonOptions } = this.props;
onChange({
...this.refresh(),
comparison,
relativeTimeComparison: e.target.checked,
comparisonOptions,
});
}
handleCustomValueChange = (value: number | undefined) => {
if (value) {
this.setState({
customValue: value,
errorText: '',
});
} else {
this.setState({
customValue: value,
errorText: 'Custom value is required',
});
}
}
handleCustomTypeChange = (value: string) => {
this.setState({ customType: value });
}
handleCustomBtnClick = () => {
const { onChange, comparison, relativeTimeComparison, comparisonOptions } = this.props;
const { customValue, customType } = this.state;
const currentCustomTypeObj = _.find(customTypeOptions, { value: customType });
if (!customValue || !currentCustomTypeObj) {
this.setState({
errorText: 'Custom value is required',
});
} else {
this.setState({
errorText: '',
}, () => {
const ms = currentCustomTypeObj.ms * customValue;
const comparisonOptionsClone = _.cloneDeep(comparisonOptions);
const comparisonClone = _.cloneDeep(comparison);
comparisonClone.push(_.toString(ms));
comparisonOptionsClone.push({
label: `${customValue}${currentCustomTypeObj.label}`,
value: _.toString(ms),
});
const newComparisonOptions = _.unionBy(comparisonOptionsClone, 'value');
onChange({
...this.refresh(),
comparison: comparisonClone,
relativeTimeComparison,
comparisonOptions: newComparisonOptions,
});
});
}
}
render() {
const { size, comparison, comparisonOptions } = this.props;
const { customValue, customType, errorText } = this.state;
console.log(this.props.intl.locale);
const addonUid = _.uniqueId('inputNumber-addon-');
return (
<div className="graph-config-inner-comparison">
<Select
mode="multiple"
dropdownMatchSelectWidth={false}
size={size}
style={{ minWidth: 80, width: 'auto', verticalAlign: 'middle' }}
value={comparison}
onChange={this.handleComparisonChange}>
{
_.map(comparisonOptions, o => {
return (
<Option key={o.value} value={o.value}>
{this.props.intl.locale === 'en' ? o.labelEn : o.label}
</Option>
);
})
}
</Select>
<Popover placement="bottom" title="Enter a custom value" trigger="click" content={
<div>
<div style={{ display: 'inline-block', width: 160, marginRight: 10, verticalAlign: 'top' }}>
<Input.Group className="ant-select-wrapper" size="default">
<InputNumber value={customValue} onChange={this.handleCustomValueChange} />
<span className="ant-input-group-addon" id={addonUid}>
<Select
style={{ width: 70 }}
getPopupContainer={() => document.getElementById(addonUid) as HTMLElement}
value={customType}
onChange={this.handleCustomTypeChange}
>
{
_.map(customTypeOptions, item => (
<Option key={item.value}>
{this.props.intl.locale === 'en' ? item.labelEn : item.label}
</Option>
))
}
</Select>
</span>
</Input.Group>
</div>
<Button onClick={this.handleCustomBtnClick}>
{this.props.intl.locale === 'en' ? 'ok' : '确认'}
</Button>
<p style={{ color: '#f50' }}>{errorText}</p>
</div>
}>
<span className="ant-input-group-addon select-addon" style={{
padding: size === 'default' ? 7 : 5,
left: size === 'default' ? -5 : -3,
height: size === 'default' ? 32 : 24,
lineHeight: size === 'default' ? '18px' : '10px',
}}>
<Icon type="plus-circle-o" />
</span>
</Popover>
</div>
);
}
}
export default injectIntl(Comparison);

View File

@ -9,6 +9,7 @@ import { normalizeTreeData, renderTreeNodes } from '@cpts/Layout/utils';
import request from '@common/request';
import api from '@common/api';
import Tagkv from './Tagkv';
import Comparison from './Comparison';
import * as config from '../config';
import { getTimeLabelVal } from '../util';
import hasDtag from '../util/hasDtag';
@ -213,6 +214,18 @@ export default class GraphConfigForm extends Component<Props, State> {
}
}
handleCommonFieldChange = (changedObj) => {
const newChangedObj = {};
_.each(changedObj, (val, key) => {
newChangedObj[key] = {
$set: val,
};
});
this.setState(update(this.state, {
graphConfig: newChangedObj,
}));
}
handleNsChange = async (selectedNid: number[], currentMetricObj: MetricInterface) => {
try {
this.setLoading(true);
@ -835,6 +848,30 @@ export default class GraphConfigForm extends Component<Props, State> {
] : false
}
</FormItem>
<FormItem
labelCol={{ span: 3 }}
wrapperCol={{ span: 21 }}
label={<FormattedMessage id="graph.config.comparison" />}
style={{ marginBottom: 0 }}
>
<Comparison
size="default"
comparison={graphConfig.comparison}
relativeTimeComparison={graphConfig.relativeTimeComparison}
comparisonOptions={graphConfig.comparisonOptions}
graphConfig={graphConfig}
onChange={(values) => {
this.handleCommonFieldChange({
start: values.start,
end: values.end,
now: values.now,
comparison: values.comparison,
relativeTimeComparison: values.relativeTimeComparison,
comparisonOptions: values.comparisonOptions,
});
}}
/>
</FormItem>
{this.renderMetrics()}
<FormItem
labelCol={{ span: 3 }}

View File

@ -7,6 +7,7 @@ import { Icon, Button, Select, Checkbox, Tooltip, DatePicker } from 'antd';
import * as config from '../config';
import * as util from '../util';
import Tagkv from './Tagkv';
import Comparison from './Comparison';
import { GraphDataInterface, GraphDataChangeFunc } from '../interface';
interface Props {
@ -77,6 +78,18 @@ export default class GraphConfigInner extends Component<Props> {
});
}
handleComparisonChange = (values: any) => {
const { data, onChange } = this.props;
onChange('update', data.id, {
start: values.start,
end: values.end,
now: values.now,
comparison: values.comparison,
relativeTimeComparison: values.relativeTimeComparison,
comparisonOptions: values.comparisonOptions,
});
}
handleconsolFuncChange = (val: string) => {
const { data, onChange } = this.props;
onChange('update', data.id, {
@ -178,7 +191,7 @@ export default class GraphConfigInner extends Component<Props> {
render() {
const { data, onChange } = this.props;
const { now, start, end } = data;
const { now, start, end, comparison } = data;
const timeLabel = now === end ? util.getTimeLabelVal(start, end, 'label') : '其他';
const timeVal = now === end ? util.getTimeLabelVal(start, end, 'value') : 'custom';
const datePickerStartVal = moment(Number(start)).format(config.timeFormatMap.moment);
@ -284,21 +297,23 @@ export default class GraphConfigInner extends Component<Props> {
</Select>
</div> : null
}
{/* <div className="graph-config-inner-item">
采样函数
<Select
allowClear
size="small"
style={{ width: 85 }}
placeholder="无"
value={_.get(data.metrics, '[0].consolFunc')}
onChange={this.handleconsolFuncChange}
>
<Option value="AVERAGE">均值</Option>
<Option value="MAX">最大值</Option>
<Option value="MIN">最小值</Option>
</Select>
</div> */}
<div className="graph-config-inner-item">
<FormattedMessage id="graph.config.comparison" />
<Comparison
comparison={comparison}
relativeTimeComparison={data.relativeTimeComparison}
comparisonOptions={data.comparisonOptions}
graphConfig={data}
onChange={this.handleComparisonChange}
/>
<input
style={{
position: 'fixed',
left: -10000,
}}
id={`hiddenInput${data.id}`}
/>
</div>
<div className="graph-config-inner-item">
<Checkbox checked={!!data.legend} onChange={this.legendChange}>
Legend

View File

@ -2,21 +2,26 @@ import PropTypes from 'prop-types';
import moment from 'moment';
const now = moment();
export const comparison = [
export const comparisonOptions = [
{
label: '1小时',
labelEn: '1 hour',
value: '3600000',
}, {
label: '2小时',
labelEn: '2 hours',
value: '7200000',
}, {
label: '1',
labelEn: '1 day',
value: '86400000',
}, {
label: '2',
labelEn: '2 days',
value: '172800000',
}, {
label: '7',
labelEn: '7 days',
value: '604800000',
},
];
@ -74,6 +79,7 @@ export const graphDefaultConfig = {
now: now.clone().format('x'),
start: now.clone().subtract(3600000, 'ms').format('x'),
end: now.clone().format('x'),
comparisonOptions,
threshold: undefined,
legend: false,
shared: false,

View File

@ -32,20 +32,38 @@ export default function getTooltipsContent(activeTooltipData: ActiveTooltipData)
tooltipContent += getHeaderStr(activeTooltipData);
_.each(sortedPoints, (point) => {
tooltipContent += singlePoint(point);
tooltipContent += singlePoint(point, activeTooltipData);
});
return `<div style="table-layout: fixed;max-width: ${chartWidth}px;word-wrap: break-word;white-space: normal;">${tooltipContent}</div>`;
}
function singlePoint(pointData = {} as PointInterface) {
const { color, filledNull, serieOptions = {} } = pointData;
const { tags } = serieOptions as any;
function singlePoint(pointData = {}, activeTooltipData) {
const { color, filledNull, serieOptions = {}, timestamp } = pointData;
const { comparison: comparisons, isComparison } = activeTooltipData;
const { tags } = serieOptions;
const value = numeral(pointData.value).format('0,0[.]000');
let name = tags;
// 对比情况下 name 特殊处理
if (isComparison) {
const mDate = serieOptions.comparison && typeof serieOptions.comparison === 'number' ? moment(timestamp).subtract(serieOptions.comparison, 'seconds') : moment(timestamp);
const isAllDayLevelComparison = _.every(comparisons, (o) => {
return _.isInteger(Number(o) / 86400000);
});
if (isAllDayLevelComparison) {
const dateStr = mDate.format('YYYY-MM-DD');
name = `${dateStr}`;
} else {
const dateStr = mDate.format(fmt);
name = `${dateStr} ${name}`;
}
}
return (
`<span style="color:${color}"> </span>
${_.escape(tags)}<strong>${value}${filledNull ? '(空值填补,仅限看图使用)' : ''}</strong><br />`
${name}<strong>${value}${filledNull ? '(空值填补,仅限看图使用)' : ''}</strong><br />`
);
}

View File

@ -5,7 +5,7 @@ export function transformMsToS(ts: string) {
return Number(ts.substring(0, ts.length - 3));
}
export function processComparison(comparison: number[]) {
export function processComparison(comparison: string[]) {
const newComparison = [0];
_.each(comparison, (o) => {
newComparison.push(transformMsToS(String(o)));
@ -14,6 +14,7 @@ export function processComparison(comparison: number[]) {
}
export default function normalizeEndpointCounters(graphConfig: GraphDataInterface, counterList: CounterInterface[]) {
const newComparison = processComparison(graphConfig.comparison);
const firstMetric = _.get(graphConfig, 'metrics[0]', {});
const { aggrFunc, aggrGroup: groupKey, consolFunc } = firstMetric;
const start = transformMsToS(_.toString(graphConfig.start));
@ -27,6 +28,7 @@ export default function normalizeEndpointCounters(graphConfig: GraphDataInterfac
aggrFunc,
groupKey,
consolFunc,
comparisons: newComparison,
};
});

View File

@ -5,7 +5,7 @@ import { SerieInterface } from '../interface';
export default function normalizeSeries(data: any[]) {
const series = [] as SerieInterface[];
_.each(_.sortBy(data, ['counter', 'endpoint']), (o, i) => {
const { endpoint } = o;
const { endpoint, comparison } = o;
const color = getSerieColor(o, i);
const separatorIdx = o.counter.indexOf('/');
@ -23,6 +23,7 @@ export default function normalizeSeries(data: any[]) {
lineWidth: 2,
color,
oldColor: color,
comparison,
} as SerieInterface;
series.push(serie);
});

View File

@ -260,6 +260,7 @@ export default {
'graph.config.aggr.max': 'max',
'graph.config.aggr.min': 'min',
'graph.config.aggr.group': 'groupBy',
'graph.config.comparison': 'comparison',
'graph.config.series': 'series',
'graph.config.series.unit': 'pcs',
'graph.config.cate': 'cate',

View File

@ -261,6 +261,7 @@ export default {
'graph.config.aggr.max': '最大值',
'graph.config.aggr.min': '最小值',
'graph.config.aggr.group': '聚合维度',
'graph.config.comparison': '环比',
'graph.config.series': '曲线',
'graph.config.series.unit': '条',
'graph.config.cate': '分类',