Browse Source

Merge branch 'icons' into 'main'

Icons editor

See merge request chartwerk/grafana-chartwerk-panel!1
merge-requests/4/merge
Alexander Velikiy 2 years ago
parent
commit
463d2c6bfd
  1. 222
      src/components/editors/IconsEditor.tsx
  2. 46
      src/grafana/MatchersUI/FieldNamePicker.tsx
  3. 204
      src/grafana/MatchersUI/types.ts
  4. 116
      src/grafana/MatchersUI/utils.ts
  5. 19
      src/models/series.ts
  6. 35
      src/module.ts
  7. 16
      src/types.ts

222
src/components/editors/IconsEditor.tsx

@ -0,0 +1,222 @@
import { IconPosition } from 'types';
import { GrafanaTheme, SelectableValue, StandardEditorProps } from '@grafana/data';
import { Button, HorizontalGroup, IconButton, Input, RadioButtonGroup, Slider, stylesFactory, ThemeContext } from '@grafana/ui';
import { FieldNamePicker } from '../../grafana/MatchersUI/FieldNamePicker';
import { css } from 'emotion';
import React from 'react';
import * as _ from 'lodash';
type IconConfig = {
position: IconPosition;
url: string;
metrics: string[];
conditions: Condition[];
values: number[];
size: number;
}
enum Condition {
EQUAL = '=',
GREATER = '>',
LESS = '<',
GREATER_OR_EQUAL = '>=',
LESS_OR_EQUAL = '<=',
}
const positionOptions: Array<SelectableValue<IconPosition>> = [
{
label: 'Upper-Left',
value: IconPosition.UPPER_LEFT,
},
{
label: 'Middle',
value: IconPosition.MIDDLE,
},
{
label: 'Upper-Right',
value: IconPosition.UPPER_RIGHT,
},
];
const conditionOptions: Array<SelectableValue<Condition>> = [
{
label: '>=',
value: Condition.GREATER_OR_EQUAL,
},
{
label: '>',
value: Condition.GREATER,
},
{
label: '=',
value: Condition.EQUAL,
},
{
label: '<',
value: Condition.LESS,
},
{
label: '<=',
value: Condition.LESS_OR_EQUAL,
},
];
const DEFAULT_ICON: IconConfig = {
position: IconPosition.UPPER_LEFT,
url: '',
size: 40,
metrics: [],
conditions: [],
values: [],
}
const fieldNamePickerSettings = {
settings: { width: 24 },
} as any;
export function IconsEditor({ onChange, value, context }: StandardEditorProps<Array<IconConfig>>) {
const icons = value;
const addIcon = () => {
onChange(
_.concat(icons, _.cloneDeep(DEFAULT_ICON))
);
};
const removeIcon = (idx: number) => {
onChange(
_.filter(icons, (icon, iconIdx) => iconIdx !== idx)
);
};
const addCondition = (iconIdx:number) => {
icons[iconIdx].conditions.push(Condition.GREATER_OR_EQUAL);
icons[iconIdx].metrics.push('');
icons[iconIdx].values.push(0);
onChange(icons);
};
const removeCondition = (iconIdx: number, conditionIdx: number) => {
icons[iconIdx].conditions.splice(conditionIdx, 1);
icons[iconIdx].metrics.splice(conditionIdx, 1);
icons[iconIdx].values.splice(conditionIdx, 1);
onChange(icons);
};
const onIconFieldChange = (iconIdx: number, field: keyof IconConfig, value: any) => {
// @ts-ignore
icons[iconIdx][field] = value;
onChange(icons);
};
const onConditionChange = (iconIdx: number, conditionIdx: number, field: keyof IconConfig, value: any) => {
console.log(value)
// @ts-ignore
icons[iconIdx][field][conditionIdx] = value;
onChange(icons);
};
return (
<ThemeContext.Consumer>
{(theme) => {
const styles = getStyles(theme.v1);
return (
<div>
<div className={styles.icons}>
{
icons.map((icon, iconIdx) => {
return (
<div className={styles.icon}>
<IconButton name="trash-alt" onClick={() => removeIcon(iconIdx)}></IconButton>
<Input
type="url"
placeholder="Image URL"
value={icon.url}
onChange={evt => onIconFieldChange(iconIdx, 'url', (evt.target as any).value)}
/>
<RadioButtonGroup
value={icon.position}
options={positionOptions}
onChange={newVal => onIconFieldChange(iconIdx, 'position', newVal)}
/>
<Slider
value={icon.size}
min={1}
max={100}
step={1}
onAfterChange={newVal => onIconFieldChange(iconIdx, 'size', newVal)}
/>
{
icon.conditions.map((condition, conditionIdx) => {
return (
<HorizontalGroup>
<FieldNamePicker
context={context}
value={icon.metrics[conditionIdx]}
onChange={(newVal: any) => onConditionChange(iconIdx, conditionIdx, 'metrics', newVal) }
item={fieldNamePickerSettings}
/>
<RadioButtonGroup
value={icon.conditions[conditionIdx]}
options={conditionOptions}
onChange={newVal => onConditionChange(iconIdx, conditionIdx, 'conditions', newVal)}
/>
<Input
placeholder="value"
value={icon.values[conditionIdx]}
onChange={evt => onConditionChange(iconIdx, conditionIdx, 'values', (evt.target as any).value)}
/>
<IconButton name="trash-alt" onClick={() => removeCondition(iconIdx, conditionIdx)}></IconButton>
</HorizontalGroup>
)
})
}
<Button variant="secondary" onClick={() => addCondition(iconIdx)}>Add Condition</Button>
</div>
)
})
}
</div>
<Button variant="secondary" onClick={addIcon}>Add Icon</Button>
</div>
)
}
}
</ThemeContext.Consumer>
)
}
interface IconsEditorStyles {
icons: string;
icon: string;
}
const getStyles = stylesFactory((theme: GrafanaTheme): IconsEditorStyles => {
return {
icons: css`
display: flex;
flex-direction: column;
margin-bottom: ${theme.spacing.formSpacingBase * 2}px;
`,
icon: css`
margin-bottom: ${theme.spacing.sm};
border-bottom: 1px solid ${theme.colors.panelBorder};
padding-bottom: ${theme.spacing.formSpacingBase * 2}px;
&:last-child {
border: none;
margin-bottom: 0;
}
`,
}
});

