lujiaming 5 mesi fa
commit
1e48bb2788
100 ha cambiato i file con 14216 aggiunte e 0 eliminazioni
  1. 43 0
      .babelrc
  2. 22 0
      .editorconfig
  3. 1 0
      .eslintignore
  4. 51 0
      .eslintrc
  5. 1 0
      .gitattributes
  6. 17 0
      .gitignore
  7. 1 0
      .nvmrc
  8. 31 0
      .stylelintrc
  9. 3 0
      Dockerfile
  10. 47 0
      Makefile
  11. 63 0
      README.md
  12. BIN
      app/fonts/proximanova-regular.woff
  13. BIN
      app/fonts/robotomono-regular.ttf
  14. 18 0
      app/html/index.html
  15. BIN
      app/images/favicon.ico
  16. 1 0
      app/scripts/actions.js
  17. 463 0
      app/scripts/actions/app-actions.js
  18. 618 0
      app/scripts/actions/request-actions.js
  19. 485 0
      app/scripts/charts/__tests__/nodes-layout-test.js
  20. 115 0
      app/scripts/charts/edge-container.js
  21. 997 0
      app/scripts/charts/edge.js
  22. 107 0
      app/scripts/charts/node-container.js
  23. 53 0
      app/scripts/charts/node-networks-overlay.js
  24. 296 0
      app/scripts/charts/nodes-chart-elements.js
  25. 85 0
      app/scripts/charts/nodes-chart.js
  26. 23 0
      app/scripts/charts/nodes-error.js
  27. 208 0
      app/scripts/charts/nodes-grid.js
  28. 499 0
      app/scripts/charts/nodes-layout.js
  29. 1 0
      app/scripts/component.js
  30. 51 0
      app/scripts/components/__tests__/node-details-test.js
  31. 308 0
      app/scripts/components/app.js
  32. 43 0
      app/scripts/components/cloud-feature.js
  33. 72 0
      app/scripts/components/cloud-link.js
  34. 382 0
      app/scripts/components/debug-toolbar.js
  35. 80 0
      app/scripts/components/details-card.js
  36. 37 0
      app/scripts/components/details.js
  37. 80 0
      app/scripts/components/embedded-terminal.js
  38. 126 0
      app/scripts/components/footer.js
  39. 1996 0
      app/scripts/components/global-style.js
  40. 232 0
      app/scripts/components/help-panel.js
  41. 56 0
      app/scripts/components/loading.js
  42. 72 0
      app/scripts/components/logo.js
  43. 56 0
      app/scripts/components/matched-results.js
  44. 111 0
      app/scripts/components/matched-text.js
  45. 90 0
      app/scripts/components/metric-selector-item.js
  46. 58 0
      app/scripts/components/metric-selector.js
  47. 68 0
      app/scripts/components/network-selector-item.js
  48. 63 0
      app/scripts/components/networks-selector.js
  49. 1741 0
      app/scripts/components/node-details.js
  50. 30 0
      app/scripts/components/node-details/__tests__/node-details-health-link-item-test.js
  51. 161 0
      app/scripts/components/node-details/__tests__/node-details-table-test.js
  52. 38 0
      app/scripts/components/node-details/node-details-control-button.js
  53. 39 0
      app/scripts/components/node-details/node-details-controls.js
  54. 128 0
      app/scripts/components/node-details/node-details-generic-table.js
  55. 29 0
      app/scripts/components/node-details/node-details-health-item.js
  56. 103 0
      app/scripts/components/node-details/node-details-health-link-item.js
  57. 69 0
      app/scripts/components/node-details/node-details-health.js
  58. 88 0
      app/scripts/components/node-details/node-details-info.js
  59. 77 0
      app/scripts/components/node-details/node-details-property-list.js
  60. 49 0
      app/scripts/components/node-details/node-details-relatives-link.js
  61. 55 0
      app/scripts/components/node-details/node-details-relatives.js
  62. 55 0
      app/scripts/components/node-details/node-details-table-headers.js
  63. 53 0
      app/scripts/components/node-details/node-details-table-node-link.js
  64. 43 0
      app/scripts/components/node-details/node-details-table-node-metric-link.js
  65. 187 0
      app/scripts/components/node-details/node-details-table-row.js
  66. 327 0
      app/scripts/components/node-details/node-details-table.js
  67. 66 0
      app/scripts/components/nodes-resources.js
  68. 31 0
      app/scripts/components/nodes-resources/node-resources-layer-topology.js
  69. 57 0
      app/scripts/components/nodes-resources/node-resources-layer.js
  70. 47 0
      app/scripts/components/nodes-resources/node-resources-metric-box-info.js
  71. 177 0
      app/scripts/components/nodes-resources/node-resources-metric-box.js
  72. 97 0
      app/scripts/components/nodes.js
  73. 11 0
      app/scripts/components/overlay.js
  74. 58 0
      app/scripts/components/plugins.js
  75. 150 0
      app/scripts/components/search.js
  76. 33 0
      app/scripts/components/show-more.js
  77. 10 0
      app/scripts/components/sidebar.js
  78. 162 0
      app/scripts/components/sparkline.js
  79. 59 0
      app/scripts/components/status.js
  80. 86 0
      app/scripts/components/terminal-app.js
  81. 355 0
      app/scripts/components/terminal.js
  82. 101 0
      app/scripts/components/time-control.js
  83. 41 0
      app/scripts/components/time-travel-wrapper.js
  84. 13 0
      app/scripts/components/tooltip.js
  85. 108 0
      app/scripts/components/topologies.js
  86. 26 0
      app/scripts/components/topology-option-action.js
  87. 154 0
      app/scripts/components/topology-options.js
  88. 108 0
      app/scripts/components/troubleshooting-menu.js
  89. 52 0
      app/scripts/components/view-mode-button.js
  90. 73 0
      app/scripts/components/view-mode-selector.js
  91. 39 0
      app/scripts/components/warning.js
  92. 69 0
      app/scripts/components/zoom-control.js
  93. 290 0
      app/scripts/components/zoomable-canvas.js
  94. 71 0
      app/scripts/constants/action-types.js
  95. 4 0
      app/scripts/constants/key-codes.js
  96. 2 0
      app/scripts/constants/limits.js
  97. 21 0
      app/scripts/constants/naming.js
  98. 27 0
      app/scripts/constants/resources.js
  99. 106 0
      app/scripts/constants/styles.js
  100. 10 0
      app/scripts/constants/timer.js

+ 43 - 0
.babelrc

@@ -0,0 +1,43 @@
+{
+  "presets": [
+    [
+      "@babel/preset-env",
+      {
+        "modules": "commonjs"
+      }
+    ],
+    "@babel/preset-react"
+  ],
+  "plugins": [
+    [
+      "@babel/plugin-proposal-object-rest-spread",
+      {
+        "useBuiltIns": true
+      }
+    ],
+    "@babel/plugin-proposal-class-properties",
+    "lodash",
+  ],
+  "env": {
+    "test": {
+      "presets": [
+        [
+          "@babel/preset-env",
+          {
+            "modules": "commonjs"
+          }
+        ],
+        "@babel/preset-react"
+      ],
+      "plugins": [
+        [
+          "@babel/plugin-proposal-object-rest-spread",
+          {
+            "useBuiltIns": true
+          }
+        ],
+        "@babel/plugin-proposal-class-properties",
+      ]
+    }
+  }
+}

+ 22 - 0
.editorconfig

@@ -0,0 +1,22 @@
+# EditorConfig helps developers define and maintain consistent
+# coding styles between different editors and IDEs
+# editorconfig.org
+
+root = true
+
+
+[*]
+
+# Change these settings to your own preference
+indent_style = space
+indent_size = 2
+
+# We recommend you to keep these unchanged
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
+

+ 1 - 0
.eslintignore

@@ -0,0 +1 @@
+app/scripts/vendor/term.js

+ 51 - 0
.eslintrc

@@ -0,0 +1,51 @@
+{
+  "extends": "airbnb",
+  "parser": "babel-eslint",
+  "env": {
+    "browser": true,
+    "jest": true,
+    "node": true
+  },
+  "rules": {
+    "no-debugger": 1,
+    "comma-dangle": 0,
+    "global-require": 0,
+    "sort-keys": [
+      "error",
+      "asc",
+      {
+        "caseSensitive": false,
+        "natural": true
+      }
+    ],
+    "import/no-extraneous-dependencies": [
+      "error",
+      {
+        "devDependencies": true,
+        "optionalDependencies": true,
+        "peerDependencies": true
+      }
+    ],
+    "import/prefer-default-export": 0,
+    "jsx-a11y/no-static-element-interactions": 0,
+    "no-param-reassign": 0,
+    "no-restricted-properties": 0,
+    "object-curly-spacing": 0,
+    "react/destructuring-assignment": 0,
+    "react/jsx-closing-bracket-location": 0,
+    "react/jsx-filename-extension": [
+      2,
+      {
+        "extensions": [
+          ".js",
+          ".jsx"
+        ]
+      }
+    ],
+    "react/prefer-stateless-function": 0,
+    "react/sort-comp": 0,
+    "react/prop-types": 0,
+    "jsx-a11y/click-events-have-key-events": 0,
+    "jsx-a11y/mouse-events-have-key-events": 0
+  }
+}

+ 1 - 0
.gitattributes

@@ -0,0 +1 @@
+* text=auto

+ 17 - 0
.gitignore

@@ -0,0 +1,17 @@
+.DS_Store
+node_modules/
+/dist/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+/test/unit/coverage/
+/test/e2e/reports/
+selenium-debug.log
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln

+ 1 - 0
.nvmrc

@@ -0,0 +1 @@
+v10.19.0

+ 31 - 0
.stylelintrc

@@ -0,0 +1,31 @@
+{
+  "processors": ["stylelint-processor-styled-components"],
+  "extends": [
+    "stylelint-config-styled-components",
+    "stylelint-config-recommended",
+  ],
+  "plugins": ["stylelint-declaration-use-variable"],
+  "rules": {
+    "block-no-empty": null,
+    "color-named": "never",
+    "color-no-hex": true,
+    "function-disallowed-list": ["/^rgb/", "/^hsl/"],
+    "no-empty-source": null,
+    "no-descending-specificity": null,
+    "no-duplicate-selectors": null,
+    "property-no-vendor-prefix": [true, {
+      "ignoreProperties": ["tab-size", "hyphens"],
+    }],
+    "selector-type-no-unknown": null,
+    "sh-waqar/declaration-use-variable": [[
+      "border-radius",
+      "border-top-left-radius",
+      "border-top-right-radius",
+      "border-bottom-left-radius",
+      "border-bottom-right-radius",
+      "font-family",
+      "font-size",
+      "z-index"
+    ]],
+  },
+}

+ 3 - 0
Dockerfile

@@ -0,0 +1,3 @@
+FROM nginx:1.25.3
+COPY dist /usr/share/nginx/html/
+EXPOSE 80

+ 47 - 0
Makefile

@@ -0,0 +1,47 @@
+DOCKER_IMAGE_NAME=observe-ui
+DOCKER_REMOTE_IMAGE_NAME=reg.cestong.com.cn/cecf/${DOCKER_IMAGE_NAME}
+# DOCKER_REMOTE_IMAGE_NAME=pujielan/${DOCKER_IMAGE_NAME}
+
+docker-build:
+	npm run build
+	git rev-parse --short HEAD > dist/version
+	docker build . -t ${DOCKER_IMAGE_NAME}
+
+
+docker-push: docker-build
+	docker tag ${DOCKER_IMAGE_NAME} ${DOCKER_REMOTE_IMAGE_NAME}
+	docker push ${DOCKER_REMOTE_IMAGE_NAME}
+
+
+deploy: docker-push
+	ssh km1 'kubectl rollout restart deployment obui -n observe'
+
+
+docker-build-php:
+	docker build -t registry.cestong.com:8150/zhixueyun/hx-php-server -f ./deploy/php/Dockerfile .
+
+docker-push-php: docker-build-php
+	docker push registry.cestong.com:8150/zhixueyun/hx-php-server
+
+docker-build-nginx:
+	docker build -t registry.cestong.com:8150/zhixueyun/hx-nginx -f ./deploy/nginx/Dockerfile .
+
+db-backup-file = ~/data/test_ctc_backup_$(shell date '+%Y-%m-%dT%H-%M').sql
+db-backup-test:
+	ssh cest-2 'cd data && mysqldump -h 172.17.172.137 -u root -p1qaz2wsx3edc -P 53306 --databases ctc > dump.sql'
+	scp cest-2:~/data/dump.sql ${db-backup-file}
+
+db-sync-to-local: db-backup-test
+	mysql -u root -p1234 -h 127.0.0.1 < ${db-backup-file}
+
+release-tag:
+	git tag "v${VERSION}"
+	git push --tags
+
+ob-pack:
+	git rev-parse --short HEAD > version
+	docker build -f buildDockerfile . -t ${DOCKER_IMAGE_NAME}
+ob-push: ob-pack
+	docker tag ${DOCKER_IMAGE_NAME} ${DOCKER_REMOTE_IMAGE_NAME}
+	docker push ${DOCKER_REMOTE_IMAGE_NAME}
+

+ 63 - 0
README.md

@@ -0,0 +1,63 @@
+# Scope UI
+
+## Getting Started (using local node)
+
+- You need at least Node.js 6.9.0 and a running `weavescope` container
+- Get Yarn: `npm install -g yarn`
+- Setup: `yarn install`
+- Develop: `BACKEND_HOST=<dockerhost-ip> yarn start` and then open `http://localhost:4042/`
+
+This will start a webpack-dev-server that serves the UI and proxies API requests to the container.
+
+## Getting Started (using node in a container)
+
+- You need a running `weavescope` container
+- Develop: `make WEBPACK_SERVER_HOST=<dockerhost-ip> client-start` and then open `http://<dockerhost-ip>:4042/`
+
+This will start a webpack-dev-server that serves the UI from the UI build container and proxies API requests to the weavescope container.
+
+## Test Production Bundles Locally
+
+- Build: `yarn run build`, output will be in `build/`
+- Serve files from `build/`: `BACKEND_HOST=<dockerhost-ip> yarn run start-production` and then open `http://localhost:4042/`
+
+## Coding
+
+This directory has a `.eslintrc`, make sure your editor supports linter hints.
+To run a linter, you also run `yarn run lint`.
+
+## Logging
+
+To enable logging in the console, activate it via `localStorage` in the dev tools console:
+
+```
+localStorage["debug"] = "scope:*"
+```
+
+The Scope UI uses [debug](https://www.npmjs.com/package/debug) for logging, e.g.,:
+
+```
+const debug = require('debug')('scope:app-store');
+debug('Store log message');
+```
+
+## Gotchas
+
+Got a blank screen when loading `http://localhost:4042`?
+
+Make sure you are accessing the right machine:
+If you're running `yarn start` on a virtual machine with IP 10.0.0.8, you need to point your browser to `http://10.0.0.8:4042`.
+Also, you may need to manually configure the virtual machine to expose ports 4041 (webpack-dev-server) and 4042 (express proxy).
+
+## 补充说明
+- 新拉取代码后的额外操作
+```
+npm install less-loader@5.0.0
+npm install less@4.2.0
+```
+- 本地启动
+```
+npm start
+```
+
+

BIN
app/fonts/proximanova-regular.woff


BIN
app/fonts/robotomono-regular.ttf


+ 18 - 0
app/html/index.html

@@ -0,0 +1,18 @@
+<!doctype html>
+<html class="no-js">
+  <head>
+    <meta charset="utf-8">
+    <title>Weave Scope</title>
+    <meta name="description" content="">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <script language="javascript">window.__WEAVEWORKS_CSRF_TOKEN = "$__CSRF_TOKEN_PLACEHOLDER__";</script>
+  </head>
+  <body>
+    <!--[if lt IE 10]>
+      <p class="browsehappy">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
+    <![endif]-->
+    <div class="wrap">
+      <div id="app"></div>
+    </div>
+  </body>
+</html>

BIN
app/images/favicon.ico


+ 1 - 0
app/scripts/actions.js

@@ -0,0 +1 @@
+module.exports = require('./actions/app-actions');

+ 463 - 0
app/scripts/actions/app-actions.js

@@ -0,0 +1,463 @@
+import ActionTypes from '../constants/action-types';
+import { saveGraph } from '../utils/file-utils';
+import { clearStoredViewState, updateRoute } from '../utils/router-utils';
+import { isPausedSelector } from '../selectors/time-travel';
+import {
+  nextPinnedMetricTypeSelector,
+  previousPinnedMetricTypeSelector,
+} from '../selectors/node-metric';
+import { isResourceViewModeSelector } from '../selectors/topology';
+
+import {
+  GRAPH_VIEW_MODE,
+  TABLE_VIEW_MODE,
+} from '../constants/naming';
+
+
+export function showHelp() {
+  return { type: ActionTypes.SHOW_HELP };
+}
+
+
+export function hideHelp() {
+  return { type: ActionTypes.HIDE_HELP };
+}
+
+
+export function toggleHelp() {
+  return (dispatch, getState) => {
+    if (getState().get('showingHelp')) {
+      dispatch(hideHelp());
+    } else {
+      dispatch(showHelp());
+    }
+  };
+}
+
+
+export function sortOrderChanged(sortedBy, sortedDesc) {
+  return (dispatch, getState) => {
+    dispatch({
+      sortedBy,
+      sortedDesc,
+      type: ActionTypes.SORT_ORDER_CHANGED
+    });
+    updateRoute(getState);
+  };
+}
+
+
+//
+// Networks
+//
+
+
+export function showNetworks(visible) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: ActionTypes.SHOW_NETWORKS,
+      visible
+    });
+
+    updateRoute(getState);
+  };
+}
+
+
+export function selectNetwork(networkId) {
+  return {
+    networkId,
+    type: ActionTypes.SELECT_NETWORK
+  };
+}
+
+export function pinNetwork(networkId) {
+  return (dispatch, getState) => {
+    dispatch({
+      networkId,
+      type: ActionTypes.PIN_NETWORK,
+    });
+
+    updateRoute(getState);
+  };
+}
+
+export function unpinNetwork(networkId) {
+  return (dispatch, getState) => {
+    dispatch({
+      networkId,
+      type: ActionTypes.UNPIN_NETWORK,
+    });
+
+    updateRoute(getState);
+  };
+}
+
+
+//
+// Metrics
+//
+
+export function hoverMetric(metricType) {
+  return {
+    metricType,
+    type: ActionTypes.HOVER_METRIC,
+  };
+}
+
+export function unhoverMetric() {
+  return {
+    type: ActionTypes.UNHOVER_METRIC,
+  };
+}
+
+export function pinMetric(metricType) {
+  return (dispatch, getState) => {
+    dispatch({
+      metricType,
+      type: ActionTypes.PIN_METRIC,
+    });
+    updateRoute(getState);
+  };
+}
+
+export function unpinMetric() {
+  return (dispatch, getState) => {
+    // We always have to keep metrics pinned in the resource view.
+    if (!isResourceViewModeSelector(getState())) {
+      dispatch({
+        type: ActionTypes.UNPIN_METRIC,
+      });
+      updateRoute(getState);
+    }
+  };
+}
+
+export function pinNextMetric() {
+  return (dispatch, getState) => {
+    const nextPinnedMetricType = nextPinnedMetricTypeSelector(getState());
+    dispatch(pinMetric(nextPinnedMetricType));
+  };
+}
+
+export function pinPreviousMetric() {
+  return (dispatch, getState) => {
+    const previousPinnedMetricType = previousPinnedMetricTypeSelector(getState());
+    dispatch(pinMetric(previousPinnedMetricType));
+  };
+}
+
+export function updateSearch(searchQuery = '', pinnedSearches = []) {
+  return (dispatch, getState) => {
+    dispatch({
+      pinnedSearches,
+      searchQuery,
+      type: ActionTypes.UPDATE_SEARCH,
+    });
+    updateRoute(getState);
+  };
+}
+
+export function blurSearch() {
+  return { type: ActionTypes.BLUR_SEARCH };
+}
+
+export function clickBackground() {
+  return (dispatch, getState) => {
+    dispatch({
+      type: ActionTypes.CLICK_BACKGROUND
+    });
+    updateRoute(getState);
+  };
+}
+
+export function closeTerminal(pipeId) {
+  return (dispatch, getState) => {
+    dispatch({
+      pipeId,
+      type: ActionTypes.CLOSE_TERMINAL
+    });
+    updateRoute(getState);
+  };
+}
+
+export function clickDownloadGraph() {
+  return (dispatch) => {
+    dispatch({ exporting: true, type: ActionTypes.SET_EXPORTING_GRAPH });
+    saveGraph();
+    dispatch({ exporting: false, type: ActionTypes.SET_EXPORTING_GRAPH });
+  };
+}
+
+export function clickForceRelayout() {
+  return (dispatch) => {
+    dispatch({
+      forceRelayout: true,
+      type: ActionTypes.CLICK_FORCE_RELAYOUT
+    });
+    // fire only once, reset after dispatch
+    setTimeout(() => {
+      dispatch({
+        forceRelayout: false,
+        type: ActionTypes.CLICK_FORCE_RELAYOUT
+      });
+    }, 100);
+  };
+}
+
+export function setViewportDimensions(width, height) {
+  return (dispatch) => {
+    dispatch({ height, type: ActionTypes.SET_VIEWPORT_DIMENSIONS, width });
+  };
+}
+
+export function setGraphView() {
+  return (dispatch, getState) => {
+    dispatch({
+      type: ActionTypes.SET_VIEW_MODE,
+      viewMode: GRAPH_VIEW_MODE,
+    });
+    updateRoute(getState);
+  };
+}
+
+export function setTableView() {
+  return (dispatch, getState) => {
+    dispatch({
+      type: ActionTypes.SET_VIEW_MODE,
+      viewMode: TABLE_VIEW_MODE,
+    });
+    updateRoute(getState);
+  };
+}
+
+export function cacheZoomState(zoomState) {
+  return {
+    type: ActionTypes.CACHE_ZOOM_STATE,
+    // Make sure only proper numerical values are cached.
+    zoomState: zoomState.filter(value => !window.isNaN(value)),
+  };
+}
+
+export function openWebsocket() {
+  return {
+    type: ActionTypes.OPEN_WEBSOCKET
+  };
+}
+
+export function clearControlError(nodeId) {
+  return {
+    nodeId,
+    type: ActionTypes.CLEAR_CONTROL_ERROR
+  };
+}
+
+export function closeWebsocket() {
+  return {
+    type: ActionTypes.CLOSE_WEBSOCKET
+  };
+}
+
+export function enterEdge(edgeId) {
+  return {
+    edgeId,
+    type: ActionTypes.ENTER_EDGE
+  };
+}
+
+export function enterNode(nodeId) {
+  return {
+    nodeId,
+    type: ActionTypes.ENTER_NODE
+  };
+}
+
+export function hitEsc() {
+  return (dispatch, getState) => {
+    const state = getState();
+    const controlPipe = state.get('controlPipes').last();
+    if (controlPipe && controlPipe.get('status') === 'PIPE_DELETED') {
+      dispatch({
+        pipeId: controlPipe.get('id'),
+        type: ActionTypes.CLOSE_TERMINAL
+      });
+      updateRoute(getState);
+    } else if (state.get('showingHelp')) {
+      dispatch(hideHelp());
+    } else if (state.get('nodeDetails').last() && !controlPipe) {
+      dispatch({ type: ActionTypes.DESELECT_NODE });
+      updateRoute(getState);
+    }
+  };
+}
+
+export function leaveEdge(edgeId) {
+  return {
+    edgeId,
+    type: ActionTypes.LEAVE_EDGE
+  };
+}
+
+export function leaveNode(nodeId) {
+  return {
+    nodeId,
+    type: ActionTypes.LEAVE_NODE
+  };
+}
+
+export function receiveControlError(nodeId, err) {
+  return {
+    error: err,
+    nodeId,
+    type: ActionTypes.DO_CONTROL_ERROR
+  };
+}
+
+export function receiveControlSuccess(nodeId) {
+  return {
+    nodeId,
+    type: ActionTypes.DO_CONTROL_SUCCESS
+  };
+}
+
+export function receiveNodeDetails(details, requestTimestamp) {
+  return {
+    details,
+    requestTimestamp,
+    type: ActionTypes.RECEIVE_NODE_DETAILS
+  };
+}
+
+export function receiveNodesDelta(delta) {
+  return (dispatch, getState) => {
+    if (!isPausedSelector(getState())) {
+      // Allow css-animation to run smoothly by scheduling it to run on the
+      // next tick after any potentially expensive canvas re-draws have been
+      // completed.
+      setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0);
+
+      // When moving in time, we will consider the transition complete
+      // only when the first batch of nodes delta has been received. We
+      // do that because we want to keep the previous state blurred instead
+      // of transitioning over an empty state like when switching topologies.
+      if (getState().get('timeTravelTransitioning')) {
+        dispatch({ type: ActionTypes.FINISH_TIME_TRAVEL_TRANSITION });
+      }
+
+      const hasChanges = delta.add || delta.update || delta.remove || delta.reset;
+      if (hasChanges) {
+        dispatch({
+          delta,
+          type: ActionTypes.RECEIVE_NODES_DELTA
+        });
+      }
+    }
+  };
+}
+
+export function receiveNodes(nodes) {
+  return {
+    nodes,
+    type: ActionTypes.RECEIVE_NODES,
+  };
+}
+
+export function receiveNodesForTopology(nodes, topologyId) {
+  return {
+    nodes,
+    topologyId,
+    type: ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY
+  };
+}
+
+export function receiveControlNodeRemoved(nodeId) {
+  return (dispatch, getState) => {
+    dispatch({
+      nodeId,
+      type: ActionTypes.RECEIVE_CONTROL_NODE_REMOVED
+    });
+    updateRoute(getState);
+  };
+}
+
+export function receiveControlPipeFromParams(pipeId, rawTty, resizeTtyControl) {
+  // TODO add nodeId
+  return {
+    pipeId,
+    rawTty,
+    resizeTtyControl,
+    type: ActionTypes.RECEIVE_CONTROL_PIPE
+  };
+}
+
+export function receiveControlPipeStatus(pipeId, status) {
+  return {
+    pipeId,
+    status,
+    type: ActionTypes.RECEIVE_CONTROL_PIPE_STATUS
+  };
+}
+
+export function receiveError(errorUrl) {
+  return {
+    errorUrl,
+    type: ActionTypes.RECEIVE_ERROR
+  };
+}
+
+export function receiveNotFound(nodeId, requestTimestamp) {
+  return {
+    nodeId,
+    requestTimestamp,
+    type: ActionTypes.RECEIVE_NOT_FOUND,
+  };
+}
+
+export function setContrastMode(enabled) {
+  return (dispatch, getState) => {
+    dispatch({
+      enabled,
+      type: ActionTypes.TOGGLE_CONTRAST_MODE,
+    });
+    updateRoute(getState);
+  };
+}
+
+export function resetLocalViewState() {
+  return (dispatch) => {
+    dispatch({ type: ActionTypes.RESET_LOCAL_VIEW_STATE });
+    clearStoredViewState();
+    // eslint-disable-next-line prefer-destructuring
+    window.location.href = window.location.href.split('#')[0];
+  };
+}
+
+export function toggleTroubleshootingMenu(ev) {
+  if (ev) { ev.preventDefault(); ev.stopPropagation(); }
+  return {
+    type: ActionTypes.TOGGLE_TROUBLESHOOTING_MENU
+  };
+}
+
+export function changeInstance() {
+  return (dispatch, getState) => {
+    dispatch({
+      type: ActionTypes.CHANGE_INSTANCE
+    });
+    updateRoute(getState);
+  };
+}
+
+export function setMonitorState(monitor) {
+  return {
+    monitor,
+    type: ActionTypes.MONITOR_STATE
+  };
+}
+
+export function setStoreViewState(storeViewState) {
+  return {
+    storeViewState,
+    type: ActionTypes.SET_STORE_VIEW_STATE
+  };
+}

+ 618 - 0
app/scripts/actions/request-actions.js

@@ -0,0 +1,618 @@
+/*
+
+This file consists of functions that both dispatch actions to Redux and also make API requests.
+
+TODO: Refactor all the methods below so that the split between actions and
+requests is more clear, and make user components make explicit calls to requests
+and dispatch actions when handling request promises.
+
+*/
+import debug from 'debug';
+import { fromJS } from 'immutable';
+
+import ActionTypes from '../constants/action-types';
+import { RESOURCE_VIEW_MODE } from '../constants/naming';
+import {
+  API_REFRESH_INTERVAL,
+  TOPOLOGY_REFRESH_INTERVAL,
+} from '../constants/timer';
+import { updateRoute } from '../utils/router-utils';
+import { getCurrentTopologyUrl } from '../utils/topology-utils';
+import {
+  doRequest,
+  getApiPath,
+  getAllNodes,
+  getNodesOnce,
+  deletePipe,
+  getNodeDetails,
+  getResourceViewNodesSnapshot,
+  topologiesUrl,
+  topologiesUrlApi,
+  buildWebsocketUrl,
+} from '../utils/web-api-utils';
+import {
+  availableMetricTypesSelector,
+  pinnedMetricSelector,
+} from '../selectors/node-metric';
+import {
+  isResourceViewModeSelector,
+  resourceViewAvailableSelector,
+  activeTopologyOptionsSelector,
+} from '../selectors/topology';
+import { isPausedSelector } from '../selectors/time-travel';
+
+import {
+  receiveControlNodeRemoved,
+  receiveControlPipeStatus,
+  receiveControlSuccess,
+  receiveControlError,
+  receiveError,
+  pinMetric,
+  openWebsocket,
+  closeWebsocket,
+  receiveNodesDelta,
+  clearControlError,
+  blurSearch,
+} from './app-actions';
+
+import getToken from '../utils/get-token'
+const log = debug('scope:app-actions');
+const reconnectTimerInterval = 5000;
+const FIRST_RENDER_TOO_LONG_THRESHOLD = 100; // ms
+var apiRoot = process.env.API_ROOT;
+
+let socket;
+let topologyTimer = 0;
+let controlErrorTimer = 0;
+let reconnectTimer = 0;
+let apiDetailsTimer = 0;
+let continuePolling = true;
+let firstMessageOnWebsocketAt = null;
+let createWebsocketAt = null;
+let currentUrl = null;
+
+function createWebsocket(websocketUrl, getState, dispatch) {
+  if (socket) {
+    socket.onclose = null;
+    socket.onerror = null;
+    socket.close();
+    // onclose() is not called, but that's fine since we're opening a new one
+    // right away
+  }
+
+  // profiling
+  createWebsocketAt = new Date();
+  firstMessageOnWebsocketAt = null;
+
+
+
+  socket = new WebSocket(websocketUrl);
+
+  socket.onopen = () => {
+    log(`Opening websocket to ${websocketUrl}`);
+    socket.send({ 'Authorization': getToken })
+    dispatch(openWebsocket());
+  };
+
+  socket.onclose = () => {
+    clearTimeout(reconnectTimer);
+    log(`Closing websocket to ${websocketUrl}`, socket.readyState);
+    socket = null;
+    dispatch(closeWebsocket());
+
+    if (continuePolling && !isPausedSelector(getState())) {
+      reconnectTimer = setTimeout(() => {
+        createWebsocket(websocketUrl, getState, dispatch);
+      }, reconnectTimerInterval);
+    }
+  };
+
+  socket.onerror = () => {
+    log(`Error in websocket to ${websocketUrl}`);
+    dispatch(receiveError(websocketUrl));
+  };
+
+  socket.onmessage = (event) => {
+    const msg = JSON.parse(event.data);
+    dispatch(receiveNodesDelta(msg));
+
+    // profiling (receiveNodesDelta triggers synchronous render)
+    if (!firstMessageOnWebsocketAt) {
+      firstMessageOnWebsocketAt = new Date();
+      const timeToFirstMessage = firstMessageOnWebsocketAt - createWebsocketAt;
+      if (timeToFirstMessage > FIRST_RENDER_TOO_LONG_THRESHOLD) {
+        log(
+          'Time (ms) to first nodes render after websocket was created',
+          firstMessageOnWebsocketAt - createWebsocketAt
+        );
+      }
+    }
+  };
+}
+
+function teardownWebsockets() {
+  clearTimeout(reconnectTimer);
+  if (socket) {
+    socket.onerror = null;
+    socket.onclose = null;
+    socket.onmessage = null;
+    socket.onopen = null;
+    socket.close();
+    socket = null;
+    currentUrl = null;
+  }
+}
+
+function updateWebsocketChannel(getState, dispatch, forceRequest) {
+  const topologyUrl = getCurrentTopologyUrl(getState());
+  const topologyOptions = activeTopologyOptionsSelector(getState());
+  const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, getState());
+  // Only recreate websocket if url changed or if forced (weave cloud instance reload);
+  const isNewUrl = websocketUrl !== currentUrl;
+  // `topologyUrl` can be undefined initially, so only create a socket if it is truthy
+  // and no socket exists, or if we get a new url.
+  if (topologyUrl && (!socket || isNewUrl || forceRequest)) {
+    createWebsocket(websocketUrl, getState, dispatch);
+    currentUrl = websocketUrl;
+  }
+}
+
+function getNodes(getState, dispatch, forceRequest = false) {
+  if (isPausedSelector(getState())) {
+    getNodesOnce(getState, dispatch);
+  } else {
+    updateWebsocketChannel(getState, dispatch, forceRequest);
+  }
+  //后来注释掉的
+  // getNodeDetails(getState, dispatch);
+}
+
+export function pauseTimeAtNow() {
+  return (dispatch, getState) => {
+    dispatch({
+      type: ActionTypes.PAUSE_TIME_AT_NOW
+    });
+    updateRoute(getState);
+    if (!getState().get('nodesLoaded')) {
+      getNodes(getState, dispatch);
+      if (isResourceViewModeSelector(getState())) {
+        getResourceViewNodesSnapshot(getState(), dispatch);
+      }
+    }
+  };
+}
+
+function receiveTopologies(topologies) {
+  return (dispatch, getState) => {
+    const firstLoad = !getState().get('topologiesLoaded');
+    dispatch({
+      topologies,
+      type: ActionTypes.RECEIVE_TOPOLOGIES
+    });
+    getNodes(getState, dispatch);
+    // Populate search matches on first load
+    const state = getState();
+    // Fetch all the relevant nodes once on first load
+    if (firstLoad && isResourceViewModeSelector(state)) {
+      getResourceViewNodesSnapshot(state, dispatch);
+    }
+  };
+}
+
+function getTopologiesOnce(getState, dispatch) {
+  // const url = topologiesUrl(getState());
+  const url = topologiesUrlApi(getState())
+  doRequest({
+    error: (req) => {
+      log(`Error in topology request: ${req.responseText}`);
+      dispatch(receiveError(url));
+    },
+    success: (res) => {
+      dispatch(receiveTopologies(res));
+    },
+    url
+  });
+}
+
+function pollTopologies(getState, dispatch, initialPoll = false) {
+  // Used to resume polling when navigating between pages in Weave Cloud.
+  continuePolling = initialPoll === true ? true : continuePolling;
+  clearTimeout(topologyTimer);
+  // NOTE: getState is called every time to make sure the up-to-date state is used.
+  // const url = topologiesUrl(getState());
+  const url = topologiesUrlApi(getState())
+  doRequest({
+    error: (req) => {
+      log(`Error in topology request: ${req.responseText}`);
+      dispatch(receiveError(url));
+      // Only retry in stand-alone mode
+      if (continuePolling && !isPausedSelector(getState())) {
+        topologyTimer = setTimeout(() => {
+          pollTopologies(getState, dispatch);
+        }, TOPOLOGY_REFRESH_INTERVAL);
+      }
+    },
+    success: (res) => {
+      if (continuePolling && !isPausedSelector(getState())) {
+        dispatch(receiveTopologies(res));
+        topologyTimer = setTimeout(() => {
+          pollTopologies(getState, dispatch);
+        }, TOPOLOGY_REFRESH_INTERVAL);
+      }
+    },
+    url
+  });
+}
+
+function getTopologies(getState, dispatch, forceRequest) {
+  if (isPausedSelector(getState())) {
+    getTopologiesOnce(getState, dispatch);
+  } else {
+    pollTopologies(getState, dispatch, forceRequest);
+  }
+}
+
+export function jumpToTime(timestamp) {
+  return (dispatch, getState) => {
+    dispatch({
+      timestamp,
+      type: ActionTypes.JUMP_TO_TIME,
+    });
+    updateRoute(getState);
+    getTopologies(getState, dispatch);
+    if (!getState().get('nodesLoaded')) {
+      getNodes(getState, dispatch);
+      if (isResourceViewModeSelector(getState())) {
+        getResourceViewNodesSnapshot(getState(), dispatch);
+      }
+    } else {
+      // Get most recent details before freezing the state.
+      getNodeDetails(getState, dispatch);
+    }
+  };
+}
+
+export function receiveApiDetails(apiDetails) {
+  return (dispatch, getState) => {
+    const isFirstTime = !getState().get('version');
+    const pausedAt = getState().get('pausedAt');
+
+    dispatch({
+      capabilities: fromJS(apiDetails.capabilities || {}),
+      hostname: apiDetails.hostname,
+      newVersion: apiDetails.newVersion,
+      plugins: apiDetails.plugins,
+      type: ActionTypes.RECEIVE_API_DETAILS,
+      version: apiDetails.version,
+    });
+
+    // On initial load either start time travelling at the pausedAt timestamp
+    // (if it was given as URL param) if time travelling is enabled, otherwise
+    // simply pause at the present time which is arguably the next best thing
+    // we could do.
+    // NOTE: We can't make this decision before API details are received because
+    // we have no prior info on whether time travel would be available.
+    if (isFirstTime && pausedAt) {
+      if (apiDetails.capabilities && apiDetails.capabilities.historic_reports) {
+        dispatch(jumpToTime(pausedAt));
+      } else {
+        dispatch(pauseTimeAtNow());
+      }
+    }
+  };
+}
+
+export function getApiDetails(dispatch) {
+  clearTimeout(apiDetailsTimer);
+  const url = `${getApiPath()}/api`;
+  doRequest({
+    error: (req) => {
+      log(`Error in api details request: ${req.responseText}`);
+      receiveError(url);
+      if (continuePolling) {
+        apiDetailsTimer = setTimeout(() => {
+          getApiDetails(dispatch);
+        }, API_REFRESH_INTERVAL / 2);
+      }
+    },
+    success: (res) => {
+      dispatch(receiveApiDetails(res));
+      if (continuePolling) {
+        apiDetailsTimer = setTimeout(() => {
+          getApiDetails(dispatch);
+        }, API_REFRESH_INTERVAL);
+      }
+    },
+    url
+  });
+}
+
+function stopPolling() {
+  clearTimeout(apiDetailsTimer);
+  clearTimeout(topologyTimer);
+  continuePolling = false;
+}
+
+export function focusSearch() {
+  return (dispatch, getState) => {
+    dispatch({ type: ActionTypes.FOCUS_SEARCH });
+    // update nodes cache to allow search across all topologies,
+    // wait a second until animation is over
+    // NOTE: This will cause matching recalculation (and rerendering)
+    // of all the nodes in the topology, instead applying it only on
+    // the nodes delta. The solution would be to implement deeper
+    // search selectors with per-node caching instead of per-topology.
+    setTimeout(() => {
+      getAllNodes(getState(), dispatch);
+    }, 1200);
+  };
+}
+
+export function getPipeStatus(pipeId, dispatch) {
+  const url = `${getApiPath()}/api/pipe/${encodeURIComponent(pipeId)}/check`;
+  doRequest({
+    complete: (res) => {
+      const status = {
+        204: 'PIPE_ALIVE',
+        404: 'PIPE_DELETED'
+      }[res.status];
+
+      if (!status) {
+        log('Unexpected pipe status:', res.status);
+        return;
+      }
+
+      dispatch(receiveControlPipeStatus(pipeId, status));
+    },
+    method: 'GET',
+    url
+  });
+}
+
+export function receiveControlPipe(pipeId, nodeId, rawTty, resizeTtyControl, control) {
+  return (dispatch, getState) => {
+    const state = getState();
+    if (state.get('nodeDetails').last()
+      && nodeId !== state.get('nodeDetails').last().id) {
+      log('Node was deselected before we could set up control!');
+      deletePipe(pipeId, dispatch);
+      return;
+    }
+
+    const controlPipe = state.get('controlPipes').last();
+    if (controlPipe && controlPipe.get('id') !== pipeId) {
+      deletePipe(controlPipe.get('id'), dispatch);
+    }
+
+    dispatch({
+      control,
+      nodeId,
+      pipeId,
+      rawTty,
+      resizeTtyControl,
+      type: ActionTypes.RECEIVE_CONTROL_PIPE
+    });
+
+    updateRoute(getState);
+  };
+}
+
+function doControlRequest(nodeId, control, dispatch) {
+  clearTimeout(controlErrorTimer);
+  const url = `${getApiPath()}/api/control/${encodeURIComponent(control.probeId)}/`
+    + `${encodeURIComponent(control.nodeId)}/${control.id}`;
+  doRequest({
+    error: (err) => {
+      dispatch(receiveControlError(nodeId, err.response));
+      controlErrorTimer = setTimeout(() => {
+        dispatch(clearControlError(nodeId));
+      }, 10000);
+    },
+    method: 'POST',
+    success: (res) => {
+      dispatch(receiveControlSuccess(nodeId));
+      if (res) {
+        if (res.pipe) {
+          dispatch(blurSearch());
+          const resizeTtyControl = res.resize_tty_control
+            && { id: res.resize_tty_control, nodeId: control.nodeId, probeId: control.probeId };
+          dispatch(receiveControlPipe(
+            res.pipe,
+            nodeId,
+            res.raw_tty,
+            resizeTtyControl,
+            control
+          ));
+        }
+        if (res.removedNode) {
+          dispatch(receiveControlNodeRemoved(nodeId));
+        }
+      }
+    },
+    url
+  });
+}
+
+export function doControl(nodeId, control) {
+  return (dispatch) => {
+    dispatch({
+      control,
+      nodeId,
+      type: ActionTypes.DO_CONTROL
+    });
+    doControlRequest(nodeId, control, dispatch);
+  };
+}
+
+export function shutdown() {
+  return (dispatch) => {
+    stopPolling();
+    teardownWebsockets();
+    dispatch({
+      type: ActionTypes.SHUTDOWN
+    });
+  };
+}
+
+export function setResourceView() {
+  return (dispatch, getState) => {
+    if (resourceViewAvailableSelector(getState())) {
+      dispatch({
+        type: ActionTypes.SET_VIEW_MODE,
+        viewMode: RESOURCE_VIEW_MODE,
+      });
+      // Pin the first metric if none of the visible ones is pinned.
+      const state = getState();
+      if (!pinnedMetricSelector(state)) {
+        const firstAvailableMetricType = availableMetricTypesSelector(state).first();
+        dispatch(pinMetric(firstAvailableMetricType));
+      }
+      getResourceViewNodesSnapshot(getState(), dispatch);
+      updateRoute(getState);
+    }
+  };
+}
+
+export function changeTopologyOption(option, value, topologyId, addOrRemove) {
+  return (dispatch, getState) => {
+    dispatch({
+      addOrRemove,
+      option,
+      topologyId,
+      type: ActionTypes.CHANGE_TOPOLOGY_OPTION,
+      value
+    });
+    updateRoute(getState);
+    // update all request workers with new options
+    getTopologies(getState, dispatch);
+    getNodes(getState, dispatch);
+  };
+}
+
+export function getTopologiesWithInitialPoll() {
+  return (dispatch, getState) => {
+    getTopologies(getState, dispatch, true);
+  };
+}
+
+export function resumeTime() {
+  return (dispatch, getState) => {
+    if (isPausedSelector(getState())) {
+      dispatch({
+        type: ActionTypes.RESUME_TIME
+      });
+      updateRoute(getState);
+      // After unpausing, all of the following calls will re-activate polling.
+      getTopologies(getState, dispatch);
+      getNodes(getState, dispatch, true);
+      if (isResourceViewModeSelector(getState())) {
+        getResourceViewNodesSnapshot(getState(), dispatch);
+      }
+    }
+  };
+}
+
+export function route(urlState) {
+  return (dispatch, getState) => {
+    dispatch({
+      state: urlState,
+      type: ActionTypes.ROUTE_TOPOLOGY
+    });
+    // Handle Time Travel state update through separate actions as it's more complex.
+    // This is mostly to handle switching contexts Explore <-> Monitor in WC while
+    // the timestamp keeps changing - e.g. if we were Time Travelling in Scope and
+    // then went live in Monitor, switching back to Explore should properly close
+    // the Time Travel etc, not just update the pausedAt state directly.
+    if (!urlState.pausedAt) {
+      dispatch(resumeTime());
+    } else {
+      dispatch(jumpToTime(urlState.pausedAt));
+    }
+    // update all request workers with new options
+    getTopologies(getState, dispatch);
+    getNodes(getState, dispatch);
+    // If we are landing on the resource view page, we need to fetch not only all the
+    // nodes for the current topology, but also the nodes of all the topologies that make
+    // the layers in the resource view.
+    const state = getState();
+    if (isResourceViewModeSelector(state)) {
+      getResourceViewNodesSnapshot(state, dispatch);
+    }
+  };
+}
+
+export function clickCloseDetails(nodeId) {
+  return (dispatch, getState) => {
+    dispatch({
+      nodeId,
+      type: ActionTypes.CLICK_CLOSE_DETAILS
+    });
+    // Pull the most recent details for the next details panel that comes into focus.
+    getNodeDetails(getState, dispatch);
+    updateRoute(getState);
+  };
+}
+
+export function clickNode(nodeId, label, origin, topologyId = null, shape) {
+  return (dispatch, getState) => {
+    dispatch({
+      label,
+      nodeId,
+      origin,
+      topologyId,
+      type: ActionTypes.CLICK_NODE,
+      shape,
+
+    });
+    updateRoute(getState);
+    getNodeDetails(getState, dispatch);
+  };
+}
+
+export function clickRelative(nodeId, topologyId, label, origin) {
+  return (dispatch, getState) => {
+    dispatch({
+      label,
+      nodeId,
+      origin,
+      topologyId,
+      type: ActionTypes.CLICK_RELATIVE
+    });
+    updateRoute(getState);
+    getNodeDetails(getState, dispatch);
+  };
+}
+
+function updateTopology(dispatch, getState) {
+  const state = getState();
+  // If we're in the resource view, get the snapshot of all the relevant node topologies.
+  if (isResourceViewModeSelector(state)) {
+    getResourceViewNodesSnapshot(state, dispatch);
+  }
+  updateRoute(getState);
+  // NOTE: This is currently not needed for our static resource
+  // view, but we'll need it here later and it's simpler to just
+  // keep it than to redo the nodes delta updating logic.
+  getNodes(getState, dispatch);
+}
+
+export function clickShowTopologyForNode(topologyId, nodeId) {
+  return (dispatch, getState) => {
+    dispatch({
+      nodeId,
+      topologyId,
+      type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE
+    });
+    updateTopology(dispatch, getState);
+  };
+}
+
+export function clickTopology(topologyId) {
+  return (dispatch, getState) => {
+    dispatch({
+      topologyId,
+      type: ActionTypes.CLICK_TOPOLOGY
+    });
+    updateTopology(dispatch, getState);
+  };
+}

+ 485 - 0
app/scripts/charts/__tests__/nodes-layout-test.js

@@ -0,0 +1,485 @@
+import { fromJS, Map } from 'immutable';
+
+import { constructEdgeId as edge } from '../../utils/layouter-utils';
+
+const makeMap = Map;
+
+describe('NodesLayout', () => {
+  const NodesLayout = require('../nodes-layout');
+
+  function getNodeCoordinates(nodes) {
+    const coords = [];
+    nodes
+      .sortBy(node => node.get('id'))
+      .forEach((node) => {
+        coords.push(node.get('x'));
+        coords.push(node.get('y'));
+      });
+    return coords;
+  }
+
+  let options;
+  let nodes;
+  let coords;
+  let resultCoords;
+
+  const nodeSets = {
+    initial4: {
+      edges: fromJS({
+        [edge('n1', 'n3')]: {id: edge('n1', 'n3'), source: 'n1', target: 'n3'},
+        [edge('n1', 'n4')]: {id: edge('n1', 'n4'), source: 'n1', target: 'n4'},
+        [edge('n2', 'n4')]: {id: edge('n2', 'n4'), source: 'n2', target: 'n4'}
+      }),
+      nodes: fromJS({
+        n1: {id: 'n1'},
+        n2: {id: 'n2'},
+        n3: {id: 'n3'},
+        n4: {id: 'n4'}
+      })
+    },
+    layoutProps: {
+      edges: fromJS({}),
+      nodes: fromJS({
+        n1: {
+          id: 'n1', label: 'lold', labelMinor: 'lmold', rank: 'rold'
+        },
+      })
+    },
+    layoutProps2: {
+      edges: fromJS({}),
+      nodes: fromJS({
+        n1: {
+          id: 'n1', label: 'lnew', labelMinor: 'lmnew', rank: 'rnew', x: 111, y: 109
+        },
+      })
+    },
+    rank4: {
+      edges: fromJS({
+        [edge('n1', 'n3')]: {id: edge('n1', 'n3'), source: 'n1', target: 'n3'},
+        [edge('n1', 'n4')]: {id: edge('n1', 'n4'), source: 'n1', target: 'n4'},
+        [edge('n2', 'n4')]: {id: edge('n2', 'n4'), source: 'n2', target: 'n4'}
+      }),
+      nodes: fromJS({
+        n1: {id: 'n1', rank: 'A'},
+        n2: {id: 'n2', rank: 'A'},
+        n3: {id: 'n3', rank: 'B'},
+        n4: {id: 'n4', rank: 'B'}
+      })
+    },
+    rank6: {
+      edges: fromJS({
+        [edge('n1', 'n3')]: {id: edge('n1', 'n3'), source: 'n1', target: 'n3'},
+        [edge('n1', 'n4')]: {id: edge('n1', 'n4'), source: 'n1', target: 'n4'},
+        [edge('n1', 'n5')]: {id: edge('n1', 'n5'), source: 'n1', target: 'n5'},
+        [edge('n2', 'n4')]: {id: edge('n2', 'n4'), source: 'n2', target: 'n4'},
+        [edge('n2', 'n6')]: {id: edge('n2', 'n6'), source: 'n2', target: 'n6'},
+      }),
+      nodes: fromJS({
+        n1: {id: 'n1', rank: 'A'},
+        n2: {id: 'n2', rank: 'A'},
+        n3: {id: 'n3', rank: 'B'},
+        n4: {id: 'n4', rank: 'B'},
+        n5: {id: 'n5', rank: 'A'},
+        n6: {id: 'n6', rank: 'B'},
+      })
+    },
+    removeEdge24: {
+      edges: fromJS({
+        [edge('n1', 'n3')]: {id: edge('n1', 'n3'), source: 'n1', target: 'n3'},
+        [edge('n1', 'n4')]: {id: edge('n1', 'n4'), source: 'n1', target: 'n4'}
+      }),
+      nodes: fromJS({
+        n1: {id: 'n1'},
+        n2: {id: 'n2'},
+        n3: {id: 'n3'},
+        n4: {id: 'n4'}
+      })
+    },
+    removeNode2: {
+      edges: fromJS({
+        [edge('n1', 'n3')]: {id: edge('n1', 'n3'), source: 'n1', target: 'n3'},
+        [edge('n1', 'n4')]: {id: edge('n1', 'n4'), source: 'n1', target: 'n4'}
+      }),
+      nodes: fromJS({
+        n1: {id: 'n1'},
+        n3: {id: 'n3'},
+        n4: {id: 'n4'}
+      })
+    },
+    removeNode23: {
+      edges: fromJS({
+        [edge('n1', 'n4')]: {id: edge('n1', 'n4'), source: 'n1', target: 'n4'}
+      }),
+      nodes: fromJS({
+        n1: {id: 'n1'},
+        n4: {id: 'n4'}
+      })
+    },
+    single3: {
+      edges: fromJS({}),
+      nodes: fromJS({
+        n1: {id: 'n1'},
+        n2: {id: 'n2'},
+        n3: {id: 'n3'}
+      })
+    },
+    singlePortrait: {
+      edges: fromJS({
+        [edge('n1', 'n4')]: {id: edge('n1', 'n4'), source: 'n1', target: 'n4'}
+      }),
+      nodes: fromJS({
+        n1: {id: 'n1'},
+        n2: {id: 'n2'},
+        n3: {id: 'n3'},
+        n4: {id: 'n4'},
+        n5: {id: 'n5'}
+      })
+    },
+    singlePortrait6: {
+      edges: fromJS({
+        [edge('n1', 'n4')]: {id: edge('n1', 'n4'), source: 'n1', target: 'n4'}
+      }),
+      nodes: fromJS({
+        n1: {id: 'n1'},
+        n2: {id: 'n2'},
+        n3: {id: 'n3'},
+        n4: {id: 'n4'},
+        n5: {id: 'n5'},
+        n6: {id: 'n6'}
+      })
+    }
+  };
+
+  beforeEach(() => {
+    // clear feature flags
+    window.localStorage.clear();
+
+    options = {
+      edgeCache: makeMap(),
+      nodeCache: makeMap()
+    };
+  });
+
+  it('detects unseen nodes', () => {
+    const set1 = fromJS({
+      n1: {id: 'n1'}
+    });
+    const set12 = fromJS({
+      n1: {id: 'n1'},
+      n2: {id: 'n2'}
+    });
+    const set13 = fromJS({
+      n1: {id: 'n1'},
+      n3: {id: 'n3'}
+    });
+    let hasUnseen;
+    hasUnseen = NodesLayout.hasUnseenNodes(set12, set1);
+    expect(hasUnseen).toBeTruthy();
+    hasUnseen = NodesLayout.hasUnseenNodes(set13, set1);
+    expect(hasUnseen).toBeTruthy();
+    hasUnseen = NodesLayout.hasUnseenNodes(set1, set12);
+    expect(hasUnseen).toBeFalsy();
+    hasUnseen = NodesLayout.hasUnseenNodes(set1, set13);
+    expect(hasUnseen).toBeFalsy();
+    hasUnseen = NodesLayout.hasUnseenNodes(set12, set13);
+    expect(hasUnseen).toBeTruthy();
+  });
+
+  it('lays out initial nodeset in a rectangle', () => {
+    const result = NodesLayout.doLayout(
+      nodeSets.initial4.nodes,
+      nodeSets.initial4.edges
+    );
+    nodes = result.nodes.toJS();
+
+    expect(nodes.n1.x).toBeLessThan(nodes.n2.x);
+    expect(nodes.n1.y).toEqual(nodes.n2.y);
+    expect(nodes.n1.x).toEqual(nodes.n3.x);
+    expect(nodes.n1.y).toBeLessThan(nodes.n3.y);
+    expect(nodes.n3.x).toBeLessThan(nodes.n4.x);
+    expect(nodes.n3.y).toEqual(nodes.n4.y);
+  });
+
+  it('keeps nodes in rectangle after removing one edge', () => {
+    let result = NodesLayout.doLayout(
+      nodeSets.initial4.nodes,
+      nodeSets.initial4.edges
+    );
+
+    options.cachedLayout = result;
+    options.nodeCache = options.nodeCache.merge(result.nodes);
+    options.edgeCache = options.edgeCache.merge(result.edge);
+    coords = getNodeCoordinates(result.nodes);
+
+    result = NodesLayout.doLayout(
+      nodeSets.removeEdge24.nodes,
+      nodeSets.removeEdge24.edges,
+      options
+    );
+    nodes = result.nodes.toJS();
+
+    resultCoords = getNodeCoordinates(result.nodes);
+    expect(resultCoords).toEqual(coords);
+  });
+
+  it('keeps nodes in rectangle after removed edge reappears', () => {
+    let result = NodesLayout.doLayout(
+      nodeSets.initial4.nodes,
+      nodeSets.initial4.edges
+    );
+
+    coords = getNodeCoordinates(result.nodes);
+    options.cachedLayout = result;
+    options.nodeCache = options.nodeCache.merge(result.nodes);
+    options.edgeCache = options.edgeCache.merge(result.edge);
+    result = NodesLayout.doLayout(
+      nodeSets.removeEdge24.nodes,
+      nodeSets.removeEdge24.edges,
+      options
+    );
+
+    options.cachedLayout = result;
+    options.nodeCache = options.nodeCache.merge(result.nodes);
+    options.edgeCache = options.edgeCache.merge(result.edge);
+    result = NodesLayout.doLayout(
+      nodeSets.initial4.nodes,
+      nodeSets.initial4.edges,
+      options
+    );
+
+    nodes = result.nodes.toJS();
+
+    resultCoords = getNodeCoordinates(result.nodes);
+    expect(resultCoords).toEqual(coords);
+  });
+
+  it('keeps nodes in rectangle after node disappears', () => {
+    let result = NodesLayout.doLayout(
+      nodeSets.initial4.nodes,
+      nodeSets.initial4.edges
+    );
+
+    options.cachedLayout = result;
+    options.nodeCache = options.nodeCache.merge(result.nodes);
+    options.edgeCache = options.edgeCache.merge(result.edge);
+    result = NodesLayout.doLayout(
+      nodeSets.removeNode2.nodes,
+      nodeSets.removeNode2.edges,
+      options
+    );
+
+    nodes = result.nodes.toJS();
+
+    resultCoords = getNodeCoordinates(result.nodes);
+    expect(resultCoords.slice(0, 2)).toEqual(coords.slice(0, 2));
+    expect(resultCoords.slice(2, 6)).toEqual(coords.slice(4, 8));
+  });
+
+  it('keeps nodes in rectangle after removed node reappears', () => {
+    let result = NodesLayout.doLayout(
+      nodeSets.initial4.nodes,
+      nodeSets.initial4.edges
+    );
+
+    nodes = result.nodes.toJS();
+
+    coords = getNodeCoordinates(result.nodes);
+    options.cachedLayout = result;
+    options.nodeCache = options.nodeCache.merge(result.nodes);
+    options.edgeCache = options.edgeCache.merge(result.edge);
+
+    result = NodesLayout.doLayout(
+      nodeSets.removeNode23.nodes,
+      nodeSets.removeNode23.edges,
+      options
+    );
+
+    nodes = result.nodes.toJS();
+
+    expect(nodes.n1.x).toBeLessThan(nodes.n4.x);
+    expect(nodes.n1.y).toBeLessThan(nodes.n4.y);
+
+    options.cachedLayout = result;
+    options.nodeCache = options.nodeCache.merge(result.nodes);
+    options.edgeCache = options.edgeCache.merge(result.edge);
+    result = NodesLayout.doLayout(
+      nodeSets.removeNode2.nodes,
+      nodeSets.removeNode2.edges,
+      options
+    );
+
+    nodes = result.nodes.toJS();
+
+    resultCoords = getNodeCoordinates(result.nodes);
+    expect(resultCoords.slice(0, 2)).toEqual(coords.slice(0, 2));
+    expect(resultCoords.slice(2, 6)).toEqual(coords.slice(4, 8));
+  });
+
+  it('renders single nodes in a square', () => {
+    const result = NodesLayout.doLayout(
+      nodeSets.single3.nodes,
+      nodeSets.single3.edges
+    );
+
+    nodes = result.nodes.toJS();
+
+    expect(nodes.n1.x).toEqual(nodes.n3.x);
+    expect(nodes.n1.y).toEqual(nodes.n2.y);
+    expect(nodes.n1.x).toBeLessThan(nodes.n2.x);
+    expect(nodes.n1.y).toBeLessThan(nodes.n3.y);
+  });
+
+  it('renders single nodes next to portrait graph', () => {
+    const result = NodesLayout.doLayout(
+      nodeSets.singlePortrait.nodes,
+      nodeSets.singlePortrait.edges,
+      { noCache: true }
+    );
+
+    nodes = result.nodes.toJS();
+
+    // first square row on same level as top-most other node
+    expect(nodes.n1.y).toEqual(nodes.n2.y);
+    expect(nodes.n1.y).toEqual(nodes.n3.y);
+    expect(nodes.n4.y).toEqual(nodes.n5.y);
+
+    // all singles right to other nodes
+    expect(nodes.n1.x).toEqual(nodes.n4.x);
+    expect(nodes.n1.x).toBeLessThan(nodes.n2.x);
+    expect(nodes.n1.x).toBeLessThan(nodes.n3.x);
+    expect(nodes.n1.x).toBeLessThan(nodes.n5.x);
+    expect(nodes.n2.x).toEqual(nodes.n5.x);
+  });
+
+  it('renders an additional single node in single nodes group', () => {
+    let result = NodesLayout.doLayout(
+      nodeSets.singlePortrait.nodes,
+      nodeSets.singlePortrait.edges,
+      { noCache: true }
+    );
+
+    nodes = result.nodes.toJS();
+
+    // first square row on same level as top-most other node
+    expect(nodes.n1.y).toEqual(nodes.n2.y);
+    expect(nodes.n1.y).toEqual(nodes.n3.y);
+    expect(nodes.n4.y).toEqual(nodes.n5.y);
+
+    // all singles right to other nodes
+    expect(nodes.n1.x).toEqual(nodes.n4.x);
+    expect(nodes.n1.x).toBeLessThan(nodes.n2.x);
+    expect(nodes.n1.x).toBeLessThan(nodes.n3.x);
+    expect(nodes.n1.x).toBeLessThan(nodes.n5.x);
+    expect(nodes.n2.x).toEqual(nodes.n5.x);
+
+    options.cachedLayout = result;
+    options.nodeCache = options.nodeCache.merge(result.nodes);
+    options.edgeCache = options.edgeCache.merge(result.edge);
+
+    result = NodesLayout.doLayout(
+      nodeSets.singlePortrait6.nodes,
+      nodeSets.singlePortrait6.edges,
+      options
+    );
+
+    nodes = result.nodes.toJS();
+
+    expect(nodes.n1.x).toBeLessThan(nodes.n2.x);
+    expect(nodes.n1.x).toBeLessThan(nodes.n3.x);
+    expect(nodes.n1.x).toBeLessThan(nodes.n5.x);
+    expect(nodes.n1.x).toBeLessThan(nodes.n6.x);
+  });
+
+  it('adds a new node to existing layout in a line', () => {
+    // feature flag
+    window.localStorage.setItem('scope-experimental:layout-dance', true);
+
+    let result = NodesLayout.doLayout(
+      nodeSets.rank4.nodes,
+      nodeSets.rank4.edges,
+      { noCache: true }
+    );
+
+    nodes = result.nodes.toJS();
+
+    coords = getNodeCoordinates(result.nodes);
+    options.cachedLayout = result;
+    options.nodeCache = options.nodeCache.merge(result.nodes);
+    options.edgeCache = options.edgeCache.merge(result.edge);
+
+    expect(NodesLayout.hasNewNodesOfExistingRank(
+      nodeSets.rank6.nodes,
+      nodeSets.rank6.edges,
+      result.nodes
+    )).toBeTruthy();
+
+    result = NodesLayout.doLayout(
+      nodeSets.rank6.nodes,
+      nodeSets.rank6.edges,
+      options
+    );
+
+    nodes = result.nodes.toJS();
+
+    expect(nodes.n5.x).toBeGreaterThan(nodes.n1.x);
+    expect(nodes.n5.y).toEqual(nodes.n1.y);
+    expect(nodes.n6.x).toBeGreaterThan(nodes.n3.x);
+    expect(nodes.n6.y).toEqual(nodes.n3.y);
+  });
+
+  it('rerenders the nodes completely after the coordinates have been messed up', () => {
+    // Take an initial setting
+    let result = NodesLayout.doLayout(
+      nodeSets.rank4.nodes,
+      nodeSets.rank4.edges,
+    );
+
+    // Cache the result layout
+    options.cachedLayout = result;
+    options.nodeCache = options.nodeCache.merge(result.nodes);
+    options.edgeCache = options.edgeCache.merge(result.edge);
+
+    // Shrink the coordinates of all the notes 2x to make them closer to one another
+    options.nodeCache = options.nodeCache.update(cache => cache.map(node => node.merge({
+      x: node.get('x') / 2,
+      y: node.get('y') / 2,
+    })));
+
+    // Rerun the initial layout to get a trivial diff and skip all the advanced layouting logic.
+    result = NodesLayout.doLayout(
+      nodeSets.rank4.nodes,
+      nodeSets.rank4.edges,
+      options
+    );
+
+    // The layout should have updated by running into our last 'integration testing' criterion
+    coords = getNodeCoordinates(options.nodeCache);
+    resultCoords = getNodeCoordinates(result.nodes);
+    expect(resultCoords).not.toEqual(coords);
+  });
+
+  it('only caches layout-related properties', () => {
+    // populate cache by doing a full layout run, this stores layout values in cache
+    const first = NodesLayout.doLayout(
+      nodeSets.layoutProps.nodes,
+      nodeSets.layoutProps.edges,
+      { noCache: true }
+    );
+
+    // now pass updated nodes with modified values
+    const second = NodesLayout.doLayout(
+      nodeSets.layoutProps2.nodes,
+      nodeSets.layoutProps2.edges,
+      {}
+    );
+
+    // new labels should not be overwritten by cache
+    nodes = second.nodes.toJS();
+    expect(nodes.n1.label).toEqual('lnew');
+    expect(nodes.n1.labelMinor).toEqual('lmnew');
+    // but layout values should be preferred from cache
+    expect(nodes.n1.rank).toEqual('rold');
+    expect(nodes.n1.x).toEqual(first.nodes.getIn(['n1', 'x']));
+    expect(nodes.n1.y).toEqual(first.nodes.getIn(['n1', 'y']));
+  });
+});

+ 115 - 0
app/scripts/charts/edge-container.js

@@ -0,0 +1,115 @@
+import React from 'react';
+import { Motion } from 'react-motion';
+import { Repeat, fromJS, Map as makeMap } from 'immutable';
+import { line, curveBasis } from 'd3-shape';
+import { times } from 'lodash';
+
+import { weakSpring } from 'weaveworks-ui-components/lib/utils/animation';
+
+import { NODE_BASE_SIZE, EDGE_WAYPOINTS_CAP } from '../constants/styles';
+import Edge from './edge';
+
+
+const spline = line()
+  .curve(curveBasis)
+  .x(d => d.x)
+  .y(d => d.y);
+
+const transformedEdge = (props, path, thickness) => (
+  <Edge {...props} path={spline(path)} thickness={thickness} />
+);
+
+// Converts a waypoints map of the format { x0: 11, y0: 22, x1: 33, y1: 44 }
+// that is used by Motion to an array of waypoints in the format
+// [{ x: 11, y: 22 }, { x: 33, y: 44 }] that can be used by D3.
+const waypointsMapToArray = (waypointsMap) => {
+  const waypointsArray = times(EDGE_WAYPOINTS_CAP, () => ({}));
+  waypointsMap.forEach((value, key) => {
+    const [axis, index] = [key[0], key.slice(1)];
+    waypointsArray[index][axis] = value;
+  });
+  return waypointsArray;
+};
+
+// Converts a waypoints array of the input format [{ x: 11, y: 22 }, { x: 33, y: 44 }]
+// to an array of waypoints that is used by Motion in the format { x0: 11, y0: 22, x1: 33, y1: 44 }.
+const waypointsArrayToMap = (waypointsArray) => {
+  let waypointsMap = makeMap();
+  waypointsArray.forEach((point, index) => {
+    waypointsMap = waypointsMap.set(`x${index}`, weakSpring(point.get('x')));
+    waypointsMap = waypointsMap.set(`y${index}`, weakSpring(point.get('y')));
+  });
+  return waypointsMap;
+};
+
+
+export default class EdgeContainer extends React.PureComponent {
+  constructor(props, context) {
+    super(props, context);
+    this.state = {
+      thickness: 1,
+      waypointsMap: makeMap(),
+    };
+  }
+
+  componentWillMount() {
+    this.prepareWaypointsForMotion(this.props);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    // immutablejs allows us to `===`! \o/
+    const waypointsChanged = this.props.waypoints !== nextProps.waypoints;
+    const animationChanged = this.props.isAnimated !== nextProps.isAnimated;
+    if (waypointsChanged || animationChanged) {
+      this.prepareWaypointsForMotion(nextProps);
+    }
+    // Edge thickness will reflect the zoom scale.
+    const baseScale = (nextProps.scale * 0.01) * NODE_BASE_SIZE;
+    const thickness = (nextProps.focused ? 3 : 1) * baseScale;
+    this.setState({ thickness });
+  }
+
+  render() {
+
+    const {
+      isAnimated, waypoints, scale, ...forwardedProps
+    } = this.props;
+    const { thickness, waypointsMap } = this.state;
+
+    if (!isAnimated) {
+      return transformedEdge(forwardedProps, waypoints.toJS(), thickness);
+    }
+
+    return (
+      // For the Motion interpolation to work, the waypoints need to be in a map format like
+      // { x0: 11, y0: 22, x1: 33, y1: 44 } that we convert to the array format when rendering.
+      <Motion style={{
+        interpolatedThickness: weakSpring(thickness),
+        ...waypointsMap.toJS(),
+      }}>
+        {
+          ({ interpolatedThickness, ...interpolatedWaypoints }) => transformedEdge(
+            forwardedProps,
+            waypointsMapToArray(fromJS(interpolatedWaypoints)),
+            interpolatedThickness
+          )
+        }
+      </Motion>
+    );
+  }
+
+  prepareWaypointsForMotion({ waypoints, isAnimated }) {
+    // Don't update if the edges are not animated.
+    if (!isAnimated) return;
+
+    // The Motion library requires the number of waypoints to be constant, so we fill in for
+    // the missing ones by reusing the edge source point, which doesn't affect the edge shape
+    // because of how the curveBasis interpolation is done.
+    const waypointsMissing = EDGE_WAYPOINTS_CAP - waypoints.size;
+    if (waypointsMissing > 0) {
+      waypoints = Repeat(waypoints.get(0), waypointsMissing).concat(waypoints);
+    }
+
+    this.setState({ waypointsMap: waypointsArrayToMap(waypoints) });
+  }
+}

+ 997 - 0
app/scripts/charts/edge.js

@@ -0,0 +1,997 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+
+import { enterEdge, leaveEdge } from '../actions/app-actions';
+import { encodeIdAttribute, decodeIdAttribute } from '../utils/dom-utils';
+
+import {
+  DETAILS_PANEL_WIDTH as WIDTH,
+  DETAILS_PANEL_OFFSET as OFFSET,
+  DETAILS_PANEL_MARGINS as MARGINS
+} from '../constants/styles';
+
+import { setNodeColor } from '../utils/color-utils';
+import '../../styles/index.less'
+import '../../styles/iconfont.css'
+// import { GlobalIcon } from '../../styles/icon.js';
+
+// import echarts from 'echarts'
+import * as echarts from 'echarts'
+import axios from 'axios'
+import moment from 'moment'
+
+import { Table,Icon,Radio,Drawer, Button,Switch,Checkbox } from "antd";
+import "antd/dist/antd.css";
+import getToken from '../utils/get-token'
+
+function isStorageComponent(id) {
+  const storageComponents = ['<persistent_volume>', '<storage_class>', '<persistent_volume_claim>', '<volume_snapshot>', '<volume_snapshot_data>'];
+  return storageComponents.includes(id);
+}
+
+// getAdjacencyClass takes id which contains information about edge as a topology
+// of parent and child node.
+// For example: id is of form "nodeA;<storage_class>---nodeB;<persistent_volume_claim>"
+function getAdjacencyClass(id) {
+  const topologyId = id.split('---');
+  const fromNode = topologyId[0].split(';');
+  const toNode = topologyId[1].split(';');
+  if (fromNode[1] !== undefined && toNode[1] !== undefined) {
+    if (isStorageComponent(fromNode[1]) || isStorageComponent(toNode[1])) {
+      return 'link-storage';
+    }
+  }
+  return 'link-none';
+}
+
+
+
+class Edge extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.handleMouseEnter = this.handleMouseEnter.bind(this);
+    this.handleMouseLeave = this.handleMouseLeave.bind(this);
+    this.handleClick = this.handleClick.bind(this)
+    this.state = {
+        showElem:false,
+        x:0,
+        y:'30%',
+        lineTip:'',
+        visible: false,  //控制弹窗显示隐藏
+        AnalystData:{},  //延迟分位图数据存储
+        queryParams:{
+          start_time:Math.round((new Date().getTime())/1000 - (5*60)),
+          end_time:Math.round(new Date().getTime()/1000),
+          app_alias:'UNSET',
+          service_name:'frontend',
+          percentile:0.95
+        },
+        traceData:[],
+        // traceUrl:'http://observe-front.cestong.com.cn', //本地调试使用
+        // baseUrl:'http://observe-server.cestong.com.cn', //本地调试使用
+        baseUrl:'/re',  //上线时打开
+        traceUrl:'',   //上线时打开
+        scoreObj:[],
+        bgColor:setNodeColor('R'),
+        pagination: {
+          pageIndex:1,
+          pageSize:10,
+          total:0,
+          current:1,
+        },
+        traceQuery:{
+          only_exception:0,
+          only_database:0,
+        },
+        loading: false,
+        value:0.95,
+        total:0,
+        getToken: ''
+    }
+  }
+
+  render() {
+    let transform;
+    const panelHeight = window.innerHeight - MARGINS.bottom - MARGINS.top;
+    const {
+      id, path, highlighted, focused, thickness, source, target
+    } = this.props;
+    const shouldRenderMarker = (focused || highlighted) && (source !== target);
+    const className = classNames('edge', { highlighted });
+    const spinnerClassName = classNames('fa fa-circle-notch', { 'fa-spin': this.props.mounted });
+    const wrapStyle = {
+      left: this.state.showElem ? MARGINS.right : null,
+      width: this.state.showElem ? null : WIDTH,
+      zIndex: 9999,
+    }
+    // const bgColor = setNodeColor('B')
+    const styles = {
+      header: {
+        backgroundColor: this.state.bgColor
+      },
+      headerColor:{
+        color:this.state.bgColor
+      }
+    }
+    let labelsContainer = {
+      // transform: 'scale(2)',
+      pointerEvents: 'none',
+      position:'absolute',
+      wordWrap: 'break-word', /* 当单词过长时进行断开 */
+      overflowWrap: 'break-word', /* 支持更多语言的断开 */
+      borderRadius: '8px',
+    }
+    let TipboxStyle={
+      borderRadius: '8px',
+      padding: '10px',
+      zIndex: 9999,
+      background:'#fff',
+      width:'100%',
+      border:'1px solid #f1f1f1',
+      // color:'#fff',
+      // boxShadow:'0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
+      boxShadow: '0px 0px 22px 2px rgba(0, 0, 0, 0.1)'
+    }
+    let box={
+      position: 'fixed',
+      display: 'flex',
+      right: '30px',
+      top: '100px',
+      bottom: '48px',
+      transition: 'transform 0.33333s cubic-bezier(0, 0, 0.21, 1) 0s, margin-top 0.15s ease-in-out 0s !important',
+      transform: 'translateX(0px)',
+      width: '420px',
+      height:'100%'
+    }
+    const columns = [
+      {
+        title: 'TraceID',
+        dataIndex: 'trace_id',
+        key: 'trace_id',
+        width:'27%',
+        ellipsis:true,
+        align:'left',
+        render: (text,record) => <a target='_blank' href={`${this.state.traceUrl}/#/latency/index?traceId=${text}&app_alias=${this.state.queryParams.app_alias}&span_id=${record.span_id}&datetime=${Date.parse(record.datetime)/1000}`}>{text}</a>
+        // scopedSlots:{customRender:'trace_id'},
+        // render:traceID => <a target='_blank' href={`${this.state.traceUrl}/#/latency/index?traceId=${traceID}&app_alias=${this.state.queryParams.app_alias}`}>{traceID}</a>
+        // render: traceID => <a target='_blank' href={`${grafanaRoot}/explore?orgId=1&left={"datasource":"sV_Dh0LVz","queries":[{"refId":"A","datasource":{"type":"tempo","uid":"sV_Dh0LVz"},"queryType":"nativeSearch","serviceName":"${this.props.selectedNodeId}"}],"range":{"from":"now-1h","to":"now"}}`}>{traceID}</a>,
+      },
+      {
+        title: 'Service',
+        dataIndex: 'service_name',
+        key: 'service_name',
+        width:'20%',
+        ellipsis:true,
+        align:'right'
+      },
+      {
+        title: 'Meth.',
+        dataIndex: 'method',
+        key: 'method',
+        width:'18%',
+        ellipsis:true,
+        align:'right'
+      },
+      {
+        title: 'Code',
+        dataIndex: 'code',
+        key: 'code',
+        width:'15%',
+        ellipsis:true,
+        align:'right'
+      },
+      {
+        title: 'Dur.(ms)',
+        dataIndex: 'duration',
+        key: 'duration',
+        width:'20%',
+        ellipsis:true,
+        align:'right',
+        render:(text) => <span>{text.toFixed(2)}</span>,
+      },
+    ]
+
+    return (
+        <g
+          id={encodeIdAttribute(id)}
+          className={className}
+          onMouseEnter={this.handleMouseEnter}
+          onMouseLeave={this.handleMouseLeave}
+          onClick={this.handleClick}
+          style={{position:'relative'}}
+        >
+          {/* {this.state.showElem?
+            <foreignObject
+                style={labelsContainer}
+                y={this.state.y}
+                x={(this.state.x)-10}
+                width={300}
+                height={100}
+                >
+                <div className='Tipbox' style={TipboxStyle} dangerouslySetInnerHTML={{ __html:this.state.lineTip }}>
+                </div>
+            </foreignObject>:null
+          } */}
+
+          <path className="shadow" d={path} style={{ strokeWidth: 10 * thickness }} />
+          <path
+            className={getAdjacencyClass(id)}
+            d={path}
+            style={{ strokeWidth: 5 }}
+          />
+          <path
+            className="link"
+            d={path}
+            markerEnd={shouldRenderMarker ? 'url(#end-arrow)' : null}
+            style={{ strokeWidth: thickness }}
+          />
+          {this.state.showElem?
+            <div className='drawerBox'>
+              <Drawer
+              placement="right"
+              onClose={this.onClose}
+              visible={this.state.showElem}
+              destroyOnClose={true}
+              width="420px"
+              >
+                <div className="tour-step-anchor node-details">
+                  {/* {tools} */}
+                  <div className="node-details-header" style={styles.header}>
+                    <div className='node-details-header-flex'>
+                      <div className="node-details-header-wrapper">
+                        <div className='node-details-header-icon'>
+                            {/* <div className='fa fa-level-down'></div> */}
+                            <div className='roll_box'><Icon className='roll' type='swap-right'></Icon></div>
+
+                            {/* <div className='roll_box'>
+                              <span className='iconfont icon-chehui2'></span>
+                            </div> */}
+                        </div>
+                        <div style={{width:'85%'}}>
+                          <h2 className="node-details-header-label truncate" title='标题部分'>
+                              {source}
+                          </h2>
+                          <div className="node-details-header-relatives truncate ">
+                          </div>
+                          <h2 className="node-details-header-label truncate" title='标题部分'>
+                              {target}
+                          </h2>
+                          <div className="node-details-header-relatives truncate">
+                          </div>
+                        </div>
+                      </div>
+                      <div className='node-details-header-apdex' style={styles.headerColor}>
+                        Apdex:
+                          {
+                            this.state.scoreObj.length>0?
+                            this.state.scoreObj.map((item,i)=>{
+                              return <div key={i} className='node-details-header-score'>{Math.floor(item.apdex*100)}</div>
+                            }):<div className='node-details-header-score'>0</div>
+                          }
+                      </div>
+                    </div>
+                  </div>
+
+                  <div className="node-details-content" style={{marginTop:'123px'}}>
+                    {/* 状态 */}
+                    <div className="node-details-content-section">
+                      <div className="node-details-content-section-header">状态</div>
+                      <div className='node-details-info'>
+                        {
+                          this.state.scoreObj.length>0?
+                          this.state.scoreObj.map((v,i)=>{
+                            return (
+                              <div key={i} className='node-details-info-field-flex'>
+                                 <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>Total:</div>
+                                    <div className='node-details-info-field-value truncate w50'>
+                                      {v.total_num?v.total_num:0}
+                                    </div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>Avg:</div>
+                                    <div className='node-details-info-field-value truncate w50'>{v.duration_average?v.duration_average:0}</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>Qps:</div>
+                                    <div className='node-details-info-field-value truncate w50'>{v.qps?v.qps:0}r/s</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>P.50:</div>
+                                    <div className='node-details-info-field-value truncate w50'>{v.duration_median?v.duration_median:0}</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>ErrRate:</div>
+                                    <div className='node-details-info-field-value truncate w50'>{v.error_rate?v.error_rate.toFixed(2):0}%</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>P.90:</div>
+                                    <div className='node-details-info-field-value truncate w50'>{v.duration_p90?v.duration_p90:0}</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>ErrNum:</div>
+                                    <div className='node-details-info-field-value truncate w50'>{v.error_num?v.error_num:0}</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>P.99:</div>
+                                    <div className='node-details-info-field-value truncate w50'>{v.duration_p99?v.duration_p99:0}</div>
+                                  </div>
+                              </div>
+                            )
+                          }):(
+                            <div className='node-details-info-field-flex'>
+                                 <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>Total:</div>
+                                    <div className='node-details-info-field-value truncate w50'>0</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>Avg:</div>
+                                    <div className='node-details-info-field-value truncate w50'>0</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>Qps:</div>
+                                    <div className='node-details-info-field-value truncate w50'>0r/s</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>P.50:</div>
+                                    <div className='node-details-info-field-value truncate w50'>0</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>ErrRate:</div>
+                                    <div className='node-details-info-field-value truncate w50'>0%</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>P.90:</div>
+                                    <div className='node-details-info-field-value truncate w50'>0</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>ErrNum:</div>
+                                    <div className='node-details-info-field-value truncate w50'>0</div>
+                                  </div>
+                                  <div className='node-details-info-field'>
+                                    <div className='node-details-info-field-label truncate w50'>P.99:</div>
+                                    <div className='node-details-info-field-value truncate w50'>0</div>
+                                  </div>
+                            </div>
+                          )
+                        }
+
+                      </div>
+                    </div>
+
+
+                    <div className="node-details-content-section">
+                      <div className="node-details-content-section-header">延迟分位</div>
+                      <div className='node-details-info' style={{marginTop: "-15px",position:'relative'}}>
+                        {
+                          JSON.stringify(this.state.AnalystData)!="{}"?
+                          (<div>
+                            <div id='main' className="echartsbox" style={{height:'240px'}}></div>
+                          </div>)
+                          :(
+                            <div className='noData'>暂无数据</div>
+                          )
+                        }
+                        <div className='LatencySelect'>
+                          <Radio.Group onChange={this.onChange} value={this.state.queryParams.percentile} size="small">
+                            <Radio value={0.5}>p.50</Radio>
+                            <Radio value={0.95}>p.95</Radio>
+                            <Radio value={0.99}>p.99</Radio>
+                          </Radio.Group>
+                        </div>
+                      </div>
+                    </div>
+
+                    <div className='node-details-content-section'>
+                      <div className="node-details-content-section-header">异常Trace</div>
+                      <div className='node-serch'>
+                          <Checkbox onChange={this.onChangeError}>仅异常</Checkbox>
+                          <Checkbox onChange={this.onChangeSql}>仅SQL</Checkbox>
+                      </div>
+                      <Table dataSource={this.state.traceData} columns={columns} rowKey='span_id'  pagination={this.state.pagination}
+                                                   onChange={this.handleTableChange} size='small'>
+                          {/* <span slot='trace_id' slot-scope='text,record'>
+                            <template>
+                              <div>
+                                <a target='_blank' href={`${this.state.traceUrl}/#/latency/index?traceId=${record.trace_id}&app_alias=${this.state.queryParams.app_alias}&span_id=${record.span_id}`}>{record.trace_id}</a>
+                              </div>
+                            </template>
+                          </span> */}
+                      </Table>
+
+                    </div>
+
+
+                  </div>
+
+                </div>
+
+              </Drawer>
+            </div> :null
+          }
+        </g>
+
+
+
+
+    );
+  }
+
+
+  onClose = (e) => {
+    e.stopPropagation();
+    e.preventDefault();
+    this.setState({
+      showElem: false,
+    },()=>{
+      // console.log(this.state.showElem,'关闭按钮的值')
+    });
+  }
+
+  componentDidMount() {
+    let _this = this
+    //上线时打开开始
+    this.setQueryParams();
+    const traceURL = `http://${parent.location.hostname}`
+    this.setState({
+      traceUrl:traceURL
+    },()=>{
+    })
+    //上线时打开结束
+
+  }
+  setQueryParams(){
+    var strr = parent.location.href;   //上线做为iframe嵌套时使用
+    let param = this.parseQueryString(strr);  //全链路需要的参数多,因此解析成对象形式
+    const queryParams = this.state.queryParams;
+    if(parseInt(param.start_time)!=0 && parseInt(param.end_time) !=0){ //上线时打开
+      let newStartTime = parseInt(param.start_time); // 设置新的属性值
+      let newEndTime = parseInt(param.end_time); // 设置新的属性值
+      queryParams.start_time = newStartTime
+      queryParams.end_time = newEndTime
+    }
+    const newAppAlias = param.app_alias
+    queryParams.app_alias = newAppAlias
+    this.setState({
+      queryParams: queryParams
+    },()=>{
+    });
+
+  }
+  //解析URL
+  parseQueryString(url){
+    var json = {};
+    var arr = url.substr(url.indexOf('?') + 1).split('&');
+    arr.forEach(item=>{
+        var tmp = item.split('=');
+                  json[tmp[0]] = tmp[1];
+    });
+    return json;
+  }
+
+  // 散点图渲染
+  initChart(tmpData) {
+    // let chart = id+'Chart';
+    var chart;
+    chart = echarts.init(document.getElementById('main'))
+    chart.clear();
+    let option = {
+      title: {
+        text: '',
+        subtext: '',
+        textStyle:{
+          fontSize:14
+        },
+      },
+      grid: {
+        top:'8%',
+        left: '3%',
+        right: '2%',
+        bottom: '14%',
+        containLabel: true
+      },
+      tooltip: {
+        // trigger: 'axis',
+        showDelay: 0,
+        formatter: function (params) {
+          let newParams = moment(params.data[0]).format('YYYY-MM-DD HH:mm:ss');
+          let time = newParams+"<br/>"+ params.data[1]+"ms"
+          return time;
+        },
+          axisPointer: {
+          show: true,
+          type: 'cross',
+          lineStyle: {
+            type: 'dashed',
+            width: 1
+          }
+        }
+      },
+      toolbox: {
+        show:true,
+        showTitle: true,
+        feature: {
+            rect: {
+                show: true,
+                title: 'Trace选择'
+            },
+        },
+        left:"40%",                              //组件离容器左侧的距离,'left', 'center', 'right','20%'
+        top:"-3%",                                   //组件离容器上侧的距离,'top', 'middle', 'bottom','20%'
+        right:"auto",                               //组件离容器右侧的距离,'20%'
+        bottom:"auto",
+      },
+      brush: {
+          toolbox: ['rect'],
+          xAxisIndex: 0,
+          throttleType:'debounce',
+          throttleDelay:600,
+          // 'brush': () => {
+          //   //手动触发缩放 解决数据显示不全问题
+          //   let echartsInstance = document.getElementById('main').getEchartsInstance()
+          //     //首先获取当前缩放位置
+          //     let {start, end} = echartsInstance.getOption().dataZoom[0]
+          //     echartsInstance.dispatchAction({
+          //         type: 'dataZoom',
+          //         // 可选,dataZoom 组件的 index,多个 dataZoom 组件时有用,默认为 0
+          //         dataZoomIndex: 0,
+          //         // 开始位置的百分比,0 - 100
+          //         start: start,
+          //         // 结束位置的百分比,0 - 100
+          //         end: end,
+          //         // // 开始位置的数值
+          //         // startValue: 0,
+          //         // // 结束位置的数值
+          //         // endValue: 100
+          //     })
+          // }
+      },
+      legend: {
+        data: ['Success', 'Failed'],
+        left: 'center',
+        bottom: 0,
+        itemGap: 100,
+        textStyle: {//文字颜色
+            fontSize: 12,
+            padding:[0,3],//文字与图形之间的左右间距
+            rich:{
+              labelName:{
+                fontSize:14,
+                color:'#333',
+                fontWeight:500
+              }
+            }
+        },
+        formatter: function (params) {
+          // 获取legend显示内容
+          let data = tmpData;
+          let sl,fl;
+          if(data.success!=null){
+            sl = tmpData.success.length;
+          }else{
+            sl = 0
+          }
+          if( data.failed!=null){
+            fl = tmpData.failed.length;
+          }else{
+            fl = 0
+          }
+          var target;
+          if(params == 'Success'){
+            target = sl;
+          }else if(params == 'Failed'){
+            target = fl;
+          }
+          return target != undefined?params +' '+`{labelName|${target}}`:params
+        },
+      },
+      xAxis: [
+        {
+          // type: 'category',
+          type:'time',
+          // scale: true,
+          gridIndex:0,
+          // splitNumber: 4,
+          axisLabel: {
+            show:false,
+            textStyle: {
+              fontSize: 12,
+              textAlign:'center'
+            },
+            formatter: function(params) {
+              let newParams = moment(params).format('YYYY-MM-DD HH:mm:ss');
+              let newArr = newParams.split(' ')
+              let time = newArr[0] + "\n" + newArr[1]
+              return time;
+            }
+          },
+          splitLine: {
+            show: true
+          },
+        }
+      ],
+      yAxis: [
+        {
+          type: 'value',
+          // scale: true,
+          gridIndex:0,
+          axisLabel: {
+            formatter: '{value}'
+          },
+          axisLine:{
+            show:true
+          },
+          axisTick:{
+            show:true
+          },
+          splitLine: {
+            show: true
+          },
+          data:[0,2500,5000,7500,10000],
+        }
+      ],
+      series: [
+        {
+          name: 'Success',
+          type: 'scatter',
+          emphasis: {
+            focus: 'series'
+          },
+          //设置散点图样式
+          itemStyle:{
+            color:'#13ce66'
+          },
+          symbolSize:10,//设置散点的大小
+          data:tmpData.success,
+          markArea: {
+            silent: true,
+            itemStyle: {
+              color: 'transparent',
+              borderWidth: 0,
+              borderType: 'dashed'
+            },
+          },
+        },
+        {
+          name: 'Failed',
+          type: 'scatter',
+          emphasis: {
+            focus: 'series'
+          },
+          itemStyle:{
+            color:'#ff4949'
+          },
+          // prettier-ignore
+          data:tmpData.failed,
+          // data:[],
+          markArea: {
+            silent: true,
+            itemStyle: {
+              color: 'transparent',
+              borderWidth: 0,
+              borderType: 'dashed'
+            },
+          },
+        }
+      ]
+    }
+    chart.setOption(option,true)
+    chart.off("brushSelected");
+    //框选选择数据
+    chart.on('brushSelected', (params) => {
+
+      var brushComponent = params.batch[0];
+
+      let successIndexList=[];
+      let failIndexList =[];
+      let successList=[];
+      let failList=[];
+        if(brushComponent.selected.length>1){
+          successIndexList = brushComponent.selected[0].dataIndex
+          failIndexList = brushComponent.selected[1].dataIndex
+        }else{
+          if(brushComponent.selected[0].seriesName !=undefined){
+            if(brushComponent.selected[0].seriesName =="Failed"){
+              failIndexList = brushComponent.selected[0].dataIndex
+            }else{
+              successIndexList = brushComponent.selected[0].dataIndex
+            }
+          }
+        }
+
+        if(successIndexList.length>0){
+            for(let i = 0;i<tmpData.success.length;i++){
+              for(let j=0;j<successIndexList.length;j++){
+                if(successIndexList[j] == i){
+                  successList.push(tmpData.success[i])
+                }
+              }
+            }
+        }
+
+        if(failIndexList.length>0){
+          for(let k=0;k<tmpData.failed.length;k++){
+            for(let l=0;l<failIndexList.length;l++){
+              if(failIndexList[l] == k){
+                failList.push(tmpData.failed[k])
+              }
+            }
+          }
+        }
+
+        let arr =successList.concat(failList);
+        let dataRange={};
+        if(arr.length>0){
+            let timeArr =[];
+            let valueArr=[];
+            for(let m=0;m<arr.length;m++){
+              timeArr.push(Math.round(Date.parse(arr[m][0])/1000));
+              valueArr.push(arr[m][1]);
+            }
+
+            let minTime = Math.min(...timeArr);
+            let maxTime = Math.max(...timeArr);
+
+            let minValue = Math.min(...valueArr);
+            let maxValue = Math.max(...valueArr)
+
+            if(minTime == maxTime){
+              minTime = minTime-1;
+              maxTime = maxTime+1;
+            }
+
+            if(minValue == maxValue){
+              minValue = minValue-1;
+              maxValue = maxValue+1;
+            }
+
+
+            //看是否有成功节点
+            if(successIndexList.length>0){
+                dataRange = {
+                start_time:minTime,
+                end_time:maxTime,
+                min_duration:minValue,
+                max_duration:maxValue,
+                failed:false,
+                app_alias:this.state.queryParams.app_alias,
+                service_name:this.props.id,
+              }
+            }else{
+              dataRange = {
+                start_time:minTime,
+                end_time:maxTime,
+                min_duration:minValue,
+                max_duration:maxValue,
+                failed:true,
+                app_alias:this.state.queryParams.app_alias,
+                service_name:this.props.id,
+              }
+            }
+
+
+            let timeAndDuration = JSON.stringify(dataRange);
+
+            // let href = this.$router.resolve({
+            //   path:'/latency/index',
+            //   query:{
+            //     data:timeAndDuration
+            //   }
+            // })
+            // window.open(window.location.origin+"/"+href.href,"_blank")
+
+
+            let href = `${this.state.traceUrl}/#/latency/index?start_time=${dataRange.start_time}&end_time=${dataRange.end_time}&min_duration=${dataRange.min_duration}&max_duration=${dataRange.max_duration}&failed=${dataRange.failed}&app_alias=${this.state.queryParams.app_alias}&service_name=${this.props.id}`
+              window.open(href,"_blank")
+            setTimeout(()=>{
+              chart.dispatchAction({
+                  type: 'brush',//选择action行为
+                  areas:[]//areas表示选框的集合,此时为空即可。
+              });
+            },500)
+
+        }
+
+
+    });
+
+
+    window.addEventListener("resize",function (){
+      chart.resize();
+    });
+
+  }
+
+  handleMouseEnter(ev) {
+    ev.stopPropagation();
+    ev.preventDefault();
+    this.props.enterEdge(decodeIdAttribute(ev.currentTarget.id));
+  }
+
+  handleMouseLeave(ev) {
+    ev.stopPropagation();
+    ev.preventDefault();
+    this.props.leaveEdge(decodeIdAttribute(ev.currentTarget.id));
+  }
+
+  //点击事件
+  handleClick(ev){
+    if(ev.target.tagName=='path'){
+
+
+      ev.stopPropagation();
+      ev.preventDefault();
+
+      // this.setQueryParams();
+      this.getEdgeAppsScore(this.props.source,this.props.target)
+      this.getEdgeAnalyst(this.props.source,this.props.target)
+      this.getServiceSpans(this.props.source,this.props.target)
+
+      this.setState({
+        showElem:true,
+      },()=>{
+      })
+
+    }
+
+  }
+  //获取边线弹窗内的信息
+  getEdgeAppsScore(sourceService,targetService){
+    axios({
+      url: `${this.state.baseUrl}/api/v1/apps_score/${this.state.queryParams.app_alias}/edge`,
+      method: "get",
+      headers: { 'Authorization': getToken },
+      params: {
+        source_service:sourceService,
+        target_service:targetService,
+        start_time:this.state.queryParams.start_time,
+        end_time:this.state.queryParams.end_time
+      }
+    }).then(res => {
+        if(res && res.data.code == 200){
+          const newArr= ((res || {}).data || {}).data || []
+          this.setState({
+            scoreObj:[...newArr]
+          },()=>{
+            if(this.state.scoreObj.length>0){
+              for(let i=0;i<this.state.scoreObj.length;i++){
+                this.state.scoreObj[i].apdex>=0.94?this.setState({bgColor:setNodeColor('G')})
+                :(this.state.scoreObj[i].apdex>=0.85&&this.state.scoreObj[i].apdex<0.94)?this.setState({bgColor:setNodeColor('B')})
+                :(this.state.scoreObj[i].apdex>=0.7&&this.state.scoreObj[i].apdex<0.85)?this.setState({bgColor:setNodeColor('DI')})
+                :(this.state.scoreObj[i].apdex>=0.5&&this.state.scoreObj[i].apdex<0.7)?this.setState({bgColor:setNodeColor('Y')})
+                :this.setState({bgColor:setNodeColor('R')})
+              }
+            }else{
+              this.setState({bgColor:setNodeColor('R')})
+            }
+          })
+
+        }
+    });
+  }
+  //获取边线散点图
+  getEdgeAnalyst(sourceService,targetService){
+    axios({
+      url: `${this.state.baseUrl}/api/v1/app/analyst/${this.state.queryParams.app_alias}/edge`,
+      method: "get",
+      headers: { 'Authorization': getToken },
+      params: {
+        source_service:sourceService,
+        target_service:targetService,
+        start_time:this.state.queryParams.start_time,
+        end_time:this.state.queryParams.end_time,
+        percentile:this.state.queryParams.percentile
+      }
+    }).then(res => {
+        if(res && res.data.code == 200){
+          const obj = ((res || {}).data || {}).data || {}
+          this.setState({
+            AnalystData:{...obj}
+          },()=>{
+            if(JSON.stringify(this.state.AnalystData)!="{}"){
+              this.initChart(this.state.AnalystData)
+            }
+          })
+        }
+    });
+  }
+
+  //获取异常trace列表 /api/v1/service/spans
+  getServiceSpans(sourceService,targetService){
+    this.setState({ loading: true });
+    this.setState({
+      traceData:[],
+      pagination:{total:0}
+    },()=>{
+    })
+    axios({
+      url: `${this.state.baseUrl}/api/v1/service/spans`,
+      method: "get",
+      headers: { 'Authorization': getToken },
+      params: {
+        // source_service:sourceService,
+        // target_service:targetService,
+        service_name:sourceService,
+        service_name:targetService,
+        only_exception:this.state.traceQuery.only_exception,  // 仅显示异常trace相关
+        only_database:this.state.traceQuery.only_database,
+        pageIndex:this.state.pagination.pageIndex,
+        pageSize:this.state.pagination.pageSize,
+        start_time:this.state.queryParams.start_time,
+        end_time:this.state.queryParams.end_time,
+        app_alias:this.state.queryParams.app_alias
+      }
+    }).then(res => {
+
+        if(res && res.data.code == 200){
+          const list = (((res || {}).data || {}).data || {}).list || []
+          const total = (((res || {}).data || {}).data || {}).count
+          this.setState({
+            loading:false,
+            traceData:[...list],
+            pagination:{...this.state.pagination,total:total}
+          },()=>{
+          })
+        }
+    });
+  }
+
+  handleTableChange=(pagination,pageSize)=>{
+    const {current} = pagination
+    this.setState({
+      pagination: {...this.state.pagination,pageIndex:current,current:current},
+    },()=>{
+      this.getServiceSpans(this.props.source,this.props.target);
+    });
+  }
+
+  //仅异常
+  onChangeError = e =>{
+    const only_exception = e.target.checked ? 1:0;
+    const pageIndex = 1
+    const current = 1
+    this.setState({
+      traceQuery:{...this.state.traceQuery,only_exception:only_exception},
+      pagination: {...this.state.pagination,pageIndex:pageIndex,current:current},
+    },()=>{
+      this.getServiceSpans(this.props.source,this.props.target);
+    });
+
+  }
+  //仅sql
+  onChangeSql = e =>{
+    const only_database = e.target.checked ? 1:0;
+    const pageIndex = 1
+    const current = 1
+    this.setState({
+      traceQuery:{...this.state.traceQuery,only_database:only_database},
+      pagination: {...this.state.pagination,pageIndex:pageIndex,current:current},
+    },()=>{
+      this.getServiceSpans(this.props.source,this.props.target);
+    });
+  }
+  //单选按钮
+  onChange = e => {
+    const percentile = e.target.value
+    this.setState({
+      queryParams:{...this.state.queryParams,percentile:percentile}
+    },()=>{
+      this.getEdgeAnalyst(this.props.source,this.props.target);
+    });
+  }
+
+
+}
+
+function mapStateToProps(state) {
+  return {
+    contrastMode: state.get('contrastMode')
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { enterEdge, leaveEdge }
+)(Edge);

+ 107 - 0
app/scripts/charts/node-container.js

@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { List as makeList } from 'immutable';
+import { GraphNode } from 'weaveworks-ui-components';
+
+import {
+  getMetricValue,
+  getMetricColor,
+} from '../utils/metric-utils';
+import { clickNode } from '../actions/request-actions';
+import { enterNode, leaveNode } from '../actions/app-actions';
+import { trackAnalyticsEvent } from '../utils/tracking-utils';
+import { getNodeColor,getStatusColor,setNodeColor } from '../utils/color-utils';
+import MatchedResults from '../components/matched-results';
+import { GRAPH_VIEW_MODE } from '../constants/naming';
+
+
+import NodeNetworksOverlay from './node-networks-overlay';
+
+class NodeContainer extends React.Component {
+  saveRef = (ref) => {
+    this.ref = ref;
+  };
+
+  handleMouseClick = (nodeId, ev) => {
+    ev.stopPropagation();
+    trackAnalyticsEvent('scope.node.click', {
+      layout: GRAPH_VIEW_MODE,
+      parentTopologyId: this.props.currentTopology.get('parentId'),
+      topologyId: this.props.currentTopology.get('id'),
+      shape: this.props.shape
+    });
+    this.props.clickNode(nodeId, this.props.label, this.props.shape, this.ref.getBoundingClientRect());
+  };
+
+  renderPrependedInfo = () => {
+    const { showingNetworks, networks } = this.props;
+    if (!showingNetworks) return null;
+
+    return (
+      <NodeNetworksOverlay networks={networks} />
+    );
+  };
+
+  renderAppendedInfo = () => {
+    const matchedMetadata = this.props.matches.get('metadata', makeList());
+    const matchedParents = this.props.matches.get('parents', makeList());
+    const matchedDetails = matchedMetadata.concat(matchedParents);
+    return (
+      <MatchedResults matches={matchedDetails} searchTerms={this.props.searchTerms} />
+    );
+  };
+
+  render() {
+    const {
+      rank, label, pseudo, metric, showingNetworks, networks,color
+    } = this.props;
+    const { hasMetric, height, formattedValue } = getMetricValue(metric);
+    const metricFormattedValue = !pseudo && hasMetric ? formattedValue : '';
+    const labelOffset = (showingNetworks && networks) ? 10 : 0;
+    return (
+      <GraphNode
+        id={this.props.id}
+        shape={this.props.shape}
+        tag={this.props.tag}
+        label={this.props.label}
+        labelMinor={this.props.labelMinor}
+        labelOffset={labelOffset}
+        stacked={this.props.stacked}
+        highlighted={this.props.highlighted}
+        // color={getNodeColor(rank, label, pseudo)}
+        color={setNodeColor(color)}
+        size={this.props.size}
+        isAnimated={this.props.isAnimated}
+        contrastMode={this.props.contrastMode}
+        forceSvg={this.props.exportingGraph}
+        searchTerms={this.props.searchTerms}
+        metricColor={getMetricColor(metric)}
+        metricFormattedValue={metricFormattedValue}
+        metricNumericValue={height}
+        renderPrependedInfo={this.renderPrependedInfo}
+        renderAppendedInfo={this.renderAppendedInfo}
+        onMouseEnter={this.props.enterNode}
+        onMouseLeave={this.props.leaveNode}
+        onClick={this.handleMouseClick}
+        graphNodeRef={this.saveRef}
+        x={this.props.x}
+        y={this.props.y}
+      />
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    contrastMode: state.get('contrastMode'),
+    currentTopology: state.get('currentTopology'),
+    exportingGraph: state.get('exportingGraph'),
+    searchTerms: [state.get('searchQuery')],
+    showingNetworks: state.get('showingNetworks'),
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { clickNode, enterNode, leaveNode }
+)(NodeContainer);

+ 53 - 0
app/scripts/charts/node-networks-overlay.js

@@ -0,0 +1,53 @@
+import React from 'react';
+import { scaleBand } from 'd3-scale';
+import { List as makeList } from 'immutable';
+import { connect } from 'react-redux';
+
+import { getNetworkColor } from '../utils/color-utils';
+
+// Min size is about a quarter of the width, feels about right.
+const minBarWidth = 0.25;
+const barHeight = 0.08;
+const innerPadding = 0.04;
+const borderRadius = 0.01;
+const offset = 0.67;
+const x = scaleBand();
+
+function NodeNetworksOverlay({ networks = makeList() }) {
+  const barWidth = Math.max(1, minBarWidth * networks.size);
+  const yPosition = offset - (barHeight * 0.5);
+
+  // Update singleton scale.
+  x.domain(networks.map((n, i) => i).toJS());
+  x.range([barWidth * -0.5, barWidth * 0.5]);
+  x.paddingInner(innerPadding);
+
+  const bandwidth = x.bandwidth();
+  const bars = networks.map((n, i) => (
+    <rect
+      className="node-network"
+      key={n.get('id')}
+      x={x(i)}
+      y={yPosition}
+      width={bandwidth}
+      height={barHeight}
+      rx={borderRadius}
+      ry={borderRadius}
+      style={{ fill: getNetworkColor(n.get('colorKey', n.get('id'))) }}
+    />
+  ));
+
+  return (
+    <g transform="translate(0, -5) scale(60)">
+      {bars.toJS()}
+    </g>
+  );
+}
+
+function mapStateToProps(state) {
+  return {
+    contrastMode: state.get('contrastMode')
+  };
+}
+
+export default connect(mapStateToProps)(NodeNetworksOverlay);

+ 296 - 0
app/scripts/charts/nodes-chart-elements.js

@@ -0,0 +1,296 @@
+import React from 'react';
+import classNames from 'classnames';
+import { connect } from 'react-redux';
+import { fromJS, Map as makeMap, List as makeList } from 'immutable';
+import theme from 'weaveworks-ui-components/lib/theme';
+
+import NodeContainer from './node-container';
+import EdgeContainer from './edge-container';
+import { getAdjacentNodes, hasSelectedNode as hasSelectedNodeFn } from '../utils/topology-utils';
+import { graphExceedsComplexityThreshSelector } from '../selectors/topology';
+import { nodeNetworksSelector, selectedNetworkNodesIdsSelector } from '../selectors/node-networks';
+import { searchNodeMatchesSelector } from '../selectors/search';
+import { nodeMetricSelector } from '../selectors/node-metric';
+import {
+  highlightedNodeIdsSelector,
+  highlightedEdgeIdsSelector
+} from '../selectors/graph-view/decorators';
+import {
+  selectedScaleSelector,
+  layoutNodesSelector,
+  layoutEdgesSelector
+} from '../selectors/graph-view/layout';
+
+import { NODE_BASE_SIZE } from '../constants/styles';
+import {
+  BLURRED_EDGES_LAYER,
+  BLURRED_NODES_LAYER,
+  NORMAL_EDGES_LAYER,
+  NORMAL_NODES_LAYER,
+  HIGHLIGHTED_EDGES_LAYER,
+  HIGHLIGHTED_NODES_LAYER,
+  HOVERED_EDGES_LAYER,
+  HOVERED_NODES_LAYER,
+} from '../constants/naming';
+
+
+class NodesChartElements extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.renderNode = this.renderNode.bind(this);
+    this.renderEdge = this.renderEdge.bind(this);
+    this.renderElement = this.renderElement.bind(this);
+    this.nodeDisplayLayer = this.nodeDisplayLayer.bind(this);
+    this.edgeDisplayLayer = this.edgeDisplayLayer.bind(this);
+
+    // Node decorators
+    this.nodeHighlightedDecorator = this.nodeHighlightedDecorator.bind(this);
+    this.nodeFocusedDecorator = this.nodeFocusedDecorator.bind(this);
+    this.nodeBlurredDecorator = this.nodeBlurredDecorator.bind(this);
+    this.nodeMatchesDecorator = this.nodeMatchesDecorator.bind(this);
+    this.nodeNetworksDecorator = this.nodeNetworksDecorator.bind(this);
+    this.nodeMetricDecorator = this.nodeMetricDecorator.bind(this);
+    this.nodeScaleDecorator = this.nodeScaleDecorator.bind(this);
+
+    // Edge decorators
+    this.edgeFocusedDecorator = this.edgeFocusedDecorator.bind(this);
+    this.edgeBlurredDecorator = this.edgeBlurredDecorator.bind(this);
+    this.edgeHighlightedDecorator = this.edgeHighlightedDecorator.bind(this);
+    this.edgeScaleDecorator = this.edgeScaleDecorator.bind(this);
+    this.state={
+      nodeColor:'#5B8FF9'
+    }
+  }
+
+  nodeDisplayLayer(node) {
+    if (node.get('id') === this.props.mouseOverNodeId) {
+      return HOVERED_NODES_LAYER;
+    } if (node.get('blurred') && !node.get('focused')) {
+      return BLURRED_NODES_LAYER;
+    } if (node.get('highlighted')) {
+      return HIGHLIGHTED_NODES_LAYER;
+    }
+    return NORMAL_NODES_LAYER;
+  }
+
+  edgeDisplayLayer(edge) {
+    if (edge.get('id') === this.props.mouseOverEdgeId) {
+      return HOVERED_EDGES_LAYER;
+    } if (edge.get('blurred') && !edge.get('focused')) {
+      return BLURRED_EDGES_LAYER;
+    } if (edge.get('highlighted')) {
+      return HIGHLIGHTED_EDGES_LAYER;
+    }
+    return NORMAL_EDGES_LAYER;
+  }
+
+  nodeHighlightedDecorator(node) {
+    const nodeSelected = (this.props.selectedNodeId === node.get('id'));
+    const nodeHighlighted = this.props.highlightedNodeIds.has(node.get('id'));
+    return node.set('highlighted', nodeHighlighted || nodeSelected);
+  }
+
+  nodeFocusedDecorator(node) {
+    const nodeSelected = (this.props.selectedNodeId === node.get('id'));
+    const isNeighborOfSelected = this.props.neighborsOfSelectedNode.includes(node.get('id'));
+    return node.set('focused', nodeSelected || isNeighborOfSelected);
+  }
+
+  nodeBlurredDecorator(node) {
+    const belongsToNetwork = this.props.selectedNetworkNodesIds.contains(node.get('id'));
+    const noMatches = this.props.searchNodeMatches.get(node.get('id'), makeMap()).isEmpty();
+    const notMatched = (this.props.searchQuery && !node.get('highlighted') && noMatches);
+    const notFocused = (this.props.selectedNodeId && !node.get('focused'));
+    const notInNetwork = (this.props.selectedNetwork && !belongsToNetwork);
+    return node.set('blurred', notMatched || notFocused || notInNetwork);
+  }
+
+  nodeMatchesDecorator(node) {
+    return node.set('matches', this.props.searchNodeMatches.get(node.get('id')));
+  }
+
+  nodeNetworksDecorator(node) {
+    return node.set('networks', this.props.nodeNetworks.get(node.get('id')));
+  }
+
+  nodeMetricDecorator(node) {
+    return node.set('metric', this.props.nodeMetric.get(node.get('id')));
+  }
+
+  nodeScaleDecorator(node) {
+    return node.set('scale', node.get('focused') ? this.props.selectedScale : 1);
+  }
+
+  edgeHighlightedDecorator(edge) {
+    return edge.set('highlighted', this.props.highlightedEdgeIds.has(edge.get('id')));
+  }
+
+  edgeFocusedDecorator(edge) {
+    const sourceSelected = (this.props.selectedNodeId === edge.get('source'));
+    const targetSelected = (this.props.selectedNodeId === edge.get('target'));
+    return edge.set('focused', this.props.hasSelectedNode && (sourceSelected || targetSelected));
+  }
+
+  edgeBlurredDecorator(edge) {
+    const { selectedNodeId, searchNodeMatches, selectedNetworkNodesIds } = this.props;
+    const sourceSelected = (selectedNodeId === edge.get('source'));
+    const targetSelected = (selectedNodeId === edge.get('target'));
+    const otherNodesSelected = this.props.hasSelectedNode && !sourceSelected && !targetSelected;
+    const sourceNoMatches = searchNodeMatches.get(edge.get('source'), makeMap()).isEmpty();
+    const targetNoMatches = searchNodeMatches.get(edge.get('target'), makeMap()).isEmpty();
+    const notMatched = this.props.searchQuery && (sourceNoMatches || targetNoMatches);
+    const sourceInNetwork = selectedNetworkNodesIds.contains(edge.get('source'));
+    const targetInNetwork = selectedNetworkNodesIds.contains(edge.get('target'));
+    const notInNetwork = this.props.selectedNetwork && (!sourceInNetwork || !targetInNetwork);
+    return edge.set('blurred', !edge.get('highlighted') && !edge.get('focused')
+      && (otherNodesSelected || notMatched || notInNetwork));
+  }
+
+  edgeScaleDecorator(edge) {
+    return edge.set('scale', edge.get('focused') ? this.props.selectedScale : 1);
+  }
+
+  renderNode(node) {
+    const { isAnimated } = this.props;
+  
+    // old versions of scope reports have a node shape of `storagesheet`
+    // if so, normalise to `sheet`
+    const shape = node.get('shape') === 'storagesheet' ? 'sheet' : node.get('shape');
+
+    return (
+      <NodeContainer
+        matches={node.get('matches')}
+        networks={node.get('networks')}
+        metric={node.get('metric')}
+        focused={node.get('focused')}
+        highlighted={node.get('highlighted')}
+        shape={shape}
+        tag={node.get('tag')}
+        stacked={node.get('stack')}
+        key={node.get('id')}
+        id={node.get('id')}
+        label={node.get('label')}
+        labelMinor={node.get('labelMinor')}
+        pseudo={node.get('pseudo')}
+        rank={node.get('rank')}
+        x={node.get('x')}
+        y={node.get('y')}
+        size={node.get('scale') * NODE_BASE_SIZE}
+        isAnimated={isAnimated}
+        color={node.get('color')}
+      />
+    );
+  }
+
+  renderEdge(edge) {
+    const { isAnimated } = this.props;
+    return (
+      <EdgeContainer
+        key={edge.get('id')}
+        id={edge.get('id')}
+        source={edge.get('source')}
+        target={edge.get('target')}
+        waypoints={edge.get('points')}
+        highlighted={edge.get('highlighted')}
+        focused={edge.get('focused')}
+        scale={edge.get('scale')}
+        isAnimated={isAnimated}
+      />
+    );
+  }
+
+  renderOverlay(element) {
+    // NOTE: This piece of code is a bit hacky - as we can't set the absolute coords for the
+    // SVG element, we set the zoom level high enough that we're sure it covers the screen.
+    const className = classNames('nodes-chart-overlay', { active: element.get('isActive') });
+    const scale = (this.props.selectedScale || 1) * 100000;
+    return (
+      <rect
+        className={className}
+        key="nodes-chart-overlay"
+        transform={`scale(${scale})`}
+        fill={theme.colors.purple25}
+        x={-1}
+        y={-1}
+        width={2}
+        height={2}
+      />
+    );
+  }
+
+  renderElement(element) {
+    if (element.get('isOverlay')) {
+      return this.renderOverlay(element);
+    }
+    // This heuristics is not ideal but it works.
+    return element.get('points') ? this.renderEdge(element) : this.renderNode(element);
+  }
+
+  render() {
+    const nodes = this.props.layoutNodes.toIndexedSeq()
+      .map(this.nodeHighlightedDecorator)
+      .map(this.nodeFocusedDecorator)
+      .map(this.nodeBlurredDecorator)
+      .map(this.nodeMatchesDecorator)
+      .map(this.nodeNetworksDecorator)
+      .map(this.nodeMetricDecorator)
+      .map(this.nodeScaleDecorator)
+      .groupBy(this.nodeDisplayLayer);
+
+    const edges = this.props.layoutEdges.toIndexedSeq()
+      .map(this.edgeHighlightedDecorator)
+      .map(this.edgeFocusedDecorator)
+      .map(this.edgeBlurredDecorator)
+      .map(this.edgeScaleDecorator)
+      .groupBy(this.edgeDisplayLayer);
+
+    // NOTE: The elements need to be arranged into a single array outside
+    // of DOM structure for React rendering engine to do smart rearrangements
+    // without unnecessary re-rendering of the elements themselves. So e.g.
+    // rendering the element layers individually below would be significantly slower.
+    const orderedElements = makeList([
+      edges.get(BLURRED_EDGES_LAYER, makeList()),
+      nodes.get(BLURRED_NODES_LAYER, makeList()),
+      fromJS([{ isActive: !!nodes.get(BLURRED_NODES_LAYER), isOverlay: true }]),
+      edges.get(NORMAL_EDGES_LAYER, makeList()),
+      nodes.get(NORMAL_NODES_LAYER, makeList()),
+      edges.get(HIGHLIGHTED_EDGES_LAYER, makeList()),
+      nodes.get(HIGHLIGHTED_NODES_LAYER, makeList()),
+      edges.get(HOVERED_EDGES_LAYER, makeList()),
+      nodes.get(HOVERED_NODES_LAYER, makeList()),
+    ]).flatten(true);
+
+    return (
+      <g className="tour-step-anchor nodes-chart-elements">
+        {orderedElements.map(this.renderElement)}
+      </g>
+    );
+  }
+}
+
+
+function mapStateToProps(state) {
+  return {
+    contrastMode: state.get('contrastMode'),
+    hasSelectedNode: hasSelectedNodeFn(state),
+    highlightedEdgeIds: highlightedEdgeIdsSelector(state),
+    highlightedNodeIds: highlightedNodeIdsSelector(state),
+    isAnimated: !graphExceedsComplexityThreshSelector(state),
+    layoutEdges: layoutEdgesSelector(state),
+    layoutNodes: layoutNodesSelector(state),
+    mouseOverEdgeId: state.get('mouseOverEdgeId'),
+    mouseOverNodeId: state.get('mouseOverNodeId'),
+    neighborsOfSelectedNode: getAdjacentNodes(state),
+    nodeMetric: nodeMetricSelector(state),
+    nodeNetworks: nodeNetworksSelector(state),
+    searchNodeMatches: searchNodeMatchesSelector(state),
+    searchQuery: state.get('searchQuery'),
+    selectedNetwork: state.get('selectedNetwork'),
+    selectedNetworkNodesIds: selectedNetworkNodesIdsSelector(state),
+    selectedNodeId: state.get('selectedNodeId'),
+    selectedScale: selectedScaleSelector(state),
+  };
+}
+
+export default connect(mapStateToProps)(NodesChartElements);

+ 85 - 0
app/scripts/charts/nodes-chart.js

@@ -0,0 +1,85 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import NodesChartElements from './nodes-chart-elements';
+import ZoomableCanvas from '../components/zoomable-canvas';
+import { transformToString } from '../utils/transform-utils';
+import { clickBackground } from '../actions/app-actions';
+import {
+  graphLimitsSelector,
+  graphZoomStateSelector,
+} from '../selectors/graph-view/zoom';
+
+import { CONTENT_INCLUDED } from '../constants/naming';
+
+
+const EdgeMarkerDefinition = ({ selectedNodeId }) => {
+  const markerOffset = selectedNodeId ? '35' : '40';
+  const markerSize = selectedNodeId ? '10' : '30';
+  return (
+    <defs>
+      <marker
+        className="edge-marker"
+        id="end-arrow"
+        viewBox="1 0 10 10"
+        refX={markerOffset}
+        refY="3.5"
+        markerWidth={markerSize}
+        markerHeight={markerSize}
+        orient="auto">
+        <polygon className="link" points="0 0, 10 3.5, 0 7" />
+      </marker>
+    </defs>
+  );
+};
+
+class NodesChart extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.handleMouseClick = this.handleMouseClick.bind(this);
+  }
+
+  handleMouseClick() {
+    if (this.props.selectedNodeId) {
+      this.props.clickBackground();
+    }
+  }
+
+  renderContent(transform) {
+    return (
+      <g transform={transformToString(transform)}>
+        <EdgeMarkerDefinition selectedNodeId={this.props.selectedNodeId} />
+        <NodesChartElements />
+      </g>
+    );
+  }
+
+  render() {
+    return (
+      <div className="nodes-chart">
+        <ZoomableCanvas
+          onClick={this.handleMouseClick}
+          boundContent={CONTENT_INCLUDED}
+          limitsSelector={graphLimitsSelector}
+          zoomStateSelector={graphZoomStateSelector}
+          disabled={this.props.selectedNodeId}>
+          {transform => this.renderContent(transform)}
+        </ZoomableCanvas>
+      </div>
+    );
+  }
+}
+
+
+function mapStateToProps(state) {
+  return {
+    selectedNodeId: state.get('selectedNodeId'),
+  };
+}
+
+
+export default connect(
+  mapStateToProps,
+  { clickBackground }
+)(NodesChart);

+ 23 - 0
app/scripts/charts/nodes-error.js

@@ -0,0 +1,23 @@
+import React from 'react';
+import classnames from 'classnames';
+
+const NodesError = ({
+  children, faIconClass, hidden, mainClassName = 'nodes-chart-error'
+}) => {
+  const className = classnames(mainClassName, {
+    hide: hidden
+  });
+
+  return (
+    <div className={className}>
+      <div className="nodes-chart-error-icon-container">
+        <div className="nodes-chart-error-icon">
+          <span className={faIconClass} />
+        </div>
+      </div>
+      {children}
+    </div>
+  );
+};
+
+export default NodesError;

+ 208 - 0
app/scripts/charts/nodes-grid.js

@@ -0,0 +1,208 @@
+/* eslint react/jsx-no-bind: "off" */
+import React from 'react';
+import styled from 'styled-components';
+import { connect } from 'react-redux';
+import { List as makeList, Map as makeMap } from 'immutable';
+import capitalize from 'lodash/capitalize';
+
+import NodeDetailsTable from '../components/node-details/node-details-table';
+import { clickNode } from '../actions/request-actions';
+import { sortOrderChanged } from '../actions/app-actions';
+import { shownNodesSelector } from '../selectors/node-filters';
+import { trackAnalyticsEvent } from '../utils/tracking-utils';
+import { findTopologyById } from '../utils/topology-utils';
+import { TABLE_VIEW_MODE } from '../constants/naming';
+
+import { windowHeightSelector } from '../selectors/canvas';
+import { searchNodeMatchesSelector } from '../selectors/search';
+import { getNodeColor } from '../utils/color-utils';
+
+
+const IGNORED_COLUMNS = ['docker_container_ports', 'docker_container_id', 'docker_image_id',
+  'docker_container_command', 'docker_container_networks'];
+
+
+const Icon = styled.span`
+  border-radius: ${props => props.theme.borderRadius.soft};
+  background-color: ${props => props.color};
+  margin-top: 3px;
+  display: block;
+  height: 12px;
+  width: 12px;
+`;
+
+function topologyLabel(topologies, id) {
+  const topology = findTopologyById(topologies, id);
+  if (!topology) {
+    return capitalize(id);
+  }
+  return topology.get('fullName');
+}
+
+function getColumns(nodes, topologies) {
+  const metricColumns = nodes
+    .toList()
+    .flatMap((n) => {
+      const metrics = (n.get('metrics') || makeList())
+        .filter(m => !m.get('valueEmpty'))
+        .map(m => makeMap({ dataType: 'number', id: m.get('id'), label: m.get('label') }));
+      return metrics;
+    })
+    .toSet()
+    .toList()
+    .sortBy(m => m.get('label'));
+
+  const metadataColumns = nodes
+    .toList()
+    .flatMap((n) => {
+      const metadata = (n.get('metadata') || makeList())
+        .map(m => makeMap({
+          dataType: m.get('dataType'),
+          id: m.get('id'),
+          label: m.get('label')
+        }));
+      return metadata;
+    })
+    .toSet()
+    .filter(n => !IGNORED_COLUMNS.includes(n.get('id')))
+    .toList()
+    .sortBy(m => m.get('label'));
+
+
+  const relativesColumns = nodes
+    .toList()
+    .flatMap((n) => {
+      const metadata = (n.get('parents') || makeList())
+        .map(m => makeMap({
+          id: m.get('topologyId'),
+          label: topologyLabel(topologies, m.get('topologyId'))
+        }));
+      return metadata;
+    })
+    .toSet()
+    .toList()
+    .sortBy(m => m.get('label'));
+
+  return relativesColumns.concat(metadataColumns, metricColumns).toJS();
+}
+
+
+function renderIdCell({
+  rank, label, labelMinor, pseudo
+}) {
+  const showSubLabel = Boolean(pseudo) && labelMinor;
+  const title = showSubLabel ? `${label} (${labelMinor})` : label;
+
+  return (
+    <div title={title} className="nodes-grid-id-column">
+      <div style={{ flex: 'none', width: 16 }}>
+        <Icon color={getNodeColor(rank, label)} />
+      </div>
+      <div className="truncate">
+        {label}
+        {' '}
+        {showSubLabel && <span className="nodes-grid-label-minor">{labelMinor}</span>}
+      </div>
+    </div>
+  );
+}
+class NodesGrid extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.onClickRow = this.onClickRow.bind(this);
+    this.onSortChange = this.onSortChange.bind(this);
+    this.saveTableRef = this.saveTableRef.bind(this);
+  }
+
+  onClickRow(ev, node) {
+    trackAnalyticsEvent('scope.node.click', {
+      layout: TABLE_VIEW_MODE,
+      parentTopologyId: this.props.currentTopology.get('parentId'),
+      topologyId: this.props.currentTopology.get('id'),
+      shape: node.shape
+    });
+    this.props.clickNode(node.id, node.label, node.shape, ev.target.getBoundingClientRect(), node.topologyId );
+  }
+
+  onSortChange(sortedBy, sortedDesc) {
+    this.props.sortOrderChanged(sortedBy, sortedDesc);
+  }
+
+  saveTableRef(ref) {
+    this.tableRef = ref;
+  }
+
+  render() {
+    const {
+      nodes, gridSortedBy, gridSortedDesc, searchNodeMatches, searchQuery, windowHeight, topologies
+    } = this.props;
+    const height = this.tableRef
+      ? windowHeight - this.tableRef.getBoundingClientRect().top - 30
+      : 0;
+    const cmpStyle = {
+      height,
+      paddingLeft: 40,
+      paddingRight: 40,
+    };
+    // TODO: What are 24 and 18? Use a comment or extract into constants.
+    const tbodyHeight = height - 24 - 18;
+    const className = 'tour-step-anchor scroll-body';
+    const tbodyStyle = {
+      height: `${tbodyHeight}px`,
+    };
+
+    const detailsData = {
+      columns: getColumns(nodes, topologies),
+      id: '',
+      label: this.props.currentTopology && this.props.currentTopology.get('fullName'),
+      nodes: nodes
+        .toList()
+        .filter(n => !(searchQuery && searchNodeMatches.get(n.get('id'), makeMap()).isEmpty()))
+        .toJS()
+    };
+
+    return (
+      <div className="nodes-grid" ref={this.saveTableRef}>
+        {nodes.size > 0 && (
+          <NodeDetailsTable
+            style={cmpStyle}
+            className={className}
+            renderIdCell={renderIdCell}
+            tbodyStyle={tbodyStyle}
+            topologyId={this.props.currentTopologyId}
+            onSortChange={this.onSortChange}
+            onClickRow={this.onClickRow}
+            sortedBy={gridSortedBy}
+            sortedDesc={gridSortedDesc}
+            selectedNodeId={this.props.selectedNodeId}
+            limit={1000}
+            {...detailsData}
+          />
+        )}
+      </div>
+    );
+  }
+}
+
+
+function mapStateToProps(state) {
+  return {
+    currentTopology: state.get('currentTopology'),
+    currentTopologyId: state.get('currentTopologyId'),
+    gridSortedBy: state.get('gridSortedBy'),
+    gridSortedDesc: state.get('gridSortedDesc'),
+    nodes: shownNodesSelector(state),
+    searchNodeMatches: searchNodeMatchesSelector(state),
+    searchQuery: state.get('searchQuery'),
+    selectedNodeId: state.get('selectedNodeId'),
+    topologies: state.get('topologies'),
+    windowHeight: windowHeightSelector(state),
+  };
+}
+
+
+export default connect(
+  mapStateToProps,
+  { clickNode, sortOrderChanged }
+)(NodesGrid);

+ 499 - 0
app/scripts/charts/nodes-layout.js

@@ -0,0 +1,499 @@
+import dagre from 'dagre';
+import debug from 'debug';
+import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable';
+import pick from 'lodash/pick';
+
+import { NODE_BASE_SIZE, EDGE_WAYPOINTS_CAP } from '../constants/styles';
+import { EDGE_ID_SEPARATOR } from '../constants/naming';
+import { trackAnalyticsEvent } from '../utils/tracking-utils';
+import { featureIsEnabledAny } from '../utils/feature-utils';
+import { buildTopologyCacheId, updateNodeDegrees } from '../utils/topology-utils';
+import { minEuclideanDistanceBetweenPoints } from '../utils/math-utils';
+import { uniformSelect } from '../utils/array-utils';
+
+const log = debug('scope:nodes-layout');
+
+const topologyCaches = {};
+export const DEFAULT_MARGINS = { left: 0, top: 0 };
+// Pretend the nodes are bigger than they are so that the edges would not enter
+// them under a high curvature which would cause arrow heads to be misplaced.
+const NODE_SIZE_FACTOR = 1.5 * NODE_BASE_SIZE;
+const NODE_SEPARATION_FACTOR = 1 * NODE_BASE_SIZE;
+const RANK_SEPARATION_FACTOR = 2 * NODE_BASE_SIZE;
+const NODE_CENTERS_SEPARATION_FACTOR = NODE_SIZE_FACTOR + NODE_SEPARATION_FACTOR;
+let layoutRuns = 0;
+let layoutRunsTrivial = 0;
+
+function graphNodeId(id) {
+  return id.replace('.', '<DOT>');
+}
+
+function fromGraphNodeId(encodedId) {
+  return encodedId.replace('<DOT>', '.');
+}
+
+// Adds some additional waypoints to the edge to make sure the it connects the node
+// centers and that the edge enters the target node relatively straight so that the
+// arrow is drawn correctly. The total number of waypoints is capped to EDGE_WAYPOINTS_CAP.
+function correctedEdgePath(waypoints, source, target) {
+  // Get the relevant waypoints that will be added/replicated.
+  const sourcePoint = fromJS({ x: source.get('x'), y: source.get('y') });
+  const targetPoint = fromJS({ x: target.get('x'), y: target.get('y') });
+  const entrancePoint = waypoints.last();
+
+  if (target !== source) {
+    // The strategy for the non-loop edges is the following:
+    //   * Uniformly select at most CAP - 4 of the central waypoints ignoring the target node
+    //     entrance point. Such a selection will ensure that both the source node exit point and
+    //     the point before the target node entrance point are taken as boundaries of the interval.
+    //   * Now manually add those 4 points that we always want to have included in the edge path -
+    //     centers of source/target nodes and twice the target node entrance point to ensure the
+    //     edge path actually goes through it and thus doesn't miss the arrow element.
+    //   * In the end, what matters for the arrow is that the last 4 points of the array are always
+    //     fixed regardless of the total number of waypoints. That way we ensure the arrow is drawn
+    //     correctly, but also that the edge path enters the target node smoothly.
+    waypoints = fromJS(uniformSelect(waypoints.butLast().toJS(), EDGE_WAYPOINTS_CAP - 4));
+    waypoints = waypoints.unshift(sourcePoint);
+    waypoints = waypoints.push(entrancePoint);
+    waypoints = waypoints.push(entrancePoint);
+    waypoints = waypoints.push(targetPoint);
+  } else {
+    // For loops we simply set the endpoints at the center of source/target node to
+    // make them smoother and, of course, we cap the total number of waypoints.
+    waypoints = fromJS(uniformSelect(waypoints.toJS(), EDGE_WAYPOINTS_CAP));
+    waypoints = waypoints.set(0, sourcePoint);
+    waypoints = waypoints.set(waypoints.size - 1, targetPoint);
+  }
+
+  return waypoints;
+}
+
+/**
+ * Add coordinates to 0-degree nodes using a square layout
+ * Depending on the previous layout run's graph aspect ratio, the square will be
+ * placed on the right side or below the graph.
+ * @param  {Object} layout Layout with nodes and edges
+ * @param  {Object} opts   Options with node distances
+ * @return {Object}        modified layout
+ */
+function layoutSingleNodes(layout, opts) {
+  const result = Object.assign({}, layout);
+  const options = opts || {};
+  const margins = options.margins || DEFAULT_MARGINS;
+  const ranksep = RANK_SEPARATION_FACTOR / 2; // dagre splits it in half
+  const nodesep = NODE_SEPARATION_FACTOR;
+  const nodeWidth = NODE_SIZE_FACTOR;
+  const nodeHeight = NODE_SIZE_FACTOR;
+  const graphHeight = layout.graphHeight || layout.height;
+  const graphWidth = layout.graphWidth || layout.width;
+  const aspectRatio = graphHeight ? graphWidth / graphHeight : 1;
+
+  let { nodes } = layout;
+
+  // 0-degree nodes
+  const singleNodes = nodes.filter(node => node.get('degree') === 0);
+
+  if (singleNodes.size) {
+    let offsetX;
+    let offsetY;
+    const nonSingleNodes = nodes.filter(node => node.get('degree') !== 0);
+    if (nonSingleNodes.size > 0) {
+      if (aspectRatio < 1) {
+        log('laying out single nodes to the right', aspectRatio);
+        offsetX = nonSingleNodes.maxBy(node => node.get('x')).get('x');
+        offsetY = nonSingleNodes.minBy(node => node.get('y')).get('y');
+        if (offsetX) {
+          offsetX += nodeWidth + nodesep;
+        }
+      } else {
+        log('laying out single nodes below', aspectRatio);
+        offsetX = nonSingleNodes.minBy(node => node.get('x')).get('x');
+        offsetY = nonSingleNodes.maxBy(node => node.get('y')).get('y');
+        if (offsetY) {
+          offsetY += nodeHeight + ranksep;
+        }
+      }
+    }
+
+    // default margins
+    offsetX = offsetX || (margins.left + nodeWidth) / 2;
+    offsetY = offsetY || (margins.top + nodeHeight) / 2;
+
+    const columns = Math.ceil(Math.sqrt(singleNodes.size));
+    let row = 0;
+    let col = 0;
+    let singleX;
+    let singleY;
+    nodes = nodes.sortBy(node => node.get('rank')).map((node) => {
+      if (singleNodes.has(node.get('id'))) {
+        if (col === columns) {
+          col = 0;
+          row += 1;
+        }
+        singleX = (col * (nodesep + nodeWidth)) + offsetX;
+        singleY = (row * (ranksep + nodeHeight)) + offsetY;
+        col += 1;
+        return node.merge({
+          x: singleX,
+          y: singleY
+        });
+      }
+      return node;
+    });
+
+    // adjust layout dimensions if graph is now bigger
+    result.width = Math.max(layout.width, singleX + (nodeWidth / 2) + nodesep);
+    result.height = Math.max(layout.height, singleY + (nodeHeight / 2) + ranksep);
+    result.nodes = nodes;
+  }
+
+  return result;
+}
+
+/**
+ * Layout engine runner
+ * After the layout engine run nodes and edges have x-y-coordinates. Engine is
+ * not run if the number of nodes is bigger than `MAX_NODES`.
+ * @param  {Object} graph dagre graph instance
+ * @param  {Map} imNodes new node set
+ * @param  {Map} imEdges new edge set
+ * @param  {Object} opts Options with nodes layout
+ * @return {Object}         Layout with nodes, edges, dimensions
+ */
+function runLayoutEngine(graph, imNodes, imEdges, opts) {
+  let nodes = imNodes;
+  let edges = imEdges;
+
+  const ranksep = RANK_SEPARATION_FACTOR;
+  const nodesep = NODE_SEPARATION_FACTOR;
+  const nodeWidth = NODE_SIZE_FACTOR;
+  const nodeHeight = NODE_SIZE_FACTOR;
+
+  // configure node margins
+  graph.setGraph({
+    nodesep,
+    ranksep
+  });
+
+  // add nodes to the graph if not already there
+  nodes.forEach((node) => {
+    const gNodeId = graphNodeId(node.get('id'));
+    if (!graph.hasNode(gNodeId)) {
+      graph.setNode(gNodeId, {
+        height: nodeHeight,
+        width: nodeWidth
+      });
+    }
+  });
+
+  // remove nodes that are no longer there or are 0-degree nodes
+  graph.nodes().forEach((gNodeId) => {
+    const nodeId = fromGraphNodeId(gNodeId);
+    if (!nodes.has(nodeId) || nodes.get(nodeId).get('degree') === 0) {
+      graph.removeNode(gNodeId);
+    }
+  });
+
+  // add edges to the graph if not already there
+  edges.forEach((edge) => {
+    const s = graphNodeId(edge.get('source'));
+    const t = graphNodeId(edge.get('target'));
+    if (!graph.hasEdge(s, t)) {
+      const virtualNodes = s === t ? 1 : 0;
+      graph.setEdge(s, t, {id: edge.get('id'), minlen: virtualNodes});
+    }
+  });
+
+  // remove edges that are no longer there
+  graph.edges().forEach((edgeObj) => {
+    const edge = [fromGraphNodeId(edgeObj.v), fromGraphNodeId(edgeObj.w)];
+    const edgeId = edge.join(EDGE_ID_SEPARATOR);
+    if (!edges.has(edgeId)) {
+      graph.removeEdge(edgeObj.v, edgeObj.w);
+    }
+  });
+
+  dagre.layout(graph, { debugTiming: false });
+
+  // apply coordinates to nodes and edges
+  graph.nodes().forEach((gNodeId) => {
+    const graphNode = graph.node(gNodeId);
+    const nodeId = fromGraphNodeId(gNodeId);
+    nodes = nodes.setIn([nodeId, 'x'], graphNode.x);
+    nodes = nodes.setIn([nodeId, 'y'], graphNode.y);
+  });
+  graph.edges().forEach((graphEdge) => {
+    const graphEdgeMeta = graph.edge(graphEdge);
+    const edge = edges.get(graphEdgeMeta.id);
+
+    const source = nodes.get(fromGraphNodeId(edge.get('source')));
+    const target = nodes.get(fromGraphNodeId(edge.get('target')));
+    const waypoints = correctedEdgePath(fromJS(graphEdgeMeta.points), source, target);
+
+    edges = edges.setIn([graphEdgeMeta.id, 'points'], waypoints);
+  });
+
+  const { width, height } = graph.graph();
+  let layout = {
+    edges,
+    graphHeight: height,
+    graphWidth: width,
+    height,
+    nodes,
+    width
+  };
+
+  // layout the single nodes
+  layout = layoutSingleNodes(layout, opts);
+
+  // return object with the width and height of layout
+  return layout;
+}
+
+/**
+ * Adds `points` array to edge based on location of source and target
+ * @param {Map} edge           new edge
+ * @param {Map} nodeCache      all nodes
+ * @returns {Map}              modified edge
+ */
+function setSimpleEdgePoints(edge, nodeCache) {
+  const source = nodeCache.get(edge.get('source'));
+  const target = nodeCache.get(edge.get('target'));
+  return edge.set('points', fromJS([
+    {x: source.get('x'), y: source.get('y')},
+    {x: target.get('x'), y: target.get('y')}
+  ]));
+}
+
+/**
+ * Layout nodes that have rank that already exists.
+ * Relies on only nodes being added that have a connection to an existing node
+ * while having a rank of an existing node. They will be laid out in the same
+ * line as the latter, with a direct connection between the existing and the new node.
+ * @param  {object} layout    Layout with nodes and edges
+ * @param  {Map} nodeCache    previous nodes
+ * @param  {object} opts      Options
+ * @return {object}           new layout object
+ */
+export function doLayoutNewNodesOfExistingRank(layout, nodeCache) {
+  const result = Object.assign({}, layout);
+  const nodesep = NODE_SEPARATION_FACTOR;
+  const nodeWidth = NODE_SIZE_FACTOR;
+
+  // determine new nodes
+  const oldNodes = ImmSet.fromKeys(nodeCache);
+  const newNodes = ImmSet.fromKeys(layout.nodes.filter(n => n.get('degree') > 0))
+    .subtract(oldNodes);
+  result.nodes = layout.nodes.map((n) => {
+    if (newNodes.contains(n.get('id'))) {
+      const nodesSameRank = nodeCache.filter(nn => nn.get('rank') === n.get('rank'));
+      if (nodesSameRank.size > 0) {
+        const y = nodesSameRank.first().get('y');
+        const x = nodesSameRank.maxBy(nn => nn.get('x')).get('x') + nodesep + nodeWidth;
+        return n.merge({ x, y });
+      }
+      return n;
+    }
+    return n;
+  });
+
+  result.edges = layout.edges.map((edge) => {
+    if (!edge.has('points')) {
+      return setSimpleEdgePoints(edge, layout.nodes);
+    }
+    return edge;
+  });
+
+  return result;
+}
+
+/**
+ * Determine if nodes were added between node sets
+ * @param  {Map} nodes     new Map of nodes
+ * @param  {Map} cache     old Map of nodes
+ * @return {Boolean}       True if nodes had node ids that are not in cache
+ */
+export function hasUnseenNodes(nodes, cache) {
+  const hasUnseen = nodes.size > cache.size
+    || !ImmSet.fromKeys(nodes).isSubset(ImmSet.fromKeys(cache));
+  if (hasUnseen) {
+    log('unseen nodes:', ...ImmSet.fromKeys(nodes).subtract(ImmSet.fromKeys(cache)).toJS());
+  }
+  return hasUnseen;
+}
+
+/**
+ * Determine if all new nodes are 0-degree nodes
+ * Requires cached nodes (implies a previous layout run).
+ * @param  {Map} nodes     new Map of nodes
+ * @param  {Map} cache     old Map of nodes
+ * @return {Boolean} True if all new nodes are 0-nodes
+ */
+function hasNewSingleNode(nodes, cache) {
+  const oldNodes = ImmSet.fromKeys(cache);
+  const newNodes = ImmSet.fromKeys(nodes).subtract(oldNodes);
+  const hasNewSingleNodes = newNodes.every(key => nodes.getIn([key, 'degree']) === 0);
+  return oldNodes.size > 0 && hasNewSingleNodes;
+}
+
+/**
+ * Determine if all new nodes are of existing ranks
+ * Requires cached nodes (implies a previous layout run).
+ * @param  {Map} nodes     new Map of nodes
+ * @param  {Map} edges     new Map of edges
+ * @param  {Map} cache     old Map of nodes
+ * @return {Boolean} True if all new nodes have a rank that already exists
+ */
+export function hasNewNodesOfExistingRank(nodes, edges, cache) {
+  const oldNodes = ImmSet.fromKeys(cache);
+  const newNodes = ImmSet.fromKeys(nodes).subtract(oldNodes);
+
+  // if new there are edges that connect 2 new nodes, need a full layout
+  const bothNodesNew = edges.find(edge => newNodes.contains(edge.get('source'))
+    && newNodes.contains(edge.get('target')));
+  if (bothNodesNew) {
+    return false;
+  }
+
+  const oldRanks = cache.filter(n => n.get('rank')).map(n => n.get('rank')).toSet();
+  const hasNewNodesOfExistingRankOrSingle = newNodes.every(key => nodes.getIn([key, 'degree']) === 0
+    || oldRanks.contains(nodes.getIn([key, 'rank'])));
+  return oldNodes.size > 0 && hasNewNodesOfExistingRankOrSingle;
+}
+
+/**
+ * Determine if edge has same endpoints in new nodes as well as in the nodeCache
+ * @param  {Map}  edge      Edge with source and target
+ * @param  {Map}  nodes     new node set
+ * @return {Boolean}           True if old and new endpoints have same coordinates
+ */
+function hasSameEndpoints(cachedEdge, nodes) {
+  const oldPoints = cachedEdge.get('points');
+  const oldSourcePoint = oldPoints.first();
+  const oldTargetPoint = oldPoints.last();
+  const newSource = nodes.get(cachedEdge.get('source'));
+  const newTarget = nodes.get(cachedEdge.get('target'));
+  return (oldSourcePoint && oldTargetPoint && newSource && newTarget
+    && oldSourcePoint.get('x') === newSource.get('x')
+    && oldSourcePoint.get('y') === newSource.get('y')
+    && oldTargetPoint.get('x') === newTarget.get('x')
+    && oldTargetPoint.get('y') === newTarget.get('y'));
+}
+
+/**
+ * Clones a previous layout
+ * @param  {Object} layout Layout object
+ * @param  {Map} nodes  new nodes
+ * @param  {Map} edges  new edges
+ * @return {Object}        layout clone
+ */
+function cloneLayout(layout, nodes, edges) {
+  const clone = Object.assign({}, layout, {edges, nodes});
+  return clone;
+}
+
+/**
+ * Copies node properties from previous layout runs to new nodes.
+ * This assumes the cache has data for all new nodes.
+ * @param  {Object} layout Layout
+ * @param  {Object} nodeCache  cache of all old nodes
+ * @param  {Object} edgeCache  cache of all old edges
+ * @return {Object}        modified layout
+ */
+function copyLayoutProperties(layout, nodeCache, edgeCache) {
+  const result = Object.assign({}, layout);
+  result.nodes = layout.nodes.map(node => (nodeCache.has(node.get('id'))
+    ? node.merge(nodeCache.get(node.get('id'))) : node));
+  result.edges = layout.edges.map((edge) => {
+    if (edgeCache.has(edge.get('id'))
+      && hasSameEndpoints(edgeCache.get(edge.get('id')), result.nodes)) {
+      return edge.merge(edgeCache.get(edge.get('id')));
+    } if (nodeCache.get(edge.get('source')) && nodeCache.get(edge.get('target'))) {
+      return setSimpleEdgePoints(edge, nodeCache);
+    }
+    return edge;
+  });
+  return result;
+}
+
+
+/**
+ * Layout of nodes and edges
+ * If a previous layout was given and not too much changed, the previous layout
+ * is changed and returned. Otherwise does a new layout engine run.
+ * @param  {Map} immNodes All nodes
+ * @param  {Map} immEdges All edges
+ * @param  {object} opts  width, height, margins, etc...
+ * @return {object} graph object with nodes, edges, dimensions
+ */
+export function doLayout(immNodes, immEdges, opts) {
+  const options = opts || {};
+  const cacheId = buildTopologyCacheId(options.topologyId, options.topologyOptions);
+
+  // one engine and node and edge caches per topology, to keep renderings similar
+  if (options.noCache || !topologyCaches[cacheId]) {
+    topologyCaches[cacheId] = {
+      edgeCache: makeMap(),
+      graph: new dagre.graphlib.Graph({}),
+      nodeCache: makeMap()
+    };
+  }
+
+  const cache = topologyCaches[cacheId];
+  const cachedLayout = options.cachedLayout || cache.cachedLayout;
+  const nodeCache = options.nodeCache || cache.nodeCache;
+  const edgeCache = options.edgeCache || cache.edgeCache;
+  const useCache = !options.forceRelayout && cachedLayout && nodeCache && edgeCache;
+  const nodesWithDegrees = updateNodeDegrees(immNodes, immEdges);
+  let layout;
+
+  layoutRuns += 1;
+  if (useCache && !hasUnseenNodes(immNodes, nodeCache)) {
+    layoutRunsTrivial += 1;
+    // trivial case: no new nodes have been added
+    log('skip layout, trivial adjustment', layoutRunsTrivial, layoutRuns);
+    layout = cloneLayout(cachedLayout, immNodes, immEdges);
+    layout = copyLayoutProperties(layout, nodeCache, edgeCache);
+  } else if (useCache
+    && featureIsEnabledAny('layout-dance', 'layout-dance-single')
+    && hasNewSingleNode(nodesWithDegrees, nodeCache)) {
+    // special case: new nodes are 0-degree nodes, no need for layout run,
+    // they will be laid out further below
+    log('skip layout, only 0-degree node(s) added');
+    layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges);
+    layout = copyLayoutProperties(layout, nodeCache, edgeCache);
+    layout = layoutSingleNodes(layout, opts);
+  } else if (useCache
+    && featureIsEnabledAny('layout-dance', 'layout-dance-rank')
+    && hasNewNodesOfExistingRank(nodesWithDegrees, immEdges, nodeCache)) {
+    // special case: few new nodes were added, no need for layout run,
+    // they will inserted according to ranks
+    log('skip layout, used rank-based insertion');
+    layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges);
+    layout = copyLayoutProperties(layout, nodeCache, edgeCache);
+    layout = doLayoutNewNodesOfExistingRank(layout, nodeCache);
+    layout = layoutSingleNodes(layout, opts);
+  } else {
+    // default case: the new layout is too different and refreshing is required
+    layout = runLayoutEngine(cache.graph, nodesWithDegrees, immEdges, opts);
+  }
+
+
+  if (layout) {
+    // Last line of defense - re-render everything if two nodes are too close to one another.
+    if (minEuclideanDistanceBetweenPoints(layout.nodes) < NODE_CENTERS_SEPARATION_FACTOR) {
+      layout = runLayoutEngine(cache.graph, nodesWithDegrees, immEdges, opts);
+      trackAnalyticsEvent('scope.layout.graph.overlap');
+    }
+
+    // cache results
+    cache.cachedLayout = layout;
+    // only cache layout-related properties
+    // NB: These properties must be immutable wrt a given node because properties of updated nodes
+    // will be overwritten with the cached values, see copyLayoutProperties()
+    cache.nodeCache = cache.nodeCache.merge(layout.nodes.map(n => fromJS(pick(n.toJS(), ['x', 'y', 'rank']))));
+    cache.edgeCache = cache.edgeCache.merge(layout.edges);
+  }
+
+  return layout;
+}

+ 1 - 0
app/scripts/component.js

@@ -0,0 +1 @@
+module.exports = require('./components/app').default;

+ 51 - 0
app/scripts/components/__tests__/node-details-test.js

@@ -0,0 +1,51 @@
+import React from 'react';
+import Immutable from 'immutable';
+import TestUtils from 'react-dom/test-utils';
+import { Provider } from 'react-redux';
+import configureStore from '../../stores/configureStore';
+
+// need ES5 require to keep automocking off
+const NodeDetails = require('../node-details.js').default.WrappedComponent;
+
+describe('NodeDetails', () => {
+  let nodes;
+  let nodeId;
+  let details;
+  const makeMap = Immutable.OrderedMap;
+
+  beforeEach(() => {
+    nodes = makeMap();
+    nodeId = 'n1';
+  });
+
+  it('shows n/a when node was not found', () => {
+    const c = TestUtils.renderIntoDocument((
+      <Provider store={configureStore()}>
+        <NodeDetails notFound />
+      </Provider>
+    ));
+    const notFound = TestUtils.findRenderedDOMComponentWithClass(
+      c,
+      'node-details-header-notavailable'
+    );
+    expect(notFound).toBeDefined();
+  });
+
+  it('show label of node with title', () => {
+    nodes = nodes.set(nodeId, Immutable.fromJS({id: nodeId}));
+    details = {label: 'Node 1'};
+    const c = TestUtils.renderIntoDocument((
+      <Provider store={configureStore()}>
+        <NodeDetails
+          nodes={nodes}
+          topologyId="containers"
+          nodeId={nodeId}
+          details={details}
+          />
+      </Provider>
+    ));
+
+    const title = TestUtils.findRenderedDOMComponentWithClass(c, 'node-details-header-label');
+    expect(title.title).toBe('Node 1');
+  });
+});

+ 308 - 0
app/scripts/components/app.js

@@ -0,0 +1,308 @@
+import debug from 'debug';
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { connect } from 'react-redux';
+import { debounce, isEqual } from 'lodash';
+
+import { ThemeProvider } from 'styled-components';
+import commonTheme from 'weaveworks-ui-components/lib/theme';
+
+import GlobalStyle from './global-style';
+// import Logo from './logo';
+import Footer from './footer';
+import Sidebar from './sidebar';
+import HelpPanel from './help-panel';
+import TroubleshootingMenu from './troubleshooting-menu';
+import Search from './search';
+import Status from './status';
+import Topologies from './topologies';
+import TopologyOptions from './topology-options';
+import Overlay from './overlay';
+import {
+  pinNextMetric,
+  pinPreviousMetric,
+  hitEsc,
+  unpinMetric,
+  toggleHelp,
+  setGraphView,
+  setMonitorState,
+  setTableView,
+  setStoreViewState,
+  setViewportDimensions,
+} from '../actions/app-actions';
+import {
+  focusSearch,
+  getApiDetails,
+  setResourceView,
+  getTopologiesWithInitialPoll,
+  shutdown,
+} from '../actions/request-actions';
+import Details from './details';
+import Nodes from './nodes';
+import TimeControl from './time-control';
+import TimeTravelWrapper from './time-travel-wrapper';
+import ViewModeSelector from './view-mode-selector';
+import NetworkSelector from './networks-selector';
+import DebugToolbar, { showingDebugToolbar, toggleDebugToolbar } from './debug-toolbar';
+import { getUrlState } from '../utils/router-utils';
+import { getRouter } from '../router';
+import { trackAnalyticsEvent } from '../utils/tracking-utils';
+import { availableNetworksSelector } from '../selectors/node-networks';
+import { timeTravelSupportedSelector } from '../selectors/time-travel';
+import {
+  isResourceViewModeSelector,
+  isTableViewModeSelector,
+  isGraphViewModeSelector,
+} from '../selectors/topology';
+import defaultTheme from '../themes/default';
+import contrastTheme from '../themes/contrast';
+import { VIEWPORT_RESIZE_DEBOUNCE_INTERVAL } from '../constants/timer';
+import {
+  ESC_KEY_CODE,
+} from '../constants/key-codes';
+
+const keyPressLog = debug('scope:app-key-press');
+
+class App extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.props.dispatch(setMonitorState(this.props.monitor));
+    this.props.dispatch(setStoreViewState(!this.props.disableStoreViewState));
+
+    this.setViewportDimensions = this.setViewportDimensions.bind(this);
+    this.handleResize = debounce(this.setViewportDimensions, VIEWPORT_RESIZE_DEBOUNCE_INTERVAL);
+    this.handleRouteChange = debounce(props.onRouteChange, 50);
+
+    this.saveAppRef = this.saveAppRef.bind(this);
+    this.onKeyPress = this.onKeyPress.bind(this);
+    this.onKeyUp = this.onKeyUp.bind(this);
+  }
+
+  componentDidMount() {
+    this.setViewportDimensions();
+    window.addEventListener('resize', this.handleResize);
+    window.addEventListener('keypress', this.onKeyPress);
+    window.addEventListener('keyup', this.onKeyUp);
+
+
+    this.router = this.props.dispatch(getRouter(this.props.urlState));
+    this.router.start({ hashbang: true });
+
+    if (!this.props.routeSet || process.env.WEAVE_CLOUD) {
+      // dont request topologies when already done via router.
+      // If running as a component, always request topologies when the app mounts.
+      this.props.dispatch(getTopologiesWithInitialPoll());
+    }
+    getApiDetails(this.props.dispatch);
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('resize', this.handleResize);
+    window.removeEventListener('keypress', this.onKeyPress);
+    window.removeEventListener('keyup', this.onKeyUp);
+    this.props.dispatch(shutdown());
+    this.router.stop();
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.monitor !== this.props.monitor) {
+      this.props.dispatch(setMonitorState(nextProps.monitor));
+    }
+    if (nextProps.disableStoreViewState !== this.props.disableStoreViewState) {
+      this.props.dispatch(setStoreViewState(!nextProps.disableStoreViewState));
+    }
+    // Debounce-notify about the route change if the URL state changes its content.
+    if (!isEqual(nextProps.urlState, this.props.urlState)) {
+      this.handleRouteChange(nextProps.urlState);
+    }
+  }
+
+  onKeyUp(ev) {
+    const { showingTerminal } = this.props;
+    keyPressLog('onKeyUp', 'keyCode', ev.keyCode, ev);
+
+    // don't get esc in onKeyPress
+    if (ev.keyCode === ESC_KEY_CODE) {
+      this.props.dispatch(hitEsc());
+    } else if (ev.code === 'KeyD' && ev.ctrlKey && !showingTerminal) {
+      toggleDebugToolbar();
+      this.forceUpdate();
+    }
+  }
+
+  onKeyPress(ev) {
+    const { dispatch, searchFocused, showingTerminal } = this.props;
+    //
+    // keyup gives 'key'
+    // keypress gives 'char'
+    // Distinction is important for international keyboard layouts where there
+    // is often a different {key: char} mapping.
+    if (!searchFocused && !showingTerminal) {
+      keyPressLog('onKeyPress', 'keyCode', ev.keyCode, ev);
+      const char = String.fromCharCode(ev.charCode);
+      if (char === '<') {
+        dispatch(pinPreviousMetric());
+        this.trackEvent('scope.metric.selector.pin.previous.keypress', {
+          metricType: this.props.pinnedMetricType
+        });
+      } else if (char === '>') {
+        dispatch(pinNextMetric());
+        this.trackEvent('scope.metric.selector.pin.next.keypress', {
+          metricType: this.props.pinnedMetricType
+        });
+      } else if (char === 'g') {
+        dispatch(setGraphView());
+        this.trackEvent('scope.layout.selector.keypress');
+      } else if (char === 't') {
+        dispatch(setTableView());
+        this.trackEvent('scope.layout.selector.keypress');
+      } else if (char === 'r') {
+        dispatch(setResourceView());
+        this.trackEvent('scope.layout.selector.keypress');
+      } else if (char === 'q') {
+        this.trackEvent('scope.metric.selector.unpin.keypress', {
+          metricType: this.props.pinnedMetricType
+        });
+        dispatch(unpinMetric());
+      } else if (char === '/') {
+        ev.preventDefault();
+        ev.stopPropagation();
+        dispatch(focusSearch());
+      } else if (char === '?') {
+        dispatch(toggleHelp());
+      }
+    }
+  }
+
+  trackEvent(eventName, additionalProps = {}) {
+    trackAnalyticsEvent(eventName, {
+      layout: this.props.topologyViewMode,
+      parentTopologyId: this.props.currentTopology.get('parentId'),
+      topologyId: this.props.currentTopology.get('id'),
+      ...additionalProps,
+    });
+  }
+
+  setViewportDimensions() {
+    if (this.appRef) {
+      const { width, height } = this.appRef.getBoundingClientRect();
+      this.props.dispatch(setViewportDimensions(width, height));
+    }
+  }
+
+  saveAppRef(ref) {
+    this.appRef = ref;
+  }
+
+  render() {
+    const {
+      isTableViewMode, isGraphViewMode, isResourceViewMode, showingDetails,
+      showingHelp, showingNetworkSelector, showingTroubleshootingMenu,
+      timeTravelTransitioning, timeTravelSupported, contrastMode,
+    } = this.props;
+
+
+    const className = classNames('scope-app', {
+      'contrast-mode': contrastMode,
+      'time-travel-open': timeTravelSupported,
+    });
+    const isIframe = window !== window.top;
+
+    return (
+      <ThemeProvider theme={{...commonTheme, scope: contrastMode ? contrastTheme : defaultTheme }}>
+        <>
+          <GlobalStyle />
+
+          <div className={className} ref={this.saveAppRef}>
+            {showingDebugToolbar() && <DebugToolbar />}
+
+            {showingHelp && <HelpPanel />}
+
+            {showingTroubleshootingMenu && <TroubleshootingMenu />}
+
+            {showingDetails && (
+            <Details
+              renderNodeDetailsExtras={this.props.renderNodeDetailsExtras}
+            />
+            )}
+
+            <div className="header">
+              {timeTravelSupported && this.props.renderTimeTravel()}
+
+              <div className="selectors">
+                {/* <div className="logo">
+                  {!isIframe
+                    && (
+                    <svg width="100%" height="100%" viewBox="0 0 1089 217">
+                      <Logo />
+                    </svg>
+                    )
+                  }
+                </div> */}
+                <Search />
+                <Topologies />
+                <ViewModeSelector />
+                <TimeControl />
+              </div>
+            </div>
+
+            <Nodes />
+
+            <Sidebar classNames={isTableViewMode ? 'sidebar-gridmode' : ''}>
+              {/* {showingNetworkSelector && isGraphViewMode && <NetworkSelector />} */}
+              {/* {!isResourceViewMode && <Status />} */}
+              {!isResourceViewMode && <TopologyOptions />}
+            </Sidebar>
+
+            <Footer />
+
+            <Overlay faded={timeTravelTransitioning} />
+          </div>
+        </>
+      </ThemeProvider>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    contrastMode: state.get('contrastMode'),
+    currentTopology: state.get('currentTopology'),
+    isGraphViewMode: isGraphViewModeSelector(state),
+    isResourceViewMode: isResourceViewModeSelector(state),
+    isTableViewMode: isTableViewModeSelector(state),
+    pinnedMetricType: state.get('pinnedMetricType'),
+    routeSet: state.get('routeSet'),
+    searchFocused: state.get('searchFocused'),
+    searchQuery: state.get('searchQuery'),
+    showingDetails: state.get('nodeDetails').size > 0,
+    showingHelp: state.get('showingHelp'),
+    showingNetworkSelector: availableNetworksSelector(state).count() > 0,
+    showingTerminal: state.get('controlPipes').size > 0,
+    showingTroubleshootingMenu: state.get('showingTroubleshootingMenu'),
+    timeTravelSupported: timeTravelSupportedSelector(state),
+    timeTravelTransitioning: state.get('timeTravelTransitioning'),
+    topologyViewMode: state.get('topologyViewMode'),
+    urlState: getUrlState(state)
+  };
+}
+
+App.propTypes = {
+  disableStoreViewState: PropTypes.bool,
+  monitor: PropTypes.bool,
+  onRouteChange: PropTypes.func,
+  renderNodeDetailsExtras: PropTypes.func,
+  renderTimeTravel: PropTypes.func,
+};
+
+App.defaultProps = {
+  disableStoreViewState: false,
+  monitor: false,
+  onRouteChange: () => null,
+  renderNodeDetailsExtras: () => null,
+  renderTimeTravel: () => <TimeTravelWrapper />,
+};
+
+export default connect(mapStateToProps)(App);

+ 43 - 0
app/scripts/components/cloud-feature.js

@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+
+class CloudFeature extends React.Component {
+  getChildContext() {
+    return {
+      store: this.context.serviceStore || this.context.store || {}
+    };
+  }
+
+  render() {
+    if (process.env.WEAVE_CLOUD) {
+      return React.cloneElement(React.Children.only(this.props.children), {
+        params: this.context.router.params,
+        router: this.context.router
+      });
+    }
+
+    // also show if not in weave cloud?
+    if (this.props.alwaysShow) {
+      return React.cloneElement(React.Children.only(this.props.children));
+    }
+
+    return null;
+  }
+}
+
+/* eslint-disable react/forbid-prop-types */
+// TODO: Remove this component as part of https://github.com/weaveworks/scope/issues/3278.
+CloudFeature.contextTypes = {
+  router: PropTypes.object,
+  serviceStore: PropTypes.object,
+  store: PropTypes.object.isRequired
+};
+
+CloudFeature.childContextTypes = {
+  router: PropTypes.object,
+  store: PropTypes.object
+};
+/* eslint-enable react/forbid-prop-types */
+
+export default connect()(CloudFeature);

+ 72 - 0
app/scripts/components/cloud-link.js

@@ -0,0 +1,72 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import filterInvalidDOMProps from 'filter-invalid-dom-props';
+
+import CloudFeature from './cloud-feature';
+
+/**
+ * CloudLink provides an anchor that allows to set a target
+ * that is comprised of Weave Cloud related pieces.
+ *
+ * We support here relative links with a leading `/` that rewrite
+ * the browser url as well as cloud-related placeholders (:instanceId).
+ *
+ * If no `url` is given, only the children is rendered (no anchor).
+ *
+ * If you want to render the content even if not on the cloud, set
+ * the `alwaysShow` property. A location redirect will be made for
+ * clicks instead.
+ */
+const CloudLink = ({ alwaysShow, ...props }) => (
+  <CloudFeature alwaysShow={alwaysShow}>
+    <LinkWrapper {...props} />
+  </CloudFeature>
+);
+
+class LinkWrapper extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.handleClick = this.handleClick.bind(this);
+    this.buildHref = this.buildHref.bind(this);
+  }
+
+  handleClick(ev, href) {
+    ev.preventDefault();
+    if (!href) return;
+
+    const { router, onClick } = this.props;
+
+    if (onClick) {
+      onClick();
+    }
+
+    if (router && href[0] === '/') {
+      router.push(href);
+    } else {
+      window.location.href = href;
+    }
+  }
+
+  buildHref(url) {
+    const { params } = this.props;
+    if (!url || !params || !params.instanceId) return url;
+    return url.replace(/:instanceid/gi, encodeURIComponent(params.instanceId));
+  }
+
+  render() {
+    const { url, children, ...props } = this.props;
+    if (!url) {
+      return React.isValidElement(children) ? children : (<span>{children}</span>);
+    }
+
+    const href = this.buildHref(url);
+    return (
+      <a {...filterInvalidDOMProps(props)} href={href} onClick={e => this.handleClick(e, href)}>
+        {children}
+      </a>
+    );
+  }
+}
+
+export default connect()(CloudLink);

+ 382 - 0
app/scripts/components/debug-toolbar.js

@@ -0,0 +1,382 @@
+/* eslint react/jsx-no-bind: "off" */
+import React from 'react';
+import { connect } from 'react-redux';
+import {
+  sampleSize, sample, random, range, flattenDeep, times
+} from 'lodash';
+import { fromJS, Set as makeSet } from 'immutable';
+import { hsl } from 'd3-color';
+import debug from 'debug';
+
+import ActionTypes from '../constants/action-types';
+import { receiveNodesDelta } from '../actions/app-actions';
+import { getNodeColor, getNodeColorDark, text2degree } from '../utils/color-utils';
+import { availableMetricsSelector } from '../selectors/node-metric';
+
+
+const SHAPES = ['square', 'hexagon', 'heptagon', 'circle'];
+const STACK_VARIANTS = [false, true];
+const METRIC_FILLS = [0, 0.1, 50, 99.9, 100];
+const NETWORKS = [
+  'be', 'fe', 'zb', 'db', 're', 'gh', 'jk', 'lol', 'nw'
+].map(n => ({ colorKey: n, id: n, label: n }));
+
+const INTERNET = 'the-internet';
+const LOREM = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
+incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
+ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
+voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
+proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`;
+
+const sampleArray = (collection, n = 4) => sampleSize(collection, random(n));
+const log = debug('scope:debug-panel');
+
+const shapeTypes = {
+  circle: ['Host', 'Hosts'],
+  heptagon: ['Pod', 'Pods'],
+  hexagon: ['Container', 'Containers'],
+  square: ['Process', 'Processes']
+};
+
+
+const LABEL_PREFIXES = range('A'.charCodeAt(), 'Z'.charCodeAt() + 1)
+  .map(n => String.fromCharCode(n));
+
+
+const deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, networks = NETWORKS) => ({
+  adjacency,
+  controls: {},
+  id: name,
+  label: name,
+  labelMinor: name,
+  latest: {},
+  networks,
+  origins: [],
+  rank: name,
+  shape,
+  stack
+});
+
+
+function addMetrics(availableMetrics, node, v) {
+  const metrics = availableMetrics.size > 0 ? availableMetrics : fromJS([
+    { id: 'host_cpu_usage_percent', label: 'CPU' }
+  ]);
+
+  return Object.assign({}, node, {
+    metrics: metrics.map(m => Object.assign({}, m, {
+      id: 'zing', label: 'zing', max: 100, value: v
+    })).toJS()
+  });
+}
+
+
+function label(shape, stacked) {
+  const type = shapeTypes[shape];
+  return stacked ? `Group of ${type[1]}` : type[0];
+}
+
+
+function addAllVariants(dispatch) {
+  const newNodes = flattenDeep(STACK_VARIANTS.map(stack => (SHAPES.map((s) => {
+    if (!stack) return [deltaAdd(label(s, stack), [], s, stack)];
+    return times(3).map(() => deltaAdd(label(s, stack), [], s, stack));
+  }))));
+
+  dispatch(receiveNodesDelta({
+    add: newNodes
+  }));
+}
+
+
+function addAllMetricVariants(availableMetrics) {
+  const newNodes = flattenDeep(METRIC_FILLS.map((v, i) => (
+    SHAPES.map(s => [addMetrics(availableMetrics, deltaAdd(label(s) + i, [], s), v)])
+  )));
+
+  return (dispatch) => {
+    dispatch(receiveNodesDelta({
+      add: newNodes
+    }));
+  };
+}
+
+export function showingDebugToolbar() {
+  return (('debugToolbar' in localStorage && JSON.parse(localStorage.debugToolbar))
+    || window.location.pathname.indexOf('debug') > -1);
+}
+
+
+export function toggleDebugToolbar() {
+  if ('debugToolbar' in localStorage) {
+    localStorage.debugToolbar = !showingDebugToolbar();
+  }
+}
+
+
+function enableLog(ns) {
+  debug.enable(`scope:${ns}`);
+  window.location.reload();
+}
+
+
+function disableLog() {
+  debug.disable();
+  window.location.reload();
+}
+
+
+function setAppState(fn) {
+  return (dispatch) => {
+    dispatch({
+      fn,
+      type: ActionTypes.DEBUG_TOOLBAR_INTERFERING
+    });
+  };
+}
+
+
+class DebugToolbar extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.onChange = this.onChange.bind(this);
+    this.toggleColors = this.toggleColors.bind(this);
+    this.addNodes = this.addNodes.bind(this);
+    this.intermittentTimer = null;
+    this.intermittentNodes = makeSet();
+    this.shortLivedTimer = null;
+    this.shortLivedNodes = makeSet();
+    this.state = {
+      nodesToAdd: 30,
+      showColors: false
+    };
+  }
+
+  onChange(ev) {
+    this.setState({ nodesToAdd: parseInt(ev.target.value, 10) });
+  }
+
+  toggleColors() {
+    this.setState(prevState => ({
+      showColors: !prevState.showColors
+    }));
+  }
+
+  asyncDispatch(v) {
+    setTimeout(() => this.props.dispatch(v), 0);
+  }
+
+  setLoading(loading) {
+    this.asyncDispatch(setAppState(state => state.set('topologiesLoaded', !loading)));
+  }
+
+  setIntermittent() {
+    // simulate epheremal nodes
+    if (this.intermittentTimer) {
+      clearInterval(this.intermittentTimer);
+      this.intermittentTimer = null;
+    } else {
+      this.intermittentTimer = setInterval(() => {
+        // add new node
+        this.addNodes(1);
+
+        // remove random node
+        const ns = this.props.nodes;
+        const nodeNames = ns.keySeq().toJS();
+        const randomNode = sample(nodeNames);
+        this.asyncDispatch(receiveNodesDelta({
+          remove: [randomNode]
+        }));
+      }, 1000);
+    }
+  }
+
+  setShortLived() {
+    // simulate nodes with same ID popping in and out
+    if (this.shortLivedTimer) {
+      clearInterval(this.shortLivedTimer);
+      this.shortLivedTimer = null;
+    } else {
+      this.shortLivedTimer = setInterval(() => {
+        // filter random node
+        const ns = this.props.nodes;
+        const nodeNames = ns.keySeq().toJS();
+        const randomNode = sample(nodeNames);
+        if (randomNode) {
+          let nextNodes = ns.setIn([randomNode, 'filtered'], true);
+          this.shortLivedNodes = this.shortLivedNodes.add(randomNode);
+          // bring nodes back after a bit
+          if (this.shortLivedNodes.size > 5) {
+            const returningNode = this.shortLivedNodes.first();
+            this.shortLivedNodes = this.shortLivedNodes.rest();
+            nextNodes = nextNodes.setIn([returningNode, 'filtered'], false);
+          }
+          this.asyncDispatch(setAppState(state => state.set('nodes', nextNodes)));
+        }
+      }, 1000);
+    }
+  }
+
+  updateAdjacencies() {
+    const ns = this.props.nodes;
+    const nodeNames = ns.keySeq().toJS();
+    this.asyncDispatch(receiveNodesDelta({
+      add: this.createRandomNodes(7),
+      remove: this.randomExistingNode(),
+      update: sampleArray(nodeNames).map(n => ({
+        adjacency: sampleArray(nodeNames),
+        id: n,
+      }), nodeNames.length),
+    }));
+  }
+
+  createRandomNodes(n, prefix = 'zing') {
+    const ns = this.props.nodes;
+    const nodeNames = ns.keySeq().toJS();
+    const newNodeNames = range(ns.size, ns.size + n).map(i => (
+      // `${randomLetter()}${randomLetter()}-zing`
+      `${prefix}${i}`
+    ));
+    const allNodes = nodeNames.concat(newNodeNames);
+    return newNodeNames.map(name => deltaAdd(
+      name,
+      sampleArray(allNodes),
+      sample(SHAPES),
+      sample(STACK_VARIANTS),
+      sampleArray(NETWORKS, 10)
+    ));
+  }
+
+  addInternetNode() {
+    setTimeout(() => {
+      this.asyncDispatch(receiveNodesDelta({
+        add: [{
+          id: INTERNET, label: INTERNET, labelMinor: 'Outgoing packets', pseudo: true, shape: 'cloud'
+        }]
+      }));
+    }, 0);
+  }
+
+  addNodes(n, prefix = 'zing') {
+    setTimeout(() => {
+      this.asyncDispatch(receiveNodesDelta({
+        add: this.createRandomNodes(n, prefix)
+      }));
+      log('added nodes', n);
+    }, 0);
+  }
+
+  randomExistingNode() {
+    const ns = this.props.nodes;
+    const nodeNames = ns.keySeq().toJS();
+    return [nodeNames[random(nodeNames.length - 1)]];
+  }
+
+  removeNode() {
+    this.asyncDispatch(receiveNodesDelta({
+      remove: this.randomExistingNode()
+    }));
+  }
+
+  render() {
+    const { availableMetrics } = this.props;
+
+    return (
+      <div className="debug-panel">
+        <div>
+          <strong>Add nodes </strong>
+          <button type="button" onClick={() => this.addNodes(1)}>+1</button>
+          <button type="button" onClick={() => this.addNodes(10)}>+10</button>
+          <input type="number" onChange={this.onChange} value={this.state.nodesToAdd} />
+          <button type="button" onClick={() => this.addNodes(this.state.nodesToAdd)}>+</button>
+          <button type="button" onClick={() => this.asyncDispatch(addAllVariants)}>
+            Variants
+          </button>
+          <button
+            type="button"
+            onClick={() => this.asyncDispatch(addAllMetricVariants(availableMetrics))}>
+            Metric Variants
+          </button>
+          <button type="button" onClick={() => this.addNodes(1, LOREM)}>Long name</button>
+          <button type="button" onClick={() => this.addInternetNode()}>Internet</button>
+          <button type="button" onClick={() => this.removeNode()}>Remove node</button>
+          <button type="button" onClick={() => this.updateAdjacencies()}>Update adj.</button>
+        </div>
+
+        <div>
+          <strong>Logging </strong>
+          <button type="button" onClick={() => enableLog('*')}>scope:*</button>
+          <button type="button" onClick={() => enableLog('dispatcher')}>scope:dispatcher</button>
+          <button type="button" onClick={() => enableLog('app-key-press')}>
+            scope:app-key-press
+          </button>
+          <button type="button" onClick={() => enableLog('terminal')}>scope:terminal</button>
+          <button type="button" onClick={() => disableLog()}>Disable log</button>
+        </div>
+
+        <div>
+          <strong>Colors </strong>
+          <button type="button" onClick={this.toggleColors}>toggle</button>
+        </div>
+
+        {this.state.showColors
+          && (
+            <table>
+              <tbody>
+                {LABEL_PREFIXES.map(r => (
+                  <tr key={r}>
+                    <td
+                      title={`${r}`}
+                      style={{ backgroundColor: hsl(text2degree(r), 0.5, 0.5).toString() }} />
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+
+        {this.state.showColors && [getNodeColor, getNodeColorDark].map(fn => (
+          <table key={fn}>
+            <tbody>
+              {LABEL_PREFIXES.map(r => (
+                <tr key={r}>
+                  {LABEL_PREFIXES.map(c => (
+                    <td key={c} title={`(${r}, ${c})`} style={{ backgroundColor: fn(r, c) }} />
+                  ))}
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        ))}
+
+        <div>
+          <strong>State </strong>
+          <button type="button" onClick={() => this.setLoading(true)}>
+            Set doing initial load
+          </button>
+          <button type="button" onClick={() => this.setLoading(false)}>Stop</button>
+        </div>
+
+        <div>
+          <strong>Short-lived nodes </strong>
+          <button type="button" onClick={() => this.setShortLived()}>
+            Toggle short-lived nodes
+          </button>
+          <button type="button" onClick={() => this.setIntermittent()}>
+            Toggle intermittent nodes
+          </button>
+        </div>
+      </div>
+    );
+  }
+}
+
+
+function mapStateToProps(state) {
+  return {
+    availableMetrics: availableMetricsSelector(state),
+    nodes: state.get('nodes'),
+  };
+}
+
+
+export default connect(mapStateToProps)(DebugToolbar);

+ 80 - 0
app/scripts/components/details-card.js

@@ -0,0 +1,80 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import NodeDetails from './node-details';
+import EmbeddedTerminal from './embedded-terminal';
+import {
+  DETAILS_PANEL_WIDTH as WIDTH,
+  DETAILS_PANEL_OFFSET as OFFSET,
+  DETAILS_PANEL_MARGINS as MARGINS
+} from '../constants/styles';
+
+class DetailsCard extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.state = {
+      mounted: null
+    };
+  }
+
+  componentDidMount() {
+    setTimeout(() => {
+      this.setState({mounted: true});
+    });
+  }
+
+  render() {
+    let transform;
+    const { origin, showingTerminal } = this.props;
+    const panelHeight = window.innerHeight - MARGINS.bottom - MARGINS.top;
+    if (origin && !this.state.mounted) {
+      // render small panel near origin, will transition into normal panel after being mounted
+      const scaleY = origin.height / (window.innerHeight - MARGINS.bottom - MARGINS.top) / 2;
+      const scaleX = origin.width / WIDTH / 2;
+      const centerX = window.innerWidth - MARGINS.right - (WIDTH / 2);
+      const centerY = (panelHeight / 2) + MARGINS.top;
+      const dx = (origin.left + (origin.width / 2)) - centerX;
+      const dy = (origin.top + (origin.height / 2)) - centerY;
+      transform = `translate(${dx}px, ${dy}px) scale(${scaleX},${scaleY})`;
+    } else {
+      // stack effect: shift top cards to the left, shrink lower cards vertically
+      const shiftX = -1 * this.props.index * OFFSET;
+      const position = this.props.cardCount - this.props.index - 1; // reverse index
+      const scaleY = (position === 0) ? 1 : (panelHeight - (2 * OFFSET * position)) / panelHeight;
+      if (scaleY !== 1) {
+        transform = `translateX(${shiftX}px) scaleY(${scaleY})`;
+      } else {
+        // scale(1) is sometimes blurry
+        transform = `translateX(${shiftX}px)`;
+      }
+    }
+    const style = {
+      left: showingTerminal ? MARGINS.right : null,
+      transform,
+      width: showingTerminal ? null : WIDTH
+    };
+    return (
+      <div className="details-wrapper" style={style}>
+        {showingTerminal && <EmbeddedTerminal />}
+        <NodeDetails
+          key={this.props.id}
+          nodeId={this.props.id}
+          mounted={this.state.mounted}
+          renderNodeDetailsExtras={this.props.renderNodeDetailsExtras}
+          {...this.props}
+        />
+      </div>
+    );
+  }
+}
+
+
+function mapStateToProps(state, props) {
+  const pipe = state.get('controlPipes').last();
+  return {
+    showingTerminal: pipe && pipe.get('nodeId') === props.id,
+  };
+}
+
+
+export default connect(mapStateToProps)(DetailsCard);

+ 37 - 0
app/scripts/components/details.js

@@ -0,0 +1,37 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import DetailsCard from './details-card';
+
+class Details extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+  }
+  render() {
+    const { controlStatus, details } = this.props;
+    // render all details as cards, later cards go on top
+    return (
+      <div className="details">
+        {details.toIndexedSeq().map((obj, index) => (
+          <DetailsCard
+            key={obj.id}
+            index={index}
+            cardCount={details.size}
+            nodeControlStatus={controlStatus.get(obj.id)}
+            renderNodeDetailsExtras={this.props.renderNodeDetailsExtras}
+            {...obj}
+          />
+        ))}
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    controlStatus: state.get('controlStatus'),
+    details: state.get('nodeDetails')
+  };
+}
+
+export default connect(mapStateToProps)(Details);

+ 80 - 0
app/scripts/components/embedded-terminal.js

@@ -0,0 +1,80 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { brightenColor, getNodeColorDark } from '../utils/color-utils';
+import { DETAILS_PANEL_WIDTH, DETAILS_PANEL_MARGINS } from '../constants/styles';
+import Terminal from './terminal';
+
+class EmeddedTerminal extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.state = {
+      animated: null,
+      mounted: null,
+    };
+
+    this.handleTransitionEnd = this.handleTransitionEnd.bind(this);
+  }
+
+  componentDidMount() {
+    this.mountedTimeout = setTimeout(() => {
+      this.setState({mounted: true});
+    });
+    this.animationTimeout = setTimeout(() => {
+      this.setState({ animated: true });
+    }, 2000);
+  }
+
+  componentWillUnmount() {
+    clearTimeout(this.mountedTimeout);
+    clearTimeout(this.animationTimeout);
+  }
+
+  getTransform() {
+    const dx = this.state.mounted ? 0
+      : window.innerWidth - DETAILS_PANEL_WIDTH - DETAILS_PANEL_MARGINS.right;
+    return `translateX(${dx}px)`;
+  }
+
+  handleTransitionEnd() {
+    this.setState({ animated: true });
+  }
+
+  render() {
+    const { pipe, details } = this.props;
+    const nodeId = pipe.get('nodeId');
+    const node = details.get(nodeId);
+    const d = node && node.details;
+    const titleBarColor = d && getNodeColorDark(d.rank, d.label, d.pseudo);
+    const statusBarColor = d && brightenColor(titleBarColor);
+    const title = d && d.label;
+
+    // React unmount/remounts when key changes, this is important for cleaning up
+    // the term.js and creating a new one for the new pipe.
+    return (
+      <div className="tour-step-anchor terminal-embedded">
+        <div
+          onTransitionEnd={this.handleTransitionEnd}
+          className="terminal-animation-wrapper"
+          style={{transform: this.getTransform()}}>
+          <Terminal
+            key={pipe.get('id')}
+            pipe={pipe}
+            connect={this.state.animated}
+            titleBarColor={titleBarColor}
+            statusBarColor={statusBarColor}
+            title={title} />
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    details: state.get('nodeDetails'),
+    pipe: state.get('controlPipes').last()
+  };
+}
+
+export default connect(mapStateToProps)(EmeddedTerminal);

+ 126 - 0
app/scripts/components/footer.js

@@ -0,0 +1,126 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import Plugins from './plugins';
+import { trackAnalyticsEvent } from '../utils/tracking-utils';
+import {
+  clickDownloadGraph,
+  clickForceRelayout,
+  toggleHelp,
+  toggleTroubleshootingMenu,
+  setContrastMode
+} from '../actions/app-actions';
+
+
+class Footer extends React.Component {
+  handleContrastClick = (ev) => {
+    ev.preventDefault();
+    this.props.setContrastMode(!this.props.contrastMode);
+  }
+
+  handleRelayoutClick = (ev) => {
+    ev.preventDefault();
+    trackAnalyticsEvent('scope.layout.refresh.click', {
+      layout: this.props.topologyViewMode,
+    });
+    this.props.clickForceRelayout();
+  }
+
+  render() {
+    const {
+      hostname, version, versionUpdate, contrastMode
+    } = this.props;
+
+    const otherContrastModeTitle = contrastMode
+      ? 'Switch to normal contrast' : 'Switch to high contrast';
+    const forceRelayoutTitle = 'Force re-layout (might reduce edge crossings, '
+      + 'but may shift nodes around)';
+    const versionUpdateTitle = versionUpdate
+      ? `New version available: ${versionUpdate.get('version')} Click to download`
+      : '';
+
+    return (
+      <div className="footer">
+        {/* <div className="footer-status">
+          {versionUpdate
+            && (
+              <a
+                className="footer-versionupdate"
+                title={versionUpdateTitle}
+                href={versionUpdate.get('downloadUrl')}
+                target="_blank"
+                rel="noopener noreferrer">
+                Update available:
+                {' '}
+                {versionUpdate.get('version')}
+              </a>
+            )
+          }
+          <span className="footer-label">Version</span>
+          {version || '...'}
+          <span className="footer-label">on</span>
+          {hostname}
+        </div>
+
+        <div className="footer-plugins">
+          <Plugins />
+        </div> */}
+
+        <div className="footer-tools">
+          <button
+            type="button"
+            className="footer-icon"
+            onClick={this.handleRelayoutClick}
+            title={forceRelayoutTitle}>
+            <i className="fa fa-sync" />
+          </button>
+          {/* <button
+            type="button"
+            onClick={this.handleContrastClick}
+            className="footer-icon"
+            title={otherContrastModeTitle}>
+            <i className="fa fa-adjust" />
+          </button>
+          <button
+            type="button"
+            onClick={this.props.toggleTroubleshootingMenu}
+            className="footer-icon"
+            title="Open troubleshooting menu"
+            href=""
+          >
+            <i className="fa fa-bug" />
+          </button>
+          <button
+            type="button"
+            className="footer-icon"
+            onClick={this.props.toggleHelp}
+            title="Show help">
+            <i className="fa fa-question" />
+          </button> */}
+        </div>
+
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    contrastMode: state.get('contrastMode'),
+    hostname: state.get('hostname'),
+    topologyViewMode: state.get('topologyViewMode'),
+    version: state.get('version'),
+    versionUpdate: state.get('versionUpdate'),
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  {
+    clickDownloadGraph,
+    clickForceRelayout,
+    setContrastMode,
+    toggleHelp,
+    toggleTroubleshootingMenu
+  }
+)(Footer);

+ 1996 - 0
app/scripts/components/global-style.js

@@ -0,0 +1,1996 @@
+import { createGlobalStyle } from 'styled-components';
+import { transparentize } from 'polished';
+import { borderRadius, color, fontSize } from 'weaveworks-ui-components/lib/theme/selectors';
+
+import '@fortawesome/fontawesome-free/css/all.css';
+import '@fortawesome/fontawesome-free/css/v4-shims.css';
+import 'rc-slider/dist/rc-slider.css';
+
+const scopeTheme = key => props => props.theme.scope[key];
+
+const hideable = props => `
+  transition: opacity .5s ${props.theme.scope.baseEase};
+`;
+
+const palable = props => `
+  transition: all .2s ${props.theme.scope.baseEase};
+`;
+
+const blinkable = props => `
+  animation: blinking 1.5s infinite ${props.theme.scope.baseEase};
+`;
+
+const colorable = props => `
+  transition: background-color .3s ${props.theme.scope.baseEase};
+`;
+
+/* add this class to truncate text with ellipsis, container needs width */
+const truncate = () => `
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const shadow2 = props => `
+  box-shadow: 0 3px 10px ${transparentize(0.84, props.theme.colors.black)}, 0 3px 10px ${transparentize(0.77, props.theme.colors.black)};
+`;
+
+const btnOpacity = props => `
+  ${palable(props)};
+  opacity: ${props.theme.scope.btnOpacityDefault};
+  &-selected {
+    opacity: ${props.theme.scope.btnOpacitySelected};
+  }
+  &[disabled] {
+    cursor: default;
+    opacity: ${props.theme.scope.btnOpacityDisabled};
+  }
+  &:not([disabled]):hover {
+    opacity: ${props.theme.scope.btnOpacityHover};
+  }
+`;
+
+/* From https://stackoverflow.com/a/18294634 */
+const fullyPannable = () => `
+  width: 100%;
+  height: 100%;
+  /* stylelint-disable value-no-vendor-prefix */
+  /* Grabbable */
+  cursor: move; /* fallback if grab cursor is unsupported */
+  cursor: grab;
+  cursor: -moz-grab;
+  cursor: -webkit-grab;
+
+  &.panning {
+    /* Grabbing */
+    cursor: grabbing;
+    cursor: -moz-grabbing;
+    cursor: -webkit-grabbing;
+  }
+  /* stylelint-enable value-no-vendor-prefix */
+`;
+
+const overlayWrapper = props => `
+  align-items: center;
+  background-color: ${transparentize(0.1, props.theme.colors.purple25)};
+  border-radius: ${props.theme.borderRadius.soft};
+  color: ${props.theme.scope.textTertiaryColor};
+  display: flex;
+  font-size: ${props.theme.fontSizes.tiny};
+  justify-content: center;
+  padding: 5px;
+  position: fixed;
+  bottom: 11px;
+
+  button {
+    ${btnOpacity(props)};
+    background-color: transparent;
+    border: 1px solid transparent;
+    border-radius: ${props.theme.borderRadius.soft};
+    color: ${props.theme.scope.textSecondaryColor};
+    cursor: pointer;
+    line-height: 20px;
+    padding: 1px 3px;
+    outline: 0;
+
+    i {
+      font-size: ${props.theme.fontSizes.small};
+      position: relative;
+      top: 2px;
+    }
+
+    &:hover, &.selected {
+      border: 1px solid ${props.theme.scope.textTertiaryColor};
+    }
+
+    &.active {
+      & > * { ${blinkable(props)}; }
+      border: 1px solid ${props.theme.scope.textTertiaryColor};
+    }
+  }
+`;
+
+const GlobalStyle = createGlobalStyle`
+  /*
+  * Contain all the styles in the root div instead of having them truly
+  * global, so that they don't interfere with the app they're injected into.
+  */
+  .scope-app, .terminal-app {
+
+    /* Extendable classes */
+    .hideable { ${hideable}; }
+    .palable { ${palable}; }
+    .blinkable { ${blinkable}; }
+    .colorable { ${colorable}; }
+    .truncate { ${truncate}; }
+    .shadow-2 { ${shadow2}; }
+    .btn-opacity { ${btnOpacity}; }
+    .fully-pannable { ${fullyPannable}; }
+    .overlay-wrapper { ${overlayWrapper}; }
+
+    /* General styles */
+    -webkit-font-smoothing: antialiased;
+    background: ${scopeTheme('bodyBackgroundColor')};
+    bottom: 0;
+    color: ${scopeTheme('textColor')};
+    font-family: ${props => props.theme.fontFamilies.regular};
+    font-size: ${fontSize('small')};
+    height: auto;
+    left: 0;
+    line-height: 150%;
+    margin: 0;
+    overflow: auto;
+    position: fixed;
+    right: 0;
+    top: 0;
+    width: 100%;
+    a {
+      text-decoration: none;
+    }
+    .iAOmmf{
+      font-size: ${fontSize('small')};
+    }
+    * {
+      box-sizing: border-box;
+      -webkit-tap-highlight-color: transparent;
+    }
+    *:before,
+    *:after {
+      box-sizing: border-box;
+    }
+
+    p {
+      line-height: 20px;
+      padding-top: 6px;
+      margin-bottom: 14px;
+      letter-spacing: 0;
+      font-weight: 400;
+      color: ${scopeTheme('textColor')};
+    }
+
+    h2 {
+      font-size: ${fontSize('extraLarge')};
+      line-height: 40px;
+      padding-top: 8px;
+      margin-bottom: 12px;
+      font-weight: 400;
+    }
+
+    .browsehappy {
+      margin: 0.2em 0;
+      background: ${color('gray200')};
+      color: ${color('black')};
+      padding: 0.2em 0;
+    }
+
+    .hang-around {
+      transition-delay: .5s;
+    }
+
+    .overlay {
+      ${hideable};
+
+      background-color: ${color('white')};
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      top: 0;
+      left: 0;
+      opacity: 0;
+      pointer-events: none;
+      z-index: ${props => props.theme.layers.modal};
+
+      &.faded {
+        /* NOTE: Not sure if we should block the pointer events here.. */
+        pointer-events: all;
+        cursor: wait;
+        opacity: 0.5;
+      }
+    }
+
+    .hide {
+      opacity: 0;
+    }
+
+    &.time-travel-open {
+      .details-wrapper {
+        margin-top: ${scopeTheme('timelineHeight')} + 50px;
+      }
+    }
+
+    .header {
+      background-color: transparentize(${color('purple25')}, 0.2);
+      z-index: ${props => props.theme.layers.front};
+      padding: 15px 10px 0;
+      position: relative;
+      width: 100%;
+
+      .selectors {
+        display: flex;
+        position: relative;
+
+        > * {
+          flex: 1 1;
+        }
+
+        .logo {
+          margin: -16px 0 0 8px;
+          height: 64px;
+          max-width: 250px;
+          min-width: 0;
+        }
+      }
+    }
+    .nodes-wrapper{
+      height:calc(100% - 100px);
+    }
+
+    .rc-slider {
+      .rc-slider-step { cursor: pointer; }
+      .rc-slider-track { background-color: ${scopeTheme('textTertiaryColor')}; }
+      .rc-slider-rail { background-color: ${scopeTheme('borderLightColor')}; }
+      .rc-slider-handle { border-color: ${scopeTheme('textTertiaryColor')}; }
+    }
+
+    .footer {
+      ${overlayWrapper};
+      right: 43px;
+
+      &-status {
+        margin-right: 1em;
+      }
+
+      &-label, .pause-text {
+        margin: 0 0.25em;
+      }
+
+      &-versionupdate {
+        margin-right: 0.5em;
+      }
+
+      &-tools {
+        display: flex;
+      }
+
+      &-icon {
+        margin-left: 0.5em;
+      }
+
+      .tooltip-wrapper {
+        position: relative;
+
+        .tooltip {
+          display: none;
+          background-color: ${color('black')};
+          position: absolute;
+          color: ${color('white')};
+          text-align: center;
+          line-height: 22px;
+          border-radius: ${borderRadius('soft')};
+          font-size: ${fontSize('tiny')};
+          margin-bottom: 25px;
+          margin-left: -4px;
+          opacity: 0.75;
+          padding: 10px 20px;
+          bottom: 0;
+          left: 10px;
+          transform: translateX(-50%);
+          white-space: nowrap;
+          z-index: ${props => props.theme.layers.tooltip};
+
+          /* Adjusted from http://www.cssarrowplease.com/ */
+          &:after {
+            border: 6px solid transparent;
+            content: ' ';
+            top: 100%;
+            left: 50%;
+            height: 0;
+            width: 0;
+            position: absolute;
+            border-color: transparent;
+            border-top-color: ${color('black')};
+            margin-left: -6px;
+          }
+        }
+
+        &:hover .tooltip {
+          display: block;
+        }
+      }
+    }
+    .zoomable-canvas svg {
+      ${fullyPannable};
+    }
+
+    .topologies-selector {
+      margin: 0 4px;
+      display: flex;
+
+      .topologies-item {
+        margin: 0px 8px;
+
+        &-label {
+          font-size: ${fontSize('normal')};
+        }
+
+      }
+
+      .topologies-sub {
+        &-item {
+          &-label {
+            font-size: ${fontSize('small')};
+          }
+        }
+      }
+
+      .topologies-item-main,
+      .topologies-sub-item {
+        pointer-events: all;
+        color: ${scopeTheme('textSecondaryColor')};
+        ${btnOpacity};
+        cursor: pointer;
+        padding: 4px 8px;
+        border-radius: ${borderRadius('soft')};
+        opacity: 0.9;
+        margin-bottom: 3px;
+        border: 1px solid transparent;
+        white-space: nowrap;
+
+        &-active, &:hover {
+          color: ${scopeTheme('textColor')};
+          background-color: ${scopeTheme('backgroundDarkerColor')};
+        }
+
+        &-active {
+          opacity: 0.85;
+        }
+
+        &-matched {
+          border-color: ${color('blue400')};
+        }
+
+      }
+
+      .topologies-sub-item {
+        padding: 2px 8px;
+      }
+
+    }
+
+    .nodes-chart-overlay {
+      pointer-events: none;
+      opacity: ${scopeTheme('nodeElementsInBackgroundOpacity')};
+
+      &:not(.active) {
+        display: none;
+      }
+    }
+
+    .nodes-chart, .nodes-resources {
+
+      &-error, &-loading {
+        ${hideable};
+        pointer-events: none;
+        position: absolute;
+        left: 50%;
+        top: 50%;
+        margin-left: -16.5%;
+        margin-top: -275px;
+        color: ${scopeTheme('textSecondaryColor')};
+        width: 33%;
+        height: 550px;
+
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+
+        .heading {
+          font-size: ${fontSize('normal')};
+        }
+
+        &-icon {
+          text-align: center;
+          opacity: 0.25;
+          font-size: ${props => props.theme.overlayIconSize};
+        }
+
+        li { padding-top: 5px; }
+      }
+
+      /* Make watermarks blink only if actually shown (otherwise the FF performance decreses weirdly). */
+      &-loading:not(.hide) &-error-icon-container {
+        ${blinkable};
+      }
+
+      &-loading {
+        text-align: center;
+      }
+
+      svg {
+        ${hideable};
+        position: absolute;
+        top: 0px;
+      }
+
+      .logo {
+        display: none;
+      }
+
+      svg.exported {
+        .logo {
+          display: inline;
+        }
+      }
+
+      text {
+        font-family: ${props => props.theme.fontFamilies.regular};
+        fill: ${scopeTheme('textSecondaryColor')};
+      }
+
+      .nodes-chart-elements .matched-results {
+        background-color: ${scopeTheme('labelBackgroundColor')};
+      }
+
+      .edge {
+        .link-none {
+          fill: none;
+          display: none;
+        }
+        .link-storage {
+          fill: none;
+          stroke: ${scopeTheme('edgeColor')};
+          stroke-dasharray: 1, 30;
+          stroke-linecap: round;
+        }
+        .link {
+          fill: none;
+          stroke: ${scopeTheme('edgeColor')};
+        }
+        .shadow {
+          fill: none;
+          stroke: ${color('blue400')};
+          stroke-opacity: 0;
+        }
+        &.highlighted {
+          .shadow {
+            stroke-opacity: ${scopeTheme('edgeHighlightOpacity')};
+          }
+        }
+      }
+
+      .edge-marker {
+        color: ${scopeTheme('edgeColor')};
+        fill: ${scopeTheme('edgeColor')};
+      }
+    }
+
+    .matched-results {
+      text-align: center;
+
+      &-match {
+        font-size: ${fontSize('small')};
+
+        &-wrapper {
+          display: inline-block;
+          margin: 1px;
+          padding: 2px 4px;
+          background-color: transparentize(${color('blue400')}, 0.9);
+        }
+
+        &-label {
+          color: ${scopeTheme('textSecondaryColor')};
+          margin-right: 0.5em;
+        }
+      }
+
+      &-more {
+        font-size: ${fontSize('tiny')};
+        color: ${color('blue700')};
+        margin-top: -2px;
+      }
+    }
+
+    .details {
+      &-wrapper {
+        position: fixed;
+        display: flex;
+        z-index: ${props => props.theme.layers.toolbar};
+        right: ${scopeTheme('detailsWindowPaddingLeft')};
+        top: 100px;
+        bottom: 48px;
+        transition: transform 0.33333s cubic-bezier(0,0,0.21,1), margin-top .15s ${scopeTheme('baseEase')};
+      }
+    }
+
+    .node-details {
+      height: 100%;
+      width: ${scopeTheme('detailsWindowWidth')};
+      display: flex;
+      flex-flow: column;
+      margin-bottom: 12px;
+      padding-bottom: 2px;
+      border-radius: ${borderRadius('soft')};
+      background-color: ${color('white')};
+      ${shadow2};
+      /* keep node-details above the terminal. */
+      z-index: ${props => props.theme.layers.front};
+      overflow: hidden;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+
+      &-tools-wrapper {
+        position: relative;
+      }
+
+
+      &-tools {
+        position: absolute;
+        top: 6px;
+        right: 8px;
+
+        .close-details {
+          position: relative;
+          z-index: ${props => props.theme.layers.front};
+        }
+
+        > i {
+          ${btnOpacity};
+          padding: 4px 5px;
+          margin-left: 2px;
+          font-size: ${fontSize('normal')};
+          color: ${color('white')};
+          cursor: pointer;
+          border: 1px solid transparent;
+          border-radius: ${borderRadius('soft')};
+
+          span {
+            font-family: ${props => props.theme.fontFamilies.regular};
+            font-size: ${fontSize('small')};
+            margin-left: 4px;
+
+            span {
+              text-transform: capitalize;
+              font-size: ${fontSize('normal')};
+              margin-left: 0;
+            }
+          }
+
+          &:hover {
+            border-color: ${props => transparentize(0.4, props.theme.colors.white)};
+          }
+        }
+      }
+
+      .match {
+        background-color: ${props => transparentize(0.7, props.theme.colors.blue400)};
+        border: 1px solid ${color('blue400')};
+      }
+
+      &-header {
+        ${colorable};
+
+        &-wrapper {
+          padding: 12px 24px;
+        }
+
+        &-label {
+          color: ${color('white')};
+          margin: 0;
+          width: 348px;
+          padding-top: 0;
+        }
+
+        .details-tools {
+          position: absolute;
+          top: 16px;
+          right: 24px;
+        }
+
+        &-notavailable {
+          background-color: ${scopeTheme('backgroundDarkColor')};
+        }
+
+      }
+
+      &-relatives {
+        margin-top: 4px;
+        font-size: ${fontSize('normal')};
+        color: ${color('white')};
+
+        &-link {
+          ${truncate};
+          ${btnOpacity};
+          display: inline-block;
+          margin-right: 0.5em;
+          cursor: pointer;
+          text-decoration: underline;
+          opacity: ${scopeTheme('linkOpacityDefault')};
+          max-width: 12em;
+        }
+
+        &-more {
+          ${btnOpacity};
+          padding: 0 2px;
+          cursor: pointer;
+          font-size: ${fontSize('tiny')};
+          font-weight: bold;
+          display: inline-block;
+          position: relative;
+          top: -5px;
+        }
+      }
+
+      &-controls {
+        white-space: nowrap;
+        padding: 8px 0;
+        font-size: ${fontSize('small')};
+
+        &-wrapper {
+          padding: 0 36px 0 32px;
+        }
+
+        .node-control-button {
+          color: ${color('white')};
+        }
+
+        &-spinner {
+          ${hideable};
+          color: ${color('white')};
+          margin-left: 8px;
+        }
+
+        &-error {
+          ${truncate};
+          float: right;
+          width: 55%;
+          padding-top: 6px;
+          text-align: left;
+          color: ${color('white')};
+
+          &-icon {
+            ${blinkable};
+            margin-right: 0.5em;
+          }
+        }
+      }
+
+      &-content {
+        flex: 1;
+        padding: 0 24px 0 24px;
+        overflow-y: auto;
+
+        &-loading {
+          margin-top: 48px;
+          text-align: center;
+          font-size: ${fontSize('huge')};
+          color: ${scopeTheme('textTertiaryColor')};
+        }
+
+        &-section {
+          margin: 16px 0;
+
+          &-header {
+            font-size: ${fontSize('normal')};
+            color: ${scopeTheme('textTertiaryColor')};
+            margin-bottom: 20px;
+          }
+        }
+      }
+
+      &-health {
+
+        &-wrapper {
+          display: flex;
+          justify-content: space-around;
+          align-content: center;
+          text-align: center;
+          flex-wrap: wrap;
+        }
+
+        &-overflow {
+          ${btnOpacity};
+          flex-basis: 33%;
+          display: flex;
+          flex-direction: row;
+          flex-wrap: wrap;
+          align-items: center;
+          opacity: 0.85;
+          cursor: pointer;
+          position: relative;
+          padding-bottom: 16px;
+
+          &-item {
+            padding: 4px 8px;
+            line-height: 1.2;
+            flex-basis: 48%;
+
+            &-value {
+              color: ${scopeTheme('textSecondaryColor')};
+              font-size: ${fontSize('normal')};
+            }
+
+            &-label {
+              color: ${scopeTheme('textSecondaryColor')};
+              font-size: ${fontSize('tiny')};
+            }
+          }
+        }
+
+        &-item {
+          padding: 8px 16px;
+          width: 33%;
+          display: flex;
+          flex-direction: column;
+          flex-grow: 1;
+
+          &-label {
+            color: ${scopeTheme('textSecondaryColor')};
+            font-size: ${fontSize('small')};
+          }
+
+          &-sparkline {
+            margin-top: auto;
+          }
+
+          &-placeholder {
+            font-size: ${fontSize('large')};
+            opacity: 0.2;
+            margin-bottom: 0.2em;
+          }
+        }
+
+        &-link-item {
+          ${btnOpacity};
+          cursor: pointer;
+          opacity: ${scopeTheme('linkOpacityDefault')};
+          width: 33%;
+          display: flex;
+          color: inherit;
+
+          .node-details-health-item {
+            width: auto;
+          }
+        }
+      }
+
+      &-info {
+
+        &-field {
+          display: flex;
+          align-items: baseline;
+
+          &-label {
+            text-align: right;
+            width: 30%;
+            color: ${scopeTheme('textSecondaryColor')};
+            padding: 0 0.5em 0 0;
+            white-space: nowrap;
+            font-size: ${fontSize('small')};
+
+            &::after {
+              content: ':';
+            }
+          }
+
+          &-value {
+            font-size: ${fontSize('small')};
+            flex: 1;
+            /* Now required (from chrome 48) to get overflow + flexbox behaving: */
+            min-width: 0;
+            color: ${scopeTheme('textColor')};
+          }
+        }
+      }
+
+      &-property-list {
+        &-controls {
+          margin-left: -4px;
+        }
+
+        &-field {
+          display: flex;
+          align-items: baseline;
+
+          &-label {
+            text-align: right;
+            width: 50%;
+            color: ${scopeTheme('textSecondaryColor')};
+            padding: 0 0.5em 0 0;
+            white-space: nowrap;
+            font-size: ${fontSize('small')};
+
+            &::after {
+              content: ':';
+            }
+          }
+
+          &-value {
+            font-size: ${fontSize('small')};
+            flex: 1;
+            /* Now required (from chrome 48) to get overflow + flexbox behaving: */
+            min-width: 0;
+            color: ${scopeTheme('textColor')};
+          }
+        }
+      }
+
+      &-generic-table {
+        width: 100%;
+
+        tr {
+          display: flex;
+          th, td {
+            padding: 0 5px;
+          }
+        }
+      }
+
+      &-table {
+        width: 100%;
+        border-spacing: 0;
+        /* need fixed for truncating, but that does not extend wide columns dynamically */
+        table-layout: fixed;
+
+        &-wrapper {
+          margin: 24px -4px;
+        }
+
+        &-header {
+          color: ${scopeTheme('textTertiaryColor')};
+          font-size: ${fontSize('small')};
+          text-align: right;
+          padding: 0;
+
+          .node-details-table-header-sortable {
+            padding: 3px 4px;
+            cursor: pointer;
+          }
+
+          &-sorted {
+            color: ${scopeTheme('textSecondaryColor')};
+          }
+
+          &-sorter {
+            margin: 0 0.35em;
+          }
+
+          &:first-child {
+            margin-right: 0;
+            text-align: left;
+          }
+        }
+
+        tbody {
+          position: relative;
+
+          .min-height-constraint {
+            position: absolute;
+            width: 0 !important;
+            opacity: 0;
+            top: 0;
+          }
+        }
+
+        &-node {
+          font-size: ${fontSize('small')};
+          line-height: 1.5;
+
+          &:hover, &.selected {
+            background-color: ${color('white')};
+          }
+
+          > * {
+            padding: 0 4px;
+          }
+
+          &-link {
+            ${btnOpacity};
+            text-decoration: underline;
+            cursor: pointer;
+            opacity: ${scopeTheme('linkOpacityDefault')};
+            color: ${scopeTheme('textColor')};
+          }
+
+          &-value, &-metric {
+            flex: 1;
+            margin-left: 0.5em;
+            text-align: right;
+          }
+
+          &-metric-link {
+            ${btnOpacity};
+            text-decoration: underline;
+            cursor: pointer;
+            opacity: ${scopeTheme('linkOpacityDefault')};
+            color: ${scopeTheme('textColor')};
+          }
+
+          &-value-scalar {
+            /* width: 2em; */
+            text-align: right;
+            margin-right: 0.5em;
+          }
+
+          &-value-minor,
+          &-value-unit {
+            font-size: ${fontSize('small')};
+            color: ${scopeTheme('textSecondaryColor')};
+          }
+        }
+      }
+    }
+
+
+
+    .node-resources {
+      &-metric-box {
+        ${palable};
+        cursor: pointer;
+        fill: ${props => transparentize(0.6, props.theme.colors.gray600)};
+
+        &-info {
+          background-color: ${props => transparentize(0.4, props.theme.colors.white)};
+          border-radius: ${borderRadius('soft')};
+          cursor: inherit;
+          padding: 5px;
+
+          .wrapper {
+            display: block;
+
+            &.label { font-size: ${fontSize('small')}; }
+            &.consumption { font-size: ${fontSize('tiny')}; }
+          }
+        }
+      }
+
+      &-layer-topology {
+        background-color: ${props => transparentize(0.05, props.theme.colors.gray50)};
+        border-radius: ${borderRadius('soft')};
+        border: 1px solid ${color('gray200')};
+        color: ${scopeTheme('textTertiaryColor')};
+        font-size: ${fontSize('normal')};
+        font-weight: bold;
+        padding-right: 20px;
+        text-align: right;
+        text-transform: capitalize;
+      }
+    }
+
+    /* This part sets the styles only for the 'real' node details table, not applying
+    them to the nodes grid, because there we control hovering from the JS.
+    NOTE: Maybe it would be nice to separate the class names between the two places
+    where node tables are used - i.e. it doesn't make sense that node-details-table
+    can also refer to the tables in the nodes grid. */
+    .details-wrapper .node-details-table {
+      &-node {
+        &:hover, &.selected {
+          background-color: ${color('white')};
+        }
+      }
+    }
+
+    .node-control-button {
+      ${btnOpacity};
+      padding: 6px;
+      margin-left: 2px;
+      font-size: ${fontSize('small')};
+      color: ${scopeTheme('textSecondaryColor')};
+      cursor: pointer;
+      border: 1px solid transparent;
+      border-radius: ${borderRadius('soft')};
+      &:hover {
+        border-color: ${props => transparentize(0.4, props.theme.colors.white)};
+      }
+      &-pending, &-pending:hover {
+        opacity: 0.2;
+        border-color: transparent;
+        cursor: not-allowed;
+      }
+    }
+
+    .terminal {
+
+      &-app {
+        display: flex;
+        flex-flow: column;
+      }
+
+      &-embedded {
+        position: relative;
+        /* shadow of animation-wrapper is 10px, let it fit in here without being
+        overflow hiddened. */
+        flex: 1;
+        overflow-x: hidden;
+      }
+
+      &-animation-wrapper {
+        position: absolute;
+        /* some room for the drop shadow. */
+        top: 10px;
+        left: 10px;
+        bottom: 10px;
+        right: 0;
+        transition: transform 0.5s cubic-bezier(0.230, 1.000, 0.320, 1.000);
+        ${shadow2};
+      }
+
+      &-wrapper {
+        width: 100%;
+        height: 100%;
+        border: 0px solid ${color('black')};
+        color: ${color('gray50')};
+      }
+
+      &-header {
+        ${truncate};
+        color: ${color('white')};
+        height: ${scopeTheme('terminalHeaderHeight')};
+        padding: 8px 24px;
+        background-color: ${scopeTheme('textColor')};
+        position: relative;
+        font-size: ${fontSize('small')};
+        line-height: 28px;
+        border-top-left-radius: ${borderRadius('soft')};
+
+        &-title {
+          cursor: default;
+        }
+
+        &-tools {
+          position: absolute;
+          right: 8px;
+          top: 6px;
+
+          &-item, &-item-icon {
+            ${palable};
+            padding: 4px 5px;
+            color: ${color('white')};
+            cursor: pointer;
+            opacity: 0.7;
+            border: 1px solid transparent;
+            border-radius: ${borderRadius('soft')};
+
+            font-size: ${fontSize('tiny')};
+            font-weight: bold;
+
+            &:hover {
+              opacity: 1;
+              border-color: ${props => transparentize(0.4, props.theme.colors.white)};
+            }
+          }
+
+          &-item-icon {
+            font-size: ${fontSize('normal')};
+          }
+        }
+      }
+
+      &-embedded { .terminal-inner { top: ${scopeTheme('terminalHeaderHeight')}; } }
+      &-inner {
+        cursor: text;
+        font-family: ${props => props.theme.fontFamilies.monospace};
+        position: absolute;
+        bottom: 0;
+        left: 0;
+        right: 0;
+        top: 0;
+        background-color: ${color('black')};
+        padding: 8px;
+        box-sizing: content-box;
+        border-bottom-left-radius: ${borderRadius('soft')};
+
+        .terminal {
+          background-color: transparent !important;
+        }
+      }
+
+      &-status-bar {
+        font-family: ${props => props.theme.fontFamilies.regular};
+        position: absolute;
+        bottom: 16px;
+        right: 16px;
+        width: 50%;
+        padding: 16px 16px;
+        opacity: 0.8;
+        border-radius: ${borderRadius('soft')};
+
+        h3 {
+          margin: 4px 0;
+        }
+
+        &-message {
+          margin: 4px 0;
+          color: ${color('white')};
+        }
+
+        .link {
+          font-weight: bold;
+          cursor: pointer;
+          float: right;
+        }
+      }
+
+      &-cursor {
+        color: ${color('black')};
+        background: ${color('gray50')};
+      }
+    }
+
+    .terminal-inactive .terminal-cursor {
+      visibility: hidden;
+    }
+
+    .metric {
+      &-unit {
+        padding-left: 0.25em;
+      }
+    }
+
+    .show-more {
+      ${btnOpacity};
+      border-top: 1px dotted ${scopeTheme('borderLightColor')};
+      padding: 0px 0;
+      margin-top: 4px;
+      text-align: right;
+      cursor: pointer;
+      color: ${scopeTheme('textSecondaryColor')};
+      font-size: ${fontSize('small')};
+
+      &-icon {
+        color: ${scopeTheme('textTertiaryColor')};
+        font-size: ${fontSize('normal')};
+        position: relative;
+        top: 1px;
+      }
+    }
+
+    .plugins {
+      margin-right: 0.5em;
+
+      &-label {
+        margin-right: 0.25em;
+      }
+
+      &-plugin {
+        cursor: default;
+      }
+
+      &-plugin + &-plugin:before {
+        content: ', ';
+      }
+
+      &-plugin-icon {
+        top: 1px;
+        position: relative;
+        font-size: ${fontSize('large')};
+        margin-right: 2px;
+      }
+
+      .error {
+        animation: blinking 2.0s 60 ${scopeTheme('baseEase')}; /* blink for 2 minutes */
+        color: ${scopeTheme('textSecondaryColor')};
+      }
+
+      &-empty {
+        opacity: ${scopeTheme('textSecondaryColor')};
+      }
+    }
+
+    .status {
+      padding: 2px 12px;
+      background-color: ${scopeTheme('bodyBackgroundColor')};
+      border-radius: ${borderRadius('soft')};
+      color: ${scopeTheme('textSecondaryColor')};
+      display: inline-block;
+      opacity: 0.9;
+
+      &-icon {
+        font-size: ${fontSize('normal')};
+        position: relative;
+        top: 0.125rem;
+        margin-right: 0.25rem;
+      }
+
+      &.status-loading {
+        animation: blinking 2.0s 150 ${scopeTheme('baseEase')}; /* keep blinking for 5 minutes */
+        text-transform: none;
+        color: ${scopeTheme('textColor')};
+      }
+    }
+
+    .topology-option, .metric-selector, .network-selector, .view-mode-selector, .time-control {
+      font-size: ${fontSize('normal')};
+      color: ${scopeTheme('textSecondaryColor')};
+      margin-bottom: 6px;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+
+      i {
+        font-size: ${fontSize('tiny')};
+        margin-left: 4px;
+        color: ${color('orange500')};
+      }
+
+      &-wrapper {
+        pointer-events: all;
+        border-radius: ${borderRadius('soft')};
+        border: 1px solid ${scopeTheme('backgroundDarkerColor')};
+        display: inline-block;
+        white-space: nowrap;
+      }
+
+      &-action {
+        ${btnOpacity};
+        padding: 3px 12px;
+        cursor: pointer;
+        display: inline-block;
+        background-color: ${scopeTheme('backgroundColor')};
+
+        &-selected, &:not([disabled]):hover {
+          color: ${scopeTheme('textDarkerColor')};
+          background-color: ${scopeTheme('backgroundDarkerColor')};
+        }
+
+        &:first-child {
+          border-left: none;
+          border-top-left-radius: ${borderRadius('soft')};
+          border-bottom-left-radius: ${borderRadius('soft')};
+        }
+
+        &:last-child {
+          border-top-right-radius: ${borderRadius('soft')};
+          border-bottom-right-radius: ${borderRadius('soft')};
+        }
+      }
+    }
+
+    .metric-selector {
+      font-size: ${fontSize('small')};
+      margin-top: 6px;
+    }
+
+    .view-mode-selector, .time-control {
+      margin-left: 20px;
+
+      &-wrapper {
+        pointer-events: all;
+        border-color: ${scopeTheme('backgroundDarkerSecondaryColor')};
+        overflow: hidden;
+      }
+
+      &:first-child,
+      &:last-child {
+        .view-mode-selector-action {
+          border-radius: ${borderRadius('none')};
+        }
+      }
+
+      &-action {
+        background-color: transparent;
+
+        &-selected, &:not([disabled]):hover {
+          background-color: ${scopeTheme('backgroundDarkerColor')};
+        }
+
+        &:not(:last-child) {
+          border-right: 1px solid ${scopeTheme('backgroundDarkerSecondaryColor')};
+        }
+      }
+    }
+
+    .time-control {
+      margin-right: 20px;
+
+      &-controls {
+        align-items: center;
+        justify-content: flex-end;
+        display: flex;
+      }
+
+      &-spinner {
+        display: inline-block;
+        margin-right: 15px;
+        margin-top: 3px;
+
+        i {
+          color: ${scopeTheme('textSecondaryColor')};
+          font-size: ${fontSize('normal')};
+        }
+      }
+
+      &-info {
+        ${blinkable};
+        display: block;
+        margin-top: 5px;
+        text-align: right;
+        pointer-events: all;
+        font-size: ${fontSize('small')};
+      }
+    }
+
+    .topology-option {
+      &-wrapper {
+        display: inline-flex;
+        flex-wrap: wrap;
+        overflow: hidden;
+        max-height: 27px;
+        transition: max-height 0.5s 0s ${scopeTheme('baseEase')};
+
+        .topology-option-action {
+          flex: 1 1 auto;
+          text-align: center;
+        }
+      }
+
+        &:last-child :hover {
+        height: auto;
+        max-height: calc((13px * 1.5 + 3px + 3px) * 8); /* expand to display 8 rows */
+        overflow: auto;
+        transition: max-height 0.5s 0s ${scopeTheme('baseEase')};
+      }
+
+      font-size: ${fontSize('small')};
+
+      &-action {
+        &-selected {
+          cursor: default;
+        }
+      }
+    }
+
+    .view-mode-selector-wrapper, .time-control-wrapper {
+      .label { margin-left: 4px; }
+      i {
+        margin-left: 0;
+        color: ${scopeTheme('textSecondaryColor')};
+      }
+    }
+
+    .network-selector-action {
+      border-top: 3px solid transparent;
+      border-bottom: 3px solid ${scopeTheme('backgroundDarkColor')};
+    }
+
+    .warning {
+      display: inline-block;
+      cursor: pointer;
+      border: 1px dashed transparent;
+      text-transform: none;
+      border-radius: ${borderRadius('soft')};
+      margin-left: 4px;
+
+      &-wrapper {
+        display: flex;
+      }
+
+      &-text {
+        display: inline-block;
+        color: ${scopeTheme('textSecondaryColor')};
+        padding-left: 0.5em;
+      }
+
+      &-icon {
+        ${btnOpacity};
+      }
+
+      &-expanded {
+        margin-left: 0;
+        padding: 2px 4px;
+        border-color: ${scopeTheme('textTertiaryColor')};
+      }
+
+      &-expanded &-icon {
+        position: relative;
+        top: 4px;
+        left: 2px;
+      }
+
+    }
+
+    .sidebar {
+      position: fixed;
+      bottom: 11px;
+      left: 11px;
+      padding: 5px;
+      font-size: ${fontSize('small')};
+      border-radius: ${borderRadius('soft')};
+      border: 1px solid transparent;
+      pointer-events: none;
+      max-width: 50%;
+    }
+
+    .sidebar-gridmode {
+      background-color: ${color('purple50')};
+      border-color: ${scopeTheme('backgroundDarkerColor')};
+      opacity: 0.9;
+    }
+
+    .search {
+      &-wrapper {
+        margin: 0 8px;
+        min-width: 160px;
+        text-align: right;
+      }
+    }
+
+    @keyframes blinking {
+      0%, 50%, 100% {
+        opacity: 1.0;
+      } 25% {
+        opacity: 0.5;
+      }
+    }
+
+    /*
+    Help panel!
+    */
+
+    .help-panel {
+      z-index: ${props => props.theme.layers.modal};
+      background-color: ${color('white')};
+      ${shadow2};
+      display: flex;
+      position: relative;
+
+      &-wrapper {
+        position: absolute;
+        width: 100%;
+        height: 100%;
+
+        display: flex;
+        justify-content: center;
+        align-items: flex-start;
+      }
+
+      &-header {
+        background-color: ${color('blue400')};
+        padding: 12px 24px;
+        color: ${color('white')};
+
+        h2 {
+          margin: 0;
+          font-size: ${fontSize('large')};
+        }
+      }
+
+      &-tools {
+        position: absolute;
+        top: 6px;
+        right: 8px;
+
+        i {
+          ${btnOpacity};
+          padding: 4px 5px;
+          margin-left: 2px;
+          font-size: ${fontSize('normal')};
+          color: ${color('purple400')};
+          cursor: pointer;
+          border: 1px solid transparent;
+          border-radius: ${borderRadius('soft')};
+
+          &:hover {
+            border-color: ${props => transparentize(0.4, props.theme.colors.purple400)};
+          }
+        }
+
+      }
+
+      &-main {
+        display: flex;
+        padding: 12px 36px 36px 36px;
+        flex-direction: row;
+        align-items: stretch;
+
+        h2 {
+          line-height: 150%;
+          font-size: ${fontSize('large')};
+          color: ${color('purple400')};
+          padding: 4px 0;
+          border-bottom: 1px solid ${props => transparentize(0.9, props.theme.colors.purple400)};
+        }
+
+        h3 {
+          font-size: ${fontSize('normal')};
+          color: ${color('purple400')};
+          padding: 4px 0;
+        }
+
+        p {
+          margin: 0;
+        }
+      }
+
+      &-shortcuts {
+        margin-right: 36px;
+
+        &-shortcut {
+          kbd {
+            display: inline-block;
+            padding: 3px 5px;
+            font-size: ${fontSize('tiny')};
+            line-height: 10px;
+            color: ${color('gray600')};
+            vertical-align: middle;
+            background-color: ${color('white')};
+            border: solid 1px ${color('gray200')};
+            border-bottom-color: ${color('gray600')};
+            border-radius: ${borderRadius('soft')};
+            box-shadow: inset 0 -1px 0 ${color('gray600')};
+          }
+          div.key {
+            width: 60px;
+            display: inline-block;
+          }
+          div.label {
+            display: inline-block;
+          }
+        }
+      }
+
+      &-search {
+        margin-right: 36px;
+
+        &-row {
+          display: flex;
+          flex-direction: row;
+
+          &-term {
+            flex: 1;
+            color: ${scopeTheme('textSecondaryColor')};
+            i {
+              margin-right: 5px;
+            }
+          }
+
+          &-term-label {
+            flex: 1;
+            b {
+              color: ${scopeTheme('textSecondaryColor')};
+            }
+          }
+        }
+      }
+
+      &-fields {
+        display: flex;
+        flex-direction: column;
+
+        &-current-topology {
+          color: ${color('purple400')};
+        }
+
+        &-fields {
+          display: flex;
+          align-items: stretch;
+
+          &-column {
+            display: flex;
+            flex-direction: column;
+            flex: 1;
+            margin-right: 12px;
+
+            &-content {
+              overflow: auto;
+              /* 160px for top and bottom margins and the rest of the help window
+              is about 160px too.
+              Notes: Firefox gets a bit messy if you try and bubble
+              heights + overflow up (min-height issue + still doesn't work v.well),
+              so this is a bit of a hack. */
+              max-height: calc(100vh - 160px - 160px - 160px);
+            }
+          }
+        }
+      }
+    }
+
+    /*
+    Zoom control
+    */
+
+    .zoom-control {
+      ${overlayWrapper};
+      flex-direction: column;
+      padding: 5px 7px 0;
+      bottom: 50px;
+      right: 40px;
+
+      a:hover { border-color: transparent; }
+
+      .rc-slider {
+        margin: 5px 0;
+        height: 60px;
+      }
+    }
+
+    /*
+    Debug panel!
+    */
+
+    .debug-panel {
+      ${shadow2};
+      background-color: ${color('white')};
+      top: 80px;
+      position: absolute;
+      padding: 10px;
+      left: 10px;
+      z-index: ${props => props.theme.layers.modal};
+
+      opacity: 0.3;
+
+      &:hover {
+        opacity: 1;
+      }
+
+      table {
+        display: inline-block;
+        border-collapse: collapse;
+        margin: 4px 2px;
+
+        td {
+          width: 10px;
+          height: 10px;
+        }
+      }
+    }
+
+    /*
+    Nodes grid.
+    */
+
+    .nodes-grid {
+      tr {
+        border-radius: ${borderRadius('soft')};
+      }
+
+      &-label-minor {
+        opacity: 0.7;
+      }
+
+      &-id-column {
+        margin: -3px -4px;
+        padding: 2px 4px;
+        display: flex;
+        div {
+          flex: 1;
+        }
+      }
+
+      .node-details-table-wrapper-wrapper {
+
+        flex: 1;
+        display: flex;
+        flex-direction: row;
+        width: 100%;
+
+        .node-details-table-wrapper {
+          margin: 0;
+          flex: 1;
+        }
+
+        .nodes-grid-graph {
+          position: relative;
+          margin-top: 24px;
+        }
+
+        .node-details-table-node > * {
+          padding: 3px 4px;
+        }
+
+        /* Keeping the row height fixed is important for locking the rows on hover. */
+        .node-details-table-node, thead tr {
+          height: 28px;
+        }
+
+        tr:nth-child(even) {
+          background: ${color('gray100')};
+        }
+
+        tbody tr {
+          border: 1px solid transparent;
+          border-radius: ${borderRadius('soft')};
+          cursor: pointer;
+        }
+
+        /* We fully control hovering of the grid rows from JS,
+        because we want consistent behaviour between the
+        visual and row locking logic that happens on hover. */
+        tbody tr.selected, tbody tr.focused {
+          background-color: ${props => transparentize(0.85, props.theme.colors.blue400)};
+          border: 1px solid ${color('blue400')};
+        }
+      }
+
+      .scroll-body {
+
+        table {
+          border-bottom: 1px solid ${color('gray200')};
+        }
+
+        thead, tbody tr {
+          display: table;
+          width: 100%;
+          table-layout: fixed;
+        }
+
+        tbody:after {
+          content: '';
+          display: block;
+          /* height of the controls so you can scroll the last row up above them
+          and have a good look. */
+          height: 140px;
+        }
+
+        thead {
+          box-shadow: 0 4px 2px -2px ${props => transparentize(0.84, props.theme.colors.black)};
+          border-bottom: 1px solid ${color('gray600')};
+        }
+
+        tbody {
+          display: block;
+          overflow-y: scroll;
+        }
+      }
+    }
+
+    .troubleshooting-menu {
+      display: flex;
+      position: relative;
+
+      &-wrapper {
+        height: 100%;
+        width: 100%;
+        align-items: center;
+        display: flex;
+        justify-content: center;
+        position: absolute;
+      }
+
+      &-content {
+        position: relative;
+        background-color: ${color('white')};
+        padding: 20px;
+        ${shadow2};
+        z-index: ${props => props.theme.layers.modal};
+      }
+
+      &-item {
+        height: 40px;
+        .soft { opacity: 0.6; }
+      }
+
+      button {
+        border: 0;
+        background-color: transparent;
+        cursor: pointer;
+        padding: 0;
+        outline: 0;
+      }
+
+      button, a {
+        color: ${color('purple900')};
+
+        &:hover {
+          color: ${scopeTheme('textColor')};
+        }
+      }
+
+      i {
+        width: 20px;
+        text-align: center;
+        margin-right: 10px;
+      }
+
+      .fa.fa-times {
+        width: 25px;
+      }
+    }
+
+    @media (max-width: 1330px) {
+      .view-mode-selector .label { display: none; }
+    }
+
+      .ant-drawer-content-wrapper{
+        position: fixed !important;
+        display: flex!important ;
+        transition: transform 0.33333s cubic-bezier(0, 0, 0.21, 1) 0s, margin-top 0.15s ease-in-out 0s !important;
+        transform: translateX(0px) !important;
+      }
+
+
+  }
+
+
+  /**
+  * Copyright (c) 2014 The xterm.js authors. All rights reserved.
+  * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
+  * https://github.com/chjj/term.js
+  * @license MIT
+  *
+  * Permission is hereby granted, free of charge, to any person obtaining a copy
+  * of this software and associated documentation files (the "Software"), to deal
+  * in the Software without restriction, including without limitation the rights
+  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  * copies of the Software, and to permit persons to whom the Software is
+  * furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+  * THE SOFTWARE.
+  *
+  * Originally forked from (with the author's permission):
+  *   Fabrice Bellard's javascript vt100 for jslinux:
+  *   http://bellard.org/jslinux/
+  *   Copyright (c) 2011 Fabrice Bellard
+  *   The original design remains. The terminal itself
+  *   has been extended to include xterm CSI codes, among
+  *   other features.
+  */
+
+  /**
+  *  Default styles for xterm.js
+  */
+
+  .xterm {
+      font-family: ${props => props.theme.fontFamilies.monospace};
+      font-feature-settings: "liga" 0;
+      position: relative;
+      user-select: none;
+      /* stylelint-disable property-no-vendor-prefix */
+      -ms-user-select: none;
+      -webkit-user-select: none;
+      /* stylelint-enable property-no-vendor-prefix */
+  }
+
+  .xterm.focus,
+  .xterm:focus {
+      outline: none;
+  }
+
+  .xterm .xterm-helpers {
+      position: absolute;
+      top: 0;
+      /**
+      * The z-index of the helpers must be higher than the canvases in order for
+      * IMEs to appear on top.
+      */
+      /* stylelint-disable sh-waqar/declaration-use-variable */
+      z-index: 10;
+      /* stylelint-enable sh-waqar/declaration-use-variable */
+  }
+
+  .xterm .xterm-helper-textarea {
+      /*
+      * HACK: to fix IE's blinking cursor
+      * Move textarea out of the screen to the far left, so that the cursor is not visible.
+      */
+      position: absolute;
+      opacity: 0;
+      left: -9999em;
+      top: 0;
+      width: 0;
+      height: 0;
+      /* stylelint-disable sh-waqar/declaration-use-variable */
+      z-index: -10;
+      /* stylelint-enable sh-waqar/declaration-use-variable */
+      /** Prevent wrapping so the IME appears against the textarea at the correct position */
+      white-space: nowrap;
+      overflow: hidden;
+      resize: none;
+  }
+
+  .xterm .composition-view {
+      /* TODO: Composition position got messed up somewhere */
+      background: ${color('black')};
+      color: ${color('white')};
+      display: none;
+      position: absolute;
+      white-space: nowrap;
+      z-index: ${props => props.theme.layers.front};
+  }
+
+  .xterm .composition-view.active {
+      display: block;
+  }
+
+  .xterm .xterm-viewport {
+      /* On OS X this is required in order for the scroll bar to appear fully opaque */
+      background-color: ${color('black')};
+      overflow-y: scroll;
+      cursor: default;
+      position: absolute;
+      right: 0;
+      left: 0;
+      top: 0;
+      bottom: 0;
+  }
+
+  .xterm .xterm-screen {
+      position: relative;
+  }
+
+  .xterm .xterm-screen canvas {
+      position: absolute;
+      left: 0;
+      top: 0;
+  }
+
+  .xterm .xterm-scroll-area {
+      visibility: hidden;
+  }
+
+  .xterm-char-measure-element {
+      display: inline-block;
+      visibility: hidden;
+      position: absolute;
+      top: 0;
+      left: -9999em;
+      line-height: normal;
+  }
+
+  .xterm.enable-mouse-events {
+      /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
+      cursor: default;
+  }
+
+  .xterm:not(.enable-mouse-events) {
+      cursor: text;
+  }
+
+  .xterm .xterm-accessibility,
+  .xterm .xterm-message {
+      position: absolute;
+      left: 0;
+      top: 0;
+      bottom: 0;
+      right: 0;
+      /* stylelint-disable sh-waqar/declaration-use-variable */
+      z-index: 100;
+      /* stylelint-enable sh-waqar/declaration-use-variable */
+      color: transparent;
+  }
+
+  .xterm .live-region {
+      position: absolute;
+      left: -9999px;
+      width: 1px;
+      height: 1px;
+      overflow: hidden;
+  }
+
+  .xterm-cursor-pointer {
+      cursor: pointer;
+  }
+`;
+
+export default GlobalStyle;

+ 232 - 0
app/scripts/components/help-panel.js

@@ -0,0 +1,232 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { searchableFieldsSelector } from '../selectors/search';
+import { canvasMarginsSelector } from '../selectors/canvas';
+import { hideHelp } from '../actions/app-actions';
+
+
+const GENERAL_SHORTCUTS = [
+  { key: 'esc', label: 'Close active panel' },
+  { key: '/', label: 'Activate search field' },
+  { key: '?', label: 'Toggle shortcut menu' },
+  { key: 'g', label: 'Switch to Graph view' },
+  { key: 't', label: 'Switch to Table view' },
+  { key: 'r', label: 'Switch to Resources view' },
+];
+
+
+const CANVAS_METRIC_SHORTCUTS = [
+  { key: '<', label: 'Select and pin previous metric' },
+  { key: '>', label: 'Select and pin next metric' },
+  { key: 'q', label: 'Unpin current metric' },
+];
+
+
+function renderShortcuts(cuts) {
+  return (
+    <div>
+      {cuts.map(({ key, label }) => (
+        <div key={key} className="help-panel-shortcuts-shortcut">
+          <div className="key"><kbd>{key}</kbd></div>
+          <div className="label">{label}</div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+
+function renderShortcutPanel() {
+  return (
+    <div className="help-panel-shortcuts">
+      <h2>Shortcuts</h2>
+      <h3>General</h3>
+      {renderShortcuts(GENERAL_SHORTCUTS)}
+      <h3>Canvas Metrics</h3>
+      {renderShortcuts(CANVAS_METRIC_SHORTCUTS)}
+    </div>
+  );
+}
+
+
+const BASIC_SEARCHES = [
+  { label: 'All fields for foo', term: 'foo' },
+  {
+    label: (
+      <span>
+        Any field matching
+        <b>pid</b>
+        {' '}
+        for the value 12345
+      </span>
+    ),
+    term: 'pid: 12345'
+  },
+];
+
+
+const REGEX_SEARCHES = [
+  {
+    label: 'All fields for foo or bar',
+    term: 'foo|bar'
+  },
+  {
+    label: (
+      <span>
+        <b>command</b>
+        {' '}
+        field for foobar or foobaz
+      </span>
+    ),
+    term: 'command: foo(bar|baz)'
+  },
+];
+
+
+const METRIC_SEARCHES = [
+  {
+    label: (
+      <span>
+        <b>CPU</b>
+        {' '}
+        greater than 4%
+      </span>
+    ),
+    term: 'cpu > 4%'
+  },
+  {
+    label: (
+      <span>
+        <b>Memory</b>
+        {' '}
+        less than 10 megabytes
+      </span>
+    ),
+    term: 'memory < 10mb'
+  },
+];
+
+
+function renderSearches(searches) {
+  return (
+    <div>
+      {searches.map(({ term, label }) => (
+        <div key={term} className="help-panel-search-row">
+          <div className="help-panel-search-row-term">
+            <i className="fa fa-search search-label-icon" />
+            {term}
+          </div>
+          <div className="help-panel-search-row-term-label">{label}</div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+
+function renderSearchPanel() {
+  return (
+    <div className="help-panel-search">
+      <h2>Search</h2>
+      <h3>Basics</h3>
+      {renderSearches(BASIC_SEARCHES)}
+
+      <h3>Regular expressions</h3>
+      {renderSearches(REGEX_SEARCHES)}
+
+      <h3>Metrics</h3>
+      {renderSearches(METRIC_SEARCHES)}
+
+    </div>
+  );
+}
+
+
+function renderFieldsPanel(currentTopologyName, searchableFields) {
+  const none = (
+    <span style={{ fontStyle: 'italic' }}>None</span>
+  );
+  const currentTopology = (
+    <span className="help-panel-fields-current-topology">
+      {currentTopologyName}
+    </span>
+  );
+
+  return (
+    <div className="help-panel-fields">
+      <h2>Fields and Metrics</h2>
+      <p>
+        Searchable fields and metrics in the
+        {' '}
+        <br />
+        currently selected
+        {' '}
+        {currentTopology}
+        {' '}
+        topology:
+      </p>
+      <div className="help-panel-fields-fields">
+        <div className="help-panel-fields-fields-column">
+          <h3>Fields</h3>
+          <div className="help-panel-fields-fields-column-content">
+            {searchableFields.get('fields').map(f => (
+              <div key={f}>{f}</div>
+            ))}
+            {searchableFields.get('fields').size === 0 && none}
+          </div>
+        </div>
+        <div className="help-panel-fields-fields-column">
+          <h3>Metrics</h3>
+          <div className="help-panel-fields-fields-column-content">
+            {searchableFields.get('metrics').map(m => (
+              <div key={m}>{m}</div>
+            ))}
+            {searchableFields.get('metrics').size === 0 && none}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+
+function HelpPanel({
+  currentTopologyName, searchableFields, onClickClose, canvasMargins
+}) {
+  return (
+    <div className="help-panel-wrapper">
+      <div className="help-panel" style={{ marginTop: canvasMargins.top }}>
+        <div className="help-panel-header">
+          <h2>Help</h2>
+        </div>
+        <div className="help-panel-main">
+          {renderShortcutPanel()}
+          {renderSearchPanel()}
+          {renderFieldsPanel(currentTopologyName, searchableFields)}
+        </div>
+        <div className="help-panel-tools">
+          <i
+            title="Close details"
+            className="fa fa-times"
+            onClick={onClickClose}
+          />
+        </div>
+      </div>
+    </div>
+  );
+}
+
+
+function mapStateToProps(state) {
+  return {
+    canvasMargins: canvasMarginsSelector(state),
+    currentTopologyName: state.getIn(['currentTopology', 'fullName']),
+    searchableFields: searchableFieldsSelector(state)
+  };
+}
+
+
+export default connect(mapStateToProps, {
+  onClickClose: hideHelp
+})(HelpPanel);

+ 56 - 0
app/scripts/components/loading.js

@@ -0,0 +1,56 @@
+import React from 'react';
+import { sample } from 'lodash';
+
+import { findTopologyById } from '../utils/topology-utils';
+import NodesError from '../charts/nodes-error';
+
+
+const LOADING_TEMPLATES = [
+  'Loading THINGS',
+  'Verifying THINGS',
+  'Fetching THINGS',
+  'Processing THINGS',
+  'Reticulating THINGS',
+  'Locating THINGS',
+  'Optimizing THINGS',
+  'Transporting THINGS',
+];
+
+
+export function getNodeType(topology, topologies) {
+  if (!topology || topologies.size === 0) {
+    return '';
+  }
+  let name = topology.get('name');
+  if (topology.get('parentId')) {
+    const parentTopology = findTopologyById(topologies, topology.get('parentId'));
+    name = parentTopology.get('name');
+  }
+  return name.toLowerCase();
+}
+
+
+function renderTemplate(nodeType, template) {
+  return template.replace('THINGS', nodeType);
+}
+
+
+export class Loading extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.state = {
+      template: sample(LOADING_TEMPLATES)
+    };
+  }
+
+  render() {
+    const { itemType, show } = this.props;
+    const message = renderTemplate(itemType, this.state.template);
+    return (
+      <NodesError mainClassName="nodes-chart-loading" faIconClass="far fa-circle" hidden={!show}>
+        <div className="heading">{message}</div>
+      </NodesError>
+    );
+  }
+}

+ 72 - 0
app/scripts/components/logo.js

@@ -0,0 +1,72 @@
+/* eslint react/jsx-first-prop-new-line: "off" */
+/* eslint max-len: "off" */
+import React from 'react';
+
+export default function Logo({ transform = '' }) {
+  return (
+    <g className="logo" transform={transform}>
+      <path fill="#32324B" d="M114.937,118.165l75.419-67.366c-5.989-4.707-12.71-8.52-19.981-11.211l-55.438,49.52V118.165z" />
+      <path fill="#32324B" d="M93.265,108.465l-20.431,18.25c1.86,7.57,4.88,14.683,8.87,21.135l11.561-10.326V108.465z" />
+      <path fill="#00D2FF"
+        d="M155.276,53.074V35.768C151.815,35.27,148.282,35,144.685,35c-3.766,0-7.465,0.286-11.079,0.828v36.604
+        L155.276,53.074z" />
+      <path fill="#00D2FF"
+        d="M155.276,154.874V82.133l-21.671,19.357v80.682c3.614,0.543,7.313,0.828,11.079,0.828
+        c4.41,0,8.723-0.407,12.921-1.147l58.033-51.838c1.971-6.664,3.046-13.712,3.046-21.015c0-3.439-0.254-6.817-0.708-10.132
+        L155.276,154.874z" />
+      <path fill="#FF4B19" d="M155.276,133.518l58.14-51.933c-2.77-6.938-6.551-13.358-11.175-19.076l-46.965,41.951V133.518z" />
+      <path fill="#FF4B19"
+        d="M133.605,123.817l-18.668,16.676V41.242c-8.086,3.555-15.409,8.513-21.672,14.567V162.19
+        c4.885,4.724,10.409,8.787,16.444,12.03l23.896-21.345V123.817z" />
+      <polygon fill="#32324B"
+        points="325.563,124.099 339.389,72.22 357.955,72.22 337.414,144.377 315.556,144.377 303.311,95.79
+        291.065,144.377 269.207,144.377 248.666,72.22 267.232,72.22 281.058,124.099 294.752,72.22 311.869,72.22 " />
+      <path fill="#32324B"
+        d="M426.429,120.676c-2.106,14.352-13.167,24.623-32.128,24.623c-20.146,0-35.025-12.114-35.025-36.605
+        c0-24.622,15.406-37.395,35.025-37.395c21.726,0,33.182,15.933,33.182,37.263v3.819h-49.772c0,8.031,3.291,18.17,16.327,18.17
+        c7.242,0,12.904-3.555,14.353-10.27L426.429,120.676z M408.654,99.608c-0.659-10.008-7.11-13.694-14.484-13.694
+        c-8.427,0-14.879,5.135-15.801,13.694H408.654z" />
+      <path fill="#32324B"
+        d="M480.628,97.634v-2.502c0-5.662-2.37-9.351-13.036-9.351c-13.298,0-13.694,7.375-13.694,9.877h-17.117
+        c0-10.666,4.477-24.359,31.338-24.359c25.676,0,30.285,12.771,30.285,23.174v39.766c0,2.897,0.131,5.267,0.395,7.11l0.527,3.028
+        h-18.172v-7.241c-5.134,5.134-12.245,8.163-22.384,8.163c-14.221,0-25.018-8.296-25.018-22.648c0-16.59,15.67-20.146,21.99-21.199
+        L480.628,97.634z M480.628,111.195l-6.979,1.054c-3.819,0.658-8.427,1.315-11.192,1.843c-3.029,0.527-5.662,1.186-7.637,2.765
+        c-1.844,1.449-2.765,3.425-2.765,5.926c0,2.107,0.79,8.69,10.666,8.69c5.793,0,10.928-2.105,13.693-4.872
+        c3.556-3.555,4.214-8.032,4.214-11.587V111.195z" />
+      <polygon fill="#32324B"
+        points="549.495,144.377 525.399,144.377 501.698,72.221 521.186,72.221 537.775,127.392 554.499,72.221
+        573.459,72.221 " />
+      <path fill="#32324B"
+        d="M641.273,120.676c-2.106,14.352-13.167,24.623-32.128,24.623c-20.146,0-35.025-12.114-35.025-36.605
+        c0-24.622,15.406-37.395,35.025-37.395c21.726,0,33.182,15.933,33.182,37.263v3.819h-49.772c0,8.031,3.291,18.17,16.327,18.17
+        c7.242,0,12.904-3.555,14.354-10.27L641.273,120.676z M623.498,99.608c-0.659-10.008-7.109-13.694-14.483-13.694
+        c-8.428,0-14.88,5.135-15.802,13.694H623.498z" />
+      <path fill="#32324B"
+        d="M682.976,80.873c-7.524,0-16.896,2.376-16.896,10.692c0,17.952,46.201,1.452,46.201,30.229
+        c0,9.637-5.676,22.309-30.229,22.309c-19.009,0-27.721-9.636-28.249-22.44h11.881c0.264,7.788,5.147,13.332,17.688,13.332
+        c14.52,0,17.952-6.204,17.952-12.54c0-13.332-24.421-7.788-37.753-15.181c-4.885-2.771-8.316-7.128-8.316-15.048
+        c0-11.616,10.824-20.461,27.853-20.461c20.989,0,27.193,12.145,27.589,20.196h-11.484
+        C698.685,83.381,691.556,80.873,682.976,80.873z" />
+      <path fill="#32324B"
+        d="M756.233,134.994c10.429,0,17.953-5.939,19.009-16.632h10.957c-1.98,17.028-13.597,25.74-29.966,25.74
+        c-18.744,0-32.076-12.012-32.076-35.905c0-23.76,13.464-36.433,32.209-36.433c16.104,0,27.721,8.712,29.568,25.213h-10.956
+        c-1.452-11.353-9.24-16.104-18.877-16.104c-12.012,0-20.856,8.448-20.856,27.324C735.245,127.471,744.485,134.994,756.233,134.994z
+        " />
+      <path fill="#32324B"
+        d="M830.418,144.103c-19.141,0-32.341-12.145-32.341-36.169c0-23.893,13.2-36.169,32.341-36.169
+        c19.009,0,32.209,12.145,32.209,36.169C862.627,132.091,849.427,144.103,830.418,144.103z M830.418,134.994
+        c12.145,0,21.12-7.392,21.12-27.061c0-19.536-8.976-27.061-21.12-27.061c-12.276,0-21.253,7.393-21.253,27.061
+        C809.165,127.603,818.142,134.994,830.418,134.994z" />
+      <path fill="#32324B"
+        d="M888.629,72.688v10.692c3.96-6.732,12.54-11.616,22.969-11.616c19.009,0,30.757,12.673,30.757,36.169
+        c0,23.629-12.145,36.169-31.152,36.169c-10.429,0-18.745-4.224-22.573-11.22v35.641h-10.824V72.688H888.629z M910.409,134.994
+        c12.145,0,20.857-7.392,20.857-27.061c0-19.536-8.713-27.061-20.857-27.061c-12.275,0-21.912,7.393-21.912,27.061
+        C888.497,127.603,898.134,134.994,910.409,134.994z" />
+      <path fill="#32324B"
+        d="M1016.801,119.022c-1.452,12.408-10.032,25.08-30.229,25.08c-18.745,0-32.341-12.804-32.341-36.037
+        c0-21.912,13.464-36.301,32.209-36.301c19.8,0,30.757,14.784,30.757,38.018h-51.878c0.265,13.332,5.809,25.212,21.385,25.212
+        c11.484,0,18.217-7.128,19.141-16.104L1016.801,119.022z M1005.448,101.201c-1.056-14.916-9.636-20.328-19.272-20.328
+      c-10.824,0-19.141,7.26-20.46,20.328H1005.448z" />
+    </g>
+  );
+}

+ 56 - 0
app/scripts/components/matched-results.js

@@ -0,0 +1,56 @@
+import React from 'react';
+import { MatchedText } from 'weaveworks-ui-components';
+
+const SHOW_ROW_COUNT = 2;
+
+const Match = (searchTerms, match) => (
+  <div className="matched-results-match" key={match.label}>
+    <div className="matched-results-match-wrapper">
+      <span className="matched-results-match-label">
+        {match.label}
+:
+      </span>
+      <MatchedText
+        text={match.text}
+        matches={searchTerms}
+      />
+    </div>
+  </div>
+);
+
+export default class MatchedResults extends React.PureComponent {
+  render() {
+    const { matches, searchTerms, style } = this.props;
+
+    if (!matches) {
+      return null;
+    }
+
+    let moreFieldMatches;
+    let moreFieldMatchesTitle;
+    if (matches.size > SHOW_ROW_COUNT) {
+      moreFieldMatches = matches
+        .valueSeq()
+        .skip(SHOW_ROW_COUNT)
+        .map(field => field.label);
+      moreFieldMatchesTitle = `More matches:\n${moreFieldMatches.join(',\n')}`;
+    }
+
+    return (
+      <div className="matched-results" style={style}>
+        {matches
+          .keySeq()
+          .take(SHOW_ROW_COUNT)
+          .map(fieldId => Match(searchTerms, matches.get(fieldId)))
+        }
+        {moreFieldMatches
+          && (
+          <div className="matched-results-more" title={moreFieldMatchesTitle}>
+            {`${moreFieldMatches.size} more matches`}
+          </div>
+          )
+        }
+      </div>
+    );
+  }
+}

+ 111 - 0
app/scripts/components/matched-text.js

@@ -0,0 +1,111 @@
+import React from 'react';
+
+const TRUNCATE_CONTEXT = 6;
+const TRUNCATE_ELLIPSIS = '…';
+
+/**
+ * Returns an array with chunks that cover the whole text via {start, length}
+ * objects.
+ *
+ * `('text', {start: 2, length: 1}) => [{text: 'te'}, {text: 'x', match: true}, {text: 't'}]`
+ */
+function chunkText(text, { start, length }) {
+  if (text && !window.isNaN(start) && !window.isNaN(length)) {
+    const chunks = [];
+    // text chunk before match
+    if (start > 0) {
+      chunks.push({text: text.substr(0, start)});
+    }
+    // matching chunk
+    chunks.push({match: true, offset: start, text: text.substr(start, length)});
+    // text after match
+    const remaining = start + length;
+    if (remaining < text.length) {
+      chunks.push({text: text.substr(remaining)});
+    }
+    return chunks;
+  }
+  return [{ text }];
+}
+
+/**
+ * Truncates chunks with ellipsis
+ *
+ * First chunk is truncated from left, second chunk (match) is truncated in the
+ * middle, last chunk is truncated at the end, e.g.
+ * `[{text: "...cation is a "}, {text: "useful...or not"}, {text: "tool..."}]`
+ */
+function truncateChunks(chunks, text, maxLength) {
+  if (chunks && chunks.length === 3 && maxLength && text && text.length > maxLength) {
+    const res = chunks.map(c => Object.assign({}, c));
+    let needToCut = text.length - maxLength;
+    // trucate end
+    const end = res[2];
+    if (end.text.length > TRUNCATE_CONTEXT) {
+      needToCut -= end.text.length - TRUNCATE_CONTEXT;
+      end.text = `${end.text.substr(0, TRUNCATE_CONTEXT)}${TRUNCATE_ELLIPSIS}`;
+    }
+
+    if (needToCut) {
+      // truncate front
+      const start = res[0];
+      if (start.text.length > TRUNCATE_CONTEXT) {
+        needToCut -= start.text.length - TRUNCATE_CONTEXT;
+        start.text = `${TRUNCATE_ELLIPSIS}`
+          + `${start.text.substr(start.text.length - TRUNCATE_CONTEXT)}`;
+      }
+    }
+
+    if (needToCut) {
+      // truncate match
+      const middle = res[1];
+      if (middle.text.length > 2 * TRUNCATE_CONTEXT) {
+        middle.text = `${middle.text.substr(0, TRUNCATE_CONTEXT)}`
+          + `${TRUNCATE_ELLIPSIS}`
+          + `${middle.text.substr(middle.text.length - TRUNCATE_CONTEXT)}`;
+      }
+    }
+
+    return res;
+  }
+  return chunks;
+}
+
+/**
+ * Renders text with highlighted search match.
+ *
+ * A match object is of shape `{text, label, match}`.
+ * `match` is a text match object of shape `{start, length}`
+ * that delimit text matches in `text`. `label` shows the origin of the text.
+ */
+export default class MatchedText extends React.PureComponent {
+  render() {
+    const {
+      match, text, truncate, maxLength
+    } = this.props;
+
+    const showFullValue = !truncate || (match && (match.start + match.length) > truncate);
+    const displayText = showFullValue ? text : text.slice(0, truncate);
+
+    if (!match) {
+      return <span>{displayText}</span>;
+    }
+
+    const chunks = chunkText(displayText, match);
+
+    return (
+      <span className="matched-text" title={text}>
+        {truncateChunks(chunks, displayText, maxLength).map((chunk) => {
+          if (chunk.match) {
+            return (
+              <span className="match" key={chunk.offset}>
+                {chunk.text}
+              </span>
+            );
+          }
+          return chunk.text;
+        })}
+      </span>
+    );
+  }
+}

+ 90 - 0
app/scripts/components/metric-selector-item.js

@@ -0,0 +1,90 @@
+import React from 'react';
+import classNames from 'classnames';
+import { connect } from 'react-redux';
+
+import { hoverMetric, pinMetric, unpinMetric } from '../actions/app-actions';
+import { selectedMetricTypeSelector } from '../selectors/node-metric';
+import { trackAnalyticsEvent } from '../utils/tracking-utils';
+
+
+class MetricSelectorItem extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.onMouseOver = this.onMouseOver.bind(this);
+    this.onMouseClick = this.onMouseClick.bind(this);
+  }
+
+  trackEvent(eventName) {
+    trackAnalyticsEvent(eventName, {
+      layout: this.props.topologyViewMode,
+      metricType: this.props.metric.get('label'),
+      parentTopologyId: this.props.currentTopology.get('parentId'),
+      topologyId: this.props.currentTopology.get('id'),
+    });
+  }
+
+  onMouseOver() {
+    const metricType = this.props.metric.get('label');
+    this.props.hoverMetric(metricType);
+  }
+
+  onMouseClick() {
+    const metricType = this.props.metric.get('label');
+    const { pinnedMetricType } = this.props;
+
+    if (metricType !== pinnedMetricType) {
+      this.trackEvent('scope.metric.selector.pin.click');
+      this.props.pinMetric(metricType);
+    } else {
+      this.trackEvent('scope.metric.selector.unpin.click');
+      this.props.unpinMetric();
+    }
+  }
+
+  render() {
+    const { metric, selectedMetricType, pinnedMetricType } = this.props;
+    const type = metric.get('label');
+    const isPinned = (type === pinnedMetricType);
+    const isSelected = (type === selectedMetricType);
+    const className = classNames('metric-selector-action', {
+      'metric-selector-action-selected': isSelected
+    });
+    //默认选中Apdex 方式1
+    this.props.pinMetric(pinnedMetricType)
+    return (
+      <div
+        key={type}
+        className={className}
+        onMouseOver={this.onMouseOver}
+        onClick={this.onMouseClick}>
+        {type}
+        {isPinned && <i className="fa fa-thumbtack" />}
+      </div>
+    );
+  }
+  //监听 默认选中Apdex 方式2
+  // componentDidUpdate(prevProps) {
+  //   if (!prevProps.pinnedMetricType) {
+  //     prevProps.defaultMetric && this.props.pinMetric(prevProps.defaultMetric)
+  //   }
+  // }
+}
+//props添加默认值 默认选中Apdex 方式1
+MetricSelectorItem.defaultProps = {
+  selectedMetricType: 'Apdex',
+  pinnedMetricType: 'Apdex'
+}
+function mapStateToProps(state) {
+  return {
+    currentTopology: state.get('currentTopology'),
+    pinnedMetricType: state.get('pinnedMetricType'),
+    selectedMetricType: selectedMetricTypeSelector(state),
+    topologyViewMode: state.get('topologyViewMode'),
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { hoverMetric, pinMetric, unpinMetric }
+)(MetricSelectorItem);

+ 58 - 0
app/scripts/components/metric-selector.js

@@ -0,0 +1,58 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { unhoverMetric } from '../actions/app-actions';
+import { availableMetricsSelector } from '../selectors/node-metric';
+import MetricSelectorItem from './metric-selector-item';
+
+class MetricSelector extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.onMouseOut = this.onMouseOut.bind(this);
+  }
+
+  onMouseOut() {
+    this.props.unhoverMetric();
+  }
+
+  render() {
+    const { availableMetrics } = this.props;
+    const hasMetrics = !availableMetrics.isEmpty();
+    //默认选中第一个指标,Apdex 方式2
+    // let defaultMetric;
+    // hasMetrics && availableMetrics.map(metric => {
+    //   if (!defaultMetric) {
+    //     defaultMetric = metric.get('label')
+    //   }
+    // })
+    return (
+      <div className="metric-selector">
+        {hasMetrics
+          && (
+            <div className="metric-selector-wrapper" onMouseLeave={this.onMouseOut}>
+              {availableMetrics.map(metric => (
+                <MetricSelectorItem
+                  key={metric.get('id')}
+                  metric={metric}
+                // defaultMetric={defaultMetric}
+                />
+              ))}
+            </div>
+          )
+        }
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    availableMetrics: availableMetricsSelector(state),
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { unhoverMetric }
+)(MetricSelector);

+ 68 - 0
app/scripts/components/network-selector-item.js

@@ -0,0 +1,68 @@
+import React from 'react';
+import classNames from 'classnames';
+import { connect } from 'react-redux';
+
+import { selectNetwork, pinNetwork, unpinNetwork } from '../actions/app-actions';
+import { getNetworkColor } from '../utils/color-utils';
+
+class NetworkSelectorItem extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.onMouseOver = this.onMouseOver.bind(this);
+    this.onMouseClick = this.onMouseClick.bind(this);
+  }
+
+  onMouseOver() {
+    const k = this.props.network.get('id');
+    this.props.selectNetwork(k);
+  }
+
+  onMouseClick() {
+    const k = this.props.network.get('id');
+    const { pinnedNetwork } = this.props;
+
+    if (k === pinnedNetwork) {
+      this.props.unpinNetwork(k);
+    } else {
+      this.props.pinNetwork(k);
+    }
+  }
+
+  render() {
+    const {network, selectedNetwork, pinnedNetwork} = this.props;
+    const id = network.get('id');
+    const isPinned = (id === pinnedNetwork);
+    const isSelected = (id === selectedNetwork);
+    const className = classNames('network-selector-action', {
+      'network-selector-action-selected': isSelected
+    });
+    const style = {
+      borderBottomColor: getNetworkColor(network.get('colorKey', id))
+    };
+
+    return (
+      <div
+        key={id}
+        className={className}
+        onMouseOver={this.onMouseOver}
+        onClick={this.onMouseClick}
+        style={style}>
+        {network.get('label')}
+        {isPinned && <i className="fa fa-thumbtack" />}
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    pinnedNetwork: state.get('pinnedNetwork'),
+    selectedNetwork: state.get('selectedNetwork')
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { pinNetwork, selectNetwork, unpinNetwork }
+)(NetworkSelectorItem);

+ 63 - 0
app/scripts/components/networks-selector.js

@@ -0,0 +1,63 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+
+import { selectNetwork, showNetworks } from '../actions/app-actions';
+import { availableNetworksSelector } from '../selectors/node-networks';
+import NetworkSelectorItem from './network-selector-item';
+
+class NetworkSelector extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.onClick = this.onClick.bind(this);
+    this.onMouseOut = this.onMouseOut.bind(this);
+  }
+
+  onClick() {
+    return this.props.showNetworks(!this.props.showingNetworks);
+  }
+
+  onMouseOut() {
+    this.props.selectNetwork(this.props.pinnedNetwork);
+  }
+
+  render() {
+    const { availableNetworks, showingNetworks } = this.props;
+
+    const items = availableNetworks.map(network => (
+      <NetworkSelectorItem key={network.get('id')} network={network} />
+    ));
+
+    const className = classNames('network-selector-action', {
+      'network-selector-action-selected': showingNetworks
+    });
+
+    const style = {
+      borderBottomColor: showingNetworks ? '#A2A0B3' : 'transparent'
+    };
+
+    return (
+      <div className="network-selector">
+        <div className="network-selector-wrapper" onMouseLeave={this.onMouseOut}>
+          <div className={className} onClick={this.onClick} style={style}>
+            Networks
+          </div>
+          {showingNetworks && items}
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    availableNetworks: availableNetworksSelector(state),
+    pinnedNetwork: state.get('pinnedNetwork'),
+    showingNetworks: state.get('showingNetworks')
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { selectNetwork, showNetworks }
+)(NetworkSelector);

+ 1741 - 0
app/scripts/components/node-details.js

@@ -0,0 +1,1741 @@
+import debug from 'debug';
+import React from 'react';
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { Map as makeMap } from 'immutable';
+import { noop } from 'lodash';
+
+import { clickCloseDetails, clickShowTopologyForNode } from '../actions/request-actions';
+import { brightenColor, getNeutralColor, getNodeColorDark,getStatusColor,setNodeColor } from '../utils/color-utils';
+import { isGenericTable, isPropertyList } from '../utils/node-details-utils';
+import { resetDocumentTitle, setDocumentTitle } from '../utils/title-utils';
+
+import Overlay from './overlay';
+import MatchedText from './matched-text';
+import NodeDetailsControls from './node-details/node-details-controls';
+import NodeDetailsGenericTable from './node-details/node-details-generic-table';
+import NodeDetailsPropertyList from './node-details/node-details-property-list';
+import NodeDetailsHealth from './node-details/node-details-health';
+import f from './node-details/node-details-info';
+import NodeDetailsRelatives from './node-details/node-details-relatives';
+import NodeDetailsTable from './node-details/node-details-table';
+import Warning from './warning';
+
+
+import * as echarts from 'echarts'
+import axios from 'axios'
+import moment from 'moment'
+import { Table,Radio,Checkbox,Button, Drawer, Descriptions } from "antd";
+import "antd/dist/antd.css";
+import '../../styles/nodeDetail.less'
+import getToken from '../utils/get-token'
+const log = debug('scope:node-details');
+
+function getTruncationText(count) {
+  return 'This section was too long to be handled efficiently and has been truncated'
+  + ` (${count} extra entries not included). We are working to remove this limitation.`;
+}
+
+class NodeDetails extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.state = {
+      // baseUrl:'http://observe-server.cestong.com.cn',  //本地调试使用
+      // traceUrl:'http://observe-front.cestong.com.cn',  //本地调试使用
+      // coreBaseUrl: 'http://observe-front.cestong.com.cn/core',  //本地调试使用
+      coreBaseUrl:'',//上线时打开
+      baseUrl:'/re', //上线时打开
+      traceUrl:'',//上线时打开
+      nodeData:{},  //节点基础信息
+      AnalystData:{},//散点图
+      queryParams:{
+        start_time:Math.round((new Date().getTime())/1000 - (5*60)),
+        end_time:Math.round(new Date().getTime()/1000),
+        app_alias:'opentelemetry-demo',
+        service_name:'frontend',
+        percentile:0.5,
+        sort_field:'Timestamp',
+        sort_type: 'DESC'
+      },
+      traceData:[],
+      livenessData:[],
+      barData:[],
+      tableData: [],
+      bgColor:setNodeColor('R'),
+      pagination: {
+        pageIndex:1,
+        pageSize:10,
+        total:0,
+        current:1,
+      },
+      pagination2: {
+        page_num:1,
+        page_size:10,
+        total:0,
+        current:1,
+      },
+      traceQuery:{
+        only_exception:0,
+        only_database:0,
+      },
+      timeoutId: null,
+      messaging_stats: {
+        topic_stats:[]
+      },
+      cloudTable: [],
+      cloudTable2: [],
+      cloudPagination: {
+        page_num:1,
+        page_size:10,
+        total:0,
+        current:1,
+      }
+    }
+  }
+
+  handleClickClose = (ev) => {
+    ev.preventDefault();
+    this.props.clickCloseDetails(this.props.nodeId);
+  }
+
+  handleShowTopologyForNode = (ev) => {
+    ev.preventDefault();
+    this.props.clickShowTopologyForNode(this.props.topologyId, this.props.nodeId);
+  }
+
+  componentDidMount() {
+    let _this = this
+    //上线时打开开始
+    this.setQueryParams();//做为iframe嵌套时打开,上线时打开
+    const traceURL = `http://${parent.location.hostname}`
+    const coreBaseURL = `http://${parent.location.hostname}/core`
+    this.setState({
+      traceUrl:traceURL,
+      coreBaseUrl:coreBaseURL
+    },()=>{
+    })
+    //上线时打开结束
+    // 接受父组件参数
+
+    // window.top == window  true 自己本身没有被嵌套
+    if(window.top !== window){// false 被嵌套
+      const data = JSON.parse(parent.localStorage.global_times)
+      const queryParams = _this.state.queryParams
+      queryParams.start_time = data.startTime
+      queryParams.end_time = data.endTime
+      _this.setState({
+        queryParams: queryParams
+      },()=>{
+      });
+    }
+
+    window.addEventListener('message', function(event) {
+      // 处理接收到的消息
+      const data = event.data
+      const queryParams = _this.state.queryParams
+      if (data.eventType == 'globalTimesChange') {
+        queryParams.start_time = data.data.startTime
+        queryParams.end_time = data.data.endTime
+        _this.setState({
+          queryParams: queryParams
+        },()=>{
+
+          if (_this.props.shape == 'cylinder'){
+            _this.getTableData(); //table
+            _this.getBarData(); // 柱形图
+          }
+          if (_this.props.shape == 'dottedcylinder'){
+            _this.getDottBasicsData() // dottedcylinder 基础信息
+          }
+          if (_this.props.shape == 'circle'){
+            _this.getNodeBasic();
+            _this.getNodeAnalyst();//获取散点图
+            _this.getNodeLiveness(); // 折线图
+            _this.getServiceSpans();
+          }
+          if (_this.props.shape == 'cloud'){
+            _this.getCloudTableData()
+          }
+        });
+      } else if (data.eventType == 'getLoginAuth') {
+        _this.setState({
+          getToken: data.data
+        }, ()=>{})
+      }
+
+    }, false);
+  }
+  componentWillUnmount() {
+    clearTimeout(this.timeoutId);
+  }
+  setQueryParams(){
+    var strr = parent.location.href;   //上线做为iframe嵌套时使用
+    let param = this.parseQueryString(strr);  //全链路需要的参数多,因此解析成对象形式
+    const queryParams = this.state.queryParams;
+    if(parseInt(param.start_time)!=0 && parseInt(param.end_time) !=0){ //上线时打开
+      let newStartTime = parseInt(param.start_time); // 设置新的属性值
+      let newEndTime = parseInt(param.end_time); // 设置新的属性值
+      queryParams.start_time = newStartTime
+      queryParams.end_time = newEndTime
+    }
+    const newAppAlias = param.app_alias
+    queryParams.app_alias = newAppAlias
+    this.setState({
+      queryParams: queryParams
+    },()=>{
+
+      if (this.props.shape == 'cylinder'){
+        this.getTableData(); //table
+        this.getBarData(); // 柱形图
+      }
+      if (this.props.shape == 'dottedcylinder'){
+        this.getDottBasicsData() // dottedcylinder 基础信息
+      }
+      if (this.props.shape == 'circle'){
+        this.getNodeBasic();
+        this.getNodeAnalyst();//获取散点图
+        this.getNodeLiveness(); // 折线图
+        this.getServiceSpans();
+      }
+      if (this.props.shape == 'cloud') {
+        this.getCloudTableData()
+      }
+    });
+
+  }
+  //解析URL
+  parseQueryString(url){
+    var json = {};
+    var arr = url.substr(url.indexOf('?') + 1).split('&');
+    arr.forEach(item=>{
+        var tmp = item.split('=');
+                  json[tmp[0]] = tmp[1];
+    });
+    return json;
+  }
+  componentWillUnmount() {
+    resetDocumentTitle();
+  }
+
+  renderTools() {
+    const showSwitchTopology = this.props.nodeId !== this.props.selectedNodeId;
+    const topologyTitle = `View ${this.props.label} in ${this.props.topologyId}`;
+
+    return (
+      <div className="node-details-tools-wrapper">
+        <div className="node-details-tools">
+          {showSwitchTopology
+            && (
+            <i
+              title={topologyTitle}
+              className="fa fa-long-arrow-alt-left"
+              onClick={this.handleShowTopologyForNode}>
+              <span>
+Show in
+                {/* <span>{this.props.topologyId.replace(/-/g, ' ')}</span> */}
+              </span>
+            </i>
+            )
+          }
+          <i
+            title="Close details"
+            className="fa fa-times close-details"
+            onClick={this.handleClickClose}
+          />
+        </div>
+      </div>
+    );
+  }
+
+  renderLoading() {
+    const node = this.props.nodes.get(this.props.nodeId);
+    const label = node ? node.get('label') : this.props.label;
+    // NOTE: If we start the fa-spin animation before the node details panel has been
+    // mounted, the spinner is displayed blurred the whole time in Chrome (possibly
+    // caused by a bug having to do with animating the details panel).
+    const spinnerClassName = classNames('fa fa-circle-notch', { 'fa-spin': this.props.mounted });
+    const nodeColor = (node
+      ? getNodeColorDark(node.get('rank'), label, node.get('pseudo'))
+      : getNeutralColor());
+    const tools = this.renderTools();
+    const styles = {
+      header: {
+        backgroundColor: nodeColor
+      }
+    };
+
+    return (
+      <div className="node-details">
+        {tools}
+        <div className="node-details-header" style={styles.header}>
+          <div className="node-details-header-wrapper">
+            <h2 className="node-details-header-label truncate">
+              {label}
+            </h2>
+            <div className="node-details-relatives truncate">
+              Loading...
+            </div>
+          </div>
+        </div>
+        <div className="node-details-content" style="padding:0 12px">
+          <div className="node-details-content-loading">
+            <span className={spinnerClassName} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  renderNotAvailable() {
+    const tools = this.renderTools();
+    return (
+      <div className="node-details">
+        {tools}
+        <div className="node-details-header node-details-header-notavailable">
+          <div className="node-details-header-wrapper">
+            <h2 className="node-details-header-label">
+              {this.props.label}
+            </h2>
+            <div className="node-details-relatives truncate">
+              n/a
+            </div>
+          </div>
+        </div>
+        <div className="node-details-content">
+          <p className="node-details-content-info">
+            <strong>{this.props.label}</strong>
+            {' '}
+not found!
+          </p>
+        </div>
+        <Overlay faded={this.props.transitioning} />
+      </div>
+    );
+  }
+
+  render() {
+    // if (this.props.notFound) {
+    //   return this.renderNotAvailable();
+    // }
+
+    // if (this.props.details) {
+    //   return this.renderDetails();
+    // }
+
+    // return this.renderLoading();
+
+    return this.renderNodeDetails();
+
+  }
+
+
+  renderDetails() {
+    const {
+      details, nodeControlStatus, nodeMatches = makeMap(), topologyId
+    } = this.props;
+    const showControls = details.controls && details.controls.length > 0;
+    const nodeColor = getNodeColorDark(details.rank, details.label, details.pseudo);
+    // const nodeColor= setNodeColor(details.color)
+    const {error, pending} = nodeControlStatus ? nodeControlStatus.toJS() : {};
+    const tools = this.renderTools();
+    const styles = {
+      controls: {
+        backgroundColor: brightenColor(nodeColor)
+      },
+      header: {
+        backgroundColor: nodeColor
+      }
+    };
+
+    return (
+      <div className="tour-step-anchor node-details">
+        {tools}
+        <div className="node-details-header" style={styles.header}>
+          <div className="node-details-header-wrapper">
+            <h2 className="node-details-header-label truncate" title={details.label}>
+              <MatchedText text={details.label} match={nodeMatches.get('label')} />
+            </h2>
+            <div className="node-details-header-relatives">
+              {details.parents && (
+              <NodeDetailsRelatives
+                matches={nodeMatches.get('parents')}
+                relatives={details.parents} />
+              )}
+            </div>
+          </div>
+        </div>
+
+        {showControls
+          && (
+          <div className="tour-step-anchor node-details-controls-wrapper" style={styles.controls}>
+            <NodeDetailsControls
+              nodeId={this.props.nodeId}
+              controls={details.controls}
+              pending={pending}
+              error={error} />
+          </div>
+          )
+        }
+
+        <div className="node-details-content">
+          {details.metrics
+            && (
+            <div className="node-details-content-section">
+              <div className="node-details-content-section-header">Status</div>
+              <NodeDetailsHealth
+                metrics={details.metrics}
+                topologyId={topologyId}
+                />
+            </div>
+            )
+          }
+          {details.metadata
+            && (
+            <div className="node-details-content-section">
+              <div className="node-details-content-section-header">Info</div>
+              <NodeDetailsInfo rows={details.metadata} matches={nodeMatches.get('metadata')} />
+            </div>
+            )
+          }
+
+          {details.connections && details.connections.filter(cs => cs.connections.length > 0)
+            .map(connections => (
+              <div className="node-details-content-section" key={connections.id}>
+                <NodeDetailsTable
+                  {...connections}
+                  nodes={connections.connections}
+                  nodeIdKey="nodeId"
+                />
+              </div>
+            ))}
+
+          {details.children && details.children.map(children => (
+            <div className="node-details-content-section" key={children.topologyId}>
+              <NodeDetailsTable {...children} />
+            </div>
+          ))}
+
+          {details.tables && details.tables.length > 0 && details.tables.map((table) => {
+            if (table.rows.length > 0) {
+              return (
+                <div className="node-details-content-section" key={table.id}>
+                  <div className="node-details-content-section-header">
+                    {table.label && table.label.length > 0 && table.label}
+                    {table.truncationCount > 0
+                      && (
+                      <span
+                        className="node-details-content-section-header-warning">
+                        <Warning text={getTruncationText(table.truncationCount)} />
+                      </span>
+                      )
+                    }
+                  </div>
+                  {this.renderTable(table)}
+                </div>
+              );
+            }
+            return null;
+          })}
+
+          {this.props.renderNodeDetailsExtras({ details, topologyId })}
+        </div>
+
+        <Overlay faded={this.props.transitioning} />
+      </div>
+    );
+  }
+
+  renderNodeDetails(){
+    const {
+      details, nodeControlStatus, nodeMatches = makeMap(), topologyId
+    } = this.props;
+    const node = this.props.nodes.get(this.props.nodeId);
+    const label = node ? node.get('label') : this.props.label;
+    // const nodeColor = getNodeColorDark(details.rank, details.label, details.pseudo);
+    // const nodeColor= setNodeColor(details.color)
+    const {error, pending} = nodeControlStatus ? nodeControlStatus.toJS() : {};
+    const tools = this.renderTools();
+    const styles = {
+      controls: {
+        // backgroundColor: brightenColor(nodeColor)
+      },
+      header: {
+        // backgroundColor: '#5BB2FA'
+        backgroundColor:this.state.bgColor
+      }
+    };
+    const columns = [
+      {
+        title: 'TraceID',
+        dataIndex: 'trace_id',
+        key: 'trace_id',
+        width:'20%',
+        ellipsis:true,
+        align:'center',
+        // scopedSlots:{customRender:'trace_id'},
+        render: (text,record) => <a target='_blank' href={`${this.state.traceUrl}/#/latency/index?traceId=${text}&app_alias=${this.state.queryParams.app_alias}&span_id=${record.span_id}&datetime=${Date.parse(record.datetime)/1000}`}>{text}</a>
+      },
+      {
+        title: '方法',
+        dataIndex: 'method',
+        key: 'method',
+        width:'15%',
+        ellipsis:true,
+        align:'center'
+      },
+      {
+        title: '状态码',
+        dataIndex: 'code',
+        key: 'code',
+        width:'15%',
+        ellipsis:true,
+        align:'center'
+      },
+      {
+        title: '请求时长(ms)',
+        dataIndex: 'duration',
+        key: 'duration',
+        width:'30%',
+        ellipsis:true,
+        align:'center',
+        render:(text) => <span>{text.toFixed(2)}</span>
+      },
+      {
+        title: '日期',
+        dataIndex: 'datetime',
+        key: 'datetime',
+        width:'30%',
+        ellipsis:true,
+        align:'center',
+        defaultSortOrder: 'descend',
+        sorter:true
+      },
+    ]
+    const colums2 = [
+      {
+        title: '服务名',
+        dataIndex: 'service_name',
+        key: 'service_name',
+        ellipsis:true,
+        align:'center',
+        render: (text, record) => (
+          <span>{record.service_name_cn? record.service_name_cn: record.service_name}</span>
+        )
+      },
+      {
+        title: '执行语句',
+        dataIndex: 'query',
+        key: 'query',
+        width:'40%',
+        ellipsis:true,
+        align:'center',
+        render: (text, record) => (
+          <textarea value={record.query} disabled rows="3" />
+        )
+      },
+      {
+        title: '慢查询次数',
+        dataIndex: 'slow_num',
+        key: 'slow_num',
+        ellipsis:true,
+        align:'center'
+      },
+      {
+        title: '错误数量',
+        dataIndex: 'error_num',
+        key: 'error_num',
+        ellipsis:true,
+        align:'center'
+      }
+    ]
+    const cloudColums = [
+      {
+        title: '应用名称',
+        dataIndex: 'app_name',
+        align:'center',
+        key: 'app_name'
+      },
+      {
+        title: '请求总数',
+        dataIndex: 'request_total',
+        align:'center',
+        key: 'request_total',
+      },
+      {
+        title: '错误数',
+        dataIndex: 'error_num',
+        align:'center',
+        key: 'error_num',
+      },
+      {
+        title: '平均延迟',
+        dataIndex: 'duration_average',
+        align:'center',
+        key: 'duration_average',
+        render: (text, record) => (
+          <span>{record.duration_average.toFixed(2)}</span>
+        )
+      },
+      {
+        title: '中位延迟',
+        dataIndex: 'duration_median',
+        align:'center',
+        key: 'duration_median',
+        render: (text, record) => (
+          <span>{record.duration_median.toFixed(2)}</span>
+        )
+      }
+      // {
+      //   title: '错误数',
+      //   dataIndex: 'duration_p90',
+      //   key: 'duration_p90',
+      // },
+      // {
+      //   title: '错误数',
+      //   dataIndex: 'duration_p99',
+      //   key: 'duration_p99',
+      // },
+    ];
+    const cloudColums2 = [
+      {
+        title: '应用名称',
+        align:'center',
+        dataIndex: 'name',
+        key: 'name'
+      },
+      {
+        title: '请求总数',
+        align:'center',
+        dataIndex: 'request_total',
+        key: 'request_total',
+      },
+      {
+        title: '错误数',
+        align:'center',
+        dataIndex: 'error_num',
+        key: 'error_num',
+      },
+      {
+        title: '平均延迟',
+        dataIndex: 'duration_average',
+        align:'center',
+        key: 'duration_average',
+        render: (text, record) => (
+          <span>{record.duration_average.toFixed(2)}</span>
+        )
+      },
+      {
+        title: '中位延迟',
+        dataIndex: 'duration_median',
+        align:'center',
+        key: 'duration_median',
+        render: (text, record) => (
+          <span>{record.duration_median.toFixed(2)}</span>
+        )
+      }
+    ];
+    const expandedRowRender = (record) => {
+      const columns = [
+        {
+          title: '服务名',
+          dataIndex: 'service_name_cn',
+          align:'center',
+          key: 'service_name_cn',
+        },
+        {
+          title: '请求总数',
+          dataIndex: 'request_total',
+          align:'center',
+          key: 'request_total',
+        },
+        {
+          title: '错误数',
+          dataIndex: 'error_num',
+          align:'center',
+          key: 'error_num',
+        },
+        {
+          title: '平均延迟',
+          dataIndex: 'duration_average',
+          key: 'duration_average',
+          align:'center',
+          render: (text, record) => (
+            <span>{record.duration_average.toFixed(2)}</span>
+          )
+        },
+        {
+          title: '中位延迟',
+          dataIndex: 'duration_median',
+          key: 'duration_median',
+          align:'center',
+          render: (text, record) => (
+            <span>{record.duration_median.toFixed(2)}</span>
+          )
+        }
+      ];
+      return <Table columns={columns} rowKey={'service_name'}  dataSource={record.service_list} pagination={false} size='small' />;
+    };
+    return (
+      <Drawer  placement="right"
+      onClose={this.handleClickClose} visible={true}
+      destroyOnClose={true}
+      width="65%">
+        <div className="tour-step-anchor node-details">
+          {tools}
+          <div className="node-details-header" style={styles.header}>
+            <div className="node-details-header-wrapper">
+              <h2 className="node-details-header-label truncate">
+              {label}
+              </h2>
+              <div className="node-details-header-relatives">
+                {this.state.nodeData.subtitle}
+              </div>
+            </div>
+          </div>
+          {/* {showControls
+            && (
+            <div className="tour-step-anchor node-details-controls-wrapper" style={styles.controls}>
+              <NodeDetailsControls
+                nodeId={this.props.nodeId}
+                controls={details.controls}
+                pending={pending}
+                error={error} />
+            </div>
+            )
+          } */}
+          {
+            this.props.shape == 'cylinder'&&
+            <div className="node-details-content" >
+              <div style={{marginTop:'16px'}}>
+                <div className="node-details-content-section-header" style={{marginBottom:0}}>执行次数</div>
+                <div id="chartContent"  style={{width: '100%', height: '300px', borderRadius: '12px'}}></div>
+              <div>
+              <Table dataSource={this.state.tableData} columns={colums2} rowKey={'service_name'+ Math.random()} pagination={this.state.pagination2} onChange={this.handleTableChange2} size='small'>
+              </Table>
+              </div>
+              </div>
+            </div>
+          }
+
+
+          { this.props.shape == 'circle'&&
+            (<div className="node-details-content">
+              <div>
+                <div className="node-details-content-section">
+                  <div className="node-details-content-section-header">基本信息</div>
+                  <div className='node-details-info'>
+                    <div style={{textAlign:'right'}}>
+                      {/* <Button size={size}>进入服务详情</Button> */}
+                      <Button size='small'><a target='_blank' style={{fontSize:'12px'}} href={`${this.state.traceUrl}/#/service/serviceDetail/index?app_alias=${this.state.queryParams.app_alias}&service_name=${label}`}>服务详情</a></Button>
+                    </div>
+                  </div>
+                </div>
+
+          {/* https://zhongdian.feishu.cn/docx/HkXSdrGa2ou84zxPvvycGX5Fn3b 中让去掉的 */}
+                {/* <div className="node-details-content-section">
+                  <div className="node-details-content-section-header">状态</div>
+                  <div>
+                      <div className='node-details-info-field'>
+                        <div className='node-details-info-field-label truncate w50' style={{width:"50%"}}>可用性</div>
+                        <div className='node-details-info-field-value truncate w50' style={{width:"50%"}}>
+                          {this.state.nodeData.apdex?Math.floor(this.state.nodeData.apdex*100):0}
+                        </div>
+                      </div>
+                      <div className='node-details-info-field'>
+                        <div className='node-details-info-field-label truncate w50' style={{width:"50%"}}>成功率</div>
+                        <div className='node-details-info-field-value truncate w50' style={{width:"50%"}}>{this.state.nodeData.arc__success?(this.state.nodeData.arc__success*100).toFixed(2):0}%</div>
+                      </div>
+                      <div className='node-details-info-field'>
+                        <div className='node-details-info-field-label truncate w50' style={{width:"50%"}}>失败率</div>
+                        <div className='node-details-info-field-value truncate w50' style={{width:"50%"}}>{this.state.nodeData.arc__faild?(this.state.nodeData.arc__faild*100).toFixed(2):0}%</div>
+                      </div>
+                      <div className='node-details-info-field'>
+                        <div className='node-details-info-field-label truncate w50' style={{width:"50%"}}>接收数量</div>
+                        <div className='node-details-info-field-value truncate w50' style={{width:"50%"}}>{this.state.nodeData.receive?this.state.nodeData.receive:0}</div>
+                      </div>
+                      <div className='node-details-info-field'>
+                        <div className='node-details-info-field-label truncate w50' style={{width:"50%"}}>发送数量</div>
+                        <div className='node-details-info-field-value truncate w50' style={{width:"50%"}}>{this.state.nodeData.send?this.state.nodeData.send:0}</div>
+                      </div>
+                  </div>
+                </div> */}
+                <div className="node-details-content-section">
+                  <div className="node-details-content-section-header">调用次数</div>
+                  <div className='node-details-info'>
+                    {
+                      this.state.livenessData.length>0?
+                      (<div>
+                        <div id='box' className="echartsbox"></div>
+                      </div>)
+                      :(
+                        <div className='noData'>暂无数据</div>
+                      )
+                    }
+                  </div>
+                </div>
+
+                <div className="node-details-content-section">
+                  <div className="node-details-content-section-header">延迟比例</div>
+                  <div className='node-details-info' style={{marginTop: "-15px",position:'relative'}}>
+                    {
+                      JSON.stringify(this.state.AnalystData)!="{}"?
+                      (<div>
+                        <div id='main' className="echartsbox" style={{height:'240px'}}></div>
+                      </div>)
+                      :(
+                        <div className='noData'>暂无数据</div>
+                      )
+                    }
+                    <div className='LatencySelect'>
+                      <Radio.Group onChange={this.onChange} value={this.state.queryParams.percentile} size="small">
+                        <Radio value={0.5}>50分位</Radio>
+                        {/* <Radio value={0.95}>p.95</Radio> */}
+                        <Radio value={0.99}>99分位</Radio>
+                      </Radio.Group>
+                    </div>
+                  </div>
+                </div>
+
+                <div className='node-details-content-section'>
+                  <div className="node-details-content-section-header">异常Trace</div>
+                  <div className='node-serch'>
+                    <Checkbox onChange={this.onChangeError}>仅异常</Checkbox>
+                    <Checkbox onChange={this.onChangeSql}>仅SQL</Checkbox>
+                  </div>
+                  <Table dataSource={this.state.traceData} columns={columns} rowKey='span_id' pagination={this.state.pagination} onChange={this.handleTableChange} size='small'>
+                    {/* <span slot='trace_id' slot-scope='text,record'>
+                      <template>
+                        <div>
+                          <a target='_blank' href={`${this.state.traceUrl}/#/latency/index?traceId=${record.trace_id}&app_alias=${this.state.queryParams.app_alias}&span_id=${record.span_id}`}>{record.trace_id}</a>
+                        </div>
+                      </template>
+                    </span> */}
+                  </Table>
+                </div>
+              </div>
+            </div>)
+          }
+          {
+            this.props.shape == 'dottedcylinder' && (
+              <div className='ant-descriptions-header'>
+                <Descriptions title="" >
+                  {/* <Descriptions.Item label="系统名称">{this.state.messaging_stats.name}</Descriptions.Item> */}
+                  <Descriptions.Item label="生产消息数量">{this.state.messaging_stats.produce_num || 0}</Descriptions.Item>
+                  <Descriptions.Item label="消费消息数量">{this.state.messaging_stats.consume_num || 0}</Descriptions.Item>
+                  <Descriptions.Item label="生产消息错误率">{this.state.messaging_stats.produce_error_rate?(this.state.messaging_stats.produce_error_rate*100).toFixed(2):0}</Descriptions.Item>
+                  <Descriptions.Item label="消费消息错误率">{this.state.messaging_stats.consume_error_rate?(this.state.messaging_stats.consume_error_rate*100).toFixed(2):0}</Descriptions.Item>
+                  <Descriptions.Item label="生产消息平均耗时">{this.state.messaging_stats.produce_duration_average?this.state.messaging_stats.produce_duration_average.toFixed(2):0}</Descriptions.Item>
+                  <Descriptions.Item label="消费消息耗时">{this.state.messaging_stats.consume_duration_average?this.state.messaging_stats.consume_duration_average.toFixed(2):0}</Descriptions.Item>
+                  <Descriptions.Item label="平均消息大小">{this.state.messaging_stats.message_size_average?this.state.messaging_stats.message_size_average.toFixed(2):0}</Descriptions.Item>
+                  <Descriptions.Item label="主题数量">{this.state.messaging_stats.topic_num || 0}</Descriptions.Item>
+                </Descriptions>
+               <div className="bisic-title">主题统计信息</div>
+               <div>
+                 {this.state.messaging_stats.topic_stats.length > 0  && this.state.messaging_stats.topic_stats.map((item, index)=>{
+                   return <Descriptions key={index+item.name} >
+                     <Descriptions.Item label="主题名称" >{item.name || '--'}</Descriptions.Item>
+                     <Descriptions.Item label="生产消息数量">{item.produce_num || 0}</Descriptions.Item>
+                     <Descriptions.Item label="消费消息数量">{item.consume_num || 0}</Descriptions.Item>
+                     <Descriptions.Item label="生产消息错误率">{item.produce_error_rate ? (item.produce_error_rate*100).toFixed(2)+'%' :0}</Descriptions.Item>
+                     <Descriptions.Item label="消费消息错误率">{item.consume_error_rate?(item.consume_error_rate*!100).toFixed(2)+ '%' :0}</Descriptions.Item>
+                     <Descriptions.Item label="生产消息平均耗时">{item.produce_duration_average?item.produce_duration_average.toFixed(2):0}</Descriptions.Item>
+                     <Descriptions.Item label="消费消息耗时">{item.consume_duration_average?item.consume_duration_average.toFixed(2):0}</Descriptions.Item>
+                     <Descriptions.Item label="平均消息大小">{item.message_size_average?item.message_size_average.toFixed(2):0}</Descriptions.Item>
+                   </Descriptions>
+                 })}
+               </div>
+              </div>
+
+            )
+          }
+          {
+            this.props.shape == 'cloud' && (
+              <div className="node-details-content" >
+                <div style={{marginTop:'30px'}}>
+                  <div style={{width:'100%'}}>
+                    <h4>已知应用</h4>
+                    <Table dataSource={this.state.cloudTable}
+                    columns={cloudColums}
+
+                    rowKey={'app_alias'}
+                    expandedRowRender={(record) => expandedRowRender(record)}
+                    size='small' bordered pagination={false}>
+                    </Table>
+                  </div>
+                  <div style={{marginTop:'40px'}}>
+                    <h4>未知应用</h4>
+                    <div style={{paddingLeft: '50px'}}>
+                      <Table dataSource={this.state.cloudTable2}
+                      columns={cloudColums2}
+                      rowKey={`name`}  size='small' bordered pagination={false}>
+                      </Table>
+                    </div>
+                  </div>
+
+                </div>
+              </div>)
+          }
+        </div>
+      </Drawer>
+    );
+  }
+
+  //获取基础信息
+  getNodeBasic(){
+    axios({
+      url: `${this.state.baseUrl}/api/v1/apps_score/${this.state.queryParams.app_alias}/svr`,
+      method: "get",
+      headers: { 'Authorization': getToken },
+      params: {
+        source_service:this.props.id,
+        start_time:this.state.queryParams.start_time,
+        end_time:this.state.queryParams.end_time
+      }
+    }).then(res => {
+        if(res && res.data.code == 200){
+          const newObj= ((res ||{}).data || {}).data || {}
+          this.setState({
+            nodeData:{...newObj}
+          },()=>{
+            if(JSON.stringify(this.state.nodeData)!="{}"){
+              this.state.nodeData.apdex>=0.94?this.setState({bgColor:setNodeColor('G')})
+              :(this.state.nodeData.apdex>=0.85&&this.state.nodeData.apdex<0.94)?this.setState({bgColor:setNodeColor('B')})
+              :(this.state.nodeData.apdex>=0.7&&this.state.nodeData.apdex<0.85)?this.setState({bgColor:setNodeColor('DI')})
+              :(this.state.nodeData.apdex>=0.5&&this.state.nodeData.apdex<0.7)?this.setState({bgColor:setNodeColor('Y')})
+              :this.setState({bgColor:setNodeColor('R')})
+            }else{
+              this.setState({bgColor:setNodeColor('R')})
+            }
+          })
+        }
+    });
+  }
+
+  //获取散点图
+  getNodeAnalyst(){
+    axios({
+      url: `${this.state.baseUrl}/api/v1/app/analyst/${this.state.queryParams.app_alias}/svr`,
+      method: "get",
+      headers: { 'Authorization': getToken },
+      params: {
+        source_service:this.props.id,
+        start_time:this.state.queryParams.start_time,
+        end_time:this.state.queryParams.end_time,
+        percentile:this.state.queryParams.percentile
+      }
+    }).then(res => {
+        if(res && res.data.code == 200){
+          const obj = ((res || {}).data ||{}).data || {}
+          this.setState({
+            AnalystData:{...obj}
+          },()=>{
+            if(JSON.stringify(this.state.AnalystData)!="{}"){
+              this.initChart(this.state.AnalystData)
+            }
+          })
+        }
+    });
+  }
+
+  // 散点图渲染
+  initChart(tmpData) {
+    let chart = echarts.getInstanceByDom(document.getElementById("main"));
+    if (chart == null) {
+      chart = echarts.init(document.getElementById("main"));
+    }else {
+      chart.dispose();
+      chart = echarts.init(document.getElementById("main"));
+    }
+    let successData = tmpData.success && tmpData.success.map(item => new Date(item[0]).toLocaleString()) || []
+    let failedData = tmpData.failed && tmpData.failed.map(item => new Date(item[0]).toLocaleString()) || []
+    let option = {
+      title: {
+        text: '',
+        subtext: '',
+        textStyle:{
+          fontSize:14
+        },
+      },
+      grid: {
+        top:'8%',
+        left: '3%',
+        right: '7%',
+        bottom: '14%',
+        containLabel: true
+      },
+      tooltip: {
+        showDelay: 0,
+        formatter: function (params) {
+          let newParams = moment(params.data[0]).format('YYYY-MM-DD HH:mm:ss');
+          let time = newParams+"<br/>"+ params.data[1]+"ms"
+          return time;
+        },
+        axisPointer: {
+          show: true,
+          type: 'cross',
+          lineStyle: {
+            type: 'dashed',
+            width: 1
+          }
+        }
+      },
+      toolbox: {
+        show:true,
+        showTitle: true,
+        feature: {
+          rect: {
+            show: true,
+            title: 'Trace选择'
+          },
+          brush: {
+            type: ["rect"], // 开启矩形选择
+            show: true,//是否显示 这里我们直接true
+            iconStyle: {
+              opacity: 0,//通过opacity设置为0隐藏图标
+            },
+          }
+        },
+        left:"40%",                              //组件离容器左侧的距离,'left', 'center', 'right','20%'
+        top:"-3%",                                   //组件离容器上侧的距离,'top', 'middle', 'bottom','20%'
+        right:"auto",                               //组件离容器右侧的距离,'20%'
+        bottom:"auto",
+      },
+      brush: {
+        toolbox: ['rect'],
+        xAxisIndex: 0,
+        throttleType:'debounce',
+        throttleDelay:600
+      },
+      legend: {
+        data: ['成功', '失败'],
+        left: 'center',
+        bottom: 0,
+        itemGap: 100,
+        textStyle: {//文字颜色
+          fontSize: 12,
+          padding:[0,3],//文字与图形之间的左右间距
+          rich:{
+            labelName:{
+              fontSize:14,
+              color:'#333',
+              fontWeight:500
+            }
+          }
+        },
+        formatter: function (params) {
+          // 获取legend显示内容
+          let data = tmpData;
+          let sl,fl;
+          if(data.success!=null){
+            sl = tmpData.success.length;
+          }else{
+            sl = 0
+          }
+          if( data.failed!=null){
+            fl = tmpData.failed.length;
+          }else{
+            fl = 0
+          }
+          var target;
+          if(params == '成功'){
+            target = sl;
+          }else if(params == '失败'){
+            target = fl;
+          }
+          return target != undefined?params +' '+`{labelName|${target}}`:params
+        },
+      },
+      xAxis: [
+        {
+
+          type: 'time',
+          data: [
+          ...successData,
+          ...failedData
+          ],
+          gridIndex:0,
+          axisLabel: {
+            show:true,
+            rotate: 45, // 旋转标签,适用于标签较长的情况
+            interval: 'auto',
+            margin: 10, // 增加刻度标签间隔
+            textStyle: {
+              fontSize: 10,
+              textAlign:'center'
+            },
+            formatter: function(params) {
+              let newParams = moment(params).format('HH:mm:ss');
+              let time = newParams
+              return time;
+            }
+          },
+          splitLine: {
+            show: true
+          },
+        }
+      ],
+      yAxis: [
+        {
+          type: 'value',
+          // scale: true,
+          gridIndex:0,
+          axisLabel: {
+            formatter: '{value}'
+          },
+          axisLine:{
+            show:true
+          },
+          axisTick:{
+            show:true
+          },
+          splitLine: {
+            show: true
+          },
+          data:[0,2500,5000,7500,10000],
+        }
+      ],
+      series: [
+        {
+          name: '成功',
+          type: 'scatter',
+          emphasis: {
+            focus: 'series'
+          },
+          //设置散点图样式
+          itemStyle:{
+            color:'#13ce66'
+          },
+          symbolSize:10,//设置散点的大小
+          data:tmpData.success,
+          markArea: {
+            silent: true,
+            itemStyle: {
+              color: 'transparent',
+              borderWidth: 0,
+              borderType: 'dashed'
+            },
+          },
+        },
+        {
+          name: '失败',
+          type: 'scatter',
+          emphasis: {
+            focus: 'series'
+          },
+          itemStyle:{
+            color:'#ff4949'
+          },
+          // prettier-ignore
+          data:tmpData.failed,
+          // data:[],
+          markArea: {
+            silent: true,
+            itemStyle: {
+              color: 'transparent',
+              borderWidth: 0,
+              borderType: 'dashed'
+            },
+          },
+        }
+      ]
+    }
+    chart.setOption(option,true)
+    // 默认开启框选
+    chart.dispatchAction({
+      type: 'takeGlobalCursor',
+      key: 'brush',
+      brushOption: {
+        brushType: 'rect' // 指定选框类型
+      }
+    })
+    chart.off("brushSelected");
+    //框选选择数据
+    chart.on('brushSelected', (params) => {
+
+      var brushComponent = params.batch[0];
+
+      let successIndexList=[];
+      let failIndexList =[];
+      let successList=[];
+      let failList=[];
+        if(brushComponent.selected.length>1){
+          successIndexList = brushComponent.selected[0].dataIndex
+          failIndexList = brushComponent.selected[1].dataIndex
+        }else{
+          if(brushComponent.selected[0].seriesName !=undefined){
+            if(brushComponent.selected[0].seriesName =="失败"){
+              failIndexList = brushComponent.selected[0].dataIndex
+            }else{
+              successIndexList = brushComponent.selected[0].dataIndex
+            }
+          }
+        }
+
+        if(successIndexList.length>0){
+            for(let i = 0;i<tmpData.success.length;i++){
+              for(let j=0;j<successIndexList.length;j++){
+                if(successIndexList[j] == i){
+                  successList.push(tmpData.success[i])
+                }
+              }
+            }
+        }
+
+        if(failIndexList.length>0){
+          for(let k=0;k<tmpData.failed.length;k++){
+            for(let l=0;l<failIndexList.length;l++){
+              if(failIndexList[l] == k){
+                failList.push(tmpData.failed[k])
+              }
+            }
+          }
+        }
+
+        let arr =successList.concat(failList);
+        let dataRange={};
+        if(arr.length>0){
+            let timeArr =[];
+            let valueArr=[];
+            for(let m=0;m<arr.length;m++){
+              timeArr.push(Math.round(Date.parse(arr[m][0])/1000));
+              valueArr.push(arr[m][1]);
+            }
+
+            let minTime = Math.min(...timeArr);
+            let maxTime = Math.max(...timeArr);
+
+            let minValue = Math.min(...valueArr);
+            let maxValue = Math.max(...valueArr)
+
+            if(minTime == maxTime){
+              minTime = minTime-1;
+              maxTime = maxTime+1;
+            }
+
+            if(minValue == maxValue){
+              minValue = minValue-1;
+              maxValue = maxValue+1;
+            }
+
+
+            //看是否有成功节点
+            if(successIndexList.length>0){
+                dataRange = {
+                start_time:minTime,
+                end_time:maxTime,
+                min_duration:minValue,
+                max_duration:maxValue,
+                failed:false,
+                app_alias:this.state.queryParams.app_alias,
+                service_name:this.props.id,
+              }
+            }else{
+              dataRange = {
+                start_time:minTime,
+                end_time:maxTime,
+                min_duration:minValue,
+                max_duration:maxValue,
+                failed:true,
+                app_alias:this.state.queryParams.app_alias,
+                service_name:this.props.id,
+              }
+            }
+
+
+            let timeAndDuration = JSON.stringify(dataRange);
+
+            // let href = this.$router.resolve({
+            //   path:'/latency/index',
+            //   query:{
+            //     data:timeAndDuration
+            //   }
+            // })
+            // window.open(window.location.origin+"/"+href.href,"_blank")
+
+
+            let href = `${this.state.traceUrl}/#/latency/index?start_time=${dataRange.start_time}&end_time=${dataRange.end_time}&min_duration=${dataRange.min_duration}&max_duration=${dataRange.max_duration}&failed=${dataRange.failed}&app_alias=${this.state.queryParams.app_alias}&service_name=${this.props.id}`
+              window.open(href,"_blank")
+              this.timeoutId = setTimeout(()=>{
+              chart.dispatchAction({
+                  type: 'brush',//选择action行为
+                  areas:[]//areas表示选框的集合,此时为空即可。
+              });
+            },500)
+
+        }
+
+
+    });
+
+    window.addEventListener("resize",function (){
+      chart.resize();
+    });
+  }
+
+  //获取折线图
+  // http://127.0.0.1:8000/api/v1/service/liveness?service_name={service_name}
+  getNodeLiveness(){
+    axios({
+      url: `${this.state.baseUrl}/api/v1/service/liveness`,
+      method: "get",
+      headers: { 'Authorization': getToken },
+      params: {
+        service_name:this.props.id,
+        start_time:this.state.queryParams.start_time,
+        end_time:this.state.queryParams.end_time,
+      }
+    }).then(res => {
+        if(res && res.data.code == 200){
+          const list = ((res || {}).data || {}).data || []
+          this.setState({
+            livenessData:[...list]
+          },()=>{
+            if(this.state.livenessData.length>0){
+              this.initLiveness(this.state.livenessData);
+            }
+          })
+        }
+    });
+  }
+  //渲染折线图
+  initLiveness(data){
+    let liveChart = echarts.getInstanceByDom(document.getElementById("box"));
+    if (liveChart == null) {
+      liveChart = echarts.init(document.getElementById("box"));
+    }else {
+      liveChart.dispose();
+      liveChart = echarts.init(document.getElementById("box"));
+    }
+    let option = {
+      grid: {
+        top:'5%',
+        left: '1%',
+        right: '1%',
+        bottom: '5%',
+        containLabel: true
+      },
+      tooltip: {
+        showDelay: 0,
+        formatter: function (params) {
+          let newParams = moment(params.data[0]).format('YYYY-MM-DD HH:mm:ss');
+          let time = newParams+"<br/>"+ params.data[1]+"ms"
+          return time;
+        },
+        axisPointer: {
+          show: true,
+          type: 'cross',
+          lineStyle: {
+            type: 'dashed',
+            width: 1
+          }
+        }
+      },
+      xAxis: {
+        type: 'time',
+        boundaryGap: false,
+        // splitNumber: 3,
+        axisLabel: {
+          show:true,
+          rotate: 45, // 旋转标签,适用于标签较长的情况
+          interval: 'auto',
+          margin: 10, // 增加刻度标签间隔
+          textStyle: {
+            fontSize: 10,
+            textAlign:'center'
+          },
+          formatter: function(params) {
+            let newParams = moment(params).format('HH:mm:ss');
+            let time = newParams
+            return time;
+          }
+        },
+        splitLine: {
+          show: true
+        },
+      },
+      yAxis: {
+        type: 'value',
+        boundaryGap: [0, '30%']
+      },
+      visualMap: {
+        type: 'piecewise',
+        show: false,
+        dimension: 0,
+        seriesIndex: 0,
+        pieces: [
+          {
+            gt: 1,
+            lt: 3,
+            color: 'rgba(0, 0, 180, 0.4)'
+          },
+          {
+            gt: 5,
+            lt: 7,
+            color: 'rgba(0, 0, 180, 0.4)'
+          }
+        ]
+      },
+      series: [
+        {
+          type: 'line',
+          smooth: 0.6,
+          symbol: 'none',
+          lineStyle: {
+            color: '#5470C6',
+            width: 1
+          },
+          markLine: {
+            symbol: ['none', 'none'],
+            label: { show: false },
+            data: [{ xAxis: 1 }, { xAxis: 3 }, { xAxis: 5 }, { xAxis: 7 }]
+          },
+          areaStyle: {},
+          data: data
+        }
+      ]
+    };
+    liveChart.setOption(option,true)
+  }
+
+  //获取异常trace列表 /api/v1/service/spans
+  getServiceSpans(){
+    this.setState({ loading: true });
+    this.setState({
+      traceData:[],
+      pagination:{total:0}
+    },()=>{
+    })
+    axios({
+      url: `${this.state.baseUrl}/api/v1/service/spans`,
+      method: "get",
+      headers: { 'Authorization': getToken },
+      params: {
+        service_name:this.props.id,
+        only_exception:this.state.traceQuery.only_exception,  // 仅显示异常trace相关
+        only_database:this.state.traceQuery.only_database,
+        pageIndex:this.state.pagination.pageIndex,
+        pageSize:this.state.pagination.pageSize,
+        start_time:this.state.queryParams.start_time,
+        end_time:this.state.queryParams.end_time,
+        app_alias:this.state.queryParams.app_alias,
+        sort_field:'Timestamp',
+        sort_type: this.state.queryParams.sort_type
+      }
+    }).then(res => {
+
+        if(res && res.data.code == 200){
+          const list = (((res || {}).data || {}).data || {}).list || []
+          const total = (((res || {}).data || {}).data || {}).count || 0
+          this.setState({
+            traceData:[...list],
+            pagination:{...this.state.pagination,total:total}
+          },()=>{
+          })
+        }
+    });
+  }
+  //获取table 数据
+  getTableData(){
+    this.setState({ loading: true });
+    this.setState({
+      tableData:[],
+      pagination2:{total:0}
+    },()=>{
+    })
+    axios({
+      url: `${this.state.coreBaseUrl}/v1/system-component/reqlist`,
+      method: "get",
+      headers: { 'Authorization': getToken },
+      params: {
+        page_num:this.state.pagination2.page_num,
+        page_size:this.state.pagination2.page_size,
+        start_time:this.state.queryParams.start_time,
+        end_time:this.state.queryParams.end_time,
+        app_alias:this.state.queryParams.app_alias,
+        component: this.props.id//
+      }
+    }).then(res => {
+
+        if(res && res.data.code == 200){
+          const list = (((res || {}).data || {}).data || {}).list || []
+          const total = (((res || {}).data || {}).data || {}).total || 0
+          const page_num = (((res || {}).data || {}).data || {}).page_num || 1
+          const page_size = (((res || {}).data || {}).data || {}).page_size || 10
+          this.setState({
+            tableData:[...list],
+            pagination2:{page_num:page_num,page_size:page_size,total:total}
+          },()=>{
+          })
+        }
+    });
+  }
+  getBarData(){// 获取柱状图数据
+    this.setState({ loading: true });
+    this.setState({
+      tableData:[],
+      pagination2:{total:0}
+    },()=>{
+    })
+    axios({
+      url: `${this.state.coreBaseUrl}/v1/system-component/stats`,
+      method: "get",
+      headers: { 'Authorization': getToken },
+      params: {
+        start_time:this.state.queryParams.start_time,
+        end_time:this.state.queryParams.end_time,
+        app_alias:this.state.queryParams.app_alias,
+        component: this.props.id //
+      }
+    }).then(res => {
+      if(res && res.data.code == 200){
+        const list = res?.data?.data?.database_stats?.request_bar || []
+        this.setState({
+          barData:list
+        },()=>{
+          if(this.state.barData.length>0){
+            setTimeout(()=>{
+              this.initLineBar(this.state.barData);
+            },0)
+          }
+        })
+      }
+    });
+  }
+
+  // 渲染调用统计 折线/柱形图
+  initLineBar(data){
+    let liveChart = echarts.getInstanceByDom(document.getElementById("chartContent"));
+    let xAxisArr = data.map(item => item.start_time) || []
+    let dataArr = data.map(item => item.total) || []
+
+    if (liveChart == null) {
+      liveChart = echarts.init(document.getElementById("chartContent"));
+    } else {
+      liveChart.dispose();
+      liveChart = echarts.init(document.getElementById("chartContent"));
+    }
+    let option = {
+      xAxis: {
+        type: 'category',
+        data: xAxisArr,
+        axisLabel: {
+          show:true,
+          rotate: 45, // 旋转标签,适用于标签较长的情况
+          interval: 'auto',
+          margin: 10, // 增加刻度标签间隔
+          textStyle: {
+            fontSize: 10,
+            textAlign:'center'
+          },
+          formatter: function(params) {
+            let newParams = moment(params).format('HH:mm:ss');
+            let time = newParams
+            return time;
+          }
+        },
+      },
+      tooltip: {
+        showDelay: 0,
+        axisPointer: {
+          show: true,
+          type: 'cross',
+          lineStyle: {
+            type: 'dashed',
+            width: 1
+          }
+        }
+      },
+      yAxis: {
+        type: 'value'
+      },
+      series: [
+        {
+          data: dataArr,
+          type: 'bar'
+        }
+      ]
+    };
+    liveChart.setOption(option,true)
+  }
+  handleTableChange=(pagination,filters, sorter, extra)=>{
+    const {current} = pagination
+    let sort = ''
+    if(sorter && sorter.order == 'ascend'){
+      sort = 'ASC'
+    } else if(sorter && sorter.order == 'descend'){
+      sort = 'DESC'
+    }
+
+    this.setState({
+      pagination: {...this.state.pagination,pageIndex:current,current:current},
+      queryParams:{...this.state.queryParams,sort_type:sort}
+    },()=>{
+      this.getServiceSpans();
+    });
+  }
+  handleTableChange2=(pagination,filters, sorter, extra)=>{
+    const {current} = pagination
+    this.setState({
+      pagination2: {...this.state.pagination2,page_num:current}
+    },()=>{
+      this.getTableData();
+    });
+  }
+  //仅异常
+  onChangeError = e =>{
+    const only_exception = e.target.checked ? 1:0;
+    const pageIndex = 1
+    const current = 1
+    this.setState({
+      traceQuery:{...this.state.traceQuery,only_exception:only_exception},
+      pagination: {...this.state.pagination,pageIndex:pageIndex,current:current},
+    },()=>{
+      this.getServiceSpans();
+    });
+
+  }
+  //仅sql
+  onChangeSql = e =>{
+    const only_database = e.target.checked ? 1:0;
+    const pageIndex = 1
+    const current = 1
+    this.setState({
+      traceQuery:{...this.state.traceQuery,only_database:only_database},
+      pagination: {...this.state.pagination,pageIndex:pageIndex,current:current},
+    },()=>{
+      this.getServiceSpans(this.props.source,this.props.target);
+    });
+  }
+
+   //单选按钮
+  onChange = e => {
+    const percentile = e.target.value
+    this.setState({
+      queryParams:{...this.state.queryParams,percentile:percentile},
+      AnalystData:[]
+    },()=>{
+      this.getNodeAnalyst();
+    });
+  };
+
+  renderTable(table) {
+    const { nodeMatches = makeMap() } = this.props;
+
+    if (isGenericTable(table)) {
+      return (
+        <NodeDetailsGenericTable
+          rows={table.rows}
+          columns={table.columns}
+          matches={nodeMatches.get('tables')}
+        />
+      );
+    } if (isPropertyList(table)) {
+      return (
+        <NodeDetailsPropertyList
+          rows={table.rows}
+          controls={table.controls}
+          matches={nodeMatches.get('property-lists')}
+        />
+      );
+    }
+
+    log(`Undefined type '${table.type}' for table ${table.id}`);
+    return null;
+  }
+
+  componentDidUpdate() {
+    this.updateTitle();
+  }
+
+  updateTitle() {
+    setDocumentTitle(this.props.details && this.props.details.label);
+  }
+
+  //dottedcylinder 类型 -获取基础信息
+  getDottBasicsData(){
+    this.setState({ loading: true });
+    this.setState({
+      messaging_stats:{
+        topic_stats:[]
+      }
+    },()=>{
+    })
+    axios({
+      url: `${this.state.coreBaseUrl}/v1/system-component/stats`,
+      method: "get",
+      headers: { 'Authorization': getToken },
+      params: {
+        start_time:this.state.queryParams.start_time,
+        end_time:this.state.queryParams.end_time,
+        app_alias:this.state.queryParams.app_alias,
+        component: this.props.id//
+      }
+    }).then(res => {
+
+        if(res && res.data.code == 200){
+          const messaging_stats = (((res || {}).data || {}).data || {}).messaging_stats || {}
+          this.setState({
+            messaging_stats:messaging_stats
+          },()=>{
+          })
+
+        }
+    });
+  }
+
+  // cloud 类型-获取嵌套table
+  getCloudTableData(){// 获取柱状图数据
+    this.setState({ loading: true });
+    this.setState({
+      cloudTable:[],
+      cloudTable2:[],
+    },()=>{
+    })
+    axios({
+      url: `${this.state.coreBaseUrl}/v1/service/related-apps`,
+      method: "get",
+      headers: { 'Authorization': getToken },
+      params: {
+        start_time:this.state.queryParams.start_time,
+        end_time:this.state.queryParams.end_time,
+        app_alias:this.state.queryParams.app_alias,
+        type: this.props.id //
+      }
+    }).then(res => {
+      if(res && res.data.code == 200){
+        const list = (((res || {}).data ||{}).data || {}).app_list || []
+        const list2 = (((res ||{}).data ||{}).data ||{}).client_list || []
+
+        this.setState({
+          cloudTable:[...list],
+          cloudTable2: [...list2]
+        },()=>{
+        })
+      }
+    });
+  }
+}
+
+NodeDetails.propTypes = {
+  renderNodeDetailsExtras: PropTypes.func,
+};
+
+NodeDetails.defaultProps = {
+  renderNodeDetailsExtras: noop,
+};
+
+function mapStateToProps(state, ownProps) {
+  const currentTopologyId = state.get('currentTopologyId');
+  return {
+    nodeMatches: state.getIn(['searchNodeMatches', currentTopologyId, ownProps.id]),
+    nodes: state.get('nodes'),
+    selectedNodeId: state.get('selectedNodeId'),
+    transitioning: state.get('pausedAt') !== ownProps.timestamp,
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { clickCloseDetails, clickShowTopologyForNode }
+)(NodeDetails);

+ 30 - 0
app/scripts/components/node-details/__tests__/node-details-health-link-item-test.js

@@ -0,0 +1,30 @@
+import moment from 'moment';
+
+import { appendTime } from '../node-details-health-link-item';
+
+
+describe('NodeDetailsHealthLinkItem', () => {
+  describe('appendTime', () => {
+    const time = '2017-06-01T00:00:00Z';
+    const timeUnix = moment(time).unix();
+
+    it('returns url for empty url or time', () => {
+      expect(appendTime('', time)).toEqual('');
+      expect(appendTime('foo', null)).toEqual('foo');
+      expect(appendTime('', null)).toEqual('');
+    });
+
+    it('appends as json for cloud link', () => {
+      const url = appendTime('/prom/:instanceid/notebook/new/%7B%22cells%22%3A%5B%7B%22queries%22%3A%5B%22go_goroutines%22%5D%7D%5D%7D', time);
+      expect(url).toContain(timeUnix);
+
+      const payload = JSON.parse(decodeURIComponent(url.substr(url.indexOf('new/') + 4)));
+      expect(payload.time.queryEnd).toEqual(timeUnix);
+    });
+
+    it('appends as GET parameter', () => {
+      expect(appendTime('http://example.test?q=foo', time)).toEqual('http://example.test?q=foo&time=1496275200');
+      expect(appendTime('http://example.test/q=foo/', time)).toEqual('http://example.test/q=foo/?time=1496275200');
+    });
+  });
+});

+ 161 - 0
app/scripts/components/node-details/__tests__/node-details-table-test.js

@@ -0,0 +1,161 @@
+import React from 'react';
+import TestUtils from 'react-dom/test-utils';
+import { Provider } from 'react-redux';
+import configureStore from '../../../stores/configureStore';
+
+// need ES5 require to keep automocking off
+const NodeDetailsTable = require('../node-details-table.js').default;
+
+describe('NodeDetailsTable', () => {
+  let nodes;
+  let columns;
+  let component;
+
+  beforeEach(() => {
+    columns = [
+      { dataType: 'ip', id: 'kubernetes_ip', label: 'IP' },
+      { id: 'kubernetes_namespace', label: 'Namespace' },
+      { dataType: 'duration', id: 'uptime', label: 'Uptime' },
+    ];
+    nodes = [
+      {
+        id: 'node-1',
+        metadata: [
+          { id: 'kubernetes_ip', label: 'IP', value: '10.244.253.24' },
+          { id: 'kubernetes_namespace', label: 'Namespace', value: '1111' },
+          {
+            dataType: 'duration', id: 'uptime', label: 'Uptime', value: '1'
+          },
+        ]
+      }, {
+        id: 'node-2',
+        metadata: [
+          { id: 'kubernetes_ip', label: 'IP', value: '10.244.253.4' },
+          { id: 'kubernetes_namespace', label: 'Namespace', value: '12' },
+          {
+            dataType: 'duration', id: 'uptime', label: 'Uptime', value: '4'
+          },
+        ]
+      }, {
+        id: 'node-3',
+        metadata: [
+          { id: 'kubernetes_ip', label: 'IP', value: '10.44.253.255' },
+          { id: 'kubernetes_namespace', label: 'Namespace', value: '5' },
+          {
+            dataType: 'duration', id: 'uptime', label: 'Uptime', value: '30'
+          },
+        ]
+      }, {
+        id: 'node-4',
+        metadata: [
+          { id: 'kubernetes_ip', label: 'IP', value: '10.244.253.100' },
+          { id: 'kubernetes_namespace', label: 'Namespace', value: '00000' },
+          {
+            dataType: 'duration', id: 'uptime', label: 'Uptime', value: '22222'
+          },
+        ]
+      },
+    ];
+  });
+
+  function matchColumnValues(columnLabel, expectedValues) {
+    // Get the index of the column whose values we want to match.
+    const columnIndex = columns.findIndex(column => column.id === columnLabel);
+    // Get all the values rendered in the table.
+    const values = TestUtils
+      .scryRenderedDOMComponentsWithClass(component, 'node-details-table-node-value')
+      .map(d => d.title);
+    // Since we are interested only in the values that appear in the column `columnIndex`,
+    // we drop the rest. As `values` are ordered by appearance in the DOM structure
+    // (that is, first by row and then by column), the indexes we are interested in are of the
+    // form columnIndex + n * columns.length, where n >= 0. Therefore we take only the values
+    // at the index which divided by columns.length gives a reminder columnIndex.
+    const filteredValues = values.filter(
+      (element, index) => index % columns.length === columnIndex
+    );
+    // Array comparison
+    expect(filteredValues).toEqual(expectedValues);
+  }
+
+  function clickColumn(title) {
+    const node = TestUtils.scryRenderedDOMComponentsWithTag(component, 'td')
+      .find(d => d.title === title);
+    TestUtils.Simulate.click(node.children[0]);
+  }
+
+  describe('kubernetes_ip', () => {
+    it('sorts by column', () => {
+      component = TestUtils.renderIntoDocument((
+        <Provider store={configureStore()}>
+          <NodeDetailsTable
+            columns={columns}
+            sortedBy="kubernetes_ip"
+            nodeIdKey="id"
+            nodes={nodes}
+          />
+        </Provider>
+      ));
+
+      matchColumnValues('kubernetes_ip', [
+        '10.44.253.255',
+        '10.244.253.4',
+        '10.244.253.24',
+        '10.244.253.100'
+      ]);
+      clickColumn('IP');
+      matchColumnValues('kubernetes_ip', [
+        '10.244.253.100',
+        '10.244.253.24',
+        '10.244.253.4',
+        '10.44.253.255'
+      ]);
+      clickColumn('IP');
+      matchColumnValues('kubernetes_ip', [
+        '10.44.253.255',
+        '10.244.253.4',
+        '10.244.253.24',
+        '10.244.253.100'
+      ]);
+    });
+  });
+
+  describe('kubernetes_namespace', () => {
+    it('sorts by column', () => {
+      component = TestUtils.renderIntoDocument((
+        <Provider store={configureStore()}>
+          <NodeDetailsTable
+            columns={columns}
+            sortedBy="kubernetes_namespace"
+            nodeIdKey="id"
+            nodes={nodes}
+          />
+        </Provider>
+      ));
+
+      matchColumnValues('kubernetes_namespace', ['00000', '1111', '12', '5']);
+      clickColumn('Namespace');
+      matchColumnValues('kubernetes_namespace', ['5', '12', '1111', '00000']);
+      clickColumn('Namespace');
+      matchColumnValues('kubernetes_namespace', ['00000', '1111', '12', '5']);
+    });
+  });
+
+  describe('uptime duration', () => {
+    it('sorts by column', () => {
+      component = TestUtils.renderIntoDocument((
+        <Provider store={configureStore()}>
+          <NodeDetailsTable
+            columns={columns}
+            sortedBy="uptime"
+            nodeIdKey="id"
+            nodes={nodes}
+          />
+        </Provider>
+      ));
+
+      matchColumnValues('uptime', ['1 second', '4 seconds', '30 seconds', '6 hours']);
+      clickColumn('Uptime');
+      matchColumnValues('uptime', ['6 hours', '30 seconds', '4 seconds', '1 second']);
+    });
+  });
+});

+ 38 - 0
app/scripts/components/node-details/node-details-control-button.js

@@ -0,0 +1,38 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { isEmpty } from 'lodash';
+import classNames from 'classnames';
+
+import { trackAnalyticsEvent } from '../../utils/tracking-utils';
+import { doControl } from '../../actions/request-actions';
+
+class NodeDetailsControlButton extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  render() {
+    const { icon, id, human } = this.props.control;
+    const className = classNames('tour-step-anchor node-control-button', icon, {
+      // Old Agent / plugins don't include the 'fa ' prefix, so provide it if they don't.
+      fa: icon.startsWith('fa-'),
+      'node-control-button-pending': this.props.pending
+    });
+    return (
+      <i className={className} data-id={id} title={human} onClick={this.handleClick} />
+    );
+  }
+
+  handleClick(ev) {
+    ev.preventDefault();
+    const { id, human, confirmation } = this.props.control;
+    trackAnalyticsEvent('scope.node.control.click', { id, title: human });
+    if (isEmpty(confirmation) || window.confirm(confirmation)) { // eslint-disable-line no-alert
+      this.props.dispatch(doControl(this.props.nodeId, this.props.control));
+    }
+  }
+}
+
+// Using this instead of PureComponent because of props.dispatch
+export default connect()(NodeDetailsControlButton);

+ 39 - 0
app/scripts/components/node-details/node-details-controls.js

@@ -0,0 +1,39 @@
+import React from 'react';
+import { sortBy } from 'lodash';
+
+import NodeDetailsControlButton from './node-details-control-button';
+
+export default function NodeDetailsControls({
+  controls, error, nodeId, pending
+}) {
+  let spinnerClassName = 'fa fa-circle-notch fa-spin';
+  if (pending) {
+    spinnerClassName += ' node-details-controls-spinner';
+  } else {
+    spinnerClassName += ' node-details-controls-spinner hide';
+  }
+
+  return (
+    <div className="node-details-controls">
+      {error
+        && (
+        <div className="node-details-controls-error" title={error}>
+          <i className="node-details-controls-error-icon fa fa-exclamation-triangle" />
+          <span className="node-details-controls-error-messages">{error}</span>
+        </div>
+        )
+      }
+      <span className="node-details-controls-buttons">
+        {sortBy(controls, 'rank').map(control => (
+          <NodeDetailsControlButton
+            nodeId={nodeId}
+            control={control}
+            pending={pending}
+            key={control.id}
+          />
+        ))}
+      </span>
+      {controls && <span title="Applying..." className={spinnerClassName} />}
+    </div>
+  );
+}

+ 128 - 0
app/scripts/components/node-details/node-details-generic-table.js

@@ -0,0 +1,128 @@
+import React from 'react';
+import sortBy from 'lodash/sortBy';
+import { Map as makeMap } from 'immutable';
+
+import { NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT } from '../../constants/limits';
+
+import {
+  isNumeric,
+  getTableColumnsStyles,
+  genericTableEntryKey
+} from '../../utils/node-details-utils';
+import NodeDetailsTableHeaders from './node-details-table-headers';
+import MatchedText from '../matched-text';
+import ShowMore from '../show-more';
+
+
+function sortedRows(rows, columns, sortedBy, sortedDesc) {
+  const column = columns.find(c => c.id === sortedBy);
+  const sorted = sortBy(rows, (row) => {
+    let value = row.entries[sortedBy];
+    if (isNumeric(column)) {
+      value = parseFloat(value);
+    }
+    return value;
+  });
+  if (sortedDesc) {
+    sorted.reverse();
+  }
+  return sorted;
+}
+
+export default class NodeDetailsGenericTable extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.state = {
+      limit: NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT,
+      sortedBy: props.columns && props.columns[0].id,
+      sortedDesc: true
+    };
+    this.handleLimitClick = this.handleLimitClick.bind(this);
+    this.updateSorted = this.updateSorted.bind(this);
+  }
+
+  updateSorted(sortedBy, sortedDesc) {
+    this.setState({ sortedBy, sortedDesc });
+  }
+
+  handleLimitClick() {
+    this.setState(prevState => ({
+      limit: prevState.limit ? 0 : NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT
+    }));
+  }
+
+  render() {
+    const { sortedBy, sortedDesc } = this.state;
+    const { columns, matches = makeMap() } = this.props;
+    const expanded = this.state.limit === 0;
+
+    let rows = this.props.rows || [];
+    let notShown = 0;
+
+    // If there are rows that would be hidden behind 'show more', keep them
+    // expanded if any of them match the search query; otherwise hide them.
+    if (this.state.limit > 0 && rows.length > this.state.limit) {
+      const hasHiddenMatch = rows
+        .slice(this.state.limit)
+        .some(
+          row => columns.some(column => matches.has(genericTableEntryKey(row, column)))
+        );
+      if (!hasHiddenMatch) {
+        notShown = rows.length - NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT;
+        rows = rows.slice(0, this.state.limit);
+      }
+    }
+
+    const styles = getTableColumnsStyles(columns);
+    return (
+      <div className="node-details-generic-table">
+        <table>
+          <thead>
+            <NodeDetailsTableHeaders
+              headers={columns}
+              sortedBy={sortedBy}
+              sortedDesc={sortedDesc}
+              onClick={this.updateSorted}
+            />
+          </thead>
+          <tbody>
+            {sortedRows(rows, columns, sortedBy, sortedDesc).map(row => (
+              <tr className="node-details-generic-table-row" key={row.id}>
+                {columns.map((column, index) => {
+                  const match = matches.get(genericTableEntryKey(row, column));
+                  const value = row.entries[column.id];
+                  return (
+                    <td
+                      className="node-details-generic-table-value truncate"
+                      title={value}
+                      key={column.id}
+                      style={styles[index]}>
+                      {column.dataType === 'link'
+                        ? (
+                          <a
+                            rel="noopener noreferrer"
+                            target="_blank"
+                            className="node-details-table-node-link"
+                            href={value}>
+                            {value}
+                          </a>
+                        )
+                        : <MatchedText text={value} match={match} />
+                      }
+                    </td>
+                  );
+                })}
+              </tr>
+            ))}
+          </tbody>
+        </table>
+        <ShowMore
+          handleClick={this.handleLimitClick}
+          collection={this.props.rows}
+          expanded={expanded}
+          notShown={notShown}
+        />
+      </div>
+    );
+  }
+}

+ 29 - 0
app/scripts/components/node-details/node-details-health-item.js

@@ -0,0 +1,29 @@
+import React from 'react';
+
+import Sparkline from '../sparkline';
+import { formatMetric } from '../../utils/string-utils';
+
+function NodeDetailsHealthItem(props) {
+  const labelStyle = { color: props.labelColor };
+  return (
+    <div className="node-details-health-item">
+      {!props.valueEmpty && <div className="node-details-health-item-value" style={labelStyle}>{formatMetric(props.value, props)}</div>}
+      <div className="node-details-health-item-sparkline">
+        <Sparkline
+          data={props.samples}
+          max={props.max}
+          format={props.format}
+          first={props.first}
+          last={props.last}
+          hoverColor={props.metricColor}
+          hovered={props.hovered}
+        />
+      </div>
+      <div className="node-details-health-item-label" style={labelStyle}>
+        {props.label}
+      </div>
+    </div>
+  );
+}
+
+export default NodeDetailsHealthItem;

+ 103 - 0
app/scripts/components/node-details/node-details-health-link-item.js

@@ -0,0 +1,103 @@
+import React from 'react';
+import moment from 'moment';
+import { connect } from 'react-redux';
+import stableStringify from 'json-stable-stringify';
+
+import NodeDetailsHealthItem from './node-details-health-item';
+import CloudLink from '../cloud-link';
+import { getMetricColor } from '../../utils/metric-utils';
+import { darkenColor } from '../../utils/color-utils';
+import { trackAnalyticsEvent } from '../../utils/tracking-utils';
+
+/**
+ * @param {string} url
+ * @param {string} time
+ * @returns {string}
+ */
+export function appendTime(url, time) {
+  if (!url || !time) return url;
+
+  // rudimentary check whether we have a cloud link
+  const cloudLinkPathEnd = 'notebook/new/';
+  const pos = url.indexOf(cloudLinkPathEnd);
+  const timeUnix = moment(time).unix();
+  if (pos !== -1) {
+    let payload;
+    const json = decodeURIComponent(url.substr(pos + cloudLinkPathEnd.length));
+    try {
+      payload = JSON.parse(json);
+      payload.time = { queryEnd: timeUnix };
+    } catch (e) {
+      return url;
+    }
+
+    return `${url.substr(0, pos + cloudLinkPathEnd.length)}${encodeURIComponent(stableStringify(payload) || '')}`;
+  }
+
+  if (url.indexOf('?') !== -1) {
+    return `${url}&time=${timeUnix}`;
+  }
+  return `${url}?time=${timeUnix}`;
+}
+
+class NodeDetailsHealthLinkItem extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      hovered: false
+    };
+
+    this.onMouseOver = this.onMouseOver.bind(this);
+    this.onMouseOut = this.onMouseOut.bind(this);
+    this.onClick = this.onClick.bind(this);
+  }
+
+  onMouseOver() {
+    this.setState({hovered: true});
+  }
+
+  onMouseOut() {
+    this.setState({hovered: false});
+  }
+
+  onClick() {
+    trackAnalyticsEvent('scope.node.metric.click', { topologyId: this.props.topologyId });
+  }
+
+  render() {
+    const {
+      id, url, monitor, pausedAt, ...props
+    } = this.props;
+    const metricColor = getMetricColor(id);
+    const labelColor = this.state.hovered && !props.valueEmpty && darkenColor(metricColor);
+
+    const timedUrl = monitor === true ? appendTime(url, pausedAt) : '';
+
+    return (
+      <CloudLink
+        alwaysShow
+        className="node-details-health-link-item"
+        onMouseOver={this.onMouseOver}
+        onMouseOut={this.onMouseOut}
+        onClick={this.onClick}
+        url={timedUrl}
+      >
+        <NodeDetailsHealthItem
+          {...props}
+          hovered={this.state.hovered}
+          labelColor={labelColor}
+          metricColor={metricColor}
+        />
+      </CloudLink>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    monitor: state.get('monitor'),
+    pausedAt: state.get('pausedAt'),
+  };
+}
+
+export default connect(mapStateToProps)(NodeDetailsHealthLinkItem);

+ 69 - 0
app/scripts/components/node-details/node-details-health.js

@@ -0,0 +1,69 @@
+import React from 'react';
+
+import ShowMore from '../show-more';
+import NodeDetailsHealthLinkItem from './node-details-health-link-item';
+
+export default class NodeDetailsHealth extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.state = {
+      expanded: false
+    };
+    this.handleClickMore = this.handleClickMore.bind(this);
+  }
+
+  handleClickMore() {
+    this.setState(prevState => ({
+      expanded: !prevState.expanded
+    }));
+  }
+
+  render() {
+    const {
+      metrics = [],
+      topologyId,
+    } = this.props;
+
+    let primeMetrics = metrics.filter(m => !m.valueEmpty);
+    let emptyMetrics = metrics.filter(m => m.valueEmpty);
+
+    if (primeMetrics.length === 0 && emptyMetrics.length > 0) {
+      primeMetrics = emptyMetrics;
+      emptyMetrics = [];
+    }
+
+    const shownWithData = this.state.expanded ? primeMetrics : primeMetrics.slice(0, 3);
+    const shownEmpty = this.state.expanded ? emptyMetrics : [];
+    const notShown = metrics.length - shownWithData.length - shownEmpty.length;
+
+    return (
+      <div className="node-details-health" style={{ justifyContent: 'space-around' }}>
+        <div className="node-details-health-wrapper">
+          {shownWithData.map(item => (
+            <NodeDetailsHealthLinkItem
+              {...item}
+              key={item.id}
+              topologyId={topologyId}
+            />
+          ))}
+        </div>
+        <div className="node-details-health-wrapper">
+          {shownEmpty.map(item => (
+            <NodeDetailsHealthLinkItem
+              {...item}
+              key={item.id}
+              topologyId={topologyId}
+            />
+          ))}
+        </div>
+        <ShowMore
+          handleClick={this.handleClickMore}
+          collection={metrics}
+          expanded={this.state.expanded}
+          notShown={notShown}
+          hideNumber={this.state.expanded}
+        />
+      </div>
+    );
+  }
+}

+ 88 - 0
app/scripts/components/node-details/node-details-info.js

@@ -0,0 +1,88 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { Map as makeMap } from 'immutable';
+
+import MatchedText from '../matched-text';
+import ShowMore from '../show-more';
+import { formatDataType } from '../../utils/string-utils';
+
+
+class NodeDetailsInfo extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.state = {
+      expanded: false
+    };
+    this.handleClickMore = this.handleClickMore.bind(this);
+  }
+
+  handleClickMore() {
+    this.setState(prevState => ({
+      expanded: !prevState.expanded
+    }));
+  }
+
+  render() {
+    const { timestamp, matches = makeMap() } = this.props;
+    let rows = (this.props.rows || []);
+    let notShown = 0;
+
+    const prime = rows.filter(row => row.priority < 10);
+    if (!this.state.expanded && prime.length < rows.length) {
+      // check if there is a search match in non-prime fields
+      const hasNonPrimeMatch = matches && rows.filter(row => row.priority >= 10
+        && matches.has(row.id)).length > 0;
+      if (!hasNonPrimeMatch) {
+        notShown = rows.length - prime.length;
+        rows = prime;
+      }
+    }
+
+    return (
+      <div className="node-details-info">
+        {rows.map((field) => {
+          const { value, title } = formatDataType(field, timestamp);
+          return (
+            <div className="node-details-info-field" key={field.id}>
+              <div className="node-details-info-field-label truncate" title={field.label}>
+                {field.label}
+              </div>
+              <div className="node-details-info-field-value truncate" title={title}>
+                {field.dataType === 'link'
+                  ? (
+                    <a
+                      rel="noopener noreferrer"
+                      target="_blank"
+                      className="truncate node-details-table-node-link"
+                      href={value}>
+                      {value}
+                    </a>
+                  )
+                  : (
+                    <MatchedText
+                      text={value}
+                      truncate={field.truncate}
+                      match={matches.get(field.id)} />
+                  )
+                }
+              </div>
+            </div>
+          );
+        })}
+        <ShowMore
+          handleClick={this.handleClickMore}
+          collection={this.props.rows}
+          expanded={this.state.expanded}
+          notShown={notShown} />
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    timestamp: state.get('pausedAt'),
+  };
+}
+
+export default connect(mapStateToProps)(NodeDetailsInfo);

+ 77 - 0
app/scripts/components/node-details/node-details-property-list.js

@@ -0,0 +1,77 @@
+import React from 'react';
+import { Map as makeMap } from 'immutable';
+import sortBy from 'lodash/sortBy';
+
+import { NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT } from '../../constants/limits';
+import NodeDetailsControlButton from './node-details-control-button';
+import MatchedText from '../matched-text';
+import ShowMore from '../show-more';
+
+const Controls = controls => (
+  <div className="node-details-property-list-controls">
+    {sortBy(controls, 'rank').map(control => (
+      <NodeDetailsControlButton
+        nodeId={control.nodeId}
+        control={control}
+        key={control.id} />
+    ))}
+  </div>
+);
+
+export default class NodeDetailsPropertyList extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.state = {
+      limit: NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT,
+    };
+    this.handleLimitClick = this.handleLimitClick.bind(this);
+  }
+
+  handleLimitClick() {
+    this.setState(prevState => ({
+      limit: prevState.limit ? 0 : NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT
+    }));
+  }
+
+  render() {
+    const { controls, matches = makeMap() } = this.props;
+    let { rows } = this.props;
+    let notShown = 0;
+    const limited = rows && this.state.limit > 0 && rows.length > this.state.limit;
+    const expanded = this.state.limit === 0;
+    if (rows && limited) {
+      const hasNotShownMatch = rows.filter((row, index) => index >= this.state.limit
+        && matches.has(row.id)).length > 0;
+      if (!hasNotShownMatch) {
+        notShown = rows.length - NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT;
+        rows = rows.slice(0, this.state.limit);
+      }
+    }
+
+    return (
+      <div className="node-details-property-list">
+        {controls && Controls(controls)}
+        {rows.map(field => (
+          <div className="node-details-property-list-field" key={field.id}>
+            <div
+              className="node-details-property-list-field-label truncate"
+              title={field.entries.label}
+              key={field.id}>
+              {field.entries.label}
+            </div>
+            <div
+              className="node-details-property-list-field-value truncate"
+              title={field.entries.value}>
+              <MatchedText text={field.entries.value} match={matches.get(field.id)} />
+            </div>
+          </div>
+        ))}
+        <ShowMore
+          handleClick={this.handleLimitClick}
+          collection={this.props.rows}
+          expanded={expanded}
+          notShown={notShown} />
+      </div>
+    );
+  }
+}

+ 49 - 0
app/scripts/components/node-details/node-details-relatives-link.js

@@ -0,0 +1,49 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { clickRelative } from '../../actions/request-actions';
+import { trackAnalyticsEvent } from '../../utils/tracking-utils';
+import MatchedText from '../matched-text';
+
+
+class NodeDetailsRelativesLink extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.handleClick = this.handleClick.bind(this);
+    this.saveNodeRef = this.saveNodeRef.bind(this);
+  }
+
+  handleClick(ev) {
+    ev.preventDefault();
+    trackAnalyticsEvent('scope.node.relative.click', {
+      topologyId: this.props.topologyId,
+    });
+    this.props.dispatch(clickRelative(
+      this.props.id,
+      this.props.topologyId,
+      this.props.label,
+      this.node.getBoundingClientRect()
+    ));
+  }
+
+  saveNodeRef(ref) {
+    this.node = ref;
+  }
+
+  render() {
+    const title = `View in ${this.props.topologyId}: ${this.props.label}`;
+    return (
+      <span
+        className="node-details-relatives-link"
+        title={title}
+        onClick={this.handleClick}
+        ref={this.saveNodeRef}>
+        <MatchedText text={this.props.label} match={this.props.match} />
+      </span>
+    );
+  }
+}
+
+// Using this instead of PureComponent because of props.dispatch
+export default connect()(NodeDetailsRelativesLink);

+ 55 - 0
app/scripts/components/node-details/node-details-relatives.js

@@ -0,0 +1,55 @@
+import React from 'react';
+import { Map as makeMap } from 'immutable';
+
+import { NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT } from '../../constants/limits';
+import NodeDetailsRelativesLink from './node-details-relatives-link';
+
+export default class NodeDetailsRelatives extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.state = {
+      limit: NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT
+    };
+    this.handleLimitClick = this.handleLimitClick.bind(this);
+  }
+
+  handleLimitClick(ev) {
+    ev.preventDefault();
+    this.setState(prevState => ({
+      limit: prevState.limit ? 0 : NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT
+    }));
+  }
+
+  render() {
+    let { relatives } = this.props;
+    const { matches = makeMap() } = this.props;
+
+    const limited = this.state.limit > 0 && relatives.length > this.state.limit;
+    const showLimitAction = limited || (this.state.limit === 0
+      && relatives.length > NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT);
+    const limitActionText = limited ? 'Show more' : 'Show less';
+    if (limited) {
+      relatives = relatives.slice(0, this.state.limit);
+    }
+
+    return (
+      <div className="node-details-relatives">
+        {relatives.map(relative => (
+          <NodeDetailsRelativesLink
+            key={relative.id}
+            match={matches.get(relative.id)}
+            {...relative} />
+        ))}
+        {showLimitAction
+          && (
+            <span
+              className="node-details-relatives-more"
+              onClick={this.handleLimitClick}>
+              {limitActionText}
+            </span>
+          )
+        }
+      </div>
+    );
+  }
+}

+ 55 - 0
app/scripts/components/node-details/node-details-table-headers.js

@@ -0,0 +1,55 @@
+import React from 'react';
+import { defaultSortDesc, getTableColumnsStyles } from '../../utils/node-details-utils';
+import { NODE_DETAILS_TABLE_CW, NODE_DETAILS_TABLE_XS_LABEL } from '../../constants/styles';
+
+
+export default class NodeDetailsTableHeaders extends React.Component {
+  handleClick(ev, headerId, currentSortedBy, currentSortedDesc) {
+    ev.preventDefault();
+    const header = this.props.headers.find(h => h.id === headerId);
+    const sortedBy = header.id;
+    const sortedDesc = sortedBy === currentSortedBy
+      ? !currentSortedDesc : defaultSortDesc(header);
+    this.props.onClick(sortedBy, sortedDesc);
+  }
+
+  render() {
+    const { headers, sortedBy, sortedDesc } = this.props;
+    const colStyles = getTableColumnsStyles(headers);
+    return (
+      <tr>
+        {headers.map((header, index) => {
+          const headerClasses = ['node-details-table-header', 'truncate'];
+          const onClick = (ev) => {
+            this.handleClick(ev, header.id, sortedBy, sortedDesc);
+          };
+          // sort by first metric by default
+          const isSorted = header.id === sortedBy;
+          const isSortedDesc = isSorted && sortedDesc;
+          const isSortedAsc = isSorted && !isSortedDesc;
+
+          if (isSorted) {
+            headerClasses.push('node-details-table-header-sorted');
+          }
+
+          const style = colStyles[index];
+          const label = (
+            style.width === NODE_DETAILS_TABLE_CW.XS && NODE_DETAILS_TABLE_XS_LABEL[header.id]
+          ) ? NODE_DETAILS_TABLE_XS_LABEL[header.id] : header.label;
+
+          return (
+            <td className={headerClasses.join(' ')} style={style} title={header.label} key={header.id}>
+              <div className="node-details-table-header-sortable" onClick={onClick}>
+                {isSortedAsc
+                  && <i className="node-details-table-header-sorter fa fa-caret-up" />}
+                {isSortedDesc
+                  && <i className="node-details-table-header-sorter fa fa-caret-down" />}
+                {label}
+              </div>
+            </td>
+          );
+        })}
+      </tr>
+    );
+  }
+}

+ 53 - 0
app/scripts/components/node-details/node-details-table-node-link.js

@@ -0,0 +1,53 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { clickRelative } from '../../actions/request-actions';
+import { trackAnalyticsEvent } from '../../utils/tracking-utils';
+import { dismissRowClickProps } from '../../utils/dom-utils';
+
+
+class NodeDetailsTableNodeLink extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.handleClick = this.handleClick.bind(this);
+    this.saveNodeRef = this.saveNodeRef.bind(this);
+  }
+
+  handleClick(ev) {
+    ev.preventDefault();
+    trackAnalyticsEvent('scope.node.relative.click', {
+      topologyId: this.props.topologyId,
+    });
+    this.props.dispatch(clickRelative(
+      this.props.nodeId,
+      this.props.topologyId,
+      this.props.label,
+      this.node.getBoundingClientRect()
+    ));
+  }
+
+  saveNodeRef(ref) {
+    this.node = ref;
+  }
+
+  render() {
+    const { label, labelMinor } = this.props;
+    const title = !labelMinor ? label : `${label} (${labelMinor})`;
+
+    return (
+      <span
+        className="node-details-table-node-link"
+        title={title}
+        ref={this.saveNodeRef}
+        onClick={this.handleClick}
+        {...dismissRowClickProps}
+      >
+        {label}
+      </span>
+    );
+  }
+}
+
+// Using this instead of PureComponent because of props.dispatch
+export default connect()(NodeDetailsTableNodeLink);

+ 43 - 0
app/scripts/components/node-details/node-details-table-node-metric-link.js

@@ -0,0 +1,43 @@
+import React from 'react';
+
+import CloudLink from '../cloud-link';
+import { formatMetric } from '../../utils/string-utils';
+import { trackAnalyticsEvent } from '../../utils/tracking-utils';
+import { dismissRowClickProps } from '../../utils/dom-utils';
+
+class NodeDetailsTableNodeMetricLink extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.onClick = this.onClick.bind(this);
+  }
+
+  onClick() {
+    trackAnalyticsEvent('scope.node.metric.click', { topologyId: this.props.topologyId });
+  }
+
+  render() {
+    const {
+      url, style, value, valueEmpty
+    } = this.props;
+
+    return (
+      <td
+        className="node-details-table-node-metric"
+        style={style}
+        {...dismissRowClickProps}
+      >
+        <CloudLink
+          alwaysShow
+          url={url}
+          className={url && 'node-details-table-node-metric-link'}
+          onClick={this.onClick}
+        >
+          {!valueEmpty && formatMetric(value, this.props)}
+        </CloudLink>
+      </td>
+    );
+  }
+}
+
+export default NodeDetailsTableNodeMetricLink;

+ 187 - 0
app/scripts/components/node-details/node-details-table-row.js

@@ -0,0 +1,187 @@
+import React from 'react';
+import classNames from 'classnames';
+import { groupBy, mapValues } from 'lodash';
+import { intersperse } from '../../utils/array-utils';
+
+
+import NodeDetailsTableNodeLink from './node-details-table-node-link';
+import NodeDetailsTableNodeMetricLink from './node-details-table-node-metric-link';
+import { formatDataType } from '../../utils/string-utils';
+
+function getValuesForNode(node) {
+  let values = {};
+  ['metrics', 'metadata'].forEach((collection) => {
+    if (node[collection]) {
+      node[collection].forEach((field) => {
+        const result = Object.assign({}, field);
+        result.valueType = collection;
+        values[field.id] = result;
+      });
+    }
+  });
+
+  if (node.parents) {
+    const byTopologyId = groupBy(node.parents, parent => parent.topologyId);
+    const relativesByTopologyId = mapValues(byTopologyId, (relatives, topologyId) => ({
+      id: topologyId,
+      label: topologyId,
+      relatives,
+      value: relatives.map(relative => relative.label).join(', '),
+      valueType: 'relatives',
+    }));
+
+    values = {
+      ...values,
+      ...relativesByTopologyId,
+    };
+  }
+
+  return values;
+}
+
+
+function renderValues(node, columns = [], columnStyles = [], timestamp = null, topologyId = null) {
+  const fields = getValuesForNode(node);
+  return columns.map(({ id }, i) => {
+    const field = fields[id];
+    const style = columnStyles[i];
+    if (field) {
+      if (field.valueType === 'metadata') {
+        const { value, title } = formatDataType(field, timestamp);
+        return (
+          <td
+            className="node-details-table-node-value truncate"
+            title={title}
+            style={style}
+            key={field.id}>
+            {field.dataType === 'link'
+              ? (
+                <a
+                  rel="noopener noreferrer"
+                  target="_blank"
+                  className="node-details-table-node-link"
+                  href={value}>
+                  {value}
+                </a>
+              )
+              : value}
+          </td>
+        );
+      }
+      if (field.valueType === 'relatives') {
+        return (
+          <td
+            className="node-details-table-node-value truncate"
+            title={field.value}
+            style={style}
+            key={field.id}>
+            {intersperse(field.relatives.map(relative => (
+              <NodeDetailsTableNodeLink
+                key={relative.id}
+                linkable
+                nodeId={relative.id}
+                {...relative}
+              />
+            )), ' ')}
+          </td>
+        );
+      }
+      // valueType === 'metrics'
+      return (
+        <NodeDetailsTableNodeMetricLink
+          style={style}
+          key={field.id}
+          topologyId={topologyId}
+          {...field} />
+      );
+    }
+    // empty cell to complete the row for proper hover
+    return (
+      <td className="node-details-table-node-value" style={style} key={id} />
+    );
+  });
+}
+
+export default class NodeDetailsTableRow extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    //
+    // We watch how far the mouse moves when click on a row, move to much and we assume that the
+    // user is selecting some data in the row. In this case don't trigger the onClick event which
+    // is most likely a details panel popping open.
+    //
+    this.state = { focused: false };
+    this.mouseDrag = {};
+  }
+
+  onMouseEnter = () => {
+    this.setState({ focused: true });
+    if (this.props.onMouseEnter) {
+      this.props.onMouseEnter(this.props.index, this.props.node);
+    }
+  }
+
+  onMouseLeave = () => {
+    this.setState({ focused: false });
+    if (this.props.onMouseLeave) {
+      this.props.onMouseLeave();
+    }
+  }
+
+  onMouseDown = (ev) => {
+    this.mouseDrag = {
+      originX: ev.pageX,
+      originY: ev.pageY,
+    };
+  }
+
+  onClick = (ev) => {
+    const thresholdPx = 2;
+    const { pageX, pageY } = ev;
+    const { originX, originY } = this.mouseDrag;
+    const movedTheMouseTooMuch = (
+      Math.abs(originX - pageX) > thresholdPx
+      || Math.abs(originY - pageY) > thresholdPx
+    );
+    if (movedTheMouseTooMuch && originX && originY) {
+      return;
+    }
+
+    this.props.onClick(ev, this.props.node);
+    this.mouseDrag = {};
+  }
+
+  render() {
+    const {
+      node, nodeIdKey, topologyId, columns, onClick, colStyles, timestamp
+    } = this.props;
+    const [firstColumnStyle, ...columnStyles] = colStyles;
+    const values = renderValues(node, columns, columnStyles, timestamp, topologyId);
+    const nodeId = node[nodeIdKey];
+
+    const className = classNames('tour-step-anchor node-details-table-node', {
+      focused: this.state.focused,
+      selected: this.props.selected,
+    });
+
+    return (
+      <tr
+        onClick={onClick && this.onClick}
+        onMouseDown={onClick && this.onMouseDown}
+        onMouseEnter={this.onMouseEnter}
+        onMouseLeave={this.onMouseLeave}
+        className={className}>
+        <td className="node-details-table-node-label truncate" style={firstColumnStyle}>
+          {this.props.renderIdCell(Object.assign(node, { nodeId, topologyId }))}
+        </td>
+        {values}
+      </tr>
+    );
+  }
+}
+
+
+NodeDetailsTableRow.defaultProps = {
+  renderIdCell: props => <NodeDetailsTableNodeLink {...props} />
+};

+ 327 - 0
app/scripts/components/node-details/node-details-table.js

@@ -0,0 +1,327 @@
+import React from 'react';
+import classNames from 'classnames';
+import { connect } from 'react-redux';
+import {
+  find, get, union, sortBy, groupBy, concat, debounce
+} from 'lodash';
+
+import { NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT } from '../../constants/limits';
+import { TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL } from '../../constants/timer';
+
+import ShowMore from '../show-more';
+import NodeDetailsTableRow from './node-details-table-row';
+import NodeDetailsTableHeaders from './node-details-table-headers';
+import { ipToPaddedString } from '../../utils/string-utils';
+import { moveElement, insertElement } from '../../utils/array-utils';
+import {
+  isIP, isNumeric, defaultSortDesc, getTableColumnsStyles
+} from '../../utils/node-details-utils';
+
+
+function getDefaultSortedBy(columns, nodes) {
+  // default sorter specified by columns
+  const defaultSortColumn = find(columns, { defaultSort: true });
+  if (defaultSortColumn) {
+    return defaultSortColumn.id;
+  }
+  // otherwise choose first metric
+  const firstNodeWithMetrics = find(nodes, n => get(n, ['metrics', 0]));
+  if (firstNodeWithMetrics) {
+    return get(firstNodeWithMetrics, ['metrics', 0, 'id']);
+  }
+
+  return 'label';
+}
+
+
+function maybeToLower(value) {
+  if (!value || !value.toLowerCase) {
+    return value;
+  }
+  return value.toLowerCase();
+}
+
+
+function getNodeValue(node, header) {
+  const fieldId = header && header.id;
+  if (fieldId !== null) {
+    let field = union(node.metrics, node.metadata).find(f => f.id === fieldId);
+
+    if (field) {
+      if (isIP(header)) {
+        // Format the IPs so that they are sorted numerically.
+        return ipToPaddedString(field.value);
+      } if (isNumeric(header)) {
+        return parseFloat(field.value);
+      }
+      return field.value;
+    }
+
+    if (node.parents) {
+      field = node.parents.find(f => f.topologyId === fieldId);
+      if (field) {
+        return field.label;
+      }
+    }
+
+    if (node[fieldId] !== undefined && node[fieldId] !== null) {
+      return node[fieldId];
+    }
+  }
+
+  return null;
+}
+
+
+function getValueForSortedBy(sortedByHeader) {
+  return node => maybeToLower(getNodeValue(node, sortedByHeader));
+}
+
+
+function getMetaDataSorters(nodes) {
+  // returns an array of sorters that will take a node
+  return get(nodes, [0, 'metadata'], []).map((field, index) => (node) => {
+    const nodeMetadataField = node.metadata && node.metadata[index];
+    if (nodeMetadataField) {
+      if (isNumeric(nodeMetadataField)) {
+        return parseFloat(nodeMetadataField.value);
+      }
+      return nodeMetadataField.value;
+    }
+    return null;
+  });
+}
+
+
+function sortNodes(nodes, getValue, sortedDesc) {
+  const sortedNodes = sortBy(
+    nodes,
+    getValue,
+    getMetaDataSorters(nodes)
+  );
+  if (sortedDesc) {
+    sortedNodes.reverse();
+  }
+  return sortedNodes;
+}
+
+
+function getSortedNodes(nodes, sortedByHeader, sortedDesc) {
+  const getValue = getValueForSortedBy(sortedByHeader);
+  const withAndWithoutValues = groupBy(nodes, (n) => {
+    if (!n || n.valueEmpty) {
+      return 'withoutValues';
+    }
+    const v = getValue(n);
+    return v !== null && v !== undefined ? 'withValues' : 'withoutValues';
+  });
+  const withValues = sortNodes(withAndWithoutValues.withValues, getValue, sortedDesc);
+  const withoutValues = sortNodes(withAndWithoutValues.withoutValues, getValue, sortedDesc);
+
+  return concat(withValues, withoutValues);
+}
+
+
+// By inserting this fake invisible row into the table, with the help of
+// some CSS trickery, we make the inner scrollable content of the table
+// have a minimal height. That prevents auto-scroll under a focus if the
+// number of table rows shrinks.
+function minHeightConstraint(height = 0) {
+  return <tr className="min-height-constraint" style={{ height }} />;
+}
+
+
+class NodeDetailsTable extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.state = {
+      limit: props.limit,
+      sortedBy: this.props.sortedBy,
+      sortedDesc: this.props.sortedDesc
+    };
+    this.focusState = {};
+
+    this.updateSorted = this.updateSorted.bind(this);
+    this.handleLimitClick = this.handleLimitClick.bind(this);
+    this.onMouseLeaveRow = this.onMouseLeaveRow.bind(this);
+    this.onMouseEnterRow = this.onMouseEnterRow.bind(this);
+    this.saveTableContentRef = this.saveTableContentRef.bind(this);
+    this.saveTableHeadRef = this.saveTableHeadRef.bind(this);
+    // Use debouncing to prevent event flooding when e.g. crossing fast with mouse cursor
+    // over the whole table. That would be expensive as each focus causes table to rerender.
+    this.debouncedFocusRow = debounce(this.focusRow, TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL);
+    this.debouncedBlurRow = debounce(this.blurRow, TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL);
+  }
+
+  updateSorted(sortedBy, sortedDesc) {
+    this.setState({ sortedBy, sortedDesc });
+    this.props.onSortChange(sortedBy, sortedDesc);
+  }
+
+  handleLimitClick() {
+    this.setState(prevState => ({
+      limit: prevState.limit ? 0 : this.props.limit
+    }));
+  }
+
+  focusRow(rowIndex, node) {
+    // Remember the focused row index, the node that was focused and
+    // the table content height so that we can keep the node row fixed
+    // without auto-scrolling happening.
+    // NOTE: It would be ideal to modify the real component state here,
+    // but that would cause whole table to rerender, which becomes to
+    // expensive with the current implementation if the table consists
+    // of 1000+ nodes.
+    this.focusState = {
+      focusedNode: node,
+      focusedRowIndex: rowIndex,
+      tableContentMinHeightConstraint: this.tableContentRef && this.tableContentRef.scrollHeight,
+    };
+  }
+
+  blurRow() {
+    // Reset the focus state
+    this.focusState = {};
+  }
+
+  onMouseEnterRow(rowIndex, node) {
+    this.debouncedBlurRow.cancel();
+    this.debouncedFocusRow(rowIndex, node);
+  }
+
+  onMouseLeaveRow() {
+    this.debouncedFocusRow.cancel();
+    this.debouncedBlurRow();
+  }
+
+  saveTableContentRef(ref) {
+    this.tableContentRef = ref;
+  }
+
+  saveTableHeadRef(ref) {
+    this.tableHeadRef = ref;
+  }
+
+  getColumnHeaders() {
+    const columns = this.props.columns || [];
+    return [{ id: 'label', label: this.props.label }].concat(columns);
+  }
+
+  componentDidMount() {
+    const scrollbarWidth = this.tableContentRef.offsetWidth - this.tableContentRef.clientWidth;
+    this.tableHeadRef.style.paddingRight = `${scrollbarWidth}px`;
+  }
+
+  render() {
+    const {
+      nodeIdKey, columns, topologyId, onClickRow,
+      onMouseEnter, onMouseLeave, timestamp
+    } = this.props;
+
+    const sortedBy = this.state.sortedBy || getDefaultSortedBy(columns, this.props.nodes);
+    const sortedByHeader = this.getColumnHeaders().find(h => h.id === sortedBy);
+    const sortedDesc = (this.state.sortedDesc === null)
+      ? defaultSortDesc(sortedByHeader) : this.state.sortedDesc;
+
+    let nodes = getSortedNodes(this.props.nodes, sortedByHeader, sortedDesc);
+
+    const { focusedNode, focusedRowIndex, tableContentMinHeightConstraint } = this.focusState;
+    if (Number.isInteger(focusedRowIndex) && focusedRowIndex < nodes.length) {
+      const nodeRowIndex = nodes.findIndex(node => node.id === focusedNode.id);
+      if (nodeRowIndex >= 0) {
+        // If the focused node still exists in the table, we move it
+        // to the hovered row, keeping the rest of the table sorted.
+        nodes = moveElement(nodes, nodeRowIndex, focusedRowIndex);
+      } else {
+        // Otherwise we insert the dead focused node there, pretending
+        // it's still alive. That enables the users to read off all the
+        // info they want and perhaps even open the details panel. Also,
+        // only if we do this, we can guarantee that mouse hover will
+        // always freeze the table row until we focus out.
+        nodes = insertElement(nodes, focusedRowIndex, focusedNode);
+      }
+    }
+
+    // If we are 1 over the limit, we still show the row. We never display
+    // "+1" but only "+2" and up.
+    const limit = this.state.limit > 0 && nodes.length === this.state.limit + 1
+      ? nodes.length
+      : this.state.limit;
+    const limited = nodes && limit > 0 && nodes.length > limit;
+    const expanded = limit === 0;
+    const notShown = nodes.length - limit;
+    if (nodes && limited) {
+      nodes = nodes.slice(0, limit);
+    }
+
+    const className = classNames('node-details-table-wrapper-wrapper', this.props.className);
+    const headers = this.getColumnHeaders();
+    const styles = getTableColumnsStyles(headers);
+
+    return (
+      <div className={className} style={this.props.style}>
+        <div className="node-details-table-wrapper">
+          <table className="node-details-table">
+            <thead ref={this.saveTableHeadRef}>
+              {this.props.nodes && this.props.nodes.length > 0 && (
+                <NodeDetailsTableHeaders
+                  headers={headers}
+                  sortedBy={sortedBy}
+                  sortedDesc={sortedDesc}
+                  onClick={this.updateSorted}
+                />
+              )}
+            </thead>
+            <tbody
+              style={this.props.tbodyStyle}
+              ref={this.saveTableContentRef}
+              onMouseEnter={onMouseEnter}
+              onMouseLeave={onMouseLeave}>
+              {nodes && nodes.map((node, index) => (
+                <NodeDetailsTableRow
+                  key={node.id}
+                  renderIdCell={this.props.renderIdCell}
+                  selected={this.props.selectedNodeId === node.id}
+                  node={node}
+                  index={index}
+                  nodeIdKey={nodeIdKey}
+                  colStyles={styles}
+                  columns={columns}
+                  onClick={onClickRow}
+                  onMouseEnter={this.onMouseEnterRow}
+                  onMouseLeave={this.onMouseLeaveRow}
+                  timestamp={timestamp}
+                  topologyId={topologyId} />
+              ))}
+              {minHeightConstraint(tableContentMinHeightConstraint)}
+            </tbody>
+          </table>
+          <ShowMore
+            handleClick={this.handleLimitClick}
+            collection={nodes}
+            expanded={expanded}
+            notShown={notShown} />
+        </div>
+      </div>
+    );
+  }
+}
+
+
+NodeDetailsTable.defaultProps = {
+  limit: NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT,
+  // key to identify a node in a row (used for topology links)
+  nodeIdKey: 'id',
+  onSortChange: () => { },
+  sortedBy: null,
+  sortedDesc: null,
+};
+
+function mapStateToProps(state) {
+  return {
+    timestamp: state.get('pausedAt'),
+  };
+}
+
+export default connect(mapStateToProps)(NodeDetailsTable);

+ 66 - 0
app/scripts/components/nodes-resources.js

@@ -0,0 +1,66 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import ZoomableCanvas from './zoomable-canvas';
+import NodesResourcesLayer from './nodes-resources/node-resources-layer';
+import { layersTopologyIdsSelector } from '../selectors/resource-view/layout';
+import {
+  resourcesLimitsSelector,
+  resourcesZoomStateSelector,
+} from '../selectors/resource-view/zoom';
+import { clickBackground } from '../actions/app-actions';
+
+import { CONTENT_COVERING } from '../constants/naming';
+
+
+class NodesResources extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.handleMouseClick = this.handleMouseClick.bind(this);
+  }
+
+  handleMouseClick() {
+    if (this.props.selectedNodeId) {
+      this.props.clickBackground();
+    }
+  }
+
+  renderLayers(transform) {
+    return this.props.layersTopologyIds.map((topologyId, index) => (
+      <NodesResourcesLayer
+        key={topologyId}
+        topologyId={topologyId}
+        transform={transform}
+        slot={index}
+      />
+    ));
+  }
+
+  render() {
+    return (
+      <div className="nodes-resources">
+        <ZoomableCanvas
+          onClick={this.handleMouseClick}
+          fixVertical
+          boundContent={CONTENT_COVERING}
+          limitsSelector={resourcesLimitsSelector}
+          zoomStateSelector={resourcesZoomStateSelector}>
+          {transform => this.renderLayers(transform)}
+        </ZoomableCanvas>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    layersTopologyIds: layersTopologyIdsSelector(state),
+    selectedNodeId: state.get('selectedNodeId'),
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { clickBackground }
+)(NodesResources);

+ 31 - 0
app/scripts/components/nodes-resources/node-resources-layer-topology.js

@@ -0,0 +1,31 @@
+import React from 'react';
+import pick from 'lodash/pick';
+
+import { applyTransform } from '../../utils/transform-utils';
+import {
+  RESOURCES_LAYER_TITLE_WIDTH,
+  RESOURCES_LAYER_HEIGHT,
+} from '../../constants/styles';
+
+
+export default class NodeResourcesLayerTopology extends React.Component {
+  render() {
+    // This component always has a fixed horizontal position and width,
+    // so we only apply the vertical zooming transformation to match the
+    // vertical position and height of the resource boxes.
+    const verticalTransform = pick(this.props.transform, ['translateY', 'scaleY']);
+    const { width, height, y } = applyTransform(verticalTransform, {
+      height: RESOURCES_LAYER_HEIGHT,
+      width: RESOURCES_LAYER_TITLE_WIDTH,
+      y: this.props.verticalPosition,
+    });
+
+    return (
+      <foreignObject width={width} height={height} y={y}>
+        <div className="node-resources-layer-topology" style={{ lineHeight: `${height}px` }}>
+          {this.props.topologyId}
+        </div>
+      </foreignObject>
+    );
+  }
+}

+ 57 - 0
app/scripts/components/nodes-resources/node-resources-layer.js

@@ -0,0 +1,57 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { Map as makeMap } from 'immutable';
+
+import NodeResourcesMetricBox from './node-resources-metric-box';
+import NodeResourcesLayerTopology from './node-resources-layer-topology';
+import {
+  layerVerticalPositionByTopologyIdSelector,
+  layoutNodesByTopologyIdSelector,
+} from '../../selectors/resource-view/layout';
+
+
+class NodesResourcesLayer extends React.Component {
+  render() {
+    const {
+      layerVerticalPosition, topologyId, transform, layoutNodes
+    } = this.props;
+
+    return (
+      <g className="node-resources-layer">
+        <g className="node-resources-metric-boxes">
+          {layoutNodes.toIndexedSeq().map(node => (
+            <NodeResourcesMetricBox
+              id={node.get('id')}
+              key={node.get('id')}
+              color={node.get('color')}
+              label={node.get('label')}
+              topologyId={topologyId}
+              metricSummary={node.get('metricSummary')}
+              width={node.get('width')}
+              height={node.get('height')}
+              x={node.get('offset')}
+              y={layerVerticalPosition}
+              transform={transform}
+            />
+          ))}
+        </g>
+        {!layoutNodes.isEmpty() && (
+        <NodeResourcesLayerTopology
+          verticalPosition={layerVerticalPosition}
+          transform={transform}
+          topologyId={topologyId}
+        />
+        )}
+      </g>
+    );
+  }
+}
+
+function mapStateToProps(state, props) {
+  return {
+    layerVerticalPosition: layerVerticalPositionByTopologyIdSelector(state).get(props.topologyId),
+    layoutNodes: layoutNodesByTopologyIdSelector(state).get(props.topologyId, makeMap()),
+  };
+}
+
+export default connect(mapStateToProps)(NodesResourcesLayer);

+ 47 - 0
app/scripts/components/nodes-resources/node-resources-metric-box-info.js

@@ -0,0 +1,47 @@
+import React from 'react';
+
+
+export default class NodeResourcesMetricBoxInfo extends React.Component {
+  humanizedMetricInfo() {
+    const {
+      humanizedTotalCapacity, humanizedAbsoluteConsumption,
+      humanizedRelativeConsumption, showCapacity, format
+    } = this.props.metricSummary.toJS();
+    const showExtendedInfo = showCapacity && format !== 'percent';
+
+    return (
+      <span>
+        <strong>
+          {showExtendedInfo ? humanizedRelativeConsumption : humanizedAbsoluteConsumption}
+        </strong>
+        {' '}
+used
+        {showExtendedInfo
+          && (
+          <i>
+            {' - '}
+(
+            {humanizedAbsoluteConsumption}
+            {' '}
+/
+            <strong>{humanizedTotalCapacity}</strong>
+)
+          </i>
+          )
+        }
+      </span>
+    );
+  }
+
+  render() {
+    const { width, x, y } = this.props;
+    return (
+      <foreignObject x={x} y={y} width={width} height="45px">
+        <div className="node-resources-metric-box-info">
+          <span className="wrapper label truncate">{this.props.label}</span>
+          <span className="wrapper consumption truncate">{this.humanizedMetricInfo()}</span>
+        </div>
+      </foreignObject>
+    );
+  }
+}

+ 177 - 0
app/scripts/components/nodes-resources/node-resources-metric-box.js

@@ -0,0 +1,177 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import theme from 'weaveworks-ui-components/lib/theme';
+
+import NodeResourcesMetricBoxInfo from './node-resources-metric-box-info';
+import { clickNode } from '../../actions/request-actions';
+import { trackAnalyticsEvent } from '../../utils/tracking-utils';
+import { applyTransform } from '../../utils/transform-utils';
+import { RESOURCE_VIEW_MODE } from '../../constants/naming';
+import {
+  RESOURCES_LAYER_TITLE_WIDTH,
+  RESOURCES_LABEL_MIN_SIZE,
+  RESOURCES_LABEL_PADDING,
+} from '../../constants/styles';
+
+
+// Transforms the rectangle box according to the zoom state forwarded by
+// the zooming wrapper. Two main reasons why we're doing it per component
+// instead of on the parent group are:
+//   1. Due to single-precision SVG coordinate system implemented by most browsers,
+//      the resource boxes would be incorrectly rendered on extreme zoom levels (it's
+//      not just about a few pixels here and there, the whole layout gets screwed). So
+//      we don't actually use the native SVG transform but transform the coordinates
+//      ourselves (with `applyTransform` helper).
+//   2. That also enables us to do the resources info label clipping, which would otherwise
+//      not be possible with pure zooming.
+//
+// The downside is that the rendering becomes slower as the transform prop needs to be forwarded
+// down to this component, so a lot of stuff gets rerendered/recalculated on every zoom action.
+// On the other hand, this enables us to easily leave out the nodes that are not in the viewport.
+const transformedDimensions = (props) => {
+  const {
+    width, height, x, y
+  } = applyTransform(props.transform, props);
+
+  // Trim the beginning of the resource box just after the layer topology
+  // name to the left and the viewport width to the right. That enables us
+  // to make info tags 'sticky', but also not to render the nodes with no
+  // visible part in the viewport.
+  const xStart = Math.max(RESOURCES_LAYER_TITLE_WIDTH, x);
+  const xEnd = Math.min(x + width, props.viewportWidth);
+
+  // Update the horizontal transform with trimmed values.
+  return {
+    height,
+    width: xEnd - xStart,
+    x: xStart,
+    y,
+  };
+};
+
+class NodeResourcesMetricBox extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.state = transformedDimensions(props);
+
+    this.handleClick = this.handleClick.bind(this);
+    this.saveNodeRef = this.saveNodeRef.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    this.setState(transformedDimensions(nextProps));
+  }
+
+  handleClick(ev) {
+    ev.stopPropagation();
+    trackAnalyticsEvent('scope.node.click', {
+      layout: RESOURCE_VIEW_MODE,
+      topologyId: this.props.topologyId,
+    });
+    this.props.clickNode(
+      this.props.id,
+      this.props.label,
+      this.nodeRef.getBoundingClientRect(),
+      this.props.topologyId,
+      this.props.shape
+    );
+  }
+
+  saveNodeRef(ref) {
+    this.nodeRef = ref;
+  }
+
+  defaultRectProps(relativeHeight = 1) {
+    const {
+      x, y, width, height
+    } = this.state;
+    const translateY = height * (1 - relativeHeight);
+    return {
+      height: height * relativeHeight,
+      opacity: this.props.contrastMode ? 1 : 0.85,
+      stroke: this.props.contrastMode ? 'black' : 'white',
+      transform: `translate(0, ${translateY})`,
+      width,
+      x,
+      y,
+    };
+  }
+
+  render() {
+    const { x, y, width } = this.state;
+    const {
+      id, selectedNodeId, label, color, metricSummary
+    } = this.props;
+    const { showCapacity, relativeConsumption, type } = metricSummary.toJS();
+    const opacity = (selectedNodeId && selectedNodeId !== id) ? 0.35 : 1;
+
+    const showInfo = width >= RESOURCES_LABEL_MIN_SIZE;
+    const showNode = width >= 1; // hide the thin nodes
+
+    // Don't display the nodes which are less than 1px wide.
+    // TODO: Show `+ 31 nodes` kind of tag in their stead.
+    if (!showNode) return null;
+
+    const resourceUsageTooltipInfo = showCapacity
+      ? metricSummary.get('humanizedRelativeConsumption')
+      : metricSummary.get('humanizedAbsoluteConsumption');
+
+    return (
+      <g
+        className="node-resources-metric-box"
+        style={{ opacity }}
+        onClick={this.handleClick}
+        ref={this.saveNodeRef}
+      >
+        <title>
+          {label}
+          {' '}
+-
+          {' '}
+          {type}
+          {' '}
+usage at
+          {' '}
+          {resourceUsageTooltipInfo}
+        </title>
+        {showCapacity && (
+        <rect
+          className="frame"
+          rx={theme.borderRadius.soft}
+          ry={theme.borderRadius.soft}
+          {...this.defaultRectProps()}
+        />
+        )}
+        <rect
+          className="bar"
+          fill={color}
+          rx={theme.borderRadius.soft}
+          ry={theme.borderRadius.soft}
+          {...this.defaultRectProps(relativeConsumption)}
+        />
+        {showInfo && (
+        <NodeResourcesMetricBoxInfo
+          label={label}
+          metricSummary={metricSummary}
+          width={width - (2 * RESOURCES_LABEL_PADDING)}
+          x={x + RESOURCES_LABEL_PADDING}
+          y={y + RESOURCES_LABEL_PADDING}
+        />
+        )}
+      </g>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    contrastMode: state.get('contrastMode'),
+    selectedNodeId: state.get('selectedNodeId'),
+    viewportWidth: state.getIn(['viewport', 'width']),
+  };
+}
+export default connect(
+  mapStateToProps,
+  { clickNode }
+)(NodeResourcesMetricBox);

+ 97 - 0
app/scripts/components/nodes.js

@@ -0,0 +1,97 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import NodesChart from '../charts/nodes-chart';
+import NodesGrid from '../charts/nodes-grid';
+import NodesResources from './nodes-resources';
+import NodesError from '../charts/nodes-error';
+import DelayedShow from '../utils/delayed-show';
+import { Loading, getNodeType } from './loading';
+import {
+  isTopologyNodeCountZero,
+  isNodesDisplayEmpty,
+} from '../utils/topology-utils';
+import { nodesLoadedSelector } from '../selectors/node-filters';
+import {
+  isGraphViewModeSelector,
+  isTableViewModeSelector,
+  isResourceViewModeSelector,
+} from '../selectors/topology';
+
+import { TOPOLOGY_LOADER_DELAY } from '../constants/timer';
+
+
+// TODO: The information that we already have available on the frontend should enable
+// us to determine which of these cases exactly is preventing us from seeing the nodes.
+const NODES_STATS_COUNT_ZERO_CAUSES = [
+  'We haven\'t received any reports from probes recently. Are the probes properly connected?',
+  'Containers view only: you\'re not running Docker, or you don\'t have any containers',
+];
+const NODES_NOT_DISPLAYED_CAUSES = [
+  'There are nodes, but they\'ve been filtered out by pinned searches in the top-left corner.',
+  'There are nodes, but they\'re currently hidden. Check the view options in the bottom-left if they allow for showing hidden nodes.',
+  'There are no nodes for this particular moment in time. Use the time travel feature at the bottom-right corner to explore different times.',
+];
+
+const renderCauses = causes => (
+  <ul>{causes.map(cause => <li key={cause}>{cause}</li>)}</ul>
+);
+
+class Nodes extends React.Component {
+  renderConditionalEmptyTopologyError() {
+    const { topologyNodeCountZero, nodesDisplayEmpty } = this.props;
+
+    return (
+      <NodesError faIconClass="far fa-circle" hidden={!nodesDisplayEmpty}>
+        {/* <div className="heading">Nothing to show. This can have any of these reasons:</div> */}
+        <div style={{textAlign:'center',width:'100%',marginTop:'16px'}}>未发现服务节点</div>
+        {/* {topologyNodeCountZero
+          ? renderCauses(NODES_STATS_COUNT_ZERO_CAUSES)
+          : renderCauses(NODES_NOT_DISPLAYED_CAUSES)} */}
+      </NodesError>
+    );
+  }
+
+  render() {
+    const {
+      topologiesLoaded, nodesLoaded, topologies, currentTopology, isGraphViewMode,
+      isTableViewMode, isResourceViewMode
+    } = this.props;
+
+    // TODO: Rename view mode components.
+    return (
+      <div className="nodes-wrapper">
+        <DelayedShow delay={TOPOLOGY_LOADER_DELAY} show={!topologiesLoaded || !nodesLoaded}>
+          <Loading itemType="topologies" show={!topologiesLoaded} />
+          <Loading
+            itemType={getNodeType(currentTopology, topologies)}
+            show={topologiesLoaded && !nodesLoaded} />
+        </DelayedShow>
+
+        {topologiesLoaded && nodesLoaded && this.renderConditionalEmptyTopologyError()}
+
+        {isGraphViewMode && <NodesChart />}
+        {isTableViewMode && <NodesGrid />}
+        {isResourceViewMode && <NodesResources />}
+      </div>
+    );
+  }
+}
+
+
+function mapStateToProps(state) {
+  return {
+    currentTopology: state.get('currentTopology'),
+    isGraphViewMode: isGraphViewModeSelector(state),
+    isResourceViewMode: isResourceViewModeSelector(state),
+    isTableViewMode: isTableViewModeSelector(state),
+    nodesDisplayEmpty: isNodesDisplayEmpty(state),
+    nodesLoaded: nodesLoadedSelector(state),
+    topologies: state.get('topologies'),
+    topologiesLoaded: state.get('topologiesLoaded'),
+    topologyNodeCountZero: isTopologyNodeCountZero(state),
+  };
+}
+
+
+export default connect(mapStateToProps)(Nodes);

+ 11 - 0
app/scripts/components/overlay.js

@@ -0,0 +1,11 @@
+import React from 'react';
+import classNames from 'classnames';
+
+
+export default class Overlay extends React.Component {
+  render() {
+    const className = classNames('overlay', { faded: this.props.faded });
+
+    return <div className={className} />;
+  }
+}

+ 58 - 0
app/scripts/components/plugins.js

@@ -0,0 +1,58 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+
+import Tooltip from './tooltip';
+
+
+const Plugin = ({
+  id, label, description, status
+}) => {
+  const error = status !== 'ok';
+  const className = classNames({ error });
+  const tip = (
+    <span>
+Description:
+      {description}
+      <br />
+Status:
+      {' '}
+      {status}
+    </span>
+  );
+
+  // Inner span to hold styling so we don't effect the "before:content"
+  return (
+    <span className="plugins-plugin" key={id}>
+      <Tooltip tip={tip}>
+        <span className={className}>
+          {error && <i className="plugins-plugin-icon fa fa-exclamation-circle" />}
+          {label || id}
+        </span>
+      </Tooltip>
+    </span>
+  );
+};
+
+class Plugins extends React.Component {
+  render() {
+    const hasPlugins = this.props.plugins && this.props.plugins.size > 0;
+    return (
+      <div className="plugins">
+        <span className="plugins-label">
+          Plugins:
+        </span>
+        {hasPlugins && this.props.plugins.toIndexedSeq().map(plugin => Plugin(plugin.toJS()))}
+        {!hasPlugins && <span className="plugins-empty">n/a</span>}
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    plugins: state.get('plugins')
+  };
+}
+
+export default connect(mapStateToProps)(Plugins);

+ 150 - 0
app/scripts/components/search.js

@@ -0,0 +1,150 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { isEmpty } from 'lodash';
+import { Search } from 'weaveworks-ui-components';
+import styled from 'styled-components';
+
+import {
+  blurSearch, updateSearch, toggleHelp
+} from '../actions/app-actions';
+import {
+  focusSearch
+} from '../actions/request-actions';
+import { searchMatchCountByTopologySelector } from '../selectors/search';
+import { isResourceViewModeSelector } from '../selectors/topology';
+import { slugify } from '../utils/string-utils';
+import { isTopologyNodeCountZero } from '../utils/topology-utils';
+import { trackAnalyticsEvent } from '../utils/tracking-utils';
+
+
+const SearchWrapper = styled.div`
+  margin: 0 8px;
+  min-width: 160px;
+  text-align: right;
+`;
+
+const SearchContainer = styled.div`
+  display: inline-block;
+  position: relative;
+  pointer-events: all;
+  line-height: 100%;
+  max-width: 400px;
+  width: 100%;
+`;
+
+const SearchHint = styled.div`
+  font-size: ${props => props.theme.fontSizes.tiny};
+  color: ${props => props.theme.colors.purple400};
+  transition: transform 0.3s 0s ease-in-out, opacity 0.3s 0s ease-in-out;
+  text-align: left;
+  margin-top: 3px;
+  padding: 0 1em;
+  opacity: 0;
+
+  ${props => props.active && `
+    opacity: 1;
+  `};
+`;
+
+const SearchHintIcon = styled.span`
+  font-size: ${props => props.theme.fontSizes.normal};
+  cursor: pointer;
+
+  &:hover {
+    color: ${props => props.theme.colors.purple600};
+  }
+`;
+
+function shortenHintLabel(text) {
+  return text
+    .split(' ')[0]
+    .toLowerCase()
+    .substr(0, 12);
+}
+
+// dynamic hint based on node names
+function getHint(nodes) {
+  // let label = 'mycontainer';
+  let label = 'myservice';
+  let metadataLabel = 'ip';
+  let metadataValue = '10.1.0.1';
+
+  const node = nodes.filter(n => !n.get('pseudo') && n.has('metadata')).last();
+  if (node) {
+    [label] = shortenHintLabel(node.get('label')).split('.');
+    if (node.get('metadata')) {
+      const metadataField = node.get('metadata').first();
+      metadataLabel = shortenHintLabel(slugify(metadataField.get('label')))
+        .split('.').pop();
+      metadataValue = shortenHintLabel(metadataField.get('value'));
+    }
+  }
+
+  // return `Try "${label}", "${metadataLabel}:${metadataValue}", or "cpu > 2%".`;
+
+  return `Try "${label}", "error>20%".`;
+  // return 'Try "myservice","error>20%".'
+}
+
+
+class SearchComponent extends React.Component {
+  handleChange = (searchQuery, pinnedSearches) => {
+    trackAnalyticsEvent('scope.search.query.change', {
+      layout: this.props.topologyViewMode,
+      parentTopologyId: this.props.currentTopology.get('parentId'),
+      topologyId: this.props.currentTopology.get('id'),
+    });
+    this.props.updateSearch(searchQuery, pinnedSearches);
+  }
+
+  render() {
+    const {
+      searchHint, searchMatchesCount, searchQuery, pinnedSearches, topologiesLoaded,
+      isResourceViewMode, isTopologyEmpty,
+    } = this.props;
+
+    return (
+      <SearchWrapper>
+        <SearchContainer title={searchMatchesCount ? `${searchMatchesCount} matches` : undefined}>
+          <Search
+            placeholder="search"
+            query={searchQuery}
+            pinnedTerms={pinnedSearches}
+            disabled={topologiesLoaded && !isResourceViewMode && isTopologyEmpty}
+            onChange={this.handleChange}
+            onFocus={this.props.focusSearch}
+            onBlur={this.props.blurSearch}
+          />
+          <SearchHint active={this.props.searchFocused && isEmpty(pinnedSearches)}>
+            {searchHint}
+            {' '}
+            {/* <SearchHintIcon
+              className="fa fa-question-circle"
+              onMouseDown={this.props.toggleHelp}
+            /> */}
+          </SearchHint>
+        </SearchContainer>
+      </SearchWrapper>
+    );
+  }
+}
+
+
+export default connect(
+  state => ({
+    currentTopology: state.get('currentTopology'),
+    isResourceViewMode: isResourceViewModeSelector(state),
+    isTopologyEmpty: isTopologyNodeCountZero(state),
+    pinnedSearches: state.get('pinnedSearches').toJS(),
+    searchFocused: state.get('searchFocused'),
+    searchHint: getHint(state.get('nodes')),
+    searchMatchesCount: searchMatchCountByTopologySelector(state)
+      .reduce((count, topologyMatchCount) => count + topologyMatchCount, 0),
+    searchQuery: state.get('searchQuery'),
+    topologiesLoaded: state.get('topologiesLoaded'),
+    topologyViewMode: state.get('topologyViewMode'),
+  }),
+  {
+    blurSearch, focusSearch, toggleHelp, updateSearch
+  }
+)(SearchComponent);

+ 33 - 0
app/scripts/components/show-more.js

@@ -0,0 +1,33 @@
+import React from 'react';
+
+export default class ShowMore extends React.PureComponent {
+  constructor(props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick(ev) {
+    ev.preventDefault();
+    this.props.handleClick();
+  }
+
+  render() {
+    const {
+      collection, notShown, expanded, hideNumber
+    } = this.props;
+    const showLimitAction = collection && (expanded || notShown > 0);
+    const limitActionText = !hideNumber && !expanded && notShown > 0 ? `+${notShown}` : '';
+    const limitActionIcon = !expanded && notShown > 0 ? 'fa fa-caret-down' : 'fa fa-caret-up';
+
+    if (!showLimitAction) {
+      return <span />;
+    }
+    return (
+      <div className="show-more" onClick={this.handleClick}>
+        {limitActionText}
+        {' '}
+        <span className={`show-more-icon ${limitActionIcon}`} />
+      </div>
+    );
+  }
+}

+ 10 - 0
app/scripts/components/sidebar.js

@@ -0,0 +1,10 @@
+import React from 'react';
+
+export default function Sidebar({children, classNames}) {
+  const className = `tour-step-anchor sidebar ${classNames}`;
+  return (
+    <div className={className}>
+      {children}
+    </div>
+  );
+}

+ 162 - 0
app/scripts/components/sparkline.js

@@ -0,0 +1,162 @@
+// Forked from: https://github.com/KyleAMathews/react-sparkline at commit a9d7c5203d8f240938b9f2288287aaf0478df013
+import React from 'react';
+import PropTypes from 'prop-types';
+import { min as d3Min, max as d3Max, mean as d3Mean } from 'd3-array';
+import { isoParse as parseDate } from 'd3-time-format';
+import { line, curveLinear } from 'd3-shape';
+import { scaleLinear } from 'd3-scale';
+
+import { formatMetricSvg } from '../utils/string-utils';
+
+
+const HOVER_RADIUS_MULTIPLY = 1.5;
+const HOVER_STROKE_MULTIPLY = 5;
+const MARGIN = 2;
+
+export default class Sparkline extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.x = scaleLinear();
+    this.y = scaleLinear();
+    this.line = line()
+      .x(d => this.x(d.date))
+      .y(d => this.y(d.value));
+  }
+
+  initRanges(hasCircle) {
+    // adjust scales and leave some room for the circle on the right, upper, and lower edge
+    let circleSpace = MARGIN;
+    if (hasCircle) {
+      circleSpace += Math.ceil(this.props.circleRadius * HOVER_RADIUS_MULTIPLY);
+    }
+
+    this.x.range([MARGIN, this.props.width - circleSpace]);
+    this.y.range([this.props.height - circleSpace, circleSpace]);
+    this.line.curve(curveLinear);
+  }
+
+  getGraphData() {
+    // data is of shape [{date, value}, ...] and is sorted by date (ASC)
+    let { data } = this.props;
+
+    this.initRanges(true);
+
+    // Convert dates into D3 dates
+    data = data.map(d => ({
+      date: parseDate(d.date),
+      value: d.value
+    }));
+
+    // determine date range
+    let firstDate = this.props.first ? parseDate(this.props.first) : data[0].date;
+    let lastDate = this.props.last ? parseDate(this.props.last) : data[data.length - 1].date;
+    // if last prop is after last value, we need to add that difference as
+    // padding before first value to right-align sparkline
+    const skip = lastDate - data[data.length - 1].date;
+    if (skip > 0) {
+      firstDate -= skip;
+      lastDate -= skip;
+    }
+    this.x.domain([firstDate, lastDate]);
+
+    // determine value range
+    const minValue = this.props.min !== undefined ? this.props.min : d3Min(data, d => d.value);
+    const maxValue = this.props.max !== undefined
+      ? Math.max(this.props.max, d3Max(data, d => d.value)) : d3Max(data, d => d.value);
+    this.y.domain([minValue, maxValue]);
+
+    const lastValue = data[data.length - 1].value;
+    const lastX = this.x(lastDate);
+    const lastY = this.y(lastValue);
+    const min = formatMetricSvg(d3Min(data, d => d.value), this.props);
+    const max = formatMetricSvg(d3Max(data, d => d.value), this.props);
+    const mean = formatMetricSvg(d3Mean(data, d => d.value), this.props);
+    const title = `Last ${Math.round((lastDate - firstDate) / 1000)} seconds, `
+      + `${data.length} samples, min: ${min}, max: ${max}, mean: ${mean}`;
+
+    return {
+      data, lastX, lastY, title
+    };
+  }
+
+  getEmptyGraphData() {
+    this.initRanges(false);
+    const first = new Date(0);
+    const last = new Date(15);
+    this.x.domain([first, last]);
+    this.y.domain([0, 1]);
+
+    return {
+      data: [
+        {date: first, value: 0},
+        {date: last, value: 0},
+      ],
+      lastX: this.x(last),
+      lastY: this.y(0),
+      title: '',
+    };
+  }
+
+  render() {
+    const dash = 5;
+    const hasData = this.props.data && this.props.data.length > 0;
+    const strokeColor = this.props.hovered && hasData
+      ? this.props.hoverColor
+      : this.props.strokeColor;
+    const strokeWidth = this.props.strokeWidth * (this.props.hovered ? HOVER_STROKE_MULTIPLY : 1);
+    const strokeDasharray = hasData ? undefined : `${dash}, ${dash}`;
+    const radius = this.props.circleRadius * (this.props.hovered ? HOVER_RADIUS_MULTIPLY : 1);
+    const fillOpacity = this.props.hovered ? 1 : 0.6;
+    const circleColor = hasData && this.props.hovered ? strokeColor : strokeColor;
+    const graph = hasData ? this.getGraphData() : this.getEmptyGraphData();
+
+    return (
+      <div title={graph.title}>
+        <svg width={this.props.width} height={this.props.height}>
+          <path
+            className="sparkline"
+            fill="none"
+            stroke={strokeColor}
+            strokeWidth={strokeWidth}
+            strokeDasharray={strokeDasharray}
+            d={this.line(graph.data)}
+          />
+          {hasData && (
+          <circle
+            className="sparkcircle"
+            cx={graph.lastX}
+            cy={graph.lastY}
+            fill={circleColor}
+            fillOpacity={fillOpacity}
+            stroke="none"
+            r={radius}
+          />
+          )}
+        </svg>
+      </div>
+    );
+  }
+}
+
+Sparkline.propTypes = {
+  circleRadius: PropTypes.number,
+  data: PropTypes.arrayOf(PropTypes.object),
+  height: PropTypes.number,
+  hoverColor: PropTypes.string,
+  hovered: PropTypes.bool,
+  strokeColor: PropTypes.string,
+  strokeWidth: PropTypes.number,
+  width: PropTypes.number,
+};
+
+Sparkline.defaultProps = {
+  circleRadius: 1.75,
+  data: [],
+  height: 24,
+  hoverColor: '#7d7da8',
+  hovered: false,
+  strokeColor: '#7d7da8',
+  strokeWidth: 0.5,
+  width: 80,
+};

+ 59 - 0
app/scripts/components/status.js

@@ -0,0 +1,59 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { isPausedSelector } from '../selectors/time-travel';
+
+
+class Status extends React.Component {
+  render() {
+    const {
+      errorUrl, topologiesLoaded, filteredNodeCount, topology, websocketClosed
+    } = this.props;
+
+    let title = '';
+    let text = 'Trying to reconnect...';
+    let showWarningIcon = false;
+    let classNames = 'status sidebar-item';
+
+    if (errorUrl) {
+      title = `Cannot reach Scope. Make sure the following URL is reachable: ${errorUrl}`;
+      classNames += ' status-loading';
+      showWarningIcon = true;
+    } else if (!topologiesLoaded) {
+      text = 'Connecting to Scope...';
+      classNames += ' status-loading';
+      showWarningIcon = true;
+    } else if (websocketClosed) {
+      classNames += ' status-loading';
+      showWarningIcon = true;
+    } else if (topology) {
+      const stats = topology.get('stats');
+      text = `${stats.get('node_count') - filteredNodeCount} nodes`;
+      if (stats.get('filtered_nodes')) {
+        text = `${text} (${stats.get('filtered_nodes') + filteredNodeCount} filtered)`;
+      }
+      classNames += ' status-stats';
+      showWarningIcon = false;
+    }
+
+    return (
+      <div className={classNames}>
+        {showWarningIcon && <i className="status-icon fa fa-exclamation-circle" />}
+        <span className="status-label" title={title}>{text}</span>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    errorUrl: state.get('errorUrl'),
+    filteredNodeCount: state.get('nodes').filter(node => node.get('filtered')).size,
+    showingCurrentState: !isPausedSelector(state),
+    topologiesLoaded: state.get('topologiesLoaded'),
+    topology: state.get('currentTopology'),
+    websocketClosed: state.get('websocketClosed'),
+  };
+}
+
+export default connect(mapStateToProps)(Status);

+ 86 - 0
app/scripts/components/terminal-app.js

@@ -0,0 +1,86 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { ThemeProvider } from 'styled-components';
+import commonTheme from 'weaveworks-ui-components/lib/theme';
+
+import Terminal from './terminal';
+import GlobalStyle from './global-style';
+import defaultTheme from '../themes/default';
+import { receiveControlPipeFromParams, hitEsc } from '../actions/app-actions';
+
+const ESC_KEY_CODE = 27;
+
+class TerminalApp extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    const paramString = window.location.hash.split('/').pop();
+    const params = JSON.parse(decodeURIComponent(paramString));
+    this.props.receiveControlPipeFromParams(
+      params.pipe.id, params.pipe.raw,
+      params.pipe.resizeTtyControl
+    );
+
+    this.state = {
+      statusBarColor: params.statusBarColor,
+      title: params.title,
+      titleBarColor: params.titleBarColor
+    };
+
+    this.onKeyUp = this.onKeyUp.bind(this);
+  }
+
+  componentDidMount() {
+    window.addEventListener('keyup', this.onKeyUp);
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('keyup', this.onKeyUp);
+  }
+
+  onKeyUp(ev) {
+    if (ev.keyCode === ESC_KEY_CODE) {
+      this.props.hitEsc();
+    }
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (!nextProps.controlPipe) {
+      window.close();
+    }
+  }
+
+  render() {
+    const style = {borderTop: `4px solid ${this.state.titleBarColor}`};
+
+    return (
+      <ThemeProvider theme={{...commonTheme, scope: defaultTheme }}>
+        <>
+          <GlobalStyle />
+          <div className="terminal-app" style={style}>
+            {this.props.controlPipe && (
+              <Terminal
+                pipe={this.props.controlPipe}
+                titleBarColor={this.state.titleBarColor}
+                statusBarColor={this.state.statusBarColor}
+                title={this.state.title}
+                embedded={false} />
+            )}
+          </div>
+        </>
+      </ThemeProvider>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    controlPipe: state.get('controlPipes').last()
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { hitEsc, receiveControlPipeFromParams }
+)(TerminalApp);

+ 355 - 0
app/scripts/components/terminal.js

@@ -0,0 +1,355 @@
+/* eslint no-return-assign: "off", react/jsx-no-bind: "off" */
+import debug from 'debug';
+import React from 'react';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+import { debounce } from 'lodash';
+import { Terminal as Term } from 'xterm';
+import * as fit from 'xterm/lib/addons/fit/fit';
+import stableStringify from 'json-stable-stringify';
+
+import { closeTerminal } from '../actions/app-actions';
+import { getPipeStatus } from '../actions/request-actions';
+import { getNeutralColor } from '../utils/color-utils';
+import { setDocumentTitle } from '../utils/title-utils';
+import {
+  deletePipe, doResizeTty, getWebsocketUrl, basePath
+} from '../utils/web-api-utils';
+
+const log = debug('scope:terminal');
+
+const DEFAULT_COLS = 80;
+const DEFAULT_ROWS = 24;
+// Unicode points can be used in html and document.title
+// html shorthand codes (&times;) don't work in document.title.
+const TIMES = '\u00D7';
+const MDASH = '\u2014';
+
+const reconnectTimerInterval = 2000;
+
+
+function ab2str(buf) {
+  // http://stackoverflow.com/questions/17191945/conversion-between-utf-8-arraybuffer-and-string
+  const encodedString = String.fromCharCode.apply(null, new Uint8Array(buf));
+  const decodedString = decodeURIComponent(escape(encodedString));
+  return decodedString;
+}
+
+function openNewWindow(url, bcr, minWidth = 200) {
+  const screenLeft = window.screenX || window.screenLeft;
+  const screenTop = window.screenY || window.screenTop;
+  const popoutWindowToolbarHeight = 51;
+  // TODO replace this stuff w/ looking up bounding box.
+  const windowOptions = {
+    height: bcr.height - popoutWindowToolbarHeight,
+    left: screenLeft + bcr.left,
+    location: 'no',
+    top: screenTop + (window.outerHeight - window.innerHeight) + bcr.top,
+    width: Math.max(minWidth, bcr.width),
+  };
+
+  const windowOptionsString = Object.keys(windowOptions)
+    .map(k => `${k}=${windowOptions[k]}`)
+    .join(',');
+
+  window.open(url, '', windowOptionsString);
+}
+
+
+class Terminal extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.reconnectTimeout = null;
+    this.resizeTimeout = null;
+
+    this.state = {
+      cols: DEFAULT_COLS,
+      connected: false,
+      detached: false,
+      rows: DEFAULT_ROWS,
+    };
+
+    this.handleCloseClick = this.handleCloseClick.bind(this);
+    this.handlePopoutTerminal = this.handlePopoutTerminal.bind(this);
+    this.saveInnerFlexRef = this.saveInnerFlexRef.bind(this);
+    this.saveNodeRef = this.saveNodeRef.bind(this);
+    this.handleResize = this.handleResize.bind(this);
+    this.handleResizeDebounced = debounce(this.handleResize, 500);
+  }
+
+  createWebsocket(term) {
+    const socket = new WebSocket(`${getWebsocketUrl()}/api/pipe/${this.getPipeId()}`);
+    socket.binaryType = 'arraybuffer';
+
+    getPipeStatus(this.getPipeId(), this.props.dispatch);
+
+    socket.onopen = () => {
+      clearTimeout(this.reconnectTimeout);
+      log('socket open to', getWebsocketUrl());
+      this.setState({connected: true});
+    };
+
+    socket.onclose = () => {
+      //
+      // componentWillUnmount has called close and tidied up! don't try and do it again here
+      // (setState etc), its too late.
+      //
+      if (!this.socket) {
+        return;
+      }
+      this.socket = null;
+      const wereConnected = this.state.connected;
+      if (this.isComponentMounted) {
+        // Calling setState on an unmounted component will throw a warning.
+        // `connected` will get set to false by `componentWillUnmount`.
+        this.setState({connected: false});
+      }
+      if (this.term && this.props.pipe.get('status') !== 'PIPE_DELETED') {
+        if (wereConnected) {
+          this.createWebsocket(term);
+        } else {
+          this.reconnectTimeout = setTimeout(
+            this.createWebsocket.bind(this, term),
+            reconnectTimerInterval
+          );
+        }
+      }
+    };
+
+    socket.onerror = (err) => {
+      log('socket error', err);
+    };
+
+    socket.onmessage = (event) => {
+      log('pipe data', event.data.size);
+      const input = ab2str(event.data);
+      term.write(input);
+    };
+
+    this.socket = socket;
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (this.props.connect !== nextProps.connect && nextProps.connect) {
+      this.mountTerminal();
+    }
+    // Close the terminal window immediately when the pipe is deleted.
+    if (nextProps.pipe.get('status') === 'PIPE_DELETED') {
+      this.props.dispatch(closeTerminal(this.getPipeId()));
+    }
+  }
+
+  componentDidMount() {
+    this.isComponentMounted = true;
+    if (this.props.connect) {
+      this.mountTerminal();
+    }
+  }
+
+  mountTerminal() {
+    Term.applyAddon(fit);
+    this.term = new Term({
+      convertEol: !this.props.pipe.get('raw'),
+      cursorBlink: true,
+      //
+      // Some linux systems fail to render 'monospace' on `<canvas>` correctly:
+      // https://github.com/xtermjs/xterm.js/issues/1170
+      // `theme.fontFamilies.monospace` doesn't provide many options so we add
+      // some here that are very common. The alternative _might_ be to bundle Roboto-Mono
+      //
+      fontFamily: '"Roboto Mono", "Courier New", "Courier", monospace',
+      // `theme.fontSizes.tiny` (`"12px"`) is a string and we need an int here.
+      fontSize: 12,
+      scrollback: 10000,
+    });
+
+    this.term.open(this.innerFlex);
+    this.term.focus();
+
+    this.term.on('data', (data) => {
+      if (this.socket) {
+        this.socket.send(data);
+      }
+    });
+
+    this.term.on('resize', ({ cols, rows }) => {
+      const resizeTtyControl = this.props.pipe.get('resizeTtyControl');
+      if (resizeTtyControl) {
+        doResizeTty(this.getPipeId(), resizeTtyControl, cols, rows);
+      }
+      this.setState({ cols, rows });
+    });
+
+    this.createWebsocket(this.term);
+
+    window.addEventListener('resize', this.handleResizeDebounced);
+
+    this.resizeTimeout = setTimeout(() => {
+      this.handleResize();
+    }, 10);
+  }
+
+  componentWillUnmount() {
+    this.isComponentMounted = false;
+    this.setState({connected: false});
+    log('cwu terminal');
+
+    clearTimeout(this.reconnectTimeout);
+    clearTimeout(this.resizeTimeout);
+
+    window.removeEventListener('resize', this.handleResizeDebounced);
+
+    if (this.term) {
+      log('destroy terminal');
+      this.term.blur();
+      this.term.destroy();
+      this.term = null;
+    }
+
+    if (!this.state.detached) {
+      deletePipe(this.getPipeId());
+    }
+
+    if (this.socket) {
+      log('close socket');
+      this.socket.close();
+      this.socket = null;
+    }
+  }
+
+  componentDidUpdate() {
+    if (!this.isEmbedded()) {
+      setDocumentTitle(this.getTitle());
+    }
+  }
+
+  handleCloseClick(ev) {
+    ev.preventDefault();
+    this.props.dispatch(closeTerminal(this.getPipeId()));
+  }
+
+  handlePopoutTerminal(ev) {
+    ev.preventDefault();
+    const paramString = stableStringify(this.props);
+    this.props.dispatch(closeTerminal(this.getPipeId()));
+    this.setState({detached: true});
+
+    const bcr = this.node.getBoundingClientRect();
+    openNewWindow(`${basePath(window.location.pathname)}/terminal.html#!/state/${paramString}`, bcr);
+  }
+
+  handleResize() {
+    this.term.fit();
+  }
+
+  isEmbedded() {
+    return (this.props.embedded !== false);
+  }
+
+  getPipeId() {
+    return this.props.pipe.get('id');
+  }
+
+  getTitle() {
+    const nodeName = this.props.title || 'n/a';
+    return `Terminal ${nodeName} ${MDASH}
+      ${this.state.cols}${TIMES}${this.state.rows}`;
+  }
+
+  getTerminalHeader() {
+    const light = this.props.statusBarColor || getNeutralColor();
+    const style = {
+      backgroundColor: light,
+    };
+    return (
+      <div className="terminal-header" style={style}>
+        <div className="terminal-header-tools">
+          <span
+            title="Open in new browser window"
+            className="terminal-header-tools-item"
+            onClick={this.handlePopoutTerminal}>
+          Pop out
+          </span>
+          <i
+            title="Close"
+            className="terminal-header-tools-item-icon fa fa-times"
+            onClick={this.handleCloseClick} />
+        </div>
+        {this.getControlStatusIcon()}
+        <span className="terminal-header-title">{this.getTitle()}</span>
+      </div>
+    );
+  }
+
+  getStatus() {
+    if (!this.state.connected) {
+      return (
+        <h3>Connecting...</h3>
+      );
+    }
+
+    return (
+      <h3>Connected</h3>
+    );
+  }
+
+  getTerminalStatusBar() {
+    const style = {
+      backgroundColor: this.props.statusBarColor || getNeutralColor(),
+      opacity: this.state.connected ? 0 : 0.9
+    };
+    return (
+      <div className="terminal-status-bar hideable" style={style}>
+        {this.getStatus()}
+      </div>
+    );
+  }
+
+  saveNodeRef(ref) {
+    this.node = ref;
+  }
+
+  saveInnerFlexRef(ref) {
+    this.innerFlex = ref;
+  }
+
+  render() {
+    const innerFlexStyle = {
+      opacity: this.state.connected ? 1 : 0.8,
+      overflow: 'hidden',
+    };
+    const innerClassName = classNames('terminal-inner hideable', {
+      'terminal-inactive': !this.state.connected
+    });
+
+    return (
+      <div className="terminal-wrapper" ref={this.saveNodeRef}>
+        {this.isEmbedded() && this.getTerminalHeader()}
+        <div className={innerClassName} style={innerFlexStyle} ref={this.saveInnerFlexRef} />
+        {this.getTerminalStatusBar()}
+      </div>
+    );
+  }
+
+  getControlStatusIcon() {
+    const icon = this.props.controlStatus && this.props.controlStatus.get('control').icon;
+    return (
+      <i
+        style={{marginRight: '8px', width: '14px'}}
+        className={classNames('fa', {[icon]: icon})}
+      />
+    );
+  }
+}
+
+function mapStateToProps(state, ownProps) {
+  const controlStatus = state.get('controlPipes').find(pipe => pipe.get('nodeId') === ownProps.pipe.get('nodeId'));
+  return { controlStatus };
+}
+
+Terminal.defaultProps = {
+  connect: true
+};
+
+export default connect(mapStateToProps)(Terminal);

+ 101 - 0
app/scripts/components/time-control.js

@@ -0,0 +1,101 @@
+import React from 'react';
+import classNames from 'classnames';
+import { connect } from 'react-redux';
+import { TimestampTag } from 'weaveworks-ui-components';
+
+import { trackAnalyticsEvent } from '../utils/tracking-utils';
+import { pauseTimeAtNow, resumeTime } from '../actions/request-actions';
+import { isPausedSelector, timeTravelSupportedSelector } from '../selectors/time-travel';
+
+import moment from 'moment'
+const className = isSelected => (
+  classNames('time-control-action', { 'time-control-action-selected': isSelected })
+);
+
+class TimeControl extends React.Component {
+  getTrackingMetadata = (data = {}) => {
+    const { currentTopology } = this.props;
+    return {
+      layout: this.props.topologyViewMode,
+      parentTopologyId: currentTopology && currentTopology.get('parentId'),
+      topologyId: currentTopology && currentTopology.get('id'),
+      ...data,
+    };
+  }
+
+  handleNowClick = () => {
+    trackAnalyticsEvent('scope.time.resume.click', this.getTrackingMetadata());
+    this.props.resumeTime();
+  }
+
+  handlePauseClick = () => {
+    trackAnalyticsEvent('scope.time.pause.click', this.getTrackingMetadata());
+    this.props.pauseTimeAtNow();
+  }
+
+  render() {
+    const { isPaused, pausedAt, topologiesLoaded } = this.props;
+
+    // If Time Travel is supported, show an empty placeholder div instead
+    // of this control, since time will be controlled through the timeline.
+    // We return <div /> instead of null so that selector controls would
+    // be aligned the same way between WC Explore and Scope standalone.
+    if (this.props.timeTravelSupported) return <div />;
+
+    return (
+      <div className="time-control">
+        <div className="time-control-controls">
+          <div className="time-control-wrapper">
+          {/* title="Show live state of the system"> */}
+            <span
+              className={className(!isPaused)}
+              onClick={this.handleNowClick}
+              disabled={!topologiesLoaded}>
+              {!isPaused && <i className="fa fa-play" />}
+              <span className="label" style={{fontSize:"14px"}}>实时</span>
+            </span>
+            {/* title="Pause updates (freezes the nodes in their current layout)"> */}
+            <span
+              className={className(isPaused)}
+              onClick={this.handlePauseClick}
+              disabled={!topologiesLoaded}
+              >
+              {isPaused && <i className="fa fa-pause" />}
+              {/* <span className="label">{isPaused ? 'Paused' : 'Pause'}</span> */}
+              <span className="label" style={{fontSize:"14px"}}>{isPaused ? '已暂停' : '暂停'}</span>
+            </span>
+          </div>
+        </div>
+        {isPaused
+          && (
+          <span className="time-control-info" style={{fontSize:"12px"}}>
+            状态暂停于
+            {' '}
+            {moment().format("YYYY-MM-DD HH:mm:ss")}
+            {/* <TimestampTag inheritStyles relative timestamp={pausedAt} /> */}
+          </span>
+          )
+        }
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    currentTopology: state.get('currentTopology'),
+    isPaused: isPausedSelector(state),
+    pausedAt: state.get('pausedAt'),
+    timeTravelSupported: timeTravelSupportedSelector(state),
+    topologiesLoaded: state.get('topologiesLoaded'),
+    topologyViewMode: state.get('topologyViewMode'),
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  {
+    pauseTimeAtNow,
+    resumeTime,
+  }
+)(TimeControl);

+ 41 - 0
app/scripts/components/time-travel-wrapper.js

@@ -0,0 +1,41 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { TimeTravel } from 'weaveworks-ui-components';
+
+import { jumpToTime, resumeTime, pauseTimeAtNow } from '../actions/request-actions';
+
+class TimeTravelWrapper extends React.Component {
+  handleLiveModeChange = (showingLive) => {
+    if (showingLive) {
+      this.props.resumeTime();
+    } else {
+      this.props.pauseTimeAtNow();
+    }
+  }
+
+  render() {
+    return (
+      <TimeTravel
+        hasLiveMode
+        timestamp={this.props.timestamp}
+        showingLive={this.props.showingLive}
+        onChangeTimestamp={this.props.jumpToTime}
+        onChangeLiveMode={this.handleLiveModeChange}
+      />
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    showingLive: !state.get('pausedAt'),
+    timestamp: state.get('pausedAt'),
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  {
+    jumpToTime, pauseTimeAtNow, resumeTime
+  },
+)(TimeTravelWrapper);

+ 13 - 0
app/scripts/components/tooltip.js

@@ -0,0 +1,13 @@
+import React from 'react';
+
+
+export default class Tooltip extends React.Component {
+  render() {
+    return (
+      <span className="tooltip-wrapper">
+        <div className="tooltip">{this.props.tip}</div>
+        {this.props.children}
+      </span>
+    );
+  }
+}

+ 108 - 0
app/scripts/components/topologies.js

@@ -0,0 +1,108 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import classnames from 'classnames';
+
+import { trackAnalyticsEvent } from '../utils/tracking-utils';
+import { searchMatchCountByTopologySelector } from '../selectors/search';
+import { isResourceViewModeSelector } from '../selectors/topology';
+import { clickTopology } from '../actions/request-actions';
+
+
+function basicTopologyInfo(topology, searchMatchCount) {
+  const info = [
+    `Nodes: ${topology.getIn(['stats', 'node_count'])}`,
+    `Connections: ${topology.getIn(['stats', 'edge_count'])}`
+  ];
+  if (searchMatchCount) {
+    info.push(`Search Matches: ${searchMatchCount}`);
+  }
+  return info.join('\n');
+}
+
+class Topologies extends React.Component {
+  onTopologyClick = (ev, topology) => {
+    ev.preventDefault();
+    trackAnalyticsEvent('scope.topology.selector.click', {
+      parentTopologyId: topology.get('parentId'),
+      topologyId: topology.get('id'),
+    });
+    this.props.clickTopology(ev.currentTarget.getAttribute('rel'));
+  }
+
+  renderSubTopology(subTopology) {
+    const topologyId = subTopology.get('id');
+    const isActive = subTopology === this.props.currentTopology;
+    const searchMatchCount = this.props.searchMatchCountByTopology.get(topologyId) || 0;
+    const title = basicTopologyInfo(subTopology, searchMatchCount);
+    const className = classnames(`topologies-sub-item topologies-item-${topologyId}`, {
+      'topologies-sub-item-active': isActive,
+      // Don't show matches in the resource view as searching is not supported there yet.
+      'topologies-sub-item-matched': !this.props.isResourceViewMode && searchMatchCount,
+    });
+
+    return (
+      <div
+        className={className}
+        title={title}
+        key={topologyId}
+        rel={topologyId}
+        onClick={ev => this.onTopologyClick(ev, subTopology)}>
+        <div className="topologies-sub-item-label">
+          {subTopology.get('name')}
+        </div>
+      </div>
+    );
+  }
+
+  renderTopology(topology) {
+    const topologyId = topology.get('id');
+    const isActive = topology === this.props.currentTopology;
+    const searchMatchCount = this.props.searchMatchCountByTopology.get(topology.get('id')) || 0;
+    const className = classnames(`tour-step-anchor topologies-item-main topologies-item-${topologyId}`, {
+      'topologies-item-main-active': isActive,
+      // Don't show matches in the resource view as searching is not supported there yet.
+      'topologies-item-main-matched': !this.props.isResourceViewMode && searchMatchCount,
+    });
+    const title = basicTopologyInfo(topology, searchMatchCount);
+
+    return (
+      <div className="topologies-item" key={topologyId}>
+        <div
+          className={className}
+          title={title}
+          rel={topologyId}
+          onClick={ev => this.onTopologyClick(ev, topology)}>
+          <div className="topologies-item-label">
+            {topology.get('name')}
+          </div>
+        </div>
+        {/* <div className="topologies-sub">
+          {topology.has('sub_topologies')
+            && topology.get('sub_topologies').map(subTop => this.renderSubTopology(subTop))}
+        </div> */}
+      </div>
+    );
+  }
+
+  render() {
+    return (
+      <div className="tour-step-anchor topologies-selector">
+        {this.props.currentTopology && this.props.topologies.map(t => this.renderTopology(t))}
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    currentTopology: state.get('currentTopology'),
+    isResourceViewMode: isResourceViewModeSelector(state),
+    searchMatchCountByTopology: searchMatchCountByTopologySelector(state),
+    topologies: state.get('topologies'),
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { clickTopology }
+)(Topologies);

+ 26 - 0
app/scripts/components/topology-option-action.js

@@ -0,0 +1,26 @@
+import React from 'react';
+
+export default class TopologyOptionAction extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.onClick = this.onClick.bind(this);
+  }
+
+  onClick(ev) {
+    ev.preventDefault();
+    const { optionId, topologyId, item } = this.props;
+    this.props.onClick(optionId, item.get('value'), topologyId);
+  }
+
+  render() {
+    const { activeValue, item } = this.props;
+    const className = activeValue.includes(item.get('value'))
+      ? 'topology-option-action topology-option-action-selected'
+      : 'topology-option-action';
+    return (
+      <div className={className} onClick={this.onClick}>
+        {item.get('label')}
+      </div>
+    );
+  }
+}

+ 154 - 0
app/scripts/components/topology-options.js

@@ -0,0 +1,154 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { Set as makeSet, Map as makeMap } from 'immutable';
+import includes from 'lodash/includes';
+
+import { trackAnalyticsEvent } from '../utils/tracking-utils';
+import { getCurrentTopologyOptions } from '../utils/topology-utils';
+import { activeTopologyOptionsSelector } from '../selectors/topology';
+import TopologyOptionAction from './topology-option-action';
+import { changeTopologyOption } from '../actions/request-actions';
+
+class TopologyOptions extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.trackOptionClick = this.trackOptionClick.bind(this);
+    this.handleOptionClick = this.handleOptionClick.bind(this);
+    this.handleNoneClick = this.handleNoneClick.bind(this);
+  }
+
+  trackOptionClick(optionId, nextOptions) {
+    trackAnalyticsEvent('scope.topology.option.click', {
+      layout: this.props.topologyViewMode,
+      optionId,
+      parentTopologyId: this.props.currentTopology.get('parentId'),
+      topologyId: this.props.currentTopology.get('id'),
+      value: nextOptions,
+    });
+  }
+
+  handleOptionClick(optionId, value, topologyId) {
+    let nextOptions = [value];
+    const { activeOptions, options } = this.props;
+    const selectedOption = options.find(o => o.get('id') === optionId);
+
+    if (selectedOption.get('selectType') === 'union') {
+      // Multi-select topology options (such as k8s namespaces) are handled here.
+      // Users can select one, many, or none of these options.
+      // The component builds an array of the next selected values that are sent to the action.
+      const opts = activeOptions.toJS();
+      const selected = selectedOption.get('id');
+      const selectedActiveOptions = opts[selected] || [];
+      const isSelectedAlready = includes(selectedActiveOptions, value);
+
+      if (isSelectedAlready) {
+        // Remove the option if it is already selected
+        nextOptions = selectedActiveOptions.filter(o => o !== value);
+      } else {
+        // Add it to the array if it's not selected
+        nextOptions = selectedActiveOptions.concat(value);
+      }
+      // Since the user is clicking an option, remove the highlighting from the none option,
+      // unless they are removing the last option. In that case, default to the none label.
+      // Note that since the other ids are potentially user-controlled (eg. k8s namespaces),
+      // the only string we can use for the none option is the empty string '',
+      // since that can't collide.
+      if (nextOptions.length === 0) {
+        nextOptions = [''];
+      } else {
+        nextOptions = nextOptions.filter(o => o !== '');
+      }
+    }
+    this.trackOptionClick(optionId, nextOptions);
+    this.props.changeTopologyOption(optionId, nextOptions, topologyId);
+  }
+
+  handleNoneClick(optionId, value, topologyId) {
+    const nextOptions = [''];
+    this.trackOptionClick(optionId, nextOptions);
+    this.props.changeTopologyOption(optionId, nextOptions, topologyId);
+  }
+
+  renderOption(option) {
+    const { activeOptions, currentTopologyId } = this.props;
+    const optionId = option.get('id');
+
+    // Make the active value be the intersection of the available options
+    // and the active selection and use the default value if there is no
+    // overlap. It seems intuitive that active selection would always be a
+    // subset of available option, but the exception can happen when going
+    // back in time (making available options change, while not touching
+    // the selection).
+    // TODO: This logic should probably be made consistent with how topology
+    // selection is handled when time travelling, especially when the name-
+    // spaces are brought under category selection.
+    // TODO: Consider extracting this into a global selector.
+    let activeValue = option.get('defaultValue');
+    if (activeOptions && activeOptions.has(optionId)) {
+      const activeSelection = makeSet(activeOptions.get(optionId));
+      const availableOptions = makeSet(option.get('options').map(o => o.get('value')));
+      const intersection = activeSelection.intersect(availableOptions);
+      if (!intersection.isEmpty()) {
+        activeValue = intersection.toJS();
+      }
+    }
+
+    const noneItem = makeMap({
+      label: option.get('noneLabel'),
+      value: ''
+    });
+    return (
+      <div className="topology-option" key={optionId}>
+        <div className="topology-option-wrapper">
+          {option.get('selectType') === 'union'
+            && (
+            <TopologyOptionAction
+              onClick={this.handleNoneClick}
+              optionId={optionId}
+              item={noneItem}
+              topologyId={currentTopologyId}
+              activeValue={activeValue}
+            />
+            )
+          }
+         
+          {option.get('options').map(item => (
+            <TopologyOptionAction
+              onClick={this.handleOptionClick}
+              optionId={optionId}
+              topologyId={currentTopologyId}
+              key={item.get('value')}
+              activeValue={activeValue}
+              item={item}
+            />
+          ))}
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    const { options } = this.props;
+    return (
+      <div className="topology-options">
+        {options && options.toIndexedSeq().map(option => this.renderOption(option))}
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    activeOptions: activeTopologyOptionsSelector(state),
+    currentTopology: state.get('currentTopology'),
+    currentTopologyId: state.get('currentTopologyId'),
+    options: getCurrentTopologyOptions(state),
+    topologyViewMode: state.get('topologyViewMode')
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { changeTopologyOption }
+)(TopologyOptions);

+ 108 - 0
app/scripts/components/troubleshooting-menu.js

@@ -0,0 +1,108 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import {
+  toggleTroubleshootingMenu,
+  resetLocalViewState,
+  clickDownloadGraph
+} from '../actions/app-actions';
+import { getReportUrl } from '../utils/web-api-utils';
+
+class DebugMenu extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.handleClickReset = this.handleClickReset.bind(this);
+  }
+
+  handleClickReset(ev) {
+    ev.preventDefault();
+    this.props.resetLocalViewState();
+  }
+
+  render() {
+    const { pausedAt } = this.props;
+    return (
+      <div className="troubleshooting-menu-wrapper">
+        <div className="troubleshooting-menu">
+          <div className="troubleshooting-menu-content">
+            <h3>Debugging/Troubleshooting</h3>
+            <div className="troubleshooting-menu-item">
+              <a
+                className="footer-icon"
+                title="Save raw data as JSON"
+                href={getReportUrl(pausedAt)}
+                download
+              >
+                <i className="fa fa-code" />
+                <span className="description">Save raw data as JSON</span>
+                {pausedAt && (
+                  <span className="soft">
+                    {' '}
+                    (
+                      {pausedAt}
+                    )
+                  </span>
+                )}
+              </a>
+            </div>
+            <div className="troubleshooting-menu-item">
+              <button
+                type="button"
+                className="footer-icon"
+                onClick={this.props.clickDownloadGraph}
+                title="Save canvas as SVG (does not include search highlighting)"
+              >
+                <i className="fa fa-download" />
+                <span className="description">Save canvas as SVG</span>
+                <span className="soft"> (does not include search highlighting)</span>
+              </button>
+            </div>
+            <div className="troubleshooting-menu-item">
+              <button
+                type="button"
+                className="footer-icon"
+                title="Reset view state"
+                onClick={this.handleClickReset}
+              >
+                <i className="fa fa-undo" />
+                <span className="description">Reset your local view state and reload the page</span>
+              </button>
+            </div>
+            <div className="troubleshooting-menu-item">
+              <a
+                className="footer-icon"
+                title="Report an issue"
+                href="https://gitreports.com/issue/weaveworks/scope"
+                target="_blank"
+                rel="noopener noreferrer"
+              >
+                <i className="fa fa-bug" />
+                <span className="description">Report a bug</span>
+              </a>
+            </div>
+            <div className="help-panel-tools">
+              <i
+                title="Close menu"
+                className="fa fa-times"
+                onClick={this.props.toggleTroubleshootingMenu}
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    pausedAt: state.get('pausedAt'),
+  };
+}
+
+export default connect(mapStateToProps, {
+  clickDownloadGraph,
+  resetLocalViewState,
+  toggleTroubleshootingMenu
+})(DebugMenu);

+ 52 - 0
app/scripts/components/view-mode-button.js

@@ -0,0 +1,52 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+
+import { trackAnalyticsEvent } from '../utils/tracking-utils';
+
+
+class ViewModeButton extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick() {
+    trackAnalyticsEvent('scope.layout.selector.click', {
+      layout: this.props.viewMode,
+      parentTopologyId: this.props.currentTopology.get('parentId'),
+      topologyId: this.props.currentTopology.get('id'),
+    });
+    this.props.onClick();
+  }
+
+  render() {
+    const { label, viewMode, disabled } = this.props;
+
+    const isSelected = (this.props.topologyViewMode === viewMode);
+    const className = classNames(`tour-step-anchor view-mode-selector-action view-${label}-action`, {
+      'view-mode-selector-action-selected': isSelected,
+    });
+
+    return (
+      <div
+        className={className}
+        disabled={disabled}
+        onClick={!disabled ? this.handleClick : undefined}
+        title={`View ${label.toLowerCase()}`}>
+        <i className={this.props.icons} />
+        <span className="label">{label}</span>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    currentTopology: state.get('currentTopology'),
+    topologyViewMode: state.get('topologyViewMode'),
+  };
+}
+
+export default connect(mapStateToProps)(ViewModeButton);

+ 73 - 0
app/scripts/components/view-mode-selector.js

@@ -0,0 +1,73 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import ViewModeButton from './view-mode-button';
+import MetricSelector from './metric-selector';
+import { setResourceView } from '../actions/request-actions';
+import { setGraphView, setTableView } from '../actions/app-actions';
+import { availableMetricsSelector } from '../selectors/node-metric';
+import {
+  isResourceViewModeSelector,
+  resourceViewAvailableSelector,
+} from '../selectors/topology';
+import {
+  GRAPH_VIEW_MODE,
+  TABLE_VIEW_MODE,
+  RESOURCE_VIEW_MODE,
+} from '../constants/naming';
+
+
+class ViewModeSelector extends React.Component {
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.isResourceViewMode && !nextProps.hasResourceView) {
+      nextProps.setGraphView();
+    }
+  }
+
+  render() {
+    return (
+      <div className="view-mode-selector">
+        <div className="tour-step-anchor view-mode-selector-wrapper">
+        {/* label="Graph" */}
+          <ViewModeButton
+            label="图"
+            icons="fa fa-sitemap"
+            viewMode={GRAPH_VIEW_MODE}
+            onClick={this.props.setGraphView}
+          />
+          {/* label="Table" */}
+          <ViewModeButton
+            label="表"
+            icons="fa fa-table"
+            viewMode={TABLE_VIEW_MODE}
+            onClick={this.props.setTableView}
+          />
+          {/* label="Resources" */}
+          <ViewModeButton
+            label="资源"
+            icons="fa fa-chart-bar"
+            viewMode={RESOURCE_VIEW_MODE}
+            onClick={this.props.setResourceView}
+            disabled={!this.props.hasResourceView}
+          />
+        </div>
+        <MetricSelector />
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    currentTopology: state.get('currentTopology'),
+    hasResourceView: resourceViewAvailableSelector(state),
+    isResourceViewMode: isResourceViewModeSelector(state),
+    showingMetricsSelector: availableMetricsSelector(state).count() > 0,
+    topologyViewMode: state.get('topologyViewMode'),
+  };
+}
+
+export default connect(
+  mapStateToProps,
+  { setGraphView, setResourceView, setTableView }
+)(ViewModeSelector);

+ 39 - 0
app/scripts/components/warning.js

@@ -0,0 +1,39 @@
+import React from 'react';
+import classnames from 'classnames';
+
+
+class Warning extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+    this.state = {
+      expanded: false
+    };
+  }
+
+  handleClick() {
+    this.setState(prevState => ({
+      expanded: !prevState.expanded
+    }));
+  }
+
+  render() {
+    const { text } = this.props;
+    const { expanded } = this.state;
+
+    const className = classnames('warning', {
+      'warning-expanded': expanded
+    });
+
+    return (
+      <div className={className} onClick={this.handleClick}>
+        <div className="warning-wrapper">
+          <i className="warning-icon fa fa-exclamation-triangle" title={text} />
+          {expanded && <span className="warning-text">{text}</span>}
+        </div>
+      </div>
+    );
+  }
+}
+
+export default Warning;

+ 69 - 0
app/scripts/components/zoom-control.js

@@ -0,0 +1,69 @@
+import React from 'react';
+import Slider from 'rc-slider';
+import { scaleLog } from 'd3-scale';
+
+
+const SLIDER_STEP = 0.001;
+const CLICK_STEP = 0.05;
+
+// Returns a log-scale that maps zoom factors to slider values.
+const getSliderScale = ({ minScale, maxScale }) => (
+  scaleLog()
+    // Zoom limits may vary between different views.
+    .domain([minScale, maxScale])
+    // Taking the unit range for the slider ensures consistency
+    // of the zoom button steps across different zoom domains.
+    .range([0, 1])
+    // This makes sure the input values are always clamped into the valid domain/range.
+    .clamp(true)
+);
+
+export default class ZoomControl extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.handleChange = this.handleChange.bind(this);
+    this.handleZoomOut = this.handleZoomOut.bind(this);
+    this.handleZoomIn = this.handleZoomIn.bind(this);
+    this.getSliderValue = this.getSliderValue.bind(this);
+    this.toZoomScale = this.toZoomScale.bind(this);
+  }
+
+  handleChange(sliderValue) {
+    this.props.zoomAction(this.toZoomScale(sliderValue));
+  }
+
+  handleZoomOut() {
+    this.props.zoomAction(this.toZoomScale(this.getSliderValue() - CLICK_STEP));
+  }
+
+  handleZoomIn() {
+    this.props.zoomAction(this.toZoomScale(this.getSliderValue() + CLICK_STEP));
+  }
+
+  getSliderValue() {
+    const toSliderValue = getSliderScale(this.props);
+    return toSliderValue(this.props.scale);
+  }
+
+  toZoomScale(sliderValue) {
+    const toSliderValue = getSliderScale(this.props);
+    return toSliderValue.invert(sliderValue);
+  }
+
+  render() {
+    const value = this.getSliderValue();
+
+    return (
+      <div className="zoom-control">
+        <button className="zoom-in" type="button" onClick={this.handleZoomIn}>
+          <i className="fa fa-plus" />
+        </button>
+        <Slider value={value} max={1} step={SLIDER_STEP} vertical onChange={this.handleChange} />
+        <button className="zoom-out" type="button" onClick={this.handleZoomOut}>
+          <i className="fa fa-minus" />
+        </button>
+      </div>
+    );
+  }
+}

+ 290 - 0
app/scripts/components/zoomable-canvas.js

@@ -0,0 +1,290 @@
+import React from 'react';
+import classNames from 'classnames';
+import { connect } from 'react-redux';
+import { clamp, debounce, pick } from 'lodash';
+import { fromJS } from 'immutable';
+import stableStringify from 'json-stable-stringify';
+
+import { drag } from 'd3-drag';
+import { event as d3Event, select } from 'd3-selection';
+import { zoomFactor } from 'weaveworks-ui-components/lib/utils/zooming';
+
+import Logo from './logo';
+import ZoomControl from './zoom-control';
+import { cacheZoomState } from '../actions/app-actions';
+import { applyTransform, inverseTransform } from '../utils/transform-utils';
+import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming';
+import {
+  canvasMarginsSelector,
+  canvasWidthSelector,
+  canvasHeightSelector,
+} from '../selectors/canvas';
+
+import { ZOOM_CACHE_DEBOUNCE_INTERVAL } from '../constants/timer';
+import { CONTENT_INCLUDED, CONTENT_COVERING } from '../constants/naming';
+
+
+class ZoomableCanvas extends React.Component {
+  constructor(props, context) {
+    super(props, context);
+
+    this.state = {
+      contentMaxX: 0,
+      contentMaxY: 0,
+      contentMinX: 0,
+      contentMinY: 0,
+      isPanning: false,
+      maxScale: 1,
+      minScale: 1,
+      scaleX: 1, //默认是1
+      scaleY: 1,
+      translateX: 0,
+      translateY: 0,
+    };
+
+    this.debouncedCacheZoom = debounce(this.cacheZoom.bind(this), ZOOM_CACHE_DEBOUNCE_INTERVAL);
+    this.handleZoomControlAction = this.handleZoomControlAction.bind(this);
+    this.canChangeZoom = this.canChangeZoom.bind(this);
+
+    this.handleZoom = this.handleZoom.bind(this);
+    this.handlePanStart = this.handlePanStart.bind(this);
+    this.handlePanEnd = this.handlePanEnd.bind(this);
+    this.handlePan = this.handlePan.bind(this);
+  }
+
+  componentDidMount() {
+    this.svg = select('.zoomable-canvas svg');
+    this.drag = drag()
+      .on('start', this.handlePanStart)
+      .on('end', this.handlePanEnd)
+      .on('drag', this.handlePan);
+    this.svg.call(this.drag);
+
+    this.zoomRestored = false;
+
+    this.updateZoomLimits(this.props);
+    this.restoreZoomState(this.props);
+    document
+      .getElementById('canvas')
+      .addEventListener('wheel', this.handleZoom, { passive: false });
+  }
+
+  componentWillUnmount() {
+    this.debouncedCacheZoom.cancel();
+    document
+      .getElementById('canvas')
+      .removeEventListener('wheel', this.handleZoom, { passive: false });
+  }
+
+  componentWillReceiveProps(nextProps) {
+    const layoutChanged = nextProps.layoutId !== this.props.layoutId;
+
+    // If the layout has changed (either active topology or its options) or
+    // relayouting has been requested, stop pending zoom caching event and
+    // ask for the new zoom settings to be restored again from the cache.
+    if (layoutChanged || nextProps.forceRelayout) {
+      this.debouncedCacheZoom.cancel();
+      this.zoomRestored = false;
+    }
+
+    this.updateZoomLimits(nextProps);
+    if (!this.zoomRestored) {
+      this.restoreZoomState(nextProps);
+    }
+  }
+
+  handleZoomControlAction(scale) {
+    // Get the center of the SVG and zoom around it.
+    const {
+      top, bottom, left, right
+    } = this.svg.node().getBoundingClientRect();
+    const centerOfCanvas = {
+      x: (left + right) / 2,
+      y: (top + bottom) / 2,
+    };
+    // Zoom factor diff is obtained by dividing the new zoom scale with the old one.
+    this.zoomAtPositionByFactor(centerOfCanvas, scale / this.state.scaleX);
+  }
+
+  render() {
+    const className = classNames({ panning: this.state.isPanning });
+
+    return (
+      <div className="zoomable-canvas">
+        <svg id="canvas" className={className} onClick={this.props.onClick}>
+          <Logo transform="translate(24,24) scale(0.25)" />
+          <g className="zoom-content">
+            {this.props.children(this.state)}
+          </g>
+        </svg>
+        {this.canChangeZoom() && (
+        <ZoomControl
+          zoomAction={this.handleZoomControlAction}
+          minScale={this.state.minScale}
+          maxScale={this.state.maxScale}
+          scale={this.state.scaleX}
+        />
+        )}
+      </div>
+    );
+  }
+
+  // Decides which part of the zoom state is cachable depending
+  // on the horizontal/vertical degrees of freedom.
+  cachableState(state = this.state) {
+    const cachableFields = []
+      .concat(this.props.fixHorizontal ? [] : ['scaleX', 'translateX'])
+      .concat(this.props.fixVertical ? [] : ['scaleY', 'translateY']);
+
+    return pick(state, cachableFields);
+  }
+
+  cacheZoom() {
+    this.props.cacheZoomState(fromJS(this.cachableState()));
+  }
+
+  updateZoomLimits(props) {
+    this.setState(props.layoutLimits.toJS());
+  }
+
+  // Restore the zooming settings
+  restoreZoomState(props) {
+    if (!props.layoutZoomState.isEmpty()) {
+      const zoomState = props.layoutZoomState.toJS();
+
+      // Update the state variables.
+      this.setState(zoomState);
+      this.zoomRestored = true;
+    }
+  }
+
+  canChangeZoom() {
+    const { disabled, layoutLimits } = this.props;
+    const canvasHasContent = !layoutLimits.isEmpty();
+    return !disabled && canvasHasContent;
+  }
+
+  handlePanStart() {
+    this.setState({ isPanning: true });
+  }
+
+  handlePanEnd() {
+    this.setState({ isPanning: false });
+  }
+
+  handlePan() {
+    let { state } = this;
+    // Apply the translation respecting the boundaries.
+    state = this.clampedTranslation({
+      ...state,
+      translateX: this.state.translateX + d3Event.dx,
+      translateY: this.state.translateY + d3Event.dy,
+    });
+    this.updateState(state);
+  }
+
+  handleZoom(ev) {
+    if (this.canChangeZoom()) {
+      // Get the exact mouse cursor position in the SVG and zoom around it.
+      const { top, left } = this.svg.node().getBoundingClientRect();
+      const mousePosition = {
+        x: ev.clientX - left,
+        y: ev.clientY - top,
+      };
+      this.zoomAtPositionByFactor(mousePosition, zoomFactor(ev));
+    }
+    ev.preventDefault();
+  }
+
+  clampedTranslation(state) {
+    const {
+      width, height, canvasMargins, boundContent, layoutLimits
+    } = this.props;
+    const {
+      contentMinX, contentMaxX, contentMinY, contentMaxY
+    } = layoutLimits.toJS();
+
+    if (boundContent) {
+      // If the content is required to be bounded in any way, the translation will
+      // be adjusted so that certain constraints between the viewport and displayed
+      // content bounding box are met.
+      const viewportMin = { x: canvasMargins.left, y: canvasMargins.top };
+      const viewportMax = { x: canvasMargins.left + width, y: canvasMargins.top + height };
+      const contentMin = applyTransform(state, { x: contentMinX, y: contentMinY });
+      const contentMax = applyTransform(state, { x: contentMaxX, y: contentMaxY });
+
+      switch (boundContent) {
+        case CONTENT_COVERING:
+          // These lines will adjust the translation by 'minimal effort' in
+          // such a way that the content always FULLY covers the viewport,
+          // i.e. that the viewport rectangle is always fully contained in
+          // the content bounding box rectangle - the assumption made here
+          // is that that can always be done.
+          state.translateX += Math.max(0, viewportMax.x - contentMax.x);
+          state.translateX -= Math.max(0, contentMin.x - viewportMin.x);
+          state.translateY += Math.max(0, viewportMax.y - contentMax.y);
+          state.translateY -= Math.max(0, contentMin.y - viewportMin.y);
+          break;
+        case CONTENT_INCLUDED:
+          // These lines will adjust the translation by 'minimal effort' in
+          // such a way that the content is always at least PARTLY contained
+          // within the viewport, i.e. that the intersection between the
+          // viewport and the content bounding box always exists.
+          state.translateX -= Math.max(0, contentMin.x - viewportMax.x);
+          state.translateX += Math.max(0, viewportMin.x - contentMax.x);
+          state.translateY -= Math.max(0, contentMin.y - viewportMax.y);
+          state.translateY += Math.max(0, viewportMin.y - contentMax.y);
+          break;
+        default:
+          break;
+      }
+    }
+
+    return state;
+  }
+
+  zoomAtPositionByFactor(position, factor) {
+    // Update the scales by the given factor, respecting the zoom limits.
+    const { minScale, maxScale } = this.state;
+    const scaleX = clamp(this.state.scaleX * factor, minScale, maxScale);
+    const scaleY = clamp(this.state.scaleY * factor, minScale, maxScale);
+    let state = { ...this.state, scaleX, scaleY };
+
+    // Get the position in the coordinates before the transition and use it
+    // to adjust the translation part of the new transition (respecting the
+    // translation limits). Adapted from:
+    // https://github.com/d3/d3-zoom/blob/807f02c7a5fe496fbd08cc3417b62905a8ce95fa/src/zoom.js#L251
+    const inversePosition = inverseTransform(this.state, position);
+    state = this.clampedTranslation({
+      ...state,
+      translateX: position.x - (inversePosition.x * scaleX),
+      translateY: position.y - (inversePosition.y * scaleY),
+    });
+
+    this.updateState(state);
+  }
+
+  updateState(state) {
+    this.setState(this.cachableState(state));
+    this.debouncedCacheZoom();
+  }
+}
+
+
+function mapStateToProps(state, props) {
+  return {
+    canvasMargins: canvasMarginsSelector(state),
+    forceRelayout: state.get('forceRelayout'),
+    height: canvasHeightSelector(state),
+    layoutId: stableStringify(activeTopologyZoomCacheKeyPathSelector(state)),
+    layoutLimits: props.limitsSelector(state),
+    layoutZoomState: props.zoomStateSelector(state),
+    width: canvasWidthSelector(state),
+  };
+}
+
+
+export default connect(
+  mapStateToProps,
+  { cacheZoomState }
+)(ZoomableCanvas);

+ 71 - 0
app/scripts/constants/action-types.js

@@ -0,0 +1,71 @@
+import { zipObject } from 'lodash';
+
+const ACTION_TYPES = [
+  'ADD_QUERY_FILTER',
+  'BLUR_SEARCH',
+  'CACHE_ZOOM_STATE',
+  'CHANGE_INSTANCE',
+  'CHANGE_TOPOLOGY_OPTION',
+  'CLEAR_CONTROL_ERROR',
+  'CLICK_BACKGROUND',
+  'CLICK_CLOSE_DETAILS',
+  'CLICK_FORCE_RELAYOUT',
+  'CLICK_NODE',
+  'CLICK_RELATIVE',
+  'CLICK_SHOW_TOPOLOGY_FOR_NODE',
+  'CLICK_TERMINAL',
+  'CLICK_TOPOLOGY',
+  'CLOSE_TERMINAL',
+  'CLOSE_WEBSOCKET',
+  'DEBUG_TOOLBAR_INTERFERING',
+  'DESELECT_NODE',
+  'DO_CONTROL_ERROR',
+  'DO_CONTROL_SUCCESS',
+  'DO_CONTROL',
+  'ENTER_EDGE',
+  'ENTER_NODE',
+  'FINISH_TIME_TRAVEL_TRANSITION',
+  'FOCUS_SEARCH',
+  'HIDE_HELP',
+  'HOVER_METRIC',
+  'JUMP_TO_TIME',
+  'LEAVE_EDGE',
+  'LEAVE_NODE',
+  'MONITOR_STATE',
+  'OPEN_WEBSOCKET',
+  'PAUSE_TIME_AT_NOW',
+  'PIN_METRIC',
+  'PIN_NETWORK',
+  'RECEIVE_API_DETAILS',
+  'RECEIVE_CONTROL_NODE_REMOVED',
+  'RECEIVE_CONTROL_PIPE_STATUS',
+  'RECEIVE_CONTROL_PIPE',
+  'RECEIVE_ERROR',
+  'RECEIVE_NODE_DETAILS',
+  'RECEIVE_NODES_DELTA',
+  'RECEIVE_NODES_FOR_TOPOLOGY',
+  'RECEIVE_NODES',
+  'RECEIVE_NOT_FOUND',
+  'RECEIVE_TOPOLOGIES',
+  'RESET_LOCAL_VIEW_STATE',
+  'RESUME_TIME',
+  'ROUTE_TOPOLOGY',
+  'SELECT_NETWORK',
+  'SET_EXPORTING_GRAPH',
+  'SET_RECEIVED_NODES_DELTA',
+  'SET_STORE_VIEW_STATE',
+  'SET_VIEW_MODE',
+  'SET_VIEWPORT_DIMENSIONS',
+  'SHOW_HELP',
+  'SHOW_NETWORKS',
+  'SHUTDOWN',
+  'SORT_ORDER_CHANGED',
+  'TOGGLE_CONTRAST_MODE',
+  'TOGGLE_TROUBLESHOOTING_MENU',
+  'UNHOVER_METRIC',
+  'UNPIN_METRIC',
+  'UNPIN_NETWORK',
+  'UPDATE_SEARCH',
+];
+
+export default zipObject(ACTION_TYPES, ACTION_TYPES);

+ 4 - 0
app/scripts/constants/key-codes.js

@@ -0,0 +1,4 @@
+
+export const BACKSPACE_KEY_CODE = 8;
+export const ENTER_KEY_CODE = 13;
+export const ESC_KEY_CODE = 27;

+ 2 - 0
app/scripts/constants/limits.js

@@ -0,0 +1,2 @@
+
+export const NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT = 5;

+ 21 - 0
app/scripts/constants/naming.js

@@ -0,0 +1,21 @@
+
+export const EDGE_ID_SEPARATOR = '---';
+
+// NOTE: Inconsistent naming is a consequence of
+// keeping it backwards-compatible with the old URLs.
+export const GRAPH_VIEW_MODE = 'topo';
+export const TABLE_VIEW_MODE = 'grid';
+export const RESOURCE_VIEW_MODE = 'resource';
+
+// Named constants to avoid typos that would result in hard-to-detect bugs.
+export const BLURRED_EDGES_LAYER = 'blurred-edges';
+export const BLURRED_NODES_LAYER = 'blurred-nodes';
+export const NORMAL_EDGES_LAYER = 'normal-edges';
+export const NORMAL_NODES_LAYER = 'normal-nodes';
+export const HIGHLIGHTED_EDGES_LAYER = 'highlighted-edges';
+export const HIGHLIGHTED_NODES_LAYER = 'highlighted-nodes';
+export const HOVERED_EDGES_LAYER = 'hovered-edges';
+export const HOVERED_NODES_LAYER = 'hovered-nodes';
+
+export const CONTENT_INCLUDED = 'content-included';
+export const CONTENT_COVERING = 'content-covering';

+ 27 - 0
app/scripts/constants/resources.js

@@ -0,0 +1,27 @@
+
+// Cap the number of layers in the resource view to this constant. The reason why we have
+// this constant is not just about the style, but also helps us build the selectors.
+export const RESOURCE_VIEW_MAX_LAYERS = 3;
+
+// TODO: Consider fetching these from the backend.
+export const TOPOLOGIES_WITH_CAPACITY = ['hosts'];
+
+// TODO: These too should ideally be provided by the backend. Currently, we are showing
+// the same layers for all the topologies, because their number is small, but later on
+// we might be interested in fully customizing the layers' hierarchy per topology.
+export const RESOURCE_VIEW_LAYERS = {
+  containers: ['hosts', 'containers', 'processes'],
+  hosts: ['hosts', 'containers', 'processes'],
+  processes: ['hosts', 'containers', 'processes'],
+};
+
+// TODO: These are all the common metrics that appear across all the current resource view
+// topologies. The reason for taking them only is that we want to get meaningful data for all
+// the layers. These should be taken directly from the backend report, but as their info is
+// currently only contained in the nodes data, it would be hard to determine them before all
+// the nodes for all the layers have been loaded, so we'd need to change the routing logic
+// since the requirement is that one these is always pinned in the resource view.
+export const RESOURCE_VIEW_METRICS = [
+  { id: 'host_cpu_usage_percent', label: 'CPU' },
+  { id: 'host_mem_usage_bytes', label: 'Memory' },
+];

+ 106 - 0
app/scripts/constants/styles.js

@@ -0,0 +1,106 @@
+import { GRAPH_VIEW_MODE, RESOURCE_VIEW_MODE } from './naming';
+
+
+export const DETAILS_PANEL_WIDTH = 620;
+export const DETAILS_PANEL_OFFSET = 8;
+export const DETAILS_PANEL_MARGINS = {
+  bottom: 48,
+  right: 36,
+  top: 24
+};
+
+// Resource view
+export const RESOURCES_LAYER_TITLE_WIDTH = 200;
+export const RESOURCES_LAYER_HEIGHT = 150;
+export const RESOURCES_LAYER_PADDING = 10;
+export const RESOURCES_LABEL_MIN_SIZE = 50;
+export const RESOURCES_LABEL_PADDING = 10;
+
+// Node shapes
+export const UNIT_CLOUD_PATH = 'M-1.25 0.233Q-1.25 0.44-1.104 0.587-0.957 0.733-0.75 0.733H0.667Q'
+  + '0.908 0.733 1.079 0.562 1.25 0.391 1.25 0.15 1.25-0.022 1.158-0.164 1.065-0.307 0.914-0.377q'
+  + '0.003-0.036 0.003-0.056 0-0.276-0.196-0.472-0.195-0.195-0.471-0.195-0.206 0-0.373 0.115-0.167'
+  + ' 0.115-0.244 0.299-0.091-0.081-0.216-0.081-0.138 0-0.236 0.098-0.098 0.098-0.098 0.236 0 0.098'
+  + ' 0.054 0.179-0.168 0.039-0.278 0.175-0.109 0.136-0.109 0.312z';
+
+// Node Cylinder shape
+export const UNIT_CYLINDER_PATH = 'm -1 -1.25' // this line is responsible for adjusting place of the shape with respect to dot
+  + 'a 1 0.4 0 0 0 2 0'
+  + 'm -2 0'
+  + 'v 1.8'
+  + 'a 1 0.4 0 0 0 2 0'
+  + 'v -1.8'
+  + 'a 1 0.4 0 0 0 -2 0';
+
+// Node Storage Sheet Shape
+export const SHEET = 'm -1.2 -1.6 m 0.4 0 v 2.4 m -0.4 -2.4 v 2.4 h 2 v -2.4 z m 0 0.4 h 2';
+
+// NOTE: This value represents the node unit radius (in pixels). Since zooming is
+// controlled at the top level now, this renormalization would be obsolete (i.e.
+// value 1 could be used instead), if it wasn't for the following factors:
+//   1. `dagre` library only works with integer coordinates,
+//      so >> 1 value is used to increase layout precision.
+//   2. Fonts don't behave nicely (especially on Firefox) if they
+//      are given on a small unit scale as foreign objects in SVG.
+export const NODE_BASE_SIZE = 200;  //系统默认100
+
+// This value represents the upper bound on the number of control points along the graph edge
+// curve. Any integer value >= 6 should result in valid edges, but generally the greater this
+// value is, the nicer the edge bundling will be. On the other hand, big values would result
+// in slower rendering of the graph.
+export const EDGE_WAYPOINTS_CAP = 10;
+
+export const CANVAS_MARGINS = {
+  [GRAPH_VIEW_MODE]: {
+    bottom: 150, left: 80, right: 80, top: 220
+  },
+  [RESOURCE_VIEW_MODE]: {
+    bottom: 150, left: 210, right: 40, top: 200
+  },
+};
+
+// Node details table constants
+export const NODE_DETAILS_TABLE_CW = {
+  L: '85px',
+  M: '70px',
+  // 6 chars wide with our current font choices, (pids can be 6, ports only 5).
+  S: '56px',
+  XL: '120px',
+  XS: '32px',
+  XXL: '140px',
+  XXXL: '170px',
+};
+
+export const NODE_DETAILS_TABLE_COLUMN_WIDTHS = {
+  container: NODE_DETAILS_TABLE_CW.XS,
+  count: NODE_DETAILS_TABLE_CW.XS,
+  docker_container_created: NODE_DETAILS_TABLE_CW.XXXL,
+  docker_container_restart_count: NODE_DETAILS_TABLE_CW.M,
+  docker_container_state_human: NODE_DETAILS_TABLE_CW.XXXL,
+  docker_container_uptime: NODE_DETAILS_TABLE_CW.L,
+  docker_cpu_total_usage: NODE_DETAILS_TABLE_CW.M,
+  docker_memory_usage: NODE_DETAILS_TABLE_CW.M,
+  // e.g. details panel > pods
+  kubernetes_ip: NODE_DETAILS_TABLE_CW.XL,
+  kubernetes_state: NODE_DETAILS_TABLE_CW.M,
+  open_files_count: NODE_DETAILS_TABLE_CW.M,
+  pid: NODE_DETAILS_TABLE_CW.S,
+  port: NODE_DETAILS_TABLE_CW.S,
+  // Label "Parent PID" needs more space
+  ppid: NODE_DETAILS_TABLE_CW.M,
+  process_cpu_usage_percent: NODE_DETAILS_TABLE_CW.M,
+
+  process_memory_usage_bytes: NODE_DETAILS_TABLE_CW.M,
+  threads: NODE_DETAILS_TABLE_CW.M,
+
+  // weave connections
+  weave_connection_connection: NODE_DETAILS_TABLE_CW.XXL,
+  weave_connection_info: NODE_DETAILS_TABLE_CW.XL,
+  weave_connection_state: NODE_DETAILS_TABLE_CW.L,
+};
+
+export const NODE_DETAILS_TABLE_XS_LABEL = {
+  // TODO: consider changing the name of this field on the BE
+  container: '#',
+  count: '#',
+};

+ 10 - 0
app/scripts/constants/timer.js

@@ -0,0 +1,10 @@
+/* Intervals in ms */
+export const API_REFRESH_INTERVAL = 30000;
+export const TOPOLOGY_REFRESH_INTERVAL = 5000;
+
+export const TOPOLOGY_LOADER_DELAY = 100;
+
+export const TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL = 10;
+export const VIEWPORT_RESIZE_DEBOUNCE_INTERVAL = 200;
+
+export const ZOOM_CACHE_DEBOUNCE_INTERVAL = 500;

Some files were not shown because too many files changed in this diff