diff --git a/CHANGELOG.md b/CHANGELOG.md index 043235a2b3..d302c750dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ * **Trace detail:** Log Markers on Spans ([Fix #119](https://github.com/jaegertracing/jaeger-ui/issues/119)) ([@sfriberg](https://github.com/sfriberg) in [#309](https://github.com/jaegertracing/jaeger-ui/pull/309)) +* **Search:** Load trace(s) from a JSON file ([Fix #214](https://github.com/jaegertracing/jaeger-ui/issues/214)) ([@yuribit](https://github.com/yuribit) in [#327](https://github.com/jaegertracing/jaeger-ui/pull/327)) + ## v1.0.1 (February 15, 2019) ### Fixes diff --git a/packages/jaeger-ui/src/actions/file-reader-api.js b/packages/jaeger-ui/src/actions/file-reader-api.js new file mode 100644 index 0000000000..8cdbd10bda --- /dev/null +++ b/packages/jaeger-ui/src/actions/file-reader-api.js @@ -0,0 +1,25 @@ +// @flow + +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createAction } from 'redux-actions'; +import fileReader from '../utils/fileReader'; + +// eslint-disable-next-line import/prefer-default-export +export const loadJsonTraces = createAction( + '@FILE_READER_API/LOAD_JSON', + fileList => fileReader.readJsonFile(fileList), + fileList => ({ fileList }) +); diff --git a/packages/jaeger-ui/src/actions/file-reader-api.test.js b/packages/jaeger-ui/src/actions/file-reader-api.test.js new file mode 100644 index 0000000000..d45bade5b0 --- /dev/null +++ b/packages/jaeger-ui/src/actions/file-reader-api.test.js @@ -0,0 +1,40 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import sinon from 'sinon'; +import isPromise from 'is-promise'; + +import * as fileReaderActions from './file-reader-api'; +import fileReader from '../utils/fileReader'; + +it('loadJsonTraces should return a promise', () => { + const fileList = { data: {}, filename: 'whatever' }; + + const { payload } = fileReaderActions.loadJsonTraces(fileList); + expect(isPromise(payload)).toBeTruthy(); + // prevent the unhandled rejection warnings + payload.catch(() => {}); +}); + +it('loadJsonTraces should call readJsonFile', () => { + const fileList = { data: {}, filename: 'whatever' }; + const mock = sinon.mock(fileReader); + const called = mock + .expects('readJsonFile') + .once() + .withExactArgs(fileList); + fileReaderActions.loadJsonTraces(fileList); + expect(called.verify()).toBeTruthy(); + mock.restore(); +}); diff --git a/packages/jaeger-ui/src/components/SearchTracePage/FileLoader.js b/packages/jaeger-ui/src/components/SearchTracePage/FileLoader.js new file mode 100644 index 0000000000..19e70eb1e4 --- /dev/null +++ b/packages/jaeger-ui/src/components/SearchTracePage/FileLoader.js @@ -0,0 +1,36 @@ +// @flow + +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { Upload, Icon } from 'antd'; + +const Dragger = Upload.Dragger; + +type FileLoaderProps = { + loadJsonTraces: (fileList: FileList) => void, +}; + +export default function FileLoader(props: FileLoaderProps) { + return ( + +

+ +

+

Click or drag files to this area.

+

Support JSON files containig one or more traces.

+
+ ); +} diff --git a/packages/jaeger-ui/src/components/SearchTracePage/FileLoader.test.js b/packages/jaeger-ui/src/components/SearchTracePage/FileLoader.test.js new file mode 100644 index 0000000000..7c12ab0f9e --- /dev/null +++ b/packages/jaeger-ui/src/components/SearchTracePage/FileLoader.test.js @@ -0,0 +1,30 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; + +import FileLoader from './FileLoader'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('matches the snapshot', () => { + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/jaeger-ui/src/components/SearchTracePage/__snapshots__/FileLoader.test.js.snap b/packages/jaeger-ui/src/components/SearchTracePage/__snapshots__/FileLoader.test.js.snap new file mode 100644 index 0000000000..0ea82b4d4c --- /dev/null +++ b/packages/jaeger-ui/src/components/SearchTracePage/__snapshots__/FileLoader.test.js.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches the snapshot 1`] = ` + +

+ +

+

+ Click or drag files to this area. +

+

+ Support JSON files containig one or more traces. +

+
+`; diff --git a/packages/jaeger-ui/src/components/SearchTracePage/index.js b/packages/jaeger-ui/src/components/SearchTracePage/index.js index f3ab70021d..95d12f64f3 100644 --- a/packages/jaeger-ui/src/components/SearchTracePage/index.js +++ b/packages/jaeger-ui/src/components/SearchTracePage/index.js @@ -15,7 +15,7 @@ /* eslint-disable react/require-default-props */ import React, { Component } from 'react'; -import { Col, Row } from 'antd'; +import { Col, Row, Tabs } from 'antd'; import PropTypes from 'prop-types'; import queryString from 'query-string'; import { connect } from 'react-redux'; @@ -26,6 +26,7 @@ import SearchForm from './SearchForm'; import SearchResults, { sortFormSelector } from './SearchResults'; import { isSameQuery, getUrl } from './url'; import * as jaegerApiActions from '../../actions/jaeger-api'; +import * as fileReaderActions from '../../actions/file-reader-api'; import ErrorMessage from '../common/ErrorMessage'; import LoadingIndicator from '../common/LoadingIndicator'; import { getLocation as getTraceLocation } from '../TracePage/url'; @@ -34,10 +35,13 @@ import { fetchedState } from '../../constants'; import { sortTraces } from '../../model/search'; import getLastXformCacher from '../../utils/get-last-xform-cacher'; import { stripEmbeddedState } from '../../utils/embedded-url'; +import FileLoader from './FileLoader'; import './index.css'; import JaegerLogo from '../../img/jaeger-logo.svg'; +const TabPane = Tabs.TabPane; + // export for tests export class SearchTracePageImpl extends Component { componentDidMount() { @@ -85,6 +89,7 @@ export class SearchTracePageImpl extends Component { services, traceResults, queryOfResults, + loadJsonTraces, } = this.props; const hasTraceResults = traceResults && traceResults.length > 0; const showErrors = errors && !loadingTraces; @@ -95,8 +100,14 @@ export class SearchTracePageImpl extends Component { {!embedded && (
-

Find Traces

- {!loadingServices && services ? : } + + + {!loadingServices && services ? : } + + + + +
)} @@ -177,6 +188,7 @@ SearchTracePageImpl.propTypes = { message: PropTypes.string, }) ), + loadJsonTraces: PropTypes.func, }; const stateTraceXformer = getLastXformCacher(stateTrace => { @@ -257,6 +269,7 @@ function mapDispatchToProps(dispatch) { jaegerApiActions, dispatch ); + const { loadJsonTraces } = bindActionCreators(fileReaderActions, dispatch); const { cohortAddTrace, cohortRemoveTrace } = bindActionCreators(traceDiffActions, dispatch); return { cohortAddTrace, @@ -265,6 +278,7 @@ function mapDispatchToProps(dispatch) { fetchServiceOperations, fetchServices, searchTraces, + loadJsonTraces, }; } diff --git a/packages/jaeger-ui/src/reducers/trace.js b/packages/jaeger-ui/src/reducers/trace.js index d8f5199f7b..2aa8d8c84a 100644 --- a/packages/jaeger-ui/src/reducers/trace.js +++ b/packages/jaeger-ui/src/reducers/trace.js @@ -16,6 +16,7 @@ import _isEqual from 'lodash/isEqual'; import { handleActions } from 'redux-actions'; import { fetchTrace, fetchMultipleTraces, searchTraces } from '../actions/jaeger-api'; +import { loadJsonTraces } from '../actions/file-reader-api'; import { fetchedState } from '../constants'; import transformTraceData from '../model/transform-trace-data'; @@ -124,6 +125,31 @@ function searchErred(state, { meta, payload }) { return { ...state, search }; } +function loadJsonStarted(state) { + const { search } = state; + return { ...state, search: { ...search, state: fetchedState.LOADING } }; +} + +function loadJsonDone(state, { payload }) { + const processed = payload.data.map(transformTraceData); + const resultTraces = {}; + const results = new Set(state.search.results); + for (let i = 0; i < processed.length; i++) { + const data = processed[i]; + const id = data.traceID; + resultTraces[id] = { data, id, state: fetchedState.DONE }; + results.add(id); + } + const traces = { ...state.traces, ...resultTraces }; + const search = { ...state.search, results: Array.from(results), state: fetchedState.DONE }; + return { ...state, search, traces }; +} + +function loadJsonErred(state, { payload }) { + const search = { error: payload, results: [], state: fetchedState.ERROR }; + return { ...state, search }; +} + export default handleActions( { [`${fetchTrace}_PENDING`]: fetchTraceStarted, @@ -137,6 +163,10 @@ export default handleActions( [`${searchTraces}_PENDING`]: fetchSearchStarted, [`${searchTraces}_FULFILLED`]: searchDone, [`${searchTraces}_REJECTED`]: searchErred, + + [`${loadJsonTraces}_PENDING`]: loadJsonStarted, + [`${loadJsonTraces}_FULFILLED`]: loadJsonDone, + [`${loadJsonTraces}_REJECTED`]: loadJsonErred, }, initialState ); diff --git a/packages/jaeger-ui/src/reducers/trace.test.js b/packages/jaeger-ui/src/reducers/trace.test.js index 7ed64647f1..8f1d4621f2 100644 --- a/packages/jaeger-ui/src/reducers/trace.test.js +++ b/packages/jaeger-ui/src/reducers/trace.test.js @@ -13,6 +13,7 @@ // limitations under the License. import * as jaegerApiActions from '../actions/jaeger-api'; +import * as fileReaderActions from '../actions/file-reader-api'; import { fetchedState } from '../constants'; import traceGenerator from '../demo/trace-generators'; import transformTraceData from '../model/transform-trace-data'; @@ -209,3 +210,55 @@ describe('search traces', () => { }); }); }); + +describe('load json traces', () => { + it('handles a pending load json request', () => { + const state = traceReducer( + { search: { results: [id] } }, + { + type: `${fileReaderActions.loadJsonTraces}${ACTION_POSTFIX_PENDING}`, + } + ); + const outcome = { + results: [id], + state: fetchedState.LOADING, + }; + expect(state.search).toEqual(outcome); + }); + + it('handles a successful load json request', () => { + const state = traceReducer(undefined, { + type: `${fileReaderActions.loadJsonTraces}${ACTION_POSTFIX_FULFILLED}`, + payload: { data: [trace] }, + }); + const outcome = { + traces: { + [id]: { + id, + data: transformTraceData(trace), + state: fetchedState.DONE, + }, + }, + search: { + query: null, + state: fetchedState.DONE, + results: [id], + }, + }; + expect(state).toEqual(outcome); + }); + + it('handles a failed load json request', () => { + const error = 'some-error'; + const state = traceReducer(undefined, { + type: `${fileReaderActions.loadJsonTraces}${ACTION_POSTFIX_REJECTED}`, + payload: error, + }); + const outcome = { + error, + results: [], + state: fetchedState.ERROR, + }; + expect(state.search).toEqual(outcome); + }); +}); diff --git a/packages/jaeger-ui/src/utils/fileReader.js b/packages/jaeger-ui/src/utils/fileReader.js new file mode 100644 index 0000000000..e6ecbeb8e0 --- /dev/null +++ b/packages/jaeger-ui/src/utils/fileReader.js @@ -0,0 +1,51 @@ +// @flow + +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const fileReader = { + readJsonFile(fileList: { file: File }) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result !== 'string') { + reject(new Error('Invalid result type')); + return; + } + try { + resolve(JSON.parse(reader.result)); + } catch (error) { + reject(new Error(`Error parsing JSON: ${error.message}`)); + } + }; + reader.onerror = () => { + // eslint-disable-next-line no-console + const errMessage = reader.error ? `: ${String(reader.error)}` : ''; + reject(new Error(`Error reading the JSON file${errMessage}`)); + }; + reader.onabort = () => { + // eslint-disable-next-line no-console + reject(new Error(`Reading the JSON file has been aborted`)); + }; + try { + reader.readAsText(fileList.file); + } catch (error) { + // eslint-disable-next-line no-console + reject(new Error(`Error reading the JSON file: ${error.message}`)); + } + }); + }, +}; + +export default fileReader; diff --git a/packages/jaeger-ui/src/utils/fileReader.test.js b/packages/jaeger-ui/src/utils/fileReader.test.js new file mode 100644 index 0000000000..67904cc24e --- /dev/null +++ b/packages/jaeger-ui/src/utils/fileReader.test.js @@ -0,0 +1,56 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import isPromise from 'is-promise'; + +import fileReader from './fileReader.js'; + +describe('fileReader.readJsonFile', () => { + it('returns a promise', () => { + const promise = fileReader.readJsonFile({ rando: true }); + // prevent the unhandled rejection warning + promise.catch(() => {}); + expect(isPromise(promise)).toBeTruthy(); + }); + + it('rejects when given an invalid file', () => { + const p = fileReader.readJsonFile({ rando: true }); + return expect(p).rejects.toMatchObject(expect.any(Error)); + }); + + it('does not throw when given an invalid file', () => { + let threw = false; + try { + const p = fileReader.readJsonFile({ rando: true }); + // prevent the unhandled rejection warning + p.catch(() => {}); + } catch (_) { + threw = true; + } + return expect(threw).toBe(false); + }); + + it('loads JSON data, successfully', () => { + const obj = { ok: true }; + const file = new File([JSON.stringify(obj)], 'foo.json'); + const p = fileReader.readJsonFile({ file }); + return expect(p).resolves.toMatchObject(obj); + }); + + it('rejects on malform JSON', () => { + const file = new File(['not-json'], 'foo.json'); + const p = fileReader.readJsonFile({ file }); + return expect(p).rejects.toMatchObject(expect.any(Error)); + }); +});