diff --git a/packages/jaeger-ui/src/actions/jaeger-api.js b/packages/jaeger-ui/src/actions/jaeger-api.js index dfd94ab81d..2e01153104 100644 --- a/packages/jaeger-ui/src/actions/jaeger-api.js +++ b/packages/jaeger-ui/src/actions/jaeger-api.js @@ -14,6 +14,7 @@ import { createAction } from 'redux-actions'; import JaegerAPI from '../api/jaeger'; +import fileReader from '../utils/fileReader'; export const fetchTrace = createAction( '@JAEGER_API/FETCH_TRACE', @@ -50,3 +51,9 @@ export const fetchServiceOperations = createAction( export const fetchDependencies = createAction('@JAEGER_API/FETCH_DEPENDENCIES', () => JaegerAPI.fetchDependencies() ); + +export const uploadTraces = createAction( + '@JAEGER_API/UPLOAD_TRACES', + fileList => fileReader.readJSONFile(fileList), + fileList => ({ fileList }) +); diff --git a/packages/jaeger-ui/src/actions/jaeger-api.test.js b/packages/jaeger-ui/src/actions/jaeger-api.test.js index 4565422aba..9255b67514 100644 --- a/packages/jaeger-ui/src/actions/jaeger-api.test.js +++ b/packages/jaeger-ui/src/actions/jaeger-api.test.js @@ -26,6 +26,7 @@ import isPromise from 'is-promise'; import * as jaegerApiActions from './jaeger-api'; import JaegerAPI from '../api/jaeger'; +import fileReader from '../utils/fileReader'; it('@JAEGER_API/FETCH_TRACE should fetch the trace by id', () => { const api = JaegerAPI; @@ -114,3 +115,22 @@ it('@JAEGER_API/FETCH_SERVICE_OPERATIONS should call the JaegerAPI', () => { expect(called.verify()).toBeTruthy(); mock.restore(); }); + +it('uploadTraces should return a promise', () => { + const fileList = { data: {}, filename: 'whatever' }; + + const { payload } = jaegerApiActions.uploadTraces(fileList); + expect(isPromise(payload)).toBeTruthy(); +}); + +it('uploadTraces should call readJSONFile', () => { + const fileList = { data: {}, filename: 'whatever' }; + const mock = sinon.mock(fileReader); + const called = mock + .expects('readJSONFile') + .once() + .withExactArgs(fileList); + jaegerApiActions.uploadTraces(fileList); + expect(called.verify()).toBeTruthy(); + mock.restore(); +}); diff --git a/packages/jaeger-ui/src/api/jaeger.js b/packages/jaeger-ui/src/api/jaeger.js index 2abedb6f1b..cbebe7f482 100644 --- a/packages/jaeger-ui/src/api/jaeger.js +++ b/packages/jaeger-ui/src/api/jaeger.js @@ -89,7 +89,7 @@ const JaegerAPI = { return getJSON(`${this.apiRoot}services`).catch(() => []); }, fetchServiceOperations(serviceName) { - return getJSON(`${this.apiRoot}services/${encodeURIComponent(serviceName)}/operations`); + return getJSON(`${this.apiRoot}services/${encodeURIComponent(serviceName)}/operations`).catch(() => []); }, fetchDependencies(endTs = new Date().getTime(), lookback = DEFAULT_DEPENDENCY_LOOKBACK) { return getJSON(`${this.apiRoot}dependencies`, { query: { endTs, lookback } }); diff --git a/packages/jaeger-ui/src/api/jaeger.test.js b/packages/jaeger-ui/src/api/jaeger.test.js index 3207cc1597..4a999ab0ab 100644 --- a/packages/jaeger-ui/src/api/jaeger.test.js +++ b/packages/jaeger-ui/src/api/jaeger.test.js @@ -88,6 +88,25 @@ it('fetchServices() returns [] on a >= 400 status code', done => { }); }); +it('fetchOperations() returns [] on a >= 400 status code', done => { + const status = 400; + const statusText = 'some-status'; + const msg = 'some-message'; + const errorData = { errors: [{ msg, code: status }] }; + + fetchMock.mockReturnValue( + Promise.resolve({ + status, + statusText, + text: () => Promise.resolve(JSON.stringify(errorData)), + }) + ); + JaegerAPI.fetchServiceOperations().then(operations => { + expect(operations).toEqual([]); + done(); + }); +}); + it('fetchTrace() throws an useful error derived from a text payload', done => { const status = 400; const statusText = 'some-status'; diff --git a/packages/jaeger-ui/src/components/SearchTracePage/FileUploader.js b/packages/jaeger-ui/src/components/SearchTracePage/FileUploader.js new file mode 100644 index 0000000000..e7532f4d89 --- /dev/null +++ b/packages/jaeger-ui/src/components/SearchTracePage/FileUploader.js @@ -0,0 +1,49 @@ +// Copyright (c) 2017 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'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import * as jaegerApiActions from '../../actions/jaeger-api'; + +const Dragger = Upload.Dragger; + +export function FileUploaderImpl(props) { + const { uploadTraces } = props; + return ( + +

+ +

+

Click or drag files to this area.

+

Support JSON files containig one or more traces.

+
+ ); +} + +FileUploaderImpl.propTypes = { + uploadTraces: PropTypes.func.isRequired, +}; + +function mapDispatchToProps(dispatch) { + const { uploadTraces } = bindActionCreators(jaegerApiActions, dispatch); + return { + uploadTraces, + }; +} + +export default connect(null, mapDispatchToProps)(FileUploaderImpl); diff --git a/packages/jaeger-ui/src/components/SearchTracePage/FileUploader.test.js b/packages/jaeger-ui/src/components/SearchTracePage/FileUploader.test.js new file mode 100644 index 0000000000..6937682b33 --- /dev/null +++ b/packages/jaeger-ui/src/components/SearchTracePage/FileUploader.test.js @@ -0,0 +1,30 @@ +// Copyright (c) 2017 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 { mount } from 'enzyme'; + +import { FileUploaderImpl as FileUploader } from './FileUploader'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + it('does not explode', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/packages/jaeger-ui/src/components/SearchTracePage/index.js b/packages/jaeger-ui/src/components/SearchTracePage/index.js index f3ab70021d..6f5e46b2b7 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'; @@ -34,10 +34,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 FileUploader from './FileUploader'; import './index.css'; import JaegerLogo from '../../img/jaeger-logo.svg'; +const TabPane = Tabs.TabPane; + // export for tests export class SearchTracePageImpl extends Component { componentDidMount() { @@ -95,8 +98,14 @@ export class SearchTracePageImpl extends Component { {!embedded && (
-

Find Traces

- {!loadingServices && services ? : } + + + {!loadingServices && services ? : } + + + + +
)} diff --git a/packages/jaeger-ui/src/reducers/trace.js b/packages/jaeger-ui/src/reducers/trace.js index d8f5199f7b..835f689c4a 100644 --- a/packages/jaeger-ui/src/reducers/trace.js +++ b/packages/jaeger-ui/src/reducers/trace.js @@ -15,7 +15,7 @@ import _isEqual from 'lodash/isEqual'; import { handleActions } from 'redux-actions'; -import { fetchTrace, fetchMultipleTraces, searchTraces } from '../actions/jaeger-api'; +import { fetchTrace, fetchMultipleTraces, searchTraces, uploadTraces } from '../actions/jaeger-api'; import { fetchedState } from '../constants'; import transformTraceData from '../model/transform-trace-data'; @@ -124,6 +124,34 @@ function searchErred(state, { meta, payload }) { return { ...state, search }; } +function uploadStarted(state) { + const search = { + results: [].concat(state.search.results), + state: fetchedState.LOADING, + }; + return { ...state, search }; +} + +function uploadDone(state, { payload }) { + const processed = payload.data.map(transformTraceData); + const resultTraces = {}; + const results = [].concat(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.push(id); + } + const traces = { ...state.traces, ...resultTraces }; + const search = { ...state.search, results, state: fetchedState.DONE }; + return { ...state, search, traces }; +} + +function uploadErred(state, { payload }) { + const search = { error: payload, results: [], state: fetchedState.ERROR }; + return { ...state, search }; +} + export default handleActions( { [`${fetchTrace}_PENDING`]: fetchTraceStarted, @@ -137,6 +165,10 @@ export default handleActions( [`${searchTraces}_PENDING`]: fetchSearchStarted, [`${searchTraces}_FULFILLED`]: searchDone, [`${searchTraces}_REJECTED`]: searchErred, + + [`${uploadTraces}_PENDING`]: uploadStarted, + [`${uploadTraces}_FULFILLED`]: uploadDone, + [`${uploadTraces}_REJECTED`]: uploadErred, }, initialState ); diff --git a/packages/jaeger-ui/src/reducers/trace.test.js b/packages/jaeger-ui/src/reducers/trace.test.js index 7ed64647f1..527f0aaf0b 100644 --- a/packages/jaeger-ui/src/reducers/trace.test.js +++ b/packages/jaeger-ui/src/reducers/trace.test.js @@ -209,3 +209,55 @@ describe('search traces', () => { }); }); }); + +describe('upload traces', () => { + it('handles a pending upload request', () => { + const state = traceReducer( + { search: { results: [id] } }, + { + type: `${jaegerApiActions.uploadTraces}${ACTION_POSTFIX_PENDING}`, + } + ); + const outcome = { + results: [id], + state: fetchedState.LOADING, + }; + expect(state.search).toEqual(outcome); + }); + + it('handles a successful upload request', () => { + const state = traceReducer(undefined, { + type: `${jaegerApiActions.uploadTraces}${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 upload request', () => { + const error = 'some-error'; + const state = traceReducer(undefined, { + type: `${jaegerApiActions.uploadTraces}${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..4b612a0510 --- /dev/null +++ b/packages/jaeger-ui/src/utils/fileReader.js @@ -0,0 +1,33 @@ +// Copyright (c) 2017 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) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + resolve(reader.result); + }; + reader.onerror = () => { + reject(); + }; + reader.onabort = () => { + reject(); + }; + reader.readAsText(fileList.file); + }).then(result => JSON.parse(result)); + }, +}; + +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..4721d605f6 --- /dev/null +++ b/packages/jaeger-ui/src/utils/fileReader.test.js @@ -0,0 +1,58 @@ +// Copyright (c) 2017 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 sinon from 'sinon'; + +import fileReader from './fileReader.js'; + +it('readJSONFile returns a promise', () => { + const fileList = { data: {}, filename: 'whatever' }; + + const promise = fileReader.readJSONFile(fileList); + expect(isPromise(promise)).toBeTruthy(); +}); + +it('readJSONFile fails to load a fail', async () => { + expect.assertions(1); + const fileList = { data: {}, filename: 'whatever' }; + try { + await fileReader.readJSONFile(fileList); + } catch (e) { + expect(true).toBeTruthy(); + } +}); + +it('readJSONFile fails when fileList is wrong', async () => { + expect.assertions(2); + + const mock = sinon.mock(window); + const called = mock.expects('FileReader').once(); + const fileList = { + action: '', + filename: 'file', + file: { uid: '1234' }, + data: {}, + headers: {}, + withCredentials: false, + }; + + try { + await fileReader.readJSONFile(fileList); + } catch (e) { + expect(true).toBeTruthy(); + expect(called.verify()).toBeTruthy(); + } + mock.restore(); +});