mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PanelEditor: Present actionable suggestions when panel cannot visualize current data (#42083)
* PanelDataError: Show actions when current panel cannot visualize data * Fixed so that suggestions tab is opened from action * Cleanup * Fixed tests * Fix tests * Fixing tests * Fixed ts issues
This commit is contained in:
parent
6a86758f3b
commit
070344943c
@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { PanelData } from '@grafana/data';
|
||||
|
||||
/**
|
||||
* Describes the properties that can be passed to the PanelDataErrorView.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface PanelDataErrorViewProps {
|
||||
message?: string;
|
||||
panelId: number;
|
||||
data: PanelData;
|
||||
needsTimeField?: boolean;
|
||||
needsNumberField?: boolean;
|
||||
// suggestions?: VisualizationSuggestion[]; <<< for sure optional
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified type with defaults that describes the PanelDataErrorView.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type PanelDataErrorViewType = React.ComponentType<PanelDataErrorViewProps>;
|
||||
|
||||
/**
|
||||
* PanelDataErrorView allows panels to show a consistent error message when
|
||||
* the result structure does not meet expected criteria
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export let PanelDataErrorView: PanelDataErrorViewType = ({ message }) => {
|
||||
return <div>Unable to render data: {message}.</div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to bootstrap the PanelDataErrorView during application start so the
|
||||
* PanelDataErrorView is exposed via runtime.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function setPanelDataErrorView(renderer: PanelDataErrorViewType) {
|
||||
PanelDataErrorView = renderer;
|
||||
}
|
@ -22,7 +22,8 @@ export {
|
||||
BackendDataSourceResponse,
|
||||
DataResponse,
|
||||
} from './utils/queryResponse';
|
||||
export { PanelRenderer, PanelRendererProps } from './components/PanelRenderer';
|
||||
export { PanelDataErrorView, PanelDataErrorViewProps } from './components/PanelDataErrorView';
|
||||
export { toDataQueryError } from './utils/toDataQueryError';
|
||||
export { PanelRenderer, PanelRendererProps, PanelRendererType, setPanelRenderer } from './components/PanelRenderer';
|
||||
export { setQueryRunnerFactory, createQueryRunner, QueryRunnerFactory } from './services/QueryRunner';
|
||||
export { DataSourcePicker, DataSourcePickerProps, DataSourcePickerState } from './components/DataSourcePicker';
|
||||
|
@ -33,7 +33,6 @@ import {
|
||||
setDataSourceSrv,
|
||||
setEchoSrv,
|
||||
setLocationSrv,
|
||||
setPanelRenderer,
|
||||
setQueryRunnerFactory,
|
||||
} from '@grafana/runtime';
|
||||
import { Echo } from './core/services/echo/Echo';
|
||||
@ -60,6 +59,9 @@ import { ApplicationInsightsBackend } from './core/services/echo/backends/analyt
|
||||
import { RudderstackBackend } from './core/services/echo/backends/analytics/RudderstackBackend';
|
||||
import { getAllOptionEditors } from './core/components/editors/registry';
|
||||
import { backendSrv } from './core/services/backend_srv';
|
||||
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
|
||||
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
|
||||
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
|
||||
import { DatasourceSrv } from './features/plugins/datasource_srv';
|
||||
import { AngularApp } from './angular';
|
||||
import { ModalManager } from './core/services/ModalManager';
|
||||
@ -93,6 +95,7 @@ export class GrafanaApp {
|
||||
setLocale(config.bootData.user.locale);
|
||||
setWeekStart(config.bootData.user.weekStart);
|
||||
setPanelRenderer(PanelRenderer);
|
||||
setPanelDataErrorView(PanelDataErrorView);
|
||||
setLocationSrv(locationService);
|
||||
setTimeZoneResolver(() => config.bootData.user.timezone);
|
||||
// Important that extension reducers are initialized before store
|
||||
|
52
public/app/core/components/CardButton.tsx
Normal file
52
public/app/core/components/CardButton.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import React, { HTMLAttributes } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, IconName, useStyles2 } from '@grafana/ui';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLButtonElement> {
|
||||
icon: IconName;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CardButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
({ icon, children, onClick, ...restProps }, ref) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<button {...restProps} className={styles.action} onClick={onClick}>
|
||||
<Icon name={icon} size="xl" />
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardButton.displayName = 'CardButton';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
action: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
justify-self: center;
|
||||
cursor: pointer;
|
||||
background: ${theme.colors.background.secondary};
|
||||
border-radius: ${theme.shape.borderRadius(1)};
|
||||
color: ${theme.colors.text.primary};
|
||||
border: unset;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colors.emphasize(theme.colors.background.secondary)};
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
@ -15,3 +15,5 @@ export const PANEL_BORDER = 2;
|
||||
export const EDIT_PANEL_ID = 23763571993;
|
||||
|
||||
export const DEFAULT_PER_PAGE_PAGINATION = 40;
|
||||
|
||||
export const LS_VISUALIZATION_SELECT_TAB_KEY = 'VisualizationSelectPane.ListMode';
|
||||
|
@ -22,7 +22,7 @@ describe('AddPanelWidget', () => {
|
||||
|
||||
it('should render the add panel actions', () => {
|
||||
getTestContext();
|
||||
expect(screen.getByText(/Add an empty panel/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Add a new panel/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Add a new row/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Add a panel from the panel library/i)).toBeInTheDocument();
|
||||
});
|
||||
|
@ -4,7 +4,7 @@ import { css, cx, keyframes } from '@emotion/css';
|
||||
import { chain, cloneDeep, defaults, find, sortBy } from 'lodash';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { Icon, IconButton, styleMixins, useStyles2 } from '@grafana/ui';
|
||||
import { Icon, IconButton, useStyles2 } from '@grafana/ui';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
LibraryPanelsSearch,
|
||||
LibraryPanelsSearchVariant,
|
||||
} from '../../../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch';
|
||||
import { CardButton } from 'app/core/components/CardButton';
|
||||
|
||||
export type PanelPluginInfo = { id: any; defaults: { gridPos: { w: any; h: any }; title: any } };
|
||||
|
||||
@ -145,53 +146,48 @@ export const AddPanelWidgetUnconnected: React.FC<Props> = ({ panel, dashboard })
|
||||
<LibraryPanelsSearch onClick={onAddLibraryPanel} variant={LibraryPanelsSearchVariant.Tight} showPanelFilter />
|
||||
) : (
|
||||
<div className={styles.actionsWrapper}>
|
||||
<div className={cx(styles.actionsRow, styles.columnGap)}>
|
||||
<div
|
||||
onClick={() => {
|
||||
reportInteraction('Create new panel');
|
||||
onCreateNewPanel();
|
||||
}}
|
||||
aria-label={selectors.pages.AddDashboard.addNewPanel}
|
||||
>
|
||||
<Icon name="file-blank" size="xl" />
|
||||
Add an empty panel
|
||||
</div>
|
||||
<div
|
||||
className={styles.rowGap}
|
||||
onClick={() => {
|
||||
reportInteraction('Create new row');
|
||||
onCreateNewRow();
|
||||
}}
|
||||
aria-label={selectors.pages.AddDashboard.addNewRow}
|
||||
>
|
||||
<Icon name="wrap-text" size="xl" />
|
||||
Add a new row
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actionsRow}>
|
||||
<div
|
||||
onClick={() => {
|
||||
reportInteraction('Add a panel from the panel library');
|
||||
setAddPanelView(true);
|
||||
}}
|
||||
<CardButton
|
||||
icon="file-blank"
|
||||
aria-label={selectors.pages.AddDashboard.addNewPanel}
|
||||
onClick={() => {
|
||||
reportInteraction('Create new panel');
|
||||
onCreateNewPanel();
|
||||
}}
|
||||
>
|
||||
Add a new panel
|
||||
</CardButton>
|
||||
<CardButton
|
||||
icon="wrap-text"
|
||||
aria-label={selectors.pages.AddDashboard.addNewRow}
|
||||
onClick={() => {
|
||||
reportInteraction('Create new row');
|
||||
onCreateNewRow();
|
||||
}}
|
||||
>
|
||||
Add a new row
|
||||
</CardButton>
|
||||
<CardButton
|
||||
icon="book-open"
|
||||
aria-label={selectors.pages.AddDashboard.addNewPanelLibrary}
|
||||
onClick={() => {
|
||||
reportInteraction('Add a panel from the panel library');
|
||||
setAddPanelView(true);
|
||||
}}
|
||||
>
|
||||
Add a panel from the panel library
|
||||
</CardButton>
|
||||
{copiedPanelPlugins.length === 1 && (
|
||||
<CardButton
|
||||
icon="clipboard-alt"
|
||||
aria-label={selectors.pages.AddDashboard.addNewPanelLibrary}
|
||||
onClick={() => {
|
||||
reportInteraction('Paste panel from clipboard');
|
||||
onPasteCopiedPanel(copiedPanelPlugins[0]);
|
||||
}}
|
||||
>
|
||||
<Icon name="book-open" size="xl" />
|
||||
Add a panel from the panel library
|
||||
</div>
|
||||
{copiedPanelPlugins.length === 1 && (
|
||||
<div
|
||||
className={styles.rowGap}
|
||||
onClick={() => {
|
||||
reportInteraction('Paste panel from clipboard');
|
||||
onPasteCopiedPanel(copiedPanelPlugins[0]);
|
||||
}}
|
||||
>
|
||||
<Icon name="clipboard-alt" size="xl" />
|
||||
Paste panel from clipboard
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
Paste panel from clipboard
|
||||
</CardButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -254,44 +250,18 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
box-shadow: 0 0 0 2px black, 0 0 0px 4px #1f60c4;
|
||||
animation: ${pulsate} 2s ease infinite;
|
||||
`,
|
||||
rowGap: css`
|
||||
margin-left: ${theme.spacing(1)};
|
||||
`,
|
||||
columnGap: css`
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
actionsRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
|
||||
> div {
|
||||
justify-self: center;
|
||||
cursor: pointer;
|
||||
background: ${theme.colors.background.secondary};
|
||||
border-radius: ${theme.shape.borderRadius(1)};
|
||||
color: ${theme.colors.text.primary};
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background: ${styleMixins.hoverColor(theme.colors.background.secondary, theme)};
|
||||
}
|
||||
|
||||
&:hover > #book-icon {
|
||||
background: linear-gradient(#f05a28 30%, #fbca0a 99%);
|
||||
}
|
||||
}
|
||||
`,
|
||||
actionsWrapper: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: ${theme.spacing(0, 1, 1, 1)};
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
column-gap: ${theme.spacing(1)};
|
||||
row-gap: ${theme.spacing(1)};
|
||||
padding: ${theme.spacing(0, 1, 1, 1)};
|
||||
|
||||
// This is to make the last action full width (if by itself)
|
||||
& > div:nth-child(2n-1):nth-last-of-type(1) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
`,
|
||||
headerRow: css`
|
||||
display: flex;
|
||||
|
@ -14,6 +14,8 @@ import { getPanelPluginWithFallback } from '../../state/selectors';
|
||||
import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types';
|
||||
import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { VisualizationSelectPaneTab } from './types';
|
||||
import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants';
|
||||
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
@ -23,7 +25,11 @@ interface Props {
|
||||
export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
|
||||
const plugin = useSelector(getPanelPluginWithFallback(panel.type));
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [listMode, setListMode] = useLocalStorage(`VisualizationSelectPane.ListMode`, ListMode.Visualizations);
|
||||
const [listMode, setListMode] = useLocalStorage(
|
||||
LS_VISUALIZATION_SELECT_TAB_KEY,
|
||||
VisualizationSelectPaneTab.Visualizations
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const styles = useStyles(getStyles);
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
@ -70,12 +76,12 @@ export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const radioOptions: Array<SelectableValue<ListMode>> = [
|
||||
{ label: 'Visualizations', value: ListMode.Visualizations },
|
||||
{ label: 'Suggestions', value: ListMode.Suggestions },
|
||||
const radioOptions: Array<SelectableValue<VisualizationSelectPaneTab>> = [
|
||||
{ label: 'Visualizations', value: VisualizationSelectPaneTab.Visualizations },
|
||||
{ label: 'Suggestions', value: VisualizationSelectPaneTab.Suggestions },
|
||||
{
|
||||
label: 'Library panels',
|
||||
value: ListMode.LibraryPanels,
|
||||
value: VisualizationSelectPaneTab.LibraryPanels,
|
||||
description: 'Reusable panels you can share between multiple dashboards.',
|
||||
},
|
||||
];
|
||||
@ -107,7 +113,7 @@ export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
|
||||
<div className={styles.scrollWrapper}>
|
||||
<CustomScrollbar autoHeightMin="100%">
|
||||
<div className={styles.scrollContent}>
|
||||
{listMode === ListMode.Visualizations && (
|
||||
{listMode === VisualizationSelectPaneTab.Visualizations && (
|
||||
<VizTypePicker
|
||||
current={plugin.meta}
|
||||
onChange={onVizChange}
|
||||
@ -116,7 +122,7 @@ export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
|
||||
onClose={() => {}}
|
||||
/>
|
||||
)}
|
||||
{listMode === ListMode.Suggestions && (
|
||||
{listMode === VisualizationSelectPaneTab.Suggestions && (
|
||||
<VisualizationSuggestions
|
||||
current={plugin.meta}
|
||||
onChange={onVizChange}
|
||||
@ -126,7 +132,7 @@ export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
|
||||
onClose={() => {}}
|
||||
/>
|
||||
)}
|
||||
{listMode === ListMode.LibraryPanels && (
|
||||
{listMode === VisualizationSelectPaneTab.LibraryPanels && (
|
||||
<PanelLibraryOptionsGroup searchQuery={searchQuery} panel={panel} key="Panel Library" />
|
||||
)}
|
||||
</div>
|
||||
@ -136,12 +142,6 @@ export const VisualizationSelectPane: FC<Props> = ({ panel, data }) => {
|
||||
);
|
||||
};
|
||||
|
||||
enum ListMode {
|
||||
Visualizations,
|
||||
LibraryPanels,
|
||||
Suggestions,
|
||||
}
|
||||
|
||||
VisualizationSelectPane.displayName = 'VisualizationSelectPane';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => {
|
||||
|
@ -66,3 +66,9 @@ export interface OptionPaneItemOverrideInfo {
|
||||
tooltip: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export enum VisualizationSelectPaneTab {
|
||||
Visualizations,
|
||||
LibraryPanels,
|
||||
Suggestions,
|
||||
}
|
||||
|
@ -1,51 +0,0 @@
|
||||
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%;
|
||||
`,
|
||||
};
|
||||
};
|
109
public/app/features/panel/components/PanelDataErrorView.tsx
Normal file
109
public/app/features/panel/components/PanelDataErrorView.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { CoreApp, GrafanaTheme2, PanelDataSummary, VisualizationSuggestionsBuilder } from '@grafana/data';
|
||||
import { usePanelContext, useStyles2 } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
import { PanelDataErrorViewProps } from '@grafana/runtime';
|
||||
import { CardButton } from 'app/core/components/CardButton';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { toggleVizPicker } from 'app/features/dashboard/components/PanelEditor/state/reducers';
|
||||
import { changePanelPlugin } from '../state/actions';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import store from 'app/core/store';
|
||||
import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants';
|
||||
import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types';
|
||||
|
||||
export function PanelDataErrorView(props: PanelDataErrorViewProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const context = usePanelContext();
|
||||
const builder = new VisualizationSuggestionsBuilder(props.data);
|
||||
const { dataSummary } = builder;
|
||||
const message = getMessageFor(props, dataSummary);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const openVizPicker = () => {
|
||||
store.setObject(LS_VISUALIZATION_SELECT_TAB_KEY, VisualizationSelectPaneTab.Suggestions);
|
||||
dispatch(toggleVizPicker(true));
|
||||
};
|
||||
|
||||
const switchToTable = () => {
|
||||
const panel = getDashboardSrv().getCurrent()?.getPanelById(props.panelId);
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
changePanelPlugin({
|
||||
panel,
|
||||
pluginId: 'table',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.message}>{message}</div>
|
||||
{context.app === CoreApp.PanelEditor && dataSummary.hasData && (
|
||||
<div className={styles.actions}>
|
||||
<CardButton icon="table" onClick={switchToTable}>
|
||||
Switch to table
|
||||
</CardButton>
|
||||
<CardButton icon="chart-line" onClick={openVizPicker}>
|
||||
Open visualization suggestions
|
||||
</CardButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getMessageFor(
|
||||
{ data, message, needsNumberField, needsTimeField }: PanelDataErrorViewProps,
|
||||
dataSummary: PanelDataSummary
|
||||
): string {
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (!data.series || data.series.length === 0) {
|
||||
return 'No data';
|
||||
}
|
||||
|
||||
if (needsNumberField && !dataSummary.hasNumberField) {
|
||||
return 'Data is missing a number field';
|
||||
}
|
||||
|
||||
if (needsTimeField && !dataSummary.hasTimeField) {
|
||||
return 'Data is missing a time field';
|
||||
}
|
||||
|
||||
return 'Cannot visualize data';
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
wrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}),
|
||||
message: css({
|
||||
textAlign: 'center',
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: theme.typography.size.lg,
|
||||
width: '100%',
|
||||
}),
|
||||
actions: css({
|
||||
marginTop: theme.spacing(2),
|
||||
display: 'flex',
|
||||
height: '50%',
|
||||
maxHeight: '150px',
|
||||
columnGap: theme.spacing(1),
|
||||
rowGap: theme.spacing(1),
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
}),
|
||||
};
|
||||
};
|
@ -18,11 +18,13 @@ import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBu
|
||||
import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
|
||||
import { prepareCandlestickFields } from './fields';
|
||||
import uPlot from 'uplot';
|
||||
import { PanelDataErrorView } from '@grafana/runtime';
|
||||
|
||||
interface CandlestickPanelProps extends PanelProps<CandlestickOptions> {}
|
||||
|
||||
export const CandlestickPanel: React.FC<CandlestickPanelProps> = ({
|
||||
data,
|
||||
id,
|
||||
timeRange,
|
||||
timeZone,
|
||||
width,
|
||||
@ -52,6 +54,10 @@ export const CandlestickPanel: React.FC<CandlestickPanelProps> = ({
|
||||
tweakAxis,
|
||||
};
|
||||
|
||||
if (!info) {
|
||||
return doNothing;
|
||||
}
|
||||
|
||||
// Un-encoding the already parsed special fields
|
||||
// This takes currently matched fields and saves the name so they can be looked up by name later
|
||||
// ¯\_(ツ)_/¯ someday this can make more sense!
|
||||
@ -202,12 +208,8 @@ export const CandlestickPanel: React.FC<CandlestickPanelProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options, data.structureRev]);
|
||||
|
||||
if (!info.frame || info.warn) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>{info.warn ?? 'No data found in response'}</p>
|
||||
</div>
|
||||
);
|
||||
if (!info) {
|
||||
return <PanelDataErrorView panelId={id} data={data} needsTimeField={true} needsNumberField={true} />;
|
||||
}
|
||||
|
||||
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
|
||||
|
@ -23,7 +23,7 @@ describe('Candlestick data', () => {
|
||||
options,
|
||||
theme
|
||||
);
|
||||
expect(info.warn).toMatchInlineSnapshot(`"Data does not have a time field"`);
|
||||
expect(info).toBeNull();
|
||||
});
|
||||
|
||||
it('will match common names by default', () => {
|
||||
@ -42,8 +42,7 @@ describe('Candlestick data', () => {
|
||||
options,
|
||||
theme
|
||||
);
|
||||
expect(info.warn).toBeUndefined();
|
||||
expect(info.names).toMatchInlineSnapshot(`
|
||||
expect(info?.names).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"close": "Next open",
|
||||
"high": "MAX",
|
||||
@ -72,7 +71,8 @@ describe('Candlestick data', () => {
|
||||
],
|
||||
options,
|
||||
theme
|
||||
);
|
||||
)!;
|
||||
|
||||
expect(info.open).toBeDefined();
|
||||
expect(info.open).toEqual(info.high);
|
||||
expect(info.open).toEqual(info.low);
|
||||
@ -114,7 +114,8 @@ describe('Candlestick data', () => {
|
||||
],
|
||||
options,
|
||||
theme
|
||||
);
|
||||
)!;
|
||||
|
||||
expect(info.open!.values.toArray()).toEqual([1, 1, 2, 3, 4]);
|
||||
expect(info.close!.values.toArray()).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
@ -59,8 +59,6 @@ export const candlestickFieldsInfo: Record<keyof CandlestickFieldMap, FieldPicke
|
||||
};
|
||||
|
||||
export interface CandlestickData {
|
||||
warn?: string;
|
||||
noTimeField?: boolean;
|
||||
autoOpenClose?: boolean;
|
||||
|
||||
// Special fields
|
||||
@ -97,30 +95,31 @@ export function prepareCandlestickFields(
|
||||
series: DataFrame[] | undefined,
|
||||
options: CandlestickOptions,
|
||||
theme: GrafanaTheme2
|
||||
): CandlestickData {
|
||||
): CandlestickData | null {
|
||||
if (!series?.length) {
|
||||
return { warn: 'No data' } as CandlestickData;
|
||||
return null;
|
||||
}
|
||||
|
||||
// All fields
|
||||
const fieldMap = options.fields ?? {};
|
||||
const aligned = series.length === 1 ? series[0] : outerJoinDataFrames({ frames: series, enforceSort: true });
|
||||
if (!aligned?.length) {
|
||||
return { warn: 'No data found' } as CandlestickData;
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: CandlestickData = { aligned, frame: aligned, names: {} };
|
||||
|
||||
// Apply same filter as everythign else in timeseries
|
||||
const norm = prepareGraphableFields([aligned], theme);
|
||||
if (norm.warn || norm.noTimeField || !norm.frames?.length) {
|
||||
return norm as CandlestickData;
|
||||
const timeSeriesFrames = prepareGraphableFields([aligned], theme);
|
||||
if (!timeSeriesFrames) {
|
||||
return null;
|
||||
}
|
||||
const frame = (data.frame = norm.frames[0]);
|
||||
|
||||
const frame = (data.frame = timeSeriesFrames[0]);
|
||||
const timeIndex = frame.fields.findIndex((f) => f.type === FieldType.time);
|
||||
|
||||
if (timeIndex < 0) {
|
||||
data.warn = 'Missing time field';
|
||||
data.noTimeField = true;
|
||||
return data;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the known fields
|
||||
|
@ -43,18 +43,22 @@ const numericFieldFilter = (f: Field) => f.type === FieldType.number;
|
||||
function addFieldPicker(
|
||||
builder: PanelOptionsEditorBuilder<CandlestickOptions>,
|
||||
info: FieldPickerInfo,
|
||||
data: CandlestickData
|
||||
data: CandlestickData | null
|
||||
) {
|
||||
const current = data[info.key] as Field;
|
||||
let placeholderText = 'Auto ';
|
||||
if (current?.config) {
|
||||
placeholderText += '= ' + getFieldDisplayName(current);
|
||||
|
||||
if (current === data?.open && info.key !== 'open') {
|
||||
placeholderText += ` (${info.defaults.join(',')})`;
|
||||
if (data) {
|
||||
const current = data[info.key] as Field;
|
||||
|
||||
if (current?.config) {
|
||||
placeholderText += '= ' + getFieldDisplayName(current);
|
||||
|
||||
if (current === data?.open && info.key !== 'open') {
|
||||
placeholderText += ` (${info.defaults.join(',')})`;
|
||||
}
|
||||
} else {
|
||||
placeholderText += `(${info.defaults.join(',')})`;
|
||||
}
|
||||
} else {
|
||||
placeholderText += `(${info.defaults.join(',')})`;
|
||||
}
|
||||
|
||||
builder.addFieldNamePicker({
|
||||
|
@ -19,7 +19,7 @@ export class CandlestickSuggestionsSupplier {
|
||||
}
|
||||
|
||||
const info = prepareCandlestickFields(builder.data.series, defaultPanelOptions, config.theme2);
|
||||
if (!info.open || info.warn || info.noTimeField) {
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,7 @@ import { prepareGraphableFields } from './utils';
|
||||
import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin';
|
||||
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
|
||||
import { config } from 'app/core/config';
|
||||
import { PanelDataErrorView } from '@grafana/runtime';
|
||||
|
||||
interface TimeSeriesPanelProps extends PanelProps<TimeSeriesOptions> {}
|
||||
|
||||
@ -24,6 +25,7 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
||||
fieldConfig,
|
||||
onChangeTimeRange,
|
||||
replaceVariables,
|
||||
id,
|
||||
}) => {
|
||||
const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, onSplitOpen } = usePanelContext();
|
||||
|
||||
@ -31,14 +33,10 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
||||
return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange });
|
||||
};
|
||||
|
||||
const { frames, warn } = useMemo(() => prepareGraphableFields(data?.series, config.theme2), [data]);
|
||||
const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2), [data]);
|
||||
|
||||
if (!frames || warn) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>{warn ?? 'No data found in response'}</p>
|
||||
</div>
|
||||
);
|
||||
if (!frames) {
|
||||
return <PanelDataErrorView panelId={id} data={data} needsTimeField={true} needsNumberField={true} />;
|
||||
}
|
||||
|
||||
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
|
||||
|
@ -3,7 +3,7 @@ import { prepareGraphableFields } from './utils';
|
||||
|
||||
describe('prepare timeseries graph', () => {
|
||||
it('errors with no time fields', () => {
|
||||
const frames = [
|
||||
const input = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'a', values: [1, 2, 3] },
|
||||
@ -11,12 +11,12 @@ describe('prepare timeseries graph', () => {
|
||||
],
|
||||
}),
|
||||
];
|
||||
const info = prepareGraphableFields(frames, createTheme());
|
||||
expect(info.warn).toEqual('Data does not have a time field');
|
||||
const frames = prepareGraphableFields(input, createTheme());
|
||||
expect(frames).toBeNull();
|
||||
});
|
||||
|
||||
it('requires a number or boolean value', () => {
|
||||
const frames = [
|
||||
const input = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'a', type: FieldType.time, values: [1, 2, 3] },
|
||||
@ -24,12 +24,12 @@ describe('prepare timeseries graph', () => {
|
||||
],
|
||||
}),
|
||||
];
|
||||
const info = prepareGraphableFields(frames, createTheme());
|
||||
expect(info.warn).toEqual('No graphable fields');
|
||||
const frames = prepareGraphableFields(input, createTheme());
|
||||
expect(frames).toBeNull();
|
||||
});
|
||||
|
||||
it('will graph numbers and boolean values', () => {
|
||||
const frames = [
|
||||
const input = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'a', type: FieldType.time, values: [1, 2, 3] },
|
||||
@ -39,10 +39,9 @@ describe('prepare timeseries graph', () => {
|
||||
],
|
||||
}),
|
||||
];
|
||||
const info = prepareGraphableFields(frames, createTheme());
|
||||
expect(info.warn).toBeUndefined();
|
||||
const frames = prepareGraphableFields(input, createTheme());
|
||||
const out = frames![0];
|
||||
|
||||
const out = info.frames![0];
|
||||
expect(out.fields.map((f) => f.name)).toEqual(['a', 'c', 'd']);
|
||||
|
||||
const field = out.fields.find((f) => f.name === 'c');
|
||||
@ -66,9 +65,9 @@ describe('prepare timeseries graph', () => {
|
||||
{ name: 'a', values: [-10, NaN, 10, -Infinity, +Infinity] },
|
||||
],
|
||||
});
|
||||
const result = prepareGraphableFields([df], createTheme());
|
||||
const frames = prepareGraphableFields([df], createTheme());
|
||||
|
||||
const field = result.frames![0].fields.find((f) => f.name === 'a');
|
||||
const field = frames![0].fields.find((f) => f.name === 'a');
|
||||
expect(field!.values.toArray()).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
-10,
|
||||
|
@ -9,37 +9,32 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { GraphFieldConfig, LineInterpolation, StackingMode } from '@grafana/schema';
|
||||
|
||||
export interface GraphableFieldsResult {
|
||||
frames?: DataFrame[];
|
||||
warn?: string;
|
||||
noTimeField?: boolean;
|
||||
}
|
||||
|
||||
// This will return a set of frames with only graphable values included
|
||||
export function prepareGraphableFields(series: DataFrame[] | undefined, theme: GrafanaTheme2): GraphableFieldsResult {
|
||||
/**
|
||||
* Returns null if there are no graphable fields
|
||||
*/
|
||||
export function prepareGraphableFields(series: DataFrame[], theme: GrafanaTheme2): DataFrame[] | null {
|
||||
if (!series?.length) {
|
||||
return { warn: 'No data in response' };
|
||||
return null;
|
||||
}
|
||||
|
||||
let copy: Field;
|
||||
let hasTimeseries = false;
|
||||
|
||||
const frames: DataFrame[] = [];
|
||||
|
||||
for (let frame of series) {
|
||||
let isTimeseries = false;
|
||||
let changed = false;
|
||||
const fields: Field[] = [];
|
||||
|
||||
let hasTimeField = false;
|
||||
let hasValueField = false;
|
||||
|
||||
for (const field of frame.fields) {
|
||||
switch (field.type) {
|
||||
case FieldType.time:
|
||||
isTimeseries = true;
|
||||
hasTimeseries = true;
|
||||
hasTimeField = true;
|
||||
fields.push(field);
|
||||
break;
|
||||
case FieldType.number:
|
||||
changed = true;
|
||||
hasValueField = true;
|
||||
copy = {
|
||||
...field,
|
||||
values: new ArrayVector(
|
||||
@ -60,7 +55,7 @@ export function prepareGraphableFields(series: DataFrame[] | undefined, theme: G
|
||||
fields.push(copy);
|
||||
break; // ok
|
||||
case FieldType.boolean:
|
||||
changed = true;
|
||||
hasValueField = true;
|
||||
const custom: GraphFieldConfig = field.config?.custom ?? {};
|
||||
const config = {
|
||||
...field.config,
|
||||
@ -95,31 +90,20 @@ export function prepareGraphableFields(series: DataFrame[] | undefined, theme: G
|
||||
|
||||
fields.push(copy);
|
||||
break;
|
||||
default:
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isTimeseries && fields.length > 1) {
|
||||
hasTimeseries = true;
|
||||
if (changed) {
|
||||
frames.push({
|
||||
...frame,
|
||||
fields,
|
||||
});
|
||||
} else {
|
||||
frames.push(frame);
|
||||
}
|
||||
if (hasTimeField && hasValueField) {
|
||||
frames.push({
|
||||
...frame,
|
||||
fields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTimeseries) {
|
||||
return { warn: 'Data does not have a time field', noTimeField: true };
|
||||
if (frames.length) {
|
||||
return frames;
|
||||
}
|
||||
|
||||
if (!frames.length) {
|
||||
return { warn: 'No graphable fields' };
|
||||
}
|
||||
|
||||
return { frames };
|
||||
return null;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user