diff --git a/package.json b/package.json index ac64f90fa16..403b4533b06 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "@types/file-saver": "2.0.1", "@types/history": "^4.7.8", "@types/hoist-non-react-statics": "3.3.1", - "@types/jest": "26.0.15", + "@types/jest": "27.0.2", "@types/jquery": "3.3.38", "@types/jsurl": "^1.2.28", "@types/lodash": "4.14.149", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index b66359958f9..ac296683eed 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -38,7 +38,7 @@ "@rollup/plugin-json": "4.1.0", "@rollup/plugin-node-resolve": "10.0.0", "@types/braintree__sanitize-url": "4.1.0", - "@types/jest": "26.0.15", + "@types/jest": "27.0.2", "@types/jquery": "3.3.38", "@types/lodash": "4.14.123", "@types/marked": "1.1.0", diff --git a/packages/grafana-data/src/dataframe/processDataFrame.test.ts b/packages/grafana-data/src/dataframe/processDataFrame.test.ts index 7de45a267c1..e7d0e8f7274 100644 --- a/packages/grafana-data/src/dataframe/processDataFrame.test.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.test.ts @@ -174,6 +174,48 @@ describe('toDataFrame', () => { expect(v0.length).toEqual(1); expect(v0.get(0)).toEqual(input1.datapoints[0]); }); + + it('converts JSON response to dataframes', () => { + const msg = { + schema: { + fields: [ + { + name: 'First', + type: 'string', + }, + { + name: 'Second', + type: 'number', + }, + ], + }, + data: { + values: [ + ['2019-02-15', '2019-03-15', '2019-04-15'], + [3, 9, 16], + ], + }, + }; + const dataFrame = toDataFrame(msg); + expect(dataFrame.fields.map((f) => ({ [f.name]: f.values.toArray() }))).toMatchInlineSnapshot(` + Array [ + Object { + "First": Array [ + "2019-02-15", + "2019-03-15", + "2019-04-15", + ], + }, + Object { + "Second": Array [ + 3, + 9, + 16, + ], + }, + ] + `); + }); }); describe('SeriesData backwards compatibility', () => { diff --git a/packages/grafana-data/src/dataframe/processDataFrame.ts b/packages/grafana-data/src/dataframe/processDataFrame.ts index 33fe81aa8bb..f0fee32b9fe 100644 --- a/packages/grafana-data/src/dataframe/processDataFrame.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.ts @@ -25,6 +25,7 @@ import { ArrayDataFrame } from './ArrayDataFrame'; import { getFieldDisplayName } from '../field/fieldState'; import { fieldIndexComparer } from '../field/fieldComparers'; import { vectorToArray } from '../vector/vectorToArray'; +import { dataFrameFromJSON } from './DataFrameJSON'; function convertTableToDataFrame(table: TableData): DataFrame { const fields = table.columns.map((c) => { @@ -302,6 +303,9 @@ export function toDataFrame(data: any): DataFrame { } if (data.hasOwnProperty('data')) { + if (data.hasOwnProperty('schema')) { + return dataFrameFromJSON(data); + } return convertGraphSeriesToDataFrame(data); } diff --git a/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts b/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts index 164da1cc347..1fb32c79cb2 100644 --- a/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts +++ b/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts @@ -145,6 +145,68 @@ describe('Labels as Columns', () => { `); }); }); + + it('data frame with labels and multiple fields', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.labelsToFields, + options: {}, + }; + + const source = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { name: 'a', type: FieldType.number, values: [1, 3], labels: { name: 'thing' } }, + { name: 'b', type: FieldType.number, values: [2, 4], labels: { name: 'thing' } }, + ], + }); + + await expect(transformDataFrame([cfg], [source])).toEmitValuesWith((received) => { + const data = received[0]; + const result = toDataFrameDTO(data[0]); + + const expected: FieldDTO[] = [ + { name: 'time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'a', type: FieldType.number, values: [1, 3], config: {} }, + { name: 'b', type: FieldType.number, values: [2, 4], config: {} }, + { name: 'name', type: FieldType.string, values: ['thing', 'thing'], config: {} }, + ]; + + expect(result.fields).toEqual(expected); + }); + }); + + it('data frame with labels and multiple fields with different labels', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.labelsToFields, + options: {}, + }; + + const source = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { name: 'a', type: FieldType.number, values: [1, 3], labels: { name: 'thing', field: 'a' } }, + { name: 'b', type: FieldType.number, values: [2, 4], labels: { name: 'thing', field: 'b' } }, + ], + }); + + await expect(transformDataFrame([cfg], [source])).toEmitValuesWith((received) => { + const data = received[0]; + const result = toDataFrameDTO(data[0]); + + const expected: FieldDTO[] = [ + { name: 'time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'a', type: FieldType.number, values: [1, 3], config: {} }, + { name: 'b', type: FieldType.number, values: [2, 4], config: {} }, + { name: 'name', type: FieldType.string, values: ['thing', 'thing'], config: {} }, + { name: 'field', type: FieldType.string, values: ['a', 'a'], config: {} }, + { name: 'field', type: FieldType.string, values: ['b', 'b'], config: {} }, + ]; + + expect(result.fields).toEqual(expected); + }); + }); }); function toSimpleObject(frame: DataFrame) { diff --git a/packages/grafana-data/src/transformations/transformers/labelsToFields.ts b/packages/grafana-data/src/transformations/transformers/labelsToFields.ts index fb930d5968c..181e801e115 100644 --- a/packages/grafana-data/src/transformations/transformers/labelsToFields.ts +++ b/packages/grafana-data/src/transformations/transformers/labelsToFields.ts @@ -24,6 +24,7 @@ export const labelsToFieldsTransformer: SynchronousDataTransformerInfo> = {}; for (const field of frame.fields) { if (!field.labels) { @@ -50,9 +51,16 @@ export const labelsToFieldsTransformer: SynchronousDataTransformerInfo) { ); } -function ModalButtonRow({ children }: { children: React.ReactNode }) { +function ModalButtonRow({ leftItems, children }: { leftItems?: React.ReactNode; children: React.ReactNode }) { const theme = useTheme2(); const styles = getModalStyles(theme); + if (leftItems) { + return ( +
+ + + {leftItems} + + + {children} + + +
+ ); + } + return (
diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.test.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.test.tsx index da80804f835..9c961ded1ad 100644 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.test.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.test.tsx @@ -121,6 +121,7 @@ describe('When adding and updating range map', () => { it('should add new range map', async () => { const onChangeSpy = jest.fn(); setup(onChangeSpy, { value: [] }); + screen.getAllByTestId('remove-value-mapping')[0].click(); fireEvent.click(screen.getByLabelText(selectors.components.ValuePicker.button('Add a new mapping'))); const selectComponent = await screen.findByLabelText(selectors.components.ValuePicker.select('Add a new mapping')); @@ -148,10 +149,11 @@ describe('When adding and updating range map', () => { }); }); -describe('When adding and updating tegex map', () => { +describe('When adding and updating regex map', () => { it('should add new regex map', async () => { const onChangeSpy = jest.fn(); setup(onChangeSpy, { value: [] }); + screen.getAllByTestId('remove-value-mapping')[0].click(); fireEvent.click(screen.getByLabelText(selectors.components.ValuePicker.button('Add a new mapping'))); const selectComponent = await screen.findByLabelText(selectors.components.ValuePicker.select('Add a new mapping')); diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.tsx index 4feae052e3d..1951015bb67 100644 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.tsx @@ -81,51 +81,65 @@ export function ValueMappingsEditorModal({ value, onChange, onClose }: Props) { onClose(); }; + // Start with an empty row + useEffect(() => { + if (!value?.length) { + onAddValueMapping({ value: MappingType.ValueToText }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( <> - - - - - - - - - - - - - {(provided) => ( - - {rows.map((row, index) => ( - - ))} - {provided.placeholder} - - )} - - -
- Condition - Display textColor
- - +
+ + + + + + + + + + + + + {(provided) => ( + + {rows.map((row, index) => ( + + ))} + {provided.placeholder} + + )} + + +
+ Condition + Display textColor
+
+ + + } + > @@ -138,6 +152,12 @@ export function ValueMappingsEditorModal({ value, onChange, onClose }: Props) { } export const getStyles = (theme: GrafanaTheme2) => ({ + tableWrap: css` + max-height: calc(80vh - 170px); + min-height: 40px; + overflow: auto; + `, + editTable: css({ width: '100%', marginBottom: theme.spacing(2), diff --git a/packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx b/packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx index becb214cf7a..53c68bcb47f 100644 --- a/packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx +++ b/packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx @@ -3,9 +3,9 @@ import { IconName } from '../../types'; import { SelectableValue } from '@grafana/data'; import { Button, ButtonVariant } from '../Button'; import { Select } from '../Select/Select'; -import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer'; import { ComponentSize } from '../../types/size'; import { selectors } from '@grafana/e2e-selectors'; +import { useTheme2 } from '../../themes'; export interface ValuePickerProps { /** Label to display on the picker button */ @@ -20,6 +20,8 @@ export interface ValuePickerProps { variant?: ButtonVariant; /** Size of button */ size?: ComponentSize; + /** Min width for select in grid units */ + minWidth?: number; /** Should the picker cover the full width of its parent */ isFullWidth?: boolean; /** Control where the menu is rendered */ @@ -32,29 +34,31 @@ export function ValuePicker({ options, onChange, variant, + minWidth = 16, size = 'sm', isFullWidth = true, menuPlacement, }: ValuePickerProps) { const [isPicking, setIsPicking] = useState(false); + const theme = useTheme2(); - const buttonEl = ( - - ); return ( <> - {!isPicking && (isFullWidth ? {buttonEl} : buttonEl)} + {!isPicking && ( + + )} {isPicking && ( - + + }) + .then((v: any) => { + console.log('ADDED', v); + setPattern(undefined); + setPatternType(undefined); + onRuleAdded(v.rule); + }) + .catch((e) => { + appEvents.emit(AppEvents.alertError, ['Error adding rule', e]); + e.isHandled = true; + }); + }; + + if (patternType) { + return ( +
+ + {patternType === 'any' && ( + + setPattern(e.currentTarget.value)} + placeholder="scope/namespace/path" + /> + + )} + {patternType === 'ds' && ( + <> + + { + setDatasource(ds.name); + setPatternPrefix(`${LiveChannelScope.DataSource}/${ds.uid}/`); + }} + /> + + + setPattern(e.currentTarget.value)} placeholder="path" /> + + + )} + + + - - - )} - + + + + + +
+ ); + } + + return ( +
+ setPatternType(v.value)} + /> +
); } diff --git a/public/app/features/live/pages/PipelineAdminPage.tsx b/public/app/features/live/pages/PipelineAdminPage.tsx index 99bdf32b2fd..2edda347c00 100644 --- a/public/app/features/live/pages/PipelineAdminPage.tsx +++ b/public/app/features/live/pages/PipelineAdminPage.tsx @@ -1,33 +1,20 @@ import React, { useEffect, useState, ChangeEvent } from 'react'; import { getBackendSrv } from '@grafana/runtime'; -import { Input, Tag, useStyles, Button, Modal, IconButton } from '@grafana/ui'; +import { Input } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; import { useNavModel } from 'app/core/hooks/useNavModel'; -import { css } from '@emotion/css'; -import { GrafanaTheme } from '@grafana/data'; -import { Rule, Output, RuleType } from './types'; -import { RuleModal } from './RuleModal'; +import { Rule } from './types'; +import { PipelineTable } from './PipelineTable'; import { AddNewRule } from './AddNewRule'; -function renderOutputTags(key: string, output?: Output): React.ReactNode { - if (!output?.type) { - return null; - } - return ; -} - export default function PipelineAdminPage() { const [rules, setRules] = useState([]); - const [isOpen, setOpen] = useState(false); - const [selectedRule, setSelectedRule] = useState(); const [defaultRules, setDefaultRules] = useState([]); + const [newRule, setNewRule] = useState(); const navModel = useNavModel('live-pipeline'); - const [isOpenEditor, setOpenEditor] = useState(false); const [error, setError] = useState(); - const [clickColumn, setClickColumn] = useState('converter'); - const styles = useStyles(getStyles); - useEffect(() => { + const loadRules = () => { getBackendSrv() .get(`api/live/channel-rules`) .then((data) => { @@ -39,20 +26,12 @@ export default function PipelineAdminPage() { setError(JSON.stringify(e.data, null, 2)); } }); - }, [isOpenEditor, isOpen]); - - const onRowClick = (event: any) => { - const pattern = event.target.getAttribute('data-pattern'); - const column = event.target.getAttribute('data-column'); - if (column === 'pattern') { - setClickColumn('converter'); - } else { - setClickColumn(column); - } - setSelectedRule(rules.filter((rule) => rule.pattern === pattern)[0]); - setOpen(true); }; + useEffect(() => { + loadRules(); + }, []); + const onSearchQueryChange = (e: ChangeEvent) => { if (e.target.value) { setRules(rules.filter((rule) => rule.pattern.toLowerCase().includes(e.target.value.toLowerCase()))); @@ -61,11 +40,6 @@ export default function PipelineAdminPage() { } }; - const onRemoveRule = (pattern: string) => { - getBackendSrv() - .delete(`api/live/channel-rules`, JSON.stringify({ pattern: pattern })) - .catch((e) => console.error(e)); - }; return ( @@ -73,75 +47,19 @@ export default function PipelineAdminPage() {
-
-
- - - - - - - - - - - {rules.map((rule) => ( - - - - - - - - ))} - -
PatternConverterProcessorOutput
- {rule.pattern} - - {rule.settings?.converter?.type} - - {rule.settings?.frameProcessors?.map((processor) => ( - {processor.type} - ))} - - {rule.settings?.frameOutputs?.map((output) => ( - {renderOutputTags('out', output)} - ))} - - onRemoveRule(rule.pattern)}> -
-
- {isOpenEditor && ( - setOpenEditor(false)} title="Add a new rule"> - - - )} - {isOpen && selectedRule && ( - { - setOpen(false); - }} - clickColumn={clickColumn} - /> - )} + + + + { + console.log('GOT', r, 'vs', rules[0]); + setNewRule(r); + loadRules(); + }} + />
); } - -const getStyles = (theme: GrafanaTheme) => { - return { - row: css` - cursor: pointer; - `, - addNew: css` - margin-left: 10px; - `, - }; -}; diff --git a/public/app/features/live/pages/PipelineTable.tsx b/public/app/features/live/pages/PipelineTable.tsx new file mode 100644 index 00000000000..32873a40c16 --- /dev/null +++ b/public/app/features/live/pages/PipelineTable.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState } from 'react'; +import { getBackendSrv } from '@grafana/runtime'; +import { Tag, useStyles, IconButton } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; +import { Rule, Output, RuleType } from './types'; +import { RuleModal } from './RuleModal'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; + +function renderOutputTags(key: string, output?: Output): React.ReactNode { + if (!output?.type) { + return null; + } + return ; +} + +interface Props { + rules: Rule[]; + onRuleChanged: () => void; + selectRule?: Rule; +} + +export const PipelineTable: React.FC = (props) => { + const { rules } = props; + const [isOpen, setOpen] = useState(false); + const [selectedRule, setSelectedRule] = useState(); + const [clickColumn, setClickColumn] = useState('converter'); + const styles = useStyles(getStyles); + + const onRowClick = (rule: Rule, event?: any) => { + if (!rule) { + return; + } + let column = event?.target?.getAttribute('data-column'); + if (!column || column === 'pattern') { + column = 'converter'; + } + setClickColumn(column); + setSelectedRule(rule); + setOpen(true); + }; + + // Supports selecting a rule from external config (after add rule) + useEffect(() => { + if (props.selectRule) { + onRowClick(props.selectRule); + } + }, [props.selectRule]); + + const onRemoveRule = (pattern: string) => { + getBackendSrv() + .delete(`api/live/channel-rules`, JSON.stringify({ pattern: pattern })) + .catch((e) => console.error(e)) + .finally(() => { + props.onRuleChanged(); + }); + }; + + const renderPattern = (pattern: string) => { + if (pattern.startsWith('ds/')) { + const idx = pattern.indexOf('/', 4); + if (idx > 3) { + const uid = pattern.substring(3, idx); + const ds = getDatasourceSrv().getInstanceSettings(uid); + if (ds) { + return ( +
+   + {pattern.substring(idx + 1)} +
+ ); + } + } + } + return pattern; + }; + + return ( +
+
+ + + + + + + + + + + + {rules.map((rule) => ( + onRowClick(rule, e)} className={styles.row}> + + + + + + + ))} + +
ChannelConverterProcessorOutput 
+ {renderPattern(rule.pattern)} + + {rule.settings?.converter?.type} + + {rule.settings?.frameProcessors?.map((processor) => ( + {processor.type} + ))} + + {rule.settings?.frameOutputs?.map((output) => ( + {renderOutputTags('out', output)} + ))} + + { + e.stopPropagation(); + onRemoveRule(rule.pattern); + }} + > +
+
+ {isOpen && selectedRule && ( + { + setOpen(false); + }} + clickColumn={clickColumn} + /> + )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => { + return { + row: css` + cursor: pointer; + `, + }; +}; diff --git a/public/app/features/live/pages/RuleModal.tsx b/public/app/features/live/pages/RuleModal.tsx index 605a564da1f..0f2686024cb 100644 --- a/public/app/features/live/pages/RuleModal.tsx +++ b/public/app/features/live/pages/RuleModal.tsx @@ -7,6 +7,7 @@ import { GrafanaTheme } from '@grafana/data'; import { RuleSettingsEditor } from './RuleSettingsEditor'; import { getPipeLineEntities } from './utils'; import { RuleSettingsArray } from './RuleSettingsArray'; +import { RuleTest } from './RuleTest'; interface Props { rule: Rule; @@ -14,35 +15,41 @@ interface Props { onClose: () => void; clickColumn: RuleType; } -interface TabType { +interface TabInfo { label: string; - value: RuleType; + type?: RuleType; + isTest?: boolean; + isConverter?: boolean; + icon?: string; } -const tabs: TabType[] = [ - { label: 'Converter', value: 'converter' }, - { label: 'Processors', value: 'frameProcessors' }, - { label: 'Outputs', value: 'frameOutputs' }, +const tabs: TabInfo[] = [ + { label: 'Converter', type: 'converter', isConverter: true }, + { label: 'Processors', type: 'frameProcessors' }, + { label: 'Outputs', type: 'frameOutputs' }, + { label: 'Test', isTest: true, icon: 'flask' }, ]; export const RuleModal: React.FC = (props) => { const { isOpen, onClose, clickColumn } = props; const [rule, setRule] = useState(props.rule); - const [activeTab, setActiveTab] = useState(clickColumn); + const [activeTab, setActiveTab] = useState(tabs.find((t) => t.type === clickColumn)); // to show color of Save button const [hasChange, setChange] = useState(false); - const [ruleSetting, setRuleSetting] = useState(rule?.settings?.[activeTab]); + const [ruleSetting, setRuleSetting] = useState(activeTab?.type ? rule?.settings?.[activeTab.type] : undefined); const [entitiesInfo, setEntitiesInfo] = useState(); const styles = useStyles(getStyles); const onRuleSettingChange = (value: RuleSetting | RuleSetting[]) => { setChange(true); - setRule({ - ...rule, - settings: { - ...rule.settings, - [activeTab]: value, - }, - }); + if (activeTab?.type) { + setRule({ + ...rule, + settings: { + ...rule.settings, + [activeTab?.type]: value, + }, + }); + } setRuleSetting(value); }; @@ -71,32 +78,40 @@ export const RuleModal: React.FC = (props) => { { - setActiveTab(tab.value); - // to notify children of the new rule - setRuleSetting(rule?.settings?.[tab.value]); + setActiveTab(tab); + if (tab.type) { + // to notify children of the new rule + setRuleSetting(rule?.settings?.[tab.type]); + } }} /> ); })} - {entitiesInfo && rule && activeTab === 'converter' && ( - - )} - {entitiesInfo && rule && activeTab !== 'converter' && ( - + {entitiesInfo && rule && activeTab && ( + <> + {activeTab?.isTest && } + {activeTab.isConverter && ( + + )} + {!activeTab.isConverter && activeTab.type && ( + + )} + )}