46
src/grafana/MatchersUI/FieldNamePicker.tsx

@ -0,0 +1,46 @@
// copied from https://github.com/grafana/grafana/blob/3c6e0e8ef85048af952367751e478c08342e17b4/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx
import React, { useCallback } from 'react';
import { FieldNamePickerConfigSettings, SelectableValue, StandardEditorProps } from '@grafana/data';
import { Select } from '@grafana/ui';
import { useFieldDisplayNames, useSelectOptions, frameHasName } from './utils';
// Pick a field name out of the fulds
export const FieldNamePicker: React.FC<StandardEditorProps<string, FieldNamePickerConfigSettings>> = ({
value,
onChange,
context,
item,
}) => {
const settings: FieldNamePickerConfigSettings = item.settings ?? {};
const names = useFieldDisplayNames(context.data, settings?.filter);
const selectOptions = useSelectOptions(names, value);
const onSelectChange = useCallback(
(selection?: SelectableValue<string>) => {
if (selection && !frameHasName(selection.value, names)) {
return; // can not select name that does not exist?
}
return onChange(selection?.value);
},
[names, onChange]
);
const selectedOption = selectOptions.find((v: any) => v.value === value);
return (
<>
<Select
menuShouldPortal
value={selectedOption}
placeholder={settings.placeholderText ?? 'Select field'}
options={selectOptions}
onChange={onSelectChange}
noOptionsMessage={settings.noFieldsMessage}
width={settings.width}
isClearable={true}
/>
</>
);
};

204
src/grafana/MatchersUI/types.ts

