diff --git a/.gitignore b/.gitignore index 9818f4233b..29cbb21930 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,4 @@ npm-debug.log .vscode .idea yarn-error.log - -lerna-debug\.log +lerna-debug.log diff --git a/.travis.yml b/.travis.yml index 3eca28fcf3..6f2ad2599c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,16 @@ language: node_js node_js: - '6' cache: yarn +env: + global: + # so the yarn installed in before_install will be used + - PATH=$HOME/.yarn/bin:$PATH +before_install: + # update yarn - travis is using an old version + # https://yarnpkg.com/en/docs/install#alternatives-stable + - curl -o- -L https://yarnpkg.com/install.sh | bash +install: + - yarn install --frozen-lockfile script: - yarn lint - yarn coverage diff --git a/lerna.json b/lerna.json index 03cfbda637..d1fc74aa9b 100644 --- a/lerna.json +++ b/lerna.json @@ -1,7 +1,7 @@ { "lerna": "2.10.2", - "packages": ["packages/*"], "version": "independent", "npmClient": "yarn", + "npmClientArgs": ["--pure-lockfile"], "useWorkspaces": true } diff --git a/package.json b/package.json index eca60349a3..05dc0dc24c 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,9 @@ "coverage": "lerna run coverage", "eslint": "eslint 'scripts/*.js' 'packages/*/src/**/*.js' 'packages/*/*.js'", "flow": "glow", - "lint": "npm run eslint && npm run prettier && npm run flow && npm run check-license", + "lint": "yarn run eslint && yarn run prettier && yarn run flow && yarn run check-license", "precommit": "lint-staged", + "prepare": "lerna run --stream --sort prepublishOnly", "prettier": "prettier --write '{.,scripts}/*.{js,json,md}' 'packages/*/{src,demo/src}/**/*.{css,js,json,md}' 'packages/*/*.{css,js,json,md}'", "test": "lerna run test", @@ -39,7 +40,7 @@ "trailingComma": "es5" }, "lint-staged": { - "*.{css,js,json}": ["npm run lint", "npm run test", "git add"], - "*.md": ["npm run prettier", "git add"] + "*.{css,js,json}": ["yarn run lint", "yarn run test", "git add"], + "*.md": ["yarn run prettier", "git add"] } } diff --git a/packages/jaeger-ui/config-overrides-antd-vars.less b/packages/jaeger-ui/config-overrides-antd-vars.less index 53cceab91b..31ecdf902d 100644 --- a/packages/jaeger-ui/config-overrides-antd-vars.less +++ b/packages/jaeger-ui/config-overrides-antd-vars.less @@ -19,3 +19,6 @@ @layout-zero-trigger-height : 42px; @menu-dark-bg: #151515; + +// Table +@table-row-hover-bg:#e5f2f2; diff --git a/packages/jaeger-ui/package.json b/packages/jaeger-ui/package.json index cfedcf24b2..021afd6575 100644 --- a/packages/jaeger-ui/package.json +++ b/packages/jaeger-ui/package.json @@ -29,6 +29,7 @@ "sinon": "^3.2.1" }, "dependencies": { + "@jaegertracing/plexus": "0.0.1-dev.3", "antd": "^3.0.3", "chance": "^1.0.10", "classnames": "^2.2.5", @@ -77,7 +78,7 @@ }, "scripts": { "build": "REACT_APP_VSN_STATE=$(../../scripts/get-tracking-version.js) react-app-rewired build", - "coverage": "npm run test -- --coverage", + "coverage": "yarn run test -- --coverage", "start:ga-debug": "REACT_APP_GA_DEBUG=1 REACT_APP_VSN_STATE=$(../../scripts/get-tracking-version.js) react-app-rewired start", "start": "react-app-rewired start", diff --git a/packages/jaeger-ui/src/actions/jaeger-api.js b/packages/jaeger-ui/src/actions/jaeger-api.js index 56cdc6889b..dfd94ab81d 100644 --- a/packages/jaeger-ui/src/actions/jaeger-api.js +++ b/packages/jaeger-ui/src/actions/jaeger-api.js @@ -21,6 +21,12 @@ export const fetchTrace = createAction( id => ({ id }) ); +export const fetchMultipleTraces = createAction( + '@JAEGER_API/FETCH_MULTIPLE_TRACES', + ids => JaegerAPI.searchTraces({ traceID: ids }), + ids => ({ ids }) +); + export const archiveTrace = createAction( '@JAEGER_API/ARCHIVE_TRACE', id => JaegerAPI.archiveTrace(id), diff --git a/packages/jaeger-ui/src/components/App/Page.js b/packages/jaeger-ui/src/components/App/Page.js index 53cca06ec1..47fe93fd98 100644 --- a/packages/jaeger-ui/src/components/App/Page.js +++ b/packages/jaeger-ui/src/components/App/Page.js @@ -22,47 +22,44 @@ import type { Location } from 'react-router-dom'; import { withRouter } from 'react-router-dom'; import TopNav from './TopNav'; -import type { Config } from '../../types/config'; import { trackPageView } from '../../utils/tracking'; import './Page.css'; -type PageProps = { - location: Location, +type Props = { + pathname: string, + search: string, children: React.Node, - config: Config, }; const { Header, Content } = Layout; // export for tests -export class PageImpl extends React.Component { - props: PageProps; +export class PageImpl extends React.Component { + props: Props; componentDidMount() { - const { pathname, search } = this.props.location; + const { pathname, search } = this.props; trackPageView(pathname, search); } - componentWillReceiveProps(nextProps: PageProps) { - const { pathname, search } = this.props.location; - const { pathname: nextPathname, search: nextSearch } = nextProps.location; + componentWillReceiveProps(nextProps: Props) { + const { pathname, search } = this.props; + const { pathname: nextPathname, search: nextSearch } = nextProps; if (pathname !== nextPathname || search !== nextSearch) { trackPageView(nextPathname, nextSearch); } } render() { - const { children, config, location } = this.props; - const menu = config && config.menu; return (
- +
- {children} + {this.props.children}
); @@ -70,10 +67,9 @@ export class PageImpl extends React.Component { } // export for tests -export function mapStateToProps(state: { config: Config, router: { location: Location } }, ownProps: any) { - const { config } = state; - const { location } = state.router; - return { ...ownProps, config, location }; +export function mapStateToProps(state: { router: { location: Location } }) { + const { pathname, search } = state.router.location; + return { pathname, search }; } export default withRouter(connect(mapStateToProps)(PageImpl)); diff --git a/packages/jaeger-ui/src/components/App/Page.test.js b/packages/jaeger-ui/src/components/App/Page.test.js index 6761b444e8..c8e5fef3b3 100644 --- a/packages/jaeger-ui/src/components/App/Page.test.js +++ b/packages/jaeger-ui/src/components/App/Page.test.js @@ -24,16 +24,12 @@ import { trackPageView } from '../../utils/tracking'; describe('mapStateToProps()', () => { it('maps state to props', () => { + const pathname = 'a-pathname'; + const search = 'a-search'; const state = { - config: {}, - router: { location: {} }, + router: { location: { pathname, search } }, }; - const ownProps = { a: {} }; - expect(mapStateToProps(state, ownProps)).toEqual({ - config: state.config, - location: state.router.location, - a: ownProps.a, - }); + expect(mapStateToProps(state)).toEqual({ pathname, search }); }); }); @@ -44,11 +40,8 @@ describe('', () => { beforeEach(() => { trackPageView.mockReset(); props = { - location: { - pathname: String(Math.random()), - search: String(Math.random()), - }, - config: { menu: [] }, + pathname: String(Math.random()), + search: String(Math.random()), }; wrapper = mount(); }); @@ -58,14 +51,14 @@ describe('', () => { }); it('tracks an initial page-view', () => { - const { pathname, search } = props.location; + const { pathname, search } = props; expect(trackPageView.mock.calls).toEqual([[pathname, search]]); }); it('tracks a pageView when the location changes', () => { trackPageView.mockReset(); - const location = { pathname: 'le-path', search: 'searching' }; - wrapper.setProps({ location }); - expect(trackPageView.mock.calls).toEqual([[location.pathname, location.search]]); + props = { pathname: 'le-path', search: 'searching' }; + wrapper.setProps(props); + expect(trackPageView.mock.calls).toEqual([[props.pathname, props.search]]); }); }); diff --git a/packages/jaeger-ui/src/components/App/TopNav.js b/packages/jaeger-ui/src/components/App/TopNav.js index 5cd102697c..d414395836 100644 --- a/packages/jaeger-ui/src/components/App/TopNav.js +++ b/packages/jaeger-ui/src/components/App/TopNav.js @@ -16,28 +16,38 @@ import React from 'react'; import { Dropdown, Icon, Menu } from 'antd'; -import { Link } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { Link, withRouter } from 'react-router-dom'; import TraceIDSearchInput from './TraceIDSearchInput'; -import type { ConfigMenuItem, ConfigMenuGroup } from '../../types/config'; +import * as dependencies from '../DependencyGraph/url'; +import * as searchUrl from '../SearchTracePage/url'; +import * as diffUrl from '../TraceDiff/url'; import { getConfigValue } from '../../utils/config/get-config'; import prefixUrl from '../../utils/prefix-url'; -type TopNavProps = { - activeKey: string, - menuConfig: (ConfigMenuItem | ConfigMenuGroup)[], -}; +import type { ReduxState } from '../../types'; +import type { ConfigMenuItem, ConfigMenuGroup } from '../../types/config'; + +type Props = ReduxState; const NAV_LINKS = [ { - to: prefixUrl('/search'), + to: searchUrl.getUrl(), + matches: searchUrl.matches, text: 'Search', }, + { + to: (props: Props) => diffUrl.getUrl(props.traceDiff), + matches: diffUrl.matches, + text: 'Compare', + }, ]; if (getConfigValue('dependencies.menuEnabled')) { NAV_LINKS.push({ - to: prefixUrl('/dependencies'), + to: dependencies.getUrl(), + matches: dependencies.matches, text: 'Dependencies', }); } @@ -66,12 +76,13 @@ function CustomNavDropdown({ label, items }: ConfigMenuGroup) { ); } -export default function TopNav(props: TopNavProps) { - const { activeKey, menuConfig } = props; - const menuItems = Array.isArray(menuConfig) ? menuConfig : []; +export function TopNavImpl(props: Props) { + const { config, router } = props; + const { pathname } = router.location; + const menuItems = Array.isArray(config.menu) ? config.menu : []; return (
- + {menuItems.map(m => { if (m.items != null) { const group = ((m: any): ConfigMenuGroup); @@ -91,25 +102,35 @@ export default function TopNav(props: TopNavProps) { ); })} - + Jaeger UI - {NAV_LINKS.map(({ to, text }) => ( - - {text} - - ))} + {NAV_LINKS.map(({ matches, to, text }) => { + const url = typeof to === 'string' ? to : to(props); + const key = matches(pathname) ? pathname : url; + return ( + + {text} + + ); + })}
); } -TopNav.defaultProps = { +TopNavImpl.defaultProps = { menuConfig: [], }; -TopNav.CustomNavDropdown = CustomNavDropdown; +TopNavImpl.CustomNavDropdown = CustomNavDropdown; + +function mapStateToProps(state: Props) { + return state; +} + +export default withRouter(connect(mapStateToProps)(TopNavImpl)); diff --git a/packages/jaeger-ui/src/components/App/TopNav.test.js b/packages/jaeger-ui/src/components/App/TopNav.test.js index 33817f0fd7..e38254d003 100644 --- a/packages/jaeger-ui/src/components/App/TopNav.test.js +++ b/packages/jaeger-ui/src/components/App/TopNav.test.js @@ -16,7 +16,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Link } from 'react-router-dom'; -import TopNav from './TopNav'; +import { TopNavImpl as TopNav } from './TopNav'; describe('', () => { const labelGitHub = 'GitHub'; @@ -34,16 +34,21 @@ describe('', () => { ]; const defaultProps = { - menuConfig: [ - { - label: labelGitHub, - url: githubUrl, - }, - { - label: labelAbout, - items: dropdownItems, - }, - ], + config: { + menu: [ + { + label: labelGitHub, + url: githubUrl, + }, + { + label: labelAbout, + items: dropdownItems, + }, + ], + }, + router: { + location: { location: { pathname: 'some-path ' } }, + }, }; let wrapper; diff --git a/packages/jaeger-ui/src/components/App/index.js b/packages/jaeger-ui/src/components/App/index.js index 57337b0bf7..1a5ac67ea5 100644 --- a/packages/jaeger-ui/src/components/App/index.js +++ b/packages/jaeger-ui/src/components/App/index.js @@ -20,9 +20,14 @@ import { ConnectedRouter } from 'react-router-redux'; import NotFound from './NotFound'; import Page from './Page'; -import { ConnectedDependencyGraphPage } from '../DependencyGraph'; -import { ConnectedSearchTracePage } from '../SearchTracePage'; -import { ConnectedTracePage } from '../TracePage'; +import DependencyGraph from '../DependencyGraph'; +import { ROUTE_PATH as dependenciesPath } from '../DependencyGraph/url'; +import SearchTracePage from '../SearchTracePage'; +import { ROUTE_PATH as searchPath } from '../SearchTracePage/url'; +import TraceDiff from '../TraceDiff'; +import { ROUTE_PATH as traceDiffPath } from '../TraceDiff/url'; +import TracePage from '../TracePage'; +import { ROUTE_PATH as tracePath } from '../TracePage/url'; import JaegerAPI, { DEFAULT_API_ROOT } from '../../api/jaeger'; import configureStore from '../../utils/configure-store'; import prefixUrl from '../../utils/prefix-url'; @@ -45,12 +50,15 @@ export default class JaegerUIApp extends Component { - - - - - - + + + + + + + + + diff --git a/packages/jaeger-ui/src/components/DependencyGraph/index.js b/packages/jaeger-ui/src/components/DependencyGraph/index.js index 350828d275..1a75997d1c 100644 --- a/packages/jaeger-ui/src/components/DependencyGraph/index.js +++ b/packages/jaeger-ui/src/components/DependencyGraph/index.js @@ -40,7 +40,8 @@ export const GRAPH_TYPES = { const dagMaxNumServices = getConfigValue('dependencies.dagMaxNumServices') || FALLBACK_DAG_MAX_NUM_SERVICES; -export default class DependencyGraphPage extends Component { +// export for tests +export class DependencyGraphPageImpl extends Component { static propTypes = { // eslint-disable-next-line react/forbid-prop-types dependencies: PropTypes.any.isRequired, @@ -129,4 +130,4 @@ export function mapDispatchToProps(dispatch) { return { fetchDependencies }; } -export const ConnectedDependencyGraphPage = connect(mapStateToProps, mapDispatchToProps)(DependencyGraphPage); +export default connect(mapStateToProps, mapDispatchToProps)(DependencyGraphPageImpl); diff --git a/packages/jaeger-ui/src/components/DependencyGraph/index.test.js b/packages/jaeger-ui/src/components/DependencyGraph/index.test.js index a2b4574eae..8217131298 100644 --- a/packages/jaeger-ui/src/components/DependencyGraph/index.test.js +++ b/packages/jaeger-ui/src/components/DependencyGraph/index.test.js @@ -18,7 +18,12 @@ import { shallow } from 'enzyme'; import DAG from './DAG'; import DependencyForceGraph from './DependencyForceGraph'; -import DependencyGraph, { GRAPH_TYPES, mapDispatchToProps, mapStateToProps } from './index'; +import { + DependencyGraphPageImpl as DependencyGraph, + GRAPH_TYPES, + mapDispatchToProps, + mapStateToProps, +} from './index'; import LoadingIndicator from '../common/LoadingIndicator'; const childId = 'boomya'; diff --git a/packages/jaeger-ui/src/components/DependencyGraph/url.js b/packages/jaeger-ui/src/components/DependencyGraph/url.js new file mode 100644 index 0000000000..79bea821d8 --- /dev/null +++ b/packages/jaeger-ui/src/components/DependencyGraph/url.js @@ -0,0 +1,31 @@ +// @flow + +// Copyright (c) 2018 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 { matchPath } from 'react-router-dom'; + +import prefixUrl from '../../utils/prefix-url'; + +export const ROUTE_PATH = prefixUrl('/dependencies'); + +const ROUTE_MATCHER = { path: ROUTE_PATH, strict: true, exact: true }; + +export function matches(path: string) { + return Boolean(matchPath(path, ROUTE_MATCHER)); +} + +export function getUrl() { + return ROUTE_PATH; +} diff --git a/packages/jaeger-ui/src/components/SearchTracePage/SearchForm.js b/packages/jaeger-ui/src/components/SearchTracePage/SearchForm.js index f4721c891c..5db0e88b80 100644 --- a/packages/jaeger-ui/src/components/SearchTracePage/SearchForm.js +++ b/packages/jaeger-ui/src/components/SearchTracePage/SearchForm.js @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react'; +import * as React from 'react'; import { Form, Input, Button, Popover, Select } from 'antd'; import logfmtParser from 'logfmt/lib/logfmt_parser'; import { stringify as logfmtStringify } from 'logfmt/lib/stringify'; @@ -151,149 +151,86 @@ export function submitForm(fields, searchTraces) { }); } -export function SearchFormImpl(props) { - const { handleSubmit, selectedLookback, selectedService = '-', services, submitting: disabled } = props; - const selectedServicePayload = services.find(s => s.name === selectedService); - const opsForSvc = (selectedServicePayload && selectedServicePayload.operations) || []; - const noSelectedService = selectedService === '-' || !selectedService; - const tz = selectedLookback === 'custom' ? new Date().toTimeString().replace(/^.*?GMT/, 'UTC') : null; - return ( -
- - Service ({services.length}) - - } - > - ({ label: v.name, value: v.name })), - required: true, - }} - /> - - - Operation ({opsForSvc ? opsForSvc.length : 0}) - - } - > - ({ label: v, value: v })), - required: true, - }} - /> - - - - Tags{' '} - - Values should be in the{' '} - - logfmt - {' '} - format. - , -
    -
  • Use space for conjunctions
  • -
  • Values containing whitespace should be enclosed in quotes
  • -
