Merge branch 'main' into encryption/use-secrets-service

This commit is contained in:
Joan López de la Franca Beltran
2021-10-25 08:36:53 +02:00
27 changed files with 637 additions and 361 deletions
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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', () => {
@@ -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);
}
@@ -145,6 +145,68 @@ describe('Labels as Columns', () => {
`);
});
});
it('data frame with labels and multiple fields', async () => {
const cfg: DataTransformerConfig<LabelsToFieldsOptions> = {
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<LabelsToFieldsOptions> = {
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) {
@@ -24,6 +24,7 @@ export const labelsToFieldsTransformer: SynchronousDataTransformerInfo<LabelsToF
for (const frame of data) {
const newFields: Field[] = [];
const uniqueLabels: Record<string, Set<string>> = {};
for (const field of frame.fields) {
if (!field.labels) {
@@ -50,9 +51,16 @@ export const labelsToFieldsTransformer: SynchronousDataTransformerInfo<LabelsToF
continue;
}
const values = new Array(frame.length).fill(field.labels[labelName]);
const uniqueValues = (uniqueLabels[labelName] ||= new Set());
uniqueValues.add(field.labels[labelName]);
}
}
for (const name in uniqueLabels) {
for (const value of uniqueLabels[name]) {
const values = new Array(frame.length).fill(value);
newFields.push({
name: labelName,
name: name,
type: FieldType.string,
values: new ArrayVector(values),
config: {},
+1 -1
View File
@@ -36,7 +36,7 @@
"@rollup/plugin-commonjs": "16.0.0",
"@rollup/plugin-node-resolve": "10.0.0",
"@types/history": "^4.7.8",
"@types/jest": "26.0.15",
"@types/jest": "27.0.2",
"@types/rollup-plugin-visualizer": "4.2.1",
"@types/systemjs": "^0.20.6",
"lodash": "4.17.21",
+1 -1
View File
@@ -36,7 +36,7 @@
"@types/command-exists": "^1.2.0",
"@types/fs-extra": "^9.0.13",
"@types/inquirer": "^6.0.3",
"@types/jest": "26.0.15",
"@types/jest": "27.0.2",
"@types/node": "^14.0.0",
"@types/prettier": "^2.4.0",
"@types/react-dev-utils": "^9.0.4",
+1 -1
View File
@@ -96,7 +96,7 @@
"@types/d3": "7.0.0",
"@types/hoist-non-react-statics": "3.3.1",
"@types/is-hotkey": "0.1.1",
"@types/jest": "26.0.15",
"@types/jest": "27.0.2",
"@types/jquery": "3.3.38",
"@types/lodash": "4.14.123",
"@types/mock-raf": "1.0.2",
@@ -89,10 +89,25 @@ export function Modal(props: PropsWithChildren<Props>) {
);
}
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 (
<div className={styles.modalButtonRow}>
<HorizontalGroup justify="space-between">
<HorizontalGroup justify="flex-start" spacing="md">
{leftItems}
</HorizontalGroup>
<HorizontalGroup justify="flex-end" spacing="md">
{children}
</HorizontalGroup>
</HorizontalGroup>
</div>
);
}
return (
<div className={styles.modalButtonRow}>
<HorizontalGroup justify="flex-end" spacing="md">
@@ -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'));
@@ -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 (
<>
<table className={styles.editTable}>
<thead>
<tr>
<th style={{ width: '1%' }}></th>
<th style={{ width: '40%', textAlign: 'left' }} colSpan={2}>
Condition
</th>
<th style={{ textAlign: 'left' }}>Display text</th>
<th style={{ width: '10%' }}>Color</th>
<th style={{ width: '1%' }}></th>
</tr>
</thead>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sortable-field-mappings" direction="vertical">
{(provided) => (
<tbody ref={provided.innerRef} {...provided.droppableProps}>
{rows.map((row, index) => (
<ValueMappingEditRow
key={index.toString()}
mapping={row}
index={index}
onChange={onChangeMapping}
onRemove={onRemoveRow}
onDuplicate={onDuplicateMapping}
/>
))}
{provided.placeholder}
</tbody>
)}
</Droppable>
</DragDropContext>
</table>
<ValuePicker
label="Add a new mapping"
variant="secondary"
size="md"
icon="plus"
menuPlacement="auto"
isFullWidth={false}
options={mappingTypes}
onChange={onAddValueMapping}
/>
<Modal.ButtonRow>
<div className={styles.tableWrap}>
<table className={styles.editTable}>
<thead>
<tr>
<th style={{ width: '1%' }}></th>
<th style={{ width: '40%', textAlign: 'left' }} colSpan={2}>
Condition
</th>
<th style={{ textAlign: 'left' }}>Display text</th>
<th style={{ width: '10%' }}>Color</th>
<th style={{ width: '1%' }}></th>
</tr>
</thead>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sortable-field-mappings" direction="vertical">
{(provided) => (
<tbody ref={provided.innerRef} {...provided.droppableProps}>
{rows.map((row, index) => (
<ValueMappingEditRow
key={index.toString()}
mapping={row}
index={index}
onChange={onChangeMapping}
onRemove={onRemoveRow}
onDuplicate={onDuplicateMapping}
/>
))}
{provided.placeholder}
</tbody>
)}
</Droppable>
</DragDropContext>
</table>
</div>
<Modal.ButtonRow
leftItems={
<ValuePicker
label="Add a new mapping"
variant="secondary"
size="md"
icon="plus"
menuPlacement="auto"
minWidth={40}
options={mappingTypes}
onChange={onAddValueMapping}
/>
}
>
<Button variant="secondary" fill="outline" onClick={onClose}>
Cancel
</Button>
@@ -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),
@@ -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<T> {
/** Label to display on the picker button */
@@ -20,6 +20,8 @@ export interface ValuePickerProps<T> {
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<T>({
options,
onChange,
variant,
minWidth = 16,
size = 'sm',
isFullWidth = true,
menuPlacement,
}: ValuePickerProps<T>) {
const [isPicking, setIsPicking] = useState(false);
const theme = useTheme2();
const buttonEl = (
<Button
size={size || 'sm'}
icon={icon || 'plus'}
onClick={() => setIsPicking(true)}
variant={variant}
aria-label={selectors.components.ValuePicker.button(label)}
>
{label}
</Button>
);
return (
<>
{!isPicking && (isFullWidth ? <FullWidthButtonContainer>{buttonEl}</FullWidthButtonContainer> : buttonEl)}
{!isPicking && (
<Button
size={size || 'sm'}
icon={icon || 'plus'}
onClick={() => setIsPicking(true)}
variant={variant}
fullWidth={isFullWidth}
aria-label={selectors.components.ValuePicker.button(label)}
>
{label}
</Button>
)}
{isPicking && (
<span>
<span style={{ minWidth: theme.spacing(minWidth), flexGrow: isFullWidth ? 1 : undefined }}>
<Select
menuShouldPortal
placeholder={label}
+1 -3
View File
@@ -317,9 +317,6 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
liveNavLinks = append(liveNavLinks, &dtos.NavLink{
Text: "Cloud", Id: "live-cloud", Url: hs.Cfg.AppSubURL + "/live/cloud", Icon: "cloud-upload",
})
liveNavLinks = append(liveNavLinks, &dtos.NavLink{
Text: "Test", Id: "live-test", Url: hs.Cfg.AppSubURL + "/live/test", Icon: "arrow",
})
navTree = append(navTree, &dtos.NavLink{
Id: "live",
Text: "Live",
@@ -328,6 +325,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
Url: hs.Cfg.AppSubURL + "/live",
Children: liveNavLinks,
HideFromMenu: true,
HideFromTabs: true,
})
}
+11 -7
View File
@@ -382,18 +382,22 @@ func (sn *SlackNotifier) sendRequest(ctx context.Context, data []byte) error {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
// Slack responds to some requests with a JSON document, that might contain an error
rslt := struct {
Ok bool `json:"ok"`
Err string `json:"error"`
}{}
if err := json.Unmarshal(body, &rslt); err == nil {
if !rslt.Ok && rslt.Err != "" {
sn.log.Warn("Sending Slack API request failed", "url", sn.url.String(), "statusCode", resp.Status,
"err", rslt.Err)
return fmt.Errorf("failed to make Slack API request: %s", rslt.Err)
}
if err := json.Unmarshal(body, &rslt); err != nil {
sn.log.Warn("Failed to unmarshal Slack API response", "url", sn.url.String(), "statusCode", resp.Status,
"err", err)
return fmt.Errorf("failed to unmarshal Slack API response with status code %d: %s", resp.StatusCode, err)
}
if !rslt.Ok && rslt.Err != "" {
sn.log.Warn("Sending Slack API request failed", "url", sn.url.String(), "statusCode", resp.Status,
"err", rslt.Err)
return fmt.Errorf("failed to make Slack API request: %s", rslt.Err)
}
sn.log.Debug("Sending Slack API request succeeded", "url", sn.url.String(), "statusCode", resp.Status)
@@ -273,20 +273,20 @@ func TestSendSlackRequest(t *testing.T) {
expectError: false,
},
{
name: "Success case, no response body",
name: "No response body",
statusCode: http.StatusOK,
expectError: false,
expectError: true,
},
{
name: "Success case, unexpected response body",
statusCode: http.StatusOK,
slackResponse: "{}",
slackResponse: `{"test": true}`,
expectError: false,
},
{
name: "Success case, ok: true",
statusCode: http.StatusOK,
slackResponse: "{\"ok\": true}",
slackResponse: `{"ok": true}`,
expectError: false,
},
{
@@ -218,7 +218,7 @@ var sendSlackRequest = func(request *http.Request, logger log.Logger) error {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
logger.Warn("Slack API request failed", "url", request.URL.String(), "statusCode", resp.Status, "body", string(body))
return fmt.Errorf("request to Slack API failed with status code %d", resp.StatusCode)
}
@@ -228,12 +228,16 @@ var sendSlackRequest = func(request *http.Request, logger log.Logger) error {
Ok bool `json:"ok"`
Err string `json:"error"`
}{}
if err := json.Unmarshal(body, &rslt); err == nil {
if !rslt.Ok && rslt.Err != "" {
logger.Warn("Sending Slack API request failed", "url", request.URL.String(), "statusCode", resp.Status,
"err", rslt.Err)
return fmt.Errorf("failed to make Slack API request: %s", rslt.Err)
}
if err := json.Unmarshal(body, &rslt); err != nil {
logger.Warn("Failed to unmarshal Slack API response", "url", request.URL.String(), "statusCode", resp.Status,
"body", string(body))
return fmt.Errorf("failed to unmarshal Slack API response: %s", err)
}
if !rslt.Ok && rslt.Err != "" {
logger.Warn("Sending Slack API request failed", "url", request.URL.String(), "statusCode", resp.Status,
"err", rslt.Err)
return fmt.Errorf("failed to make Slack API request: %s", rslt.Err)
}
logger.Debug("Sending Slack API request succeeded", "url", request.URL.String(), "statusCode", resp.Status)
@@ -270,20 +270,20 @@ func TestSendSlackRequest(t *testing.T) {
expectError: false,
},
{
name: "Success case, no response body",
name: "No response body",
statusCode: http.StatusOK,
expectError: false,
expectError: true,
},
{
name: "Success case, unexpected response body",
statusCode: http.StatusOK,
slackResponse: "{}",
slackResponse: `{"test": true}`,
expectError: false,
},
{
name: "Success case, ok: true",
statusCode: http.StatusOK,
slackResponse: "{\"ok\": true}",
slackResponse: `{"ok": true}`,
expectError: false,
},
{
@@ -260,5 +260,6 @@ function getBorderTopStyles(theme: GrafanaTheme2) {
return css({
borderTop: `1px solid ${theme.colors.border.weak}`,
padding: `${theme.spacing(2)}`,
display: 'flex',
});
}
+110 -34
View File
@@ -1,32 +1,48 @@
import React from 'react';
import { Input, Form, Field, Button } from '@grafana/ui';
import { getBackendSrv } from '@grafana/runtime';
import React, { useState } from 'react';
import { Input, Field, Button, ValuePicker, HorizontalGroup } from '@grafana/ui';
import { DataSourcePicker, getBackendSrv } from '@grafana/runtime';
import { AppEvents, DatasourceRef, LiveChannelScope, SelectableValue } from '@grafana/data';
import appEvents from 'app/core/app_events';
import { Rule } from './types';
interface Props {
onClose: (state: boolean) => void;
onRuleAdded: (rule: Rule) => void;
}
export function AddNewRule({ onClose }: Props) {
const onSubmit = (formData: Rule) => {
type PatternType = 'ds' | 'any';
const patternTypes: Array<SelectableValue<PatternType>> = [
{
label: 'Data source',
description: 'Configure a channel scoped to a data source instance',
value: 'ds',
},
{
label: 'Any',
description: 'Enter an arbitray channel pattern',
value: 'any',
},
];
export function AddNewRule({ onRuleAdded }: Props) {
const [patternType, setPatternType] = useState<PatternType>();
const [pattern, setPattern] = useState<string>();
const [patternPrefix, setPatternPrefix] = useState<string>('');
const [datasource, setDatasource] = useState<DatasourceRef>();
const onSubmit = () => {
if (!pattern) {
appEvents.emit(AppEvents.alertError, ['Enter path']);
return;
}
if (patternType === 'ds' && !patternPrefix.length) {
appEvents.emit(AppEvents.alertError, ['Select datasource']);
return;
}
getBackendSrv()
.post(`api/live/channel-rules`, {
pattern: formData.pattern,
settings: {
output: formData.settings.frameOutputs,
converter: formData.settings.converter,
},
})
.then(() => {
// close modal
onClose(false);
})
.catch((e) => console.error(e));
};
return (
<Form
defaultValues={{
pattern: '',
pattern: patternPrefix + pattern,
settings: {
converter: {
type: 'jsonAuto',
@@ -37,17 +53,77 @@ export function AddNewRule({ onClose }: Props) {
},
],
},
}}
onSubmit={onSubmit}
>
{({ register, errors }) => (
<>
<Field label="Pattern" invalid={!!errors.pattern} error="Pattern is required">
<Input {...register('pattern', { required: true })} placeholder="scope/namespace/path" />
})
.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 (
<div>
<HorizontalGroup>
{patternType === 'any' && (
<Field label="Pattern">
<Input
value={pattern ?? ''}
onChange={(e) => setPattern(e.currentTarget.value)}
placeholder="scope/namespace/path"
/>
</Field>
)}
{patternType === 'ds' && (
<>
<Field label="Data source">
<DataSourcePicker
current={datasource}
onChange={(ds) => {
setDatasource(ds.name);
setPatternPrefix(`${LiveChannelScope.DataSource}/${ds.uid}/`);
}}
/>
</Field>
<Field label="Path">
<Input value={pattern ?? ''} onChange={(e) => setPattern(e.currentTarget.value)} placeholder="path" />
</Field>
</>
)}
<Field label="">
<Button onClick={onSubmit} variant={pattern?.length ? 'primary' : 'secondary'}>
Add
</Button>
</Field>
<Button>Add</Button>
</>
)}
</Form>
<Field label="">
<Button variant="secondary" onClick={() => setPatternType(undefined)}>
Cancel
</Button>
</Field>
</HorizontalGroup>
</div>
);
}
return (
<div>
<ValuePicker
label="Add channel rule"
variant="secondary"
size="md"
icon="plus"
menuPlacement="auto"
isFullWidth={false}
options={patternTypes}
onChange={(v) => setPatternType(v.value)}
/>
</div>
);
}
@@ -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 <Tag key={key} name={output.type} />;
}
export default function PipelineAdminPage() {
const [rules, setRules] = useState<Rule[]>([]);
const [isOpen, setOpen] = useState(false);
const [selectedRule, setSelectedRule] = useState<Rule>();
const [defaultRules, setDefaultRules] = useState<any[]>([]);
const [newRule, setNewRule] = useState<Rule>();
const navModel = useNavModel('live-pipeline');
const [isOpenEditor, setOpenEditor] = useState<boolean>(false);
const [error, setError] = useState<string>();
const [clickColumn, setClickColumn] = useState<RuleType>('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<HTMLInputElement>) => {
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 (
<Page navModel={navModel}>
<Page.Contents>
@@ -73,75 +47,19 @@ export default function PipelineAdminPage() {
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<Input placeholder="Search pattern..." onChange={onSearchQueryChange} />
<Button className={styles.addNew} onClick={() => setOpenEditor(true)}>
Add Rule
</Button>
</div>
</div>
<div className="admin-list-table">
<table className="filter-table filter-table--hover form-inline">
<thead>
<tr>
<th>Pattern</th>
<th>Converter</th>
<th>Processor</th>
<th>Output</th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.pattern} onClick={onRowClick} className={styles.row}>
<td data-pattern={rule.pattern} data-column="pattern">
{rule.pattern}
</td>
<td data-pattern={rule.pattern} data-column="converter">
{rule.settings?.converter?.type}
</td>
<td data-pattern={rule.pattern} data-column="processor">
{rule.settings?.frameProcessors?.map((processor) => (
<span key={rule.pattern + processor.type}>{processor.type}</span>
))}
</td>
<td data-pattern={rule.pattern} data-column="output">
{rule.settings?.frameOutputs?.map((output) => (
<span key={rule.pattern + output.type}>{renderOutputTags('out', output)}</span>
))}
</td>
<td>
<IconButton name="trash-alt" onClick={() => onRemoveRule(rule.pattern)}></IconButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
{isOpenEditor && (
<Modal isOpen={isOpenEditor} onDismiss={() => setOpenEditor(false)} title="Add a new rule">
<AddNewRule onClose={setOpenEditor} />
</Modal>
)}
{isOpen && selectedRule && (
<RuleModal
rule={selectedRule}
isOpen={isOpen}
onClose={() => {
setOpen(false);
}}
clickColumn={clickColumn}
/>
)}
<PipelineTable rules={rules} onRuleChanged={loadRules} selectRule={newRule} />
<AddNewRule
onRuleAdded={(r: Rule) => {
console.log('GOT', r, 'vs', rules[0]);
setNewRule(r);
loadRules();
}}
/>
</Page.Contents>
</Page>
);
}
const getStyles = (theme: GrafanaTheme) => {
return {
row: css`
cursor: pointer;
`,
addNew: css`
margin-left: 10px;
`,
};
};
@@ -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 <Tag key={key} name={output.type} />;
}
interface Props {
rules: Rule[];
onRuleChanged: () => void;
selectRule?: Rule;
}
export const PipelineTable: React.FC<Props> = (props) => {
const { rules } = props;
const [isOpen, setOpen] = useState(false);
const [selectedRule, setSelectedRule] = useState<Rule>();
const [clickColumn, setClickColumn] = useState<RuleType>('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 (
<div>
<Tag name={ds.name} colorIndex={1} /> &nbsp;
<span>{pattern.substring(idx + 1)}</span>
</div>
);
}
}
}
return pattern;
};
return (
<div>
<div className="admin-list-table">
<table className="filter-table filter-table--hover form-inline">
<thead>
<tr>
<th>Channel</th>
<th>Converter</th>
<th>Processor</th>
<th>Output</th>
<th style={{ width: 10 }}>&nbsp;</th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.pattern} onClick={(e) => onRowClick(rule, e)} className={styles.row}>
<td data-pattern={rule.pattern} data-column="pattern">
{renderPattern(rule.pattern)}
</td>
<td data-pattern={rule.pattern} data-column="converter">
{rule.settings?.converter?.type}
</td>
<td data-pattern={rule.pattern} data-column="processor">
{rule.settings?.frameProcessors?.map((processor) => (
<span key={rule.pattern + processor.type}>{processor.type}</span>
))}
</td>
<td data-pattern={rule.pattern} data-column="output">
{rule.settings?.frameOutputs?.map((output) => (
<span key={rule.pattern + output.type}>{renderOutputTags('out', output)}</span>
))}
</td>
<td>
<IconButton
name="trash-alt"
onClick={(e) => {
e.stopPropagation();
onRemoveRule(rule.pattern);
}}
></IconButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
{isOpen && selectedRule && (
<RuleModal
rule={selectedRule}
isOpen={isOpen}
onClose={() => {
setOpen(false);
}}
clickColumn={clickColumn}
/>
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme) => {
return {
row: css`
cursor: pointer;
`,
};
};
+49 -34
View File
@@ -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> = (props) => {
const { isOpen, onClose, clickColumn } = props;
const [rule, setRule] = useState<Rule>(props.rule);
const [activeTab, setActiveTab] = useState<RuleType>(clickColumn);
const [activeTab, setActiveTab] = useState<TabInfo | undefined>(tabs.find((t) => t.type === clickColumn));
// to show color of Save button
const [hasChange, setChange] = useState<boolean>(false);
const [ruleSetting, setRuleSetting] = useState<any>(rule?.settings?.[activeTab]);
const [ruleSetting, setRuleSetting] = useState<any>(activeTab?.type ? rule?.settings?.[activeTab.type] : undefined);
const [entitiesInfo, setEntitiesInfo] = useState<PipeLineEntitiesInfo>();
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> = (props) => {
<Tab
key={index}
label={tab.label}
active={tab.value === activeTab}
active={tab === activeTab}
icon={tab.icon as any}
onChangeTab={() => {
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]);
}
}}
/>
);
})}
</TabsBar>
<TabContent>
{entitiesInfo && rule && activeTab === 'converter' && (
<RuleSettingsEditor
onChange={onRuleSettingChange}
value={ruleSetting}
ruleType={activeTab}
entitiesInfo={entitiesInfo}
/>
)}
{entitiesInfo && rule && activeTab !== 'converter' && (
<RuleSettingsArray
onChange={onRuleSettingChange}
value={ruleSetting}
ruleType={activeTab}
entitiesInfo={entitiesInfo}
/>
{entitiesInfo && rule && activeTab && (
<>
{activeTab?.isTest && <RuleTest rule={rule} />}
{activeTab.isConverter && (
<RuleSettingsEditor
onChange={onRuleSettingChange}
value={ruleSetting}
ruleType={'converter'}
entitiesInfo={entitiesInfo}
/>
)}
{!activeTab.isConverter && activeTab.type && (
<RuleSettingsArray
onChange={onRuleSettingChange}
value={ruleSetting}
ruleType={activeTab.type}
entitiesInfo={entitiesInfo}
/>
)}
</>
)}
<Button onClick={onSave} className={styles.save} variant={hasChange ? 'primary' : 'secondary'}>
Save
+32 -61
View File
@@ -1,33 +1,18 @@
import React, { useState, useEffect } from 'react';
import { Button, CodeEditor, Table, useStyles, Select, Field } from '@grafana/ui';
import React, { useState } from 'react';
import { Button, CodeEditor, Table, useStyles, Field } from '@grafana/ui';
import { ChannelFrame, Rule } from './types';
import Page from 'app/core/components/Page/Page';
import { useNavModel } from 'app/core/hooks/useNavModel';
import { getBackendSrv, config } from '@grafana/runtime';
import { css } from '@emotion/css';
import { getDisplayProcessor, GrafanaTheme, StreamingDataFrame } from '@grafana/data';
import { transformLabel } from './utils';
export default function RuleTest() {
const navModel = useNavModel('live-test');
interface Props {
rule: Rule;
}
export const RuleTest: React.FC<Props> = (props) => {
const [response, setResponse] = useState<ChannelFrame[]>();
const [data, setData] = useState<string>();
const [rules, setRules] = useState<Rule[]>([]);
const [channelRules, setChannelRules] = useState<Rule[]>();
const [channelSelected, setChannelSelected] = useState<string>();
const styles = useStyles(getStyles);
useEffect(() => {
getBackendSrv()
.get(`api/live/channel-rules`)
.then((data) => {
setRules(data.rules);
})
.catch((e) => {
if (e.data) {
console.log(e);
}
});
}, []);
const onBlur = (text: string) => {
setData(text);
@@ -36,8 +21,8 @@ export default function RuleTest() {
const onClick = () => {
getBackendSrv()
.post(`api/live/pipeline-convert-test`, {
channelRules: channelRules,
channel: channelSelected,
channelRules: [props.rule],
channel: props.rule.pattern,
data: data,
})
.then((data: any) => {
@@ -60,45 +45,31 @@ export default function RuleTest() {
};
return (
<Page navModel={navModel}>
<Page.Contents>
<Field label="Channel">
<Select
menuShouldPortal
options={transformLabel(rules, 'pattern')}
value=""
onChange={(v) => {
setChannelSelected(v.value);
setChannelRules(rules.filter((r) => r.pattern === v.value));
}}
placeholder="Select Channel"
/>
</Field>
<Field label="Data">
<CodeEditor
height={200}
value=""
showLineNumbers={true}
readOnly={false}
language="json"
showMiniMap={false}
onBlur={onBlur}
/>
</Field>
<Button onClick={onClick} className={styles.margin}>
Test
</Button>
<div>
<CodeEditor
height={100}
value=""
showLineNumbers={true}
readOnly={false}
language="json"
showMiniMap={false}
onBlur={onBlur}
/>
{response?.length &&
response.map((r) => (
<Field key={r.channel} label={r.channel}>
<Table data={r.frame} width={650} height={10 * r.frame.length + 10} showTypeIcons></Table>
</Field>
))}
</Page.Contents>
</Page>
<Button onClick={onClick} className={styles.margin}>
Test
</Button>
{response?.length &&
response.map((r) => (
<Field key={r.channel} label={r.channel}>
<Table data={r.frame} width={700} height={Math.min(10 * r.frame.length + 10, 150)} showTypeIcons></Table>
</Field>
))}
</div>
);
}
};
const getStyles = (theme: GrafanaTheme) => {
return {
margin: css`
-6
View File
@@ -22,12 +22,6 @@ const liveRoutes = [
() => import(/* webpackChunkName: "CloudAdminPage" */ 'app/features/live/pages/CloudAdminPage')
),
},
{
path: '/live/test',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "CloudAdminPage" */ 'app/features/live/pages/RuleTest')
),
},
];
export function getLiveRoutes(cfg = config): RouteDescriptor[] {
@@ -34,15 +34,17 @@ describe('MixedDatasource', () => {
});
const results: any[] = [];
beforeEach(async (done) => {
const ds = await getDataSourceSrv().get('-- Mixed --');
from(ds.query(requestMixed)).subscribe((result) => {
results.push(result);
if (result.state === LoadingState.Done) {
done();
}
});
beforeEach((done) => {
getDataSourceSrv()
.get('-- Mixed --')
.then((ds) => {
from(ds.query(requestMixed)).subscribe((result) => {
results.push(result);
if (result.state === LoadingState.Done) {
done();
}
});
});
});
it('direct query should return results', async () => {
@@ -68,15 +70,17 @@ describe('MixedDatasource', () => {
});
const results: any[] = [];
beforeEach(async (done) => {
const ds = await getDataSourceSrv().get('-- Mixed --');
from(ds.query(requestMixed)).subscribe((result) => {
results.push(result);
if (results.length === 5) {
done();
}
});
beforeEach((done) => {
getDataSourceSrv()
.get('-- Mixed --')
.then((ds) => {
from(ds.query(requestMixed)).subscribe((result) => {
results.push(result);
if (results.length === 5) {
done();
}
});
});
});
it('direct query should return results', async () => {
+6 -16
View File
@@ -2379,7 +2379,7 @@ __metadata:
"@rollup/plugin-node-resolve": 10.0.0
"@types/braintree__sanitize-url": 4.1.0
"@types/d3-interpolate": ^1.3.1
"@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
@@ -2494,7 +2494,7 @@ __metadata:
"@rollup/plugin-commonjs": 16.0.0
"@rollup/plugin-node-resolve": 10.0.0
"@types/history": ^4.7.8
"@types/jest": 26.0.15
"@types/jest": 27.0.2
"@types/rollup-plugin-visualizer": 4.2.1
"@types/systemjs": ^0.20.6
history: 4.10.1
@@ -2572,7 +2572,7 @@ __metadata:
"@types/command-exists": ^1.2.0
"@types/fs-extra": ^9.0.13
"@types/inquirer": ^6.0.3
"@types/jest": 26.0.15
"@types/jest": 27.0.2
"@types/node": ^14.0.0
"@types/prettier": ^2.4.0
"@types/react-dev-utils": ^9.0.4
@@ -2680,7 +2680,7 @@ __metadata:
"@types/d3": 7.0.0
"@types/hoist-non-react-statics": 3.3.1
"@types/is-hotkey": 0.1.1
"@types/jest": 26.0.15
"@types/jest": 27.0.2
"@types/jquery": 3.3.38
"@types/lodash": 4.14.123
"@types/mock-raf": 1.0.2
@@ -7568,7 +7568,7 @@ __metadata:
languageName: node
linkType: hard
"@types/jest@npm:*":
"@types/jest@npm:*, @types/jest@npm:27.0.2":
version: 27.0.2
resolution: "@types/jest@npm:27.0.2"
dependencies:
@@ -7578,16 +7578,6 @@ __metadata:
languageName: node
linkType: hard
"@types/jest@npm:26.0.15":
version: 26.0.15
resolution: "@types/jest@npm:26.0.15"
dependencies:
jest-diff: ^26.0.0
pretty-format: ^26.0.0
checksum: 5bbed3d3afa40bcac9eb2e8e76957b40b7cb86228a7a1dd455f7e30cb283a7314f7d7afaf2d46ca52ba5ce62168e0f60c57e0ab7509a6b91eab42460709b6bf1
languageName: node
linkType: hard
"@types/jest@npm:26.x":
version: 26.0.24
resolution: "@types/jest@npm:26.0.24"
@@ -17645,7 +17635,7 @@ fsevents@~2.1.2:
"@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