Merge branch 'main' into encryption/use-secrets-service
This commit is contained in:
@@ -17,6 +17,15 @@
|
||||
"minor": {
|
||||
"enabled": false
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPaths": ["grafana-toolkit/package.json"],
|
||||
"ignoreDeps": [
|
||||
"copy-webpack-plugin", // need to wait for Grafana 9 to upgrade toolkit to webpack 5
|
||||
"css-loader", // need to wait for Grafana 9 to upgrade toolkit to webpack 5
|
||||
]
|
||||
}
|
||||
],
|
||||
"patch": {
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
+3
-1
@@ -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'));
|
||||
|
||||
+63
-43
@@ -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}
|
||||
|
||||
@@ -33,7 +33,6 @@ MemoryDenyWriteExecute=false
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
PrivateTmp=true
|
||||
PrivateUsers=true
|
||||
ProtectClock=true
|
||||
ProtectControlGroups=true
|
||||
ProtectHome=true
|
||||
|
||||
@@ -32,7 +32,6 @@ MemoryDenyWriteExecute=false
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
PrivateTmp=true
|
||||
PrivateUsers=true
|
||||
ProtectClock=true
|
||||
ProtectControlGroups=true
|
||||
ProtectHome=true
|
||||
|
||||
+1
-3
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -161,9 +161,9 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
|
||||
if err != nil {
|
||||
plog.Error("Range query", query.Expr, "failed with", err)
|
||||
result.Responses[query.RefId] = backend.DataResponse{Error: err}
|
||||
continue
|
||||
} else {
|
||||
response[RangeQueryType] = rangeResponse
|
||||
}
|
||||
response[RangeQueryType] = rangeResponse
|
||||
}
|
||||
|
||||
if query.InstantQuery {
|
||||
@@ -171,9 +171,9 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
|
||||
if err != nil {
|
||||
plog.Error("Instant query", query.Expr, "failed with", err)
|
||||
result.Responses[query.RefId] = backend.DataResponse{Error: err}
|
||||
continue
|
||||
} else {
|
||||
response[InstantQueryType] = instantResponse
|
||||
}
|
||||
response[InstantQueryType] = instantResponse
|
||||
}
|
||||
|
||||
if query.ExemplarQuery {
|
||||
@@ -334,37 +334,32 @@ func (s *Service) parseQuery(queryContext *backend.QueryDataRequest, dsInfo *Dat
|
||||
}
|
||||
|
||||
func parseResponse(value map[PrometheusQueryType]interface{}, query *PrometheusQuery) (data.Frames, error) {
|
||||
frames := data.Frames{}
|
||||
var (
|
||||
frames = data.Frames{}
|
||||
nextFrames = data.Frames{}
|
||||
)
|
||||
|
||||
for _, value := range value {
|
||||
matrix, ok := value.(model.Matrix)
|
||||
if ok {
|
||||
matrixFrames := matrixToDataFrames(matrix, query)
|
||||
frames = append(frames, matrixFrames...)
|
||||
// Zero out the slice to prevent data corruption.
|
||||
nextFrames = nextFrames[:0]
|
||||
|
||||
switch v := value.(type) {
|
||||
case model.Matrix:
|
||||
nextFrames = matrixToDataFrames(v, query, nextFrames)
|
||||
case model.Vector:
|
||||
nextFrames = vectorToDataFrames(v, query, nextFrames)
|
||||
case *model.Scalar:
|
||||
nextFrames = scalarToDataFrames(v, query, nextFrames)
|
||||
case []apiv1.ExemplarQueryResult:
|
||||
nextFrames = exemplarToDataFrames(v, query, nextFrames)
|
||||
default:
|
||||
plog.Error("Query", query.Expr, "returned unexpected result type", v)
|
||||
continue
|
||||
}
|
||||
|
||||
vector, ok := value.(model.Vector)
|
||||
if ok {
|
||||
vectorFrames := vectorToDataFrames(vector, query)
|
||||
frames = append(frames, vectorFrames...)
|
||||
continue
|
||||
}
|
||||
|
||||
scalar, ok := value.(*model.Scalar)
|
||||
if ok {
|
||||
scalarFrames := scalarToDataFrames(scalar, query)
|
||||
frames = append(frames, scalarFrames...)
|
||||
continue
|
||||
}
|
||||
|
||||
exemplar, ok := value.([]apiv1.ExemplarQueryResult)
|
||||
if ok {
|
||||
exemplarFrames := exemplarToDataFrames(exemplar, query)
|
||||
frames = append(frames, exemplarFrames...)
|
||||
continue
|
||||
}
|
||||
frames = append(frames, nextFrames...)
|
||||
}
|
||||
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
@@ -398,9 +393,7 @@ func calculateRateInterval(interval time.Duration, scrapeInterval string, interv
|
||||
return rateInterval
|
||||
}
|
||||
|
||||
func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery) data.Frames {
|
||||
frames := data.Frames{}
|
||||
|
||||
func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data.Frames) data.Frames {
|
||||
for _, v := range matrix {
|
||||
tags := make(map[string]string, len(v.Metric))
|
||||
for k, v := range v.Metric {
|
||||
@@ -424,61 +417,58 @@ func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery) data.Frames
|
||||
valueField.Config = &data.FieldConfig{DisplayNameFromDS: name}
|
||||
valueField.Labels = tags
|
||||
|
||||
frame := data.NewFrame(name, timeField, valueField)
|
||||
frame.Meta = &data.FrameMeta{
|
||||
Custom: map[string]string{
|
||||
"resultType": "matrix",
|
||||
},
|
||||
}
|
||||
frames = append(frames, frame)
|
||||
frames = append(frames, newDataFrame(name, "matrix", timeField, valueField))
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
func scalarToDataFrames(scalar *model.Scalar, query *PrometheusQuery) data.Frames {
|
||||
func scalarToDataFrames(scalar *model.Scalar, query *PrometheusQuery, frames data.Frames) data.Frames {
|
||||
timeVector := []time.Time{time.Unix(scalar.Timestamp.Unix(), 0).UTC()}
|
||||
values := []float64{float64(scalar.Value)}
|
||||
name := fmt.Sprintf("%g", values[0])
|
||||
frame := data.NewFrame(name,
|
||||
data.NewField("Time", nil, timeVector),
|
||||
data.NewField("Value", nil, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name}))
|
||||
frame.Meta = &data.FrameMeta{
|
||||
Custom: map[string]string{
|
||||
"resultType": "scalar",
|
||||
},
|
||||
}
|
||||
|
||||
frames := data.Frames{frame}
|
||||
return frames
|
||||
return append(
|
||||
frames,
|
||||
newDataFrame(
|
||||
name,
|
||||
"scalar",
|
||||
data.NewField("Time", nil, timeVector),
|
||||
data.NewField("Value", nil, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func vectorToDataFrames(vector model.Vector, query *PrometheusQuery) data.Frames {
|
||||
frames := data.Frames{}
|
||||
func vectorToDataFrames(vector model.Vector, query *PrometheusQuery, frames data.Frames) data.Frames {
|
||||
for _, v := range vector {
|
||||
name := formatLegend(v.Metric, query)
|
||||
tags := make(map[string]string, len(v.Metric))
|
||||
timeVector := []time.Time{time.Unix(v.Timestamp.Unix(), 0).UTC()}
|
||||
values := []float64{float64(v.Value)}
|
||||
|
||||
for k, v := range v.Metric {
|
||||
tags[string(k)] = string(v)
|
||||
}
|
||||
frame := data.NewFrame(name,
|
||||
data.NewField("Time", nil, timeVector),
|
||||
data.NewField("Value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name}))
|
||||
frame.Meta = &data.FrameMeta{
|
||||
Custom: map[string]string{
|
||||
"resultType": "vector",
|
||||
},
|
||||
}
|
||||
frames = append(frames, frame)
|
||||
|
||||
frames = append(
|
||||
frames,
|
||||
newDataFrame(
|
||||
name,
|
||||
"vector",
|
||||
data.NewField("Time", nil, timeVector),
|
||||
data.NewField("Value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *PrometheusQuery) data.Frames {
|
||||
frames := data.Frames{}
|
||||
events := make([]ExemplarEvent, 0)
|
||||
func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *PrometheusQuery, frames data.Frames) data.Frames {
|
||||
// TODO: this preallocation is very naive.
|
||||
// We should figure out a better approximation here.
|
||||
events := make([]ExemplarEvent, 0, len(response)*2)
|
||||
|
||||
for _, exemplarData := range response {
|
||||
for _, exemplar := range exemplarData.Exemplars {
|
||||
event := ExemplarEvent{}
|
||||
@@ -486,9 +476,11 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu
|
||||
event.Time = exemplarTime
|
||||
event.Value = float64(exemplar.Value)
|
||||
event.Labels = make(map[string]string)
|
||||
|
||||
for label, value := range exemplar.Labels {
|
||||
event.Labels[string(label)] = string(value)
|
||||
}
|
||||
|
||||
for seriesLabel, seriesValue := range exemplarData.SeriesLabels {
|
||||
event.Labels[string(seriesLabel)] = string(seriesValue)
|
||||
}
|
||||
@@ -497,11 +489,11 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu
|
||||
}
|
||||
}
|
||||
|
||||
//Sampling of exemplars
|
||||
// Sampling of exemplars
|
||||
bucketedExemplars := make(map[string][]ExemplarEvent)
|
||||
values := make([]float64, 0)
|
||||
values := make([]float64, 0, len(events))
|
||||
|
||||
//Create bucketed exemplars based on aligned timestamp
|
||||
// Create bucketed exemplars based on aligned timestamp
|
||||
for _, event := range events {
|
||||
alignedTs := fmt.Sprintf("%.0f", math.Floor(float64(event.Time.Unix())/query.Step.Seconds())*query.Step.Seconds())
|
||||
_, ok := bucketedExemplars[alignedTs]
|
||||
@@ -513,18 +505,18 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu
|
||||
values = append(values, event.Value)
|
||||
}
|
||||
|
||||
//Calculate standard deviation
|
||||
// Calculate standard deviation
|
||||
standardDeviation := deviation(values)
|
||||
|
||||
//Create slice with all of the bucketed exemplars
|
||||
// Create slice with all of the bucketed exemplars
|
||||
sampledBuckets := make([]string, len(bucketedExemplars))
|
||||
for bucketTimes := range bucketedExemplars {
|
||||
sampledBuckets = append(sampledBuckets, bucketTimes)
|
||||
}
|
||||
sort.Strings(sampledBuckets)
|
||||
|
||||
//Sample exemplars based ona value, so we are not showing too many of them
|
||||
sampleExemplars := make([]ExemplarEvent, 0)
|
||||
// Sample exemplars based ona value, so we are not showing too many of them
|
||||
sampleExemplars := make([]ExemplarEvent, 0, len(sampledBuckets))
|
||||
for _, bucket := range sampledBuckets {
|
||||
exemplarsInBucket := bucketedExemplars[bucket]
|
||||
if len(exemplarsInBucket) == 1 {
|
||||
@@ -561,13 +553,15 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu
|
||||
}
|
||||
|
||||
// Create DF from sampled exemplars
|
||||
timeVector := make([]time.Time, 0, len(sampleExemplars))
|
||||
valuesVector := make([]float64, 0, len(sampleExemplars))
|
||||
timeField := data.NewFieldFromFieldType(data.FieldTypeTime, len(sampleExemplars))
|
||||
timeField.Name = "Time"
|
||||
valueField := data.NewFieldFromFieldType(data.FieldTypeFloat64, len(sampleExemplars))
|
||||
valueField.Name = "Value"
|
||||
labelsVector := make(map[string][]string, len(sampleExemplars))
|
||||
|
||||
for _, exemplar := range sampleExemplars {
|
||||
timeVector = append(timeVector, exemplar.Time)
|
||||
valuesVector = append(valuesVector, exemplar.Value)
|
||||
for i, exemplar := range sampleExemplars {
|
||||
timeField.Set(i, exemplar.Time)
|
||||
valueField.Set(i, exemplar.Value)
|
||||
|
||||
for label, value := range exemplar.Labels {
|
||||
if labelsVector[label] == nil {
|
||||
@@ -578,22 +572,13 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu
|
||||
}
|
||||
}
|
||||
|
||||
frame := data.NewFrame("exemplar",
|
||||
data.NewField("Time", nil, timeVector),
|
||||
data.NewField("Value", nil, valuesVector))
|
||||
|
||||
dataFields := make([]*data.Field, 0, len(labelsVector)+2)
|
||||
dataFields = append(dataFields, timeField, valueField)
|
||||
for label, vector := range labelsVector {
|
||||
frame.Fields = append(frame.Fields, data.NewField(label, nil, vector))
|
||||
dataFields = append(dataFields, data.NewField(label, nil, vector))
|
||||
}
|
||||
|
||||
frame.Meta = &data.FrameMeta{
|
||||
Custom: map[string]PrometheusQueryType{
|
||||
"resultType": "exemplar",
|
||||
},
|
||||
}
|
||||
|
||||
frames = append(frames, frame)
|
||||
return frames
|
||||
return append(frames, newDataFrame("exemplar", "exemplar", dataFields...))
|
||||
}
|
||||
|
||||
func deviation(values []float64) float64 {
|
||||
@@ -608,3 +593,14 @@ func deviation(values []float64) float64 {
|
||||
}
|
||||
return math.Sqrt(sd / (valuesLen - 1))
|
||||
}
|
||||
|
||||
func newDataFrame(name string, typ string, fields ...*data.Field) *data.Frame {
|
||||
frame := data.NewFrame(name, fields...)
|
||||
frame.Meta = &data.FrameMeta{
|
||||
Custom: map[string]string{
|
||||
"resultType": typ,
|
||||
},
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
@@ -260,5 +260,6 @@ function getBorderTopStyles(theme: GrafanaTheme2) {
|
||||
return css({
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
padding: `${theme.spacing(2)}`,
|
||||
display: 'flex',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface RichHistorySettingsProps {
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
container: css`
|
||||
padding-left: ${theme.spacing.sm};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
.space-between {
|
||||
margin-bottom: ${theme.spacing.lg};
|
||||
|
||||
@@ -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} />
|
||||
<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 }}> </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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -813,7 +813,7 @@ export class PrometheusDatasource extends DataSourceWithBackend<PromQuery, PromO
|
||||
async areExemplarsAvailable() {
|
||||
try {
|
||||
const res = await this.metadataRequest('/api/v1/query_exemplars', { query: 'test' });
|
||||
if (res.statusText === 'OK') {
|
||||
if (res.data.status === 'success') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -209,7 +209,7 @@ const backendSrvWithPrometheus = {
|
||||
if (uid === 'prom') {
|
||||
return {
|
||||
query() {
|
||||
return of({ data: [totalsPromMetric, secondsPromMetric] });
|
||||
return of({ data: [totalsPromMetric, secondsPromMetric, failedPromMetric] });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -280,6 +280,19 @@ const secondsPromMetric = new MutableDataFrame({
|
||||
],
|
||||
});
|
||||
|
||||
const failedPromMetric = new MutableDataFrame({
|
||||
refId: 'traces_service_graph_request_failed_total',
|
||||
fields: [
|
||||
{ name: 'Time', values: [1628169788000, 1628169788000] },
|
||||
{ name: 'client', values: ['app', 'lb'] },
|
||||
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
|
||||
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
|
||||
{ name: 'server', values: ['db', 'app'] },
|
||||
{ name: 'tempo_config', values: ['default', 'default'] },
|
||||
{ name: 'Value #traces_service_graph_request_failed_total', values: [2, 15] },
|
||||
],
|
||||
});
|
||||
|
||||
const mockInvalidJson = {
|
||||
batches: [
|
||||
{
|
||||
|
||||
@@ -292,9 +292,6 @@ function serviceMapQuery(request: DataQueryRequest<TempoQuery>, datasourceUid: s
|
||||
data: mapPromMetricsToServiceMap(responses, request.range),
|
||||
state: LoadingState.Done,
|
||||
};
|
||||
}),
|
||||
catchError((error) => {
|
||||
return of({ error: { message: error.message }, data: [] });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,16 +64,21 @@ describe('mapPromMetricsToServiceMap', () => {
|
||||
from: dateTime('2000-01-01T00:00:00'),
|
||||
to: dateTime('2000-01-01T00:01:00'),
|
||||
};
|
||||
const [nodes, edges] = mapPromMetricsToServiceMap([{ data: [totalsPromMetric, secondsPromMetric] }], {
|
||||
...range,
|
||||
raw: range,
|
||||
});
|
||||
const [nodes, edges] = mapPromMetricsToServiceMap(
|
||||
[{ data: [totalsPromMetric, secondsPromMetric, failedPromMetric] }],
|
||||
{
|
||||
...range,
|
||||
raw: range,
|
||||
}
|
||||
);
|
||||
|
||||
expect(nodes.fields).toMatchObject([
|
||||
{ name: 'id', values: new ArrayVector(['db', 'app', 'lb']) },
|
||||
{ name: 'title', values: new ArrayVector(['db', 'app', 'lb']) },
|
||||
{ name: 'mainStat', values: new ArrayVector([1000, 2000, NaN]) },
|
||||
{ name: 'secondaryStat', values: new ArrayVector([0.17, 0.33, NaN]) },
|
||||
{ name: 'arc__success', values: new ArrayVector([0.8, 0.25, 1]) },
|
||||
{ name: 'arc__failed', values: new ArrayVector([0.2, 0.75, 0]) },
|
||||
]);
|
||||
expect(edges.fields).toMatchObject([
|
||||
{ name: 'id', values: new ArrayVector(['app_db', 'lb_app']) },
|
||||
@@ -134,3 +139,16 @@ const secondsPromMetric = new MutableDataFrame({
|
||||
{ name: 'Value #traces_service_graph_request_server_seconds_sum', values: [10, 40] },
|
||||
],
|
||||
});
|
||||
|
||||
const failedPromMetric = new MutableDataFrame({
|
||||
refId: 'traces_service_graph_request_failed_total',
|
||||
fields: [
|
||||
{ name: 'Time', values: [1628169788000, 1628169788000] },
|
||||
{ name: 'client', values: ['app', 'lb'] },
|
||||
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
|
||||
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
|
||||
{ name: 'server', values: ['db', 'app'] },
|
||||
{ name: 'tempo_config', values: ['default', 'default'] },
|
||||
{ name: 'Value #traces_service_graph_request_failed_total', values: [2, 15] },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { groupBy } from 'lodash';
|
||||
import {
|
||||
DataFrame,
|
||||
DataFrameView,
|
||||
DataQueryResponse,
|
||||
FieldColorModeId,
|
||||
FieldDTO,
|
||||
MutableDataFrame,
|
||||
NodeGraphDataFrameFieldNames as Fields,
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../core/utils/tracing';
|
||||
|
||||
/**
|
||||
* Row in a trace dataFrame
|
||||
*/
|
||||
interface Row {
|
||||
traceID: string;
|
||||
spanID: string;
|
||||
@@ -129,10 +132,12 @@ function findTraceDuration(view: DataFrameView<Row>): number {
|
||||
|
||||
const secondsMetric = 'traces_service_graph_request_server_seconds_sum';
|
||||
const totalsMetric = 'traces_service_graph_request_total';
|
||||
const failedMetric = 'traces_service_graph_request_failed_total';
|
||||
|
||||
export const serviceMapMetrics = [
|
||||
secondsMetric,
|
||||
totalsMetric,
|
||||
failedMetric,
|
||||
// We don't show histogram in node graph at the moment but we could later add that into a node context menu.
|
||||
// 'traces_service_graph_request_seconds_bucket',
|
||||
// 'traces_service_graph_request_seconds_count',
|
||||
@@ -147,14 +152,15 @@ export const serviceMapMetrics = [
|
||||
* @param range
|
||||
*/
|
||||
export function mapPromMetricsToServiceMap(responses: DataQueryResponse[], range: TimeRange): [DataFrame, DataFrame] {
|
||||
const [totalsDFView, secondsDFView] = getMetricFrames(responses);
|
||||
const frames = getMetricFrames(responses);
|
||||
|
||||
// First just collect data from the metrics into a map with nodes and edges as keys
|
||||
const nodesMap: Record<string, any> = {};
|
||||
const edgesMap: Record<string, any> = {};
|
||||
const nodesMap: Record<string, ServiceMapStatistics> = {};
|
||||
const edgesMap: Record<string, EdgeObject> = {};
|
||||
// At this moment we don't have any error/success or other counts so we just use these 2
|
||||
collectMetricData(totalsDFView, 'total', totalsMetric, nodesMap, edgesMap);
|
||||
collectMetricData(secondsDFView, 'seconds', secondsMetric, nodesMap, edgesMap);
|
||||
collectMetricData(frames[totalsMetric], 'total', totalsMetric, nodesMap, edgesMap);
|
||||
collectMetricData(frames[secondsMetric], 'seconds', secondsMetric, nodesMap, edgesMap);
|
||||
collectMetricData(frames[failedMetric], 'failed', failedMetric, nodesMap, edgesMap);
|
||||
|
||||
return convertToDataFrames(nodesMap, edgesMap, range);
|
||||
}
|
||||
@@ -172,6 +178,14 @@ function createServiceMapDataFrames() {
|
||||
name: Fields.secondaryStat,
|
||||
config: { unit: 'r/sec', displayName: 'Requests per second' },
|
||||
},
|
||||
{
|
||||
name: Fields.arc + 'success',
|
||||
config: { displayName: 'Success', color: { fixedColor: 'green', mode: FieldColorModeId.Fixed } },
|
||||
},
|
||||
{
|
||||
name: Fields.arc + 'failed',
|
||||
config: { displayName: 'Failed', color: { fixedColor: 'red', mode: FieldColorModeId.Fixed } },
|
||||
},
|
||||
]);
|
||||
const edges = createDF('Edges', [
|
||||
{ name: Fields.id },
|
||||
@@ -184,13 +198,29 @@ function createServiceMapDataFrames() {
|
||||
return [nodes, edges];
|
||||
}
|
||||
|
||||
function getMetricFrames(responses: DataQueryResponse[]) {
|
||||
const responsesMap = groupBy(responses[0].data, (data) => data.refId);
|
||||
const totalsDFView = new DataFrameView(responsesMap[totalsMetric][0]);
|
||||
const secondsDFView = new DataFrameView(responsesMap[secondsMetric][0]);
|
||||
return [totalsDFView, secondsDFView];
|
||||
/**
|
||||
* Group frames from response based on ref id which is set the same as the metric name so we know which metric is where
|
||||
* and also put it into DataFrameView so it's easier to work with.
|
||||
* @param responses
|
||||
*/
|
||||
function getMetricFrames(responses: DataQueryResponse[]): Record<string, DataFrameView> {
|
||||
return responses[0].data.reduce<Record<string, DataFrameView>>((acc, frame) => {
|
||||
acc[frame.refId] = new DataFrameView(frame);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
type ServiceMapStatistics = {
|
||||
total?: number;
|
||||
seconds?: number;
|
||||
failed?: number;
|
||||
};
|
||||
|
||||
type EdgeObject = ServiceMapStatistics & {
|
||||
source: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collect data from a metric into a map of nodes and edges. The metric data is modeled as counts of metric per edge
|
||||
* which is a pair of client-server nodes. This means we convert each row of the metric 1-1 to edges and than we assign
|
||||
@@ -203,12 +233,16 @@ function getMetricFrames(responses: DataQueryResponse[]) {
|
||||
* @param edgesMap
|
||||
*/
|
||||
function collectMetricData(
|
||||
frame: DataFrameView,
|
||||
stat: 'total' | 'seconds',
|
||||
frame: DataFrameView | undefined,
|
||||
stat: keyof ServiceMapStatistics,
|
||||
metric: string,
|
||||
nodesMap: Record<string, any>,
|
||||
edgesMap: Record<string, any>
|
||||
nodesMap: Record<string, ServiceMapStatistics>,
|
||||
edgesMap: Record<string, EdgeObject>
|
||||
) {
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The name of the value column is in this format
|
||||
// TODO figure out if it can be changed
|
||||
const valueName = `Value #${metric}`;
|
||||
@@ -218,24 +252,32 @@ function collectMetricData(
|
||||
const edgeId = `${row.client}_${row.server}`;
|
||||
|
||||
if (!edgesMap[edgeId]) {
|
||||
// Create edge as it does not exist yet
|
||||
edgesMap[edgeId] = {
|
||||
target: row.server,
|
||||
source: row.client,
|
||||
[stat]: row[valueName],
|
||||
};
|
||||
} else {
|
||||
// Add stat to edge
|
||||
// We are adding the values if exists but that should not happen in general as there should be single row for
|
||||
// an edge.
|
||||
edgesMap[edgeId][stat] = (edgesMap[edgeId][stat] || 0) + row[valueName];
|
||||
}
|
||||
|
||||
if (!nodesMap[row.server]) {
|
||||
// Create node for server
|
||||
nodesMap[row.server] = {
|
||||
[stat]: row[valueName],
|
||||
};
|
||||
} else {
|
||||
// Add stat to server node. Sum up values if there are multiple edges targeting this server node.
|
||||
nodesMap[row.server][stat] = (nodesMap[row.server][stat] || 0) + row[valueName];
|
||||
}
|
||||
|
||||
if (!nodesMap[row.client]) {
|
||||
// Create the client node but don't add the stat as edge stats are attributed to the server node. This means for
|
||||
// example that the number of requests in a node show how many requests it handled not how many it generated.
|
||||
nodesMap[row.client] = {
|
||||
[stat]: 0,
|
||||
};
|
||||
@@ -244,8 +286,8 @@ function collectMetricData(
|
||||
}
|
||||
|
||||
function convertToDataFrames(
|
||||
nodesMap: Record<string, any>,
|
||||
edgesMap: Record<string, any>,
|
||||
nodesMap: Record<string, ServiceMapStatistics>,
|
||||
edgesMap: Record<string, EdgeObject>,
|
||||
range: TimeRange
|
||||
): [DataFrame, DataFrame] {
|
||||
const rangeMs = range.to.valueOf() - range.from.valueOf();
|
||||
@@ -253,22 +295,24 @@ function convertToDataFrames(
|
||||
for (const nodeId of Object.keys(nodesMap)) {
|
||||
const node = nodesMap[nodeId];
|
||||
nodes.add({
|
||||
id: nodeId,
|
||||
title: nodeId,
|
||||
[Fields.id]: nodeId,
|
||||
[Fields.title]: nodeId,
|
||||
// NaN will not be shown in the node graph. This happens for a root client node which did not process
|
||||
// any requests itself.
|
||||
mainStat: node.total ? (node.seconds / node.total) * 1000 : Number.NaN, // Average response time
|
||||
secondaryStat: node.total ? Math.round((node.total / (rangeMs / 1000)) * 100) / 100 : Number.NaN, // Request per second (to 2 decimals)
|
||||
[Fields.mainStat]: node.total ? (node.seconds! / node.total) * 1000 : Number.NaN, // Average response time
|
||||
[Fields.secondaryStat]: node.total ? Math.round((node.total / (rangeMs / 1000)) * 100) / 100 : Number.NaN, // Request per second (to 2 decimals)
|
||||
[Fields.arc + 'success']: node.total ? (node.total - (node.failed || 0)) / node.total : 1,
|
||||
[Fields.arc + 'failed']: node.total ? (node.failed || 0) / node.total : 0,
|
||||
});
|
||||
}
|
||||
for (const edgeId of Object.keys(edgesMap)) {
|
||||
const edge = edgesMap[edgeId];
|
||||
edges.add({
|
||||
id: edgeId,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
mainStat: edge.total, // Requests
|
||||
secondaryStat: edge.total ? (edge.seconds / edge.total) * 1000 : Number.NaN, // Average response time
|
||||
[Fields.id]: edgeId,
|
||||
[Fields.source]: edge.source,
|
||||
[Fields.target]: edge.target,
|
||||
[Fields.mainStat]: edge.total, // Requests
|
||||
[Fields.secondaryStat]: edge.total ? (edge.seconds! / edge.total) * 1000 : Number.NaN, // Average response time
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user