mirror of
https://github.com/grafana/grafana.git
synced 2024-11-22 08:56:43 -06:00
Jaeger: Search traces (#32805)
* WIP: Search jaeger traces * Add more customizable query params * Fix failing test * Fix e2e test * Add tags input field * Minor changes * Fix tests * Add docs * Make sure linking is working * Add tests to datasource.ts * Apply suggestions from code review Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * Update Jaeger doc * UI updates * Address review comments * Add new screenshots to docs * Update placeholder text for tags Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
This commit is contained in:
parent
f4750fb3c8
commit
1a504ce673
@ -33,23 +33,26 @@ This is a configuration for the [trace to logs feature]({{< relref "../explore/t
|
||||
- **Data source -** Target data source.
|
||||
- **Tags -** The tags that will be used in the Loki query. Default is `'cluster', 'hostname', 'namespace', 'pod'`.
|
||||
|
||||
![Trace to logs settings](/img/docs/explore/trace-to-logs-settings-7-4.png "Screenshot of the trace to logs settings")
|
||||
![Trace to logs settings](/img/docs/explore/trace-to-logs-settings-7-4.png 'Screenshot of the trace to logs settings')
|
||||
|
||||
## Query traces
|
||||
|
||||
You can query and display traces from Jaeger via [Explore]({{< relref "../explore/_index.md" >}}).
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v70/jaeger-query-editor.png" class="docs-image--no-shadow" caption="Screenshot of the Jaeger query editor" >}}
|
||||
{{< docs-imagebox img="/img/docs/explore/jaeger-search-form.png" class="docs-image--no-shadow" caption="Screenshot of the Jaeger query editor" >}}
|
||||
|
||||
The Jaeger query editor allows you to query by trace ID directly or selecting a trace from trace selector. To query by trace ID, insert the ID into the text input.
|
||||
You can query by trace ID or use the search form to find traces. To query by trace ID, select the TraceID from the Query type selector and insert the ID into the text input.
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v70/jaeger-query-editor-open.png" class="docs-image--no-shadow" caption="Screenshot of the Jaeger query editor with trace selector expanded" >}}
|
||||
{{< docs-imagebox img="/img/docs/explore/jaeger-trace-id.png" class="docs-image--no-shadow" caption="Screenshot of the Jaeger query editor with trace ID selected" >}}
|
||||
|
||||
Use the trace selector to pick particular trace from all traces logged in the time range you have selected in Explore. The trace selector has three levels of nesting:
|
||||
To perform a search, set the query type selector to Search, then use the following fields to find traces:
|
||||
|
||||
1. The service you are interested in.
|
||||
1. Particular operation is part of the selected service.
|
||||
1. Specific trace in which the selected operation occurred, represented by the root operation name and trace duration.
|
||||
- Service - Returns a list of services.
|
||||
- Operation - Field gets populated once you select a service. It then lists the operations related to the selected service. Select `All` option to query all operations.
|
||||
- Tags - Use values in the [logfmt](https://brandur.org/logfmt) format. For example `error=true db.statement="select * from User"`.
|
||||
- Min Duration - Filter all traces with a duration higher than the set value. Possible values are `1.2s, 100ms, 500us`.
|
||||
- Max Duration - Filter all traces with a duration lower than the set value. Possible values are `1.2s, 100ms, 500us`.
|
||||
- Limit - Limits the number of traces returned.
|
||||
|
||||
## Linking Trace ID from logs
|
||||
|
||||
@ -57,7 +60,7 @@ You can link to Jaeger trace from logs in Loki by configuring a derived field wi
|
||||
|
||||
## Configure the data source with provisioning
|
||||
|
||||
You can set up the data source via configuration files with Grafana’s provisioning system. Refer to [provisioning docs page]({{< relref "../administration/provisioning/#datasources" >}}) for information on various settings and how it works.
|
||||
You can set up the data source via configuration files with Grafana's provisioning system. Refer to [provisioning docs page]({{< relref "../administration/provisioning/#datasources" >}}) for more information on configuring various settings.
|
||||
|
||||
Here is an example with basic auth and trace-to-logs field.
|
||||
|
||||
@ -86,3 +89,4 @@ datasources:
|
||||
- pod
|
||||
secureJsonData:
|
||||
basicAuthPassword: my_password
|
||||
```
|
||||
|
@ -19,7 +19,7 @@ describe('Trace view', () => {
|
||||
e2e().contains('gdev-jaeger').scrollIntoView().should('be.visible').click();
|
||||
});
|
||||
|
||||
e2e.components.QueryField.container().should('be.visible').type('long-trace');
|
||||
e2e.components.DataSource.Jaeger.traceIDInput().should('be.visible').type('long-trace');
|
||||
|
||||
e2e.components.RefreshPicker.runButton().should('be.visible').click();
|
||||
|
||||
|
@ -102,6 +102,7 @@
|
||||
"@types/jest": "26.0.15",
|
||||
"@types/jquery": "3.3.38",
|
||||
"@types/lodash": "4.14.149",
|
||||
"@types/logfmt": "^1.2.1",
|
||||
"@types/lru-cache": "^5.1.0",
|
||||
"@types/moment-timezone": "0.5.13",
|
||||
"@types/mousetrap": "1.6.3",
|
||||
@ -259,6 +260,7 @@
|
||||
"json-source-map": "0.6.1",
|
||||
"jsurl": "^0.1.5",
|
||||
"lodash": "4.17.21",
|
||||
"logfmt": "^1.3.2",
|
||||
"lru-cache": "^5.1.1",
|
||||
"md5": "^2.2.1",
|
||||
"memoize-one": "5.1.1",
|
||||
|
@ -15,6 +15,9 @@ export const Components = {
|
||||
startValue: 'TestData start value',
|
||||
},
|
||||
},
|
||||
Jaeger: {
|
||||
traceIDInput: 'Trace ID',
|
||||
},
|
||||
},
|
||||
Menu: {
|
||||
MenuComponent: (title: string) => `${title} menu`,
|
||||
|
@ -1,139 +0,0 @@
|
||||
import React from 'react';
|
||||
import { JaegerQueryField } from './QueryField';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import { JaegerDatasource, JaegerQuery } from './datasource';
|
||||
import { ButtonCascader } from '@grafana/ui';
|
||||
|
||||
describe('JaegerQueryField', function () {
|
||||
it('shows empty value if no services returned', function () {
|
||||
const wrapper = shallow(
|
||||
<JaegerQueryField
|
||||
history={[]}
|
||||
datasource={makeDatasourceMock({})}
|
||||
query={{ query: '1234' } as JaegerQuery}
|
||||
onRunQuery={() => {}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(ButtonCascader).props().options[0].label).toBe('No traces found');
|
||||
});
|
||||
|
||||
it('uses URL encoded service name in metadataRequest request', async function () {
|
||||
const wrapper = mount(
|
||||
<JaegerQueryField
|
||||
history={[]}
|
||||
datasource={makeDatasourceMock({
|
||||
'service/test': {
|
||||
op1: [
|
||||
{
|
||||
traceID: '12345',
|
||||
spans: [
|
||||
{
|
||||
spanID: 's2',
|
||||
operationName: 'nonRootOp',
|
||||
references: [{ refType: 'CHILD_OF', traceID: '12345', spanID: 's1' }],
|
||||
duration: 10,
|
||||
},
|
||||
{
|
||||
operationName: 'rootOp',
|
||||
spanID: 's1',
|
||||
references: [],
|
||||
duration: 99,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
query={{ query: '1234' } as JaegerQuery}
|
||||
onRunQuery={() => {}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Simulating selection options. We need this as the function depends on the intermediate state of the component
|
||||
await wrapper.find(ButtonCascader)!.props().loadData!([{ value: 'service/test', label: 'service/test' }]);
|
||||
|
||||
wrapper.update();
|
||||
expect(wrapper.find(ButtonCascader).props().options[0].label).toEqual('service/test');
|
||||
expect(wrapper.find(ButtonCascader).props().options[0].value).toEqual('service/test');
|
||||
expect(wrapper.find(ButtonCascader).props().options![0].children![1]).toEqual({
|
||||
isLeaf: false,
|
||||
label: 'op1',
|
||||
value: 'op1',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows root span as 3rd level in cascader', async function () {
|
||||
const wrapper = mount(
|
||||
<JaegerQueryField
|
||||
history={[]}
|
||||
datasource={makeDatasourceMock({
|
||||
service1: {
|
||||
op1: [
|
||||
{
|
||||
traceID: '12345',
|
||||
spans: [
|
||||
{
|
||||
spanID: 's2',
|
||||
operationName: 'nonRootOp',
|
||||
references: [{ refType: 'CHILD_OF', traceID: '12345', spanID: 's1' }],
|
||||
duration: 10,
|
||||
},
|
||||
{
|
||||
operationName: 'rootOp',
|
||||
spanID: 's1',
|
||||
references: [],
|
||||
duration: 99,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
query={{ query: '1234' } as JaegerQuery}
|
||||
onRunQuery={() => {}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Simulating selection options. We need this as the function depends on the intermediate state of the component
|
||||
await wrapper.find(ButtonCascader)!.props().loadData!([{ value: 'service1', label: 'service1' }]);
|
||||
|
||||
await wrapper.find(ButtonCascader)!.props().loadData!([
|
||||
{ value: 'service1', label: 'service1' },
|
||||
{ value: 'op1', label: 'op1' },
|
||||
]);
|
||||
|
||||
wrapper.update();
|
||||
expect(wrapper.find(ButtonCascader)!.props().options![0].children![1].children![0]).toEqual({
|
||||
label: 'rootOp [0.099 ms]',
|
||||
value: '12345',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function makeDatasourceMock(data: { [service: string]: { [operation: string]: any } }): JaegerDatasource {
|
||||
return {
|
||||
metadataRequest(url: string, params: Record<string, any>) {
|
||||
if (url.match(/\/services$/)) {
|
||||
return Promise.resolve(Object.keys(data));
|
||||
}
|
||||
let match = url.match(/\/services\/(.*)\/operations/);
|
||||
if (match) {
|
||||
const decodedService = decodeURIComponent(match[1]);
|
||||
expect(decodedService).toBe(Object.keys(data)[0]);
|
||||
return Promise.resolve(Object.keys(data[decodedService]));
|
||||
}
|
||||
|
||||
if (url.match(/\/traces?/)) {
|
||||
return Promise.resolve(data[params.service][params.operation]);
|
||||
}
|
||||
throw new Error(`Unexpected url: ${url}`);
|
||||
},
|
||||
|
||||
getTimeRange(): { start: number; end: number } {
|
||||
return { start: 1, end: 100 };
|
||||
},
|
||||
} as any;
|
||||
}
|
@ -1,241 +0,0 @@
|
||||
import { ExploreQueryFieldProps } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { ButtonCascader, CascaderOption } from '@grafana/ui';
|
||||
import React from 'react';
|
||||
import { JaegerDatasource, JaegerQuery } from './datasource';
|
||||
import { Span, TraceResponse } from './types';
|
||||
import { dispatch } from 'app/store/store';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
|
||||
const ALL_OPERATIONS_KEY = '__ALL__';
|
||||
const NO_TRACES_KEY = '__NO_TRACES__';
|
||||
|
||||
type Props = ExploreQueryFieldProps<JaegerDatasource, JaegerQuery>;
|
||||
interface State {
|
||||
serviceOptions: CascaderOption[];
|
||||
}
|
||||
|
||||
function findRootSpan(spans: Span[]): Span | undefined {
|
||||
return spans.find((s) => !s.references?.length);
|
||||
}
|
||||
|
||||
function getLabelFromTrace(trace: TraceResponse): string {
|
||||
const rootSpan = findRootSpan(trace.spans);
|
||||
if (rootSpan) {
|
||||
return `${rootSpan.operationName} [${rootSpan.duration / 1000} ms]`;
|
||||
}
|
||||
return trace.traceID;
|
||||
}
|
||||
|
||||
export class JaegerQueryField extends React.PureComponent<Props, State> {
|
||||
private _isMounted = false;
|
||||
|
||||
constructor(props: Props, context: React.Context<any>) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
serviceOptions: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
// We should probably call this periodically to get new services after mount.
|
||||
this.getServices();
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this._isMounted = false;
|
||||
}
|
||||
|
||||
async getServices() {
|
||||
const url = '/api/services';
|
||||
const { datasource } = this.props;
|
||||
try {
|
||||
const services: string[] | null = await datasource.metadataRequest(url);
|
||||
if (!this._isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (services) {
|
||||
const serviceOptions: CascaderOption[] = services.sort().map((service) => ({
|
||||
label: service,
|
||||
value: service,
|
||||
isLeaf: false,
|
||||
}));
|
||||
this.setState({ serviceOptions });
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(notifyApp(createErrorNotification('Failed to load services from Jaeger', error)));
|
||||
}
|
||||
}
|
||||
|
||||
onLoadOptions = async (selectedOptions: CascaderOption[]) => {
|
||||
const service = selectedOptions[0].value;
|
||||
if (selectedOptions.length === 1) {
|
||||
// Load operations
|
||||
const operations: string[] = await this.findOperations(service);
|
||||
if (!this._isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allOperationsOption: CascaderOption = {
|
||||
label: '[ALL]',
|
||||
value: ALL_OPERATIONS_KEY,
|
||||
};
|
||||
const operationOptions: CascaderOption[] = [
|
||||
allOperationsOption,
|
||||
...operations.sort().map((operation) => ({
|
||||
label: operation,
|
||||
value: operation,
|
||||
isLeaf: false,
|
||||
})),
|
||||
];
|
||||
this.setState((state) => {
|
||||
const serviceOptions = state.serviceOptions.map((serviceOption) => {
|
||||
if (serviceOption.value === service) {
|
||||
return {
|
||||
...serviceOption,
|
||||
children: operationOptions,
|
||||
};
|
||||
}
|
||||
return serviceOption;
|
||||
});
|
||||
return { serviceOptions };
|
||||
});
|
||||
} else if (selectedOptions.length === 2) {
|
||||
// Load traces
|
||||
const operationValue = selectedOptions[1].value;
|
||||
const operation = operationValue === ALL_OPERATIONS_KEY ? '' : operationValue;
|
||||
const traces: any[] = await this.findTraces(service, operation);
|
||||
if (!this._isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
let traceOptions: CascaderOption[] = traces.map((trace) => ({
|
||||
label: getLabelFromTrace(trace),
|
||||
value: trace.traceID,
|
||||
}));
|
||||
if (traceOptions.length === 0) {
|
||||
traceOptions = [
|
||||
{
|
||||
label: '[No traces in time range]',
|
||||
value: NO_TRACES_KEY,
|
||||
},
|
||||
];
|
||||
}
|
||||
this.setState((state) => {
|
||||
// Place new traces into the correct service/operation sub-tree
|
||||
const serviceOptions = state.serviceOptions.map((serviceOption) => {
|
||||
if (serviceOption.value === service && serviceOption.children) {
|
||||
const operationOptions = serviceOption.children.map((operationOption) => {
|
||||
if (operationOption.value === operationValue) {
|
||||
return {
|
||||
...operationOption,
|
||||
children: traceOptions,
|
||||
};
|
||||
}
|
||||
return operationOption;
|
||||
});
|
||||
return {
|
||||
...serviceOption,
|
||||
children: operationOptions,
|
||||
};
|
||||
}
|
||||
return serviceOption;
|
||||
});
|
||||
return { serviceOptions };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
findOperations = async (service: string) => {
|
||||
const { datasource } = this.props;
|
||||
const url = `/api/services/${encodeURIComponent(service)}/operations`;
|
||||
try {
|
||||
return await datasource.metadataRequest(url);
|
||||
} catch (error) {
|
||||
dispatch(notifyApp(createErrorNotification('Failed to load operations from Jaeger', error)));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
findTraces = async (service: string, operation?: string) => {
|
||||
const { datasource } = this.props;
|
||||
const { start, end } = datasource.getTimeRange();
|
||||
|
||||
const traceSearch = {
|
||||
start,
|
||||
end,
|
||||
service,
|
||||
operation,
|
||||
limit: 10,
|
||||
lookback: '1h',
|
||||
maxDuration: '',
|
||||
minDuration: '',
|
||||
};
|
||||
const url = '/api/traces';
|
||||
try {
|
||||
return await datasource.metadataRequest(url, traceSearch);
|
||||
} catch (error) {
|
||||
dispatch(notifyApp(createErrorNotification('Failed to load traces from Jaeger', error)));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
onSelectTrace = (values: string[], selectedOptions: CascaderOption[]) => {
|
||||
const { query, onChange, onRunQuery } = this.props;
|
||||
if (selectedOptions.length === 3) {
|
||||
const traceID = selectedOptions[2].value;
|
||||
onChange({ ...query, query: traceID });
|
||||
onRunQuery();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { query, onChange } = this.props;
|
||||
const { serviceOptions } = this.state;
|
||||
const cascaderOptions = serviceOptions && serviceOptions.length ? serviceOptions : noTracesFoundOptions;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form-inline gf-form-inline--nowrap">
|
||||
<div className="gf-form flex-shrink-0">
|
||||
<ButtonCascader options={cascaderOptions} onChange={this.onSelectTrace} loadData={this.onLoadOptions}>
|
||||
Traces
|
||||
</ButtonCascader>
|
||||
</div>
|
||||
<div className="gf-form gf-form--grow flex-shrink-1">
|
||||
<div className="slate-query-field__wrapper">
|
||||
<div className="slate-query-field" aria-label={selectors.components.QueryField.container}>
|
||||
<input
|
||||
style={{ width: '100%' }}
|
||||
value={query.query || ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...query,
|
||||
query: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const noTracesFoundOptions = [
|
||||
{
|
||||
label: 'No traces found',
|
||||
value: 'no_traces',
|
||||
isLeaf: true,
|
||||
|
||||
// Cannot be disabled because then cascader shows 'loading' for some reason.
|
||||
// disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default JaegerQueryField;
|
@ -0,0 +1,123 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { Icon, InlineField, InlineFieldRow, InlineLabel, Input, useStyles } from '@grafana/ui';
|
||||
import React, { useState } from 'react';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import { JaegerQuery } from '../types';
|
||||
|
||||
const durationPlaceholder = 'e.g. 1.2s, 100ms, 500us';
|
||||
|
||||
type Props = {
|
||||
query: JaegerQuery;
|
||||
onChange: (value: JaegerQuery) => void;
|
||||
};
|
||||
|
||||
export function AdvancedOptions({ query, onChange }: Props) {
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InlineFieldRow>
|
||||
<div className={styles.advancedOptionsContainer} onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}>
|
||||
<InlineLabel as="div">
|
||||
Advanced options{' '}
|
||||
<Icon className={showAdvancedOptions ? styles.angleUp : styles.angleDown} name="angle-down" />
|
||||
</InlineLabel>
|
||||
</div>
|
||||
</InlineFieldRow>
|
||||
<CSSTransition
|
||||
in={showAdvancedOptions}
|
||||
mountOnEnter={true}
|
||||
unmountOnExit={true}
|
||||
timeout={300}
|
||||
classNames={styles}
|
||||
>
|
||||
<div>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Min Duration" labelWidth={21} grow>
|
||||
<Input
|
||||
value={query.minDuration || ''}
|
||||
placeholder={durationPlaceholder}
|
||||
onChange={(v) =>
|
||||
onChange({
|
||||
...query,
|
||||
minDuration: v.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Max Duration" labelWidth={21} grow>
|
||||
<Input
|
||||
value={query.maxDuration || ''}
|
||||
placeholder={durationPlaceholder}
|
||||
onChange={(v) =>
|
||||
onChange({
|
||||
...query,
|
||||
maxDuration: v.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Limit" labelWidth={21} grow tooltip="Maximum numbers of returned results">
|
||||
<Input
|
||||
value={query.limit || ''}
|
||||
type="number"
|
||||
onChange={(v) =>
|
||||
onChange({
|
||||
...query,
|
||||
limit: v.currentTarget.value ? parseInt(v.currentTarget.value, 10) : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme) {
|
||||
return {
|
||||
advancedOptionsContainer: css`
|
||||
margin: 0 ${theme.spacing.xs} ${theme.spacing.xs} 0;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
`,
|
||||
enter: css`
|
||||
label: enter;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
`,
|
||||
enterActive: css`
|
||||
label: enterActive;
|
||||
height: 108px;
|
||||
opacity: 1;
|
||||
transition: height 300ms ease, opacity 300ms ease;
|
||||
`,
|
||||
exit: css`
|
||||
label: exit;
|
||||
height: 108px;
|
||||
opacity: 1;
|
||||
`,
|
||||
exitActive: css`
|
||||
label: exitActive;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
transition: height 300ms ease, opacity 300ms ease;
|
||||
`,
|
||||
angleUp: css`
|
||||
transform: rotate(-180deg);
|
||||
transition: transform 300ms;
|
||||
`,
|
||||
angleDown: css`
|
||||
transform: rotate(0deg);
|
||||
transition: transform 300ms;
|
||||
`,
|
||||
};
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { QueryEditorProps } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { InlineField, InlineFieldRow, Input, RadioButtonGroup } from '@grafana/ui';
|
||||
import React from 'react';
|
||||
import { JaegerDatasource } from '../datasource';
|
||||
import { JaegerQuery, JaegerQueryType } from '../types';
|
||||
import { SearchForm } from './SearchForm';
|
||||
|
||||
type Props = QueryEditorProps<JaegerDatasource, JaegerQuery>;
|
||||
|
||||
export function QueryEditor({ datasource, query, onChange }: Props) {
|
||||
return (
|
||||
<div className={css({ width: '50%' })}>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Query type">
|
||||
<RadioButtonGroup<JaegerQueryType>
|
||||
options={[
|
||||
{ value: 'search', label: 'Search' },
|
||||
{ value: undefined, label: 'TraceID' },
|
||||
]}
|
||||
value={query.queryType}
|
||||
onChange={(v) =>
|
||||
onChange({
|
||||
...query,
|
||||
queryType: v,
|
||||
})
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
{query.queryType === 'search' ? (
|
||||
<SearchForm datasource={datasource} query={query} onChange={onChange} />
|
||||
) : (
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Trace ID" labelWidth={21} grow>
|
||||
<Input
|
||||
aria-label={selectors.components.DataSource.Jaeger.traceIDInput}
|
||||
placeholder="Eg. 4050b8060d659e52"
|
||||
value={query.query || ''}
|
||||
onChange={(v) =>
|
||||
onChange({
|
||||
...query,
|
||||
query: v.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
118
public/app/plugins/datasource/jaeger/components/SearchForm.tsx
Normal file
118
public/app/plugins/datasource/jaeger/components/SearchForm.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { InlineField, InlineFieldRow, Input, Select } from '@grafana/ui';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { JaegerDatasource } from '../datasource';
|
||||
import { JaegerQuery } from '../types';
|
||||
import { transformToLogfmt } from '../util';
|
||||
import { AdvancedOptions } from './AdvancedOptions';
|
||||
|
||||
type Props = {
|
||||
datasource: JaegerDatasource;
|
||||
query: JaegerQuery;
|
||||
onChange: (value: JaegerQuery) => void;
|
||||
};
|
||||
|
||||
export const ALL_OPERATIONS_KEY = 'All';
|
||||
const allOperationsOption: SelectableValue<string> = {
|
||||
label: ALL_OPERATIONS_KEY,
|
||||
value: undefined,
|
||||
};
|
||||
|
||||
export function SearchForm({ datasource, query, onChange }: Props) {
|
||||
const [serviceOptions, setServiceOptions] = useState<Array<SelectableValue<string>>>();
|
||||
const [operationOptions, setOperationOptions] = useState<Array<SelectableValue<string>>>();
|
||||
|
||||
useEffect(() => {
|
||||
const getServices = async () => {
|
||||
const services = await loadServices({
|
||||
dataSource: datasource,
|
||||
url: '/api/services',
|
||||
notFoundLabel: 'No service found',
|
||||
});
|
||||
setServiceOptions(services);
|
||||
};
|
||||
getServices();
|
||||
}, [datasource]);
|
||||
|
||||
useEffect(() => {
|
||||
const getOperations = async () => {
|
||||
const operations = await loadServices({
|
||||
dataSource: datasource,
|
||||
url: `/api/services/${encodeURIComponent(query.service!)}/operations`,
|
||||
notFoundLabel: 'No operation found',
|
||||
});
|
||||
setOperationOptions([allOperationsOption, ...operations]);
|
||||
};
|
||||
if (query.service) {
|
||||
getOperations();
|
||||
}
|
||||
}, [datasource, query.service]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Service" labelWidth={21} grow>
|
||||
<Select
|
||||
options={serviceOptions}
|
||||
value={serviceOptions?.find((v) => v.value === query.service) || null}
|
||||
onChange={(v) => {
|
||||
onChange({
|
||||
...query,
|
||||
service: v.value!,
|
||||
operation: query.service !== v.value ? undefined : query.operation,
|
||||
});
|
||||
}}
|
||||
menuPlacement="bottom"
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Operation" labelWidth={21} grow disabled={!query.service}>
|
||||
<Select
|
||||
options={operationOptions}
|
||||
value={operationOptions?.find((v) => v.value === query.operation) || null}
|
||||
onChange={(v) =>
|
||||
onChange({
|
||||
...query,
|
||||
operation: v.value!,
|
||||
})
|
||||
}
|
||||
menuPlacement="bottom"
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Tags" labelWidth={21} grow>
|
||||
<Input
|
||||
value={transformToLogfmt(query.tags)}
|
||||
placeholder="http.status_code=200 error=true"
|
||||
onChange={(v) =>
|
||||
onChange({
|
||||
...query,
|
||||
tags: v.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<AdvancedOptions query={query} onChange={onChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Options = { dataSource: JaegerDatasource; url: string; notFoundLabel: string };
|
||||
|
||||
const loadServices = async ({ dataSource, url, notFoundLabel }: Options): Promise<Array<SelectableValue<string>>> => {
|
||||
const services: string[] | null = await dataSource.metadataRequest(url);
|
||||
|
||||
if (!services) {
|
||||
return [{ label: notFoundLabel, value: notFoundLabel }];
|
||||
}
|
||||
|
||||
const serviceOptions: SelectableValue[] = services.sort().map((service) => ({
|
||||
label: service,
|
||||
value: service,
|
||||
}));
|
||||
|
||||
return serviceOptions;
|
||||
};
|
@ -2,19 +2,30 @@ import { DataQueryRequest, DataSourceInstanceSettings, dateTime, FieldType, Plug
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { createFetchResponse } from 'test/helpers/createFetchResponse';
|
||||
import { JaegerDatasource, JaegerQuery } from './datasource';
|
||||
import { ALL_OPERATIONS_KEY } from './components/SearchForm';
|
||||
import { JaegerDatasource } from './datasource';
|
||||
import {
|
||||
testResponse,
|
||||
testResponseDataFrameFields,
|
||||
testResponseNodesFields,
|
||||
testResponseEdgesFields,
|
||||
} from './testResponse';
|
||||
import { JaegerQuery } from './types';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...((jest.requireActual('@grafana/runtime') as unknown) as object),
|
||||
getBackendSrv: () => backendSrv,
|
||||
}));
|
||||
|
||||
const timeSrvStub: any = {
|
||||
timeRange(): any {
|
||||
return {
|
||||
from: dateTime(1531468681),
|
||||
to: dateTime(1531489712),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
describe('JaegerDatasource', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@ -60,6 +71,52 @@ describe('JaegerDatasource', () => {
|
||||
expect(field.type).toBe(FieldType.trace);
|
||||
expect(field.values.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return search results when the query type is search', async () => {
|
||||
const mock = setupFetchMock({ data: [testResponse] });
|
||||
const ds = new JaegerDatasource(defaultSettings, timeSrvStub);
|
||||
const response = await ds
|
||||
.query({
|
||||
...defaultQuery,
|
||||
targets: [{ queryType: 'search', refId: 'a', service: 'jaeger-query', operation: '/api/services' }],
|
||||
})
|
||||
.toPromise();
|
||||
expect(mock).toBeCalledWith({
|
||||
url: `${defaultSettings.url}/api/traces?operation=%2Fapi%2Fservices&service=jaeger-query&start=1531468681000&end=1531489712000&lookback=custom`,
|
||||
});
|
||||
expect(response.data[0].meta.preferredVisualisationType).toBe('table');
|
||||
// Make sure that traceID field has data link configured
|
||||
expect(response.data[0].fields[0].config.links).toHaveLength(1);
|
||||
expect(response.data[0].fields[0].name).toBe('traceID');
|
||||
});
|
||||
|
||||
it('should remove operation from the query when all is selected', async () => {
|
||||
const mock = setupFetchMock({ data: [testResponse] });
|
||||
const ds = new JaegerDatasource(defaultSettings, timeSrvStub);
|
||||
await ds
|
||||
.query({
|
||||
...defaultQuery,
|
||||
targets: [{ queryType: 'search', refId: 'a', service: 'jaeger-query', operation: ALL_OPERATIONS_KEY }],
|
||||
})
|
||||
.toPromise();
|
||||
expect(mock).toBeCalledWith({
|
||||
url: `${defaultSettings.url}/api/traces?service=jaeger-query&start=1531468681000&end=1531489712000&lookback=custom`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert tags from logfmt format to an object', async () => {
|
||||
const mock = setupFetchMock({ data: [testResponse] });
|
||||
const ds = new JaegerDatasource(defaultSettings, timeSrvStub);
|
||||
await ds
|
||||
.query({
|
||||
...defaultQuery,
|
||||
targets: [{ queryType: 'search', refId: 'a', service: 'jaeger-query', tags: 'error=true' }],
|
||||
})
|
||||
.toPromise();
|
||||
expect(mock).toBeCalledWith({
|
||||
url: `${defaultSettings.url}/api/traces?service=jaeger-query&tags=%7B%22error%22%3A%22true%22%7D&start=1531468681000&end=1531489712000&lookback=custom`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when performing testDataSource', () => {
|
||||
|
@ -1,5 +1,4 @@
|
||||
import {
|
||||
DataQuery,
|
||||
DataQueryRequest,
|
||||
DataQueryResponse,
|
||||
DataSourceApi,
|
||||
@ -14,12 +13,12 @@ import { serializeParams } from 'app/core/utils/fetch';
|
||||
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { createTraceFrame } from './responseTransform';
|
||||
import { createTableFrame, createTraceFrame } from './responseTransform';
|
||||
import { createGraphFrames } from './graphTransform';
|
||||
|
||||
export type JaegerQuery = {
|
||||
query: string;
|
||||
} & DataQuery;
|
||||
import { JaegerQuery } from './types';
|
||||
import { identity, omit, pick, pickBy } from 'lodash';
|
||||
import { convertTagsLogfmt } from './util';
|
||||
import { ALL_OPERATIONS_KEY } from './components/SearchForm';
|
||||
|
||||
export class JaegerDatasource extends DataSourceApi<JaegerQuery> {
|
||||
constructor(private instanceSettings: DataSourceInstanceSettings, private readonly timeSrv: TimeSrv = getTimeSrv()) {
|
||||
@ -34,15 +33,14 @@ export class JaegerDatasource extends DataSourceApi<JaegerQuery> {
|
||||
query(options: DataQueryRequest<JaegerQuery>): Observable<DataQueryResponse> {
|
||||
// At this moment we expect only one target. In case we somehow change the UI to be able to show multiple
|
||||
// traces at one we need to change this.
|
||||
const id = options.targets[0]?.query;
|
||||
if (!id) {
|
||||
const target = options.targets[0];
|
||||
if (!target) {
|
||||
return of({ data: [emptyTraceDataFrame] });
|
||||
}
|
||||
|
||||
// TODO: this api is internal, used in jaeger ui. Officially they have gRPC api that should be used.
|
||||
return this._request(`/api/traces/${encodeURIComponent(id)}`).pipe(
|
||||
if (target.queryType !== 'search' && target.query) {
|
||||
return this._request(`/api/traces/${encodeURIComponent(target.query)}`).pipe(
|
||||
map((response) => {
|
||||
// We assume there is only one trace, as the querying right now does not work to query for multiple traces.
|
||||
const traceData = response?.data?.data?.[0];
|
||||
if (!traceData) {
|
||||
return { data: [emptyTraceDataFrame] };
|
||||
@ -54,6 +52,31 @@ export class JaegerDatasource extends DataSourceApi<JaegerQuery> {
|
||||
);
|
||||
}
|
||||
|
||||
let jaegerQuery = pick(target, ['operation', 'service', 'tags', 'minDuration', 'maxDuration', 'limit']);
|
||||
// remove empty properties
|
||||
jaegerQuery = pickBy(jaegerQuery, identity);
|
||||
if (jaegerQuery.tags) {
|
||||
jaegerQuery = { ...jaegerQuery, tags: convertTagsLogfmt(jaegerQuery.tags) };
|
||||
}
|
||||
|
||||
if (jaegerQuery.operation === ALL_OPERATIONS_KEY) {
|
||||
jaegerQuery = omit(jaegerQuery, 'operation');
|
||||
}
|
||||
|
||||
// TODO: this api is internal, used in jaeger ui. Officially they have gRPC api that should be used.
|
||||
return this._request(`/api/traces`, {
|
||||
...jaegerQuery,
|
||||
...this.getTimeRange(),
|
||||
lookback: 'custom',
|
||||
}).pipe(
|
||||
map((response) => {
|
||||
return {
|
||||
data: [createTableFrame(response.data.data, this.instanceSettings)],
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async testDatasource(): Promise<any> {
|
||||
return this._request('/api/services')
|
||||
.pipe(
|
||||
@ -101,7 +124,7 @@ export class JaegerDatasource extends DataSourceApi<JaegerQuery> {
|
||||
}
|
||||
|
||||
getQueryDisplayText(query: JaegerQuery) {
|
||||
return query.query;
|
||||
return query.query || '';
|
||||
}
|
||||
|
||||
private _request(apiUrl: string, data?: any, options?: Partial<BackendSrvRequest>): Observable<Record<string, any>> {
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { DataSourcePlugin } from '@grafana/data';
|
||||
import { JaegerDatasource } from './datasource';
|
||||
import { JaegerQueryField } from './QueryField';
|
||||
import { ConfigEditor } from './ConfigEditor';
|
||||
import { QueryEditor } from './components/QueryEditor';
|
||||
import { ConfigEditor } from './components/ConfigEditor';
|
||||
|
||||
export const plugin = new DataSourcePlugin(JaegerDatasource)
|
||||
.setConfigEditor(ConfigEditor)
|
||||
.setExploreQueryField(JaegerQueryField);
|
||||
export const plugin = new DataSourcePlugin(JaegerDatasource).setConfigEditor(ConfigEditor).setQueryEditor(QueryEditor);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { DataFrame, FieldType, MutableDataFrame, TraceSpanRow } from '@grafana/data';
|
||||
|
||||
import { DataFrame, DataSourceInstanceSettings, FieldType, MutableDataFrame, TraceSpanRow } from '@grafana/data';
|
||||
import { transformTraceData } from '@jaegertracing/jaeger-ui-components';
|
||||
import { Span, TraceProcess, TraceResponse } from './types';
|
||||
|
||||
export function createTraceFrame(data: TraceResponse): DataFrame {
|
||||
@ -52,3 +52,58 @@ function toSpanRow(span: Span, processes: Record<string, TraceProcess>): TraceSp
|
||||
serviceTags: processes[span.processID].tags,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTableFrame(data: TraceResponse[], instanceSettings: DataSourceInstanceSettings): DataFrame {
|
||||
const frame = new MutableDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'traceID',
|
||||
type: FieldType.string,
|
||||
config: {
|
||||
displayNameFromDS: 'Trace ID',
|
||||
links: [
|
||||
{
|
||||
title: 'Trace: ${__value.raw}',
|
||||
url: '',
|
||||
internal: {
|
||||
datasourceUid: instanceSettings.uid,
|
||||
datasourceName: instanceSettings.name,
|
||||
query: {
|
||||
query: '${__value.raw}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ name: 'traceName', type: FieldType.string, config: { displayNameFromDS: 'Trace name' } },
|
||||
{ name: 'startTime', type: FieldType.time, config: { displayNameFromDS: 'Start time' } },
|
||||
{ name: 'duration', type: FieldType.number, config: { displayNameFromDS: 'Duration', unit: 'µs' } },
|
||||
],
|
||||
meta: {
|
||||
preferredVisualisationType: 'table',
|
||||
},
|
||||
});
|
||||
// Show the most recent traces
|
||||
const traceData = data.map(transformToTraceData).sort((a, b) => b?.startTime! - a?.startTime!);
|
||||
|
||||
for (const trace of traceData) {
|
||||
frame.add(trace);
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
function transformToTraceData(data: TraceResponse) {
|
||||
const traceData = transformTraceData(data);
|
||||
if (!traceData) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
traceID: traceData.traceID,
|
||||
startTime: traceData.startTime / 1000,
|
||||
duration: traceData.duration,
|
||||
traceName: traceData.traceName,
|
||||
};
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { DataQuery } from '@grafana/data';
|
||||
|
||||
export type TraceKeyValuePair = {
|
||||
key: string;
|
||||
type?: string;
|
||||
@ -47,3 +49,18 @@ export type TraceResponse = {
|
||||
warnings?: string[] | null;
|
||||
spans: Span[];
|
||||
};
|
||||
|
||||
export type JaegerQuery = {
|
||||
// undefined means the old behavior, showing only trace ID input
|
||||
queryType?: JaegerQueryType;
|
||||
service?: string;
|
||||
operation?: string;
|
||||
// trace ID
|
||||
query?: string;
|
||||
tags?: string;
|
||||
minDuration?: string;
|
||||
maxDuration?: string;
|
||||
limit?: number;
|
||||
} & DataQuery;
|
||||
|
||||
export type JaegerQueryType = 'search';
|
||||
|
26
public/app/plugins/datasource/jaeger/util.ts
Normal file
26
public/app/plugins/datasource/jaeger/util.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import logfmt from 'logfmt';
|
||||
|
||||
export function convertTagsLogfmt(tags: string | undefined) {
|
||||
if (!tags) {
|
||||
return '';
|
||||
}
|
||||
const data: any = logfmt.parse(tags);
|
||||
Object.keys(data).forEach((key) => {
|
||||
const value = data[key];
|
||||
if (typeof value !== 'string') {
|
||||
data[key] = String(value);
|
||||
}
|
||||
});
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
export function transformToLogfmt(tags: string | undefined) {
|
||||
if (!tags) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return logfmt.stringify(JSON.parse(tags));
|
||||
} catch {
|
||||
return tags;
|
||||
}
|
||||
}
|
24
yarn.lock
24
yarn.lock
@ -5613,6 +5613,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008"
|
||||
integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==
|
||||
|
||||
"@types/logfmt@^1.2.1":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/logfmt/-/logfmt-1.2.1.tgz#981f9821464fc4a82a0aa2884b9c03d90c23ad99"
|
||||
integrity sha512-kBeA+qbJAoyhIHBCOKEQYRDKPYyvt2m77cl1ZJUGfig8f5kgXZ7KOlPBN3/5whkwkK4nm47GN0bzdeObMWoYXQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/long@^4.0.1":
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
|
||||
@ -16636,6 +16643,14 @@ log4js@1.1.1:
|
||||
semver "^5.3.0"
|
||||
streamroller "^0.4.0"
|
||||
|
||||
logfmt@^1.3.2:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/logfmt/-/logfmt-1.3.2.tgz#be34020b7390b8201212a12f533e3cb4c92d70c2"
|
||||
integrity sha512-U0lelcaGWEfEITZQXs8y5HrJp2xa0BJ+KDfkkLJRmuKbQIEVGNv145FbaNekY4ZYHJSBBx8NLJitaPtRqLEkxQ==
|
||||
dependencies:
|
||||
split "0.2.x"
|
||||
through "2.3.x"
|
||||
|
||||
loglevel@^1.6.8:
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197"
|
||||
@ -23078,6 +23093,13 @@ split2@^2.0.0:
|
||||
dependencies:
|
||||
through2 "^2.0.2"
|
||||
|
||||
split@0.2.x:
|
||||
version "0.2.10"
|
||||
resolved "https://registry.yarnpkg.com/split/-/split-0.2.10.tgz#67097c601d697ce1368f418f06cd201cf0521a57"
|
||||
integrity sha1-Zwl8YB1pfOE2j0GPBs0gHPBSGlc=
|
||||
dependencies:
|
||||
through "2"
|
||||
|
||||
split@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9"
|
||||
@ -24012,7 +24034,7 @@ through2@^4.0.0:
|
||||
dependencies:
|
||||
readable-stream "3"
|
||||
|
||||
through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@~2.3.6:
|
||||
through@2, through@2.3.x, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@~2.3.6:
|
||||
version "2.3.8"
|
||||
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
|
||||
integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
|
||||
|
Loading…
Reference in New Issue
Block a user