Skip to content

Commit

Permalink
feat: support single row layout, feedback for no data, unsupported data
Browse files Browse the repository at this point in the history
  • Loading branch information
jackw committed Jan 28, 2024
1 parent 609089b commit a67b408
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 89 deletions.
2 changes: 1 addition & 1 deletion src/components/TrafficLight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function TrafficLight({ colors = [], bgColor = 'grey', onClick, horizonta
style={{ height: '100%', width: '100%' }}
onClick={onClick}
>
<g transform={horizontal ? 'rotate(-90 0 0)' : undefined} style={{ transformOrigin: '136px center' }}>
<g transform={horizontal ? 'rotate(-90 0 0)' : undefined} style={{ transformOrigin: '25% center' }}>
<path
fill={bgColor}
d="M264 32C264 14.38 249.62.9 232 .9H40C22.38.9 8 15.28 8 32c0 84.616 2.996 256 2.996 378.987 12.38 57.75 63.63 101 125 101s112.6-43.25 124.1-101C264 282.12 264 105.121 264 32ZM136 416c-26.5 0-48-21.5-48-48s21.5-48 48-48 48 21.5 48 48-21.5 48-48 48Zm0-128c-26.5 0-48-21.5-48-48s21.5-48 48-48 48 21.5 48 48-21.5 48-48 48Zm0-128c-26.5 0-48-21.5-48-48s21.5-48 48-48 48 21.5 48 48-21.5 48-48 48Z"
Expand Down
126 changes: 96 additions & 30 deletions src/components/TrafficLightPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { PanelProps } from '@grafana/data';
import { GrafanaTheme2, PanelProps } from '@grafana/data';

import { TrafficLightOptions } from 'types';
import { DataLinksContextMenu, useTheme2 } from '@grafana/ui';
Expand All @@ -8,15 +8,21 @@ import { LightsDataResultStatus, useLightsData } from 'hooks/useLightsData';
import { calculateRowsAndColumns } from 'utils';
import { TrafficLight } from './TrafficLight';

interface Props extends PanelProps<TrafficLightOptions> {}
interface TrafficLightPanelProps extends PanelProps<TrafficLightOptions> {}

export function TrafficLightPanel({ data, width, height, options, replaceVariables, fieldConfig, timeZone }: Props) {
const { minLightWidth, sortLights, showValue, showTrend } = options;
export function TrafficLightPanel({
data,
width,
height,
options,
replaceVariables,
fieldConfig,
timeZone,
}: TrafficLightPanelProps) {
const { minLightWidth, sortLights, showValue, showTrend, singleRow } = options;
const theme = useTheme2();
const { rows, cols } = calculateRowsAndColumns(width, minLightWidth, data.series.length);
const gridTemplateColumnsStyle = `repeat(${cols}, minmax(75px, 1fr)`;
const gridTemplateRowsStyle = `repeat(${rows}, ${100 / rows}%)`;

const styles = getStyles({ rows, cols, singleRow, minLightWidth, theme });
const { values, status } = useLightsData({
fieldConfig,
replaceVariables,
Expand All @@ -26,8 +32,28 @@ export function TrafficLightPanel({ data, width, height, options, replaceVariabl
sortLights,
});

if (status === LightsDataResultStatus.nodata || status === LightsDataResultStatus.unsupported) {
return <div>Please check the data.</div>;
if (status === LightsDataResultStatus.nodata) {
return (
<div style={styles.centeredContent}>
<h4>The query returned no data.</h4>
</div>
);
}

if (status === LightsDataResultStatus.unsupported) {
return (
<div style={styles.centeredContent}>
<h4>This data format is unsupported.</h4>
</div>
);
}

if (status === LightsDataResultStatus.incorrectThresholds) {
return (
<div style={styles.centeredContent}>
<h4>Thresholds are incorrectly set.</h4>
</div>
);
}

return (
Expand All @@ -37,28 +63,11 @@ export function TrafficLightPanel({ data, width, height, options, replaceVariabl
height,
}}
>
<div
style={{
display: 'grid',
alignContent: 'top',
gridTemplateColumns: gridTemplateColumnsStyle,
gridTemplateRows: gridTemplateRowsStyle,
justifyContent: 'center',
minHeight: '100%',
height: '100%',
boxSizing: 'border-box',
}}
>
{/* @ts-ignore TODO: fix up styles. */}
<div style={styles.containerStyle}>
{values.map((light) => (
<div
key={light.title}
style={{
display: 'grid',
gridTemplateRows: '1fr max-content',
gap: theme.spacing(),
padding: theme.spacing(0.5),
}}
>
// @ts-ignore TODO: fix up styles.
<div key={light.title} style={styles.itemStyle}>
{light.hasLinks && light.getLinks !== undefined ? (
<DataLinksContextMenu links={light.getLinks} style={{ flexGrow: 1 }}>
{(api) => (
Expand Down Expand Up @@ -103,3 +112,60 @@ export function TrafficLightPanel({ data, width, height, options, replaceVariabl
</div>
);
}

function getStyles({
rows,
cols,
singleRow,
minLightWidth,
theme,
}: {
rows: number;
cols: number;
singleRow: boolean;
minLightWidth: number;
theme: GrafanaTheme2;
}) {
const gridContainerStyle = {
display: 'grid',
alignContent: 'top',
gridTemplateColumns: `repeat(${cols}, minmax(${minLightWidth}px, 1fr)`,
gridTemplateRows: `repeat(${rows}, ${100 / rows}%)`,
justifyContent: 'center',
minHeight: '100%',
height: '100%',
boxSizing: 'border-box',
};
const flexContainerStyle = {
display: 'flex',
height: '100%',
minHeight: '100%',
overflowX: 'auto',
};
const gridItemStyle = {
display: 'grid',
gridTemplateRows: '1fr max-content',
gap: theme.spacing(),
padding: theme.spacing(0.5),
};
const flexItemStyle = {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: theme.spacing(),
padding: theme.spacing(0.5),
minWidth: minLightWidth,
};
const centeredContent = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
return {
containerStyle: singleRow ? flexContainerStyle : gridContainerStyle,
itemStyle: singleRow ? flexItemStyle : gridItemStyle,
centeredContent,
};
}
52 changes: 49 additions & 3 deletions src/hooks/useLightsData.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
DataFrame,
FieldType,
GetFieldDisplayValuesOptions,
LinkModel,
ThresholdsConfig,
getActiveThreshold,
getFieldDisplayValues,
} from '@grafana/data';
Expand All @@ -11,6 +13,7 @@ import { basicTrend } from 'utils';

export enum LightsDataResultStatus {
unsupported = 'unsupported',
incorrectThresholds = 'incorrectThresholds',
nodata = 'nodata',
success = 'success',
}
Expand Down Expand Up @@ -43,6 +46,7 @@ export function useLightsData(options: UseLightsData): LightsDataResult {
const { theme, data, fieldConfig, replaceVariables, timeZone, sortLights } = options;

return useMemo(() => {
let status = LightsDataResultStatus.nodata;
if (noData(data)) {
return {
values: [
Expand All @@ -53,11 +57,23 @@ export function useLightsData(options: UseLightsData): LightsDataResult {
hasLinks: false,
},
],
status: LightsDataResultStatus.nodata,
status,
};
}

// TODO: add unsupported scenario here. E.g. Thresholds are incorrect.
if (!isSupported(data)) {
return {
values: [
{
title: '',
value: '',
trend: { color: 'transparent', value: 0 },
hasLinks: false,
},
],
status: LightsDataResultStatus.unsupported,
};
}

const fieldDisplayValues = getFieldDisplayValues({
fieldConfig: fieldConfig,
Expand All @@ -69,6 +85,7 @@ export function useLightsData(options: UseLightsData): LightsDataResult {
});

const values = fieldDisplayValues.map((displayValue) => {
const thresholdsValid = validateThresholds(displayValue.field.thresholds);
const activeThreshold = getActiveThreshold(displayValue.display.numeric, displayValue.field.thresholds?.steps);
const { title, text, suffix, prefix } = displayValue.display;
const colors = displayValue.field.thresholds?.steps.slice(1).map((threshold, i) => {
Expand All @@ -82,6 +99,12 @@ export function useLightsData(options: UseLightsData): LightsDataResult {
const trendValue = basicTrend(displayValue.view?.dataFrame.fields[1].values.toArray());
const trendColor = theme.visualization.getColorByName(getTrendColor(trendValue));

if (!thresholdsValid) {
status = LightsDataResultStatus.incorrectThresholds;
} else {
status = LightsDataResultStatus.success;
}

return {
title,
value: text,
Expand All @@ -98,7 +121,7 @@ export function useLightsData(options: UseLightsData): LightsDataResult {
});
return {
values: sortLights === SortOptions.None ? values : sortByValue(values, sortLights),
status: LightsDataResultStatus.success,
status: status,
};
}, [theme, data, fieldConfig, replaceVariables, timeZone, sortLights]);
}
Expand All @@ -113,6 +136,20 @@ function sortByValue(arr: LightsDataValues[], sortOrder: SortOptions): LightsDat
});
}

function isSupported(data?: DataFrame[]): boolean {
if (!data || data.length === 0) {
return false;
}

return data.every((d) => {
const field = d.fields.find((f) => {
return f.type === FieldType.number;
});

return Boolean(field);
});
}

function noData(data?: DataFrame[]): boolean {
return !data || data.length === 0;
}
Expand All @@ -127,3 +164,12 @@ function getTrendColor(value: number) {
return '#73BF69';
}
}

function validateThresholds(thresholds?: ThresholdsConfig) {
const numberOfSteps = thresholds?.steps.length;
if (!numberOfSteps || numberOfSteps < 4) {
return false;
}

return true;
}
102 changes: 47 additions & 55 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,52 @@
import { FieldConfigProperty, PanelPlugin, ThresholdsMode } from '@grafana/data';
import { PanelPlugin } from '@grafana/data';
import { SortOptions, TrafficLightOptions } from './types';
import { TrafficLightPanel } from './components/TrafficLightPanel';

export const plugin = new PanelPlugin<TrafficLightOptions>(TrafficLightPanel)
.useFieldConfig({
standardOptions: {
[FieldConfigProperty.Thresholds]: {
defaultValue: {
mode: ThresholdsMode.Percentage,
steps: [
{ color: 'red', value: -Infinity },
{ color: 'red', value: 0 },
{ color: '#EAB839', value: 50 },
{ color: '#73BF69', value: 80 },
],
},
},
},
})
.useFieldConfig()
.setPanelOptions((builder) => {
return builder
.addNumberInput({
path: 'minLightWidth',
name: 'Minimum light width',
description: 'Set the minimum traffic light width',
defaultValue: 75,
})
.addRadio({
path: 'sortLights',
name: 'Sort lights',
description: 'Sort lights based on values',
defaultValue: SortOptions.None,
settings: {
options: [
{ value: SortOptions.None, label: 'None' },
{ value: SortOptions.Asc, label: 'Ascending' },
{ value: SortOptions.Desc, label: 'Descending' },
],
},
})
.addBooleanSwitch({
path: 'showValue',
name: 'Show value',
description: 'Show or hide the value',
defaultValue: true,
})
.addBooleanSwitch({
path: 'showTrend',
name: 'Show trend',
description: 'Show or hide the trend color',
defaultValue: true,
})
.addBooleanSwitch({
path: 'horizontal',
name: 'Horizontal traffic lights',
description: 'Change the orientation of the traffic lights',
defaultValue: false,
});
});
return builder
.addNumberInput({
path: 'minLightWidth',
name: 'Minimum light width',
description: 'Set the minimum traffic light width',
defaultValue: 100,
})
.addBooleanSwitch({
path: 'singleRow',
name: 'Single row',
description: 'Place all lights in a single row',
defaultValue: false,
})
.addRadio({
path: 'sortLights',
name: 'Sort lights',
description: 'Sort lights based on values',
defaultValue: SortOptions.None,
settings: {
options: [
{ value: SortOptions.None, label: 'None' },
{ value: SortOptions.Asc, label: 'Ascending' },
{ value: SortOptions.Desc, label: 'Descending' },
],
},
})
.addBooleanSwitch({
path: 'showValue',
name: 'Show value',
description: 'Show or hide the value',
defaultValue: true,
})
.addBooleanSwitch({
path: 'showTrend',
name: 'Show trend',
description: 'Show or hide the trend color',
defaultValue: true,
})
.addBooleanSwitch({
path: 'horizontal',
name: 'Horizontal traffic lights',
description: 'Change the orientation of the traffic lights',
defaultValue: false,
});
});
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export interface TrafficLightOptions {
showTrend: boolean;
sortLights: SortOptions;
horizontal: boolean;
singleRow: boolean;
}

0 comments on commit a67b408

Please sign in to comment.