, - ]} - content={ -
- - error=true db.statement="select * from User" - -
- } - > - -
- - } - > - -
- - - - - - - - - - - - - - - {selectedLookback === 'custom' && [ +export class SearchFormImpl extends React.PureComponent { + render() { + const { + handleSubmit, + selectedLookback, + selectedService = '-', + services, + submitting: disabled, + } = this.props; + const selectedServicePayload = services.find(s => s.name === selectedService); + const opsForSvc = (selectedServicePayload && selectedServicePayload.operations) || []; + const noSelectedService = selectedService === '-' || !selectedService; + const tz = selectedLookback === 'custom' ? new Date().toTimeString().replace(/^.*?GMT/, 'UTC') : null; + return ( + - Start Time{' '} - - Times are expressed in {tz} - - } - > - - - + + Service ({services.length}) + } > ({ label: v.name, value: v.name })), + required: true, + }} /> - - , + + + Operation ({opsForSvc ? opsForSvc.length : 0}) + + } + > + ({ label: v, value: v })), + required: true, + }} + /> + - End Time{' '} + Tags{' '} - Times are expressed in {tz} - + Values should be in the{' '} + + logfmt + {' '} + format. + , +
    +
  • Use space for conjunctions
  • +
  • Values containing whitespace should be enclosed in quotes
  • +