@ -0,0 +1,204 @@
// copied from https://github.com/grafana/grafana/blob/3c6e0e8ef85048af952367751e478c08342e17b4/packages/grafana-ui/src/components/MatchersUI/types.ts
import { Field, FieldType } from '@grafana/data';
export type ComponentSize = 'xs' | 'sm' | 'md' | 'lg';
export type IconType = 'mono' | 'default' | 'solid';
export type IconSize = ComponentSize | 'xl' | 'xxl' | 'xxxl';
export const getAvailableIcons = () =>
[
'anchor',
'angle-double-down',
'angle-double-right',
'angle-double-up',
'angle-down',
'angle-left',
'angle-right',
'angle-up',
'apps',
'arrow',
'arrow-down',
'arrow-from-right',
'arrow-left',
'arrow-random',
'arrow-right',
'arrow-up',
'arrows-h',
'backward',
'bars',
'bell',
'bell-slash',
'bolt',
'book',
'bookmark',
'book-open',
'brackets-curly',
'building',
'bug',
'building',
'calculator-alt',
'calendar-alt',
'camera',
'capture',
'channel-add',
'chart-line',
'check',
'check-circle',
'circle',
'clipboard-alt',
'clock-nine',
'cloud',
'cloud-download',
'cloud-upload',
'code-branch',
'cog',
'columns',
'comment-alt',
'comment-alt-message',
'comment-alt-share',
'comments-alt',
'compass',
'copy',
'credit-card',
'cube',
'dashboard',
'database',
'document-info',
'download-alt',
'draggabledots',
'edit',
'ellipsis-v',
'envelope',
'exchange-alt',
'exclamation-triangle',
'exclamation-circle',
'external-link-alt',
'eye',
'eye-slash',
'ellipsis-h',
'fa fa-spinner',
'favorite',
'file-alt',
'file-blank',
'file-copy-alt',
'filter',
'folder',
'font',
'fire',
'folder-open',
'folder-plus',
'folder-upload',
'forward',
'gf-bar-alignment-after',
'gf-bar-alignment-before',
'gf-bar-alignment-center',
'gf-grid',
'gf-interpolation-linear',
'gf-interpolation-smooth',
'gf-interpolation-step-after',
'gf-interpolation-step-before',
'gf-landscape',
'gf-layout-simple',
'gf-logs',
'gf-portrait',
'grafana',
'graph-bar',
'heart',
'heart-break',
'history',
'home-alt',
'hourglass',
'import',
'info',
'info-circle',
'key-skeleton-alt',
'keyboard',
'layer-group',
'library-panel',
'line-alt',
'link',
'list-ui-alt',
'list-ul',
'lock',
'map-marker',
'message',
'minus',
'minus-circle',
'mobile-android',
'monitor',
'palette',
'panel-add',
'pause',
'pen',
'percentage',
'play',
'plug',
'plus',
'plus-circle',
'plus-square',
'power',
'presentation-play',
'process',
'question-circle',
'record-audio',
'repeat',
'rocket',
'save',
'search',
'search-minus',
'search-plus',
'share-alt',
'shield',
'shield-exclamation',
'signal',
'signin',
'signout',
'sitemap',
'slack',
'sliders-v-alt',
'sort-amount-down',
'sort-amount-up',
'square-shape',
'star',
'step-backward',
'sync',
'table',
'tag-alt',
'text-fields',
'times',
'toggle-on',
'trash-alt',
'unlock',
'upload',
'user',
'users-alt',
'wrap-text',
'x',
] as const;
type BrandIconNames = 'google' | 'microsoft' | 'github' | 'gitlab' | 'okta';
export type IconName = ReturnType<typeof getAvailableIcons>[number] | BrandIconNames;
/** Get the icon for a given field type */
export function getFieldTypeIcon(field?: Field): IconName {
if (field) {
switch (field.type) {
case FieldType.time:
return 'clock-nine';
case FieldType.string:
return 'font';
case FieldType.number:
return 'calculator-alt';
case FieldType.boolean:
return 'toggle-on';
case FieldType.trace:
return 'info-circle';
case FieldType.geo:
return 'map-marker';
case FieldType.other:
return 'brackets-curly';
}
}
return 'question-circle';
}

116
src/grafana/MatchersUI/utils.ts

@ -0,0 +1,116 @@
// copied from https://github.com/grafana/grafana/blob/3c6e0e8ef85048af952367751e478c08342e17b4/packages/grafana-ui/src/components/MatchersUI/utils.ts
import { useMemo } from 'react';
import { DataFrame, Field, getFieldDisplayName, SelectableValue } from '@grafana/data';
import { getFieldTypeIcon } from './types';
/**
* @internal
*/
export interface FrameFieldsDisplayNames {
// The display names
display: Set<string>;
// raw field names (that are explicitly not visible)
raw: Set<string>;
// Field mappings (duplicates are not supported)
fields: Map<string, Field>;
}
/**
* @internal
*/
export function frameHasName(name: string | undefined, names: FrameFieldsDisplayNames) {
if (!name) {
return false;
}
return names.display.has(name) || names.raw.has(name);
}
/**
* Retuns the distinct names in a set of frames
*/
function getFrameFieldsDisplayNames(data: DataFrame[], filter?: (field: Field) => boolean): FrameFieldsDisplayNames {
const names: FrameFieldsDisplayNames = {
display: new Set<string>(),
raw: new Set<string>(),
fields: new Map<string, Field>(),
};
for (const frame of data) {
for (const field of frame.fields) {
if (filter && !filter(field)) {
continue;
}
const disp = getFieldDisplayName(field, frame, data);
names.display.add(disp);
names.fields.set(disp, field);
if (field.name && disp !== field.name) {
names.raw.add(field.name);
names.fields.set(field.name, field);
}
}
}
return names;
}
/**
* @internal
*/
export function useFieldDisplayNames(data: DataFrame[], filter?: (field: Field) => boolean): FrameFieldsDisplayNames {
return useMemo(() => {
return getFrameFieldsDisplayNames(data, filter);
}, [data, filter]);
}
/**
* @internal
*/
export function useSelectOptions(
displayNames: FrameFieldsDisplayNames,
currentName?: string,
firstItem?: SelectableValue<string>,
fieldType?: string
): Array<SelectableValue<string>> {
return useMemo(() => {
let found = false;
const options: Array<SelectableValue<string>> = [];
if (firstItem) {
options.push(firstItem);
}
for (const name of displayNames.display) {
if (!found && name === currentName) {
found = true;
}
const field = displayNames.fields.get(name);
if (!fieldType || fieldType === field?.type) {
options.push({
value: name,
label: name,
icon: field ? getFieldTypeIcon(field) : undefined,
});
}
}
for (const name of displayNames.raw) {
if (!displayNames.display.has(name)) {
if (!found && name === currentName) {
found = true;
}
options.push({
value: name,
label: `${name} (base field name)`,
});
}
}
if (currentName && !found) {
options.push({
value: currentName,
label: `${currentName} (not found)`,
});
}
return options;
}, [displayNames, currentName, firstItem, fieldType]);
}

