mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Feature: Migrate Legend components to grafana/ui (#16468)
* Introduced Abstract list, List and InlineList components for easier lists generation * Enable custom item key on abstract list items * Enable $.flot in storybook * Expose onOptionsChange to react panel. Allow React panels to be function components * Update type on graph panel options to group graph draw options * Introduce GraphPanelController for state and effects handling of new graph panel * Group visualisation related stories under Visualisations
This commit is contained in:
@@ -2,7 +2,17 @@ import { configure, addDecorator } from '@storybook/react';
|
|||||||
import { withKnobs } from '@storybook/addon-knobs';
|
import { withKnobs } from '@storybook/addon-knobs';
|
||||||
import { withTheme } from '../src/utils/storybook/withTheme';
|
import { withTheme } from '../src/utils/storybook/withTheme';
|
||||||
import { withPaddedStory } from '../src/utils/storybook/withPaddedStory';
|
import { withPaddedStory } from '../src/utils/storybook/withPaddedStory';
|
||||||
|
import 'jquery';
|
||||||
|
import '../../../public/vendor/flot/jquery.flot.js';
|
||||||
|
import '../../../public/vendor/flot/jquery.flot.selection';
|
||||||
|
import '../../../public/vendor/flot/jquery.flot.time';
|
||||||
|
import '../../../public/vendor/flot/jquery.flot.stack';
|
||||||
|
import '../../../public/vendor/flot/jquery.flot.pie';
|
||||||
|
import '../../../public/vendor/flot/jquery.flot.stackpercent';
|
||||||
|
import '../../../public/vendor/flot/jquery.flot.fillbelow';
|
||||||
|
import '../../../public/vendor/flot/jquery.flot.crosshair';
|
||||||
|
import '../../../public/vendor/flot/jquery.flot.dashes';
|
||||||
|
import '../../../public/vendor/flot/jquery.flot.gauge';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import lightTheme from '../../../public/sass/grafana.light.scss';
|
import lightTheme from '../../../public/sass/grafana.light.scss';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { Component } from 'react';
|
|||||||
import isNil from 'lodash/isNil';
|
import isNil from 'lodash/isNil';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Scrollbars from 'react-custom-scrollbars';
|
import Scrollbars from 'react-custom-scrollbars';
|
||||||
|
import { cx, css } from 'emotion';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -10,8 +11,8 @@ interface Props {
|
|||||||
autoHideDuration?: number;
|
autoHideDuration?: number;
|
||||||
autoHeightMax?: string;
|
autoHeightMax?: string;
|
||||||
hideTracksWhenNotNeeded?: boolean;
|
hideTracksWhenNotNeeded?: boolean;
|
||||||
renderTrackHorizontal?: React.FunctionComponent<any>;
|
hideHorizontalTrack?: boolean;
|
||||||
renderTrackVertical?: React.FunctionComponent<any>;
|
hideVerticalTrack?: boolean;
|
||||||
scrollTop?: number;
|
scrollTop?: number;
|
||||||
setScrollTop: (event: any) => void;
|
setScrollTop: (event: any) => void;
|
||||||
autoHeightMin?: number | string;
|
autoHeightMin?: number | string;
|
||||||
@@ -79,8 +80,8 @@ export class CustomScrollbar extends Component<Props> {
|
|||||||
autoHide,
|
autoHide,
|
||||||
autoHideTimeout,
|
autoHideTimeout,
|
||||||
hideTracksWhenNotNeeded,
|
hideTracksWhenNotNeeded,
|
||||||
renderTrackHorizontal,
|
hideHorizontalTrack,
|
||||||
renderTrackVertical,
|
hideVerticalTrack,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,8 +97,28 @@ export class CustomScrollbar extends Component<Props> {
|
|||||||
// Before these where set to inhert but that caused problems with cut of legends in firefox
|
// Before these where set to inhert but that caused problems with cut of legends in firefox
|
||||||
autoHeightMax={autoHeightMax}
|
autoHeightMax={autoHeightMax}
|
||||||
autoHeightMin={autoHeightMin}
|
autoHeightMin={autoHeightMin}
|
||||||
renderTrackHorizontal={renderTrackHorizontal || (props => <div {...props} className="track-horizontal" />)}
|
renderTrackHorizontal={props => (
|
||||||
renderTrackVertical={renderTrackVertical || (props => <div {...props} className="track-vertical" />)}
|
<div
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
css`
|
||||||
|
visibility: ${hideHorizontalTrack ? 'none' : 'visible'};
|
||||||
|
`,
|
||||||
|
'track-horizontal'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderTrackVertical={props => (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
css`
|
||||||
|
visibility: ${hideVerticalTrack ? 'none' : 'visible'};
|
||||||
|
`,
|
||||||
|
'track-vertical'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
|
renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
|
||||||
renderThumbVertical={props => <div {...props} className="thumb-vertical" />}
|
renderThumbVertical={props => <div {...props} className="thumb-vertical" />}
|
||||||
renderView={props => <div {...props} className="view" />}
|
renderView={props => <div {...props} className="view" />}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="track-horizontal"
|
className="css-17l4171 track-horizontal"
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"display": "none",
|
"display": "none",
|
||||||
@@ -58,7 +58,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="track-vertical"
|
className="css-17l4171 track-vertical"
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"display": "none",
|
"display": "none",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// Libraries
|
// Libraries
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
|
import uniqBy from 'lodash/uniqBy';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import { TimeRange, GraphSeriesXY } from '../../types';
|
import { TimeRange, GraphSeriesXY } from '../../types';
|
||||||
|
|
||||||
interface GraphProps {
|
export interface GraphProps {
|
||||||
series: GraphSeriesXY[];
|
series: GraphSeriesXY[];
|
||||||
timeRange: TimeRange; // NOTE: we should aim to make `time` a property of the axis, not force it for all graphs
|
timeRange: TimeRange; // NOTE: we should aim to make `time` a property of the axis, not force it for all graphs
|
||||||
showLines?: boolean;
|
showLines?: boolean;
|
||||||
@@ -46,7 +47,16 @@ export class Graph extends PureComponent<GraphProps> {
|
|||||||
const ticks = width / 100;
|
const ticks = width / 100;
|
||||||
const min = timeRange.from.valueOf();
|
const min = timeRange.from.valueOf();
|
||||||
const max = timeRange.to.valueOf();
|
const max = timeRange.to.valueOf();
|
||||||
|
const yaxes = uniqBy(
|
||||||
|
series.map(s => {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
index: s.yAxis,
|
||||||
|
position: s.yAxis === 1 ? 'left' : 'right',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
yAxisConfig => yAxisConfig.index
|
||||||
|
);
|
||||||
const flotOptions = {
|
const flotOptions = {
|
||||||
legend: {
|
legend: {
|
||||||
show: false,
|
show: false,
|
||||||
@@ -80,6 +90,7 @@ export class Graph extends PureComponent<GraphProps> {
|
|||||||
ticks: ticks,
|
ticks: ticks,
|
||||||
timeformat: timeFormat(ticks, min, max),
|
timeformat: timeFormat(ticks, min, max),
|
||||||
},
|
},
|
||||||
|
yaxes,
|
||||||
grid: {
|
grid: {
|
||||||
minBorderMargin: 0,
|
minBorderMargin: 0,
|
||||||
markings: [],
|
markings: [],
|
||||||
|
|||||||
105
packages/grafana-ui/src/components/Graph/GraphLegend.story.tsx
Normal file
105
packages/grafana-ui/src/components/Graph/GraphLegend.story.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { storiesOf } from '@storybook/react';
|
||||||
|
|
||||||
|
import { GraphLegend } from './GraphLegend';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { select, number } from '@storybook/addon-knobs';
|
||||||
|
import { withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||||
|
import { generateLegendItems } from '../Legend/Legend.story';
|
||||||
|
import { LegendPlacement, LegendDisplayMode } from '../Legend/Legend';
|
||||||
|
|
||||||
|
const GraphLegendStories = storiesOf('Visualizations/Graph/GraphLegend', module);
|
||||||
|
GraphLegendStories.addDecorator(withHorizontallyCenteredStory);
|
||||||
|
|
||||||
|
const getStoriesKnobs = (isList = false) => {
|
||||||
|
const statsToDisplay = select(
|
||||||
|
'Stats to display',
|
||||||
|
{
|
||||||
|
none: [],
|
||||||
|
'single (min)': [{ text: '10ms', title: 'min', numeric: 10 }],
|
||||||
|
'multiple (min, max)': [
|
||||||
|
{ text: '10ms', title: 'min', numeric: 10 },
|
||||||
|
{ text: '100ms', title: 'max', numeric: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const numberOfSeries = number('Number of series', 3);
|
||||||
|
|
||||||
|
const containerWidth = select(
|
||||||
|
'Container width',
|
||||||
|
{
|
||||||
|
Small: '200px',
|
||||||
|
Medium: '500px',
|
||||||
|
'Full width': '100%',
|
||||||
|
},
|
||||||
|
'100%'
|
||||||
|
);
|
||||||
|
|
||||||
|
const legendPlacement = select<LegendPlacement>(
|
||||||
|
'Legend placement',
|
||||||
|
{
|
||||||
|
under: 'under',
|
||||||
|
right: 'right',
|
||||||
|
},
|
||||||
|
'under'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statsToDisplay,
|
||||||
|
numberOfSeries,
|
||||||
|
containerWidth,
|
||||||
|
legendPlacement,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
GraphLegendStories.add('list', () => {
|
||||||
|
const { statsToDisplay, numberOfSeries, containerWidth, legendPlacement } = getStoriesKnobs(true);
|
||||||
|
return (
|
||||||
|
<div style={{ width: containerWidth }}>
|
||||||
|
<GraphLegend
|
||||||
|
displayMode={LegendDisplayMode.List}
|
||||||
|
items={generateLegendItems(numberOfSeries, statsToDisplay)}
|
||||||
|
onLabelClick={(item, event) => {
|
||||||
|
action('Series label clicked')(item, event);
|
||||||
|
}}
|
||||||
|
onSeriesColorChange={(label, color) => {
|
||||||
|
action('Series color changed')(label, color);
|
||||||
|
}}
|
||||||
|
onSeriesAxisToggle={(label, useRightYAxis) => {
|
||||||
|
action('Series axis toggle')(label, useRightYAxis);
|
||||||
|
}}
|
||||||
|
onToggleSort={sortBy => {
|
||||||
|
action('Toggle legend sort')(sortBy);
|
||||||
|
}}
|
||||||
|
placement={legendPlacement}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
GraphLegendStories.add('table', () => {
|
||||||
|
const { statsToDisplay, numberOfSeries, containerWidth, legendPlacement } = getStoriesKnobs();
|
||||||
|
return (
|
||||||
|
<div style={{ width: containerWidth }}>
|
||||||
|
<GraphLegend
|
||||||
|
displayMode={LegendDisplayMode.Table}
|
||||||
|
items={generateLegendItems(numberOfSeries, statsToDisplay)}
|
||||||
|
onLabelClick={item => {
|
||||||
|
action('Series label clicked')(item);
|
||||||
|
}}
|
||||||
|
onSeriesColorChange={(label, color) => {
|
||||||
|
action('Series color changed')(label, color);
|
||||||
|
}}
|
||||||
|
onSeriesAxisToggle={(label, useRightYAxis) => {
|
||||||
|
action('Series axis toggle')(label, useRightYAxis);
|
||||||
|
}}
|
||||||
|
onToggleSort={sortBy => {
|
||||||
|
action('Toggle legend sort')(sortBy);
|
||||||
|
}}
|
||||||
|
placement={legendPlacement}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
118
packages/grafana-ui/src/components/Graph/GraphLegend.tsx
Normal file
118
packages/grafana-ui/src/components/Graph/GraphLegend.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { LegendProps, LegendItem, LegendDisplayMode } from '../Legend/Legend';
|
||||||
|
import { GraphLegendListItem, GraphLegendTableRow } from './GraphLegendItem';
|
||||||
|
import { SeriesColorChangeHandler, SeriesAxisToggleHandler } from './GraphWithLegend';
|
||||||
|
import { LegendTable } from '../Legend/LegendTable';
|
||||||
|
import { LegendList } from '../Legend/LegendList';
|
||||||
|
import union from 'lodash/union';
|
||||||
|
import sortBy from 'lodash/sortBy';
|
||||||
|
import { ThemeContext } from '../../themes/ThemeContext';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import { selectThemeVariant } from '../../themes/index';
|
||||||
|
|
||||||
|
interface GraphLegendProps extends LegendProps {
|
||||||
|
displayMode: LegendDisplayMode;
|
||||||
|
sortBy?: string;
|
||||||
|
sortDesc?: boolean;
|
||||||
|
onSeriesColorChange: SeriesColorChangeHandler;
|
||||||
|
onSeriesAxisToggle?: SeriesAxisToggleHandler;
|
||||||
|
onToggleSort: (sortBy: string) => void;
|
||||||
|
onLabelClick: (item: LegendItem, event: React.MouseEvent<HTMLElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphLegend: React.FunctionComponent<GraphLegendProps> = ({
|
||||||
|
items,
|
||||||
|
displayMode,
|
||||||
|
sortBy: sortKey,
|
||||||
|
sortDesc,
|
||||||
|
onToggleSort,
|
||||||
|
onSeriesAxisToggle,
|
||||||
|
placement,
|
||||||
|
className,
|
||||||
|
...graphLegendItemProps
|
||||||
|
}) => {
|
||||||
|
const theme = useContext(ThemeContext);
|
||||||
|
|
||||||
|
if (displayMode === LegendDisplayMode.Table) {
|
||||||
|
const columns = items
|
||||||
|
.map(item => {
|
||||||
|
if (item.displayValues) {
|
||||||
|
return item.displayValues.map(i => i.title);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(acc, current) => {
|
||||||
|
return union(acc, current.filter(item => !!item));
|
||||||
|
},
|
||||||
|
['']
|
||||||
|
) as string[];
|
||||||
|
|
||||||
|
const sortedItems = sortKey
|
||||||
|
? sortBy(items, item => {
|
||||||
|
if (item.displayValues) {
|
||||||
|
const stat = item.displayValues.filter(stat => stat.title === sortKey)[0];
|
||||||
|
return stat && stat.numeric;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
: items;
|
||||||
|
|
||||||
|
const legendTableEvenRowBackground = selectThemeVariant(
|
||||||
|
{
|
||||||
|
dark: theme.colors.dark6,
|
||||||
|
light: theme.colors.gray5,
|
||||||
|
},
|
||||||
|
theme.type
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LegendTable
|
||||||
|
className={css`
|
||||||
|
font-size: ${theme.typography.size.sm};
|
||||||
|
th {
|
||||||
|
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
items={sortDesc ? sortedItems.reverse() : sortedItems}
|
||||||
|
columns={columns}
|
||||||
|
placement={placement}
|
||||||
|
sortBy={sortKey}
|
||||||
|
sortDesc={sortDesc}
|
||||||
|
itemRenderer={(item, index) => (
|
||||||
|
<GraphLegendTableRow
|
||||||
|
key={`${item.label}-${index}`}
|
||||||
|
item={item}
|
||||||
|
onToggleAxis={() => {
|
||||||
|
if (onSeriesAxisToggle) {
|
||||||
|
onSeriesAxisToggle(item.label, item.yAxis === 1 ? 2 : 1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={css`
|
||||||
|
background: ${index % 2 === 0 ? legendTableEvenRowBackground : 'none'};
|
||||||
|
`}
|
||||||
|
{...graphLegendItemProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onToggleSort={onToggleSort}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<LegendList
|
||||||
|
items={items}
|
||||||
|
placement={placement}
|
||||||
|
itemRenderer={item => (
|
||||||
|
<GraphLegendListItem
|
||||||
|
item={item}
|
||||||
|
onToggleAxis={() => {
|
||||||
|
if (onSeriesAxisToggle) {
|
||||||
|
onSeriesAxisToggle(item.label, item.yAxis === 1 ? 2 : 1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...graphLegendItemProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
117
packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx
Normal file
117
packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { css, cx } from 'emotion';
|
||||||
|
import { LegendSeriesIcon } from '../Legend/LegendSeriesIcon';
|
||||||
|
import { LegendItem } from '../Legend/Legend';
|
||||||
|
import { SeriesColorChangeHandler } from './GraphWithLegend';
|
||||||
|
import { LegendStatsList } from '../Legend/LegendStatsList';
|
||||||
|
import { ThemeContext } from '../../themes/ThemeContext';
|
||||||
|
|
||||||
|
export interface GraphLegendItemProps {
|
||||||
|
key?: React.Key;
|
||||||
|
item: LegendItem;
|
||||||
|
className?: string;
|
||||||
|
onLabelClick: (item: LegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
onSeriesColorChange: SeriesColorChangeHandler;
|
||||||
|
onToggleAxis: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphLegendListItem: React.FunctionComponent<GraphLegendItemProps> = ({
|
||||||
|
item,
|
||||||
|
onSeriesColorChange,
|
||||||
|
onToggleAxis,
|
||||||
|
onLabelClick,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LegendSeriesIcon
|
||||||
|
color={item.color}
|
||||||
|
onColorChange={color => onSeriesColorChange(item.label, color)}
|
||||||
|
onToggleAxis={onToggleAxis}
|
||||||
|
yAxis={item.yAxis}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onClick={event => onLabelClick(item, event)}
|
||||||
|
className={css`
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.displayValues && <LegendStatsList stats={item.displayValues} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GraphLegendTableRow: React.FunctionComponent<GraphLegendItemProps> = ({
|
||||||
|
item,
|
||||||
|
onSeriesColorChange,
|
||||||
|
onToggleAxis,
|
||||||
|
onLabelClick,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const theme = useContext(ThemeContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
className={cx(
|
||||||
|
css`
|
||||||
|
font-size: ${theme.typography.size.sm};
|
||||||
|
td {
|
||||||
|
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={css`
|
||||||
|
display: flex;
|
||||||
|
white-space: nowrap;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<LegendSeriesIcon
|
||||||
|
color={item.color}
|
||||||
|
onColorChange={color => onSeriesColorChange(item.label, color)}
|
||||||
|
onToggleAxis={onToggleAxis}
|
||||||
|
yAxis={item.yAxis}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onClick={event => onLabelClick(item, event)}
|
||||||
|
className={css`
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{item.label}{' '}
|
||||||
|
{item.yAxis === 2 && (
|
||||||
|
<span
|
||||||
|
className={css`
|
||||||
|
color: ${theme.colors.gray2};
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
(right y-axis)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{item.displayValues &&
|
||||||
|
item.displayValues.map((stat, index) => {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
className={css`
|
||||||
|
text-align: right;
|
||||||
|
`}
|
||||||
|
key={`${stat.title}-${index}`}
|
||||||
|
>
|
||||||
|
{stat.text}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { storiesOf } from '@storybook/react';
|
||||||
|
|
||||||
|
import { select, text } from '@storybook/addon-knobs';
|
||||||
|
import { withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||||
|
import { GraphWithLegend } from './GraphWithLegend';
|
||||||
|
|
||||||
|
import { mockGraphWithLegendData } from './mockGraphWithLegendData';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { LegendPlacement, LegendDisplayMode } from '../Legend/Legend';
|
||||||
|
const GraphWithLegendStories = storiesOf('Visualizations/Graph/GraphWithLegend', module);
|
||||||
|
GraphWithLegendStories.addDecorator(withHorizontallyCenteredStory);
|
||||||
|
|
||||||
|
const getStoriesKnobs = () => {
|
||||||
|
const containerWidth = select(
|
||||||
|
'Container width',
|
||||||
|
{
|
||||||
|
Small: '200px',
|
||||||
|
Medium: '500px',
|
||||||
|
'Full width': '100%',
|
||||||
|
},
|
||||||
|
'100%'
|
||||||
|
);
|
||||||
|
const containerHeight = select(
|
||||||
|
'Container height',
|
||||||
|
{
|
||||||
|
Small: '200px',
|
||||||
|
Medium: '400px',
|
||||||
|
'Full height': '100%',
|
||||||
|
},
|
||||||
|
'400px'
|
||||||
|
);
|
||||||
|
|
||||||
|
const rightAxisSeries = text('Right y-axis series, i.e. A,C', '');
|
||||||
|
|
||||||
|
const legendPlacement = select<LegendPlacement>(
|
||||||
|
'Legend placement',
|
||||||
|
{
|
||||||
|
under: 'under',
|
||||||
|
right: 'right',
|
||||||
|
},
|
||||||
|
'under'
|
||||||
|
);
|
||||||
|
const renderLegendAsTable = select(
|
||||||
|
'Render legend as',
|
||||||
|
{
|
||||||
|
list: false,
|
||||||
|
table: true,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerWidth,
|
||||||
|
containerHeight,
|
||||||
|
rightAxisSeries,
|
||||||
|
legendPlacement,
|
||||||
|
renderLegendAsTable,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
GraphWithLegendStories.add('default', () => {
|
||||||
|
const { containerWidth, containerHeight, rightAxisSeries, legendPlacement, renderLegendAsTable } = getStoriesKnobs();
|
||||||
|
|
||||||
|
const props = mockGraphWithLegendData({
|
||||||
|
onSeriesColorChange: action('Series color changed'),
|
||||||
|
onSeriesAxisToggle: action('Series y-axis changed'),
|
||||||
|
displayMode: renderLegendAsTable ? LegendDisplayMode.Table : LegendDisplayMode.List,
|
||||||
|
});
|
||||||
|
const series = props.series.map(s => {
|
||||||
|
if (
|
||||||
|
rightAxisSeries
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.indexOf(s.label.split('-')[0]) > -1
|
||||||
|
) {
|
||||||
|
s.yAxis = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div style={{ width: containerWidth, height: containerHeight }}>
|
||||||
|
<GraphWithLegend {...props} placement={legendPlacement} series={series} />,
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
125
packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx
Normal file
125
packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// Libraries
|
||||||
|
import _ from 'lodash';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import { Graph, GraphProps } from './Graph';
|
||||||
|
import { LegendRenderOptions, LegendItem, LegendDisplayMode } from '../Legend/Legend';
|
||||||
|
import { GraphLegend } from './GraphLegend';
|
||||||
|
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||||
|
import { GraphSeriesValue } from '../../types/graph';
|
||||||
|
|
||||||
|
export type SeriesOptionChangeHandler<TOption> = (label: string, option: TOption) => void;
|
||||||
|
export type SeriesColorChangeHandler = SeriesOptionChangeHandler<string>;
|
||||||
|
export type SeriesAxisToggleHandler = SeriesOptionChangeHandler<number>;
|
||||||
|
|
||||||
|
export interface GraphWithLegendProps extends GraphProps, LegendRenderOptions {
|
||||||
|
isLegendVisible: boolean;
|
||||||
|
displayMode: LegendDisplayMode;
|
||||||
|
sortLegendBy?: string;
|
||||||
|
sortLegendDesc?: boolean;
|
||||||
|
onSeriesColorChange: SeriesColorChangeHandler;
|
||||||
|
onSeriesAxisToggle?: SeriesAxisToggleHandler;
|
||||||
|
onSeriesToggle?: (label: string, event: React.MouseEvent<HTMLElement>) => void;
|
||||||
|
onToggleSort: (sortBy: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGraphWithLegendStyles = ({ placement }: GraphWithLegendProps) => ({
|
||||||
|
wrapper: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: ${placement === 'under' ? 'column' : 'row'};
|
||||||
|
height: 100%;
|
||||||
|
`,
|
||||||
|
graphContainer: css`
|
||||||
|
min-height: 65%;
|
||||||
|
flex-grow: 1;
|
||||||
|
`,
|
||||||
|
legendContainer: css`
|
||||||
|
padding: 10px 0;
|
||||||
|
max-height: ${placement === 'under' ? '35%' : 'none'};
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldHideLegendItem = (data: GraphSeriesValue[][], hideEmpty = false, hideZero = false) => {
|
||||||
|
const isZeroOnlySeries = data.reduce((acc, current) => acc + (current[1] || 0), 0) === 0;
|
||||||
|
const isNullOnlySeries = !data.reduce((acc, current) => acc && current[1] !== null, true);
|
||||||
|
|
||||||
|
return (hideEmpty && isNullOnlySeries) || (hideZero && isZeroOnlySeries);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (props: GraphWithLegendProps) => {
|
||||||
|
const {
|
||||||
|
series,
|
||||||
|
timeRange,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
showBars,
|
||||||
|
showLines,
|
||||||
|
showPoints,
|
||||||
|
sortLegendBy,
|
||||||
|
sortLegendDesc,
|
||||||
|
isLegendVisible,
|
||||||
|
displayMode,
|
||||||
|
placement,
|
||||||
|
onSeriesAxisToggle,
|
||||||
|
onSeriesColorChange,
|
||||||
|
onSeriesToggle,
|
||||||
|
onToggleSort,
|
||||||
|
hideEmpty,
|
||||||
|
hideZero,
|
||||||
|
} = props;
|
||||||
|
const { graphContainer, wrapper, legendContainer } = getGraphWithLegendStyles(props);
|
||||||
|
|
||||||
|
const legendItems = series.reduce<LegendItem[]>((acc, s) => {
|
||||||
|
return shouldHideLegendItem(s.data, hideEmpty, hideZero)
|
||||||
|
? acc
|
||||||
|
: acc.concat([
|
||||||
|
{
|
||||||
|
label: s.label,
|
||||||
|
color: s.color,
|
||||||
|
isVisible: s.isVisible,
|
||||||
|
yAxis: s.yAxis,
|
||||||
|
displayValues: s.info || [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={wrapper}>
|
||||||
|
<div className={graphContainer}>
|
||||||
|
<Graph
|
||||||
|
series={series.filter(s => !!s.isVisible)}
|
||||||
|
timeRange={timeRange}
|
||||||
|
showLines={showLines}
|
||||||
|
showPoints={showPoints}
|
||||||
|
showBars={showBars}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
key={isLegendVisible ? 'legend-visible' : 'legend-invisible'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLegendVisible && (
|
||||||
|
<div className={legendContainer}>
|
||||||
|
<CustomScrollbar hideHorizontalTrack>
|
||||||
|
<GraphLegend
|
||||||
|
items={legendItems}
|
||||||
|
displayMode={displayMode}
|
||||||
|
placement={placement}
|
||||||
|
sortBy={sortLegendBy}
|
||||||
|
sortDesc={sortLegendDesc}
|
||||||
|
onLabelClick={(item, event) => {
|
||||||
|
if (onSeriesToggle) {
|
||||||
|
onSeriesToggle(item.label, event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSeriesColorChange={onSeriesColorChange}
|
||||||
|
onSeriesAxisToggle={onSeriesAxisToggle}
|
||||||
|
onToggleSort={onToggleSort}
|
||||||
|
/>
|
||||||
|
</CustomScrollbar>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
3316
packages/grafana-ui/src/components/Graph/mockGraphWithLegendData.ts
Normal file
3316
packages/grafana-ui/src/components/Graph/mockGraphWithLegendData.ts
Normal file
File diff suppressed because it is too large
Load Diff
142
packages/grafana-ui/src/components/Legend/Legend.story.tsx
Normal file
142
packages/grafana-ui/src/components/Legend/Legend.story.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { storiesOf } from '@storybook/react';
|
||||||
|
import { LegendList, LegendPlacement, LegendItem, LegendTable } from './Legend';
|
||||||
|
import tinycolor from 'tinycolor2';
|
||||||
|
import { DisplayValue } from '../../types/index';
|
||||||
|
import { number, select, text } from '@storybook/addon-knobs';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { GraphLegendListItem, GraphLegendTableRow, GraphLegendItemProps } from '../Graph/GraphLegendItem';
|
||||||
|
|
||||||
|
export const generateLegendItems = (numberOfSeries: number, statsToDisplay?: DisplayValue[]): LegendItem[] => {
|
||||||
|
const alphabet = 'abcdefghijklmnopqrstuvwxyz'.split('');
|
||||||
|
|
||||||
|
return [...new Array(numberOfSeries)].map((item, i) => {
|
||||||
|
return {
|
||||||
|
label: `${alphabet[i].toUpperCase()}-series`,
|
||||||
|
color: tinycolor.fromRatio({ h: i / alphabet.length, s: 1, v: 1 }).toHexString(),
|
||||||
|
isVisible: true,
|
||||||
|
yAxis: 1,
|
||||||
|
displayValues: statsToDisplay || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStoriesKnobs = (table = false) => {
|
||||||
|
const numberOfSeries = number('Number of series', 3);
|
||||||
|
const containerWidth = select(
|
||||||
|
'Container width',
|
||||||
|
{
|
||||||
|
Small: '200px',
|
||||||
|
Medium: '500px',
|
||||||
|
'Full width': '100%',
|
||||||
|
},
|
||||||
|
'100%'
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawRenderer = (item: LegendItem) => (
|
||||||
|
<>
|
||||||
|
Label: <strong>{item.label}</strong>, Color: <strong>{item.color}</strong>, isVisible:{' '}
|
||||||
|
<strong>{item.isVisible ? 'yes' : 'no'}</strong>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const customRenderer = (component: React.ComponentType<GraphLegendItemProps>) => (item: LegendItem) =>
|
||||||
|
React.createElement(component, {
|
||||||
|
item,
|
||||||
|
onLabelClick: action('GraphLegendItem label clicked'),
|
||||||
|
onSeriesColorChange: action('Series color changed'),
|
||||||
|
onToggleAxis: action('Y-axis toggle'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeSpecificRenderer = table
|
||||||
|
? {
|
||||||
|
'Custom renderer(GraphLegendTablerow)': 'custom-tabe',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
'Custom renderer(GraphLegendListItem)': 'custom-list',
|
||||||
|
};
|
||||||
|
const legendItemRenderer = select(
|
||||||
|
'Item rendered',
|
||||||
|
{
|
||||||
|
'Raw renderer': 'raw',
|
||||||
|
...typeSpecificRenderer,
|
||||||
|
},
|
||||||
|
'raw'
|
||||||
|
);
|
||||||
|
|
||||||
|
const rightAxisSeries = text('Right y-axis series, i.e. A,C', '');
|
||||||
|
|
||||||
|
const legendPlacement = select<LegendPlacement>(
|
||||||
|
'Legend placement',
|
||||||
|
{
|
||||||
|
under: 'under',
|
||||||
|
right: 'right',
|
||||||
|
},
|
||||||
|
'under'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
numberOfSeries,
|
||||||
|
containerWidth,
|
||||||
|
itemRenderer:
|
||||||
|
legendItemRenderer === 'raw'
|
||||||
|
? rawRenderer
|
||||||
|
: customRenderer(legendItemRenderer === 'custom-list' ? GraphLegendListItem : GraphLegendTableRow),
|
||||||
|
rightAxisSeries,
|
||||||
|
legendPlacement,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const LegendStories = storiesOf('UI/Legend/Legend', module);
|
||||||
|
|
||||||
|
LegendStories.add('list', () => {
|
||||||
|
const { numberOfSeries, itemRenderer, containerWidth, rightAxisSeries, legendPlacement } = getStoriesKnobs();
|
||||||
|
let items = generateLegendItems(numberOfSeries);
|
||||||
|
|
||||||
|
items = items.map(i => {
|
||||||
|
if (
|
||||||
|
rightAxisSeries
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.indexOf(i.label.split('-')[0]) > -1
|
||||||
|
) {
|
||||||
|
i.yAxis = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return i;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div style={{ width: containerWidth }}>
|
||||||
|
<LegendList itemRenderer={itemRenderer} items={items} placement={legendPlacement} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
LegendStories.add('table', () => {
|
||||||
|
const { numberOfSeries, itemRenderer, containerWidth, rightAxisSeries, legendPlacement } = getStoriesKnobs(true);
|
||||||
|
let items = generateLegendItems(numberOfSeries);
|
||||||
|
|
||||||
|
items = items.map(i => {
|
||||||
|
if (
|
||||||
|
rightAxisSeries
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.indexOf(i.label.split('-')[0]) > -1
|
||||||
|
) {
|
||||||
|
i.yAxis = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...i,
|
||||||
|
info: [
|
||||||
|
{ title: 'min', text: '14.42', numeric: 14.427101844163694 },
|
||||||
|
{ title: 'max', text: '18.42', numeric: 18.427101844163694 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div style={{ width: containerWidth }}>
|
||||||
|
<LegendTable itemRenderer={itemRenderer} items={items} columns={['', 'min', 'max']} placement={legendPlacement} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
43
packages/grafana-ui/src/components/Legend/Legend.tsx
Normal file
43
packages/grafana-ui/src/components/Legend/Legend.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { DisplayValue } from '../../types/index';
|
||||||
|
|
||||||
|
import { LegendList } from './LegendList';
|
||||||
|
import { LegendTable } from './LegendTable';
|
||||||
|
|
||||||
|
export enum LegendDisplayMode {
|
||||||
|
List = 'list',
|
||||||
|
Table = 'table',
|
||||||
|
}
|
||||||
|
export interface LegendBasicOptions {
|
||||||
|
isVisible: boolean;
|
||||||
|
asTable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegendRenderOptions {
|
||||||
|
placement: LegendPlacement;
|
||||||
|
hideEmpty?: boolean;
|
||||||
|
hideZero?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LegendPlacement = 'under' | 'right' | 'over'; // Over used by piechart
|
||||||
|
|
||||||
|
export interface LegendOptions extends LegendBasicOptions, LegendRenderOptions {}
|
||||||
|
|
||||||
|
export interface LegendItem {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
isVisible: boolean;
|
||||||
|
yAxis: number;
|
||||||
|
displayValues?: DisplayValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegendComponentProps {
|
||||||
|
className?: string;
|
||||||
|
items: LegendItem[];
|
||||||
|
placement: LegendPlacement;
|
||||||
|
// Function to render given item
|
||||||
|
itemRenderer?: (item: LegendItem, index: number) => JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegendProps extends LegendComponentProps {}
|
||||||
|
|
||||||
|
export { LegendList, LegendTable };
|
||||||
66
packages/grafana-ui/src/components/Legend/LegendList.tsx
Normal file
66
packages/grafana-ui/src/components/Legend/LegendList.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { LegendComponentProps, LegendItem } from './Legend';
|
||||||
|
import { InlineList } from '../List/InlineList';
|
||||||
|
import { List } from '../List/List';
|
||||||
|
import { css, cx } from 'emotion';
|
||||||
|
import { ThemeContext } from '../../themes/ThemeContext';
|
||||||
|
|
||||||
|
export const LegendList: React.FunctionComponent<LegendComponentProps> = ({
|
||||||
|
items,
|
||||||
|
itemRenderer,
|
||||||
|
placement,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const theme = useContext(ThemeContext);
|
||||||
|
|
||||||
|
const renderItem = (item: LegendItem, index: number) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={css`
|
||||||
|
padding-left: 10px;
|
||||||
|
display: flex;
|
||||||
|
font-size: ${theme.typography.size.sm};
|
||||||
|
white-space: nowrap;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{itemRenderer ? itemRenderer(item, index) : item.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemKey = (item: LegendItem) => item.label;
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
wrapper: cx(
|
||||||
|
css`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
`,
|
||||||
|
className
|
||||||
|
),
|
||||||
|
section: css`
|
||||||
|
display: flex;
|
||||||
|
`,
|
||||||
|
sectionRight: css`
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-grow: 1;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return placement === 'under' ? (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<div className={styles.section}>
|
||||||
|
<InlineList items={items.filter(item => item.yAxis === 1)} renderItem={renderItem} getItemKey={getItemKey} />
|
||||||
|
</div>
|
||||||
|
<div className={cx(styles.section, styles.sectionRight)}>
|
||||||
|
<InlineList items={items.filter(item => item.yAxis !== 1)} renderItem={renderItem} getItemKey={getItemKey} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<List items={items} renderItem={renderItem} getItemKey={getItemKey} className={className} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
LegendList.displayName = 'LegendList';
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SeriesColorPicker } from '../ColorPicker/ColorPicker';
|
||||||
|
import { SeriesIcon } from './SeriesIcon';
|
||||||
|
|
||||||
|
interface LegendSeriesIconProps {
|
||||||
|
color: string;
|
||||||
|
yAxis: number;
|
||||||
|
onColorChange: (color: string) => void;
|
||||||
|
onToggleAxis?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LegendSeriesIcon: React.FunctionComponent<LegendSeriesIconProps> = ({
|
||||||
|
yAxis,
|
||||||
|
color,
|
||||||
|
onColorChange,
|
||||||
|
onToggleAxis,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<SeriesColorPicker
|
||||||
|
yaxis={yAxis}
|
||||||
|
color={color}
|
||||||
|
onChange={onColorChange}
|
||||||
|
onToggleAxis={onToggleAxis}
|
||||||
|
enableNamedColors
|
||||||
|
>
|
||||||
|
{({ ref, showColorPicker, hideColorPicker }) => (
|
||||||
|
<span ref={ref} onClick={showColorPicker} onMouseLeave={hideColorPicker} className="graph-legend-icon">
|
||||||
|
<SeriesIcon color={color} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</SeriesColorPicker>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
LegendSeriesIcon.displayName = 'LegendSeriesIcon';
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { InlineList } from '../List/InlineList';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import { DisplayValue } from '../../types/displayValue';
|
||||||
|
import capitalize from 'lodash/capitalize';
|
||||||
|
|
||||||
|
const LegendItemStat: React.FunctionComponent<{ stat: DisplayValue }> = ({ stat }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
margin-left: 6px;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{stat.title && `${capitalize(stat.title)}:`} {stat.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
LegendItemStat.displayName = 'LegendItemStat';
|
||||||
|
|
||||||
|
export const LegendStatsList: React.FunctionComponent<{ stats: DisplayValue[] }> = ({ stats }) => {
|
||||||
|
if (stats.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <InlineList items={stats} renderItem={stat => <LegendItemStat stat={stat} />} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
LegendStatsList.displayName = 'LegendStatsList';
|
||||||
83
packages/grafana-ui/src/components/Legend/LegendTable.tsx
Normal file
83
packages/grafana-ui/src/components/Legend/LegendTable.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { css, cx } from 'emotion';
|
||||||
|
import { LegendComponentProps } from './Legend';
|
||||||
|
import { ThemeContext } from '../../themes/ThemeContext';
|
||||||
|
|
||||||
|
interface LegendTableProps extends LegendComponentProps {
|
||||||
|
columns: string[];
|
||||||
|
sortBy?: string;
|
||||||
|
sortDesc?: boolean;
|
||||||
|
onToggleSort?: (sortBy: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LegendTable: React.FunctionComponent<LegendTableProps> = ({
|
||||||
|
items,
|
||||||
|
columns,
|
||||||
|
sortBy,
|
||||||
|
sortDesc,
|
||||||
|
itemRenderer,
|
||||||
|
className,
|
||||||
|
onToggleSort,
|
||||||
|
}) => {
|
||||||
|
const theme = useContext(ThemeContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table
|
||||||
|
className={cx(
|
||||||
|
css`
|
||||||
|
width: 100%;
|
||||||
|
td {
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(columnHeader => {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={columnHeader}
|
||||||
|
className={css`
|
||||||
|
color: ${theme.colors.blue};
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: right;
|
||||||
|
cursor: pointer;
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
if (onToggleSort) {
|
||||||
|
onToggleSort(columnHeader);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{columnHeader}
|
||||||
|
{sortBy === columnHeader && (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
`fa fa-caret-${sortDesc ? 'down' : 'up'}`,
|
||||||
|
css`
|
||||||
|
margin-left: ${theme.spacing.sm};
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
return itemRenderer ? (
|
||||||
|
itemRenderer(item, index)
|
||||||
|
) : (
|
||||||
|
<tr key={`${item.label}-${index}`}>
|
||||||
|
<td>{item.label}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
packages/grafana-ui/src/components/Legend/SeriesIcon.tsx
Normal file
5
packages/grafana-ui/src/components/Legend/SeriesIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const SeriesIcon: React.FunctionComponent<{ color: string }> = ({ color }) => {
|
||||||
|
return <i className="fa fa-minus pointer" style={{ color }} />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { AbstractList } from './AbstractList';
|
||||||
|
|
||||||
|
describe('AbstractList', () => {
|
||||||
|
it('renders items using renderItem prop function', () => {
|
||||||
|
const items = [{ name: 'Item 1', id: 'item1' }, { name: 'Item 2', id: 'item2' }, { name: 'Item 3', id: 'item3' }];
|
||||||
|
|
||||||
|
const list = shallow(
|
||||||
|
<AbstractList
|
||||||
|
items={items}
|
||||||
|
renderItem={item => (
|
||||||
|
<div>
|
||||||
|
<h1>{item.name}</h1>
|
||||||
|
<small>{item.id}</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(list).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows custom item key', () => {
|
||||||
|
const items = [{ name: 'Item 1', id: 'item1' }, { name: 'Item 2', id: 'item2' }, { name: 'Item 3', id: 'item3' }];
|
||||||
|
|
||||||
|
const list = shallow(
|
||||||
|
<AbstractList
|
||||||
|
items={items}
|
||||||
|
getItemKey={item => item.id}
|
||||||
|
renderItem={item => (
|
||||||
|
<div>
|
||||||
|
<h1>{item.name}</h1>
|
||||||
|
<small>{item.id}</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(list).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
53
packages/grafana-ui/src/components/List/AbstractList.tsx
Normal file
53
packages/grafana-ui/src/components/List/AbstractList.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cx, css } from 'emotion';
|
||||||
|
|
||||||
|
export interface ListProps<T> {
|
||||||
|
items: T[];
|
||||||
|
renderItem: (item: T, index: number) => JSX.Element;
|
||||||
|
getItemKey?: (item: T) => string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AbstractListProps<T> extends ListProps<T> {
|
||||||
|
inline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbstractList<T> extends React.PureComponent<AbstractListProps<T>> {
|
||||||
|
constructor(props: AbstractListProps<T>) {
|
||||||
|
super(props);
|
||||||
|
this.getListStyles = this.getListStyles.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
getListStyles() {
|
||||||
|
const { inline, className } = this.props;
|
||||||
|
return {
|
||||||
|
list: cx([
|
||||||
|
css`
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
`,
|
||||||
|
className,
|
||||||
|
]),
|
||||||
|
item: css`
|
||||||
|
display: ${(inline && 'inline-block') || 'block'};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { items, renderItem, getItemKey } = this.props;
|
||||||
|
const styles = this.getListStyles();
|
||||||
|
return (
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{items.map((item, i) => {
|
||||||
|
return (
|
||||||
|
<li className={styles.item} key={getItemKey ? getItemKey(item) : i}>
|
||||||
|
{renderItem(item, i)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/grafana-ui/src/components/List/InlineList.tsx
Normal file
8
packages/grafana-ui/src/components/List/InlineList.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ListProps, AbstractList } from './AbstractList';
|
||||||
|
|
||||||
|
export class InlineList<T> extends React.PureComponent<ListProps<T>> {
|
||||||
|
render() {
|
||||||
|
return <AbstractList inline {...this.props} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
packages/grafana-ui/src/components/List/List.story.tsx
Normal file
68
packages/grafana-ui/src/components/List/List.story.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { storiesOf } from '@storybook/react';
|
||||||
|
import { number, select } from '@storybook/addon-knobs';
|
||||||
|
import { List } from './List';
|
||||||
|
import { css, cx } from 'emotion';
|
||||||
|
import tinycolor from 'tinycolor2';
|
||||||
|
import { InlineList } from './InlineList';
|
||||||
|
|
||||||
|
const ListStories = storiesOf('UI/List', module);
|
||||||
|
|
||||||
|
const generateListItems = (numberOfItems: number) => {
|
||||||
|
return [...new Array(numberOfItems)].map((item, i) => {
|
||||||
|
return {
|
||||||
|
name: `Item-${i}`,
|
||||||
|
id: `item-${i}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStoriesKnobs = (inline = false) => {
|
||||||
|
const numberOfItems = number('Number of items', 3);
|
||||||
|
const rawRenderer = (item: any) => <>{item.name}</>;
|
||||||
|
const customRenderer = (item: any, index: number) => (
|
||||||
|
<div
|
||||||
|
className={cx([
|
||||||
|
css`
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
background: ${tinycolor.fromRatio({ h: index / 26, s: 1, v: 1 }).toHexString()};
|
||||||
|
padding: 10px;
|
||||||
|
`,
|
||||||
|
inline
|
||||||
|
? css`
|
||||||
|
margin-right: 20px;
|
||||||
|
`
|
||||||
|
: css`
|
||||||
|
margin-bottom: 20px;
|
||||||
|
`,
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemRenderer = select(
|
||||||
|
'Item rendered',
|
||||||
|
{
|
||||||
|
'Raw renderer': 'raw',
|
||||||
|
'Custom renderer': 'custom',
|
||||||
|
},
|
||||||
|
'raw'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
numberOfItems,
|
||||||
|
renderItem: itemRenderer === 'raw' ? rawRenderer : customRenderer,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
ListStories.add('default', () => {
|
||||||
|
const { numberOfItems, renderItem } = getStoriesKnobs();
|
||||||
|
return <List items={generateListItems(numberOfItems)} renderItem={renderItem} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
ListStories.add('inline', () => {
|
||||||
|
const { numberOfItems, renderItem } = getStoriesKnobs(true);
|
||||||
|
return <InlineList items={generateListItems(numberOfItems)} renderItem={renderItem} />;
|
||||||
|
});
|
||||||
8
packages/grafana-ui/src/components/List/List.tsx
Normal file
8
packages/grafana-ui/src/components/List/List.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ListProps, AbstractList } from './AbstractList';
|
||||||
|
|
||||||
|
export class List<T> extends React.PureComponent<ListProps<T>> {
|
||||||
|
render() {
|
||||||
|
return <AbstractList {...this.props} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`AbstractList allows custom item key 1`] = `
|
||||||
|
<ul
|
||||||
|
className="css-9xf0yn"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
className="css-rwbibe"
|
||||||
|
key="item1"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1>
|
||||||
|
Item 1
|
||||||
|
</h1>
|
||||||
|
<small>
|
||||||
|
item1
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
className="css-rwbibe"
|
||||||
|
key="item2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1>
|
||||||
|
Item 2
|
||||||
|
</h1>
|
||||||
|
<small>
|
||||||
|
item2
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
className="css-rwbibe"
|
||||||
|
key="item3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1>
|
||||||
|
Item 3
|
||||||
|
</h1>
|
||||||
|
<small>
|
||||||
|
item3
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`AbstractList renders items using renderItem prop function 1`] = `
|
||||||
|
<ul
|
||||||
|
className="css-9xf0yn"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
className="css-rwbibe"
|
||||||
|
key="0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1>
|
||||||
|
Item 1
|
||||||
|
</h1>
|
||||||
|
<small>
|
||||||
|
item1
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
className="css-rwbibe"
|
||||||
|
key="1"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1>
|
||||||
|
Item 2
|
||||||
|
</h1>
|
||||||
|
<small>
|
||||||
|
item2
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
className="css-rwbibe"
|
||||||
|
key="2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1>
|
||||||
|
Item 3
|
||||||
|
</h1>
|
||||||
|
<small>
|
||||||
|
item3
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
@@ -22,7 +22,7 @@ export { SecretFormField } from './SecretFormFied/SecretFormField';
|
|||||||
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
|
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
|
||||||
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
|
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
|
||||||
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
|
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
|
||||||
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
|
||||||
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
||||||
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
|
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
|
||||||
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
|
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
|
||||||
@@ -44,8 +44,12 @@ export { TableInputCSV } from './Table/TableInputCSV';
|
|||||||
export { BigValue } from './BigValue/BigValue';
|
export { BigValue } from './BigValue/BigValue';
|
||||||
export { Gauge } from './Gauge/Gauge';
|
export { Gauge } from './Gauge/Gauge';
|
||||||
export { Graph } from './Graph/Graph';
|
export { Graph } from './Graph/Graph';
|
||||||
|
export { GraphWithLegend } from './Graph/GraphWithLegend';
|
||||||
export { BarGauge } from './BarGauge/BarGauge';
|
export { BarGauge } from './BarGauge/BarGauge';
|
||||||
export { VizRepeater } from './VizRepeater/VizRepeater';
|
export { VizRepeater } from './VizRepeater/VizRepeater';
|
||||||
|
export { LegendOptions, LegendBasicOptions, LegendRenderOptions, LegendList, LegendTable } from './Legend/Legend';
|
||||||
|
// Panel editors
|
||||||
|
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
||||||
export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
|
export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
|
||||||
export * from './SingleStatShared/shared';
|
export * from './SingleStatShared/shared';
|
||||||
export { CallToActionCard } from './CallToActionCard/CallToActionCard';
|
export { CallToActionCard } from './CallToActionCard/CallToActionCard';
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ export interface GraphSeriesXY {
|
|||||||
color: string;
|
color: string;
|
||||||
data: GraphSeriesValue[][]; // [x,y][]
|
data: GraphSeriesValue[][]; // [x,y][]
|
||||||
info?: DisplayValue[]; // Legend info
|
info?: DisplayValue[]; // Legend info
|
||||||
|
isVisible: boolean;
|
||||||
|
yAxis: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from './threshold';
|
|||||||
export * from './input';
|
export * from './input';
|
||||||
export * from './logs';
|
export * from './logs';
|
||||||
export * from './displayValue';
|
export * from './displayValue';
|
||||||
|
export * from './utils';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ComponentClass } from 'react';
|
import { ComponentClass, ComponentType } from 'react';
|
||||||
import { LoadingState, SeriesData } from './data';
|
import { LoadingState, SeriesData } from './data';
|
||||||
import { TimeRange } from './time';
|
import { TimeRange } from './time';
|
||||||
import { ScopedVars, DataQueryRequest, DataQueryError, LegacyResponseData } from './datasource';
|
import { ScopedVars, DataQueryRequest, DataQueryError, LegacyResponseData } from './datasource';
|
||||||
@@ -21,6 +21,7 @@ export interface PanelProps<T = any> {
|
|||||||
|
|
||||||
timeRange: TimeRange;
|
timeRange: TimeRange;
|
||||||
options: T;
|
options: T;
|
||||||
|
onOptionsChange: (options: T) => void;
|
||||||
renderCounter: number;
|
renderCounter: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -53,13 +54,13 @@ export type PanelTypeChangedHandler<TOptions = any> = (
|
|||||||
) => Partial<TOptions>;
|
) => Partial<TOptions>;
|
||||||
|
|
||||||
export class ReactPanelPlugin<TOptions = any> {
|
export class ReactPanelPlugin<TOptions = any> {
|
||||||
panel: ComponentClass<PanelProps<TOptions>>;
|
panel: ComponentType<PanelProps<TOptions>>;
|
||||||
editor?: ComponentClass<PanelEditorProps<TOptions>>;
|
editor?: ComponentClass<PanelEditorProps<TOptions>>;
|
||||||
defaults?: TOptions;
|
defaults?: TOptions;
|
||||||
onPanelMigration?: PanelMigrationHandler<TOptions>;
|
onPanelMigration?: PanelMigrationHandler<TOptions>;
|
||||||
onPanelTypeChanged?: PanelTypeChangedHandler<TOptions>;
|
onPanelTypeChanged?: PanelTypeChangedHandler<TOptions>;
|
||||||
|
|
||||||
constructor(panel: ComponentClass<PanelProps<TOptions>>) {
|
constructor(panel: ComponentType<PanelProps<TOptions>>) {
|
||||||
this.panel = panel;
|
this.panel = panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
packages/grafana-ui/src/types/utils.ts
Normal file
2
packages/grafana-ui/src/types/utils.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||||
|
export type Subtract<T, K> = Omit<T, keyof K>;
|
||||||
@@ -12,4 +12,5 @@ export * from './logs';
|
|||||||
export * from './labels';
|
export * from './labels';
|
||||||
export { getMappedValue } from './valueMappings';
|
export { getMappedValue } from './valueMappings';
|
||||||
export * from './validate';
|
export * from './validate';
|
||||||
|
export { getFlotPairs } from './flotPairs';
|
||||||
export * from './object';
|
export * from './object';
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export class UseState<T> extends React.Component<StateHolderProps<T>, { value: T
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleStateUpdate = (nextState: T) => {
|
handleStateUpdate = (nextState: T) => {
|
||||||
console.log(nextState);
|
|
||||||
this.setState({ value: nextState });
|
this.setState({ value: nextState });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ exports[`ServerStats Should render table with stats 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="track-horizontal"
|
className="css-17l4171 track-horizontal"
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"display": "none",
|
"display": "none",
|
||||||
@@ -233,7 +233,7 @@ exports[`ServerStats Should render table with stats 1`] = `
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="track-vertical"
|
className="css-17l4171 track-vertical"
|
||||||
style={
|
style={
|
||||||
Object {
|
Object {
|
||||||
"display": "none",
|
"display": "none",
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ export class PanelChrome extends PureComponent<Props, State> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onOptionsChange = (options: any) => {
|
||||||
|
this.props.panel.updateOptions(options);
|
||||||
|
};
|
||||||
|
|
||||||
replaceVariables = (value: string, extraVars?: ScopedVars, format?: string) => {
|
replaceVariables = (value: string, extraVars?: ScopedVars, format?: string) => {
|
||||||
let vars = this.props.panel.scopedVars;
|
let vars = this.props.panel.scopedVars;
|
||||||
if (extraVars) {
|
if (extraVars) {
|
||||||
@@ -223,6 +227,7 @@ export class PanelChrome extends PureComponent<Props, State> {
|
|||||||
height={height - PANEL_HEADER_HEIGHT - config.theme.panelPadding.vertical}
|
height={height - PANEL_HEADER_HEIGHT - config.theme.panelPadding.vertical}
|
||||||
renderCounter={renderCounter}
|
renderCounter={renderCounter}
|
||||||
replaceVariables={this.replaceVariables}
|
replaceVariables={this.replaceVariables}
|
||||||
|
onOptionsChange={this.onOptionsChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ class LegendTableHeaderItem extends PureComponent<LegendTableHeaderProps & Legen
|
|||||||
export class Legend extends PureComponent<GraphLegendProps> {
|
export class Legend extends PureComponent<GraphLegendProps> {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<CustomScrollbar renderTrackHorizontal={props => <div {...props} style={{ visibility: 'none' }} />}>
|
<CustomScrollbar hideHorizontalTrack>
|
||||||
<GraphLegend {...this.props} />
|
<GraphLegend {...this.props} />
|
||||||
</CustomScrollbar>
|
</CustomScrollbar>
|
||||||
);
|
);
|
||||||
|
|||||||
103
public/app/plugins/panel/graph2/GraphLegendEditor.tsx
Normal file
103
public/app/plugins/panel/graph2/GraphLegendEditor.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import capitalize from 'lodash/capitalize';
|
||||||
|
import without from 'lodash/without';
|
||||||
|
import { LegendOptions, PanelOptionsGroup, Switch, Input } from '@grafana/ui';
|
||||||
|
|
||||||
|
export interface GraphLegendEditorLegendOptions extends LegendOptions {
|
||||||
|
stats?: string[];
|
||||||
|
decimals?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortDesc?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphLegendEditorProps {
|
||||||
|
stats: string[];
|
||||||
|
options: GraphLegendEditorLegendOptions;
|
||||||
|
onChange: (options: GraphLegendEditorLegendOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphLegendEditor: React.FunctionComponent<GraphLegendEditorProps> = props => {
|
||||||
|
const { stats, options, onChange } = props;
|
||||||
|
|
||||||
|
const onStatToggle = (stat: string) => (event?: React.SyntheticEvent<HTMLInputElement>) => {
|
||||||
|
let newStats;
|
||||||
|
if (!event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (event.target.checked) {
|
||||||
|
newStats = (options.stats || []).concat([stat]);
|
||||||
|
} else {
|
||||||
|
newStats = without(options.stats, stat);
|
||||||
|
}
|
||||||
|
onChange({
|
||||||
|
...options,
|
||||||
|
stats: newStats,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOptionToggle = (option: keyof LegendOptions) => (event?: React.SyntheticEvent<HTMLInputElement>) => {
|
||||||
|
const newOption = {};
|
||||||
|
if (!event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO: fix the ignores
|
||||||
|
// @ts-ignore
|
||||||
|
newOption[option] = event.target.checked;
|
||||||
|
if (option === 'placement') {
|
||||||
|
// @ts-ignore
|
||||||
|
newOption[option] = event.target.checked ? 'right' : 'under';
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
...options,
|
||||||
|
...newOption,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelOptionsGroup title="Legend">
|
||||||
|
<div className="section gf-form-group">
|
||||||
|
<h4>Options</h4>
|
||||||
|
<Switch label="Show legend" checked={options.isVisible} onChange={onOptionToggle('isVisible')} />
|
||||||
|
<Switch label="Display as table" checked={options.asTable} onChange={onOptionToggle('asTable')} />
|
||||||
|
<Switch label="To the right" checked={options.placement === 'right'} onChange={onOptionToggle('placement')} />
|
||||||
|
</div>
|
||||||
|
<div className="section gf-form-group">
|
||||||
|
<h4>Values</h4>
|
||||||
|
{stats.map(stat => {
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
label={capitalize(stat)}
|
||||||
|
checked={!!options.stats && options.stats.indexOf(stat) > -1}
|
||||||
|
onChange={onStatToggle(stat)}
|
||||||
|
key={stat}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="gf-form">
|
||||||
|
<div className="gf-form-label">Decimals</div>
|
||||||
|
<Input
|
||||||
|
className="gf-form-input width-5"
|
||||||
|
type="number"
|
||||||
|
value={options.decimals}
|
||||||
|
placeholder="Auto"
|
||||||
|
onChange={event => {
|
||||||
|
onChange({
|
||||||
|
...options,
|
||||||
|
decimals: parseInt(event.target.value, 10),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section gf-form-group">
|
||||||
|
<h4>Hidden series</h4>
|
||||||
|
{/* <Switch label="With only nulls" checked={!!options.hideEmpty} onChange={onOptionToggle('hideEmpty')} /> */}
|
||||||
|
<Switch label="With only zeros" checked={!!options.hideZero} onChange={onOptionToggle('hideZero')} />
|
||||||
|
</div>
|
||||||
|
</PanelOptionsGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,67 +1,57 @@
|
|||||||
// Libraries
|
import React from 'react';
|
||||||
import _ from 'lodash';
|
import { PanelProps, GraphWithLegend /*, GraphSeriesXY*/ } from '@grafana/ui';
|
||||||
import React, { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { Graph, PanelProps, NullValueMode, colors, GraphSeriesXY, FieldType, getFirstTimeField } from '@grafana/ui';
|
|
||||||
import { Options } from './types';
|
import { Options } from './types';
|
||||||
import { getFlotPairs } from '@grafana/ui/src/utils/flotPairs';
|
import { GraphPanelController } from './GraphPanelController';
|
||||||
|
import { LegendDisplayMode } from '@grafana/ui/src/components/Legend/Legend';
|
||||||
|
|
||||||
interface Props extends PanelProps<Options> {}
|
interface GraphPanelProps extends PanelProps<Options> {}
|
||||||
|
|
||||||
export class GraphPanel extends PureComponent<Props> {
|
|
||||||
render() {
|
|
||||||
const { data, timeRange, width, height } = this.props;
|
|
||||||
const { showLines, showBars, showPoints } = this.props.options;
|
|
||||||
|
|
||||||
const graphs: GraphSeriesXY[] = [];
|
|
||||||
for (const series of data.series) {
|
|
||||||
const timeColumn = getFirstTimeField(series);
|
|
||||||
if (timeColumn < 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < series.fields.length; i++) {
|
|
||||||
const field = series.fields[i];
|
|
||||||
|
|
||||||
// Show all numeric columns
|
|
||||||
if (field.type === FieldType.number) {
|
|
||||||
// Use external calculator just to make sure it works :)
|
|
||||||
const points = getFlotPairs({
|
|
||||||
series,
|
|
||||||
xIndex: timeColumn,
|
|
||||||
yIndex: i,
|
|
||||||
nullValueMode: NullValueMode.Null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (points.length > 0) {
|
|
||||||
graphs.push({
|
|
||||||
label: field.name,
|
|
||||||
data: points,
|
|
||||||
color: colors[graphs.length % colors.length],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (graphs.length < 1) {
|
|
||||||
return (
|
|
||||||
<div className="panel-empty">
|
|
||||||
<p>No data found in response</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export const GraphPanel: React.FunctionComponent<GraphPanelProps> = ({
|
||||||
|
data,
|
||||||
|
timeRange,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
options,
|
||||||
|
onOptionsChange,
|
||||||
|
}) => {
|
||||||
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<Graph
|
<div className="panel-empty">
|
||||||
series={graphs}
|
<p>No data found in response</p>
|
||||||
timeRange={timeRange}
|
</div>
|
||||||
showLines={showLines}
|
|
||||||
showPoints={showPoints}
|
|
||||||
showBars={showBars}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const {
|
||||||
|
graph: { showLines, showBars, showPoints },
|
||||||
|
legend: legendOptions,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const graphProps = {
|
||||||
|
showBars,
|
||||||
|
showLines,
|
||||||
|
showPoints,
|
||||||
|
};
|
||||||
|
const { asTable, isVisible, ...legendProps } = legendOptions;
|
||||||
|
return (
|
||||||
|
<GraphPanelController data={data} options={options} onOptionsChange={onOptionsChange}>
|
||||||
|
{({ onSeriesToggle, ...controllerApi }) => {
|
||||||
|
return (
|
||||||
|
<GraphWithLegend
|
||||||
|
timeRange={timeRange}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
displayMode={asTable ? LegendDisplayMode.Table : LegendDisplayMode.List}
|
||||||
|
isLegendVisible={isVisible}
|
||||||
|
sortLegendBy={legendOptions.sortBy}
|
||||||
|
sortLegendDesc={legendOptions.sortDesc}
|
||||||
|
onSeriesToggle={onSeriesToggle}
|
||||||
|
{...graphProps}
|
||||||
|
{...legendProps}
|
||||||
|
{...controllerApi}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</GraphPanelController>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
156
public/app/plugins/panel/graph2/GraphPanelController.tsx
Normal file
156
public/app/plugins/panel/graph2/GraphPanelController.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { GraphSeriesXY, PanelData } from '@grafana/ui';
|
||||||
|
import difference from 'lodash/difference';
|
||||||
|
import { getGraphSeriesModel } from './getGraphSeriesModel';
|
||||||
|
import { Options, SeriesOptions } from './types';
|
||||||
|
import { SeriesColorChangeHandler, SeriesAxisToggleHandler } from '@grafana/ui/src/components/Graph/GraphWithLegend';
|
||||||
|
|
||||||
|
interface GraphPanelControllerAPI {
|
||||||
|
series: GraphSeriesXY[];
|
||||||
|
onSeriesAxisToggle: SeriesAxisToggleHandler;
|
||||||
|
onSeriesColorChange: SeriesColorChangeHandler;
|
||||||
|
onSeriesToggle: (label: string, event: React.MouseEvent<HTMLElement>) => void;
|
||||||
|
onToggleSort: (sortBy: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphPanelControllerProps {
|
||||||
|
children: (api: GraphPanelControllerAPI) => JSX.Element;
|
||||||
|
options: Options;
|
||||||
|
data: PanelData;
|
||||||
|
onOptionsChange: (options: Options) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphPanelControllerState {
|
||||||
|
graphSeriesModel: GraphSeriesXY[];
|
||||||
|
hiddenSeries: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GraphPanelController extends React.Component<GraphPanelControllerProps, GraphPanelControllerState> {
|
||||||
|
constructor(props: GraphPanelControllerProps) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.onSeriesToggle = this.onSeriesToggle.bind(this);
|
||||||
|
this.onSeriesColorChange = this.onSeriesColorChange.bind(this);
|
||||||
|
this.onSeriesAxisToggle = this.onSeriesAxisToggle.bind(this);
|
||||||
|
this.onToggleSort = this.onToggleSort.bind(this);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
graphSeriesModel: getGraphSeriesModel(
|
||||||
|
props.data,
|
||||||
|
props.options.series,
|
||||||
|
props.options.graph,
|
||||||
|
props.options.legend
|
||||||
|
),
|
||||||
|
hiddenSeries: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromProps(props: GraphPanelControllerProps, state: GraphPanelControllerState) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
graphSeriesModel: getGraphSeriesModel(
|
||||||
|
props.data,
|
||||||
|
props.options.series,
|
||||||
|
props.options.graph,
|
||||||
|
props.options.legend
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onSeriesOptionsUpdate(label: string, optionsUpdate: SeriesOptions) {
|
||||||
|
const { onOptionsChange, options } = this.props;
|
||||||
|
const updatedSeriesOptions: { [label: string]: SeriesOptions } = { ...options.series };
|
||||||
|
updatedSeriesOptions[label] = optionsUpdate;
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
series: updatedSeriesOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSeriesAxisToggle(label: string, yAxis: number) {
|
||||||
|
const {
|
||||||
|
options: { series },
|
||||||
|
} = this.props;
|
||||||
|
const seriesOptionsUpdate: SeriesOptions = series[label]
|
||||||
|
? {
|
||||||
|
...series[label],
|
||||||
|
yAxis,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
yAxis,
|
||||||
|
};
|
||||||
|
this.onSeriesOptionsUpdate(label, seriesOptionsUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSeriesColorChange(label: string, color: string) {
|
||||||
|
const {
|
||||||
|
options: { series },
|
||||||
|
} = this.props;
|
||||||
|
const seriesOptionsUpdate: SeriesOptions = series[label]
|
||||||
|
? {
|
||||||
|
...series[label],
|
||||||
|
color,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
color,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.onSeriesOptionsUpdate(label, seriesOptionsUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleSort(sortBy: string) {
|
||||||
|
const { onOptionsChange, options } = this.props;
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
legend: {
|
||||||
|
...options.legend,
|
||||||
|
sortBy,
|
||||||
|
sortDesc: sortBy === options.legend.sortBy ? !options.legend.sortDesc : false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSeriesToggle(label: string, event: React.MouseEvent<HTMLElement>) {
|
||||||
|
const { hiddenSeries, graphSeriesModel } = this.state;
|
||||||
|
|
||||||
|
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||||
|
// Toggling series with key makes the series itself to toggle
|
||||||
|
if (hiddenSeries.indexOf(label) > -1) {
|
||||||
|
this.setState({
|
||||||
|
hiddenSeries: hiddenSeries.filter(series => series !== label),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
hiddenSeries: hiddenSeries.concat([label]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Toggling series with out key toggles all the series but the clicked one
|
||||||
|
const allSeriesLabels = graphSeriesModel.map(series => series.label);
|
||||||
|
|
||||||
|
if (hiddenSeries.length + 1 === allSeriesLabels.length) {
|
||||||
|
this.setState({ hiddenSeries: [] });
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
hiddenSeries: difference(allSeriesLabels, [label]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { children } = this.props;
|
||||||
|
const { graphSeriesModel, hiddenSeries } = this.state;
|
||||||
|
|
||||||
|
return children({
|
||||||
|
series: graphSeriesModel.map(series => ({
|
||||||
|
...series,
|
||||||
|
isVisible: hiddenSeries.indexOf(series.label) === -1,
|
||||||
|
})),
|
||||||
|
onSeriesToggle: this.onSeriesToggle,
|
||||||
|
onSeriesColorChange: this.onSeriesColorChange,
|
||||||
|
onSeriesAxisToggle: this.onSeriesAxisToggle,
|
||||||
|
onToggleSort: this.onToggleSort,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,40 +3,56 @@ import _ from 'lodash';
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import { PanelEditorProps, Switch } from '@grafana/ui';
|
import { PanelEditorProps, Switch, LegendOptions, StatID } from '@grafana/ui';
|
||||||
import { Options } from './types';
|
import { Options, GraphOptions } from './types';
|
||||||
|
import { GraphLegendEditor } from './GraphLegendEditor';
|
||||||
|
|
||||||
export class GraphPanelEditor extends PureComponent<PanelEditorProps<Options>> {
|
export class GraphPanelEditor extends PureComponent<PanelEditorProps<Options>> {
|
||||||
|
onGraphOptionsChange = (options: Partial<GraphOptions>) => {
|
||||||
|
this.props.onOptionsChange({
|
||||||
|
...this.props.options,
|
||||||
|
graph: {
|
||||||
|
...this.props.options.graph,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onLegendOptionsChange = (options: LegendOptions) => {
|
||||||
|
this.props.onOptionsChange({ ...this.props.options, legend: options });
|
||||||
|
};
|
||||||
|
|
||||||
onToggleLines = () => {
|
onToggleLines = () => {
|
||||||
this.props.onOptionsChange({ ...this.props.options, showLines: !this.props.options.showLines });
|
this.onGraphOptionsChange({ showLines: !this.props.options.graph.showLines });
|
||||||
};
|
};
|
||||||
|
|
||||||
onToggleBars = () => {
|
onToggleBars = () => {
|
||||||
this.props.onOptionsChange({ ...this.props.options, showBars: !this.props.options.showBars });
|
this.onGraphOptionsChange({ showBars: !this.props.options.graph.showBars });
|
||||||
};
|
};
|
||||||
|
|
||||||
onTogglePoints = () => {
|
onTogglePoints = () => {
|
||||||
this.props.onOptionsChange({ ...this.props.options, showPoints: !this.props.options.showPoints });
|
this.onGraphOptionsChange({ showPoints: !this.props.options.graph.showPoints });
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { showBars, showPoints, showLines } = this.props.options;
|
const {
|
||||||
|
graph: { showBars, showPoints, showLines },
|
||||||
|
} = this.props.options;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="section gf-form-group">
|
<div className="section gf-form-group">
|
||||||
<h5 className="section-heading">Draw Modes</h5>
|
<h5 className="section-heading">Draw Modes</h5>
|
||||||
<Switch label="Lines" labelClass="width-5" checked={showLines} onChange={this.onToggleLines} />
|
<Switch label="Lines" labelClass="width-5" checked={showLines} onChange={this.onToggleLines} />
|
||||||
<Switch label="Bars" labelClass="width-5" checked={showBars} onChange={this.onToggleBars} />
|
<Switch label="Bars" labelClass="width-5" checked={showBars} onChange={this.onToggleBars} />
|
||||||
<Switch label="Points" labelClass="width-5" checked={showPoints} onChange={this.onTogglePoints} />
|
<Switch label="Points" labelClass="width-5" checked={showPoints} onChange={this.onTogglePoints} />
|
||||||
</div>
|
</div>
|
||||||
<div className="section gf-form-group">
|
<GraphLegendEditor
|
||||||
<h5 className="section-heading">Test Options</h5>
|
stats={[StatID.min, StatID.max, StatID.mean, StatID.last, StatID.sum]}
|
||||||
<Switch label="Lines" labelClass="width-5" checked={showLines} onChange={this.onToggleLines} />
|
options={this.props.options.legend}
|
||||||
<Switch label="Bars" labelClass="width-5" checked={showBars} onChange={this.onToggleBars} />
|
onChange={this.onLegendOptionsChange}
|
||||||
<Switch label="Points" labelClass="width-5" checked={showPoints} onChange={this.onTogglePoints} />
|
/>
|
||||||
</div>
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
83
public/app/plugins/panel/graph2/getGraphSeriesModel.ts
Normal file
83
public/app/plugins/panel/graph2/getGraphSeriesModel.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
GraphSeriesXY,
|
||||||
|
getFirstTimeField,
|
||||||
|
FieldType,
|
||||||
|
NullValueMode,
|
||||||
|
calculateStats,
|
||||||
|
colors,
|
||||||
|
getFlotPairs,
|
||||||
|
getColorFromHexRgbOrName,
|
||||||
|
getDisplayProcessor,
|
||||||
|
DisplayValue,
|
||||||
|
PanelData,
|
||||||
|
} from '@grafana/ui';
|
||||||
|
import { SeriesOptions, GraphOptions } from './types';
|
||||||
|
import { GraphLegendEditorLegendOptions } from './GraphLegendEditor';
|
||||||
|
|
||||||
|
export const getGraphSeriesModel = (
|
||||||
|
data: PanelData,
|
||||||
|
seriesOptions: SeriesOptions,
|
||||||
|
graphOptions: GraphOptions,
|
||||||
|
legendOptions: GraphLegendEditorLegendOptions
|
||||||
|
) => {
|
||||||
|
const graphs: GraphSeriesXY[] = [];
|
||||||
|
|
||||||
|
const displayProcessor = getDisplayProcessor({
|
||||||
|
decimals: legendOptions.decimals,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const series of data.series) {
|
||||||
|
const timeColumn = getFirstTimeField(series);
|
||||||
|
if (timeColumn < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < series.fields.length; i++) {
|
||||||
|
const field = series.fields[i];
|
||||||
|
|
||||||
|
// Show all numeric columns
|
||||||
|
if (field.type === FieldType.number) {
|
||||||
|
// Use external calculator just to make sure it works :)
|
||||||
|
const points = getFlotPairs({
|
||||||
|
series,
|
||||||
|
xIndex: timeColumn,
|
||||||
|
yIndex: i,
|
||||||
|
nullValueMode: NullValueMode.Null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (points.length > 0) {
|
||||||
|
const seriesStats = calculateStats({ series, stats: legendOptions.stats, fieldIndex: i });
|
||||||
|
let statsDisplayValues;
|
||||||
|
|
||||||
|
if (legendOptions.stats) {
|
||||||
|
statsDisplayValues = legendOptions.stats.map<DisplayValue>(stat => {
|
||||||
|
const statDisplayValue = displayProcessor(seriesStats[stat]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...statDisplayValue,
|
||||||
|
text: statDisplayValue.text,
|
||||||
|
title: stat,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesColor =
|
||||||
|
seriesOptions[field.name] && seriesOptions[field.name].color
|
||||||
|
? getColorFromHexRgbOrName(seriesOptions[field.name].color)
|
||||||
|
: colors[graphs.length % colors.length];
|
||||||
|
|
||||||
|
graphs.push({
|
||||||
|
label: field.name,
|
||||||
|
data: points,
|
||||||
|
color: seriesColor,
|
||||||
|
info: statsDisplayValues,
|
||||||
|
isVisible: true,
|
||||||
|
yAxis: (seriesOptions[field.name] && seriesOptions[field.name].yAxis) || 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return graphs;
|
||||||
|
};
|
||||||
@@ -1,11 +1,34 @@
|
|||||||
export interface Options {
|
import { LegendOptions } from '@grafana/ui';
|
||||||
|
import { GraphLegendEditorLegendOptions } from './GraphLegendEditor';
|
||||||
|
|
||||||
|
export interface SeriesOptions {
|
||||||
|
color?: string;
|
||||||
|
yAxis?: number;
|
||||||
|
}
|
||||||
|
export interface GraphOptions {
|
||||||
showBars: boolean;
|
showBars: boolean;
|
||||||
showLines: boolean;
|
showLines: boolean;
|
||||||
showPoints: boolean;
|
showPoints: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
graph: GraphOptions;
|
||||||
|
legend: LegendOptions & GraphLegendEditorLegendOptions;
|
||||||
|
series: {
|
||||||
|
[alias: string]: SeriesOptions;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const defaults: Options = {
|
export const defaults: Options = {
|
||||||
showBars: false,
|
graph: {
|
||||||
showLines: true,
|
showBars: false,
|
||||||
showPoints: false,
|
showLines: true,
|
||||||
|
showPoints: false,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
asTable: false,
|
||||||
|
isVisible: true,
|
||||||
|
placement: 'under',
|
||||||
|
},
|
||||||
|
series: {},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user