, + ]} + content={ +
+ + error=true db.statement="select * from User" + +
} > @@ -302,44 +239,115 @@ export function SearchFormImpl(props) { } > - -
, - ]} - - - - - - - - - - - - - - -
- ); + + + + + + + + + + + + + + + + {selectedLookback === 'custom' && [ + + Start Time{' '} + + Times are expressed in {tz} + + } + > + + + + } + > + + + , + + + End Time{' '} + + Times are expressed in {tz} + + } + > + + + + } + > + + + , + ]} + + + + + + + + + + + + + + + + ); + } } SearchFormImpl.propTypes = { diff --git a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/DiffSelection.css b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/DiffSelection.css new file mode 100644 index 0000000000..96b9dc1ee7 --- /dev/null +++ b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/DiffSelection.css @@ -0,0 +1,34 @@ +/* +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. +*/ + +.DiffSelection.is-non-empty { + position: sticky; + top: 47px; + z-index: 1; +} + +.DiffSelection--message { + background: #f5f5f5; + z-index: 11; + background: #e5f2f2; + border: 1px solid #87c3c3; + padding: 1rem; +} + +.DiffSelection--selectedItems { + border: 1px solid #ccc; + border-bottom: none; +} diff --git a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/DiffSelection.js b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/DiffSelection.js new file mode 100644 index 0000000000..70daf02b7f --- /dev/null +++ b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/DiffSelection.js @@ -0,0 +1,82 @@ +// @flow + +// 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 { Button } from 'antd'; +import { Link } from 'react-router-dom'; + +import ResultItemTitle from './ResultItemTitle'; +import { getUrl } from '../../TraceDiff/url'; +import { fetchedState } from '../../../constants'; + +import type { FetchedTrace } from '../../../types'; + +import './DiffSelection.css'; + +type Props = { + toggleComparison: (string, boolean) => void, + traces: FetchedTrace[], +}; + +const CTA_MESSAGE =

Compare traces by selecting result items

; + +export default class DiffSelection extends React.PureComponent { + props: Props; + + render() { + const { toggleComparison, traces } = this.props; + const cohort = traces.filter(ft => ft.state !== fetchedState.ERROR).map(ft => ft.id); + const compareHref = cohort.length > 1 ? getUrl({ cohort }) : null; + const compareBtn = ( + + ); + return ( +
+ {traces.length > 0 && ( +
+ {traces.map(fetchedTrace => { + const { data = {}, error, id, state } = fetchedTrace; + return ( + + ); + })} +
+ )} +
+ {traces.length > 0 ? ( + + {compareHref ? {compareBtn} : compareBtn} +

{cohort.length} Selected for comparison

+
+ ) : ( + CTA_MESSAGE + )} +
+
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.css b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.css index 93c0094cc6..f53f6b91ec 100644 --- a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.css +++ b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.css @@ -24,25 +24,6 @@ limitations under the License. border-color: #d8d8d8; } -.ResultItem--title { - background: #ececec; - border-bottom: 1px solid #d8d8d8; - padding: 0.5rem; - position: relative; -} - -.ResultItem--durationBar { - background: #d7e7ea; - bottom: 0; - left: 0; - position: absolute; - top: 0; -} - -.ResultItem:hover > * > .ResultItem--durationBar { - background: #c5dde0; -} - .ResultItem--serviceTag { border-left-width: 15px; margin: 0; diff --git a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.js b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.js index d46c6af744..fce348dbe2 100644 --- a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.js +++ b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.js @@ -16,75 +16,90 @@ import * as React from 'react'; import { Col, Divider, Row, Tag } from 'antd'; +import { Link } from 'react-router-dom'; + import { sortBy } from 'lodash'; import moment from 'moment'; import * as markers from './ResultItem.markers'; -import { FALLBACK_TRACE_NAME } from '../../../constants'; +import ResultItemTitle from './ResultItemTitle'; import colorGenerator from '../../../utils/color-generator'; -import { formatDuration, formatRelativeDate } from '../../../utils/date'; +import { formatRelativeDate } from '../../../utils/date'; -import type { TraceSummary } from '../../../types/search'; +import type { Trace } from '../../../types/trace'; import './ResultItem.css'; type Props = { - trace: TraceSummary, durationPercent: number, + isInDiffCohort: boolean, + linkTo: string, + toggleComparison: string => void, + trace: Trace, }; +const isErrorTag = ({ key, value }) => key === 'error' && (value === true || value === 'true'); + export default class ResultItem extends React.PureComponent { props: Props; render() { - const { durationPercent, trace } = this.props; - const { duration, services, timestamp, numberOfErredSpans, numberOfSpans, traceName } = trace; - const mDate = moment(timestamp); + const { durationPercent, isInDiffCohort, linkTo, toggleComparison, trace } = this.props; + const { duration, services, startTime, spans, traceName, traceID } = trace; + const mDate = moment(startTime / 1000); const timeStr = mDate.format('h:mm:ss a'); const fromNow = mDate.fromNow(); + const numSpans = spans.length; + const numErredSpans = spans.filter(sp => sp.tags.some(isErrorTag)).length; return (
-
- - {formatDuration(duration * 1000)} -

{traceName || FALLBACK_TRACE_NAME}

-
- - - - {numberOfSpans} Span{numberOfSpans > 1 && 's'} - - {Boolean(numberOfErredSpans) && ( - - {numberOfErredSpans} Error{numberOfErredSpans > 1 && 's'} + + + + + + {numSpans} Span{numSpans > 1 && 's'} - )} - - -
    - {sortBy(services, s => s.name).map(service => { - const { name, numberOfSpans: count } = service; - return ( -
  • - - {name} ({count}) - -
  • - ); - })} -
- - - {formatRelativeDate(timestamp)} - - {timeStr.slice(0, -3)} {timeStr.slice(-2)} -
- {fromNow} - -
+ {Boolean(numErredSpans) && ( + + {numErredSpans} Error{numErredSpans > 1 && 's'} + + )} + + +
    + {sortBy(services, s => s.name).map(service => { + const { name, numberOfSpans: count } = service; + return ( +
  • + + {name} ({count}) + +
  • + ); + })} +
+ + + {formatRelativeDate(startTime / 1000)} + + {timeStr.slice(0, -3)} {timeStr.slice(-2)} +
+ {fromNow} + +
+
); } diff --git a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.test.js b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.test.js index 4ea7bb7ed5..c25130617a 100644 --- a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.test.js +++ b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItem.test.js @@ -18,43 +18,25 @@ import { shallow } from 'enzyme'; import ResultItem from './ResultItem'; import * as markers from './ResultItem.markers'; +import traceGenerator from '../../../demo/trace-generators'; +import transformTraceData from '../../../model/transform-trace-data'; -const testTraceProps = { - duration: 100, - services: [ - { - name: 'Service A', - numberOfSpans: 2, - percentOfTrace: 50, - }, - ], - startTime: Date.now(), - numberOfSpans: 5, -}; +const trace = transformTraceData(traceGenerator.trace({})); it(' should render base case correctly', () => { - const wrapper = shallow(); - + const wrapper = shallow(); const numberOfSpanText = wrapper .find(`[data-test="${markers.NUM_SPANS}"]`) .first() .render() .text(); - const numberOfServicesTags = wrapper.find(`[data-test="${markers.SERVICE_TAGS}"]`).find(Tag).length; - expect(numberOfSpanText).toBe('5 Spans'); - expect(numberOfServicesTags).toBe(1); + const serviceTags = wrapper.find(`[data-test="${markers.SERVICE_TAGS}"]`).find(Tag); + expect(numberOfSpanText).toBe(`${trace.spans.length} Spans`); + expect(serviceTags).toHaveLength(trace.services.length); }); it(' should not render any ServiceTags when there are no services', () => { - const wrapper = shallow( - - ); - const numberOfServicesTags = wrapper.find(`[data-test="${markers.SERVICE_TAGS}"]`).find(Tag).length; - expect(numberOfServicesTags).toBe(0); + const wrapper = shallow(); + const serviceTags = wrapper.find(`[data-test="${markers.SERVICE_TAGS}"]`).find(Tag); + expect(serviceTags).toHaveLength(0); }); diff --git a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItemTitle.css b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItemTitle.css new file mode 100644 index 0000000000..8415c99d6e --- /dev/null +++ b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItemTitle.css @@ -0,0 +1,63 @@ +/* +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. +*/ + +.ResultItemTitle { + background: #ececec; + border-bottom: 1px solid #d8d8d8; + display: flex; +} + +.ResultItemTitle--item { + color: inherit; + padding: 0.5rem; + position: relative; +} + +.ResultItemTitle--item:first-child { + border-right: 1px solid #ddd; +} + +.ResultItemTitle--item:hover { + background: #e4e4e4; + border-color: #ccc; +} + +.ResultItemTitle--title { + margin: 0; + position: relative; +} + +.ResultItemTitle--title.is-error { + color: #c00; +} + +.ResultItemTitle--idExcerpt { + color: #888; + font-weight: normal; + padding-left: 0.5rem; +} + +.ResultItemTitle--durationBar { + background: #d7e7ea; + bottom: 0; + left: 0; + position: absolute; + top: 0; +} + +.ResultItemTitle--item:hover > .ResultItemTitle--durationBar { + background: #c5dde0; +} diff --git a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItemTitle.js b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItemTitle.js new file mode 100644 index 0000000000..b544eaa9d5 --- /dev/null +++ b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/ResultItemTitle.js @@ -0,0 +1,94 @@ +// @flow + +// 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 { Checkbox } from 'antd'; +import { Link } from 'react-router-dom'; + +import TraceName from '../../common/TraceName'; +import { fetchedState } from '../../../constants'; +import { formatDuration } from '../../../utils/date'; + +import type { FetchedState } from '../../../types'; +import type { ApiError } from '../../../types/api-error'; + +import './ResultItemTitle.css'; + +type Props = { + duration: number, + durationPercent: number, + error?: ApiError, + isInDiffCohort: boolean, + linkTo: ?string, + state: ?FetchedState, + toggleComparison: (string, boolean) => void, + traceID: string, + traceName: string, +}; + +export default class ResultItemTitle extends React.PureComponent { + props: Props; + + static defaultProps = { + durationPercent: 0, + error: undefined, + state: fetchedState.DONE, + linkTo: null, + }; + + toggleComparison = () => { + const { isInDiffCohort, toggleComparison, traceID } = this.props; + toggleComparison(traceID, isInDiffCohort); + }; + + render() { + const { + duration, + durationPercent, + error, + isInDiffCohort, + linkTo, + state, + traceID, + traceName, + } = this.props; + let WrapperComponent = 'div'; + const wrapperProps: { [string]: string } = { className: 'ResultItemTitle--item ub-flex-auto' }; + if (linkTo) { + WrapperComponent = Link; + wrapperProps.to = linkTo; + } + const isErred = state === fetchedState.ERROR; + return ( +
+ + + + {duration != null && {formatDuration(duration)}} +

+ + {traceID.slice(0, 7)} +

+
+
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/index.js b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/index.js index 26d7fd0b5f..bfaa2a670c 100644 --- a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/index.js +++ b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/index.js @@ -17,8 +17,8 @@ import * as React from 'react'; import { Select } from 'antd'; import { Field, reduxForm, formValueSelector } from 'redux-form'; -import { Link } from 'react-router-dom'; +import DiffSelection from './DiffSelection'; import * as markers from './index.markers'; import ResultItem from './ResultItem'; import ScatterPlot from './ScatterPlot'; @@ -28,13 +28,19 @@ import { getPercentageOfDuration } from '../../../utils/date'; import prefixUrl from '../../../utils/prefix-url'; import reduxFormFieldAdapter from '../../../utils/redux-form-field-adapter'; +import type { FetchedTrace } from '../../../types'; + import './index.css'; type SearchResultsProps = { + cohortAddTrace: string => void, + cohortRemoveTrace: string => void, + diffCohort: FetchedTrace[], goToTrace: string => void, loading: boolean, maxTraceDuration: number, - traces: {}[], + skipMessage?: boolean, + traces: TraceSummary[], }; const Option = Select.Option; @@ -69,18 +75,40 @@ export const sortFormSelector = formValueSelector('traceResultsSort'); export default class SearchResults extends React.PureComponent { props: SearchResultsProps; + toggleComparison = (traceID: string, remove: boolean) => { + const { cohortAddTrace, cohortRemoveTrace } = this.props; + if (remove) { + cohortRemoveTrace(traceID); + } else { + cohortAddTrace(traceID); + } + }; + render() { - const { goToTrace, loading, maxTraceDuration, traces } = this.props; + const { loading, diffCohort, skipMessage, traces } = this.props; + const diffSelection = ; if (loading) { - return ; + return ( + + {diffCohort.length > 0 && diffSelection} + + + ); } if (!Array.isArray(traces) || !traces.length) { return ( -
- No trace results. Try another query. -
+ + {diffCohort.length > 0 && diffSelection} + {!skipMessage && ( +
+ No trace results. Try another query. +
+ )} +
); } + const { goToTrace, maxTraceDuration } = this.props; + const cohortIds = new Set(diffCohort.map(datum => datum.id)); return (
@@ -88,10 +116,10 @@ export default class SearchResults extends React.PureComponent ({ - x: t.timestamp, + x: t.startTime, y: t.duration, traceID: t.traceID, - size: t.numberOfSpans, + size: t.spans.length, name: t.traceName, }))} onValueClick={t => { @@ -108,15 +136,17 @@ export default class SearchResults extends React.PureComponent
+ {diffSelection}
    {traces.map(trace => (
  • - - - +
  • ))}
diff --git a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/index.test.js b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/index.test.js index c8ac6bdaf6..df5ad9f588 100644 --- a/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/index.test.js +++ b/packages/jaeger-ui/src/components/SearchTracePage/SearchResults/index.test.js @@ -29,10 +29,11 @@ describe('', () => { beforeEach(() => { traces = [{ traceID: 'a', spans: [], processes: {} }, { traceID: 'b', spans: [], processes: {} }]; props = { - traces, + diffCohort: [], goToTrace: () => {}, loading: false, maxTraceDuration: 1, + traces, }; wrapper = shallow(); }); diff --git a/packages/jaeger-ui/src/components/SearchTracePage/index.js b/packages/jaeger-ui/src/components/SearchTracePage/index.js index c832f0590b..65cd1b59d8 100644 --- a/packages/jaeger-ui/src/components/SearchTracePage/index.js +++ b/packages/jaeger-ui/src/components/SearchTracePage/index.js @@ -14,7 +14,6 @@ import React, { Component } from 'react'; import { Col, Row } from 'antd'; -import _values from 'lodash/values'; import PropTypes from 'prop-types'; import queryString from 'query-string'; import { connect } from 'react-redux'; @@ -22,23 +21,37 @@ import { bindActionCreators } from 'redux'; import store from 'store'; import * as jaegerApiActions from '../../actions/jaeger-api'; +import { actions as traceDiffActions } from '../TraceDiff/duck'; import SearchForm from './SearchForm'; import SearchResults, { sortFormSelector } from './SearchResults'; import ErrorMessage from '../common/ErrorMessage'; import LoadingIndicator from '../common/LoadingIndicator'; -import { sortTraces, getTraceSummaries } from '../../model/search'; +import { fetchedState } from '../../constants'; +import { sortTraces } from '../../model/search'; import getLastXformCacher from '../../utils/get-last-xform-cacher'; import prefixUrl from '../../utils/prefix-url'; import './index.css'; import JaegerLogo from '../../img/jaeger-logo.svg'; -export default class SearchTracePage extends Component { +// export for tests +export class SearchTracePageImpl extends Component { componentDidMount() { - const { searchTraces, urlQueryParams, fetchServices, fetchServiceOperations } = this.props; + const { + diffCohort, + fetchMultipleTraces, + searchTraces, + urlQueryParams, + fetchServices, + fetchServiceOperations, + } = this.props; if (urlQueryParams.service || urlQueryParams.traceID) { searchTraces(urlQueryParams); } + const needForDiffs = diffCohort.filter(ft => ft.state == null).map(ft => ft.id); + if (needForDiffs.length) { + fetchMultipleTraces(needForDiffs); + } fetchServices(); const { service } = store.get('lastSearch') || {}; if (service && service !== '-') { @@ -52,6 +65,9 @@ export default class SearchTracePage extends Component { render() { const { + cohortAddTrace, + cohortRemoveTrace, + diffCohort, errors, isHomepage, loadingServices, @@ -79,6 +95,18 @@ export default class SearchTracePage extends Component { {errors.map(err => )}
)} + {!showErrors && ( + + )} {showLogo && ( )} - {!showErrors && - !showLogo && ( - - )}
@@ -103,10 +122,13 @@ export default class SearchTracePage extends Component { } } -SearchTracePage.propTypes = { +SearchTracePageImpl.propTypes = { isHomepage: PropTypes.bool, // eslint-disable-next-line react/forbid-prop-types traceResults: PropTypes.array, + diffCohort: PropTypes.array, + cohortAddTrace: PropTypes.func, + cohortRemoveTrace: PropTypes.func, maxTraceDuration: PropTypes.number, loadingServices: PropTypes.bool, loadingTraces: PropTypes.bool, @@ -124,6 +146,7 @@ SearchTracePage.propTypes = { history: PropTypes.shape({ push: PropTypes.func, }), + fetchMultipleTraces: PropTypes.func, fetchServiceOperations: PropTypes.func, fetchServices: PropTypes.func, errors: PropTypes.arrayOf( @@ -134,11 +157,20 @@ SearchTracePage.propTypes = { }; const stateTraceXformer = getLastXformCacher(stateTrace => { - const { traces: traceMap, loading: loadingTraces, error: traceError } = stateTrace; - const { traces, maxDuration } = getTraceSummaries(_values(traceMap)); + const { traces: traceMap, search } = stateTrace; + const { results, state, error: traceError } = search; + const loadingTraces = state === fetchedState.LOADING; + const traces = results.map(id => traceMap[id].data); + const maxDuration = Math.max.apply(null, traces.map(tr => tr.duration)); return { traces, maxDuration, traceError, loadingTraces }; }); +const stateTraceDiffXformer = getLastXformCacher((stateTrace, stateTraceDiff) => { + const { traces } = stateTrace; + const { cohort } = stateTraceDiff; + return cohort.map(id => traces[id] || { id }); +}); + const sortedTracesXformer = getLastXformCacher((traces, sortBy) => { const traceResults = traces.slice(); sortTraces(traceResults, sortBy); @@ -166,6 +198,7 @@ export function mapStateToProps(state) { const query = queryString.parse(state.router.location.search); const isHomepage = !Object.keys(query).length; const { traces, maxDuration, traceError, loadingTraces } = stateTraceXformer(state.trace); + const diffCohort = stateTraceDiffXformer(state.trace, state.traceDiff); const { loadingServices, services, serviceError } = stateServicesXformer(state.services); const errors = []; if (traceError) { @@ -177,11 +210,12 @@ export function mapStateToProps(state) { const sortBy = sortFormSelector(state, 'sortBy'); const traceResults = sortedTracesXformer(traces, sortBy); return { + diffCohort, isHomepage, + loadingServices, + loadingTraces, services, traceResults, - loadingTraces, - loadingServices, errors: errors.length ? errors : null, maxTraceDuration: maxDuration, sortTracesBy: sortBy, @@ -190,14 +224,19 @@ export function mapStateToProps(state) { } function mapDispatchToProps(dispatch) { - const { searchTraces, fetchServices, fetchServiceOperations } = bindActionCreators( + const { fetchMultipleTraces, fetchServiceOperations, fetchServices, searchTraces } = bindActionCreators( jaegerApiActions, dispatch ); + const { cohortAddTrace, cohortRemoveTrace } = bindActionCreators(traceDiffActions, dispatch); return { + cohortAddTrace, + cohortRemoveTrace, + fetchMultipleTraces, fetchServiceOperations, fetchServices, searchTraces, }; } -export const ConnectedSearchTracePage = connect(mapStateToProps, mapDispatchToProps)(SearchTracePage); + +export default connect(mapStateToProps, mapDispatchToProps)(SearchTracePageImpl); diff --git a/packages/jaeger-ui/src/components/SearchTracePage/index.test.js b/packages/jaeger-ui/src/components/SearchTracePage/index.test.js index 7bd04d0975..b5c4630f10 100644 --- a/packages/jaeger-ui/src/components/SearchTracePage/index.test.js +++ b/packages/jaeger-ui/src/components/SearchTracePage/index.test.js @@ -31,9 +31,10 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import store from 'store'; -import SearchTracePage, { mapStateToProps } from './index'; +import { SearchTracePageImpl as SearchTracePage, mapStateToProps } from './index'; import SearchForm from './SearchForm'; import LoadingIndicator from '../common/LoadingIndicator'; +import { fetchedState } from '../../constants'; import traceGenerator from '../../demo/trace-generators'; import { MOST_RECENT } from '../../model/order-by'; import transformTraceData from '../../model/transform-trace-data'; @@ -51,6 +52,7 @@ describe('', () => { loadingServices: false, loadingTraces: false, maxTraceDuration: 100, + diffCohort: [], numberOfTraceResults: traceResults.length, services: null, sortTracesBy: MOST_RECENT, @@ -103,7 +105,15 @@ describe('', () => { describe('mapStateToProps()', () => { it('converts state to the necessary props', () => { const trace = transformTraceData(traceGenerator.trace({})); - const stateTrace = { traces: [trace], loading: false, error: null }; + const stateTrace = { + search: { + results: [trace.traceID], + state: fetchedState.DONE, + }, + traces: { + [trace.traceID]: { id: trace.traceID, data: trace, state: fetchedState.DONE }, + }, + }; const stateServices = { loading: false, services: ['svc-a'], @@ -113,13 +123,21 @@ describe('mapStateToProps()', () => { const state = { router: { location: { search: '' } }, trace: stateTrace, + traceDiff: { + cohort: [trace.traceID], + }, services: stateServices, }; - const { maxTraceDuration, traceResults, numberOfTraceResults, ...rest } = mapStateToProps(state); - expect(traceResults.length).toBe(stateTrace.traces.length); + const { maxTraceDuration, traceResults, diffCohort, numberOfTraceResults, ...rest } = mapStateToProps( + state + ); + expect(traceResults).toHaveLength(stateTrace.search.results.length); expect(traceResults[0].traceID).toBe(trace.traceID); - expect(maxTraceDuration).toBe(trace.duration / 1000); + expect(maxTraceDuration).toBe(trace.duration); + expect(diffCohort).toHaveLength(state.traceDiff.cohort.length); + expect(diffCohort[0].id).toBe(trace.traceID); + expect(diffCohort[0].data.traceID).toBe(trace.traceID); expect(rest).toEqual({ isHomepage: true, diff --git a/packages/jaeger-ui/src/components/SearchTracePage/url.js b/packages/jaeger-ui/src/components/SearchTracePage/url.js new file mode 100644 index 0000000000..130485c8e6 --- /dev/null +++ b/packages/jaeger-ui/src/components/SearchTracePage/url.js @@ -0,0 +1,33 @@ +// @flow + +// Copyright (c) 2018 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 queryString from 'query-string'; +import { matchPath } from 'react-router-dom'; + +import prefixUrl from '../../utils/prefix-url'; + +export const ROUTE_PATH = prefixUrl('/search'); + +const ROUTE_MATCHER = { path: ROUTE_PATH, strict: true, exact: true }; + +export function matches(path: string) { + return Boolean(matchPath(path, ROUTE_MATCHER)); +} + +export function getUrl(query?: ?Object) { + const search = query ? queryString.stringify(query) : ''; + return prefixUrl(`/search?${search}`); +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiff.css b/packages/jaeger-ui/src/components/TraceDiff/TraceDiff.css new file mode 100644 index 0000000000..e628fe8f83 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiff.css @@ -0,0 +1,24 @@ +/* +Copyright (c) 2018 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. +*/ + +.TraceDiff--graphWrapper { + background: #f0f0f0; + bottom: 0; + left: 0; + position: fixed; + right: 0; + top: 0; +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiff.js b/packages/jaeger-ui/src/components/TraceDiff/TraceDiff.js new file mode 100644 index 0000000000..15fa072b2d --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiff.js @@ -0,0 +1,185 @@ +// @flow + +// Copyright (c) 2018 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 queryString from 'query-string'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import type { Match, RouterHistory } from 'react-router-dom'; + +import { actions as diffActions } from './duck'; +import { getUrl } from './url'; +import TraceDiffGraph from './TraceDiffGraph'; +import TraceDiffHeader from './TraceDiffHeader'; +import * as jaegerApiActions from '../../actions/jaeger-api'; +import { TOP_NAV_HEIGHT } from '../../constants'; + +import type { FetchedTrace, ReduxState } from '../../types'; +import type { TraceDiffState } from '../../types/trace-diff'; + +import './TraceDiff.css'; + +type Props = { + a: ?string, + b: ?string, + cohort: string[], + fetchMultipleTraces: (string[]) => void, + forceState: TraceDiffState => void, + history: RouterHistory, + tracesData: Map, + traceDiffState: TraceDiffState, +}; + +type State = { + graphTopOffset: number, +}; + +function syncStates(urlSt, reduxSt, forceState) { + const { a: urlA, b: urlB } = urlSt; + const { a: reduxA, b: reduxB } = reduxSt; + if (urlA !== reduxA || urlB !== reduxB) { + forceState(urlSt); + return; + } + const urlCohort = new Set(urlSt.cohort || []); + const reduxCohort = new Set(reduxSt.cohort || []); + if (urlCohort.size !== reduxCohort.size) { + forceState(urlSt); + return; + } + const needSync = Array.from(urlCohort).some(id => !reduxCohort.has(id)); + if (needSync) { + forceState(urlSt); + } +} + +export class TraceDiffImpl extends React.PureComponent { + props: Props; + headerWrapperElm: ?Element; + + constructor() { + super(); + this.headerWrapperElm = null; + this.state = { + graphTopOffset: TOP_NAV_HEIGHT, + }; + } + + componentDidMount() { + this.processProps(); + } + + componentDidUpdate() { + this.setGraphTopOffset(); + this.processProps(); + } + + headerWrapperRef = (elm: ?Element) => { + this.headerWrapperElm = elm; + this.setGraphTopOffset(); + }; + + setGraphTopOffset() { + if (this.headerWrapperElm) { + const graphTopOffset = TOP_NAV_HEIGHT + this.headerWrapperElm.clientHeight; + if (this.state.graphTopOffset !== graphTopOffset) { + this.setState({ graphTopOffset }); + } + } else { + this.setState({ graphTopOffset: TOP_NAV_HEIGHT }); + } + } + + processProps() { + const { a, b, cohort, fetchMultipleTraces, forceState, tracesData, traceDiffState } = this.props; + syncStates({ a, b, cohort }, traceDiffState, forceState); + const cohortData = cohort.map(id => tracesData.get(id) || { id, state: null }); + const needForDiffs = cohortData.filter(ft => ft.state == null).map(ft => ft.id); + if (needForDiffs.length) { + fetchMultipleTraces(needForDiffs); + } + } + + diffSetUrl(change: { newA?: ?string, newB?: ?string }) { + const { newA, newB } = change; + const { a, b, cohort, history } = this.props; + const url = getUrl({ a: newA || a, b: newB || b, cohort }); + history.push(url); + } + + diffSetA = (id: string) => { + const newA = id.toLowerCase(); + this.diffSetUrl({ newA }); + }; + + diffSetB = (id: string) => { + const newB = id.toLowerCase(); + this.diffSetUrl({ newB }); + }; + + render() { + const { a, b, cohort, tracesData } = this.props; + const { graphTopOffset } = this.state; + const traceA = a ? tracesData.get(a) || { id: a } : null; + const traceB = b ? tracesData.get(b) || { id: b } : null; + const cohortData: FetchedTrace[] = cohort.map(id => tracesData.get(id) || { id }); + return ( + +
+ +
+
+ +
+
+ ); + } +} + +// TODO(joe): simplify but do not invalidate the URL +export function mapStateToProps(state: ReduxState, ownProps: { match: Match }) { + const { a, b } = ownProps.match.params; + const { cohort: origCohort = [] } = queryString.parse(state.router.location.search); + const fullCohortSet: Set = new Set([].concat(a, b, origCohort).filter(Boolean)); + const cohort: string[] = Array.from(fullCohortSet); + const { traces } = state.trace; + const kvPairs = cohort.map(id => [id, traces[id] || { id, state: null }]); + const tracesData: Map = new Map(kvPairs); + return { + a, + b, + cohort, + tracesData, + traceDiffState: state.traceDiff, + }; +} + +// export for tests +export function mapDispatchToProps(dispatch: Function) { + const { fetchMultipleTraces } = bindActionCreators(jaegerApiActions, dispatch); + const { forceState } = bindActionCreators(diffActions, dispatch); + return { fetchMultipleTraces, forceState }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(TraceDiffImpl); diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/TraceDiffGraph.css b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/TraceDiffGraph.css new file mode 100644 index 0000000000..9d2353eb4b --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/TraceDiffGraph.css @@ -0,0 +1,92 @@ +/* +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. +*/ + +.TraceDiffGraph--graphWrapper { + bottom: 0; + cursor: move; + left: 0; + overflow: auto; + position: absolute; + right: 0; + top: 0; +} + +.TraceDiffGraph--errorsWrapper { + background: #eee; + bottom: 0; + left: 0; + overflow: auto; + padding: 5rem 3.5rem; + position: absolute; + right: 0; + top: 0; +} + +.TraceDiffGraph--errorMessage { + font-size: 1.8rem; +} + +.TraceDiffGraph--dag { + stroke-width: 1.2; +} + +.TraceDiffGraph--dag.is-small { + stroke-width: 0.7; +} + +/* DAG minimap */ + +.TraceDiffGraph--miniMap { + align-items: flex-end; + bottom: 1rem; + display: flex; + left: 1rem; + position: absolute; + z-index: 1; +} + +.TraceDiffGraph--miniMap > .plexus-MiniMap--item { + border: 1px solid #777; + background: #999; + box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3); + margin-right: 1rem; + position: relative; +} + +.TraceDiffGraph--miniMap > .plexus-MiniMap--map { + /* dynamic widht, height */ + box-sizing: content-box; + cursor: not-allowed; +} + +.TraceDiffGraph--miniMap .plexus-MiniMap--mapActive { + /* dynamic: width, height, transform */ + background: #ccc; + position: absolute; +} + +.TraceDiffGraph--miniMap > .plexus-MiniMap--button { + background: #ccc; + color: #888; + cursor: pointer; + font-size: 1.6em; + line-height: 0; + padding: 0.1rem; +} + +.TraceDiffGraph--miniMap > .plexus-MiniMap--button:hover { + background: #ddd; +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/TraceDiffGraph.js b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/TraceDiffGraph.js new file mode 100644 index 0000000000..2f182644f1 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/TraceDiffGraph.js @@ -0,0 +1,118 @@ +// @flow + +// Copyright (c) 2018 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 { DirectedGraph, LayoutManager } from '@jaegertracing/plexus'; + +import drawNode from './drawNode'; +import ErrorMessage from '../../common/ErrorMessage'; +import LoadingIndicator from '../../common/LoadingIndicator'; +import { fetchedState } from '../../../constants'; +import convPlexus from '../../../model/trace-dag/convPlexus'; +import TraceDag from '../../../model/trace-dag/TraceDag'; + +import type { FetchedTrace } from '../../../types'; + +import './TraceDiffGraph.css'; + +type Props = { + a: ?FetchedTrace, + b: ?FetchedTrace, +}; + +const { classNameIsSmall } = DirectedGraph.propsFactories; + +function setOnEdgesContainer(state: Object) { + const { zoomTransform } = state; + if (!zoomTransform) { + return null; + } + const { k } = zoomTransform; + const opacity = 0.1 + k * 0.9; + return { style: { opacity } }; +} + +export default class TraceDiffGraph extends React.PureComponent { + props: Props; + + layoutManager: LayoutManager; + + constructor(props: Props) { + super(props); + this.layoutManager = new LayoutManager({ useDotEdges: true, splines: 'polyline' }); + } + + componentWillUnmount() { + this.layoutManager.stopAndRelease(); + } + + render() { + const { a, b } = this.props; + if (!a || !b) { + return

At least two Traces are needed

; + } + if (a.error || b.error) { + return ( +
+ {a.error && ( + + )} + {b.error && ( + + )} +
+ ); + } + if (a.state === fetchedState.LOADING || b.state === fetchedState.LOADING) { + return ; + } + const aData = a.data; + const bData = b.data; + if (!aData || !bData) { + return
; + } + const aTraceDag = TraceDag.newFromTrace(aData); + const bTraceDag = TraceDag.newFromTrace(bData); + const diffDag = TraceDag.diff(aTraceDag, bTraceDag); + const { edges, vertices } = convPlexus(diffDag.nodesMap); + + return ( +
+ +
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/drawNode.css b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/drawNode.css new file mode 100644 index 0000000000..f09ba89814 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/drawNode.css @@ -0,0 +1,96 @@ +/* +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. +*/ + +.DiffNode { + background: #bbb; + border: 1px solid #777; + box-shadow: 0 0px 3px rgba(0, 0, 0, 0.2); + cursor: pointer; + white-space: nowrap; +} + +.TraceDiffGraph--dag.is-small .DiffNode > tbody { + opacity: 0; +} + +.DiffNode.is-changed { + color: rgba(0, 0, 0, 0.85); +} + +.DiffNode.is-more { + background: #78d539; + border-color: #2a8f04; +} + +.DiffNode.is-added { + background: #2a8f04; + border: none; + color: #fff; +} + +.DiffNode.is-less { + border-color: #cc1616; + background: #ffa39e; +} + +.DiffNode.is-removed { + background: #cc1616; + border: none; + color: #fff; +} + +.DiffNode--metricCell { + padding: 0.3rem 0.5rem; + background: rgba(255, 255, 255, 0.3); +} + +.DiffNode--metricCell.is-changed { + font-weight: 500; +} + +.DiffNode--metricCell.is-added, +.DiffNode--metricCell.is-removed { + background: rgba(0, 0, 0, 0.2); +} + +.DiffNode--metricSymbol { + margin: 0 0.1em; +} + +.DiffNode--labelCell { + padding: 0.3rem 0.5rem 0.3rem 0.75rem; +} + +/* Tweak the popover aesthetics - unfortunate but necessary */ + +.DiffNode--popover .ant-popover-inner-content { + padding: 0; + position: relative; +} + +.DiffNode--popover.is-same .ant-popover-arrow { + background: #777; +} + +.DiffNode--popover.is-more .ant-popover-arrow, +.DiffNode--popover.is-added .ant-popover-arrow { + background: #2a8f04; +} + +.DiffNode--popover.is-less .ant-popover-arrow, +.DiffNode--popover.is-removed .ant-popover-arrow { + background: #cc1616; +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/drawNode.js b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/drawNode.js new file mode 100644 index 0000000000..cbe2d3b9f6 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/drawNode.js @@ -0,0 +1,87 @@ +// @flow + +// Copyright (c) 2018 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 { Popover } from 'antd'; +import cx from 'classnames'; + +import type { PVertex } from '../../../model/trace-dag/types'; + +import './drawNode.css'; + +type Props = { + a: number, + b: number, + operation: string, + service: string, +}; + +const abs = Math.abs; +const max = Math.max; + +class DiffNode extends React.PureComponent { + props: Props; + + render() { + const { a, b, operation, service } = this.props; + const isSame = a === b; + const className = cx({ + 'is-same': isSame, + 'is-changed': !isSame, + 'is-more': b > a && a > 0, + 'is-added': a === 0, + 'is-less': a > b && b > 0, + 'is-removed': b === 0, + }); + const chgSign = a < b ? '+' : '-'; + const table = ( + + + + + + + + {isSame ? null : ( + + )} + + + +
+ {isSame ? null : {chgSign}} + {isSame ? a : abs(b - a)} + + {service} +
+ {chgSign} + {a === 0 || b === 0 ? 100 : abs((a - b) / max(a, b) * 100).toFixed(0)} + % + {operation}
+ ); + + return ( + + {table} + + ); + } +} + +export default function drawNode(vertex: PVertex) { + const { data, operation, service } = vertex.data; + return ; +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/index.js b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/index.js new file mode 100644 index 0000000000..61c7e35901 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffGraph/index.js @@ -0,0 +1,17 @@ +// @flow + +// 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. + +export { default } from './TraceDiffGraph'; diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/CohortTable.css b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/CohortTable.css new file mode 100644 index 0000000000..2e6348c6c1 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/CohortTable.css @@ -0,0 +1,24 @@ +/* +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. +*/ + +.CohortTable--traceName { + color: rgba(0, 0, 0, 0.85); +} + +.CohortTable--needMoreMsg { + border-radius: 4px; + padding: 1rem; +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/CohortTable.js b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/CohortTable.js new file mode 100644 index 0000000000..a7b36d6021 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/CohortTable.js @@ -0,0 +1,135 @@ +// @flow + +// 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 { Table, Tag } from 'antd'; + +import RelativeDate from '../../common/RelativeDate'; +import TraceName from '../../common/TraceName'; +import { fetchedState } from '../../../constants'; +import { formatDuration } from '../../../utils/date'; + +import type { FetchedTrace } from '../../../types'; + +import './CohortTable.css'; + +type Props = { + selection: { + [string]: { label: string }, + }, + current: ?string, + cohort: FetchedTrace[], + selectTrace: string => void, +}; + +const { Column } = Table; + +const defaultRowSelection = { + hideDefaultSelections: true, + type: 'radio', +}; + +const NEED_MORE_TRACES_MESSAGE = ( +

+ Enter a Trace ID or perform a search and select from the results. +

+); + +export default class CohortTable extends React.PureComponent { + props: Props; + + getCheckboxProps = (record: FetchedTrace) => { + const { current, selection } = this.props; + const { id, state } = record; + if (state === fetchedState.ERROR || (id in selection && id !== current)) { + return { disabled: true }; + } + return {}; + }; + + render() { + const { cohort, current, selection, selectTrace } = this.props; + const rowSelection = { + ...defaultRowSelection, + getCheckboxProps: this.getCheckboxProps, + onChange: ids => selectTrace(ids[0]), + selectedRowKeys: current ? [current] : [], + }; + + return [ + + {value && value.slice(0, 7)}} + /> + { + const { data, error, id, state } = record; + const { traceName } = data || {}; + const { label } = selection[id] || {}; + return ( + + {label != null && ( + + {label} + + )} + + + ); + }} + /> + + record.state === fetchedState.DONE && ( + + ) + } + /> + record.state === fetchedState.DONE && formatDuration(value)} + /> + +
, + cohort.length < 2 && NEED_MORE_TRACES_MESSAGE, + ]; + } +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceDiffHeader.css b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceDiffHeader.css new file mode 100644 index 0000000000..f4d0904859 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceDiffHeader.css @@ -0,0 +1,47 @@ +/* +Copyright (c) 2018 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. +*/ + +.TraecDiffHeader { + background: #fff; + border-bottom: 1px solid #d8d8d8; + box-shadow: 0 2px 5px 0px rgba(0, 0, 0, 0.35); + display: flex; + flex: 0; + position: relative; + z-index: 1; +} + +.TraecDiffHeader--labelItem, +.TraecDiffHeader--labelItem-darkened { + align-items: center; + border-right: 1px solid #d8d8d8; + display: flex; + padding: 0 1.25rem; +} + +.TraecDiffHeader--labelItem-darkened { + background: #eee; +} + +/* Unfortunate but necessary */ + +.TraceDiffHeader--popover .ant-popover-title { + padding: 0.25rem; +} + +.TraceDiffHeader--popover .ant-popover-inner-content { + padding: 0; +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceDiffHeader.js b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceDiffHeader.js new file mode 100644 index 0000000000..59bce3ee40 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceDiffHeader.js @@ -0,0 +1,145 @@ +// @flow + +// 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 { Popover } from 'antd'; + +import CohortTable from './CohortTable'; +import TraceHeader from './TraceHeader'; +import TraceIdInput from './TraceIdInput'; + +import type { FetchedTrace } from '../../../types'; + +import './TraceDiffHeader.css'; + +type Props = { + a: ?FetchedTrace, + b: ?FetchedTrace, + cohort: FetchedTrace[], + diffSetA: string => void, + diffSetB: string => void, +}; + +type State = { + tableVisible: ?('a' | 'b'), +}; + +export default class TraceDiffHeader extends React.PureComponent { + props: Props; + + _toggleTableA: boolean => void; + _toggleTableB: boolean => void; + _diffSetA: string => void; + _diffSetB: string => void; + + state = { + tableVisible: null, + }; + + constructor(props: Props) { + super(props); + this._toggleTableA = this._toggleTable.bind(this, 'a'); + this._toggleTableB = this._toggleTable.bind(this, 'b'); + this._diffSetA = this._diffSetTrace.bind(this, 'a'); + this._diffSetB = this._diffSetTrace.bind(this, 'b'); + } + + _toggleTable(which: 'a' | 'b', visible: boolean) { + const tableVisible = visible ? which : null; + this.setState({ tableVisible }); + } + + _diffSetTrace(which: 'a' | 'b', id: string) { + if (which === 'a') { + this.props.diffSetA(id); + } else { + this.props.diffSetB(id); + } + this.setState({ tableVisible: null }); + } + + render() { + const { a, b, cohort } = this.props; + const { tableVisible } = this.state; + const { data: aData = {}, id: aId, state: aState, error: aError } = a || {}; + const { data: bData = {}, id: bId, state: bState, error: bError } = b || {}; + const selection = { + [aId || '_']: { label: 'A' }, + [bId || '__']: { label: 'B' }, + }; + const cohortTableA = ( + + ); + const cohortTableB = ( + + ); + return ( +
+
+

A

+
+ } + content={cohortTableA} + visible={tableVisible === 'a'} + onVisibleChange={this._toggleTableA} + > +
+ +
+
+
+

VS

+
+
+

B

+
+ } + content={cohortTableB} + visible={tableVisible === 'b'} + onVisibleChange={this._toggleTableB} + > +
+ +
+
+
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceHeader.css b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceHeader.css new file mode 100644 index 0000000000..8e4b005cce --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceHeader.css @@ -0,0 +1,60 @@ +/* +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. +*/ + +.TraecDiffHeader--traceHeader { + align-items: stretch; + background: #fafafa; + border-right: 1px solid #d8d8d8; + display: flex; + flex-direction: column; + flex: 1; +} + +.TraecDiffHeader--traceTitle { + align-items: center; + background: #fff; + border-bottom: 1px solid #eee; + display: flex; + flex: 1; + font-size: 1.8em; + margin: 0; + padding: 0.25rem 2.5rem 0.25rem 1.25rem; + position: relative; +} + +.TraecDiffHeader--traceTitle.is-error { + color: #c00; +} + +.TraecDiffHeader--traceTitleChevron { + color: rgba(0, 0, 0, 0.85); + font-size: 0.75em; + margin-right: 0.75em; + position: absolute; + right: 0; + top: calc(50% - 0.5em); +} + +.TraecDiffHeader--traceAttributes { + list-style: none; + margin: 0; + padding: 0.25rem 1.25rem; +} + +.TraecDiffHeader--traceAttr { + display: inline-block; + margin-right: 1rem; +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceHeader.js b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceHeader.js new file mode 100644 index 0000000000..35a313c2ef --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceHeader.js @@ -0,0 +1,105 @@ +// @flow + +// 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 IoChevronDown from 'react-icons/lib/io/chevron-down'; + +import RelativeDate from '../../common/RelativeDate'; +import TraceName from '../../common/TraceName'; +import { fetchedState } from '../../../constants'; +import { formatDuration } from '../../../utils/date'; + +import type { FetchedState } from '../../../types'; +import type { ApiError } from '../../../types/api-error'; + +import './TraceHeader.css'; + +type Props = { + duration: ?number, + error?: ApiError, + startTime: ?number, + state: ?FetchedState, + traceID: ?string, + traceName: ?string, + totalSpans: ?number, +}; + +type AttrsProps = { + startTime: ?number, + duration: ?number, + totalSpans: ?number, +}; + +function EmptyAttrs() { + return ( +
    +
  •  
  • +
+ ); +} + +function Attrs(props: AttrsProps) { + const { startTime, duration, totalSpans } = props; + return ( +
    +
  • + + + +
  • +
  • + Duration: + {formatDuration(duration || 0)} +
  • +
  • + Spans: {totalSpans || 0} +
  • +
+ ); +} + +export default function TraceHeader(props: Props) { + const { duration, error, startTime, state, traceID, totalSpans, traceName } = props; + const AttrsComponent = state === fetchedState.DONE ? Attrs : EmptyAttrs; + return ( +
+

+ + {traceID ? ( + + {' '} + + {(traceID || '').slice(0, 7)} + + + ) : ( + Select a Trace... + )} + + +

+ +
+ ); +} + +TraceHeader.defaultProps = { + startTime: null, + duration: null, + error: undefined, + state: undefined, + totalSpans: null, +}; diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceIdInput.js b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceIdInput.js new file mode 100644 index 0000000000..ab5fd68927 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/TraceIdInput.js @@ -0,0 +1,29 @@ +// @flow + +// 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 { Input } from 'antd'; + +type Props = { + selectTrace: string => void, +}; + +const { Search } = Input; + +export default function TraceIdInput(props: Props) { + const { selectTrace } = props; + return ; +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/index.js b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/index.js new file mode 100644 index 0000000000..01846c3ac4 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/TraceDiffHeader/index.js @@ -0,0 +1,17 @@ +// @flow + +// 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. + +export { default } from './TraceDiffHeader'; diff --git a/packages/jaeger-ui/src/components/TraceDiff/duck.js b/packages/jaeger-ui/src/components/TraceDiff/duck.js new file mode 100644 index 0000000000..22cdbc7732 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/duck.js @@ -0,0 +1,99 @@ +// @flow + +// 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 { createActions, handleActions } from 'redux-actions'; + +import generateActionTypes from '../../utils/generate-action-types'; + +// traceDiff { +// a: ?id, +// b: ?id +// cohort: id[], +// } + +export function newInitialState() { + return { + cohort: [], + a: null, + b: null, + }; +} + +export const actionTypes = generateActionTypes('@jaeger-ui/trace-diff', [ + 'COHORT_ADD_TRACE', + 'COHORT_REMOVE_TRACE', + 'DIFF_SET_A', + 'DIFF_SET_B', + 'FORCE_STATE', +]); + +const fullActions = createActions({ + [actionTypes.COHORT_ADD_TRACE]: traceID => ({ traceID }), + [actionTypes.COHORT_REMOVE_TRACE]: traceID => ({ traceID }), + [actionTypes.DIFF_SET_A]: traceID => ({ traceID }), + [actionTypes.DIFF_SET_B]: traceID => ({ traceID }), + [actionTypes.FORCE_STATE]: newState => ({ newState }), +}); + +export const actions = fullActions.jaegerUi.traceDiff; + +function cohortAddTrace(state, { payload }) { + const { traceID } = payload; + const cohort = state.cohort.slice(); + if (cohort.indexOf(traceID) >= 0) { + return state; + } + cohort.push(traceID); + return { ...state, cohort }; +} + +function cohortRemoveTrace(state, { payload }) { + const { traceID } = payload; + const cohort = state.cohort.slice(); + const i = cohort.indexOf(traceID); + if (i < 0) { + return state; + } + cohort.splice(i, 1); + const a = state.a === traceID ? null : state.a; + const b = state.b === traceID ? null : state.b; + return { ...state, a, b, cohort }; +} + +function diffSetA(state, { payload }) { + const a = payload.traceID; + return { ...state, a }; +} + +function diffSetB(state, { payload }) { + const b = payload.traceID; + return { ...state, b }; +} + +function forceState(state, { payload }) { + return payload.newState; +} + +export default handleActions( + { + [actionTypes.COHORT_ADD_TRACE]: cohortAddTrace, + [actionTypes.COHORT_REMOVE_TRACE]: cohortRemoveTrace, + [actionTypes.DIFF_SET_A]: diffSetA, + [actionTypes.DIFF_SET_B]: diffSetB, + [actionTypes.FORCE_STATE]: forceState, + }, + newInitialState() +); diff --git a/packages/jaeger-ui/src/components/TraceDiff/getValidState.js b/packages/jaeger-ui/src/components/TraceDiff/getValidState.js new file mode 100644 index 0000000000..ad1701ba47 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/getValidState.js @@ -0,0 +1,24 @@ +// @flow + +// 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. + +export default function getValidState(state?: ?{ a?: ?string, b?: ?string, cohort: string[] }) { + const { a: stA, b: stB, cohort: stCohort } = state || {}; + const cohortSet = new Set([].concat(stA, stB, stCohort || []).filter(Boolean)); + const cohort: string[] = Array.from(cohortSet); + const a = cohort[0]; + const b = cohort[1]; + return { a, b, cohort }; +} diff --git a/packages/jaeger-ui/src/components/TraceDiff/index.js b/packages/jaeger-ui/src/components/TraceDiff/index.js new file mode 100644 index 0000000000..2ee73a6cf8 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/index.js @@ -0,0 +1,17 @@ +// @flow + +// 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. + +export { default } from './TraceDiff'; diff --git a/packages/jaeger-ui/src/components/TraceDiff/url.js b/packages/jaeger-ui/src/components/TraceDiff/url.js new file mode 100644 index 0000000000..072d054801 --- /dev/null +++ b/packages/jaeger-ui/src/components/TraceDiff/url.js @@ -0,0 +1,35 @@ +// @flow + +// Copyright (c) 2018 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 queryString from 'query-string'; +import { matchPath } from 'react-router-dom'; + +import getValidState from './getValidState'; +import prefixUrl from '../../utils/prefix-url'; + +export const ROUTE_PATH = prefixUrl('/trace/:a?\\.\\.\\.:b?'); + +const ROUTE_MATCHER = { path: ROUTE_PATH, strict: true, exact: true }; + +export function matches(path: string) { + return Boolean(matchPath(path, ROUTE_MATCHER)); +} + +export function getUrl(state?: ?{ a?: ?string, b?: ?string, cohort: string[] }) { + const { a, b, cohort } = getValidState(state); + const search = queryString.stringify({ cohort }); + return prefixUrl(`/trace/${a || ''}...${b || ''}${search ? '?' : ''}${search}`); +} diff --git a/packages/jaeger-ui/src/components/TracePage/ScrollManager.js b/packages/jaeger-ui/src/components/TracePage/ScrollManager.js index e7d151bf54..a1175437a3 100644 --- a/packages/jaeger-ui/src/components/TracePage/ScrollManager.js +++ b/packages/jaeger-ui/src/components/TracePage/ScrollManager.js @@ -14,7 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { Span, Trace } from '../../types'; +import type { Span, Trace } from '../../types/trace'; /** * `Accessors` is necessary because `ScrollManager` needs to be created by diff --git a/packages/jaeger-ui/src/components/TracePage/SpanGraph/index.js b/packages/jaeger-ui/src/components/TracePage/SpanGraph/index.js index 1dc76ed97e..dfbed2cded 100644 --- a/packages/jaeger-ui/src/components/TracePage/SpanGraph/index.js +++ b/packages/jaeger-ui/src/components/TracePage/SpanGraph/index.js @@ -20,7 +20,7 @@ import CanvasSpanGraph from './CanvasSpanGraph'; import TickLabels from './TickLabels'; import ViewingLayer from './ViewingLayer'; import type { ViewRange, ViewRangeTimeUpdate } from '../types'; -import type { Span, Trace } from '../../../types'; +import type { Span, Trace } from '../../../types/trace'; const TIMELINE_TICK_INTERVAL = 4; diff --git a/packages/jaeger-ui/src/components/TracePage/SpanGraph/render-into-canvas.js b/packages/jaeger-ui/src/components/TracePage/SpanGraph/render-into-canvas.js index 55b81bf871..6dc565d65b 100644 --- a/packages/jaeger-ui/src/components/TracePage/SpanGraph/render-into-canvas.js +++ b/packages/jaeger-ui/src/components/TracePage/SpanGraph/render-into-canvas.js @@ -42,6 +42,7 @@ export default function renderIntoCanvas( itemHeight = 1 / (MIN_TOTAL_HEIGHT / items.length); } const ctx = canvas.getContext('2d'); + const fillCache: Map = new Map(); for (let i = 0; i < items.length; i++) { const { valueWidth, valueOffset, serviceName } = items[i]; // eslint-disable-next-line no-bitwise @@ -51,9 +52,16 @@ export default function renderIntoCanvas( if (width < MIN_WIDTH) { width = MIN_WIDTH; } - ctx.fillStyle = `rgba(${getFillColor(serviceName) - .concat(ALPHA) - .join()})`; + let fillStyle = fillCache.get(serviceName); + if (fillStyle) { + ctx.fillStyle = fillStyle; + } else { + fillStyle = `rgba(${getFillColor(serviceName) + .concat(ALPHA) + .join()})`; + fillCache.set(serviceName, fillStyle); + ctx.fillStyle = fillStyle; + } ctx.fillRect(x, i * itemYChange, width, itemHeight); } } diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js index 94c46fe8d2..02bdc6ccfd 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js @@ -21,7 +21,7 @@ import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; import * as markers from './AccordianKeyValues.markers'; import KeyValuesTable from './KeyValuesTable'; -import type { KeyValuePair, Link } from '../../../../types'; +import type { KeyValuePair, Link } from '../../../../types/trace'; import './AccordianKeyValues.css'; diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianLogs.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianLogs.js index 5f103977a8..5a1837f482 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianLogs.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianLogs.js @@ -21,7 +21,7 @@ import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; import AccordianKeyValues from './AccordianKeyValues'; import { formatDuration } from '../utils'; -import type { Log, KeyValuePair, Link } from '../../../../types'; +import type { Log, KeyValuePair, Link } from '../../../../types/trace'; import './AccordianLogs.css'; diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/DetailState.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/DetailState.js index fa8aa34d73..0242e07ded 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/DetailState.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/DetailState.js @@ -14,7 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { Log } from '../../../../types'; +import type { Log } from '../../../../types/trace'; /** * Which items of a {@link SpanDetail} component are expanded. diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.js index 42728dc685..229f2c977b 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/KeyValuesTable.js @@ -17,7 +17,7 @@ import * as React from 'react'; import jsonMarkup from 'json-markup'; import { Dropdown, Icon, Menu } from 'antd'; -import type { KeyValuePair, Link } from '../../../../types'; +import type { KeyValuePair, Link } from '../../../../types/trace'; import './KeyValuesTable.css'; diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js index f97995783b..fc6948c52a 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js @@ -22,7 +22,7 @@ import AccordianLogs from './AccordianLogs'; import DetailState from './DetailState'; import { formatDuration } from '../utils'; import LabeledList from '../../../common/LabeledList'; -import type { Log, Span, KeyValuePair, Link } from '../../../../types'; +import type { Log, Span, KeyValuePair, Link } from '../../../../types/trace'; import './index.css'; diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetailRow.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetailRow.js index 928c11a1ac..8ce34e8639 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetailRow.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetailRow.js @@ -20,7 +20,7 @@ import SpanDetail from './SpanDetail'; import DetailState from './SpanDetail/DetailState'; import SpanTreeOffset from './SpanTreeOffset'; import TimelineRow from './TimelineRow'; -import type { Log, Span, KeyValuePair, Link } from '../../../types'; +import type { Log, Span, KeyValuePair, Link } from '../../../types/trace'; import './SpanDetailRow.css'; diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js index cd3046df10..be77c1a020 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js @@ -33,7 +33,7 @@ import { } from './utils'; import getLinks from '../../../model/link-patterns'; import type { Accessors } from '../ScrollManager'; -import type { Log, Span, Trace, KeyValuePair } from '../../../types'; +import type { Log, Span, Trace, KeyValuePair } from '../../../types/trace'; import colorGenerator from '../../../utils/color-generator'; import './VirtualizedTraceView.css'; diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.track.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.track.js index 77fa53d616..6d87f13372 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.track.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.track.js @@ -37,7 +37,8 @@ function trackParent(store: Store, action: any) { const { spanID } = action.payload; const traceID = st.traceTimeline.traceID; const isHidden = st.traceTimeline.childrenHiddenIDs.has(spanID); - const span = st.trace.traces[traceID].spans.find(sp => sp.spanID === spanID); + const trace = st.trace.traces[traceID].data; + const span = trace.spans.find(sp => sp.spanID === spanID); if (span) { trackEvent(CATEGORY_PARENT, getToggleValue(!isHidden), span.depth); } diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.track.test.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.track.test.js index 87950d2b7c..f14bce23a4 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.track.test.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.track.test.js @@ -18,6 +18,7 @@ jest.mock('../../../utils/tracking'); import DetailState from './SpanDetail/DetailState'; import * as track from './duck.track'; import { actionTypes as types } from './duck'; +import { fetchedState } from '../../../constants'; import { trackEvent } from '../../../utils/tracking'; describe('middlewareHooks', () => { @@ -30,7 +31,9 @@ describe('middlewareHooks', () => { trace: { traces: { [traceID]: { - spans: [{ spanID, depth: spanDepth }], + id: traceID, + data: { spans: [{ spanID, depth: spanDepth }] }, + state: fetchedState.DONE, }, }, }, diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/index.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/index.js index 28e4e92095..db10c2650f 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/index.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/index.js @@ -22,9 +22,10 @@ import { actions } from './duck'; import TimelineHeaderRow from './TimelineHeaderRow'; import VirtualizedTraceView from './VirtualizedTraceView'; import { merge as mergeShortcuts } from '../keyboard-shortcuts'; + import type { Accessors } from '../ScrollManager'; import type { ViewRange, ViewRangeTimeUpdate } from '../types'; -import type { Span, Trace } from '../../../types'; +import type { Span, Trace } from '../../../types/trace'; import './index.css'; diff --git a/packages/jaeger-ui/src/components/TracePage/index.js b/packages/jaeger-ui/src/components/TracePage/index.js index faec98da1b..eb8eaa28ca 100644 --- a/packages/jaeger-ui/src/components/TracePage/index.js +++ b/packages/jaeger-ui/src/components/TracePage/index.js @@ -26,7 +26,6 @@ import { bindActionCreators } from 'redux'; import ArchiveNotifier from './ArchiveNotifier'; import { actions as archiveActions } from './ArchiveNotifier/duck'; import { trackFilter, trackRange } from './index.track'; -import type { CombokeysHandler, ShortcutCallbacks } from './keyboard-shortcuts'; import { merge as mergeShortcuts, reset as resetShortcuts } from './keyboard-shortcuts'; import { cancel as cancelScroll, scrollBy, scrollTo } from './scroll-page'; import ScrollManager from './ScrollManager'; @@ -34,16 +33,18 @@ import SpanGraph from './SpanGraph'; import TracePageHeader from './TracePageHeader'; import { trackSlimHeaderToggle } from './TracePageHeader.track'; import TraceTimelineViewer from './TraceTimelineViewer'; -import type { ViewRange, ViewRangeTimeUpdate } from './types'; import ErrorMessage from '../common/ErrorMessage'; import LoadingIndicator from '../common/LoadingIndicator'; import * as jaegerApiActions from '../../actions/jaeger-api'; +import { fetchedState } from '../../constants'; import { getTraceName } from '../../model/trace-viewer'; -import type { Trace } from '../../types'; -import type { TraceArchive, TracesArchive } from '../../types/archive'; -import type { Config } from '../../types/config'; import prefixUrl from '../../utils/prefix-url'; +import type { CombokeysHandler, ShortcutCallbacks } from './keyboard-shortcuts'; +import type { ViewRange, ViewRangeTimeUpdate } from './types'; +import type { FetchedTrace, ReduxState } from '../../types'; +import type { TraceArchive } from '../../types/archive'; + import './index.css'; type TracePageProps = { @@ -54,8 +55,7 @@ type TracePageProps = { fetchTrace: string => void, history: RouterHistory, id: string, - loading: boolean, - trace: ?Trace, + trace: ?FetchedTrace, }; type TracePageState = { @@ -93,7 +93,8 @@ export function makeShortcutCallbacks(adjRange: (number, number) => void): Short return _mapValues(shortcutConfig, getHandler); } -export default class TracePage extends React.PureComponent { +// export for tests +export class TracePageImpl extends React.PureComponent { props: TracePageProps; state: TracePageState; @@ -113,7 +114,8 @@ export default class TracePage extends React.PureComponent :
; + if (!trace || trace.state === fetchedState.LOADING) { + return ; } - if (trace instanceof Error) { - return ; + const { data } = trace; + if (trace.state === fetchedState.ERROR || !data) { + return ; } - const { duration, processes, spans, startTime, traceID } = trace; + const { duration, processes, spans, startTime, traceID } = data; const maxSpanDepth = _maxBy(spans, 'depth').depth + 1; const numberOfServices = new Set(_values(processes).map(p => p.serviceName)).size; return ( @@ -284,7 +288,7 @@ export default class TracePage extends React.PureComponent {!slimView && ( ', () => { const trace = transformTraceData(traceGenerator.trace({})); const defaultProps = { - trace, + trace: { data: trace, state: fetchedState.DONE }, fetchTrace() {}, id: trace.traceID, }; @@ -91,10 +93,10 @@ describe('', () => { expect(wrapper.find(SpanGraph).length).toBe(1); }); - it('renders an empty page when not provided a trace', () => { + it('renders a a loading indicator when not provided a fetched trace', () => { wrapper.setProps({ trace: null }); - const isEmpty = wrapper.matchesElement(
); - expect(isEmpty).toBe(true); + const loading = wrapper.find(LoadingIndicator); + expect(loading.length).toBe(1); }); it('renders an error message when given an error', () => { @@ -126,7 +128,7 @@ describe('', () => { // mount because `.componentDidUpdate()` wrapper = mount(); wrapper.setState({ viewRange: { time: [0.2, 0.8] } }); - wrapper.setProps({ trace: altTrace }); + wrapper.setProps({ id: altTrace.traceID, trace: { data: altTrace, state: fetchedState.DONE } }); expect(wrapper.state('viewRange')).toEqual({ time: { current: [0, 1] } }); }); @@ -134,7 +136,7 @@ describe('', () => { wrapper = shallow(); const scrollManager = wrapper.instance()._scrollManager; scrollManager.setTrace = jest.fn(); - wrapper.setProps({ trace }); + wrapper.setProps({ trace: { data: trace } }); expect(scrollManager.setTrace.mock.calls).toEqual([[trace]]); }); @@ -354,9 +356,8 @@ describe('mapStateToProps()', () => { const trace = {}; const state = { trace: { - loading: false, traces: { - [id]: trace, + [id]: { data: trace, state: fetchedState.DONE }, }, }, config: { @@ -372,10 +373,9 @@ describe('mapStateToProps()', () => { const props = mapStateToProps(state, ownProps); expect(props).toEqual({ id, - trace, - loading: state.trace.loading, archiveEnabled: false, archiveTraceState: undefined, + trace: { data: {}, state: fetchedState.DONE }, }); }); }); diff --git a/packages/jaeger-ui/src/components/TracePage/url.js b/packages/jaeger-ui/src/components/TracePage/url.js new file mode 100644 index 0000000000..885b0c4182 --- /dev/null +++ b/packages/jaeger-ui/src/components/TracePage/url.js @@ -0,0 +1,23 @@ +// @flow + +// Copyright (c) 2018 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 prefixUrl from '../../utils/prefix-url'; + +export const ROUTE_PATH = prefixUrl('/trace/:id'); + +export function getUrl(id: string) { + return prefixUrl(`/trace/${id}`); +} diff --git a/packages/jaeger-ui/src/components/common/BreakableText.css b/packages/jaeger-ui/src/components/common/BreakableText.css new file mode 100644 index 0000000000..60b3711c18 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/BreakableText.css @@ -0,0 +1,20 @@ +/* +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. +*/ + +.BreakableText { + display: inline-block; + white-space: pre; +} diff --git a/packages/jaeger-ui/src/components/common/BreakableText.js b/packages/jaeger-ui/src/components/common/BreakableText.js new file mode 100644 index 0000000000..1598bcfe3c --- /dev/null +++ b/packages/jaeger-ui/src/components/common/BreakableText.js @@ -0,0 +1,51 @@ +// @flow + +// 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 './BreakableText.css'; + +const WORD_RX = /\W*\w+\W*/g; + +type Props = { + text: string, + className?: string, + wordRegexp?: RegExp, +}; + +export default function BreakableText(props: Props) { + const { className, text, wordRegexp = WORD_RX } = props; + if (!text) { + return typeof text === 'string' ? text : null; + } + const spans = []; + wordRegexp.exec(''); + let match = wordRegexp.exec(text); + while (match) { + spans.push( + + {match[0]} + + ); + match = wordRegexp.exec(text); + } + return spans; +} + +BreakableText.defaultProps = { + className: 'BreakableText', + wordRegexp: WORD_RX, +}; diff --git a/packages/jaeger-ui/src/components/common/ErrorMessage.js b/packages/jaeger-ui/src/components/common/ErrorMessage.js index 1817eff8b8..066affb3e4 100644 --- a/packages/jaeger-ui/src/components/common/ErrorMessage.js +++ b/packages/jaeger-ui/src/components/common/ErrorMessage.js @@ -22,6 +22,8 @@ import './ErrorMessage.css'; type ErrorMessageProps = { className?: string, + detailClassName?: string, + messageClassName?: string, error: ApiError, }; @@ -29,6 +31,7 @@ type SubPartProps = { className?: string, error: ApiError, wrap?: boolean, + wrapperClassName?: string, }; function ErrorAttr({ name, value }: { name: string, value: any }) { @@ -41,15 +44,16 @@ function ErrorAttr({ name, value }: { name: string, value: any }) { } function Message(props: SubPartProps) { - const { className, error, wrap } = props; + const { className, error, wrap, wrapperClassName } = props; + const cssClass = `ErrorMessage--msg ${className || ''}`; let msg: React.Node; if (typeof error === 'string') { - msg =

{error}

; + msg =

{error}

; } else { - msg =

{error.message}

; + msg =

{error.message}

; } if (wrap) { - return
{msg}
; + return
{msg}
; } return msg; } @@ -57,17 +61,18 @@ function Message(props: SubPartProps) { Message.defaultProps = { className: undefined, wrap: false, + wrapperClassName: undefined, }; function Details(props: SubPartProps) { - const { className, error, wrap } = props; + const { className, error, wrap, wrapperClassName } = props; if (typeof error === 'string') { return null; } const { httpStatus, httpStatusText, httpUrl, httpQuery, httpBody } = error; const bodyExcerpt = httpBody && httpBody.length > 1024 ? `${httpBody.slice(0, 1021).trim()}...` : httpBody; const details = ( -
+
{httpStatus ? : null} @@ -81,7 +86,7 @@ function Details(props: SubPartProps) { ); if (wrap) { - return
{details}
; + return
{details}
; } return details; } @@ -89,25 +94,33 @@ function Details(props: SubPartProps) { Details.defaultProps = { className: undefined, wrap: false, + wrapperClassName: undefined, }; -export default function ErrorMessage({ className, error }: ErrorMessageProps) { +export default function ErrorMessage({ + className, + detailClassName, + error, + messageClassName, +}: ErrorMessageProps) { if (!error) { return null; } if (typeof error === 'string') { - return ; + return ; } return (
- -
+ +
); } ErrorMessage.defaultProps = { className: undefined, + detailClassName: undefined, + messageClassName: undefined, }; ErrorMessage.Message = Message; diff --git a/packages/jaeger-ui/src/components/common/LoadingIndicator.css b/packages/jaeger-ui/src/components/common/LoadingIndicator.css index 7caaae008a..b576c37ab4 100644 --- a/packages/jaeger-ui/src/components/common/LoadingIndicator.css +++ b/packages/jaeger-ui/src/components/common/LoadingIndicator.css @@ -35,8 +35,12 @@ limitations under the License. 0 -0.5px rgba(0, 128, 128, 0.6); } -.LoadingIndicator--centered { +.LoadingIndicator.is-centered { display: block; margin-left: auto; margin-right: auto; } + +.LoadingIndicator.is-small { + font-size: 0.7em; +} diff --git a/packages/jaeger-ui/src/components/common/LoadingIndicator.js b/packages/jaeger-ui/src/components/common/LoadingIndicator.js index 7af781c0c5..1cdb4e2342 100644 --- a/packages/jaeger-ui/src/components/common/LoadingIndicator.js +++ b/packages/jaeger-ui/src/components/common/LoadingIndicator.js @@ -22,15 +22,22 @@ import './LoadingIndicator.css'; type LoadingIndicatorProps = { centered?: boolean, className?: string, + small?: boolean, }; export default function LoadingIndicator(props: LoadingIndicatorProps) { - const { centered, className, ...rest } = props; - const cls = `LoadingIndicator ${centered ? 'LoadingIndicator--centered' : ''} ${className || ''}`; + const { centered, className, small, ...rest } = props; + const cls = ` + LoadingIndicator + ${centered ? 'is-centered' : ''} + ${small ? 'is-small' : ''} + ${className || ''} + `; return ; } LoadingIndicator.defaultProps = { centered: false, className: undefined, + small: false, }; diff --git a/packages/jaeger-ui/src/components/common/RelativeDate.js b/packages/jaeger-ui/src/components/common/RelativeDate.js new file mode 100644 index 0000000000..1c8cbe2a8e --- /dev/null +++ b/packages/jaeger-ui/src/components/common/RelativeDate.js @@ -0,0 +1,33 @@ +// @flow + +// 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 moment from 'moment'; + +import { formatRelativeDate } from '../../utils/date'; + +type Props = { + fullMonthName: ?boolean, + includeTime: ?boolean, + value: number | Date | any, +}; + +export default function RelativeDate(props: Props) { + const { value, includeTime, fullMonthName } = props; + const m = !(value instanceof moment) ? moment(value) : value; + const dateStr = formatRelativeDate(m, Boolean(fullMonthName)); + const timeStr = includeTime ? `, ${m.format('h:mm:ss a')}` : ''; + return `${dateStr}${timeStr}`; +} diff --git a/packages/jaeger-ui/src/components/common/TraceName.css b/packages/jaeger-ui/src/components/common/TraceName.css new file mode 100644 index 0000000000..403dd75098 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/TraceName.css @@ -0,0 +1,19 @@ +/* +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. +*/ + +.TraceName.is-error { + color: #c00; +} diff --git a/packages/jaeger-ui/src/components/common/TraceName.js b/packages/jaeger-ui/src/components/common/TraceName.js new file mode 100644 index 0000000000..cdc4b7bb0b --- /dev/null +++ b/packages/jaeger-ui/src/components/common/TraceName.js @@ -0,0 +1,63 @@ +// @flow + +// 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 BreakableText from '../common/BreakableText'; +import LoadingIndicator from '../common/LoadingIndicator'; +import { fetchedState, FALLBACK_TRACE_NAME } from '../../constants'; + +import type { FetchedState } from '../../types'; +import type { ApiError } from '../../types/api-error'; + +import './TraceName.css'; + +type Props = { + breakable?: boolean, + className?: string, + error: ?ApiError, + state: ?FetchedState, + traceName: ?string, +}; + +export default function TraceName(props: Props) { + const { breakable, className, error, state, traceName } = props; + const isErred = state === fetchedState.ERROR; + let title = traceName || FALLBACK_TRACE_NAME; + let errorCssClass = ''; + if (isErred) { + errorCssClass = 'is-error'; + let titleStr = ''; + if (error) { + titleStr = typeof error === 'string' ? error : error.message || String(error); + } + if (!titleStr) { + titleStr = 'Error: Unknown error'; + } + title = ; + } else if (state === fetchedState.LOADING) { + title = ; + } else { + const text = traceName || FALLBACK_TRACE_NAME; + title = breakable ? : text; + } + return {title}; +} + +TraceName.defaultProps = { + breakable: false, + className: '', +}; diff --git a/packages/jaeger-ui/src/components/common/utils.css b/packages/jaeger-ui/src/components/common/utils.css index a9642006a8..9f5992ba65 100644 --- a/packages/jaeger-ui/src/components/common/utils.css +++ b/packages/jaeger-ui/src/components/common/utils.css @@ -18,6 +18,10 @@ limitations under the License. width: 100%; } +.u-flex-1 { + flex: 1; +} + .u-mt-vast { margin-top: 13rem; } @@ -26,6 +30,10 @@ limitations under the License. cursor: pointer; } +.u-tx-muted { + color: #aaa; +} + .u-tx-ellipsis { text-overflow: ellipsis; } @@ -60,14 +68,13 @@ limitations under the License. } .u-simple-scrollbars::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); + background: rgba(0, 0, 0, 0.2); } -.u-simple-scrollbars::-webkit-scrollbar-thumb:window-inactive { - background: rgba(0, 0, 0, 0.15); +.u-simple-scrollbars:hover::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.35); } -.u-simple-scrollbars::-webkit-scrollbar-thumb:hover { - background: rgba(128, 135, 139, 0.8); - background: rgba(0, 0, 0, 0.4); +.u-simple-scrollbars::-webkit-scrollbar-thumb:window-inactive { + background: rgba(0, 0, 0, 0.15); } diff --git a/packages/jaeger-ui/src/constants/index.js b/packages/jaeger-ui/src/constants/index.js index d6515888f5..98484f0ad3 100644 --- a/packages/jaeger-ui/src/constants/index.js +++ b/packages/jaeger-ui/src/constants/index.js @@ -1,3 +1,5 @@ +// @flow + // Copyright (c) 2017 Uber Technologies, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,5 +14,17 @@ // See the License for the specific language governing permissions and // limitations under the License. +export const TOP_NAV_HEIGHT = 47; + export const FALLBACK_DAG_MAX_NUM_SERVICES = 100; export const FALLBACK_TRACE_NAME = ''; + +export const FETCH_DONE = 'FETCH_DONE'; +export const FETCH_ERROR = 'FETCH_ERROR'; +export const FETCH_LOADING = 'FETCH_LOADING'; + +export const fetchedState = { + DONE: FETCH_DONE, + ERROR: FETCH_ERROR, + LOADING: FETCH_LOADING, +}; diff --git a/packages/jaeger-ui/src/constants/tag-keys.js b/packages/jaeger-ui/src/constants/tag-keys.js new file mode 100644 index 0000000000..266677b36a --- /dev/null +++ b/packages/jaeger-ui/src/constants/tag-keys.js @@ -0,0 +1,19 @@ +// @flow + +// Copyright (c) 2018 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. + +export const HTTP_METHOD = 'http.method'; +export const PEER_SERVICE = 'peer.service'; +export const SPAN_KIND = 'span.kind'; diff --git a/packages/jaeger-ui/src/middlewares/index.js b/packages/jaeger-ui/src/middlewares/index.js index a5fbf72d3a..f60dfac9eb 100644 --- a/packages/jaeger-ui/src/middlewares/index.js +++ b/packages/jaeger-ui/src/middlewares/index.js @@ -13,12 +13,11 @@ // limitations under the License. import promiseMiddleware from 'redux-promise-middleware'; -import queryString from 'query-string'; import { change } from 'redux-form'; import { replace } from 'react-router-redux'; import { searchTraces, fetchServiceOperations } from '../actions/jaeger-api'; -import prefixUrl from '../utils/prefix-url'; +import { getUrl as getSearchUrl } from '../components/SearchTracePage/url'; export { default as trackMiddleware } from './track'; @@ -40,7 +39,7 @@ export const loadOperationsForServiceMiddleware = store => next => action => { export const historyUpdateMiddleware = store => next => action => { if (action.type === String(searchTraces)) { - const url = prefixUrl(`/search?${queryString.stringify(action.meta.query)}`); + const url = getSearchUrl(action.meta.query); store.dispatch(replace(url)); } next(action); diff --git a/packages/jaeger-ui/src/model/link-patterns.js b/packages/jaeger-ui/src/model/link-patterns.js index 098806bf4f..e215751827 100644 --- a/packages/jaeger-ui/src/model/link-patterns.js +++ b/packages/jaeger-ui/src/model/link-patterns.js @@ -17,7 +17,7 @@ import _uniq from 'lodash/uniq'; import { getConfigValue } from '../utils/config/get-config'; import { getParent } from './span'; -import type { Span, Link, KeyValuePair } from '../types'; +import type { Span, Link, KeyValuePair } from '../types/trace'; const parameterRegExp = /#\{([^{}]*)\}/g; diff --git a/packages/jaeger-ui/src/model/search.js b/packages/jaeger-ui/src/model/search.js index 62dd3ac961..1a55526248 100644 --- a/packages/jaeger-ui/src/model/search.js +++ b/packages/jaeger-ui/src/model/search.js @@ -14,102 +14,25 @@ // See the License for the specific language governing permissions and // limitations under the License. -import _map from 'lodash/map'; -import _values from 'lodash/values'; - import { LEAST_SPANS, LONGEST_FIRST, MOST_RECENT, MOST_SPANS, SHORTEST_FIRST } from './order-by'; -import type { Trace } from '../types'; -import type { TraceSummaries, TraceSummary } from '../types/search'; - -const isErrorTag = ({ key, value }) => key === 'error' && (value === true || value === 'true'); - -/** - * Transforms a trace from the HTTP response to the data structure needed by the search page. Note: exported - * for unit tests. - * - * @param trace Trace data in the format sent over the wire. - * @return {TraceSummary} Summary of the trace data for use in the search results. - */ -export function getTraceSummary(trace: Trace): TraceSummary { - const { processes, spans, traceID } = trace; - let traceName = ''; - let minTs = Number.MAX_SAFE_INTEGER; - let maxTs = Number.MIN_SAFE_INTEGER; - let numErrorSpans = 0; - // serviceName -> { name, numberOfSpans } - const serviceMap = {}; - for (let i = 0; i < spans.length; i++) { - const { duration, processID, references, startTime, tags } = spans[i]; - // time bounds of trace - minTs = minTs > startTime ? startTime : minTs; - maxTs = maxTs < startTime + duration ? startTime + duration : maxTs; - // number of error tags - if (tags.some(isErrorTag)) { - numErrorSpans += 1; - } - // number of span per service - const { serviceName } = processes[processID]; - let svcData = serviceMap[serviceName]; - if (svcData) { - svcData.numberOfSpans += 1; - } else { - svcData = { - name: serviceName, - numberOfSpans: 1, - }; - serviceMap[serviceName] = svcData; - } - if (!references || !references.length) { - const { operationName } = spans[i]; - traceName = `${svcData.name}: ${operationName}`; - } - } - return { - traceName, - traceID, - duration: (maxTs - minTs) / 1000, - numberOfErredSpans: numErrorSpans, - numberOfSpans: spans.length, - services: _values(serviceMap), - timestamp: minTs / 1000, - }; -} - -/** - * Transforms `Trace` values into `TraceSummary` values and finds the max duration of the traces. - * - * @param {(Trace | Error)[]} _traces The trace data in the format from the HTTP request. - * @return {TraceSummaries} The `{ traces, maxDuration }` value. - */ -export function getTraceSummaries(_traces: (Trace | Error)[]): TraceSummaries { - const traces = _traces - .map(item => { - if (item instanceof Error) { - return null; - } - return getTraceSummary(item); - }) - .filter(Boolean); - const maxDuration = Math.max(..._map(traces, 'duration')); - return { maxDuration, traces }; -} +import type { Trace } from '../types/trace'; const comparators = { - [MOST_RECENT]: (a, b) => +(b.timestamp > a.timestamp) || +(a.timestamp === b.timestamp) - 1, + [MOST_RECENT]: (a, b) => +(b.startTime > a.startTime) || +(a.startTime === b.startTime) - 1, [SHORTEST_FIRST]: (a, b) => +(a.duration > b.duration) || +(a.duration === b.duration) - 1, [LONGEST_FIRST]: (a, b) => +(b.duration > a.duration) || +(a.duration === b.duration) - 1, - [MOST_SPANS]: (a, b) => +(b.numberOfSpans > a.numberOfSpans) || +(a.numberOfSpans === b.numberOfSpans) - 1, - [LEAST_SPANS]: (a, b) => +(a.numberOfSpans > b.numberOfSpans) || +(a.numberOfSpans === b.numberOfSpans) - 1, + [MOST_SPANS]: (a, b) => +(b.spans.length > a.spans.length) || +(a.spans.length === b.spans.length) - 1, + [LEAST_SPANS]: (a, b) => +(a.spans.length > b.spans.length) || +(a.spans.length === b.spans.length) - 1, }; /** - * Sorts `TraceSummary[]`, in place. + * Sorts `Trace[]`, in place. * - * @param {TraceSummary[]} traces The `TraceSummary` array to sort. + * @param {Trace[]} traces The `Trace` array to sort. * @param {string} sortBy A sort specification, see ./order-by.js. */ -export function sortTraces(traces: TraceSummary[], sortBy: string) { +export function sortTraces(traces: Trace[], sortBy: string) { const comparator = comparators[sortBy] || comparators[LONGEST_FIRST]; traces.sort(comparator); } diff --git a/packages/jaeger-ui/src/model/search.test.js b/packages/jaeger-ui/src/model/search.test.js index 866ca82a75..cfbe173bc3 100644 --- a/packages/jaeger-ui/src/model/search.test.js +++ b/packages/jaeger-ui/src/model/search.test.js @@ -16,106 +16,24 @@ import _maxBy from 'lodash/maxBy'; import _minBy from 'lodash/minBy'; import * as orderBy from './order-by'; -import { getTraceSummaries, getTraceSummary, sortTraces } from './search'; +import { sortTraces } from './search'; import traceGenerator from '../demo/trace-generators'; import transformTraceData from '../model/transform-trace-data'; -describe('getTraceSummary()', () => { - let trace; - let summary; - - beforeEach(() => { - trace = transformTraceData(traceGenerator.trace({ numberOfSpans: 2 })); - summary = getTraceSummary(trace); - }); - - it('derives duration, timestamp and numberOfSpans', () => { - expect(summary.numberOfSpans).toBe(trace.spans.length); - expect(summary.duration).toBe(trace.duration / 1000); - expect(summary.timestamp).toBe(Math.floor(trace.startTime / 1000)); - }); - - it('handles error spans', () => { - const errorTag = { key: 'error', value: true }; - expect(summary.numberOfErredSpans).toBe(0); - trace.spans[0].tags.push(errorTag); - expect(getTraceSummary(trace).numberOfErredSpans).toBe(1); - trace.spans[1].tags.push(errorTag); - expect(getTraceSummary(trace).numberOfErredSpans).toBe(2); - }); - - it('generates the traceName', () => { - trace = { - traceID: 'main-id', - spans: [ - { - traceID: 'main-id', - processID: 'pid0', - spanID: 'span-id-0', - operationName: 'op0', - startTime: 1502221240933000, - duration: 236857, - tags: [], - }, - { - traceID: 'main-id', - processID: 'pid1', - spanID: 'span-child', - operationName: 'op1', - startTime: 1502221241144382, - duration: 25305, - tags: [], - references: [{ refType: 'CHILD_OF', traceID: 'main-id', spanID: 'span-id-0' }], - }, - ], - duration: 236857, - timestamp: 1502221240933000, - processes: { - pid0: { - processID: 'pid0', - serviceName: 'serviceA', - tags: [], - }, - pid1: { - processID: 'pid1', - serviceName: 'serviceB', - tags: [], - }, - }, - }; - const { traceName } = getTraceSummary(trace); - expect(traceName).toBe('serviceA: op0'); - }); - - xit('derives services summations', () => {}); -}); - -describe('getTraceSummaries()', () => { - it('finds the max duration', () => { - const traces = [ - transformTraceData(traceGenerator.trace({})), - transformTraceData(traceGenerator.trace({})), - ]; - const maxDuration = _maxBy(traces, 'duration').duration / 1000; - expect(getTraceSummaries(traces).maxDuration).toBe(maxDuration); - }); -}); - describe('sortTraces()', () => { const idMinSpans = 4; const idMaxSpans = 2; - const rawTraces = [ + const traces = [ { ...transformTraceData(traceGenerator.trace({ numberOfSpans: 3 })), traceID: 1 }, { ...transformTraceData(traceGenerator.trace({ numberOfSpans: 100 })), traceID: idMaxSpans }, { ...transformTraceData(traceGenerator.trace({ numberOfSpans: 5 })), traceID: 3 }, { ...transformTraceData(traceGenerator.trace({ numberOfSpans: 1 })), traceID: idMinSpans }, ]; - const { traces } = getTraceSummaries(rawTraces); const { MOST_SPANS, LEAST_SPANS, LONGEST_FIRST, SHORTEST_FIRST, MOST_RECENT } = orderBy; const expecations = { - [MOST_RECENT]: _maxBy(traces, trace => trace.timestamp).traceID, + [MOST_RECENT]: _maxBy(traces, trace => trace.startTime).traceID, [LONGEST_FIRST]: _maxBy(traces, trace => trace.duration).traceID, [SHORTEST_FIRST]: _minBy(traces, trace => trace.duration).traceID, [MOST_SPANS]: idMaxSpans, diff --git a/packages/jaeger-ui/src/model/span.js b/packages/jaeger-ui/src/model/span.js index 370d5945cd..4470805832 100644 --- a/packages/jaeger-ui/src/model/span.js +++ b/packages/jaeger-ui/src/model/span.js @@ -14,7 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { Span } from '../types'; +import type { Span } from '../types/trace'; /** * Searches the span.references to find 'CHILD_OF' reference type or returns null. diff --git a/packages/jaeger-ui/src/model/trace-dag/DagNode.js b/packages/jaeger-ui/src/model/trace-dag/DagNode.js new file mode 100644 index 0000000000..ae8cdb6f7e --- /dev/null +++ b/packages/jaeger-ui/src/model/trace-dag/DagNode.js @@ -0,0 +1,42 @@ +// @flow + +// Copyright (c) 2018 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 type { NodeID } from './types'; + +export default class DagNode { + static getID(service: string, operation: string, hasChildren: boolean, parentID?: ?string): NodeID { + const name = `${service}\t${operation}${hasChildren ? '' : '\t__LEAF__'}`; + return parentID ? `${parentID}\n${name}` : name; + } + + service: string; + operation: string; + parentID: ?NodeID; + id: NodeID; + count: number; + children: Set; + data: T; + + constructor(service: string, operation: string, hasChildren: boolean, parentID?: ?NodeID, data: T) { + this.service = service; + this.operation = operation; + this.parentID = parentID; + this.id = DagNode.getID(service, operation, hasChildren, parentID); + this.count = 0; + this.children = new Set(); + this.data = data; + } +} diff --git a/packages/jaeger-ui/src/model/trace-dag/DenseTrace.js b/packages/jaeger-ui/src/model/trace-dag/DenseTrace.js new file mode 100644 index 0000000000..bea6da4cdb --- /dev/null +++ b/packages/jaeger-ui/src/model/trace-dag/DenseTrace.js @@ -0,0 +1,90 @@ +// @flow + +// Copyright (c) 2018 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 denseTransforms from './denseTransforms'; + +import type { DenseSpan } from './types'; +import type { Span, Trace } from '../../types/trace'; + +function convSpans(spans: Span[]) { + const map: Map = new Map(); + const roots: Set = new Set(); + const ids: string[] = []; + spans.forEach(span => { + const { spanID: id, operationName: operation, process, references, tags: spanTags } = span; + ids.push(id); + const { serviceName: service } = process; + const tags = spanTags.reduce((accum, tag) => { + const { key, value } = tag; + // eslint-disable-next-line no-param-reassign + accum[key] = value; + return accum; + }, {}); + let parentID: ?string = null; + if (references && references.length) { + const { refType, spanID } = references[0]; + if (refType !== 'CHILD_OF' && refType !== 'FOLLOWS_FROM') { + console.warn(`Unrecognized ref type: ${refType}`); + } else { + parentID = spanID; + } + } + + const denseSpan = { + id, + operation, + parentID, + service, + span, + tags, + children: new Set(), + skipToChild: false, + }; + const parent = parentID && map.get(parentID); + if (!parent) { + // some root spans have a parent ID but it is missing + roots.add(id); + } else { + parent.children.add(id); + } + map.set(id, denseSpan); + }); + return { ids, map, roots }; +} + +function makeDense(spanIDs: string[], map: Map) { + spanIDs.forEach(id => { + const denseSpan = map.get(id); + // make flow happy + if (denseSpan) { + denseTransforms(denseSpan, map); + } + }); +} + +export default class DenseTrace { + trace: Trace; + rootIDs: Set; + denseSpansMap: Map; + + constructor(trace: Trace) { + this.trace = trace; + const { ids, map, roots } = convSpans(trace.spans); + makeDense(ids, map); + this.rootIDs = roots; + this.denseSpansMap = map; + } +} diff --git a/packages/jaeger-ui/src/model/trace-dag/TraceDag.js b/packages/jaeger-ui/src/model/trace-dag/TraceDag.js new file mode 100644 index 0000000000..fdb627f706 --- /dev/null +++ b/packages/jaeger-ui/src/model/trace-dag/TraceDag.js @@ -0,0 +1,115 @@ +// @flow + +// Copyright (c) 2018 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 DagNode from './DagNode'; +import DenseTrace from './DenseTrace'; + +import type { NodeID } from './types'; +import type { Trace } from '../../types/trace'; + +type DiffCounts = { + a: number, + b: number, +}; + +export default class TraceDag { + static newFromTrace(trace: Trace) { + const dt: TraceDag<> = new TraceDag(); + dt._initFromTrace(trace); + return dt; + } + + static diff(a: TraceDag, b: TraceDag) { + const dt: TraceDag = new TraceDag(); + let key = 'a'; + + function pushDagNode(src: DagNode) { + const node = dt._getDagNode(src.service, src.operation, src.children.size > 0, src.parentID, { + a: 0, + b: 0, + }); + const { data } = node; + data[key] = src.count; + node.count = data.b - data.a; + if (!node.parentID) { + dt.rootIDs.add(node.id); + } + } + key = 'a'; + [...a.nodesMap.values()].forEach(pushDagNode); + key = 'b'; + [...b.nodesMap.values()].forEach(pushDagNode); + return dt; + } + + denseTrace: ?DenseTrace; + nodesMap: Map>; + rootIDs: Set; + + constructor() { + this.denseTrace = null; + this.nodesMap = new Map(); + this.rootIDs = new Set(); + } + + _initFromTrace(trace: Trace, data: T) { + this.denseTrace = new DenseTrace(trace); + [...this.denseTrace.rootIDs].forEach(id => this._addDenseSpan(id, null, data)); + } + + _getDagNode( + service: string, + operation: string, + hasChildren: boolean, + parentID?: ?NodeID, + data: T + ): DagNode { + const nodeID = DagNode.getID(service, operation, hasChildren, parentID); + let node = this.nodesMap.get(nodeID); + if (node) { + return node; + } + node = new DagNode(service, operation, hasChildren, parentID, data); + this.nodesMap.set(nodeID, node); + if (!parentID) { + this.rootIDs.add(nodeID); + } else { + const parentDag = this.nodesMap.get(parentID); + if (parentDag) { + parentDag.children.add(nodeID); + } + } + return node; + } + + _addDenseSpan(spanID: string, parentNodeID?: ?NodeID, data: T) { + const denseSpan = this.denseTrace && this.denseTrace.denseSpansMap.get(spanID); + if (!denseSpan) { + console.warn(`Missing dense span: ${spanID}`); + return; + } + const { children, operation, service, skipToChild } = denseSpan; + let nodeID: ?string = null; + if (!skipToChild) { + const node = this._getDagNode(service, operation, children.size > 0, parentNodeID, data); + node.count++; + nodeID = node.id; + } else { + nodeID = parentNodeID; + } + [...children].forEach(id => this._addDenseSpan(id, nodeID, data)); + } +} diff --git a/packages/jaeger-ui/src/model/trace-dag/convPlexus.js b/packages/jaeger-ui/src/model/trace-dag/convPlexus.js new file mode 100644 index 0000000000..2342822aaf --- /dev/null +++ b/packages/jaeger-ui/src/model/trace-dag/convPlexus.js @@ -0,0 +1,48 @@ +// @flow + +// Copyright (c) 2018 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 DagNode from './DagNode'; + +import type { NodeID, PEdge, PVertex } from './types'; + +export default function convPlexus(nodesMap: Map>) { + const vertices: PVertex[] = []; + const edges: PEdge[] = []; + const ids = [...nodesMap.keys()]; + const keyMap: Map = new Map(ids.map((id: NodeID, i: number) => [id, i])); + for (let i = 0; i < ids.length; i++) { + const id = ids[i]; + const dagNode = nodesMap.get(id); + if (!dagNode) { + // should not happen, keep flow happy + continue; + } + vertices.push({ + key: i, + label: `${dagNode.count} | ${dagNode.operation}`, + data: dagNode, + }); + const parentKey = dagNode.parentID && keyMap.get(dagNode.parentID); + if (parentKey == null) { + continue; + } + edges.push({ + from: parentKey, + to: i, + }); + } + return { edges, vertices }; +} diff --git a/packages/jaeger-ui/src/model/trace-dag/denseTransforms.js b/packages/jaeger-ui/src/model/trace-dag/denseTransforms.js new file mode 100644 index 0000000000..5397454322 --- /dev/null +++ b/packages/jaeger-ui/src/model/trace-dag/denseTransforms.js @@ -0,0 +1,135 @@ +// @flow + +// Copyright (c) 2018 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 tagKeys from '../../constants/tag-keys'; + +import type { DenseSpan } from './types'; + +// - if span +// - is client span +// - is leaf +// - has parent.operation startsWith self-tag peer.service +// - has parent.operation endsWith self.operation +// - set self.service = self-tag peer.service +function fixLeafService(denseSpan: DenseSpan, map: Map) { + const { children, operation, parentID, tags } = denseSpan; + const parent = parentID != null && map.get(parentID); + const kind = tags[tagKeys.SPAN_KIND]; + const peerSvc = tags[tagKeys.PEER_SERVICE]; + if (!parent || children.size > 0 || kind !== 'client' || !peerSvc) { + return; + } + const { operation: parentOp } = parent; + if (parentOp.indexOf(peerSvc) === 0 && parentOp.slice(-operation.length) === operation) { + // eslint-disable-next-line no-param-reassign + denseSpan.service = peerSvc; + } +} + +// - if span +// - is server span +// - parent is client span +// - parent has one child (self) +// - (parent.operation OR parent-tag peer.service) startsWith self.service +// - set parent.skipToChild = true +function skipClient(denseSpan: DenseSpan, map: Map) { + const { parentID, service, tags } = denseSpan; + const parent = parentID != null && map.get(parentID); + if (!parent) { + return; + } + const kind = tags[tagKeys.SPAN_KIND]; + const parentKind = parent.tags[tagKeys.SPAN_KIND]; + const parentPeerSvc = parent.tags[tagKeys.PEER_SERVICE] || ''; + if (kind === 'server' && parentKind === 'client' && parent.children.size === 1) { + parent.skipToChild = parent.operation.indexOf(service) === 0 || parentPeerSvc.indexOf(service) === 0; + } +} + +// - if span +// - is server span +// - has operation === tag http.method +// - (parent.operation OR parent-tag peer.service) startsWith self.service +// - fix self.operation +function fixHttpOperation(denseSpan: DenseSpan, map: Map) { + const { parentID, operation, service, tags } = denseSpan; + const parent = parentID != null && map.get(parentID); + if (!parent) { + return; + } + const kind = tags[tagKeys.SPAN_KIND]; + const httpMethod = tags[tagKeys.HTTP_METHOD]; + if (kind !== 'server' || operation !== httpMethod) { + return; + } + const parentPeerSvc = parent.tags[tagKeys.PEER_SERVICE] || ''; + if (parent.operation.indexOf(service) === 0 || parentPeerSvc.indexOf(service) === 0) { + const rx = new RegExp(`^${service}(::)?`); + const endpoint = parent.operation.replace(rx, ''); + // eslint-disable-next-line no-param-reassign + denseSpan.operation = `${httpMethod} ${endpoint}`; + } +} + +// - if span +// - has no tags +// - has only one child +// - parent.process === self.process +// - set self.skipToChild = true +function skipAnnotationSpans(denseSpan: DenseSpan, map: Map) { + const { children, parentID, span } = denseSpan; + if (children.size !== 1 || span.tags.length !== 0) { + return; + } + const parent = parentID != null && map.get(parentID); + const childID = [...children][0]; + const child = childID != null && map.get(childID); + if (!parent || !child) { + return; + } + // eslint-disable-next-line no-param-reassign + denseSpan.skipToChild = parent.span.processID === span.processID; +} + +// - if span +// - is a client span +// - has only one child +// - the child is a server span +// - parent.span.processID === self.span.processID +// - set parent.skipToChild = true +function skipClientSpans(denseSpan: DenseSpan, map: Map) { + const { children, parentID, span, tags } = denseSpan; + if (children.size !== 1 || tags[tagKeys.SPAN_KIND] !== 'client') { + return; + } + const parent = parentID != null && map.get(parentID); + const childID = [...children][0]; + const child = childID != null && map.get(childID); + if (!parent || !child) { + return; + } + // eslint-disable-next-line no-param-reassign + denseSpan.skipToChild = + child.tags[tagKeys.SPAN_KIND] === 'client' && parent.span.processID === span.processID; +} + +export default function denseTransforms(denseSpan: DenseSpan, map: Map) { + fixLeafService(denseSpan, map); + skipClient(denseSpan, map); + fixHttpOperation(denseSpan, map); + skipAnnotationSpans(denseSpan, map); + skipClientSpans(denseSpan, map); +} diff --git a/packages/jaeger-ui/src/model/trace-dag/types.js b/packages/jaeger-ui/src/model/trace-dag/types.js new file mode 100644 index 0000000000..5526d15038 --- /dev/null +++ b/packages/jaeger-ui/src/model/trace-dag/types.js @@ -0,0 +1,42 @@ +// @flow + +// Copyright (c) 2018 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 DagNode from './DagNode'; + +import type { Span } from '../../types/trace'; + +export type NodeID = string; + +export type DenseSpan = { + span: Span, + id: string, + service: string, + operation: string, + tags: { [string]: any }, + parentID: ?string, + skipToChild: boolean, + children: Set, +}; + +export type PVertex = { + key: string | number, + data: DagNode, +}; + +export type PEdge = { + from: string | number, + to: string | number, +}; diff --git a/packages/jaeger-ui/src/model/transform-trace-data.js b/packages/jaeger-ui/src/model/transform-trace-data.js index 8595261b20..d18ef1e83e 100644 --- a/packages/jaeger-ui/src/model/transform-trace-data.js +++ b/packages/jaeger-ui/src/model/transform-trace-data.js @@ -17,7 +17,8 @@ import _isEqual from 'lodash/isEqual'; import { getTraceSpanIdsAsTree } from '../selectors/trace'; -import type { Process, Span, SpanData, Trace, TraceData } from '../types'; + +import type { Process, Span, SpanData, Trace, TraceData } from '../types/trace'; type SpanWithProcess = SpanData & { process: Process }; @@ -75,6 +76,8 @@ export default function transfromTraceData(data: TraceData & { spans: SpanWithPr // siblings are sorted by start time const tree = getTraceSpanIdsAsTree(data); const spans: Span[] = []; + const svcCounts: { [string]: number } = {}; + let traceName = ''; tree.walk((spanID, node, depth) => { if (spanID === '__root__') { @@ -84,6 +87,11 @@ export default function transfromTraceData(data: TraceData & { spans: SpanWithPr if (!span) { return; } + const { serviceName } = span.process; + svcCounts[serviceName] = (svcCounts[serviceName] || 0) + 1; + if (!span.references || !span.references.length) { + traceName = `${serviceName}: ${span.operationName}`; + } span.relativeStartTime = span.startTime - traceStartTime; span.depth = depth - 1; span.hasChildren = node.children.length > 0; @@ -96,10 +104,12 @@ export default function transfromTraceData(data: TraceData & { spans: SpanWithPr }); spans.push(span); }); - + const services = Object.keys(svcCounts).map(name => ({ name, numberOfSpans: svcCounts[name] })); return { + services, spans, traceID, + traceName, // can't use spread operator for intersection types // repl: https://goo.gl/4Z23MJ // issue: https://github.com/facebook/flow/issues/1511 diff --git a/packages/jaeger-ui/src/reducers/trace.js b/packages/jaeger-ui/src/reducers/trace.js index a918797c28..42f167dff2 100644 --- a/packages/jaeger-ui/src/reducers/trace.js +++ b/packages/jaeger-ui/src/reducers/trace.js @@ -12,56 +12,119 @@ // See the License for the specific language governing permissions and // limitations under the License. -import keyBy from 'lodash/keyBy'; import { handleActions } from 'redux-actions'; -import { fetchTrace, searchTraces } from '../actions/jaeger-api'; +import { fetchTrace, fetchMultipleTraces, searchTraces } from '../actions/jaeger-api'; +import { fetchedState } from '../constants'; import transformTraceData from '../model/transform-trace-data'; const initialState = { traces: {}, - loading: false, - error: null, + search: { + results: [], + }, }; -function fetchStarted(state) { - return { ...state, loading: true }; +function fetchTraceStarted(state, { meta }) { + const { id } = meta; + const traces = { ...state.traces, [id]: { id, state: fetchedState.LOADING } }; + return { ...state, traces }; } function fetchTraceDone(state, { meta, payload }) { - const trace = transformTraceData(payload.data[0]); - let traces; - if (!trace) { - traces = { ...state.traces, [meta.id]: new Error('Invalid trace data recieved.') }; + const { id } = meta; + const data = transformTraceData(payload.data[0]); + let trace; + if (!data) { + trace = { id, state: fetchedState.ERROR, error: new Error('Invalid trace data recieved.') }; } else { - traces = { ...state.traces, [trace.traceID]: trace }; + trace = { data, id, state: fetchedState.DONE }; } - return { ...state, traces, loading: false }; + const traces = { ...state.traces, [id]: trace }; + return { ...state, traces }; } function fetchTraceErred(state, { meta, payload }) { - const traces = { ...state.traces, [meta.id]: payload }; - return { ...state, traces, loading: false }; + const { id } = meta; + const trace = { id, error: payload, state: fetchedState.ERROR }; + const traces = { ...state.traces, [id]: trace }; + return { ...state, traces }; +} + +function fetchMultipleTracesStarted(state, { meta }) { + const { ids } = meta; + const traces = { ...state.traces }; + ids.forEach(id => { + traces[id] = { id, state: fetchedState.LOADING }; + }); + return { ...state, traces }; +} + +function fetchMultipleTracesDone(state, { payload }) { + const traces = { ...state.traces }; + payload.data.forEach(raw => { + const data = transformTraceData(raw); + traces[data.traceID] = { data, id: data.traceID, state: fetchedState.DONE }; + }); + if (payload.errors) { + payload.errors.forEach(err => { + const { msg, traceID } = err; + const error = new Error(`Error: ${msg} - ${traceID}`); + traces[traceID] = { error, id: traceID, state: fetchedState.ERROR }; + }); + } + return { ...state, traces }; +} + +function fetchMultipleTracesErred(state, { meta, payload }) { + const { ids } = meta; + const traces = { ...state.traces }; + const error = payload; + ids.forEach(id => { + traces[id] = { error, id, state: fetchedState.ERROR }; + }); + return { ...state, traces }; +} + +function fetchSearchStarted(state) { + const search = { + results: [], + state: fetchedState.LOADING, + }; + return { ...state, search }; } function searchDone(state, { payload }) { const processed = payload.data.map(transformTraceData); - const traces = keyBy(processed, 'traceID'); - return { ...state, traces, error: null, loading: false }; + const resultTraces = {}; + const 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 = { results, state: fetchedState.DONE }; + return { ...state, search, traces }; } -function searchErred(state, action) { - const error = action.payload; - return { ...state, error, loading: false, traces: [] }; +function searchErred(state, { payload }) { + const search = { error: payload, results: [], state: fetchedState.ERROR }; + return { ...state, search }; } export default handleActions( { - [`${fetchTrace}_PENDING`]: fetchStarted, + [`${fetchTrace}_PENDING`]: fetchTraceStarted, [`${fetchTrace}_FULFILLED`]: fetchTraceDone, [`${fetchTrace}_REJECTED`]: fetchTraceErred, - [`${searchTraces}_PENDING`]: fetchStarted, + [`${fetchMultipleTraces}_PENDING`]: fetchMultipleTracesStarted, + [`${fetchMultipleTraces}_FULFILLED`]: fetchMultipleTracesDone, + [`${fetchMultipleTraces}_REJECTED`]: fetchMultipleTracesErred, + + [`${searchTraces}_PENDING`]: fetchSearchStarted, [`${searchTraces}_FULFILLED`]: searchDone, [`${searchTraces}_REJECTED`]: searchErred, }, diff --git a/packages/jaeger-ui/src/reducers/trace.test.js b/packages/jaeger-ui/src/reducers/trace.test.js index 7b8acb2bc6..95fab6d28b 100644 --- a/packages/jaeger-ui/src/reducers/trace.test.js +++ b/packages/jaeger-ui/src/reducers/trace.test.js @@ -12,29 +12,31 @@ // See the License for the specific language governing permissions and // limitations under the License. -import * as jaegerApiActions from '../../src/actions/jaeger-api'; -import traceReducer from '../../src/reducers/trace'; -import traceGenerator from '../../src/demo/trace-generators'; -import transformTraceData from '../../src/model/transform-trace-data'; +import * as jaegerApiActions from '../actions/jaeger-api'; +import { fetchedState } from '../constants'; +import traceGenerator from '../demo/trace-generators'; +import transformTraceData from '../model/transform-trace-data'; +import traceReducer from '../reducers/trace'; -const generatedTrace = traceGenerator.trace({ numberOfSpans: 1 }); -const { traceID } = generatedTrace; +const trace = traceGenerator.trace({ numberOfSpans: 1 }); +const { traceID: id } = trace; it('trace reducer should set loading true on a fetch', () => { const state = traceReducer(undefined, { type: `${jaegerApiActions.fetchTrace}_PENDING`, + meta: { id }, }); - expect(state.loading).toBe(true); + const outcome = { [id]: { id, state: fetchedState.LOADING } }; + expect(state.traces).toEqual(outcome); }); it('trace reducer should handle a successful FETCH_TRACE', () => { const state = traceReducer(undefined, { type: `${jaegerApiActions.fetchTrace}_FULFILLED`, - payload: { data: [generatedTrace] }, - meta: { id: traceID }, + payload: { data: [trace] }, + meta: { id }, }); - expect(state.traces).toEqual({ [traceID]: transformTraceData(generatedTrace) }); - expect(state.loading).toBe(false); + expect(state.traces).toEqual({ [id]: { id, data: transformTraceData(trace), state: fetchedState.DONE } }); }); it('trace reducer should handle a failed FETCH_TRACE', () => { @@ -42,19 +44,30 @@ it('trace reducer should handle a failed FETCH_TRACE', () => { const state = traceReducer(undefined, { type: `${jaegerApiActions.fetchTrace}_REJECTED`, payload: error, - meta: { id: traceID }, + meta: { id }, }); - expect(state.traces).toEqual({ [traceID]: error }); - expect(state.traces[traceID]).toBe(error); - expect(state.loading).toBe(false); + expect(state.traces).toEqual({ [id]: { error, id, state: fetchedState.ERROR } }); + expect(state.traces[id].error).toBe(error); }); it('trace reducer should handle a successful SEARCH_TRACES', () => { const state = traceReducer(undefined, { type: `${jaegerApiActions.searchTraces}_FULFILLED`, - payload: { data: [generatedTrace] }, + payload: { data: [trace] }, meta: { query: 'whatever' }, }); - expect(state.traces).toEqual({ [traceID]: transformTraceData(generatedTrace) }); - expect(state.loading).toBe(false); + const outcome = { + traces: { + [id]: { + id, + data: transformTraceData(trace), + state: fetchedState.DONE, + }, + }, + search: { + state: fetchedState.DONE, + results: [id], + }, + }; + expect(state).toEqual(outcome); }); diff --git a/packages/jaeger-ui/src/types/index.js b/packages/jaeger-ui/src/types/index.js index 09b6f100b1..a08112c927 100644 --- a/packages/jaeger-ui/src/types/index.js +++ b/packages/jaeger-ui/src/types/index.js @@ -14,65 +14,47 @@ // See the License for the specific language governing permissions and // limitations under the License. -/** - * All timestamps are in microseconds - */ - -export type KeyValuePair = { - key: string, - value: any, -}; - -export type Link = { - url: string, - text: string, -}; - -export type Log = { - timestamp: number, - fields: Array, -}; - -export type Process = { - serviceName: string, - tags: Array, -}; - -export type SpanReference = { - refType: 'CHILD_OF' | 'FOLLOWS_FROM', - // eslint-disable-next-line no-use-before-define - span: ?Span, - spanID: string, - traceID: string, -}; - -export type SpanData = { - spanID: string, - traceID: string, - processID: string, - operationName: string, - startTime: number, - duration: number, - logs: Array, - tags: Array, - references: Array, -}; - -export type Span = SpanData & { - depth: number, - hasChildren: boolean, - process: Process, - relativeStartTime: number, -}; - -export type TraceData = { - processes: { [string]: Process }, - traceID: string, -}; - -export type Trace = TraceData & { - duration: number, - endTime: number, - spans: Span[], - startTime: number, +import type { ContextRouter } from 'react-router-dom'; + +import type { ApiError } from './api-error'; +import type { TracesArchive } from './archive'; +import type { Config } from './config'; +import type { Trace } from './trace'; +import type { TraceDiffState } from './trace-diff'; +import type { TraceTimeline } from './trace-timeline'; + +export type FetchedState = 'FETCH_DONE' | 'FETCH_ERROR' | 'FETCH_LOADING'; + +export type FetchedTrace = { + data?: Trace, + error?: ApiError, + id: string, + state?: FetchedState, +}; + +export type ReduxState = { + archive: TracesArchive, + config: Config, + dependencies: { + dependencies: { parent: string, child: string, callCount: number }[], + loading: boolean, + error: ?ApiError, + }, + services: { + services: ?(string[]), + operationsForService: { [string]: string[] }, + loading: boolean, + error: ?ApiError, + }, + router: ContextRouter, + trace: { + traces: { [string]: FetchedTrace }, + search: { + error?: ApiError, + results: string[], + state?: FetchedState, + }, + }, + traceDiff: TraceDiffState, + traceTimeline: TraceTimeline, }; diff --git a/packages/jaeger-ui/src/types/trace-diff.js b/packages/jaeger-ui/src/types/trace-diff.js new file mode 100644 index 0000000000..932f18ba75 --- /dev/null +++ b/packages/jaeger-ui/src/types/trace-diff.js @@ -0,0 +1,21 @@ +// @flow + +// 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. + +export type TraceDiffState = { + a: ?string, + b: ?string, + cohort: string[], +}; diff --git a/packages/jaeger-ui/src/types/trace-timeline.js b/packages/jaeger-ui/src/types/trace-timeline.js new file mode 100644 index 0000000000..94457a4b2a --- /dev/null +++ b/packages/jaeger-ui/src/types/trace-timeline.js @@ -0,0 +1,25 @@ +// @flow + +// 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 DetailState from '../components/TracePage/TraceTimelineViewer/SpanDetail/DetailState'; + +export type TraceTimeline = { + traceID: ?string, + spanNameColumnWidth: number, + childrenHiddenIDs: Set, + findMatches: ?Set, + detailStates: Map, +}; diff --git a/packages/jaeger-ui/src/types/trace.js b/packages/jaeger-ui/src/types/trace.js new file mode 100644 index 0000000000..5840e46398 --- /dev/null +++ b/packages/jaeger-ui/src/types/trace.js @@ -0,0 +1,80 @@ +// @flow + +// 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. + +/** + * All timestamps are in microseconds + */ + +export type KeyValuePair = { + key: string, + value: any, +}; + +export type Link = { + url: string, + text: string, +}; + +export type Log = { + timestamp: number, + fields: Array, +}; + +export type Process = { + serviceName: string, + tags: Array, +}; + +export type SpanReference = { + refType: 'CHILD_OF' | 'FOLLOWS_FROM', + // eslint-disable-next-line no-use-before-define + span: ?Span, + spanID: string, + traceID: string, +}; + +export type SpanData = { + spanID: string, + traceID: string, + processID: string, + operationName: string, + startTime: number, + duration: number, + logs: Array, + tags: Array, + references: Array, +}; + +export type Span = SpanData & { + depth: number, + hasChildren: boolean, + process: Process, + relativeStartTime: number, +}; + +export type TraceData = { + processes: { [string]: Process }, + traceID: string, +}; + +export type Trace = TraceData & { + duration: number, + endTime: number, + spans: Span[], + startTime: number, + traceName: string, + services: { name: string, numberOfSpans: number }[], +}; diff --git a/packages/jaeger-ui/src/utils/configure-store.js b/packages/jaeger-ui/src/utils/configure-store.js index ab0f5c2a4f..c6593d60c1 100644 --- a/packages/jaeger-ui/src/utils/configure-store.js +++ b/packages/jaeger-ui/src/utils/configure-store.js @@ -16,17 +16,19 @@ import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; import { routerReducer, routerMiddleware } from 'react-router-redux'; import { window } from 'global'; +import traceDiff from '../components/TraceDiff/duck'; +import archive from '../components/TracePage/ArchiveNotifier/duck'; +import traceTimeline from '../components/TracePage/TraceTimelineViewer/duck'; import jaegerReducers from '../reducers'; import * as jaegerMiddlewares from '../middlewares'; -import archiveReducer from '../components/TracePage/ArchiveNotifier/duck'; -import traceTimelineViewReducer from '../components/TracePage/TraceTimelineViewer/duck'; export default function configureStore(history) { return createStore( combineReducers({ ...jaegerReducers, - archive: archiveReducer, - traceTimeline: traceTimelineViewReducer, + archive, + traceDiff, + traceTimeline, router: routerReducer, }), compose( diff --git a/packages/jaeger-ui/src/utils/date.js b/packages/jaeger-ui/src/utils/date.js index 0aa42cb051..6d125f76b4 100644 --- a/packages/jaeger-ui/src/utils/date.js +++ b/packages/jaeger-ui/src/utils/date.js @@ -102,11 +102,12 @@ export function formatDuration(duration, inputUnit = 'microseconds') { return _.round(d, 2) + units; } -export function formatRelativeDate(value) { +export function formatRelativeDate(value, fullMonthName = false) { const m = !(value instanceof moment) ? moment(value) : value; + const monthFormat = fullMonthName ? 'MMMM' : 'MMM'; const dt = new Date(); if (dt.getFullYear() !== m.year()) { - return m.format('MMM D, YYYY'); + return m.format(`${monthFormat} D, YYYY`); } const mMonth = m.month(); const mDate = m.date(); @@ -118,5 +119,5 @@ export function formatRelativeDate(value) { if (mMonth === dt.getMonth() && mDate === dt.getDate()) { return YESTERDAY; } - return m.format('MMM D'); + return m.format(`${monthFormat} D`); } diff --git a/packages/plexus/demo/src/data-large.js b/packages/plexus/demo/src/data-large.js index d2fa1ae7a2..8f9ce8cd0b 100644 --- a/packages/plexus/demo/src/data-large.js +++ b/packages/plexus/demo/src/data-large.js @@ -489,7 +489,7 @@ export default { export function getNodeLabel(vertex) { const [svc, op] = vertex.key.split('::', 2); return ( - + {svc}
{op} diff --git a/packages/plexus/demo/src/index.css b/packages/plexus/demo/src/index.css index adf420ce7d..09e1d56d54 100644 --- a/packages/plexus/demo/src/index.css +++ b/packages/plexus/demo/src/index.css @@ -2,9 +2,82 @@ html { font-family: Arial, Helvetica, sans-serif; } -.Node { - background: #eee; - border: 1px solid #ddd; - line-height: 1.4; - padding: 0.3em 0.5em; +.DemoGraph { + border: 1px solid #666; + cursor: move; + overflow: hidden; + height: 500px; + width: 800px; + position: relative; +} + +.DemoGraph--dag { + background: #f0f0f0; + stroke-width: 1.7; +} + +.DemoGraph--dag.is-small { + stroke-width: 0.85; +} + +.DemoGraph--node { + background: #bbb; + border: 1px solid #999; + box-shadow: 0 0 7px 1px rgba(0, 0, 0, 0.4); + cursor: pointer; + padding: 0.8em 1.25em; +} + +.DemoGraph--node:hover { + background: #666; + color: #fff; + border-color: #333; +} + +.DemoGraph--dag.is-small .DemoGraph--nodeLabel { + opacity: 0; +} + +/* DAG minimap */ + +.Demo--miniMap { + align-items: flex-end; + bottom: 1rem; + display: flex; + left: 1rem; + position: absolute; + z-index: 1; +} + +.Demo--miniMap > .plexus-MiniMap--item { + border: 1px solid #333; + background: #555; + box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3); + margin-right: 1rem; + position: relative; +} + +.Demo--miniMap > .plexus-MiniMap--map { + /* dynamic width, height */ + box-sizing: content-box; +} + +.Demo--miniMap .plexus-MiniMap--mapActive { + /* dynamic: width, height, transform */ + background: #bbb; + outline: 1px solid #333; + position: absolute; +} + +.Demo--miniMap > .plexus-MiniMap--button { + color: #bbb; + cursor: pointer; + font-size: 1.6em; + line-height: 0; + padding: 0.1rem; +} + +.Demo--miniMap > .plexus-MiniMap--button:hover { + background: #444; + color: #ddd; } diff --git a/packages/plexus/demo/src/index.js b/packages/plexus/demo/src/index.js index 7777428609..dc11be464d 100644 --- a/packages/plexus/demo/src/index.js +++ b/packages/plexus/demo/src/index.js @@ -18,70 +18,110 @@ import { render } from 'react-dom'; import largeDg, { getNodeLabel as getLargeNodeLabel } from './data-large'; import { edges as dagEdges, vertices as dagVertices } from './data-dag'; -import { varied, colored, getColorNodeLabel, setOnColorEdge, setOnColorNode } from './data-small'; +import { colored as colorData, getColorNodeLabel, setOnColorEdge, setOnColorNode } from './data-small'; import { DirectedGraph, LayoutManager } from '../../src'; import './index.css'; -const addAnAttr = () => ({ 'data-rando': Math.random() }); +const { classNameIsSmall } = DirectedGraph.propsFactories; -const addNodeDemoCss = () => ({ className: 'Node' }); +const ROOT_STYLE = { style: { float: 'left' } }; +const setLargeRootStyle = () => ROOT_STYLE; +const addAnAttr = () => ({ 'data-rando': Math.random() }); +const setNodeClassName = vertex => ({ + className: 'DemoGraph--node', + // eslint-disable-next-line no-console + onClick: () => console.log(vertex.key), +}); class Demo extends React.Component { - state = { - data: colored, - colorData: true, - }; - constructor(props) { super(props); this.layoutManager = new LayoutManager(); - this.dagLayoutManager = new LayoutManager(); - this.largeLayoutManager = new LayoutManager(); - } - - handleClick = () => { - const { colorData } = this.state; - this.setState({ - colorData: !colorData, - data: colorData ? varied : colored, + this.dagLayoutManager = new LayoutManager({ useDotEdges: true }); + this.largeDotLayoutManager = new LayoutManager({ useDotEdges: true }); + this.largeDotPolylineLayoutManager = new LayoutManager({ + useDotEdges: true, + splines: 'polyline', + ranksep: 8, }); - }; + this.largeNeatoLayoutManager = new LayoutManager(); + } render() { - const { data, colorData } = this.state; return (
-

- - plexus Demo - -

+

Directed graph with cycles - dot edges

+
+
+ +
+
+

Directed graph with cycles - neato edges

+
+
+ +
+
+

Directed graph with cycles - dot edges - polylines

+
+
+ +
+

Small graph with data driven rendering

Medium DAG

-

Larger directd graph with cycles

-
); } diff --git a/packages/plexus/package.json b/packages/plexus/package.json index 3392024839..493dcd239d 100644 --- a/packages/plexus/package.json +++ b/packages/plexus/package.json @@ -1,18 +1,21 @@ { "name": "@jaegertracing/plexus", - "version": "0.0.1-dev.2", + "version": "0.0.1-dev.3", "description": "Direct Graph React component", "main": "umd/@jaegertracing/plexus.js", "files": ["lib", "umd"], "scripts": { "build": "nwb build-react-component", "clean": "nwb clean-module && nwb clean-demo", + "coverage": "echo 'NO TESTS YET'", + "prepublishOnly": "nwb build-react-component --no-demo", "start": "nwb serve-react-demo", "test": "echo 'NO TESTS YET'", - "coverage": "echo 'NO TESTS YET'", "test:dev": "nwb test-react --server" }, "dependencies": { + "d3-selection": "^1.3.0", + "d3-zoom": "^1.7.1", "viz.js": "^1.8.1" }, "peerDependencies": { diff --git a/packages/plexus/src/DirectedGraph/DirectedGraph.js b/packages/plexus/src/DirectedGraph/DirectedGraph.js index 80055a3bc9..784b22c4d6 100644 --- a/packages/plexus/src/DirectedGraph/DirectedGraph.js +++ b/packages/plexus/src/DirectedGraph/DirectedGraph.js @@ -15,13 +15,28 @@ // limitations under the License. import * as React from 'react'; +import { select } from 'd3-selection'; +import { zoom as d3Zoom, zoomIdentity, zoomTransform as getTransform } from 'd3-zoom'; -import * as arrow from './builtins/EdgeArrow'; -import EdgePath from './builtins/EdgePath'; +import EdgeArrowDef from './builtins/EdgeArrowDef'; import EdgesContainer from './builtins/EdgesContainer'; -import Node from './builtins/Node'; -import type { DirectedGraphProps, DirectedGraphState } from './types'; -import type { Edge, Vertex } from '../types/layout'; +import PureEdges from './builtins/PureEdges'; +import PureNodes from './builtins/PureNodes'; +import MiniMap from './MiniMap'; +import classNameIsSmall from './prop-factories/classNameIsSmall'; +import mergePropSetters, { mergeClassNameAndStyle } from './prop-factories/mergePropSetters'; +import scaledStrokeWidth from './prop-factories/scaledStrokeWidth'; +import { + constrainZoom, + DEFAULT_SCALE_EXTENT, + fitWithinContainer, + getScaleExtent, + getZoomAttr, + getZoomStyle, +} from './transform-utils'; + +import type { D3Transform, DirectedGraphProps, DirectedGraphState } from './types'; +import type { Cancelled, LayoutDone, PositionsDone, Vertex } from '../types/layout'; const PHASE_NO_DATA = 0; const PHASE_CALC_SIZES = 1; @@ -29,26 +44,20 @@ const PHASE_CALC_POSITIONS = 2; const PHASE_CALC_EDGES = 3; const PHASE_DONE = 4; -function defaultGetEdgeLabel( - edge: Edge, - from: Vertex, - to: Vertex, - getNodeLabel: Vertex => string | React.Node -) { - const { label } = edge; - if (label != null) { - if (typeof label === 'string' || React.isValidElement(label)) { - return label; - } - return String(label); - } - return ( - - {getNodeLabel(from)} → {getNodeLabel(to)} - - ); -} +const WRAPPER_STYLE_ZOOM = { + height: '100%', + overflow: 'hidden', + position: 'relative', + width: '100%', +}; +const WRAPPER_STYLE = { + position: 'relative', +}; + +let idCounter = 0; + +// eslint-disable-next-line no-unused-vars function defaultGetNodeLabel(vertex: Vertex) { const { label } = vertex; if (label != null) { @@ -61,14 +70,31 @@ function defaultGetNodeLabel(vertex: Vertex) { } export default class DirectedGraph extends React.PureComponent { + arrowId: string; + arrowIriRef: string; // ref API defs in flow seem to be a WIP // https://github.com/facebook/flow/issues/6103 - vertexRefs: { current: ?HTMLElement }[]; + rootRef: { current: HTMLDivElement | null }; + rootSelection: any; + vertexRefs: { current: HTMLElement | null }[]; + zoom: any; + + static propsFactories = { + classNameIsSmall, + mergePropSetters, + scaledStrokeWidth, + }; static defaultProps = { + arrowScaleDampener: undefined, + className: '', classNamePrefix: 'plexus', - getEdgeLabel: defaultGetEdgeLabel, + // getEdgeLabel: defaultGetEdgeLabel, getNodeLabel: defaultGetNodeLabel, + minimap: false, + minimapClassName: '', + zoom: false, + zoomTransform: zoomIdentity, }; state = { @@ -80,19 +106,24 @@ export default class DirectedGraph extends React.PureComponent { + if (!result.isCancelled) { + const { graph: layoutGraph, vertices: layoutVertices } = result; + this.setState({ layoutGraph, layoutVertices, layoutPhase: PHASE_CALC_EDGES }); + } + }; + + _onLayoutDone = (result: Cancelled | LayoutDone) => { + const root = this.rootRef.current; + if (result.isCancelled || !root) { + return; + } + const { zoomEnabled } = this.state; + const { edges: layoutEdges, graph: layoutGraph, vertices: layoutVertices } = result; + const { clientHeight: height, clientWidth: width } = root; + let zoomTransform = zoomIdentity; + if (zoomEnabled) { + const scaleExtent = getScaleExtent(layoutGraph.width, layoutGraph.height, width, height); + zoomTransform = fitWithinContainer(layoutGraph.width, layoutGraph.height, width, height); + this.zoom.scaleExtent(scaleExtent); + this.rootSelection.call(this.zoom); + // set the initial transform + this.zoom.transform(this.rootSelection, zoomTransform); + } + this.setState({ layoutEdges, layoutGraph, layoutVertices, zoomTransform, layoutPhase: PHASE_DONE }); + }; + + _onZoomed = () => { + const root = this.rootRef.current; + if (!root) { + return; + } + const zoomTransform = getTransform(root); + this.setState({ zoomTransform }); + }; + + _constrainZoom = (transform: D3Transform, extent: [[number, number], [number, number]]) => { + const [, [vw, vh]] = extent; + const { height: h, width: w } = this.state.layoutGraph || {}; + if (h == null || w == null) { + // for flow + return transform; + } + return constrainZoom(transform, w, h, vw, vh); + }; + + _resetZoom = () => { + const root = this.rootRef.current; + const layoutGraph = this.state.layoutGraph; + if (!root || !layoutGraph) { + return; + } + const { clientHeight: height, clientWidth: width } = root; + const zoomTransform = fitWithinContainer(layoutGraph.width, layoutGraph.height, width, height); + this.zoom.transform(this.rootSelection, zoomTransform); + this.setState({ zoomTransform }); + }; + _setSizeVertices() { const { edges, layoutManager, vertices } = this.props; const sizeVertices = this.state.vertexRefs .map((ref, i) => { const { current } = ref; - if (!current) { - return null; - } - return { - height: current.offsetHeight, - vertex: vertices[i], - width: current.offsetWidth, - }; + return !current + ? null + : { + height: current.offsetHeight, + vertex: vertices[i], + width: current.offsetWidth, + }; }) .filter(Boolean); const { positions, layout } = layoutManager.getLayout(edges, sizeVertices); - positions.then(({ isCancelled, graph: layoutGraph, vertices: layoutVertices }) => { - if (isCancelled) { - return; - } - this.setState({ layoutGraph, layoutVertices, layoutPhase: PHASE_CALC_EDGES }); - }); - layout.then(({ isCancelled, edges: layoutEdges, graph: layoutGraph, vertices: layoutVertices }) => { - if (isCancelled) { - return; - } - this.setState({ layoutEdges, layoutGraph, layoutVertices, layoutPhase: PHASE_DONE }); - }); + positions.then(this._onPositionsDone); + layout.then(this._onLayoutDone); this.setState({ sizeVertices, layoutPhase: PHASE_CALC_POSITIONS }); } _renderVertices() { const { classNamePrefix, getNodeLabel, setOnNode, vertices } = this.props; - const { vertexRefs } = this.state; - const _getLabel = getNodeLabel != null ? getNodeLabel : defaultGetNodeLabel; - return vertices.map((v, i) => ( -