19
src/models/series.ts

@ -1,22 +1,19 @@
import { PanelData, TimeRange } from '@grafana/data';
import { DataProcessor } from '../grafana/data_processor';
import { ExtremumOptions } from 'types';
import { ValueOptions } from 'types';
import * as _ from 'lodash';
// Convert Grafana options into Chartwerk options
// Convert Grafana series into Chartwerk series
export class Series {
private processor;
private processor;
private _seriesList;
private _selectedSerieName;
constructor(grafanaData: PanelData, timeRange: TimeRange, private gaugeValueOptions: ExtremumOptions) {
if(this._isSerieOneValue()) {
this._seriesList = [{ datapoints: [[0, gaugeValueOptions.value]] }];
return;
}
if(this.gaugeValueOptions.useMetric && _.isEmpty(this.gaugeValueOptions.metricName)) {
constructor(grafanaData: PanelData, timeRange: TimeRange, private gaugeValueOptions: ValueOptions) {
if(_.isEmpty(this.gaugeValueOptions.metricName)) {
throw new Error(`Value or metric is not selected.`);
}
@ -52,8 +49,4 @@ export class Series {
};
});
}
private _isSerieOneValue(): boolean {
return !this.gaugeValueOptions.useMetric;
}
}

35
src/module.ts

@ -1,7 +1,10 @@
import { PanelPlugin } from '@grafana/data';
import { PanelOptions, Pod } from './types';
import { Panel } from './components/Panel';
import { PanelPlugin } from '@grafana/data';
import { IconsEditor } from 'components/editors/IconsEditor';
export const plugin = new PanelPlugin<PanelOptions>(Panel).setPanelOptions((builder) => {
return builder
.addRadio({
@ -29,25 +32,14 @@ export const plugin = new PanelPlugin<PanelOptions>(Panel).setPanelOptions((buil
],
},
})
.addNumberInput({
name: 'Value',
path: 'gauge.value.value',
category: ['Extremum'],
showIf: (config) => config.visualizationType === Pod.GAUGE && !config.gauge.value.useMetric,
})
.addFieldNamePicker({
name: 'Value',
path: 'gauge.value.metricName',
category: ['Extremum'],
showIf: (config) => config.visualizationType === Pod.GAUGE && config.gauge.value.useMetric,
})
.addBooleanSwitch({
path: 'gauge.value.useMetric',
name: 'Use metric',
defaultValue: false,
category: ['Extremum'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
showIf: (config) => config.visualizationType === Pod.GAUGE
})
.addNumberInput({
path: 'gauge.min.value',
name: 'Min',
@ -67,6 +59,7 @@ export const plugin = new PanelPlugin<PanelOptions>(Panel).setPanelOptions((buil
category: ['Extremum'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
})
.addNumberInput({
path: 'gauge.max.value',
name: 'Max',
@ -85,5 +78,15 @@ export const plugin = new PanelPlugin<PanelOptions>(Panel).setPanelOptions((buil
defaultValue: false,
category: ['Extremum'],
showIf: (config) => config.visualizationType === Pod.GAUGE,
});
})
.addCustomEditor({
id: 'icons',
path: 'gauge.icons',
name: 'Icons',
category: ['Icons'],
defaultValue: [],
showIf: (config) => config.visualizationType === Pod.GAUGE,
editor: IconsEditor as any,
})
});

16
src/types.ts

@ -8,10 +8,20 @@ export interface PanelOptions {
}
export type ExtremumOptions = {
value?: number;
useMetric: false;
metricName: string;
};
value?: number;
metricName?: string;
}
export type ValueOptions = {
metricName?: string
}
export enum IconPosition {
UPPER_LEFT = 'Upper left',
MIDDLE = 'Middle',
UPPER_RIGHT = 'Upper right'
}
export enum Pod {
LINE = 'Line',

Loading…
Cancel
Save