mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Feat: Singlestat panel react progress & refactorings (#16039)
* big value component * big value component * editor for font and sparkline * less logging * remove sparkline from storybook * add display value link wrapper * follow tooltip * follow tooltip * merge master * Just minor refactoring * use series after last merge * Refactoring: moving shared singlestat stuff to grafana-ui * Refactor: Moved final getSingleStatDisplayValues func
This commit is contained in:
committed by
Torkel Ödegaard
parent
1d955a8762
commit
c8b2102500
@@ -0,0 +1,37 @@
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { number, text } from '@storybook/addon-knobs';
|
||||
import { BigValue } from './BigValue';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
|
||||
|
||||
const getKnobs = () => {
|
||||
return {
|
||||
value: text('value', 'Hello'),
|
||||
valueFontSize: number('valueFontSize', 120),
|
||||
prefix: text('prefix', ''),
|
||||
};
|
||||
};
|
||||
|
||||
const BigValueStories = storiesOf('UI/BigValue', module);
|
||||
|
||||
BigValueStories.addDecorator(withCenteredStory);
|
||||
|
||||
BigValueStories.add('Singlestat viz', () => {
|
||||
const { value, prefix, valueFontSize } = getKnobs();
|
||||
|
||||
return renderComponentWithTheme(BigValue, {
|
||||
width: 300,
|
||||
height: 250,
|
||||
value: {
|
||||
text: value,
|
||||
numeric: NaN,
|
||||
fontSize: valueFontSize + '%',
|
||||
},
|
||||
prefix: prefix
|
||||
? {
|
||||
text: prefix,
|
||||
numeric: NaN,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { BigValue, Props } from './BigValue';
|
||||
import { getTheme } from '../../themes/index';
|
||||
|
||||
jest.mock('jquery', () => ({
|
||||
plot: jest.fn(),
|
||||
}));
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
height: 300,
|
||||
width: 300,
|
||||
value: {
|
||||
text: '25',
|
||||
numeric: 25,
|
||||
},
|
||||
theme: getTheme(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<BigValue {...props} />);
|
||||
const instance = wrapper.instance() as BigValue;
|
||||
|
||||
return {
|
||||
instance,
|
||||
wrapper,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Render BarGauge with basic options', () => {
|
||||
it('should render', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper).toBeDefined();
|
||||
// expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
134
packages/grafana-ui/src/components/BigValue/BigValue.tsx
Normal file
134
packages/grafana-ui/src/components/BigValue/BigValue.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// Library
|
||||
import React, { PureComponent, ReactNode, CSSProperties } from 'react';
|
||||
import $ from 'jquery';
|
||||
|
||||
// Utils
|
||||
import { getColorFromHexRgbOrName } from '../../utils';
|
||||
|
||||
// Types
|
||||
import { Themeable, DisplayValue } from '../../types';
|
||||
|
||||
export interface BigValueSparkline {
|
||||
data: any[][]; // [[number,number]]
|
||||
minX: number;
|
||||
maxX: number;
|
||||
full: boolean; // full height
|
||||
fillColor: string;
|
||||
lineColor: string;
|
||||
}
|
||||
|
||||
export interface Props extends Themeable {
|
||||
height: number;
|
||||
width: number;
|
||||
value: DisplayValue;
|
||||
prefix?: DisplayValue;
|
||||
suffix?: DisplayValue;
|
||||
sparkline?: BigValueSparkline;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* This visualization is still in POC state, needed more tests & better structure
|
||||
*/
|
||||
export class BigValue extends PureComponent<Props> {
|
||||
canvasElement: any;
|
||||
|
||||
componentDidMount() {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
draw() {
|
||||
const { sparkline, theme } = this.props;
|
||||
|
||||
if (sparkline && this.canvasElement) {
|
||||
const { data, minX, maxX, fillColor, lineColor } = sparkline;
|
||||
|
||||
const options = {
|
||||
legend: { show: false },
|
||||
series: {
|
||||
lines: {
|
||||
show: true,
|
||||
fill: 1,
|
||||
zero: false,
|
||||
lineWidth: 1,
|
||||
fillColor: getColorFromHexRgbOrName(fillColor, theme.type),
|
||||
},
|
||||
},
|
||||
yaxes: { show: false },
|
||||
xaxis: {
|
||||
show: false,
|
||||
min: minX,
|
||||
max: maxX,
|
||||
},
|
||||
grid: { hoverable: false, show: false },
|
||||
};
|
||||
|
||||
const plotSeries = {
|
||||
data,
|
||||
color: getColorFromHexRgbOrName(lineColor, theme.type),
|
||||
};
|
||||
|
||||
try {
|
||||
$.plot(this.canvasElement, [plotSeries], options);
|
||||
} catch (err) {
|
||||
console.log('sparkline rendering error', err, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderText = (value?: DisplayValue, padding?: string): ReactNode => {
|
||||
if (!value || !value.text) {
|
||||
return null;
|
||||
}
|
||||
const css: CSSProperties = {};
|
||||
if (padding) {
|
||||
css.padding = padding;
|
||||
}
|
||||
if (value.color) {
|
||||
css.color = value.color;
|
||||
}
|
||||
if (value.fontSize) {
|
||||
css.fontSize = value.fontSize;
|
||||
}
|
||||
|
||||
return <span style={css}>{value.text}</span>;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { height, width, value, prefix, suffix, sparkline, backgroundColor } = this.props;
|
||||
|
||||
const plotCss: CSSProperties = {};
|
||||
plotCss.position = 'absolute';
|
||||
|
||||
if (sparkline) {
|
||||
if (sparkline.full) {
|
||||
plotCss.bottom = '5px';
|
||||
plotCss.left = '-5px';
|
||||
plotCss.width = width - 10 + 'px';
|
||||
const dynamicHeightMargin = height <= 100 ? 5 : Math.round(height / 100) * 15 + 5;
|
||||
plotCss.height = height - dynamicHeightMargin + 'px';
|
||||
} else {
|
||||
plotCss.bottom = '0px';
|
||||
plotCss.left = '-5px';
|
||||
plotCss.width = width - 10 + 'px';
|
||||
plotCss.height = Math.floor(height * 0.25) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="big-value" style={{ width, height, backgroundColor }}>
|
||||
<span className="big-value__value">
|
||||
{this.renderText(prefix, '0px 2px 0px 0px')}
|
||||
{this.renderText(value)}
|
||||
{this.renderText(suffix)}
|
||||
</span>
|
||||
|
||||
{sparkline && <div style={plotCss} ref={element => (this.canvasElement = element)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
15
packages/grafana-ui/src/components/BigValue/_BigValue.scss
Normal file
15
packages/grafana-ui/src/components/BigValue/_BigValue.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
.big-value {
|
||||
position: relative;
|
||||
display: table;
|
||||
}
|
||||
|
||||
.big-value__value {
|
||||
line-height: 1;
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 3em;
|
||||
font-weight: $font-weight-semi-bold;
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
import React, { PureComponent, ChangeEvent } from 'react';
|
||||
|
||||
// Components
|
||||
import { FormField, FormLabel, PanelOptionsGroup, StatsPicker, UnitPicker, StatID } from '@grafana/ui';
|
||||
import {
|
||||
FormField,
|
||||
FormLabel,
|
||||
PanelOptionsGroup,
|
||||
StatsPicker,
|
||||
UnitPicker,
|
||||
StatID,
|
||||
SelectOptionItem,
|
||||
} from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { SingleStatValueOptions } from './types';
|
||||
import { SingleStatValueOptions } from './shared';
|
||||
|
||||
const labelWidth = 6;
|
||||
|
||||
@@ -15,15 +23,15 @@ export interface Props {
|
||||
}
|
||||
|
||||
export class SingleStatValueEditor extends PureComponent<Props> {
|
||||
onUnitChange = unit => this.props.onChange({ ...this.props.options, unit: unit.value });
|
||||
onUnitChange = (unit: SelectOptionItem) => this.props.onChange({ ...this.props.options, unit: unit.value });
|
||||
|
||||
onStatsChange = stats => {
|
||||
onStatsChange = (stats: string[]) => {
|
||||
const stat = stats[0] || StatID.mean;
|
||||
this.props.onChange({ ...this.props.options, stat });
|
||||
};
|
||||
|
||||
onDecimalChange = event => {
|
||||
if (!isNaN(event.target.value)) {
|
||||
onDecimalChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!isNaN(parseInt(event.target.value, 10))) {
|
||||
this.props.onChange({
|
||||
...this.props.options,
|
||||
decimals: parseInt(event.target.value, 10),
|
||||
@@ -36,14 +44,16 @@ export class SingleStatValueEditor extends PureComponent<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
onPrefixChange = event => this.props.onChange({ ...this.props.options, prefix: event.target.value });
|
||||
onSuffixChange = event => this.props.onChange({ ...this.props.options, suffix: event.target.value });
|
||||
onPrefixChange = (event: ChangeEvent<HTMLInputElement>) =>
|
||||
this.props.onChange({ ...this.props.options, prefix: event.target.value });
|
||||
onSuffixChange = (event: ChangeEvent<HTMLInputElement>) =>
|
||||
this.props.onChange({ ...this.props.options, suffix: event.target.value });
|
||||
|
||||
render() {
|
||||
const { stat, unit, decimals, prefix, suffix } = this.props.options;
|
||||
|
||||
let decimalsString = '';
|
||||
if (Number.isFinite(decimals)) {
|
||||
if (decimals !== null && decimals !== undefined && Number.isFinite(decimals as number)) {
|
||||
decimalsString = decimals.toString();
|
||||
}
|
||||
|
||||
122
packages/grafana-ui/src/components/SingleStatShared/shared.ts
Normal file
122
packages/grafana-ui/src/components/SingleStatShared/shared.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import {
|
||||
ValueMapping,
|
||||
Threshold,
|
||||
VizOrientation,
|
||||
PanelModel,
|
||||
DisplayValue,
|
||||
FieldType,
|
||||
NullValueMode,
|
||||
GrafanaTheme,
|
||||
SeriesData,
|
||||
InterpolateFunction,
|
||||
} from '../../types';
|
||||
import { getStatsCalculators, calculateStats } from '../../utils/statsCalculator';
|
||||
import { getDisplayProcessor } from '../../utils/displayValue';
|
||||
export { SingleStatValueEditor } from './SingleStatValueEditor';
|
||||
|
||||
export interface SingleStatBaseOptions {
|
||||
valueMappings: ValueMapping[];
|
||||
thresholds: Threshold[];
|
||||
valueOptions: SingleStatValueOptions;
|
||||
orientation: VizOrientation;
|
||||
}
|
||||
|
||||
export interface SingleStatValueOptions {
|
||||
unit: string;
|
||||
suffix: string;
|
||||
stat: string;
|
||||
prefix: string;
|
||||
decimals?: number | null;
|
||||
}
|
||||
|
||||
export interface GetSingleStatDisplayValueOptions {
|
||||
data: SeriesData[];
|
||||
theme: GrafanaTheme;
|
||||
valueMappings: ValueMapping[];
|
||||
thresholds: Threshold[];
|
||||
valueOptions: SingleStatValueOptions;
|
||||
replaceVariables: InterpolateFunction;
|
||||
}
|
||||
|
||||
export const getSingleStatDisplayValues = (options: GetSingleStatDisplayValueOptions): DisplayValue[] => {
|
||||
const { data, replaceVariables, valueOptions } = options;
|
||||
const { unit, decimals, stat } = valueOptions;
|
||||
|
||||
const display = getDisplayProcessor({
|
||||
unit,
|
||||
decimals,
|
||||
mappings: options.valueMappings,
|
||||
thresholds: options.thresholds,
|
||||
prefix: replaceVariables(valueOptions.prefix),
|
||||
suffix: replaceVariables(valueOptions.suffix),
|
||||
theme: options.theme,
|
||||
});
|
||||
|
||||
const values: DisplayValue[] = [];
|
||||
|
||||
for (const series of data) {
|
||||
if (stat === 'name') {
|
||||
values.push(display(series.name));
|
||||
}
|
||||
|
||||
for (let i = 0; i < series.fields.length; i++) {
|
||||
const column = series.fields[i];
|
||||
|
||||
// Show all fields that are not 'time'
|
||||
if (column.type === FieldType.number) {
|
||||
const stats = calculateStats({
|
||||
series,
|
||||
fieldIndex: i,
|
||||
stats: [stat], // The stats to calculate
|
||||
nullValueMode: NullValueMode.Null,
|
||||
});
|
||||
const displayValue = display(stats[stat]);
|
||||
values.push(displayValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (values.length === 0) {
|
||||
values.push({
|
||||
numeric: 0,
|
||||
text: 'No data',
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings'];
|
||||
|
||||
export const sharedSingleStatOptionsCheck = (
|
||||
options: Partial<SingleStatBaseOptions> | any,
|
||||
prevPluginId: string,
|
||||
prevOptions: any
|
||||
) => {
|
||||
for (const k of optionsToKeep) {
|
||||
if (prevOptions.hasOwnProperty(k)) {
|
||||
options[k] = cloneDeep(prevOptions[k]);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
export const sharedSingleStatMigrationCheck = (panel: PanelModel<SingleStatBaseOptions>) => {
|
||||
const options = panel.options;
|
||||
|
||||
if (!options) {
|
||||
// This happens on the first load or when migrating from angular
|
||||
return {};
|
||||
}
|
||||
|
||||
if (options.valueOptions) {
|
||||
// 6.1 renamed some stats, This makes sure they are up to date
|
||||
// avg -> mean, current -> last, total -> sum
|
||||
const { valueOptions } = options;
|
||||
if (valueOptions && valueOptions.stat) {
|
||||
valueOptions.stat = getStatsCalculators([valueOptions.stat]).map(s => s.id)[0];
|
||||
}
|
||||
}
|
||||
return options;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
@import 'CustomScrollbar/CustomScrollbar';
|
||||
@import 'BigValue/BigValue';
|
||||
@import 'DeleteButton/DeleteButton';
|
||||
@import 'ThresholdsEditor/ThresholdsEditor';
|
||||
@import 'Table/Table';
|
||||
|
||||
@@ -33,9 +33,11 @@ export { StatsPicker } from './StatsPicker/StatsPicker';
|
||||
export { Input, InputStatus } from './Input/Input';
|
||||
|
||||
// Visualizations
|
||||
export { BigValue } from './BigValue/BigValue';
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { BarGauge } from './BarGauge/BarGauge';
|
||||
export { VizRepeater } from './VizRepeater/VizRepeater';
|
||||
export * from './SingleStatShared/shared';
|
||||
|
||||
export { CallToActionCard } from './CallToActionCard/CallToActionCard';
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface DisplayValue {
|
||||
numeric: number; // Use isNaN to check if it is a real number
|
||||
color?: string; // color based on configs or Threshold
|
||||
title?: string;
|
||||
fontSize?: string;
|
||||
}
|
||||
|
||||
export interface DecimalInfo {
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Services & Utils
|
||||
import { DisplayValue, PanelProps, BarGauge } from '@grafana/ui';
|
||||
import { DisplayValue, PanelProps, BarGauge, getSingleStatDisplayValues } from '@grafana/ui';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
// Types
|
||||
import { BarGaugeOptions } from './types';
|
||||
import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
|
||||
import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater';
|
||||
|
||||
export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
|
||||
@@ -28,7 +27,14 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
|
||||
};
|
||||
|
||||
getProcessedValues = (): DisplayValue[] => {
|
||||
return getSingleStatValues(this.props);
|
||||
return getSingleStatDisplayValues({
|
||||
valueMappings: this.props.options.valueMappings,
|
||||
thresholds: this.props.options.thresholds,
|
||||
valueOptions: this.props.options.valueOptions,
|
||||
data: this.props.data,
|
||||
theme: config.theme,
|
||||
replaceVariables: this.props.replaceVariables,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Components
|
||||
import { ThresholdsEditor, ValueMappingsEditor, PanelOptionsGrid, PanelOptionsGroup, FormField } from '@grafana/ui';
|
||||
import {
|
||||
ThresholdsEditor,
|
||||
ValueMappingsEditor,
|
||||
PanelOptionsGrid,
|
||||
PanelOptionsGroup,
|
||||
FormField,
|
||||
SingleStatValueOptions,
|
||||
SingleStatValueEditor,
|
||||
} from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { FormLabel, PanelEditorProps, Threshold, Select, ValueMapping } from '@grafana/ui';
|
||||
import { BarGaugeOptions, orientationOptions, displayModes } from './types';
|
||||
import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
|
||||
import { SingleStatValueOptions } from '../singlestat2/types';
|
||||
|
||||
export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGaugeOptions>> {
|
||||
onThresholdsChanged = (thresholds: Threshold[]) =>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ReactPanelPlugin } from '@grafana/ui';
|
||||
import { ReactPanelPlugin, sharedSingleStatOptionsCheck } from '@grafana/ui';
|
||||
|
||||
import { BarGaugePanel } from './BarGaugePanel';
|
||||
import { BarGaugePanelEditor } from './BarGaugePanelEditor';
|
||||
import { BarGaugeOptions, defaults } from './types';
|
||||
import { singleStatBaseOptionsCheck } from '../singlestat2/module';
|
||||
|
||||
export const reactPanel = new ReactPanelPlugin<BarGaugeOptions>(BarGaugePanel)
|
||||
.setDefaults(defaults)
|
||||
.setEditor(BarGaugePanelEditor)
|
||||
.setPanelChangeHandler(singleStatBaseOptionsCheck);
|
||||
.setPanelChangeHandler(sharedSingleStatOptionsCheck);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { VizOrientation, SelectOptionItem, StatID } from '@grafana/ui';
|
||||
import { SingleStatBaseOptions } from '../singlestat2/types';
|
||||
import { VizOrientation, SelectOptionItem, StatID, SingleStatBaseOptions } from '@grafana/ui';
|
||||
|
||||
export interface BarGaugeOptions extends SingleStatBaseOptions {
|
||||
minValue: number;
|
||||
|
||||
@@ -9,8 +9,7 @@ import { Gauge } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { GaugeOptions } from './types';
|
||||
import { DisplayValue, PanelProps } from '@grafana/ui';
|
||||
import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
|
||||
import { DisplayValue, PanelProps, getSingleStatDisplayValues } from '@grafana/ui';
|
||||
import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater';
|
||||
|
||||
export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
|
||||
@@ -33,7 +32,14 @@ export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
|
||||
};
|
||||
|
||||
getProcessedValues = (): DisplayValue[] => {
|
||||
return getSingleStatValues(this.props);
|
||||
return getSingleStatDisplayValues({
|
||||
valueMappings: this.props.options.valueMappings,
|
||||
thresholds: this.props.options.thresholds,
|
||||
valueOptions: this.props.options.valueOptions,
|
||||
data: this.props.data,
|
||||
theme: config.theme,
|
||||
replaceVariables: this.props.replaceVariables,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
PanelOptionsGrid,
|
||||
ValueMappingsEditor,
|
||||
ValueMapping,
|
||||
SingleStatValueOptions,
|
||||
SingleStatValueEditor,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import { GaugeOptionsBox } from './GaugeOptionsBox';
|
||||
import { GaugeOptions } from './types';
|
||||
import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
|
||||
import { SingleStatValueOptions } from '../singlestat2/types';
|
||||
|
||||
export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOptions>> {
|
||||
onThresholdsChanged = (thresholds: Threshold[]) =>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ReactPanelPlugin } from '@grafana/ui';
|
||||
|
||||
import { ReactPanelPlugin, sharedSingleStatMigrationCheck, sharedSingleStatOptionsCheck } from '@grafana/ui';
|
||||
import { GaugePanelEditor } from './GaugePanelEditor';
|
||||
import { GaugePanel } from './GaugePanel';
|
||||
import { GaugeOptions, defaults } from './types';
|
||||
import { singleStatBaseOptionsCheck, singleStatMigrationCheck } from '../singlestat2/module';
|
||||
|
||||
export const reactPanel = new ReactPanelPlugin<GaugeOptions>(GaugePanel)
|
||||
.setDefaults(defaults)
|
||||
.setEditor(GaugePanelEditor)
|
||||
.setPanelChangeHandler(singleStatBaseOptionsCheck)
|
||||
.setMigrationHandler(singleStatMigrationCheck);
|
||||
.setPanelChangeHandler(sharedSingleStatOptionsCheck)
|
||||
.setMigrationHandler(sharedSingleStatMigrationCheck);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SingleStatBaseOptions } from '../singlestat2/types';
|
||||
import { VizOrientation, StatID } from '@grafana/ui';
|
||||
import { VizOrientation, StatID, SingleStatBaseOptions } from '@grafana/ui';
|
||||
|
||||
export interface GaugeOptions extends SingleStatBaseOptions {
|
||||
maxValue: number;
|
||||
|
||||
@@ -5,20 +5,26 @@ import React, { PureComponent } from 'react';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
// Components
|
||||
import { PieChart } from '@grafana/ui';
|
||||
import { PieChart, getSingleStatDisplayValues } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { PieChartOptions } from './types';
|
||||
import { PanelProps } from '@grafana/ui/src/types';
|
||||
import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
|
||||
|
||||
interface Props extends PanelProps<PieChartOptions> {}
|
||||
|
||||
export class PieChartPanel extends PureComponent<Props> {
|
||||
render() {
|
||||
const { width, height, options } = this.props;
|
||||
const { width, height, options, data, replaceVariables } = this.props;
|
||||
|
||||
const values = getSingleStatValues(this.props);
|
||||
const values = getSingleStatDisplayValues({
|
||||
valueMappings: options.valueMappings,
|
||||
thresholds: options.thresholds,
|
||||
valueOptions: options.valueOptions,
|
||||
data: data,
|
||||
theme: config.theme,
|
||||
replaceVariables: replaceVariables,
|
||||
});
|
||||
|
||||
return (
|
||||
<PieChart
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { PanelEditorProps, PanelOptionsGrid, ValueMappingsEditor, ValueMapping } from '@grafana/ui';
|
||||
import {
|
||||
PanelEditorProps,
|
||||
PanelOptionsGrid,
|
||||
ValueMappingsEditor,
|
||||
ValueMapping,
|
||||
SingleStatValueOptions,
|
||||
SingleStatValueEditor,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import { PieChartOptionsBox } from './PieChartOptionsBox';
|
||||
import { PieChartOptions } from './types';
|
||||
import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
|
||||
import { SingleStatValueOptions } from '../singlestat2/types';
|
||||
|
||||
export class PieChartPanelEditor extends PureComponent<PanelEditorProps<PieChartOptions>> {
|
||||
onValueMappingsChanged = (valueMappings: ValueMapping[]) =>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PieChartType, StatID, VizOrientation } from '@grafana/ui';
|
||||
import { SingleStatBaseOptions } from '../singlestat2/types';
|
||||
import { PieChartType, StatID, VizOrientation, SingleStatBaseOptions } from '@grafana/ui';
|
||||
|
||||
export interface PieChartOptions extends SingleStatBaseOptions {
|
||||
pieType: PieChartType;
|
||||
|
||||
68
public/app/plugins/panel/singlestat2/ColoringEditor.tsx
Normal file
68
public/app/plugins/panel/singlestat2/ColoringEditor.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Components
|
||||
import { Switch, PanelOptionsGroup } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { SingleStatOptions } from './types';
|
||||
|
||||
const labelWidth = 6;
|
||||
|
||||
export interface Props {
|
||||
options: SingleStatOptions;
|
||||
onChange: (options: SingleStatOptions) => void;
|
||||
}
|
||||
|
||||
// colorBackground?: boolean;
|
||||
// colorValue?: boolean;
|
||||
// colorPrefix?: boolean;
|
||||
// colorPostfix?: boolean;
|
||||
|
||||
export class ColoringEditor extends PureComponent<Props> {
|
||||
onToggleColorBackground = () =>
|
||||
this.props.onChange({ ...this.props.options, colorBackground: !this.props.options.colorBackground });
|
||||
|
||||
onToggleColorValue = () => this.props.onChange({ ...this.props.options, colorValue: !this.props.options.colorValue });
|
||||
|
||||
onToggleColorPrefix = () =>
|
||||
this.props.onChange({ ...this.props.options, colorPrefix: !this.props.options.colorPrefix });
|
||||
|
||||
onToggleColorPostfix = () =>
|
||||
this.props.onChange({ ...this.props.options, colorPostfix: !this.props.options.colorPostfix });
|
||||
|
||||
render() {
|
||||
const { colorBackground, colorValue, colorPrefix, colorPostfix } = this.props.options;
|
||||
|
||||
return (
|
||||
<PanelOptionsGroup title="Coloring">
|
||||
<Switch
|
||||
label="Background"
|
||||
labelClass={`width-${labelWidth}`}
|
||||
checked={colorBackground}
|
||||
onChange={this.onToggleColorBackground}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="Value"
|
||||
labelClass={`width-${labelWidth}`}
|
||||
checked={colorValue}
|
||||
onChange={this.onToggleColorValue}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="Prefix"
|
||||
labelClass={`width-${labelWidth}`}
|
||||
checked={colorPrefix}
|
||||
onChange={this.onToggleColorPrefix}
|
||||
/>
|
||||
<Switch
|
||||
label="Postfix"
|
||||
labelClass={`width-${labelWidth}`}
|
||||
checked={colorPostfix}
|
||||
onChange={this.onToggleColorPostfix}
|
||||
/>
|
||||
</PanelOptionsGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
67
public/app/plugins/panel/singlestat2/FontSizeEditor.tsx
Normal file
67
public/app/plugins/panel/singlestat2/FontSizeEditor.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Components
|
||||
import { FormLabel, Select, PanelOptionsGroup, SelectOptionItem } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { SingleStatOptions } from './types';
|
||||
|
||||
const labelWidth = 6;
|
||||
|
||||
export interface Props {
|
||||
options: SingleStatOptions;
|
||||
onChange: (options: SingleStatOptions) => void;
|
||||
}
|
||||
|
||||
const percents = ['20%', '30%', '50%', '70%', '80%', '100%', '110%', '120%', '150%', '170%', '200%'];
|
||||
const fontSizeOptions = percents.map(v => {
|
||||
return { value: v, label: v };
|
||||
});
|
||||
|
||||
export class FontSizeEditor extends PureComponent<Props> {
|
||||
setPrefixFontSize = (v: SelectOptionItem) => this.props.onChange({ ...this.props.options, prefixFontSize: v.value });
|
||||
|
||||
setValueFontSize = (v: SelectOptionItem) => this.props.onChange({ ...this.props.options, valueFontSize: v.value });
|
||||
|
||||
setPostfixFontSize = (v: SelectOptionItem) =>
|
||||
this.props.onChange({ ...this.props.options, postfixFontSize: v.value });
|
||||
|
||||
render() {
|
||||
const { prefixFontSize, valueFontSize, postfixFontSize } = this.props.options;
|
||||
|
||||
return (
|
||||
<PanelOptionsGroup title="Font Size">
|
||||
<div className="gf-form">
|
||||
<FormLabel width={labelWidth}>Prefix</FormLabel>
|
||||
<Select
|
||||
width={12}
|
||||
options={fontSizeOptions}
|
||||
onChange={this.setPrefixFontSize}
|
||||
value={fontSizeOptions.find(option => option.value === prefixFontSize)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gf-form">
|
||||
<FormLabel width={labelWidth}>Value</FormLabel>
|
||||
<Select
|
||||
width={12}
|
||||
options={fontSizeOptions}
|
||||
onChange={this.setValueFontSize}
|
||||
value={fontSizeOptions.find(option => option.value === valueFontSize)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gf-form">
|
||||
<FormLabel width={labelWidth}>Postfix</FormLabel>
|
||||
<Select
|
||||
width={12}
|
||||
options={fontSizeOptions}
|
||||
onChange={this.setPostfixFontSize}
|
||||
value={fontSizeOptions.find(option => option.value === postfixFontSize)}
|
||||
/>
|
||||
</div>
|
||||
</PanelOptionsGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
PanelOptionsGrid,
|
||||
ValueMappingsEditor,
|
||||
ValueMapping,
|
||||
SingleStatValueOptions,
|
||||
SingleStatValueEditor,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import { SingleStatOptions, SingleStatValueOptions } from './types';
|
||||
import { SingleStatValueEditor } from './SingleStatValueEditor';
|
||||
import { SingleStatOptions, SparklineOptions } from './types';
|
||||
import { ColoringEditor } from './ColoringEditor';
|
||||
import { FontSizeEditor } from './FontSizeEditor';
|
||||
import { SparklineEditor } from './SparklineEditor';
|
||||
|
||||
export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatOptions>> {
|
||||
onThresholdsChanged = (thresholds: Threshold[]) =>
|
||||
@@ -31,6 +35,12 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
|
||||
valueOptions,
|
||||
});
|
||||
|
||||
onSparklineChanged = (sparkline: SparklineOptions) =>
|
||||
this.props.onOptionsChange({
|
||||
...this.props.options,
|
||||
sparkline,
|
||||
});
|
||||
|
||||
render() {
|
||||
const { options } = this.props;
|
||||
|
||||
@@ -38,6 +48,10 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
|
||||
<>
|
||||
<PanelOptionsGrid>
|
||||
<SingleStatValueEditor onChange={this.onValueOptionsChanged} options={options.valueOptions} />
|
||||
<FontSizeEditor options={options} onChange={this.props.onOptionsChange} />
|
||||
<ColoringEditor options={options} onChange={this.props.onOptionsChange} />
|
||||
<SparklineEditor options={options.sparkline} onChange={this.onSparklineChanged} />
|
||||
|
||||
<ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
|
||||
</PanelOptionsGrid>
|
||||
|
||||
|
||||
@@ -1,82 +1,127 @@
|
||||
// Libraries
|
||||
import React, { PureComponent, CSSProperties } from 'react';
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Types
|
||||
import { SingleStatOptions, SingleStatBaseOptions } from './types';
|
||||
|
||||
import { DisplayValue, PanelProps, NullValueMode, FieldType, calculateStats } from '@grafana/ui';
|
||||
// Utils & Services
|
||||
import { config } from 'app/core/config';
|
||||
import { getDisplayProcessor } from '@grafana/ui';
|
||||
import { getFlotPairs } from '@grafana/ui/src/utils/flotPairs';
|
||||
|
||||
// Components
|
||||
import { ProcessedValuesRepeater } from './ProcessedValuesRepeater';
|
||||
|
||||
export const getSingleStatValues = (props: PanelProps<SingleStatBaseOptions>): DisplayValue[] => {
|
||||
const { data, replaceVariables, options } = props;
|
||||
const { valueOptions, valueMappings } = options;
|
||||
const { unit, decimals, stat } = valueOptions;
|
||||
// Types
|
||||
import { SingleStatOptions } from './types';
|
||||
import { BigValueSparkline, BigValue } from '@grafana/ui/src/components/BigValue/BigValue';
|
||||
import {
|
||||
DisplayValue,
|
||||
PanelProps,
|
||||
getDisplayProcessor,
|
||||
NullValueMode,
|
||||
FieldType,
|
||||
calculateStats,
|
||||
getFirstTimeField,
|
||||
} from '@grafana/ui';
|
||||
|
||||
const display = getDisplayProcessor({
|
||||
unit,
|
||||
decimals,
|
||||
mappings: valueMappings,
|
||||
thresholds: options.thresholds,
|
||||
prefix: replaceVariables(valueOptions.prefix),
|
||||
suffix: replaceVariables(valueOptions.suffix),
|
||||
theme: config.theme,
|
||||
});
|
||||
|
||||
const values: DisplayValue[] = [];
|
||||
|
||||
for (const series of data) {
|
||||
if (stat === 'name') {
|
||||
values.push(display(series.name));
|
||||
}
|
||||
|
||||
for (let i = 0; i < series.fields.length; i++) {
|
||||
const column = series.fields[i];
|
||||
|
||||
// Show all columns that are not 'time'
|
||||
if (column.type === FieldType.number) {
|
||||
const stats = calculateStats({
|
||||
series,
|
||||
fieldIndex: i,
|
||||
stats: [stat], // The stats to calculate
|
||||
nullValueMode: NullValueMode.Null,
|
||||
});
|
||||
const displayValue = display(stats[stat]);
|
||||
values.push(displayValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (values.length === 0) {
|
||||
values.push({
|
||||
numeric: 0,
|
||||
text: 'No data',
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
interface SingleStatDisplay {
|
||||
value: DisplayValue;
|
||||
prefix?: DisplayValue;
|
||||
suffix?: DisplayValue;
|
||||
sparkline?: BigValueSparkline;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>> {
|
||||
renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
|
||||
const style: CSSProperties = {};
|
||||
style.margin = '0 auto';
|
||||
style.fontSize = '250%';
|
||||
style.textAlign = 'center';
|
||||
if (value.color) {
|
||||
style.color = value.color;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width, height }}>
|
||||
<div style={style}>{value.text}</div>
|
||||
</div>
|
||||
);
|
||||
renderValue = (value: SingleStatDisplay, width: number, height: number): JSX.Element => {
|
||||
return <BigValue {...value} width={width} height={height} theme={config.theme} />;
|
||||
};
|
||||
|
||||
getProcessedValues = (): DisplayValue[] => {
|
||||
return getSingleStatValues(this.props);
|
||||
getProcessedValues = (): SingleStatDisplay[] => {
|
||||
const { data, replaceVariables, options, timeRange } = this.props;
|
||||
const { valueOptions, valueMappings } = options;
|
||||
|
||||
const display = getDisplayProcessor({
|
||||
unit: valueOptions.unit,
|
||||
decimals: valueOptions.decimals,
|
||||
mappings: valueMappings,
|
||||
thresholds: options.thresholds,
|
||||
theme: config.theme,
|
||||
});
|
||||
|
||||
const { colorBackground, colorValue, colorPrefix, colorPostfix, sparkline } = options;
|
||||
const { stat } = valueOptions;
|
||||
|
||||
const values: SingleStatDisplay[] = [];
|
||||
|
||||
for (const series of data) {
|
||||
const timeColumn = sparkline.show ? getFirstTimeField(series) : -1;
|
||||
|
||||
for (let i = 0; i < series.fields.length; i++) {
|
||||
const column = series.fields[i];
|
||||
|
||||
// Show all fields that are not 'time'
|
||||
if (column.type === FieldType.number) {
|
||||
const stats = calculateStats({
|
||||
series,
|
||||
fieldIndex: i,
|
||||
stats: [stat], // The stats to calculate
|
||||
nullValueMode: NullValueMode.Null,
|
||||
});
|
||||
|
||||
const v: SingleStatDisplay = {
|
||||
value: display(stats[stat]),
|
||||
};
|
||||
|
||||
const color = v.value.color;
|
||||
if (!colorValue) {
|
||||
delete v.value.color;
|
||||
}
|
||||
|
||||
if (colorBackground) {
|
||||
v.backgroundColor = color;
|
||||
}
|
||||
|
||||
if (options.valueFontSize) {
|
||||
v.value.fontSize = options.valueFontSize;
|
||||
}
|
||||
|
||||
if (valueOptions.prefix) {
|
||||
v.prefix = {
|
||||
text: replaceVariables(valueOptions.prefix),
|
||||
numeric: NaN,
|
||||
color: colorPrefix ? color : null,
|
||||
fontSize: options.prefixFontSize,
|
||||
};
|
||||
}
|
||||
if (valueOptions.suffix) {
|
||||
v.suffix = {
|
||||
text: replaceVariables(valueOptions.suffix),
|
||||
numeric: NaN,
|
||||
color: colorPostfix ? color : null,
|
||||
fontSize: options.postfixFontSize,
|
||||
};
|
||||
}
|
||||
|
||||
if (sparkline.show && timeColumn >= 0) {
|
||||
const points = getFlotPairs({
|
||||
series,
|
||||
xIndex: timeColumn,
|
||||
yIndex: i,
|
||||
nullValueMode: NullValueMode.Null,
|
||||
});
|
||||
|
||||
v.sparkline = {
|
||||
...sparkline,
|
||||
data: points,
|
||||
minX: timeRange.from.valueOf(),
|
||||
maxX: timeRange.to.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
values.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
33
public/app/plugins/panel/singlestat2/SparklineEditor.tsx
Normal file
33
public/app/plugins/panel/singlestat2/SparklineEditor.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Components
|
||||
import { Switch, PanelOptionsGroup } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { SparklineOptions } from './types';
|
||||
|
||||
const labelWidth = 6;
|
||||
|
||||
export interface Props {
|
||||
options: SparklineOptions;
|
||||
onChange: (options: SparklineOptions) => void;
|
||||
}
|
||||
|
||||
export class SparklineEditor extends PureComponent<Props> {
|
||||
onToggleShow = () => this.props.onChange({ ...this.props.options, show: !this.props.options.show });
|
||||
|
||||
onToggleFull = () => this.props.onChange({ ...this.props.options, full: !this.props.options.full });
|
||||
|
||||
render() {
|
||||
const { show, full } = this.props.options;
|
||||
|
||||
return (
|
||||
<PanelOptionsGroup title="Sparkline">
|
||||
<Switch label="Show" labelClass={`width-${labelWidth}`} checked={show} onChange={this.onToggleShow} />
|
||||
|
||||
<Switch label="Full Height" labelClass={`width-${labelWidth}`} checked={full} onChange={this.onToggleFull} />
|
||||
</PanelOptionsGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,10 @@
|
||||
import { ReactPanelPlugin, getStatsCalculators, PanelModel } from '@grafana/ui';
|
||||
import { SingleStatOptions, defaults, SingleStatBaseOptions } from './types';
|
||||
import { ReactPanelPlugin, sharedSingleStatMigrationCheck, sharedSingleStatOptionsCheck } from '@grafana/ui';
|
||||
import { SingleStatOptions, defaults } from './types';
|
||||
import { SingleStatPanel } from './SingleStatPanel';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { SingleStatEditor } from './SingleStatEditor';
|
||||
|
||||
const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings'];
|
||||
|
||||
export const singleStatBaseOptionsCheck = (
|
||||
options: Partial<SingleStatBaseOptions>,
|
||||
prevPluginId: string,
|
||||
prevOptions: any
|
||||
) => {
|
||||
for (const k of optionsToKeep) {
|
||||
if (prevOptions.hasOwnProperty(k)) {
|
||||
options[k] = cloneDeep(prevOptions[k]);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
export const singleStatMigrationCheck = (panel: PanelModel<SingleStatOptions>) => {
|
||||
const options = panel.options;
|
||||
|
||||
if (!options) {
|
||||
// This happens on the first load or when migrating from angular
|
||||
return {};
|
||||
}
|
||||
|
||||
if (options.valueOptions) {
|
||||
// 6.1 renamed some stats, This makes sure they are up to date
|
||||
// avg -> mean, current -> last, total -> sum
|
||||
const { valueOptions } = options;
|
||||
if (valueOptions && valueOptions.stat) {
|
||||
valueOptions.stat = getStatsCalculators([valueOptions.stat]).map(s => s.id)[0];
|
||||
}
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
export const reactPanel = new ReactPanelPlugin<SingleStatOptions>(SingleStatPanel)
|
||||
.setDefaults(defaults)
|
||||
.setEditor(SingleStatEditor)
|
||||
.setPanelChangeHandler(singleStatMigrationCheck)
|
||||
.setMigrationHandler(singleStatMigrationCheck);
|
||||
.setPanelChangeHandler(sharedSingleStatOptionsCheck)
|
||||
.setMigrationHandler(sharedSingleStatMigrationCheck);
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import { VizOrientation, ValueMapping, Threshold, StatID } from '@grafana/ui';
|
||||
import { VizOrientation, StatID, SingleStatBaseOptions } from '@grafana/ui';
|
||||
|
||||
export interface SingleStatBaseOptions {
|
||||
valueMappings: ValueMapping[];
|
||||
thresholds: Threshold[];
|
||||
valueOptions: SingleStatValueOptions;
|
||||
orientation: VizOrientation;
|
||||
}
|
||||
|
||||
export interface SingleStatValueOptions {
|
||||
unit: string;
|
||||
suffix: string;
|
||||
stat: string;
|
||||
prefix: string;
|
||||
decimals?: number | null;
|
||||
export interface SparklineOptions {
|
||||
show: boolean;
|
||||
full: boolean; // full height
|
||||
fillColor: string;
|
||||
lineColor: string;
|
||||
}
|
||||
|
||||
// Structure copied from angular
|
||||
export interface SingleStatOptions extends SingleStatBaseOptions {
|
||||
// TODO, fill in with options from angular
|
||||
prefixFontSize?: string;
|
||||
valueFontSize?: string;
|
||||
postfixFontSize?: string;
|
||||
|
||||
colorBackground?: boolean;
|
||||
colorValue?: boolean;
|
||||
colorPrefix?: boolean;
|
||||
colorPostfix?: boolean;
|
||||
|
||||
sparkline: SparklineOptions;
|
||||
}
|
||||
|
||||
export const defaults: SingleStatOptions = {
|
||||
sparkline: {
|
||||
show: true,
|
||||
full: false,
|
||||
lineColor: 'rgb(31, 120, 193)',
|
||||
fillColor: 'rgba(31, 118, 189, 0.18)',
|
||||
},
|
||||
|
||||
valueOptions: {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
|
||||
Reference in New Issue
Block a user