mirror of https://github.com/grafana/grafana
VisualizationSelection: Real previews of suitable visualisation and options based on current data (#40527)
* Initial pass to move panel state to it's own, and make it by key not panel.id * Progress * Not making much progress, having panel.key be mutable is causing a lot of issues * Think this is starting to work * Began fixing tests * Add selector * Bug fixes and changes to cleanup, and fixing all flicking when switching library panels * Removed console.log * fixes after merge * fixing tests * fixing tests * Added new test for changePlugin thunk * Initial struture in place * responding to state changes in another part of the state * bha * going in a different direction * This is getting exciting * minor * More structure * More real * Added builder to reduce boiler plate * Lots of progress * Adding more visualizations * More smarts * tweaks * suggestions * Move to separate view * Refactoring to builder concept * Before hover preview test * Increase line width in preview * More suggestions * Removed old elements of onSuggestVisualizations * Don't call suggestion suppliers if there is no data * Restore card styles to only borders * Changing supplier interface to support data vs option suggestion scenario * Renamed functions * Add dynamic width support * not sure about this * Improve suggestions * Improve suggestions * Single grid/list * Store vis select pane & size * Prep for option suggestions * more suggestions * Name/title option for preview cards * Improve barchart suggestions * Support suggestions when there are no data * Minor change * reverted some changes * Improve suggestions for stacking * Removed size option * starting on unit tests, hit cyclic dependency issue * muuu * First test for getting suggestion seems to work, going to bed * add missing file * A basis for more unit tests * More tests * More unit tests * Fixed unit tests * Update * Some extreme scenarios * Added basic e2e test * Added another unit test for changePanelPlugin action * More cleanup * Minor tweak * add wait to e2e test * Renamed function and cleanup of unused function * Adding search support and adding search test to e2e testpull/40518/head
parent
91c0b5a47f
commit
54af57b8e6
@ -0,0 +1,32 @@ |
||||
import { e2e } from '@grafana/e2e'; |
||||
|
||||
const PANEL_UNDER_TEST = 'Interpolation: linear'; |
||||
|
||||
e2e.scenario({ |
||||
describeName: 'Visualization suggestions', |
||||
itName: 'Should be shown and clickable', |
||||
addScenarioDataSource: false, |
||||
addScenarioDashBoard: false, |
||||
skipScenario: false, |
||||
scenario: () => { |
||||
e2e.flows.openDashboard({ uid: 'TkZXxlNG3' }); |
||||
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); |
||||
|
||||
// Try visualization suggestions
|
||||
e2e.components.PanelEditor.toggleVizPicker().click(); |
||||
e2e().contains('Suggestions').click(); |
||||
cy.wait(1000); |
||||
|
||||
// Verify we see suggestions
|
||||
e2e.components.VisualizationPreview.card('Line chart').should('be.visible'); |
||||
|
||||
// Verify search works
|
||||
e2e().get('[placeholder="Search for..."]').type('Table'); |
||||
// Should no longer see line chart
|
||||
e2e.components.VisualizationPreview.card('Line chart').should('not.exist'); |
||||
|
||||
// Select a visualisation
|
||||
e2e.components.VisualizationPreview.card('Table').click(); |
||||
e2e.components.Panels.Visualization.Table.header().should('be.visible'); |
||||
}, |
||||
}); |
@ -1,47 +1,48 @@ |
||||
import React, { FC } from 'react'; |
||||
import React, { HTMLProps } from 'react'; |
||||
import { escapeStringForRegex, unEscapeStringFromRegex } from '@grafana/data'; |
||||
import { Button, Icon, Input } from '..'; |
||||
import { useFocus } from '../Input/utils'; |
||||
import { useCombinedRefs } from '../../utils/useCombinedRefs'; |
||||
|
||||
export interface Props { |
||||
export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'onChange'> { |
||||
value: string | undefined; |
||||
placeholder?: string; |
||||
width?: number; |
||||
onChange: (value: string) => void; |
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void; |
||||
autoFocus?: boolean; |
||||
} |
||||
|
||||
export const FilterInput: FC<Props> = ({ value, placeholder, width, onChange, onKeyDown, autoFocus }) => { |
||||
const [inputRef, setInputFocus] = useFocus(); |
||||
const suffix = |
||||
value !== '' ? ( |
||||
<Button |
||||
icon="times" |
||||
fill="text" |
||||
size="sm" |
||||
onClick={(e) => { |
||||
setInputFocus(); |
||||
onChange(''); |
||||
e.stopPropagation(); |
||||
}} |
||||
> |
||||
Clear |
||||
</Button> |
||||
) : null; |
||||
export const FilterInput = React.forwardRef<HTMLInputElement, Props>( |
||||
({ value, width, onChange, ...restProps }, ref) => { |
||||
const innerRef = React.useRef<HTMLInputElement>(null); |
||||
const combinedRef = useCombinedRefs(ref, innerRef) as React.Ref<HTMLInputElement>; |
||||
|
||||
return ( |
||||
<Input |
||||
autoFocus={autoFocus ?? false} |
||||
prefix={<Icon name="search" />} |
||||
ref={inputRef} |
||||
suffix={suffix} |
||||
width={width} |
||||
type="text" |
||||
value={value ? unEscapeStringFromRegex(value) : ''} |
||||
onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))} |
||||
onKeyDown={onKeyDown} |
||||
placeholder={placeholder} |
||||
/> |
||||
); |
||||
}; |
||||
const suffix = |
||||
value !== '' ? ( |
||||
<Button |
||||
icon="times" |
||||
fill="text" |
||||
size="sm" |
||||
onClick={(e) => { |
||||
innerRef.current?.focus(); |
||||
onChange(''); |
||||
e.stopPropagation(); |
||||
}} |
||||
> |
||||
Clear |
||||
</Button> |
||||
) : null; |
||||
|
||||
return ( |
||||
<Input |
||||
prefix={<Icon name="search" />} |
||||
suffix={suffix} |
||||
width={width} |
||||
type="text" |
||||
value={value ? unEscapeStringFromRegex(value) : ''} |
||||
onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))} |
||||
{...restProps} |
||||
ref={combinedRef} |
||||
/> |
||||
); |
||||
} |
||||
); |
||||
|
||||
FilterInput.displayName = 'FilterInput'; |
||||
|
@ -0,0 +1,21 @@ |
||||
import React from 'react'; |
||||
|
||||
export function useCombinedRefs<T>(...refs: any) { |
||||
const targetRef = React.useRef<T>(null); |
||||
|
||||
React.useEffect(() => { |
||||
refs.forEach((ref: any) => { |
||||
if (!ref) { |
||||
return; |
||||
} |
||||
|
||||
if (typeof ref === 'function') { |
||||
ref(targetRef.current); |
||||
} else { |
||||
ref.current = targetRef.current; |
||||
} |
||||
}); |
||||
}, [refs]); |
||||
|
||||
return targetRef; |
||||
} |
@ -0,0 +1,51 @@ |
||||
import React from 'react'; |
||||
import { GrafanaTheme2, VisualizationSuggestion } from '@grafana/data'; |
||||
import { useStyles2 } from '../../../../../packages/grafana-ui/src'; |
||||
import { css } from '@emotion/css'; |
||||
|
||||
interface Props { |
||||
message: string; |
||||
suggestions?: VisualizationSuggestion[]; |
||||
} |
||||
|
||||
export function CannotVisualizeData({ message, suggestions }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.wrapper}> |
||||
<div className={styles.message}>{message}</div> |
||||
{ |
||||
// suggestions && (
|
||||
// <div className={styles.suggestions}>
|
||||
// {suggestions.map((suggestion, index) => (
|
||||
// <VisualizationPreview
|
||||
// key={index}
|
||||
// data={data!}
|
||||
// suggestion={suggestion}
|
||||
// onChange={onChange}
|
||||
// width={150}
|
||||
// />
|
||||
// ))}
|
||||
// </div>
|
||||
// )
|
||||
} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
wrapper: css` |
||||
display: flex; |
||||
align-items: center; |
||||
height: 100%; |
||||
width: 100%; |
||||
`,
|
||||
message: css` |
||||
text-align: center; |
||||
color: $text-muted; |
||||
font-size: $font-size-lg; |
||||
width: 100%; |
||||
`,
|
||||
}; |
||||
}; |
@ -0,0 +1,128 @@ |
||||
import React, { CSSProperties } from 'react'; |
||||
import { GrafanaTheme2, PanelData, VisualizationSuggestion } from '@grafana/data'; |
||||
import { PanelRenderer } from '../PanelRenderer'; |
||||
import { css } from '@emotion/css'; |
||||
import { Tooltip, useStyles2 } from '@grafana/ui'; |
||||
import { VizTypeChangeDetails } from './types'; |
||||
import { cloneDeep } from 'lodash'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
|
||||
export interface Props { |
||||
data: PanelData; |
||||
width: number; |
||||
suggestion: VisualizationSuggestion; |
||||
showTitle?: boolean; |
||||
onChange: (details: VizTypeChangeDetails) => void; |
||||
} |
||||
|
||||
export function VisualizationPreview({ data, suggestion, onChange, width, showTitle }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const { innerStyles, outerStyles, renderWidth, renderHeight } = getPreviewDimensionsAndStyles(width); |
||||
|
||||
const onClick = () => { |
||||
onChange({ |
||||
pluginId: suggestion.pluginId, |
||||
options: suggestion.options, |
||||
fieldConfig: suggestion.fieldConfig, |
||||
}); |
||||
}; |
||||
|
||||
let preview = suggestion; |
||||
if (suggestion.previewModifier) { |
||||
preview = cloneDeep(suggestion); |
||||
suggestion.previewModifier(preview); |
||||
} |
||||
|
||||
return ( |
||||
<div onClick={onClick} data-testid={selectors.components.VisualizationPreview.card(suggestion.name)}> |
||||
{showTitle && <div className={styles.name}>{suggestion.name}</div>} |
||||
<div className={styles.vizBox} style={outerStyles}> |
||||
<Tooltip content={suggestion.name}> |
||||
<div style={innerStyles} className={styles.renderContainer}> |
||||
<PanelRenderer |
||||
title="" |
||||
data={data} |
||||
pluginId={suggestion.pluginId} |
||||
width={renderWidth} |
||||
height={renderHeight} |
||||
options={preview.options} |
||||
fieldConfig={preview.fieldConfig} |
||||
/> |
||||
<div className={styles.hoverPane} /> |
||||
</div> |
||||
</Tooltip> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
hoverPane: css({ |
||||
position: 'absolute', |
||||
top: 0, |
||||
right: 0, |
||||
left: 0, |
||||
borderRadius: theme.spacing(2), |
||||
bottom: 0, |
||||
}), |
||||
vizBox: css` |
||||
position: relative; |
||||
border-radius: ${theme.shape.borderRadius(1)}; |
||||
cursor: pointer; |
||||
border: 1px solid ${theme.colors.border.strong}; |
||||
|
||||
transition: ${theme.transitions.create(['background'], { |
||||
duration: theme.transitions.duration.short, |
||||
})}; |
||||
|
||||
&:hover { |
||||
background: ${theme.colors.background.secondary}; |
||||
} |
||||
`,
|
||||
name: css` |
||||
font-size: ${theme.typography.bodySmall.fontSize}; |
||||
white-space: nowrap; |
||||
overflow: hidden; |
||||
color: ${theme.colors.text.secondary}; |
||||
font-weight: ${theme.typography.fontWeightMedium}; |
||||
text-overflow: ellipsis; |
||||
`,
|
||||
renderContainer: css` |
||||
position: absolute; |
||||
transform-origin: left top; |
||||
top: 6px; |
||||
left: 6px; |
||||
`,
|
||||
}; |
||||
}; |
||||
|
||||
interface PreviewDimensionsAndStyles { |
||||
renderWidth: number; |
||||
renderHeight: number; |
||||
innerStyles: CSSProperties; |
||||
outerStyles: CSSProperties; |
||||
} |
||||
|
||||
function getPreviewDimensionsAndStyles(width: number): PreviewDimensionsAndStyles { |
||||
const aspectRatio = 16 / 10; |
||||
const showWidth = width; |
||||
const showHeight = width * (1 / aspectRatio); |
||||
const renderWidth = 350; |
||||
const renderHeight = renderWidth * (1 / aspectRatio); |
||||
|
||||
const padding = 6; |
||||
const widthFactor = (showWidth - padding * 2) / renderWidth; |
||||
const heightFactor = (showHeight - padding * 2) / renderHeight; |
||||
|
||||
return { |
||||
renderHeight, |
||||
renderWidth, |
||||
outerStyles: { width: showWidth, height: showHeight }, |
||||
innerStyles: { |
||||
width: renderWidth, |
||||
height: renderHeight, |
||||
transform: `scale(${widthFactor}, ${heightFactor})`, |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,106 @@ |
||||
import React from 'react'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
import { GrafanaTheme2, PanelData, PanelPluginMeta, PanelModel, VisualizationSuggestion } from '@grafana/data'; |
||||
import { css } from '@emotion/css'; |
||||
import { VizTypeChangeDetails } from './types'; |
||||
import { VisualizationPreview } from './VisualizationPreview'; |
||||
import { getAllSuggestions } from '../../state/getAllSuggestions'; |
||||
import { useAsync, useLocalStorage } from 'react-use'; |
||||
import AutoSizer from 'react-virtualized-auto-sizer'; |
||||
|
||||
export interface Props { |
||||
current: PanelPluginMeta; |
||||
data?: PanelData; |
||||
panel?: PanelModel; |
||||
onChange: (options: VizTypeChangeDetails) => void; |
||||
searchQuery: string; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
export function VisualizationSuggestions({ onChange, data, panel, searchQuery }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const { value: suggestions } = useAsync(() => getAllSuggestions(data, panel), [data, panel]); |
||||
// temp test
|
||||
const [showTitle, setShowTitle] = useLocalStorage(`VisualizationSuggestions.showTitle`, false); |
||||
const filteredSuggestions = filterSuggestionsBySearch(searchQuery, suggestions); |
||||
|
||||
return ( |
||||
<AutoSizer disableHeight style={{ width: '100%', height: '100%' }}> |
||||
{({ width }) => { |
||||
if (!width) { |
||||
return null; |
||||
} |
||||
|
||||
const columnCount = Math.floor(width / 170); |
||||
const spaceBetween = 8 * (columnCount! - 1); |
||||
const previewWidth = (width - spaceBetween) / columnCount!; |
||||
|
||||
return ( |
||||
<div> |
||||
<div className={styles.filterRow}> |
||||
<div className={styles.infoText} onClick={() => setShowTitle(!showTitle)}> |
||||
Based on current data |
||||
</div> |
||||
</div> |
||||
<div className={styles.grid} style={{ gridTemplateColumns: `repeat(auto-fill, ${previewWidth - 1}px)` }}> |
||||
{filteredSuggestions.map((suggestion, index) => ( |
||||
<VisualizationPreview |
||||
key={index} |
||||
data={data!} |
||||
suggestion={suggestion} |
||||
onChange={onChange} |
||||
width={previewWidth} |
||||
showTitle={showTitle} |
||||
/> |
||||
))} |
||||
{searchQuery && filteredSuggestions.length === 0 && ( |
||||
<div className={styles.infoText}>No results matched your query</div> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
}} |
||||
</AutoSizer> |
||||
); |
||||
} |
||||
|
||||
function filterSuggestionsBySearch( |
||||
searchQuery: string, |
||||
suggestions?: VisualizationSuggestion[] |
||||
): VisualizationSuggestion[] { |
||||
if (!searchQuery || !suggestions) { |
||||
return suggestions || []; |
||||
} |
||||
|
||||
const regex = new RegExp(searchQuery, 'i'); |
||||
|
||||
return suggestions.filter((s) => regex.test(s.name) || regex.test(s.pluginId)); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
heading: css({ |
||||
...theme.typography.h5, |
||||
margin: theme.spacing(0, 0.5, 1), |
||||
}), |
||||
filterRow: css({ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
justifyContent: 'space-around', |
||||
alignItems: 'center', |
||||
paddingBottom: '8px', |
||||
}), |
||||
infoText: css({ |
||||
fontSize: theme.typography.bodySmall.fontSize, |
||||
color: theme.colors.text.secondary, |
||||
fontStyle: 'italic', |
||||
}), |
||||
grid: css({ |
||||
display: 'grid', |
||||
gridGap: theme.spacing(1), |
||||
gridTemplateColumns: 'repeat(auto-fill, 144px)', |
||||
marginBottom: theme.spacing(1), |
||||
justifyContent: 'space-evenly', |
||||
}), |
||||
}; |
||||
}; |
@ -1,118 +1,63 @@ |
||||
import React, { useCallback, useMemo } from 'react'; |
||||
|
||||
import config from 'app/core/config'; |
||||
import React, { useMemo } from 'react'; |
||||
import { VizTypePickerPlugin } from './VizTypePickerPlugin'; |
||||
import { EmptySearchResult, stylesFactory, useTheme } from '@grafana/ui'; |
||||
import { GrafanaTheme, PanelPluginMeta, PluginState } from '@grafana/data'; |
||||
import { EmptySearchResult, useStyles2 } from '@grafana/ui'; |
||||
import { GrafanaTheme2, PanelData, PanelPluginMeta } from '@grafana/data'; |
||||
import { css } from '@emotion/css'; |
||||
import { filterPluginList, getAllPanelPluginMeta } from '../../state/util'; |
||||
import { VizTypeChangeDetails } from './types'; |
||||
|
||||
export interface Props { |
||||
current: PanelPluginMeta; |
||||
onTypeChange: (newType: PanelPluginMeta, withModKey: boolean) => void; |
||||
data?: PanelData; |
||||
onChange: (options: VizTypeChangeDetails) => void; |
||||
searchQuery: string; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
export function getAllPanelPluginMeta(): PanelPluginMeta[] { |
||||
const allPanels = config.panels; |
||||
|
||||
return Object.keys(allPanels) |
||||
.filter((key) => allPanels[key]['hideFromList'] === false) |
||||
.map((key) => allPanels[key]) |
||||
.sort((a: PanelPluginMeta, b: PanelPluginMeta) => a.sort - b.sort); |
||||
} |
||||
|
||||
export function filterPluginList( |
||||
pluginsList: PanelPluginMeta[], |
||||
searchQuery: string, |
||||
current: PanelPluginMeta |
||||
): PanelPluginMeta[] { |
||||
if (!searchQuery.length) { |
||||
return pluginsList.filter((p) => { |
||||
if (p.state === PluginState.deprecated) { |
||||
return current.id === p.id; |
||||
} |
||||
return true; |
||||
}); |
||||
} |
||||
|
||||
const query = searchQuery.toLowerCase(); |
||||
const first: PanelPluginMeta[] = []; |
||||
const match: PanelPluginMeta[] = []; |
||||
|
||||
for (const item of pluginsList) { |
||||
if (item.state === PluginState.deprecated && current.id !== item.id) { |
||||
continue; |
||||
} |
||||
|
||||
const name = item.name.toLowerCase(); |
||||
const idx = name.indexOf(query); |
||||
|
||||
if (idx === 0) { |
||||
first.push(item); |
||||
} else if (idx > 0) { |
||||
match.push(item); |
||||
} |
||||
} |
||||
|
||||
return first.concat(match); |
||||
} |
||||
|
||||
export const VizTypePicker: React.FC<Props> = ({ searchQuery, onTypeChange, current }) => { |
||||
const theme = useTheme(); |
||||
const styles = getStyles(theme); |
||||
export function VizTypePicker({ searchQuery, onChange, current, data }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const pluginsList: PanelPluginMeta[] = useMemo(() => { |
||||
return getAllPanelPluginMeta(); |
||||
}, []); |
||||
|
||||
const getFilteredPluginList = useCallback((): PanelPluginMeta[] => { |
||||
const filteredPluginTypes = useMemo((): PanelPluginMeta[] => { |
||||
return filterPluginList(pluginsList, searchQuery, current); |
||||
}, [current, pluginsList, searchQuery]); |
||||
|
||||
const renderVizPlugin = (plugin: PanelPluginMeta, index: number) => { |
||||
const isCurrent = plugin.id === current.id; |
||||
const filteredPluginList = getFilteredPluginList(); |
||||
|
||||
const matchesQuery = filteredPluginList.indexOf(plugin) > -1; |
||||
return ( |
||||
<VizTypePickerPlugin |
||||
disabled={!matchesQuery && !!searchQuery} |
||||
key={plugin.id} |
||||
isCurrent={isCurrent} |
||||
plugin={plugin} |
||||
onClick={(e) => onTypeChange(plugin, Boolean(e.metaKey || e.ctrlKey || e.altKey))} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
const filteredPluginList = getFilteredPluginList(); |
||||
const hasResults = filteredPluginList.length > 0; |
||||
const renderList = filteredPluginList.concat(pluginsList.filter((p) => filteredPluginList.indexOf(p) === -1)); |
||||
if (filteredPluginTypes.length === 0) { |
||||
return <EmptySearchResult>Could not find anything matching your query</EmptySearchResult>; |
||||
} |
||||
|
||||
return ( |
||||
<div className={styles.grid}> |
||||
{hasResults ? ( |
||||
renderList.map((plugin, index) => { |
||||
if (plugin.state === PluginState.deprecated) { |
||||
return null; |
||||
{filteredPluginTypes.map((plugin, index) => ( |
||||
<VizTypePickerPlugin |
||||
disabled={false} |
||||
key={plugin.id} |
||||
isCurrent={plugin.id === current.id} |
||||
plugin={plugin} |
||||
onClick={(e) => |
||||
onChange({ |
||||
pluginId: plugin.id, |
||||
withModKey: Boolean(e.metaKey || e.ctrlKey || e.altKey), |
||||
}) |
||||
} |
||||
return renderVizPlugin(plugin, index); |
||||
}) |
||||
) : ( |
||||
<EmptySearchResult>Could not find anything matching your query</EmptySearchResult> |
||||
)} |
||||
/> |
||||
))} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
VizTypePicker.displayName = 'VizTypePicker'; |
||||
} |
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => { |
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
grid: css` |
||||
max-width: 100%; |
||||
display: grid; |
||||
grid-gap: ${theme.spacing.sm}; |
||||
grid-gap: ${theme.spacing(0.5)}; |
||||
`,
|
||||
heading: css({ |
||||
...theme.typography.h5, |
||||
margin: theme.spacing(0, 0.5, 1), |
||||
}), |
||||
}; |
||||
}); |
||||
}; |
||||
|
@ -0,0 +1,8 @@ |
||||
import { FieldConfigSource } from '@grafana/data'; |
||||
|
||||
export interface VizTypeChangeDetails { |
||||
pluginId: string; |
||||
options?: any; |
||||
fieldConfig?: FieldConfigSource; |
||||
withModKey?: boolean; |
||||
} |
@ -0,0 +1,305 @@ |
||||
import { |
||||
DataFrame, |
||||
FieldType, |
||||
getDefaultTimeRange, |
||||
LoadingState, |
||||
PanelData, |
||||
toDataFrame, |
||||
VisualizationSuggestion, |
||||
} from '@grafana/data'; |
||||
import { config } from 'app/core/config'; |
||||
import { SuggestionName } from 'app/types/suggestions'; |
||||
import { getAllSuggestions, panelsToCheckFirst } from './getAllSuggestions'; |
||||
|
||||
jest.unmock('app/core/core'); |
||||
jest.unmock('app/features/plugins/plugin_loader'); |
||||
|
||||
for (const pluginId of panelsToCheckFirst) { |
||||
config.panels[pluginId] = { |
||||
module: `app/plugins/panel/${pluginId}/module`, |
||||
} as any; |
||||
} |
||||
|
||||
class ScenarioContext { |
||||
data: DataFrame[] = []; |
||||
suggestions: VisualizationSuggestion[] = []; |
||||
|
||||
setData(scenarioData: DataFrame[]) { |
||||
this.data = scenarioData; |
||||
|
||||
beforeAll(async () => { |
||||
await this.run(); |
||||
}); |
||||
} |
||||
|
||||
async run() { |
||||
const panelData: PanelData = { |
||||
series: this.data, |
||||
state: LoadingState.Done, |
||||
timeRange: getDefaultTimeRange(), |
||||
}; |
||||
|
||||
this.suggestions = await getAllSuggestions(panelData); |
||||
} |
||||
|
||||
names() { |
||||
return this.suggestions.map((x) => x.name); |
||||
} |
||||
} |
||||
|
||||
function scenario(name: string, setup: (ctx: ScenarioContext) => void) { |
||||
describe(name, () => { |
||||
const ctx = new ScenarioContext(); |
||||
setup(ctx); |
||||
}); |
||||
} |
||||
|
||||
scenario('No series', (ctx) => { |
||||
ctx.setData([]); |
||||
|
||||
it('should return correct suggestions', () => { |
||||
expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel, SuggestionName.DashboardList]); |
||||
}); |
||||
}); |
||||
|
||||
scenario('No rows', (ctx) => { |
||||
ctx.setData([ |
||||
toDataFrame({ |
||||
fields: [ |
||||
{ name: 'Time', type: FieldType.time, values: [] }, |
||||
{ name: 'Max', type: FieldType.number, values: [] }, |
||||
], |
||||
}), |
||||
]); |
||||
|
||||
it('should return correct suggestions', () => { |
||||
expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel, SuggestionName.DashboardList]); |
||||
}); |
||||
}); |
||||
|
||||
scenario('Single frame with time and number field', (ctx) => { |
||||
ctx.setData([ |
||||
toDataFrame({ |
||||
fields: [ |
||||
{ name: 'Time', type: FieldType.time, values: [1, 2, 3, 4, 5] }, |
||||
{ name: 'Max', type: FieldType.number, values: [1, 10, 50, 2, 5] }, |
||||
], |
||||
}), |
||||
]); |
||||
|
||||
it('should return correct suggestions', () => { |
||||
expect(ctx.names()).toEqual([ |
||||
SuggestionName.LineChart, |
||||
SuggestionName.LineChartSmooth, |
||||
SuggestionName.AreaChart, |
||||
SuggestionName.BarChart, |
||||
SuggestionName.Gauge, |
||||
SuggestionName.GaugeNoThresholds, |
||||
SuggestionName.Stat, |
||||
SuggestionName.StatColoredBackground, |
||||
SuggestionName.BarGaugeBasic, |
||||
SuggestionName.BarGaugeLCD, |
||||
SuggestionName.Table, |
||||
SuggestionName.StateTimeline, |
||||
]); |
||||
}); |
||||
|
||||
it('Bar chart suggestion should be using timeseries panel', () => { |
||||
expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)?.pluginId).toBe('timeseries'); |
||||
}); |
||||
|
||||
it('Stat panels have reduce values disabled', () => { |
||||
for (const suggestion of ctx.suggestions) { |
||||
if (suggestion.options?.reduceOptions?.values) { |
||||
throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`); |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
scenario('Single frame with time 2 number fields', (ctx) => { |
||||
ctx.setData([ |
||||
toDataFrame({ |
||||
fields: [ |
||||
{ name: 'Time', type: FieldType.time, values: [1, 2, 3, 4, 5] }, |
||||
{ name: 'ServerA', type: FieldType.number, values: [1, 10, 50, 2, 5] }, |
||||
{ name: 'ServerB', type: FieldType.number, values: [1, 10, 50, 2, 5] }, |
||||
], |
||||
}), |
||||
]); |
||||
|
||||
it('should return correct suggestions', () => { |
||||
expect(ctx.names()).toEqual([ |
||||
SuggestionName.LineChart, |
||||
SuggestionName.LineChartSmooth, |
||||
SuggestionName.AreaChartStacked, |
||||
SuggestionName.AreaChartStackedPercent, |
||||
SuggestionName.BarChartStacked, |
||||
SuggestionName.BarChartStackedPercent, |
||||
SuggestionName.Gauge, |
||||
SuggestionName.GaugeNoThresholds, |
||||
SuggestionName.Stat, |
||||
SuggestionName.StatColoredBackground, |
||||
SuggestionName.PieChart, |
||||
SuggestionName.PieChartDonut, |
||||
SuggestionName.BarGaugeBasic, |
||||
SuggestionName.BarGaugeLCD, |
||||
SuggestionName.Table, |
||||
SuggestionName.StateTimeline, |
||||
]); |
||||
}); |
||||
|
||||
it('Stat panels have reduceOptions.values disabled', () => { |
||||
for (const suggestion of ctx.suggestions) { |
||||
if (suggestion.options?.reduceOptions?.values) { |
||||
throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`); |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
scenario('Single time series with 100 data points', (ctx) => { |
||||
ctx.setData([ |
||||
toDataFrame({ |
||||
fields: [ |
||||
{ name: 'Time', type: FieldType.time, values: [...Array(100).keys()] }, |
||||
{ name: 'ServerA', type: FieldType.number, values: [...Array(100).keys()] }, |
||||
], |
||||
}), |
||||
]); |
||||
|
||||
it('should not suggest bar chart', () => { |
||||
expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)).toBe(undefined); |
||||
}); |
||||
}); |
||||
|
||||
scenario('30 time series with 100 data points', (ctx) => { |
||||
ctx.setData( |
||||
repeatFrame( |
||||
30, |
||||
toDataFrame({ |
||||
fields: [ |
||||
{ name: 'Time', type: FieldType.time, values: [...Array(100).keys()] }, |
||||
{ name: 'ServerA', type: FieldType.number, values: [...Array(100).keys()] }, |
||||
], |
||||
}) |
||||
) |
||||
); |
||||
|
||||
it('should not suggest timeline', () => { |
||||
expect(ctx.suggestions.find((x) => x.pluginId === 'state-timeline')).toBe(undefined); |
||||
}); |
||||
}); |
||||
|
||||
scenario('50 time series with 100 data points', (ctx) => { |
||||
ctx.setData( |
||||
repeatFrame( |
||||
50, |
||||
toDataFrame({ |
||||
fields: [ |
||||
{ name: 'Time', type: FieldType.time, values: [...Array(100).keys()] }, |
||||
{ name: 'ServerA', type: FieldType.number, values: [...Array(100).keys()] }, |
||||
], |
||||
}) |
||||
) |
||||
); |
||||
|
||||
it('should not suggest gauge', () => { |
||||
expect(ctx.suggestions.find((x) => x.pluginId === 'gauge')).toBe(undefined); |
||||
}); |
||||
}); |
||||
|
||||
scenario('Single frame with string and number field', (ctx) => { |
||||
ctx.setData([ |
||||
toDataFrame({ |
||||
fields: [ |
||||
{ name: 'Name', type: FieldType.string, values: ['Hugo', 'Dominik', 'Marcus'] }, |
||||
{ name: 'ServerA', type: FieldType.number, values: [1, 2, 3] }, |
||||
], |
||||
}), |
||||
]); |
||||
|
||||
it('should return correct suggestions', () => { |
||||
expect(ctx.names()).toEqual([ |
||||
SuggestionName.BarChart, |
||||
SuggestionName.BarChartHorizontal, |
||||
SuggestionName.Gauge, |
||||
SuggestionName.GaugeNoThresholds, |
||||
SuggestionName.Stat, |
||||
SuggestionName.StatColoredBackground, |
||||
SuggestionName.PieChart, |
||||
SuggestionName.PieChartDonut, |
||||
SuggestionName.BarGaugeBasic, |
||||
SuggestionName.BarGaugeLCD, |
||||
SuggestionName.Table, |
||||
]); |
||||
}); |
||||
|
||||
it('Stat/Gauge/BarGauge/PieChart panels to have reduceOptions.values enabled', () => { |
||||
for (const suggestion of ctx.suggestions) { |
||||
if (suggestion.options?.reduceOptions && !suggestion.options?.reduceOptions?.values) { |
||||
throw new Error(`Suggestion ${suggestion.name} reduce.values set to false when it should be true`); |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
scenario('Single frame with string and 2 number field', (ctx) => { |
||||
ctx.setData([ |
||||
toDataFrame({ |
||||
fields: [ |
||||
{ name: 'Name', type: FieldType.string, values: ['Hugo', 'Dominik', 'Marcus'] }, |
||||
{ name: 'ServerA', type: FieldType.number, values: [1, 2, 3] }, |
||||
{ name: 'ServerB', type: FieldType.number, values: [1, 2, 3] }, |
||||
], |
||||
}), |
||||
]); |
||||
|
||||
it('should return correct suggestions', () => { |
||||
expect(ctx.names()).toEqual([ |
||||
SuggestionName.BarChart, |
||||
SuggestionName.BarChartStacked, |
||||
SuggestionName.BarChartStackedPercent, |
||||
SuggestionName.BarChartHorizontal, |
||||
SuggestionName.BarChartHorizontalStacked, |
||||
SuggestionName.BarChartHorizontalStackedPercent, |
||||
SuggestionName.Gauge, |
||||
SuggestionName.GaugeNoThresholds, |
||||
SuggestionName.Stat, |
||||
SuggestionName.StatColoredBackground, |
||||
SuggestionName.PieChart, |
||||
SuggestionName.PieChartDonut, |
||||
SuggestionName.BarGaugeBasic, |
||||
SuggestionName.BarGaugeLCD, |
||||
SuggestionName.Table, |
||||
]); |
||||
}); |
||||
}); |
||||
|
||||
scenario('Single frame with string with only string field', (ctx) => { |
||||
ctx.setData([ |
||||
toDataFrame({ |
||||
fields: [{ name: 'Name', type: FieldType.string, values: ['Hugo', 'Dominik', 'Marcus'] }], |
||||
}), |
||||
]); |
||||
|
||||
it('should return correct suggestions', () => { |
||||
expect(ctx.names()).toEqual([SuggestionName.Stat, SuggestionName.StatColoredBackground, SuggestionName.Table]); |
||||
}); |
||||
|
||||
it('Stat panels have reduceOptions.fields set to show all fields', () => { |
||||
for (const suggestion of ctx.suggestions) { |
||||
if (suggestion.options?.reduceOptions) { |
||||
expect(suggestion.options.reduceOptions.fields).toBe('/.*/'); |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
function repeatFrame(count: number, frame: DataFrame): DataFrame[] { |
||||
const frames: DataFrame[] = []; |
||||
for (let i = 0; i < count; i++) { |
||||
frames.push(frame); |
||||
} |
||||
return frames; |
||||
} |
@ -0,0 +1,30 @@ |
||||
import { PanelData, VisualizationSuggestion, VisualizationSuggestionsBuilder, PanelModel } from '@grafana/data'; |
||||
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; |
||||
|
||||
export const panelsToCheckFirst = [ |
||||
'timeseries', |
||||
'barchart', |
||||
'gauge', |
||||
'stat', |
||||
'piechart', |
||||
'bargauge', |
||||
'table', |
||||
'state-timeline', |
||||
'text', |
||||
'dashlist', |
||||
]; |
||||
|
||||
export async function getAllSuggestions(data?: PanelData, panel?: PanelModel): Promise<VisualizationSuggestion[]> { |
||||
const builder = new VisualizationSuggestionsBuilder(data, panel); |
||||
|
||||
for (const pluginId of panelsToCheckFirst) { |
||||
const plugin = await importPanelPlugin(pluginId); |
||||
const supplier = plugin.getSuggestionsSupplier(); |
||||
|
||||
if (supplier) { |
||||
supplier.getSuggestionsForData(builder); |
||||
} |
||||
} |
||||
|
||||
return builder.getList(); |
||||
} |
@ -0,0 +1,17 @@ |
||||
import { VisualizationSuggestion, PanelModel, PanelPlugin, PanelData } from '@grafana/data'; |
||||
|
||||
export function getOptionSuggestions( |
||||
plugin: PanelPlugin, |
||||
panel: PanelModel, |
||||
data?: PanelData |
||||
): VisualizationSuggestion[] { |
||||
// const supplier = plugin.getSuggestionsSupplier();
|
||||
|
||||
// if (supplier && supplier.getOptionSuggestions) {
|
||||
// const builder = new VisualizationSuggestionsBuilder(data, panel);
|
||||
// supplier.getOptionSuggestions(builder);
|
||||
// return builder.getList();
|
||||
// }
|
||||
|
||||
return []; |
||||
} |
@ -0,0 +1,47 @@ |
||||
import { PanelPluginMeta, PluginState } from '@grafana/data'; |
||||
import { config } from 'app/core/config'; |
||||
|
||||
export function getAllPanelPluginMeta(): PanelPluginMeta[] { |
||||
const allPanels = config.panels; |
||||
|
||||
return Object.keys(allPanels) |
||||
.filter((key) => allPanels[key]['hideFromList'] === false) |
||||
.map((key) => allPanels[key]) |
||||
.sort((a: PanelPluginMeta, b: PanelPluginMeta) => a.sort - b.sort); |
||||
} |
||||
|
||||
export function filterPluginList( |
||||
pluginsList: PanelPluginMeta[], |
||||
searchQuery: string, |
||||
current: PanelPluginMeta |
||||
): PanelPluginMeta[] { |
||||
if (!searchQuery.length) { |
||||
return pluginsList.filter((p) => { |
||||
if (p.state === PluginState.deprecated) { |
||||
return current.id === p.id; |
||||
} |
||||
return true; |
||||
}); |
||||
} |
||||
|
||||
const query = searchQuery.toLowerCase(); |
||||
const first: PanelPluginMeta[] = []; |
||||
const match: PanelPluginMeta[] = []; |
||||
|
||||
for (const item of pluginsList) { |
||||
if (item.state === PluginState.deprecated && current.id !== item.id) { |
||||
continue; |
||||
} |
||||
|
||||
const name = item.name.toLowerCase(); |
||||
const idx = name.indexOf(query); |
||||
|
||||
if (idx === 0) { |
||||
first.push(item); |
||||
} else if (idx > 0) { |
||||
match.push(item); |
||||
} |
||||
} |
||||
|
||||
return first.concat(match); |
||||
} |
@ -0,0 +1,20 @@ |
||||
import { VisualizationSuggestionsBuilder } from '@grafana/data'; |
||||
import { AlertListOptions } from './types'; |
||||
|
||||
export class AlertListSuggestionsSupplier { |
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const { dataSummary } = builder; |
||||
|
||||
if (dataSummary.hasData) { |
||||
return; |
||||
} |
||||
|
||||
const list = builder.getListAppender<AlertListOptions, {}>({ |
||||
name: 'Dashboard list', |
||||
pluginId: 'dashlist', |
||||
options: {}, |
||||
}); |
||||
|
||||
list.append({}); |
||||
} |
||||
} |
@ -0,0 +1,94 @@ |
||||
import { VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data'; |
||||
import { LegendDisplayMode, StackingMode, VisibilityMode } from '@grafana/schema'; |
||||
import { SuggestionName } from 'app/types/suggestions'; |
||||
import { BarChartFieldConfig, BarChartOptions } from './types'; |
||||
|
||||
export class BarChartSuggestionsSupplier { |
||||
getListWithDefaults(builder: VisualizationSuggestionsBuilder) { |
||||
return builder.getListAppender<BarChartOptions, BarChartFieldConfig>({ |
||||
name: SuggestionName.BarChart, |
||||
pluginId: 'barchart', |
||||
options: { |
||||
showValue: VisibilityMode.Never, |
||||
legend: { |
||||
displayMode: LegendDisplayMode.Hidden, |
||||
placement: 'right', |
||||
} as any, |
||||
}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
unit: 'short', |
||||
custom: {}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
previewModifier: (s) => { |
||||
s.options!.barWidth = 0.8; |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const list = this.getListWithDefaults(builder); |
||||
const { dataSummary } = builder; |
||||
|
||||
if (dataSummary.frameCount !== 1) { |
||||
return; |
||||
} |
||||
|
||||
if (!dataSummary.hasNumberField || !dataSummary.hasStringField) { |
||||
return; |
||||
} |
||||
|
||||
// if you have this many rows barchart might not be a good fit
|
||||
if (dataSummary.rowCountTotal > 50) { |
||||
return; |
||||
} |
||||
|
||||
// Vertical bars
|
||||
list.append({ |
||||
name: SuggestionName.BarChart, |
||||
}); |
||||
|
||||
if (dataSummary.numberFieldCount > 1) { |
||||
list.append({ |
||||
name: SuggestionName.BarChartStacked, |
||||
options: { |
||||
stacking: StackingMode.Normal, |
||||
}, |
||||
}); |
||||
list.append({ |
||||
name: SuggestionName.BarChartStackedPercent, |
||||
options: { |
||||
stacking: StackingMode.Percent, |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
// horizontal bars
|
||||
list.append({ |
||||
name: SuggestionName.BarChartHorizontal, |
||||
options: { |
||||
orientation: VizOrientation.Horizontal, |
||||
}, |
||||
}); |
||||
|
||||
if (dataSummary.numberFieldCount > 1) { |
||||
list.append({ |
||||
name: SuggestionName.BarChartHorizontalStacked, |
||||
options: { |
||||
stacking: StackingMode.Normal, |
||||
orientation: VizOrientation.Horizontal, |
||||
}, |
||||
}); |
||||
|
||||
list.append({ |
||||
name: SuggestionName.BarChartHorizontalStackedPercent, |
||||
options: { |
||||
orientation: VizOrientation.Horizontal, |
||||
stacking: StackingMode.Percent, |
||||
}, |
||||
}); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,115 @@ |
||||
import { VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data'; |
||||
import { BarGaugeDisplayMode } from '@grafana/ui'; |
||||
import { SuggestionName } from 'app/types/suggestions'; |
||||
import { BarGaugeOptions } from './types'; |
||||
|
||||
export class BarGaugeSuggestionsSupplier { |
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const { dataSummary } = builder; |
||||
|
||||
if (!dataSummary.hasData || !dataSummary.hasNumberField) { |
||||
return; |
||||
} |
||||
|
||||
const list = builder.getListAppender<BarGaugeOptions, {}>({ |
||||
name: '', |
||||
pluginId: 'bargauge', |
||||
options: {}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: {}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
previewModifier: (s) => {}, |
||||
}); |
||||
|
||||
// This is probably not a good option for many numeric fields
|
||||
if (dataSummary.numberFieldCount > 50) { |
||||
return; |
||||
} |
||||
|
||||
// To use show individual row values we also need a string field to give each value a name
|
||||
if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 30) { |
||||
list.append({ |
||||
name: SuggestionName.BarGaugeBasic, |
||||
options: { |
||||
reduceOptions: { |
||||
values: true, |
||||
calcs: [], |
||||
}, |
||||
displayMode: BarGaugeDisplayMode.Basic, |
||||
orientation: VizOrientation.Horizontal, |
||||
}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
color: { |
||||
mode: 'continuous-GrYlRd', |
||||
}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
|
||||
list.append({ |
||||
name: SuggestionName.BarGaugeLCD, |
||||
options: { |
||||
reduceOptions: { |
||||
values: true, |
||||
calcs: [], |
||||
}, |
||||
displayMode: BarGaugeDisplayMode.Lcd, |
||||
orientation: VizOrientation.Horizontal, |
||||
}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
color: { |
||||
mode: 'continuous-GrYlRd', |
||||
}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
} else { |
||||
list.append({ |
||||
name: SuggestionName.BarGaugeBasic, |
||||
options: { |
||||
displayMode: BarGaugeDisplayMode.Basic, |
||||
orientation: VizOrientation.Horizontal, |
||||
reduceOptions: { |
||||
values: false, |
||||
calcs: ['lastNotNull'], |
||||
}, |
||||
}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
color: { |
||||
mode: 'continuous-GrYlRd', |
||||
}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
|
||||
list.append({ |
||||
name: SuggestionName.BarGaugeLCD, |
||||
options: { |
||||
displayMode: BarGaugeDisplayMode.Lcd, |
||||
orientation: VizOrientation.Horizontal, |
||||
reduceOptions: { |
||||
values: false, |
||||
calcs: ['lastNotNull'], |
||||
}, |
||||
}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
color: { |
||||
mode: 'continuous-GrYlRd', |
||||
}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,20 @@ |
||||
import { VisualizationSuggestionsBuilder } from '@grafana/data'; |
||||
import { PanelOptions } from './models.gen'; |
||||
|
||||
export class DashListSuggestionsSupplier { |
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const { dataSummary } = builder; |
||||
|
||||
if (dataSummary.hasData) { |
||||
return; |
||||
} |
||||
|
||||
const list = builder.getListAppender<PanelOptions, {}>({ |
||||
name: 'Dashboard list', |
||||
pluginId: 'dashlist', |
||||
options: {}, |
||||
}); |
||||
|
||||
list.append({}); |
||||
} |
||||
} |
@ -0,0 +1,85 @@ |
||||
import { ThresholdsMode, VisualizationSuggestionsBuilder } from '@grafana/data'; |
||||
import { SuggestionName } from 'app/types/suggestions'; |
||||
import { GaugeOptions } from './types'; |
||||
|
||||
export class GaugeSuggestionsSupplier { |
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const { dataSummary } = builder; |
||||
|
||||
if (!dataSummary.hasData || !dataSummary.hasNumberField) { |
||||
return; |
||||
} |
||||
|
||||
// for many fields / series this is probably not a good fit
|
||||
if (dataSummary.numberFieldCount >= 50) { |
||||
return; |
||||
} |
||||
|
||||
const list = builder.getListAppender<GaugeOptions, {}>({ |
||||
name: SuggestionName.Gauge, |
||||
pluginId: 'gauge', |
||||
options: {}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
thresholds: { |
||||
steps: [ |
||||
{ value: -Infinity, color: 'green' }, |
||||
{ value: 70, color: 'orange' }, |
||||
{ value: 85, color: 'red' }, |
||||
], |
||||
mode: ThresholdsMode.Percentage, |
||||
}, |
||||
custom: {}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
previewModifier: (s) => { |
||||
if (s.options!.reduceOptions.values) { |
||||
s.options!.reduceOptions.limit = 2; |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10) { |
||||
list.append({ |
||||
name: SuggestionName.Gauge, |
||||
options: { |
||||
reduceOptions: { |
||||
values: true, |
||||
calcs: [], |
||||
}, |
||||
}, |
||||
}); |
||||
list.append({ |
||||
name: SuggestionName.GaugeNoThresholds, |
||||
options: { |
||||
reduceOptions: { |
||||
values: true, |
||||
calcs: [], |
||||
}, |
||||
showThresholdMarkers: false, |
||||
}, |
||||
}); |
||||
} else { |
||||
list.append({ |
||||
name: SuggestionName.Gauge, |
||||
options: { |
||||
reduceOptions: { |
||||
values: false, |
||||
calcs: ['lastNotNull'], |
||||
}, |
||||
}, |
||||
}); |
||||
list.append({ |
||||
name: SuggestionName.GaugeNoThresholds, |
||||
options: { |
||||
reduceOptions: { |
||||
values: false, |
||||
calcs: ['lastNotNull'], |
||||
}, |
||||
showThresholdMarkers: false, |
||||
}, |
||||
}); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,80 @@ |
||||
import { VisualizationSuggestionsBuilder } from '@grafana/data'; |
||||
import { LegendDisplayMode } from '@grafana/schema'; |
||||
import { SuggestionName } from 'app/types/suggestions'; |
||||
import { PieChartLabels, PieChartOptions, PieChartType } from './types'; |
||||
|
||||
export class PieChartSuggestionsSupplier { |
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const list = builder.getListAppender<PieChartOptions, {}>({ |
||||
name: SuggestionName.PieChart, |
||||
pluginId: 'piechart', |
||||
options: { |
||||
reduceOptions: { |
||||
values: false, |
||||
calcs: ['lastNotNull'], |
||||
}, |
||||
displayLabels: [PieChartLabels.Percent], |
||||
legend: { |
||||
placement: 'right', |
||||
values: [], |
||||
} as any, |
||||
}, |
||||
previewModifier: (s) => { |
||||
// Hide labels in preview
|
||||
s.options!.legend.displayMode = LegendDisplayMode.Hidden; |
||||
s.options!.displayLabels = []; |
||||
}, |
||||
}); |
||||
|
||||
const { dataSummary } = builder; |
||||
|
||||
if (!dataSummary.hasNumberField) { |
||||
return; |
||||
} |
||||
|
||||
if (dataSummary.hasStringField && dataSummary.frameCount === 1) { |
||||
// if many values this or single value PieChart is not a good option
|
||||
if (dataSummary.rowCountTotal > 30 || dataSummary.rowCountTotal < 2) { |
||||
return; |
||||
} |
||||
|
||||
list.append({ |
||||
name: SuggestionName.PieChart, |
||||
options: { |
||||
reduceOptions: { |
||||
values: true, |
||||
calcs: [], |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
list.append({ |
||||
name: SuggestionName.PieChartDonut, |
||||
options: { |
||||
reduceOptions: { |
||||
values: true, |
||||
calcs: [], |
||||
}, |
||||
pieType: PieChartType.Donut, |
||||
}, |
||||
}); |
||||
|
||||
return; |
||||
} |
||||
|
||||
if (dataSummary.numberFieldCount > 30 || dataSummary.numberFieldCount < 2) { |
||||
return; |
||||
} |
||||
|
||||
list.append({ |
||||
name: SuggestionName.PieChart, |
||||
}); |
||||
|
||||
list.append({ |
||||
name: SuggestionName.PieChartDonut, |
||||
options: { |
||||
pieType: PieChartType.Donut, |
||||
}, |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,77 @@ |
||||
import { VisualizationSuggestionsBuilder } from '@grafana/data'; |
||||
import { BigValueColorMode, BigValueGraphMode } from '@grafana/ui'; |
||||
import { SuggestionName } from 'app/types/suggestions'; |
||||
import { StatPanelOptions } from './types'; |
||||
|
||||
export class StatSuggestionsSupplier { |
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const { dataSummary } = builder; |
||||
|
||||
if (!dataSummary.hasData) { |
||||
return; |
||||
} |
||||
|
||||
const list = builder.getListAppender<StatPanelOptions, {}>({ |
||||
name: SuggestionName.Stat, |
||||
pluginId: 'stat', |
||||
options: {}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
unit: 'short', |
||||
custom: {}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
previewModifier: (s) => { |
||||
if (s.options!.reduceOptions.values) { |
||||
s.options!.reduceOptions.limit = 1; |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10) { |
||||
list.append({ |
||||
name: SuggestionName.Stat, |
||||
options: { |
||||
reduceOptions: { |
||||
values: true, |
||||
calcs: [], |
||||
fields: dataSummary.hasNumberField ? undefined : '/.*/', |
||||
}, |
||||
}, |
||||
}); |
||||
list.append({ |
||||
name: SuggestionName.StatColoredBackground, |
||||
options: { |
||||
reduceOptions: { |
||||
values: true, |
||||
calcs: [], |
||||
fields: dataSummary.hasNumberField ? undefined : '/.*/', |
||||
}, |
||||
colorMode: BigValueColorMode.Background, |
||||
}, |
||||
}); |
||||
} else if (dataSummary.hasNumberField) { |
||||
list.append({ |
||||
options: { |
||||
reduceOptions: { |
||||
values: false, |
||||
calcs: ['lastNotNull'], |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
list.append({ |
||||
name: SuggestionName.StatColoredBackground, |
||||
options: { |
||||
reduceOptions: { |
||||
values: false, |
||||
calcs: ['lastNotNull'], |
||||
}, |
||||
graphMode: BigValueGraphMode.None, |
||||
colorMode: BigValueColorMode.Background, |
||||
}, |
||||
}); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,38 @@ |
||||
import { VisualizationSuggestionsBuilder } from '@grafana/data'; |
||||
import { SuggestionName } from 'app/types/suggestions'; |
||||
import { TimelineFieldConfig, TimelineOptions } from './types'; |
||||
|
||||
export class StatTimelineSuggestionsSupplier { |
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const { dataSummary } = builder; |
||||
|
||||
if (!dataSummary.hasData) { |
||||
return; |
||||
} |
||||
|
||||
// This panel needs a time field and a string or number field
|
||||
if (!dataSummary.hasTimeField || (!dataSummary.hasStringField && !dataSummary.hasNumberField)) { |
||||
return; |
||||
} |
||||
|
||||
// If there are many series then they won't fit on y-axis so this panel is not good fit
|
||||
if (dataSummary.numberFieldCount >= 30) { |
||||
return; |
||||
} |
||||
|
||||
const list = builder.getListAppender<TimelineOptions, TimelineFieldConfig>({ |
||||
name: '', |
||||
pluginId: 'state-timeline', |
||||
options: {}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: {}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
previewModifier: (s) => {}, |
||||
}); |
||||
|
||||
list.append({ name: SuggestionName.StateTimeline }); |
||||
} |
||||
} |
@ -0,0 +1,22 @@ |
||||
import { VisualizationSuggestionsBuilder } from '@grafana/data'; |
||||
import { SuggestionName } from 'app/types/suggestions'; |
||||
import { PanelOptions, PanelFieldConfig } from './models.gen'; |
||||
|
||||
export class TableSuggestionsSupplier { |
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const list = builder.getListAppender<PanelOptions, PanelFieldConfig>({ |
||||
name: '', |
||||
pluginId: 'table', |
||||
options: {}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: {}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
previewModifier: (s) => {}, |
||||
}); |
||||
|
||||
list.append({ name: SuggestionName.Table }); |
||||
} |
||||
} |
@ -0,0 +1,29 @@ |
||||
import { VisualizationSuggestionsBuilder } from '@grafana/data'; |
||||
import { PanelOptions } from './models.gen'; |
||||
|
||||
export class TextPanelSuggestionSupplier { |
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const { dataSummary } = builder; |
||||
|
||||
if (dataSummary.hasData) { |
||||
return; |
||||
} |
||||
|
||||
const list = builder.getListAppender<PanelOptions, {}>({ |
||||
name: 'Text panel', |
||||
pluginId: 'text', |
||||
options: { |
||||
content: ` |
||||
# Title |
||||
|
||||
For markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)
|
||||
|
||||
* First item |
||||
* Second item |
||||
* Third item`,
|
||||
}, |
||||
}); |
||||
|
||||
list.append({}); |
||||
} |
||||
} |
@ -0,0 +1,169 @@ |
||||
import { VisualizationSuggestionsBuilder } from '@grafana/data'; |
||||
import { |
||||
GraphDrawStyle, |
||||
GraphFieldConfig, |
||||
GraphGradientMode, |
||||
LegendDisplayMode, |
||||
LineInterpolation, |
||||
StackingMode, |
||||
} from '@grafana/schema'; |
||||
import { SuggestionName } from 'app/types/suggestions'; |
||||
import { TimeSeriesOptions } from './types'; |
||||
|
||||
export class TimeSeriesSuggestionsSupplier { |
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { |
||||
const { dataSummary } = builder; |
||||
|
||||
if (!dataSummary.hasTimeField || !dataSummary.hasNumberField || dataSummary.rowCountTotal < 2) { |
||||
return; |
||||
} |
||||
|
||||
const list = builder.getListAppender<TimeSeriesOptions, GraphFieldConfig>({ |
||||
name: SuggestionName.LineChart, |
||||
pluginId: 'timeseries', |
||||
options: { |
||||
legend: {} as any, |
||||
}, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: {}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
previewModifier: (s) => { |
||||
s.options!.legend.displayMode = LegendDisplayMode.Hidden; |
||||
|
||||
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { |
||||
s.fieldConfig!.defaults.custom!.lineWidth = 3; |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
const maxBarsCount = 100; |
||||
|
||||
list.append({ |
||||
name: SuggestionName.LineChart, |
||||
}); |
||||
|
||||
if (dataSummary.rowCountMax < 200) { |
||||
list.append({ |
||||
name: SuggestionName.LineChartSmooth, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: { |
||||
lineInterpolation: LineInterpolation.Smooth, |
||||
}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
// Single series suggestions
|
||||
if (dataSummary.numberFieldCount === 1) { |
||||
list.append({ |
||||
name: SuggestionName.AreaChart, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: { |
||||
fillOpacity: 25, |
||||
}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
|
||||
if (dataSummary.rowCountMax < maxBarsCount) { |
||||
list.append({ |
||||
name: SuggestionName.BarChart, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: { |
||||
drawStyle: GraphDrawStyle.Bars, |
||||
fillOpacity: 100, |
||||
lineWidth: 1, |
||||
gradientMode: GraphGradientMode.Hue, |
||||
}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
// Multiple series suggestions
|
||||
|
||||
list.append({ |
||||
name: SuggestionName.AreaChartStacked, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: { |
||||
fillOpacity: 25, |
||||
stacking: { |
||||
mode: StackingMode.Normal, |
||||
group: 'A', |
||||
}, |
||||
}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
|
||||
list.append({ |
||||
name: SuggestionName.AreaChartStackedPercent, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: { |
||||
fillOpacity: 25, |
||||
stacking: { |
||||
mode: StackingMode.Percent, |
||||
group: 'A', |
||||
}, |
||||
}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
|
||||
if (dataSummary.rowCountTotal / dataSummary.numberFieldCount < maxBarsCount) { |
||||
list.append({ |
||||
name: SuggestionName.BarChartStacked, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: { |
||||
drawStyle: GraphDrawStyle.Bars, |
||||
fillOpacity: 100, |
||||
lineWidth: 1, |
||||
gradientMode: GraphGradientMode.Hue, |
||||
stacking: { |
||||
mode: StackingMode.Normal, |
||||
group: 'A', |
||||
}, |
||||
}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
|
||||
list.append({ |
||||
name: SuggestionName.BarChartStackedPercent, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: { |
||||
drawStyle: GraphDrawStyle.Bars, |
||||
fillOpacity: 100, |
||||
lineWidth: 1, |
||||
gradientMode: GraphGradientMode.Hue, |
||||
stacking: { |
||||
mode: StackingMode.Percent, |
||||
group: 'A', |
||||
}, |
||||
}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,25 @@ |
||||
export enum SuggestionName { |
||||
LineChart = 'Line chart', |
||||
LineChartSmooth = 'Line chart smooth', |
||||
AreaChart = 'Area chart', |
||||
AreaChartStacked = 'Area chart stacked', |
||||
AreaChartStackedPercent = 'Area chart 100% stacked', |
||||
BarChart = 'Bar chart', |
||||
BarChartStacked = 'Bar chart stacked', |
||||
BarChartStackedPercent = 'Bar chart 100% stacked', |
||||
BarChartHorizontal = 'Bar chart horizontal', |
||||
BarChartHorizontalStacked = 'Bar chart horizontal stacked', |
||||
BarChartHorizontalStackedPercent = 'Bar chart horizontal 100% stacked', |
||||
PieChart = 'Pie chart', |
||||
PieChartDonut = 'Pie chart donut', |
||||
Stat = 'Stat', |
||||
StatColoredBackground = 'Stat colored background', |
||||
Gauge = 'Gauge', |
||||
GaugeNoThresholds = 'Gauge no thresholds', |
||||
BarGaugeBasic = 'Bar gauge basic', |
||||
BarGaugeLCD = 'Bar gauge LCD', |
||||
Table = 'Table', |
||||
StateTimeline = 'StateTimeline', |
||||
TextPanel = 'Text panel', |
||||
DashboardList = 'Dashboard list', |
||||
} |
Loading…
Reference in new issue