From 96aae9bc9989ba117df1bc7c91c72d1705764cff Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 27 Apr 2016 13:09:00 +0200 Subject: [PATCH 01/19] Migrate from Flux to Redux * better state visibility * pure state changes * state debug panel (show: crtl-h, move: ctrl-w) --- client/.eslintrc | 1 + client/app/scripts/actions/app-actions.js | 526 +++++++------ client/app/scripts/charts/edge.js | 14 +- client/app/scripts/charts/node.js | 17 +- .../app/scripts/charts/nodes-chart-edges.js | 18 +- .../scripts/charts/nodes-chart-elements.js | 14 +- .../app/scripts/charts/nodes-chart-nodes.js | 28 +- client/app/scripts/charts/nodes-chart.js | 45 +- .../components/__tests__/node-details-test.js | 3 +- client/app/scripts/components/app.js | 145 ++-- .../app/scripts/components/debug-toolbar.js | 97 ++- client/app/scripts/components/details.js | 35 +- client/app/scripts/components/dev-tools.js | 11 + .../scripts/components/embedded-terminal.js | 54 +- client/app/scripts/components/footer.js | 173 ++-- .../components/metric-selector-item.js | 23 +- .../app/scripts/components/metric-selector.js | 25 +- client/app/scripts/components/node-details.js | 18 +- .../node-details-control-button.js | 7 +- .../node-details-relatives-link.js | 11 +- .../node-details-table-node-link.js | 11 +- client/app/scripts/components/nodes.js | 22 +- client/app/scripts/components/plugins.js | 18 +- client/app/scripts/components/status.js | 78 +- client/app/scripts/components/terminal-app.js | 39 +- client/app/scripts/components/terminal.js | 11 +- client/app/scripts/components/topologies.js | 22 +- .../components/topology-option-action.js | 12 +- .../scripts/components/topology-options.js | 21 +- client/app/scripts/contrast-main.js | 12 +- client/app/scripts/debug.js | 3 - .../app/scripts/dispatcher/app-dispatcher.js | 14 - client/app/scripts/main.dev.js | 28 + client/app/scripts/main.js | 15 +- client/app/scripts/main.prod.js | 22 + .../scripts/reducers/__tests__/root-test.js | 461 +++++++++++ client/app/scripts/reducers/root.js | 560 +++++++++++++ .../stores/__tests__/app-store-test.js | 434 ---------- client/app/scripts/stores/app-store.js | 741 ------------------ .../app/scripts/stores/configureStore.dev.js | 28 + client/app/scripts/stores/configureStore.js | 5 + .../app/scripts/stores/configureStore.prod.js | 12 + client/app/scripts/terminal-main.js | 14 +- client/app/scripts/utils/router-utils.js | 41 +- client/app/scripts/utils/topology-utils.js | 72 +- .../app/scripts/utils/update-buffer-utils.js | 13 +- client/app/scripts/utils/web-api-utils.js | 68 +- client/package.json | 19 +- client/webpack.local.config.js | 8 +- client/webpack.production.config.js | 5 +- 50 files changed, 2153 insertions(+), 1921 deletions(-) create mode 100644 client/app/scripts/components/dev-tools.js delete mode 100644 client/app/scripts/debug.js delete mode 100644 client/app/scripts/dispatcher/app-dispatcher.js create mode 100644 client/app/scripts/main.dev.js create mode 100644 client/app/scripts/main.prod.js create mode 100644 client/app/scripts/reducers/__tests__/root-test.js create mode 100644 client/app/scripts/reducers/root.js delete mode 100644 client/app/scripts/stores/__tests__/app-store-test.js delete mode 100644 client/app/scripts/stores/app-store.js create mode 100644 client/app/scripts/stores/configureStore.dev.js create mode 100644 client/app/scripts/stores/configureStore.js create mode 100644 client/app/scripts/stores/configureStore.prod.js diff --git a/client/.eslintrc b/client/.eslintrc index aa6943f9e..7320a3b9f 100644 --- a/client/.eslintrc +++ b/client/.eslintrc @@ -7,6 +7,7 @@ }, "rules": { "comma-dangle": 0, + "no-param-reassign": 0, "object-curly-spacing": 0, "react/jsx-closing-bracket-location": 0, "react/prefer-stateless-function": 0, diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 1b654a934..970a57bb2 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -1,6 +1,5 @@ import debug from 'debug'; -import AppDispatcher from '../dispatcher/app-dispatcher'; import ActionTypes from '../constants/action-types'; import { saveGraph } from '../utils/file-utils'; import { modulo } from '../utils/math-utils'; @@ -9,108 +8,122 @@ import { bufferDeltaUpdate, resumeUpdate, resetUpdateBuffer } from '../utils/update-buffer-utils'; import { doControlRequest, getNodesDelta, getNodeDetails, getTopologies, deletePipe } from '../utils/web-api-utils'; -import AppStore from '../stores/app-store'; +import { getActiveTopologyOptions, + getCurrentTopologyUrl } from '../utils/topology-utils'; const log = debug('scope:app-actions'); export function showHelp() { - AppDispatcher.dispatch({type: ActionTypes.SHOW_HELP}); + return {type: ActionTypes.SHOW_HELP}; } export function hideHelp() { - AppDispatcher.dispatch({type: ActionTypes.HIDE_HELP}); + return {type: ActionTypes.HIDE_HELP}; } export function toggleHelp() { - if (AppStore.getShowingHelp()) { - hideHelp(); - } else { - showHelp(); - } + return (dispatch, getState) => { + if (getState().get('showingHelp')) { + dispatch(hideHelp()); + } + dispatch(showHelp()); + }; } export function selectMetric(metricId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.SELECT_METRIC, metricId - }); -} - -export function pinNextMetric(delta) { - const metrics = AppStore.getAvailableCanvasMetrics().map(m => m.get('id')); - const currentIndex = metrics.indexOf(AppStore.getSelectedMetric()); - const nextIndex = modulo(currentIndex + delta, metrics.count()); - const nextMetric = metrics.get(nextIndex); - - AppDispatcher.dispatch({ - type: ActionTypes.PIN_METRIC, - metricId: nextMetric, - }); - updateRoute(); + }; } export function pinMetric(metricId) { - AppDispatcher.dispatch({ - type: ActionTypes.PIN_METRIC, - metricId, - }); - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.PIN_METRIC, + metricId, + }); + updateRoute(getState); + }; } export function unpinMetric() { - AppDispatcher.dispatch({ - type: ActionTypes.UNPIN_METRIC, - }); - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.UNPIN_METRIC, + }); + updateRoute(getState); + }; +} + +export function pinNextMetric(delta) { + return (dispatch, getState) => { + const state = getState(); + const metrics = state.get('availableCanvasMetrics').map(m => m.get('id')); + const currentIndex = metrics.indexOf(state.get('selectedMetric')); + const nextIndex = modulo(currentIndex + delta, metrics.count()); + const nextMetric = metrics.get(nextIndex); + + dispatch(pinMetric(nextMetric)); + }; } export function changeTopologyOption(option, value, topologyId) { - AppDispatcher.dispatch({ - type: ActionTypes.CHANGE_TOPOLOGY_OPTION, - topologyId, - option, - value - }); - updateRoute(); - // update all request workers with new options - resetUpdateBuffer(); - getTopologies( - AppStore.getActiveTopologyOptions() - ); - getNodesDelta( - AppStore.getCurrentTopologyUrl(), - AppStore.getActiveTopologyOptions() - ); - getNodeDetails( - AppStore.getTopologyUrlsById(), - AppStore.getNodeDetails() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CHANGE_TOPOLOGY_OPTION, + topologyId, + option, + value + }); + updateRoute(getState); + // update all request workers with new options + resetUpdateBuffer(); + const state = getState(); + getTopologies(getActiveTopologyOptions(state), dispatch); + getNodesDelta( + getCurrentTopologyUrl(state), + getActiveTopologyOptions(state), + dispatch + ); + getNodeDetails( + state.get('topologyUrlsById'), + state.get('nodeDetails'), + dispatch + ); + }; } export function clickBackground() { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_BACKGROUND - }); - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_BACKGROUND + }); + updateRoute(getState); + }; } export function clickCloseDetails(nodeId) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_CLOSE_DETAILS, - nodeId - }); - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_CLOSE_DETAILS, + nodeId + }); + updateRoute(getState); + }; } export function clickCloseTerminal(pipeId, closePipe) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_CLOSE_TERMINAL, - pipeId - }); - if (closePipe) { - deletePipe(pipeId); - } - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_CLOSE_TERMINAL, + pipeId + }); + if (closePipe) { + deletePipe(pipeId, dispatch); + } + updateRoute(getState); + }; } export function clickDownloadGraph() { @@ -118,285 +131,340 @@ export function clickDownloadGraph() { } export function clickForceRelayout() { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_FORCE_RELAYOUT - }); + return (dispatch) => { + dispatch({ + type: ActionTypes.CLICK_FORCE_RELAYOUT, + forceRelayout: true + }); + // fire only once, reset after dispatch + setTimeout(() => { + dispatch({ + type: ActionTypes.CLICK_FORCE_RELAYOUT, + forceRelayout: false + }); + }, 100); + }; } export function clickNode(nodeId, label, origin) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_NODE, - origin, - label, - nodeId - }); - updateRoute(); - getNodeDetails( - AppStore.getTopologyUrlsById(), - AppStore.getNodeDetails() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_NODE, + origin, + label, + nodeId + }); + updateRoute(getState); + const state = getState(); + getNodeDetails( + state.get('topologyUrlsById'), + state.get('nodeDetails'), + dispatch + ); + }; } export function clickPauseUpdate() { - AppDispatcher.dispatch({ + return { type: ActionTypes.CLICK_PAUSE_UPDATE - }); + }; } export function clickRelative(nodeId, topologyId, label, origin) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_RELATIVE, - label, - origin, - nodeId, - topologyId - }); - updateRoute(); - getNodeDetails( - AppStore.getTopologyUrlsById(), - AppStore.getNodeDetails() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_RELATIVE, + label, + origin, + nodeId, + topologyId + }); + updateRoute(getState); + const state = getState(); + getNodeDetails( + state.get('topologyUrlsById'), + state.get('nodeDetails'), + dispatch + ); + }; } export function clickResumeUpdate() { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_RESUME_UPDATE - }); - resumeUpdate(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_RESUME_UPDATE + }); + resumeUpdate(getState); + }; } export function clickShowTopologyForNode(topologyId, nodeId) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE, - topologyId, - nodeId - }); - updateRoute(); - resetUpdateBuffer(); - getNodesDelta( - AppStore.getCurrentTopologyUrl(), - AppStore.getActiveTopologyOptions() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE, + topologyId, + nodeId + }); + updateRoute(getState); + // update all request workers with new options + resetUpdateBuffer(); + const state = getState(); + getNodesDelta( + getCurrentTopologyUrl(state), + getActiveTopologyOptions(state), + dispatch + ); + }; } export function clickTopology(topologyId) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_TOPOLOGY, - topologyId - }); - updateRoute(); - resetUpdateBuffer(); - getNodesDelta( - AppStore.getCurrentTopologyUrl(), - AppStore.getActiveTopologyOptions() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_TOPOLOGY, + topologyId + }); + updateRoute(getState); + // update all request workers with new options + resetUpdateBuffer(); + const state = getState(); + getNodesDelta( + getCurrentTopologyUrl(state), + getActiveTopologyOptions(state), + dispatch + ); + }; } export function openWebsocket() { - AppDispatcher.dispatch({ + return { type: ActionTypes.OPEN_WEBSOCKET - }); + }; } export function clearControlError(nodeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.CLEAR_CONTROL_ERROR, nodeId - }); + }; } export function closeWebsocket() { - AppDispatcher.dispatch({ + return { type: ActionTypes.CLOSE_WEBSOCKET - }); + }; } export function doControl(nodeId, control) { - AppDispatcher.dispatch({ - type: ActionTypes.DO_CONTROL, - nodeId - }); - doControlRequest(nodeId, control); + return (dispatch) => { + dispatch({ + type: ActionTypes.DO_CONTROL, + nodeId + }); + doControlRequest(nodeId, control, dispatch); + }; } export function enterEdge(edgeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.ENTER_EDGE, edgeId - }); + }; } export function enterNode(nodeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.ENTER_NODE, nodeId - }); + }; } export function hitEsc() { - const controlPipe = AppStore.getControlPipe(); - if (AppStore.getShowingHelp()) { - hideHelp(); - } else if (controlPipe && controlPipe.get('status') === 'PIPE_DELETED') { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_CLOSE_TERMINAL, - pipeId: controlPipe.get('id') - }); - updateRoute(); - // Don't deselect node on ESC if there is a controlPipe (keep terminal open) - } else if (AppStore.getTopCardNodeId() && !controlPipe) { - AppDispatcher.dispatch({ type: ActionTypes.DESELECT_NODE }); - updateRoute(); - } + return (dispatch, getState) => { + const state = getState(); + const controlPipe = state.get('controlPipes').last(); + if (state.get('showingHelp')) { + dispatch(hideHelp()); + } else if (controlPipe && controlPipe.get('status') === 'PIPE_DELETED') { + dispatch({ + type: ActionTypes.CLICK_CLOSE_TERMINAL, + pipeId: controlPipe.get('id') + }); + updateRoute(getState); + // Don't deselect node on ESC if there is a controlPipe (keep terminal open) + } else if (state.get('nodeDetails').last() && !controlPipe) { + dispatch({ type: ActionTypes.DESELECT_NODE }); + updateRoute(getState); + } + }; } export function leaveEdge(edgeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.LEAVE_EDGE, edgeId - }); + }; } export function leaveNode(nodeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.LEAVE_NODE, nodeId - }); + }; } export function receiveControlError(nodeId, err) { - AppDispatcher.dispatch({ + return { type: ActionTypes.DO_CONTROL_ERROR, nodeId, error: err - }); + }; } export function receiveControlSuccess(nodeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.DO_CONTROL_SUCCESS, nodeId - }); + }; } export function receiveNodeDetails(details) { - AppDispatcher.dispatch({ + return { type: ActionTypes.RECEIVE_NODE_DETAILS, details - }); + }; } export function receiveNodesDelta(delta) { - if (AppStore.isUpdatePaused()) { - bufferDeltaUpdate(delta); - } else { - AppDispatcher.dispatch({ - type: ActionTypes.RECEIVE_NODES_DELTA, - delta - }); - } + return (dispatch, getState) => { + if (delta.add || delta.update || delta.remove) { + const state = getState(); + if (state.get('updatePausedAt') !== null) { + bufferDeltaUpdate(delta); + } else { + dispatch({ + type: ActionTypes.RECEIVE_NODES_DELTA, + delta + }); + } + } + }; } export function receiveTopologies(topologies) { - AppDispatcher.dispatch({ - type: ActionTypes.RECEIVE_TOPOLOGIES, - topologies - }); - getNodesDelta( - AppStore.getCurrentTopologyUrl(), - AppStore.getActiveTopologyOptions() - ); - getNodeDetails( - AppStore.getTopologyUrlsById(), - AppStore.getNodeDetails() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.RECEIVE_TOPOLOGIES, + topologies + }); + const state = getState(); + getNodesDelta( + getCurrentTopologyUrl(state), + getActiveTopologyOptions(state), + dispatch + ); + getNodeDetails( + state.get('topologyUrlsById'), + state.get('nodeDetails'), + dispatch + ); + }; } export function receiveApiDetails(apiDetails) { - AppDispatcher.dispatch({ + return { type: ActionTypes.RECEIVE_API_DETAILS, hostname: apiDetails.hostname, version: apiDetails.version, plugins: apiDetails.plugins - }); + }; } export function receiveControlNodeRemoved(nodeId) { - AppDispatcher.dispatch({ - type: ActionTypes.RECEIVE_CONTROL_NODE_REMOVED, - nodeId - }); - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.RECEIVE_CONTROL_NODE_REMOVED, + nodeId + }); + updateRoute(getState); + }; } export function receiveControlPipeFromParams(pipeId, rawTty) { // TODO add nodeId - AppDispatcher.dispatch({ + return { type: ActionTypes.RECEIVE_CONTROL_PIPE, pipeId, rawTty - }); + }; } export function receiveControlPipe(pipeId, nodeId, rawTty) { - if (nodeId !== AppStore.getTopCardNodeId()) { - log('Node was deselected before we could set up control!'); - deletePipe(pipeId); - return; - } + 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 = AppStore.getControlPipe(); - if (controlPipe && controlPipe.get('id') !== pipeId) { - deletePipe(controlPipe.get('id')); - } + const controlPipe = state.get('controlPipes').last(); + if (controlPipe && controlPipe.get('id') !== pipeId) { + deletePipe(controlPipe.get('id'), dispatch); + } - AppDispatcher.dispatch({ - type: ActionTypes.RECEIVE_CONTROL_PIPE, - nodeId, - pipeId, - rawTty - }); + dispatch({ + type: ActionTypes.RECEIVE_CONTROL_PIPE, + nodeId, + pipeId, + rawTty + }); - updateRoute(); + updateRoute(getState); + }; } export function receiveControlPipeStatus(pipeId, status) { - AppDispatcher.dispatch({ + return { type: ActionTypes.RECEIVE_CONTROL_PIPE_STATUS, pipeId, status - }); + }; } export function receiveError(errorUrl) { - AppDispatcher.dispatch({ + return { errorUrl, type: ActionTypes.RECEIVE_ERROR - }); + }; } export function receiveNotFound(nodeId) { - AppDispatcher.dispatch({ + return { nodeId, type: ActionTypes.RECEIVE_NOT_FOUND - }); + }; } -export function route(state) { - AppDispatcher.dispatch({ - state, - type: ActionTypes.ROUTE_TOPOLOGY - }); - getTopologies( - AppStore.getActiveTopologyOptions() - ); - getNodesDelta( - AppStore.getCurrentTopologyUrl(), - AppStore.getActiveTopologyOptions() - ); - getNodeDetails( - AppStore.getTopologyUrlsById(), - AppStore.getNodeDetails() - ); +export function route(urlState) { + return (dispatch, getState) => { + dispatch({ + state: urlState, + type: ActionTypes.ROUTE_TOPOLOGY + }); + // update all request workers with new options + const state = getState(); + getTopologies(getActiveTopologyOptions(state), dispatch); + getNodesDelta( + getCurrentTopologyUrl(state), + getActiveTopologyOptions(state), + dispatch + ); + getNodeDetails( + state.get('topologyUrlsById'), + state.get('nodeDetails'), + dispatch + ); + }; } diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js index 8689f6b84..458a8a7c3 100644 --- a/client/app/scripts/charts/edge.js +++ b/client/app/scripts/charts/edge.js @@ -1,11 +1,10 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import classNames from 'classnames'; import { enterEdge, leaveEdge } from '../actions/app-actions'; -export default class Edge extends React.Component { +class Edge extends React.Component { constructor(props, context) { super(props, context); @@ -27,12 +26,15 @@ export default class Edge extends React.Component { } handleMouseEnter(ev) { - enterEdge(ev.currentTarget.id); + this.props.enterEdge(ev.currentTarget.id); } handleMouseLeave(ev) { - leaveEdge(ev.currentTarget.id); + this.props.leaveEdge(ev.currentTarget.id); } } -reactMixin.onClass(Edge, PureRenderMixin); +export default connect( + null, + { enterEdge, leaveEdge } +)(Edge); diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index c7469c884..5d96faa41 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -1,7 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import classNames from 'classnames'; import { clickNode, enterNode, leaveNode } from '../actions/app-actions'; @@ -45,7 +44,7 @@ function ellipsis(text, fontSize, maxWidth) { return truncatedText; } -export default class Node extends React.Component { +class Node extends React.Component { constructor(props, context) { super(props, context); @@ -116,18 +115,22 @@ export default class Node extends React.Component { handleMouseClick(ev) { ev.stopPropagation(); - clickNode(this.props.id, this.props.label, ReactDOM.findDOMNode(this).getBoundingClientRect()); + this.props.clickNode(this.props.id, this.props.label, + ReactDOM.findDOMNode(this).getBoundingClientRect()); } handleMouseEnter() { - enterNode(this.props.id); + this.props.enterNode(this.props.id); this.setState({ hovered: true }); } handleMouseLeave() { - leaveNode(this.props.id); + this.props.leaveNode(this.props.id); this.setState({ hovered: false }); } } -reactMixin.onClass(Node, PureRenderMixin); +export default connect( + null, + { clickNode, enterNode, leaveNode } +)(Node); diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index 4d1ef1ef5..4fa976288 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -1,10 +1,10 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; +import { hasSelectedNode as hasSelectedNodeFn } from '../utils/topology-utils'; import EdgeContainer from './edge-container'; -export default class NodesChartEdges extends React.Component { +class NodesChartEdges extends React.Component { render() { const {hasSelectedNode, highlightedEdgeIds, layoutEdges, layoutPrecision, selectedNodeId} = this.props; @@ -36,4 +36,14 @@ export default class NodesChartEdges extends React.Component { } } -reactMixin.onClass(NodesChartEdges, PureRenderMixin); +function mapStateToProps(state) { + return { + hasSelectedNode: hasSelectedNodeFn(state), + selectedNodeId: state.get('selectedNodeId'), + highlightedEdgeIds: state.get('highlightedEdgeIds') + }; +} + +export default connect( + mapStateToProps +)(NodesChartEdges); diff --git a/client/app/scripts/charts/nodes-chart-elements.js b/client/app/scripts/charts/nodes-chart-elements.js index 075827818..5934bc1bc 100644 --- a/client/app/scripts/charts/nodes-chart-elements.js +++ b/client/app/scripts/charts/nodes-chart-elements.js @@ -10,19 +10,11 @@ export default class NodesChartElements extends React.Component { const props = this.props; return ( - - + layoutPrecision={props.layoutPrecision} /> ); } diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index ce33acec2..b9447e507 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -1,15 +1,15 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { fromJS } from 'immutable'; +import { getAdjacentNodes } from '../utils/topology-utils'; import NodeContainer from './node-container'; -export default class NodesChartNodes extends React.Component { +class NodesChartNodes extends React.Component { render() { - const {adjacentNodes, highlightedNodeIds, - layoutNodes, layoutPrecision, nodeScale, onNodeClick, scale, - selectedMetric, selectedNodeScale, selectedNodeId, topologyId, topCardNode} = this.props; + const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision, + nodeScale, scale, selectedMetric, selectedNodeScale, selectedNodeId, + topologyId, topCardNode } = this.props; const zoomScale = scale; @@ -56,7 +56,6 @@ export default class NodesChartNodes extends React.Component { topologyId={topologyId} shape={node.get('shape')} stack={node.get('stack')} - onClick={onNodeClick} key={node.get('id')} id={node.get('id')} label={node.get('label')} @@ -76,4 +75,17 @@ export default class NodesChartNodes extends React.Component { } } -reactMixin.onClass(NodesChartNodes, PureRenderMixin); +function mapStateToProps(state) { + return { + adjacentNodes: getAdjacentNodes(state), + highlightedNodeIds: state.get('highlightedNodeIds'), + selectedMetric: state.get('selectedMetric'), + selectedNodeId: state.get('selectedNodeId'), + topologyId: state.get('topologyId'), + topCardNode: state.get('nodeDetails').last() + }; +} + +export default connect( + mapStateToProps +)(NodesChartNodes); diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 2d6d36845..9a88e7847 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -2,8 +2,7 @@ import _ from 'lodash'; import d3 from 'd3'; import debug from 'debug'; import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { Map as makeMap, fromJS, is as isDeepEqual } from 'immutable'; import timely from 'timely'; @@ -13,6 +12,8 @@ import { DETAILS_PANEL_WIDTH } from '../constants/styles'; import Logo from '../components/logo'; import { doLayout } from './nodes-layout'; import NodesChartElements from './nodes-chart-elements'; +import { getActiveTopologyOptions, getAdjacentNodes, + isSameTopology } from '../utils/topology-utils'; const log = debug('scope:nodes-chart'); @@ -29,7 +30,7 @@ const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY']; const radiusDensity = d3.scale.threshold() .domain([3, 6]).range([2.5, 3.5, 3]); -export default class NodesChart extends React.Component { +class NodesChart extends React.Component { constructor(props, context) { super(props, context); @@ -88,7 +89,7 @@ export default class NodesChart extends React.Component { state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width); // _.assign(state, this.updateGraphState(nextProps, state)); - if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) { + if (nextProps.forceRelayout || !isSameTopology(nextProps.nodes, this.props.nodes)) { _.assign(state, this.updateGraphState(nextProps, state)); } @@ -139,22 +140,10 @@ export default class NodesChart extends React.Component { - + layoutPrecision={this.props.layoutPrecision} /> ); @@ -162,7 +151,7 @@ export default class NodesChart extends React.Component { handleMouseClick() { if (!this.isZooming) { - clickBackground(); + this.props.clickBackground(); } else { this.isZooming = false; } @@ -411,4 +400,18 @@ export default class NodesChart extends React.Component { } } -reactMixin.onClass(NodesChart, PureRenderMixin); +function mapStateToProps(state) { + return { + adjacentNodes: getAdjacentNodes(state), + forceRelayout: state.get('forceRelayout'), + nodes: state.get('nodes'), + selectedNodeId: state.get('selectedNodeId'), + topologyId: state.get('topologyId'), + topologyOptions: getActiveTopologyOptions(state) + }; +} + +export default connect( + mapStateToProps, + { clickBackground } +)(NodesChart); diff --git a/client/app/scripts/components/__tests__/node-details-test.js b/client/app/scripts/components/__tests__/node-details-test.js index 9a21ecadb..e6be1701c 100644 --- a/client/app/scripts/components/__tests__/node-details-test.js +++ b/client/app/scripts/components/__tests__/node-details-test.js @@ -2,7 +2,6 @@ import React from 'react'; import Immutable from 'immutable'; import TestUtils from 'react/lib/ReactTestUtils'; -jest.dontMock('../../dispatcher/app-dispatcher'); jest.dontMock('../node-details.js'); jest.dontMock('../node-details/node-details-controls.js'); jest.dontMock('../node-details/node-details-relatives.js'); @@ -13,7 +12,7 @@ jest.dontMock('../../utils/color-utils'); jest.dontMock('../../utils/title-utils'); // need ES5 require to keep automocking off -const NodeDetails = require('../node-details.js').default; +const NodeDetails = require('../node-details.js').NodeDetails; describe('NodeDetails', () => { let nodes; diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 93f4e5cc1..d9cd7045f 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -1,10 +1,8 @@ import debug from 'debug'; import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import Logo from './logo'; -import AppStore from '../stores/app-store'; import Footer from './footer.js'; import Sidebar from './sidebar.js'; import HelpPanel from './help-panel'; @@ -19,67 +17,32 @@ import Nodes from './nodes'; import MetricSelector from './metric-selector'; import EmbeddedTerminal from './embedded-terminal'; import { getRouter } from '../utils/router-utils'; -import { showingDebugToolbar, toggleDebugToolbar, - DebugToolbar } from './debug-toolbar.js'; +import DebugToolbar, { showingDebugToolbar, + toggleDebugToolbar } from './debug-toolbar.js'; +import { getUrlState } from '../utils/router-utils'; +import { getActiveTopologyOptions } from '../utils/topology-utils'; const ESC_KEY_CODE = 27; const keyPressLog = debug('scope:app-key-press'); -/* make sure these can all be shallow-checked for equality for PureRenderMixin */ -function getStateFromStores() { - return { - activeTopologyOptions: AppStore.getActiveTopologyOptions(), - adjacentNodes: AppStore.getAdjacentNodes(AppStore.getSelectedNodeId()), - controlStatus: AppStore.getControlStatus(), - controlPipe: AppStore.getControlPipe(), - currentTopology: AppStore.getCurrentTopology(), - currentTopologyId: AppStore.getCurrentTopologyId(), - currentTopologyOptions: AppStore.getCurrentTopologyOptions(), - errorUrl: AppStore.getErrorUrl(), - forceRelayout: AppStore.isForceRelayout(), - highlightedEdgeIds: AppStore.getHighlightedEdgeIds(), - highlightedNodeIds: AppStore.getHighlightedNodeIds(), - hostname: AppStore.getHostname(), - pinnedMetric: AppStore.getPinnedMetric(), - availableCanvasMetrics: AppStore.getAvailableCanvasMetrics(), - nodeDetails: AppStore.getNodeDetails(), - nodes: AppStore.getNodes(), - showingHelp: AppStore.getShowingHelp(), - selectedNodeId: AppStore.getSelectedNodeId(), - selectedMetric: AppStore.getSelectedMetric(), - topologies: AppStore.getTopologies(), - topologiesLoaded: AppStore.isTopologiesLoaded(), - topologyEmpty: AppStore.isTopologyEmpty(), - updatePaused: AppStore.isUpdatePaused(), - updatePausedAt: AppStore.getUpdatePausedAt(), - version: AppStore.getVersion(), - versionUpdate: AppStore.getVersionUpdate(), - plugins: AppStore.getPlugins(), - websocketClosed: AppStore.isWebsocketClosed() - }; -} - -export default class App extends React.Component { +class App extends React.Component { constructor(props, context) { super(props, context); - this.onChange = this.onChange.bind(this); this.onKeyPress = this.onKeyPress.bind(this); this.onKeyUp = this.onKeyUp.bind(this); - this.state = getStateFromStores(); } componentDidMount() { - AppStore.addListener(this.onChange); window.addEventListener('keypress', this.onKeyPress); window.addEventListener('keyup', this.onKeyUp); - getRouter().start({hashbang: true}); - if (!AppStore.isRouteSet()) { + getRouter(this.props.dispatch, this.props.urlState).start({hashbang: true}); + if (!this.props.routeSet) { // dont request topologies when already done via router - getTopologies(AppStore.getActiveTopologyOptions()); + getTopologies(this.props.activeTopologyOptions, this.props.dispatch); } - getApiDetails(); + getApiDetails(this.props.dispatch); } componentWillUnmount() { @@ -87,18 +50,15 @@ export default class App extends React.Component { window.removeEventListener('keyup', this.onKeyUp); } - onChange() { - this.setState(getStateFromStores()); - } - onKeyUp(ev) { // don't get esc in onKeyPress if (ev.keyCode === ESC_KEY_CODE) { - hitEsc(); + this.props.dispatch(hitEsc()); } } onKeyPress(ev) { + const { dispatch } = this.props; // // keyup gives 'key' // keypress gives 'char' @@ -108,42 +68,35 @@ export default class App extends React.Component { keyPressLog('onKeyPress', 'keyCode', ev.keyCode, ev); const char = String.fromCharCode(ev.charCode); if (char === '<') { - pinNextMetric(-1); + dispatch(pinNextMetric(-1)); } else if (char === '>') { - pinNextMetric(1); + dispatch(pinNextMetric(1)); } else if (char === 'q') { - unpinMetric(); - selectMetric(null); + dispatch(unpinMetric()); + dispatch(selectMetric(null)); } else if (char === 'd') { toggleDebugToolbar(); this.forceUpdate(); } else if (char === '?') { - toggleHelp(); + dispatch(toggleHelp()); } } render() { - const { nodeDetails, controlPipe } = this.state; - const topCardNode = nodeDetails.last(); + const { availableCanvasMetrics, nodeDetails, controlPipes, showingHelp } = this.props; const showingDetails = nodeDetails.size > 0; - const showingTerminal = controlPipe; - // width of details panel blocking a view - const detailsWidth = showingDetails ? 450 : 0; - const topMargin = 100; + const showingTerminal = controlPipes.size > 0; + const showingMetricsSelector = availableCanvasMetrics.count() > 0; return (
{showingDebugToolbar() && } - {this.state.showingHelp && } + {showingHelp && } - {showingDetails &&
} + {showingDetails &&
} - {showingTerminal && } + {showingTerminal && }
@@ -151,45 +104,35 @@ export default class App extends React.Component {
- +
- + - - {this.state.availableCanvasMetrics.count() > 0 && } - + + {showingMetricsSelector && } + -
+
); } } -reactMixin.onClass(App, PureRenderMixin); +function mapStateToProps(state) { + return { + activeTopologyOptions: getActiveTopologyOptions(state), + availableCanvasMetrics: state.get('availableCanvasMetrics'), + controlPipes: state.get('controlPipes'), + nodeDetails: state.get('nodeDetails'), + routeSet: state.get('routeSet'), + showingHelp: state.get('showingHelp'), + urlState: getUrlState(state) + }; +} + +export default connect( + mapStateToProps +)(App); diff --git a/client/app/scripts/components/debug-toolbar.js b/client/app/scripts/components/debug-toolbar.js index ef400ec65..214f1a1e3 100644 --- a/client/app/scripts/components/debug-toolbar.js +++ b/client/app/scripts/components/debug-toolbar.js @@ -2,12 +2,13 @@ import React from 'react'; import _ from 'lodash'; import Perf from 'react-addons-perf'; +import { connect } from 'react-redux'; +import { fromJS } from 'immutable'; import debug from 'debug'; const log = debug('scope:debug-panel'); import { receiveNodesDelta } from '../actions/app-actions'; -import AppStore from '../stores/app-store'; import { getNodeColor, getNodeColorDark } from '../utils/color-utils'; @@ -56,11 +57,10 @@ const deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, nodeCou }); -function addMetrics(node, v) { - const availableMetrics = AppStore.getAvailableCanvasMetrics().toJS(); - const metrics = availableMetrics.length > 0 ? availableMetrics : [ +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, {max: 100, value: v})) @@ -74,26 +74,26 @@ function label(shape, stacked) { } -function addAllVariants() { +function addAllVariants(dispatch) { const newNodes = _.flattenDeep(STACK_VARIANTS.map(stack => (SHAPES.map(s => { if (!stack) return [deltaAdd(label(s, stack), [], s, stack, 1)]; return NODE_COUNTS.map(n => deltaAdd(label(s, stack), [], s, stack, n)); })))); - receiveNodesDelta({ + dispatch(receiveNodesDelta({ add: newNodes - }); + })); } -function addAllMetricVariants() { +function addAllMetricVariants(availableMetrics, dispatch) { const newNodes = _.flattenDeep(METRIC_FILLS.map((v, i) => ( - SHAPES.map(s => [addMetrics(deltaAdd(label(s) + i, [], s), v)]) + SHAPES.map(s => [addMetrics(availableMetrics, deltaAdd(label(s) + i, [], s), v)]) ))); - receiveNodesDelta({ + dispatch(receiveNodesDelta({ add: newNodes - }); + })); } @@ -109,27 +109,6 @@ function startPerf(delay) { setTimeout(stopPerf, delay * 1000); } - -function addNodes(n, prefix = 'zing') { - const ns = AppStore.getNodes(); - 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).value(); - - receiveNodesDelta({ - add: newNodeNames.map((name) => deltaAdd( - name, - sample(allNodes), - _.sample(SHAPES), - _.sample(STACK_VARIANTS), - _.sample(NODE_COUNTS) - )) - }); -} - export function showingDebugToolbar() { return (('debugToolbar' in localStorage && JSON.parse(localStorage.debugToolbar)) || location.pathname.indexOf('debug') > -1); @@ -153,12 +132,13 @@ function disableLog() { window.location.reload(); } -export class DebugToolbar extends React.Component { +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.state = { nodesToAdd: 30, showColors: false @@ -175,20 +155,44 @@ export class DebugToolbar extends React.Component { }); } + addNodes(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).value(); + + this.props.dispatch(receiveNodesDelta({ + add: newNodeNames.map((name) => deltaAdd( + name, + sample(allNodes), + _.sample(SHAPES), + _.sample(STACK_VARIANTS), + _.sample(NODE_COUNTS) + )) + })); + + log('added nodes', n); + } + render() { - log('rending debug panel'); + const { availableCanvasMetrics } = this.props; return (
- - + + - - - - + + + +
@@ -228,3 +232,14 @@ export class DebugToolbar extends React.Component { ); } } + +function mapStateToProps(state) { + return { + nodes: state.get('nodes'), + availableCanvasMetrics: state.get('availableCanvasMetrics') + }; +} + +export default connect( + mapStateToProps +)(DebugToolbar); diff --git a/client/app/scripts/components/details.js b/client/app/scripts/components/details.js index 528e2790b..87938a052 100644 --- a/client/app/scripts/components/details.js +++ b/client/app/scripts/components/details.js @@ -1,15 +1,30 @@ import React from 'react'; +import { connect } from 'react-redux'; import DetailsCard from './details-card'; -export default function Details({controlStatus, details, nodes}) { - // render all details as cards, later cards go on top - return ( -
- {details.toIndexedSeq().map((obj, index) => - )} -
- ); +class Details extends React.Component { + render() { + const { controlStatus, details } = this.props; + // render all details as cards, later cards go on top + return ( +
+ {details.toIndexedSeq().map((obj, index) => + )} +
+ ); + } } + +function mapStateToProps(state) { + return { + controlStatus: state.get('controlStatus'), + details: state.get('nodeDetails') + }; +} + +export default connect( + mapStateToProps +)(Details); diff --git a/client/app/scripts/components/dev-tools.js b/client/app/scripts/components/dev-tools.js new file mode 100644 index 000000000..3012ab99b --- /dev/null +++ b/client/app/scripts/components/dev-tools.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { createDevTools } from 'redux-devtools'; +import LogMonitor from 'redux-devtools-log-monitor'; +import DockMonitor from 'redux-devtools-dock-monitor'; + +export default createDevTools( + + + +); diff --git a/client/app/scripts/components/embedded-terminal.js b/client/app/scripts/components/embedded-terminal.js index 524bc9e98..2ad699449 100644 --- a/client/app/scripts/components/embedded-terminal.js +++ b/client/app/scripts/components/embedded-terminal.js @@ -1,29 +1,45 @@ import React from 'react'; +import { connect } from 'react-redux'; import { getNodeColor, getNodeColorDark } from '../utils/color-utils'; import Terminal from './terminal'; import { DETAILS_PANEL_WIDTH, DETAILS_PANEL_MARGINS, DETAILS_PANEL_OFFSET } from '../constants/styles'; -export default function EmeddedTerminal({pipe, details}) { - const nodeId = pipe.get('nodeId'); - const node = details.get(nodeId); - const d = node && node.details; - const titleBarColor = d && getNodeColorDark(d.rank, d.label); - const statusBarColor = d && getNodeColor(d.rank, d.label); - const title = d && d.label; +class EmeddedTerminal extends React.Component { + 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); + const statusBarColor = d && getNodeColor(d.rank, d.label); + const title = d && d.label; - const style = { - right: DETAILS_PANEL_MARGINS.right + DETAILS_PANEL_WIDTH + 10 + - (details.size * DETAILS_PANEL_OFFSET) - }; + const style = { + right: DETAILS_PANEL_MARGINS.right + DETAILS_PANEL_WIDTH + 10 + + (details.size * DETAILS_PANEL_OFFSET) + }; - // 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 ( -
- -
- ); + // 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 ( +
+ +
+ ); + } } + +function mapStateToProps(state) { + return { + details: state.get('nodeDetails'), + pipe: state.get('controlPipes').last() + }; +} + +export default connect( + mapStateToProps +)(EmeddedTerminal); diff --git a/client/app/scripts/components/footer.js b/client/app/scripts/components/footer.js index 48bfedd7e..6b5b37aee 100644 --- a/client/app/scripts/components/footer.js +++ b/client/app/scripts/components/footer.js @@ -1,4 +1,5 @@ import React from 'react'; +import { connect } from 'react-redux'; import moment from 'moment'; import Plugins from './plugins.js'; @@ -8,83 +9,103 @@ import { clickDownloadGraph, clickForceRelayout, clickPauseUpdate, clickResumeUpdate, toggleHelp } from '../actions/app-actions'; import { basePathSlash } from '../utils/web-api-utils'; -export default function Footer(props) { - const { hostname, plugins, updatePaused, updatePausedAt, version, versionUpdate } = props; - const contrastMode = isContrastMode(); +class Footer extends React.Component { + render() { + const { hostname, updatePausedAt, version, versionUpdate } = this.props; + const contrastMode = isContrastMode(); - // link url to switch contrast with current UI state - const otherContrastModeUrl = contrastMode - ? basePathSlash(window.location.pathname) : contrastModeUrl; - 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)'; + // link url to switch contrast with current UI state + const otherContrastModeUrl = contrastMode + ? basePathSlash(window.location.pathname) : contrastModeUrl; + 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)'; - // pause button - const isPaused = updatePaused; - const updateCount = getUpdateBufferSize(); - const hasUpdates = updateCount > 0; - const pausedAgo = moment(updatePausedAt).fromNow(); - const pauseTitle = isPaused - ? `Paused ${pausedAgo}` : 'Pause updates (freezes the nodes in their current layout)'; - const pauseAction = isPaused ? clickResumeUpdate : clickPauseUpdate; - const pauseClassName = isPaused ? 'footer-icon footer-icon-active' : 'footer-icon'; - let pauseLabel = ''; - if (hasUpdates && isPaused) { - pauseLabel = `Paused +${updateCount}`; - } else if (hasUpdates && !isPaused) { - pauseLabel = `Resuming +${updateCount}`; - } else if (!hasUpdates && isPaused) { - pauseLabel = 'Paused'; + // pause button + const isPaused = updatePausedAt !== null; + const updateCount = getUpdateBufferSize(); + const hasUpdates = updateCount > 0; + const pausedAgo = moment(updatePausedAt).fromNow(); + const pauseTitle = isPaused + ? `Paused ${pausedAgo}` : 'Pause updates (freezes the nodes in their current layout)'; + const pauseAction = isPaused ? this.props.clickResumeUpdate : this.props.clickPauseUpdate; + const pauseClassName = isPaused ? 'footer-icon footer-icon-active' : 'footer-icon'; + let pauseLabel = ''; + if (hasUpdates && isPaused) { + pauseLabel = `Paused +${updateCount}`; + } else if (hasUpdates && !isPaused) { + pauseLabel = `Resuming +${updateCount}`; + } else if (!hasUpdates && isPaused) { + pauseLabel = 'Paused'; + } + + const versionUpdateTitle = versionUpdate + ? `New version available: ${versionUpdate.version}. Click to download` + : ''; + + return ( +
+ +
+ {versionUpdate && + Update available: {versionUpdate.version} + } + Version + {version} + on + {hostname} +
+ +
+ +
+ + + +
+ ); } - - const versionUpdateTitle = versionUpdate - ? `New version available: ${versionUpdate.version}. Click to download` - : ''; - - return ( -
- -
- {versionUpdate && - Update available: {versionUpdate.version} - } - Version - {version} - on - {hostname} -
- -
- -
- - - -
- ); } + +function mapStateToProps(state) { + return { + hostname: state.get('hostname'), + updatePausedAt: state.get('updatePausedAt'), + version: state.get('version'), + versionUpdate: state.get('versionUpdate') + }; +} + +export default connect( + mapStateToProps, + { clickDownloadGraph, clickForceRelayout, clickPauseUpdate, + clickResumeUpdate, toggleHelp } +)(Footer); diff --git a/client/app/scripts/components/metric-selector-item.js b/client/app/scripts/components/metric-selector-item.js index d3890e0a4..9c6e4481c 100644 --- a/client/app/scripts/components/metric-selector-item.js +++ b/client/app/scripts/components/metric-selector-item.js @@ -1,9 +1,10 @@ import React from 'react'; import classNames from 'classnames'; +import { connect } from 'react-redux'; + import { selectMetric, pinMetric, unpinMetric } from '../actions/app-actions'; - -export class MetricSelectorItem extends React.Component { +class MetricSelectorItem extends React.Component { constructor(props, context) { super(props, context); @@ -14,7 +15,7 @@ export class MetricSelectorItem extends React.Component { onMouseOver() { const k = this.props.metric.get('id'); - selectMetric(k); + this.props.selectMetric(k); } onMouseClick() { @@ -22,9 +23,9 @@ export class MetricSelectorItem extends React.Component { const pinnedMetric = this.props.pinnedMetric; if (k === pinnedMetric) { - unpinMetric(k); + this.props.unpinMetric(k); } else { - pinMetric(k); + this.props.pinMetric(k); } } @@ -49,3 +50,15 @@ export class MetricSelectorItem extends React.Component { ); } } + +function mapStateToProps(state) { + return { + selectedMetric: state.get('selectedMetric'), + pinnedMetric: state.get('pinnedMetric') + }; +} + +export default connect( + mapStateToProps, + { selectMetric, pinMetric, unpinMetric } +)(MetricSelectorItem); diff --git a/client/app/scripts/components/metric-selector.js b/client/app/scripts/components/metric-selector.js index a00578e33..daa2f5414 100644 --- a/client/app/scripts/components/metric-selector.js +++ b/client/app/scripts/components/metric-selector.js @@ -1,9 +1,10 @@ import React from 'react'; +import { connect } from 'react-redux'; + import { selectMetric } from '../actions/app-actions'; -import { MetricSelectorItem } from './metric-selector-item'; +import MetricSelectorItem from './metric-selector-item'; - -export default class MetricSelector extends React.Component { +class MetricSelector extends React.Component { constructor(props, context) { super(props, context); @@ -11,19 +12,18 @@ export default class MetricSelector extends React.Component { } onMouseOut() { - selectMetric(this.props.pinnedMetric); + this.props.selectMetric(this.props.pinnedMetric); } render() { const {availableCanvasMetrics} = this.props; const items = availableCanvasMetrics.map(metric => ( - + )); return ( -
+
{items}
@@ -32,3 +32,14 @@ export default class MetricSelector extends React.Component { } } +function mapStateToProps(state) { + return { + availableCanvasMetrics: state.get('availableCanvasMetrics'), + pinnedMetric: state.get('pinnedMetric') + }; +} + +export default connect( + mapStateToProps, + { selectMetric } +)(MetricSelector); diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index bf8cd1506..6c566c372 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -1,5 +1,6 @@ import _ from 'lodash'; import React from 'react'; +import { connect } from 'react-redux'; import NodeDetailsControls from './node-details/node-details-controls'; import NodeDetailsHealth from './node-details/node-details-health'; @@ -11,7 +12,7 @@ import { clickCloseDetails, clickShowTopologyForNode } from '../actions/app-acti import { brightenColor, getNeutralColor, getNodeColorDark } from '../utils/color-utils'; import { resetDocumentTitle, setDocumentTitle } from '../utils/title-utils'; -export default class NodeDetails extends React.Component { +export class NodeDetails extends React.Component { constructor(props, context) { super(props, context); @@ -21,12 +22,12 @@ export default class NodeDetails extends React.Component { handleClickClose(ev) { ev.preventDefault(); - clickCloseDetails(this.props.nodeId); + this.props.clickCloseDetails(this.props.nodeId); } handleShowTopologyForNode(ev) { ev.preventDefault(); - clickShowTopologyForNode(this.props.topologyId, this.props.nodeId); + this.props.clickShowTopologyForNode(this.props.topologyId, this.props.nodeId); } componentDidMount() { @@ -215,3 +216,14 @@ export default class NodeDetails extends React.Component { setDocumentTitle(this.props.details && this.props.details.label); } } + +function mapStateToProps(state) { + return { + nodes: state.get('nodes') + }; +} + +export default connect( + mapStateToProps, + { clickCloseDetails, clickShowTopologyForNode } +)(NodeDetails); diff --git a/client/app/scripts/components/node-details/node-details-control-button.js b/client/app/scripts/components/node-details/node-details-control-button.js index 99a2629a6..36c414ae6 100644 --- a/client/app/scripts/components/node-details/node-details-control-button.js +++ b/client/app/scripts/components/node-details/node-details-control-button.js @@ -1,8 +1,9 @@ import React from 'react'; +import { connect } from 'react-redux'; import { doControl } from '../../actions/app-actions'; -export default class NodeDetailsControlButton extends React.Component { +class NodeDetailsControlButton extends React.Component { constructor(props, context) { super(props, context); this.handleClick = this.handleClick.bind(this); @@ -20,6 +21,8 @@ export default class NodeDetailsControlButton extends React.Component { handleClick(ev) { ev.preventDefault(); - doControl(this.props.nodeId, this.props.control); + this.props.dispatch(doControl(this.props.nodeId, this.props.control)); } } + +export default connect()(NodeDetailsControlButton); diff --git a/client/app/scripts/components/node-details/node-details-relatives-link.js b/client/app/scripts/components/node-details/node-details-relatives-link.js index 50a19b24f..81c9d43d7 100644 --- a/client/app/scripts/components/node-details/node-details-relatives-link.js +++ b/client/app/scripts/components/node-details/node-details-relatives-link.js @@ -1,11 +1,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { clickRelative } from '../../actions/app-actions'; -export default class NodeDetailsRelativesLink extends React.Component { +class NodeDetailsRelativesLink extends React.Component { constructor(props, context) { super(props, context); @@ -14,8 +13,8 @@ export default class NodeDetailsRelativesLink extends React.Component { handleClick(ev) { ev.preventDefault(); - clickRelative(this.props.id, this.props.topologyId, this.props.label, - ReactDOM.findDOMNode(this).getBoundingClientRect()); + this.props.dispatch(clickRelative(this.props.id, this.props.topologyId, + this.props.label, ReactDOM.findDOMNode(this).getBoundingClientRect())); } render() { @@ -28,4 +27,4 @@ export default class NodeDetailsRelativesLink extends React.Component { } } -reactMixin.onClass(NodeDetailsRelativesLink, PureRenderMixin); +export default connect()(NodeDetailsRelativesLink); diff --git a/client/app/scripts/components/node-details/node-details-table-node-link.js b/client/app/scripts/components/node-details/node-details-table-node-link.js index b463a7e00..3c54af6ab 100644 --- a/client/app/scripts/components/node-details/node-details-table-node-link.js +++ b/client/app/scripts/components/node-details/node-details-table-node-link.js @@ -1,11 +1,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { clickRelative } from '../../actions/app-actions'; -export default class NodeDetailsTableNodeLink extends React.Component { +class NodeDetailsTableNodeLink extends React.Component { constructor(props, context) { super(props, context); @@ -14,8 +13,8 @@ export default class NodeDetailsTableNodeLink extends React.Component { handleClick(ev) { ev.preventDefault(); - clickRelative(this.props.nodeId, this.props.topologyId, this.props.label, - ReactDOM.findDOMNode(this).getBoundingClientRect()); + this.props.dispatch(clickRelative(this.props.nodeId, this.props.topologyId, + this.props.label, ReactDOM.findDOMNode(this).getBoundingClientRect())); } render() { @@ -35,4 +34,4 @@ export default class NodeDetailsTableNodeLink extends React.Component { } } -reactMixin.onClass(NodeDetailsTableNodeLink, PureRenderMixin); +export default connect()(NodeDetailsTableNodeLink); diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 28d3ae79d..d2ce010be 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -1,12 +1,13 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import NodesChart from '../charts/nodes-chart'; import NodesError from '../charts/nodes-error'; +import { isTopologyEmpty } from '../utils/topology-utils'; const navbarHeight = 160; const marginTop = 0; +const detailsWidth = 450; /** * dynamic coords precision based on topology size @@ -26,7 +27,7 @@ function getLayoutPrecision(nodesCount) { return precision; } -export default class Nodes extends React.Component { +class Nodes extends React.Component { constructor(props, context) { super(props, context); this.handleResize = this.handleResize.bind(this); @@ -70,7 +71,8 @@ export default class Nodes extends React.Component { return (
{topologyEmpty && errorEmpty} - @@ -90,4 +92,14 @@ export default class Nodes extends React.Component { } } -reactMixin.onClass(Nodes, PureRenderMixin); +function mapStateToProps(state) { + return { + nodes: state.get('nodes'), + selectedNodeId: state.get('selectedNodeId'), + topologyEmpty: isTopologyEmpty(state), + }; +} + +export default connect( + mapStateToProps +)(Nodes); diff --git a/client/app/scripts/components/plugins.js b/client/app/scripts/components/plugins.js index f7b191682..5217b1ed0 100644 --- a/client/app/scripts/components/plugins.js +++ b/client/app/scripts/components/plugins.js @@ -1,7 +1,8 @@ import React from 'react'; +import { connect } from 'react-redux'; import classNames from 'classnames'; -export default class Plugins extends React.Component { +class Plugins extends React.Component { renderPlugin({id, label, description, status}) { const error = status !== 'ok'; const className = classNames({ error }); @@ -19,15 +20,26 @@ export default class Plugins extends React.Component { } render() { - const hasPlugins = this.props.plugins && this.props.plugins.length > 0; + const hasPlugins = this.props.plugins && this.props.plugins.size > 0; return (
Plugins: - {hasPlugins && this.props.plugins.map((plugin, index) => this.renderPlugin(plugin, index))} + {hasPlugins && this.props.plugins.toIndexedSeq() + .map((plugin, index) => this.renderPlugin(plugin, index))} {!hasPlugins && n/a}
); } } + +function mapStateToProps(state) { + return { + plugins: state.get('plugins') + }; +} + +export default connect( + mapStateToProps +)(Plugins); diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js index c2a027391..b22e190f7 100644 --- a/client/app/scripts/components/status.js +++ b/client/app/scripts/components/status.js @@ -1,36 +1,54 @@ import React from 'react'; +import { connect } from 'react-redux'; -export default function Status({errorUrl, topologiesLoaded, topology, websocketClosed}) { - let title = ''; - let text = 'Trying to reconnect...'; - let showWarningIcon = false; - let classNames = 'status sidebar-item'; +class Status extends React.Component { + render() { + const {errorUrl, topologiesLoaded, topology, websocketClosed} = this.props; - 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')} nodes`; - if (stats.get('filtered_nodes')) { - text = `${text} (${stats.get('filtered_nodes')} filtered)`; + 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')} nodes`; + if (stats.get('filtered_nodes')) { + text = `${text} (${stats.get('filtered_nodes')} filtered)`; + } + classNames += ' status-stats'; + showWarningIcon = false; } - classNames += ' status-stats'; - showWarningIcon = false; - } - return ( -
- {showWarningIcon && } - {text} -
- ); + return ( +
+ {showWarningIcon && } + {text} +
+ ); + } } + +function mapStateToProps(state) { + return { + errorUrl: state.get('errorUrl'), + topologiesLoaded: state.get('topologiesLoaded'), + topology: state.get('currentTopology'), + websocketClosed: state.get('websocketClosed') + }; +} + +export default connect( + mapStateToProps +)(Status); diff --git a/client/app/scripts/components/terminal-app.js b/client/app/scripts/components/terminal-app.js index b9e348515..b48da142d 100644 --- a/client/app/scripts/components/terminal-app.js +++ b/client/app/scripts/components/terminal-app.js @@ -1,48 +1,32 @@ import React from 'react'; +import { connect } from 'react-redux'; -import AppStore from '../stores/app-store'; import Terminal from './terminal'; import { receiveControlPipeFromParams } from '../actions/app-actions'; -function getStateFromStores() { - return { - controlPipe: AppStore.getControlPipe() - }; -} - -export class TerminalApp extends React.Component { +class TerminalApp extends React.Component { constructor(props, context) { super(props, context); - this.onChange = this.onChange.bind(this); const paramString = window.location.hash.split('/').pop(); const params = JSON.parse(decodeURIComponent(paramString)); - receiveControlPipeFromParams(params.pipe.id, null, params.pipe.raw, false); + this.props.receiveControlPipeFromParams(params.pipe.id, null, params.pipe.raw, false); this.state = { title: params.title, titleBarColor: params.titleBarColor, - statusBarColor: params.statusBarColor, - controlPipe: AppStore.getControlPipe() + statusBarColor: params.statusBarColor }; } - componentDidMount() { - AppStore.addListener(this.onChange); - } - - onChange() { - this.setState(getStateFromStores()); - } - render() { const style = {borderTop: `4px solid ${this.state.titleBarColor}`}; return (
- {this.state.controlPipe && { clearTimeout(this.reconnectTimeout); @@ -210,7 +211,7 @@ export default class Terminal extends React.Component { handleCloseClick(ev) { ev.preventDefault(); if (this.isEmbedded()) { - clickCloseTerminal(this.getPipeId(), true); + this.props.dispatch(clickCloseTerminal(this.getPipeId(), true)); } else { window.close(); } @@ -219,7 +220,7 @@ export default class Terminal extends React.Component { handlePopoutTerminal(ev) { ev.preventDefault(); const paramString = JSON.stringify(this.props); - clickCloseTerminal(this.getPipeId()); + this.props.dispatch(clickCloseTerminal(this.getPipeId())); const bcr = ReactDOM.findDOMNode(this).getBoundingClientRect(); const minWidth = this.state.pixelPerCol * 80 + (8 * 2); @@ -322,3 +323,5 @@ export default class Terminal extends React.Component { ); } } + +export default connect()(Terminal); diff --git a/client/app/scripts/components/topologies.js b/client/app/scripts/components/topologies.js index d8152f412..84d8b75e9 100644 --- a/client/app/scripts/components/topologies.js +++ b/client/app/scripts/components/topologies.js @@ -1,20 +1,18 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { clickTopology } from '../actions/app-actions'; -export default class Topologies extends React.Component { +class Topologies extends React.Component { constructor(props, context) { super(props, context); this.onTopologyClick = this.onTopologyClick.bind(this); - this.renderSubTopology = this.renderSubTopology.bind(this); } onTopologyClick(ev) { ev.preventDefault(); - clickTopology(ev.currentTarget.getAttribute('rel')); + this.props.clickTopology(ev.currentTarget.getAttribute('rel')); } renderSubTopology(subTopology) { @@ -55,7 +53,7 @@ export default class Topologies extends React.Component {
{topology.has('sub_topologies') - && topology.get('sub_topologies').map(this.renderSubTopology)} + && topology.get('sub_topologies').map(subTop => this.renderSubTopology(subTop))}
); @@ -72,4 +70,14 @@ export default class Topologies extends React.Component { } } -reactMixin.onClass(Topologies, PureRenderMixin); +function mapStateToProps(state) { + return { + topologies: state.get('topologies'), + currentTopology: state.get('currentTopology') + }; +} + +export default connect( + mapStateToProps, + { clickTopology } +)(Topologies); diff --git a/client/app/scripts/components/topology-option-action.js b/client/app/scripts/components/topology-option-action.js index b83eeabe8..f8ee59770 100644 --- a/client/app/scripts/components/topology-option-action.js +++ b/client/app/scripts/components/topology-option-action.js @@ -1,10 +1,9 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { changeTopologyOption } from '../actions/app-actions'; -export default class TopologyOptionAction extends React.Component { +class TopologyOptionAction extends React.Component { constructor(props, context) { super(props, context); @@ -14,7 +13,7 @@ export default class TopologyOptionAction extends React.Component { onClick(ev) { ev.preventDefault(); const { optionId, topologyId, item } = this.props; - changeTopologyOption(optionId, item.get('value'), topologyId); + this.props.changeTopologyOption(optionId, item.get('value'), topologyId); } render() { @@ -29,4 +28,7 @@ export default class TopologyOptionAction extends React.Component { } } -reactMixin.onClass(TopologyOptionAction, PureRenderMixin); +export default connect( + null, + { changeTopologyOption } +)(TopologyOptionAction); diff --git a/client/app/scripts/components/topology-options.js b/client/app/scripts/components/topology-options.js index 758cda37a..89bf4ccc9 100644 --- a/client/app/scripts/components/topology-options.js +++ b/client/app/scripts/components/topology-options.js @@ -1,10 +1,10 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; +import { getActiveTopologyOptions, getCurrentTopologyOptions } from '../utils/topology-utils'; import TopologyOptionAction from './topology-option-action'; -export default class TopologyOptions extends React.Component { +class TopologyOptions extends React.Component { renderOption(option) { const { activeOptions, topologyId } = this.props; @@ -26,10 +26,21 @@ export default class TopologyOptions extends React.Component { render() { return (
- {this.props.options.toIndexedSeq().map(option => this.renderOption(option))} + {this.props.options && this.props.options.toIndexedSeq().map( + option => this.renderOption(option))}
); } } -reactMixin.onClass(TopologyOptions, PureRenderMixin); +function mapStateToProps(state) { + return { + options: getCurrentTopologyOptions(state), + topologyId: state.get('currentTopologyId'), + activeOptions: getActiveTopologyOptions(state) + }; +} + +export default connect( + mapStateToProps +)(TopologyOptions); diff --git a/client/app/scripts/contrast-main.js b/client/app/scripts/contrast-main.js index 20d66e878..be647be61 100644 --- a/client/app/scripts/contrast-main.js +++ b/client/app/scripts/contrast-main.js @@ -2,9 +2,19 @@ require('font-awesome-webpack'); require('../styles/contrast.less'); require('../images/favicon.ico'); +import 'babel-polyfill'; import React from 'react'; import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import configureStore from './stores/configureStore'; import App from './components/app.js'; -ReactDOM.render(, document.getElementById('app')); +const store = configureStore(); + +ReactDOM.render( + + + , + document.getElementById('app') +); diff --git a/client/app/scripts/debug.js b/client/app/scripts/debug.js deleted file mode 100644 index a546827ee..000000000 --- a/client/app/scripts/debug.js +++ /dev/null @@ -1,3 +0,0 @@ -import Immutable from 'immutable'; -import installDevTools from 'immutable-devtools'; -installDevTools(Immutable); diff --git a/client/app/scripts/dispatcher/app-dispatcher.js b/client/app/scripts/dispatcher/app-dispatcher.js deleted file mode 100644 index 918ad5d8e..000000000 --- a/client/app/scripts/dispatcher/app-dispatcher.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Dispatcher } from 'flux'; -import _ from 'lodash'; -import debug from 'debug'; -const log = debug('scope:dispatcher'); - -const instance = new Dispatcher(); - -instance.dispatch = _.wrap(Dispatcher.prototype.dispatch, (func, payload) => { - log(payload.type, payload); - func.call(instance, payload); -}); - -export default instance; -export const dispatch = instance.dispatch.bind(instance); diff --git a/client/app/scripts/main.dev.js b/client/app/scripts/main.dev.js new file mode 100644 index 000000000..1aa991bb1 --- /dev/null +++ b/client/app/scripts/main.dev.js @@ -0,0 +1,28 @@ +require('font-awesome-webpack'); +require('../styles/main.less'); +require('../images/favicon.ico'); + +import 'babel-polyfill'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; + +import configureStore from './stores/configureStore'; +import App from './components/app'; + +import DevTools from './components/dev-tools'; +import Immutable from 'immutable'; +import installDevTools from 'immutable-devtools'; +installDevTools(Immutable); + +const store = configureStore(); + +ReactDOM.render( + +
+ + +
+
, + document.getElementById('app') +); diff --git a/client/app/scripts/main.js b/client/app/scripts/main.js index 61a28bc1a..5c3028e81 100644 --- a/client/app/scripts/main.js +++ b/client/app/scripts/main.js @@ -1,10 +1,5 @@ -require('font-awesome-webpack'); -require('../styles/main.less'); -require('../images/favicon.ico'); - -import React from 'react'; -import ReactDOM from 'react-dom'; - -import App from './components/app.js'; - -ReactDOM.render(, document.getElementById('app')); +if (process.env.NODE_ENV === 'production') { + module.exports = require('./main.prod'); +} else { + module.exports = require('./main.dev'); +} diff --git a/client/app/scripts/main.prod.js b/client/app/scripts/main.prod.js new file mode 100644 index 000000000..0132b87bb --- /dev/null +++ b/client/app/scripts/main.prod.js @@ -0,0 +1,22 @@ +require('font-awesome-webpack'); +require('../styles/main.less'); +require('../images/favicon.ico'); + +import 'babel-polyfill'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; + +import configureStore from './stores/configureStore'; +import App from './components/app'; + +const store = configureStore(); + +ReactDOM.render( + +
+ +
+
, + document.getElementById('app') +); diff --git a/client/app/scripts/reducers/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js new file mode 100644 index 000000000..506d17d8a --- /dev/null +++ b/client/app/scripts/reducers/__tests__/root-test.js @@ -0,0 +1,461 @@ +jest.dontMock('../../utils/router-utils'); +jest.dontMock('../../utils/topology-utils'); +jest.dontMock('../../constants/action-types'); +jest.dontMock('../root'); + +const is = require('immutable').is; + +// Root reducer test suite using Jasmine matchers + +describe('RootReducer', () => { + const ActionTypes = require('../../constants/action-types').default; + const reducer = require('../root').default; + const initialState = require('../root').initialState; + const topologyUtils = require('../../utils/topology-utils'); + // TODO maybe extract those to topology-utils tests? + const getActiveTopologyOptions = topologyUtils.getActiveTopologyOptions; + const getAdjacentNodes = topologyUtils.getAdjacentNodes; + const isTopologyEmpty = topologyUtils.isTopologyEmpty; + const getUrlState = require('../../utils/router-utils').getUrlState; + + // fixtures + + const NODE_SET = { + n1: { + id: 'n1', + rank: undefined, + adjacency: ['n1', 'n2'], + pseudo: undefined, + label: undefined, + label_minor: undefined + }, + n2: { + id: 'n2', + rank: undefined, + adjacency: undefined, + pseudo: undefined, + label: undefined, + label_minor: undefined + } + }; + + // actions + + const ChangeTopologyOptionAction = { + type: ActionTypes.CHANGE_TOPOLOGY_OPTION, + topologyId: 'topo1', + option: 'option1', + value: 'on' + }; + + const ChangeTopologyOptionAction2 = { + type: ActionTypes.CHANGE_TOPOLOGY_OPTION, + topologyId: 'topo1', + option: 'option1', + value: 'off' + }; + + const ClickNodeAction = { + type: ActionTypes.CLICK_NODE, + nodeId: 'n1' + }; + + const ClickNode2Action = { + type: ActionTypes.CLICK_NODE, + nodeId: 'n2' + }; + + const ClickRelativeAction = { + type: ActionTypes.CLICK_RELATIVE, + nodeId: 'rel1' + }; + + const ClickShowTopologyForNodeAction = { + type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE, + topologyId: 'topo2', + nodeId: 'rel1' + }; + + const ClickSubTopologyAction = { + type: ActionTypes.CLICK_TOPOLOGY, + topologyId: 'topo1-grouped' + }; + + const ClickTopologyAction = { + type: ActionTypes.CLICK_TOPOLOGY, + topologyId: 'topo1' + }; + + const ClickTopology2Action = { + type: ActionTypes.CLICK_TOPOLOGY, + topologyId: 'topo2' + }; + + const CloseWebsocketAction = { + type: ActionTypes.CLOSE_WEBSOCKET + }; + + const deSelectNode = { + type: ActionTypes.DESELECT_NODE + }; + + const OpenWebsocketAction = { + type: ActionTypes.OPEN_WEBSOCKET + }; + + const ReceiveNodesDeltaAction = { + type: ActionTypes.RECEIVE_NODES_DELTA, + delta: { + add: [{ + id: 'n1', + adjacency: ['n1', 'n2'] + }, { + id: 'n2' + }] + } + }; + + const ReceiveNodesDeltaUpdateAction = { + type: ActionTypes.RECEIVE_NODES_DELTA, + delta: { + update: [{ + id: 'n1', + adjacency: ['n1'] + }], + remove: ['n2'] + } + }; + + const ReceiveTopologiesAction = { + type: ActionTypes.RECEIVE_TOPOLOGIES, + topologies: [{ + url: '/topo1', + name: 'Topo1', + options: [{ + id: 'option1', + defaultValue: 'off', + options: [ + {value: 'on'}, + {value: 'off'} + ] + }], + stats: { + node_count: 1 + }, + sub_topologies: [{ + url: '/topo1-grouped', + name: 'topo 1 grouped' + }] + }, { + url: '/topo2', + name: 'Topo2', + stats: { + node_count: 0 + } + }] + }; + + const RouteAction = { + type: ActionTypes.ROUTE_TOPOLOGY, + state: {} + }; + + // Basic tests + + it('returns initial state', () => { + const nextState = reducer(undefined, {}); + expect(is(nextState, initialState)).toBeTruthy(); + }); + + // topology tests + + it('init with no topologies', () => { + const nextState = reducer(undefined, {}); + expect(nextState.get('topologies').size).toBe(0); + expect(nextState.get('currentTopology')).toBeFalsy(); + }); + + it('get current topology', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + + expect(nextState.get('topologies').size).toBe(2); + expect(nextState.get('currentTopology').get('name')).toBe('Topo1'); + expect(nextState.get('currentTopology').get('url')).toBe('/topo1'); + expect(nextState.get('currentTopology').get('options').first().get('id')).toBe('option1'); + }); + + it('get sub-topology', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickSubTopologyAction); + + expect(nextState.get('topologies').size).toBe(2); + expect(nextState.get('currentTopology').get('name')).toBe('topo 1 grouped'); + expect(nextState.get('currentTopology').get('url')).toBe('/topo1-grouped'); + expect(nextState.get('currentTopology').get('options')).toBeUndefined(); + }); + + // topology options + + it('changes topology option', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + + // default options + expect(getActiveTopologyOptions(nextState).has('option1')).toBeTruthy(); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('off'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off'); + + // turn on + nextState = reducer(nextState, ChangeTopologyOptionAction); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('on'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('on'); + + // turn off + nextState = reducer(nextState, ChangeTopologyOptionAction2); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('off'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off'); + + // sub-topology should retain main topo options + nextState = reducer(nextState, ClickSubTopologyAction); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('off'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off'); + + // other topology w/o options dont return options, but keep in app state + nextState = reducer(nextState, ClickTopology2Action); + expect(getActiveTopologyOptions(nextState)).toBeUndefined(); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off'); + }); + + it('sets topology options from route', () => { + RouteAction.state = { + topologyId: 'topo1', + selectedNodeId: null, + topologyOptions: {topo1: {option1: 'on'}}}; + + let nextState = initialState; + nextState = reducer(nextState, RouteAction); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('on'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('on'); + + // stay same after topos have been received + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('on'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('on'); + }); + + it('uses default topology options from route', () => { + RouteAction.state = { + topologyId: 'topo1', + selectedNodeId: null, + topologyOptions: null}; + let nextState = initialState; + nextState = reducer(nextState, RouteAction); + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('off'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off'); + }); + + // nodes delta + + it('replaces adjacency on update', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveNodesDeltaAction); + expect(nextState.get('nodes').toJS().n1.adjacency).toEqual(['n1', 'n2']); + nextState = reducer(nextState, ReceiveNodesDeltaUpdateAction); + expect(nextState.get('nodes').toJS().n1.adjacency).toEqual(['n1']); + }); + + // browsing + + it('shows nodes that were received', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveNodesDeltaAction); + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + }); + + it('knows a route was set', () => { + let nextState = initialState; + expect(nextState.get('routeSet')).toBeFalsy(); + nextState = reducer(nextState, RouteAction); + expect(nextState.get('routeSet')).toBeTruthy(); + }); + + it('gets selected node after click', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveNodesDeltaAction); + nextState = reducer(nextState, ClickNodeAction); + + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + + nextState = reducer(nextState, deSelectNode); + expect(nextState.get('selectedNodeId')).toBe(null); + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + }); + + it('keeps showing nodes on navigating back after node click', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + nextState = reducer(nextState, ReceiveNodesDeltaAction); + expect(getUrlState(nextState).selectedNodeId).toEqual(null); + + nextState = reducer(nextState, ClickNodeAction); + expect(getUrlState(nextState).selectedNodeId).toEqual('n1'); + + // go back in browsing + RouteAction.state = {topologyId: 'topo1', selectedNodeId: null}; + nextState = reducer(nextState, RouteAction); + expect(nextState.get('selectedNodeId')).toBe(null); + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + }); + + it('closes details when changing topologies', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + nextState = reducer(nextState, ReceiveNodesDeltaAction); + + expect(getUrlState(nextState).selectedNodeId).toEqual(null); + expect(getUrlState(nextState).topologyId).toEqual('topo1'); + + nextState = reducer(nextState, ClickNodeAction); + expect(getUrlState(nextState).selectedNodeId).toEqual('n1'); + expect(getUrlState(nextState).topologyId).toEqual('topo1'); + + nextState = reducer(nextState, ClickSubTopologyAction); + expect(getUrlState(nextState).selectedNodeId).toEqual(null); + expect(getUrlState(nextState).topologyId).toEqual('topo1-grouped'); + }); + + // connection errors + + it('resets topology on websocket reconnect', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveNodesDeltaAction); + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + + nextState = reducer(nextState, CloseWebsocketAction); + expect(nextState.get('websocketClosed')).toBeTruthy(); + // keep showing old nodes + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + + nextState = reducer(nextState, OpenWebsocketAction); + expect(nextState.get('websocketClosed')).toBeFalsy(); + // opened socket clears nodes + expect(nextState.get('nodes').toJS()).toEqual({}); + }); + + // adjacency test + + it('returns the correct adjacency set for a node', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveNodesDeltaAction); + expect(getAdjacentNodes(nextState).size).toEqual(0); + + nextState = reducer(nextState, ClickNodeAction); + expect(getAdjacentNodes(nextState, 'n1').size).toEqual(2); + expect(getAdjacentNodes(nextState, 'n1').has('n1')).toBeTruthy(); + expect(getAdjacentNodes(nextState, 'n1').has('n2')).toBeTruthy(); + + nextState = reducer(nextState, deSelectNode); + expect(getAdjacentNodes(nextState).size).toEqual(0); + }); + + // empty topology + + it('detects that the topology is empty', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + expect(isTopologyEmpty(nextState)).toBeFalsy(); + + nextState = reducer(nextState, ClickTopology2Action); + expect(isTopologyEmpty(nextState)).toBeTruthy(); + + nextState = reducer(nextState, ClickTopologyAction); + expect(isTopologyEmpty(nextState)).toBeFalsy(); + }); + + // selection of relatives + + it('keeps relatives as a stack', () => { + let nextState = initialState; + nextState = reducer(nextState, ClickNodeAction); + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('nodeDetails').has('n1')).toBeTruthy(); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('n1'); + + nextState = reducer(nextState, ClickRelativeAction); + // stack relative, first node stays main node + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('rel1'); + expect(nextState.get('nodeDetails').size).toEqual(2); + expect(nextState.get('nodeDetails').has('rel1')).toBeTruthy(); + + // click on first node should clear the stack + nextState = reducer(nextState, ClickNodeAction); + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('n1'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('nodeDetails').has('rel1')).toBeFalsy(); + }); + + it('keeps clears stack when sibling is clicked', () => { + let nextState = initialState; + nextState = reducer(nextState, ClickNodeAction); + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('nodeDetails').has('n1')).toBeTruthy(); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('n1'); + + nextState = reducer(nextState, ClickRelativeAction); + // stack relative, first node stays main node + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('rel1'); + expect(nextState.get('nodeDetails').size).toEqual(2); + expect(nextState.get('nodeDetails').has('rel1')).toBeTruthy(); + + // click on sibling node should clear the stack + nextState = reducer(nextState, ClickNode2Action); + expect(nextState.get('selectedNodeId')).toBe('n2'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('n2'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('nodeDetails').has('n1')).toBeFalsy(); + expect(nextState.get('nodeDetails').has('rel1')).toBeFalsy(); + }); + + it('selectes relatives topology while keeping node selected', () => { + let nextState = initialState; + nextState = reducer(nextState, ClickTopologyAction); + nextState = reducer(nextState, ReceiveTopologiesAction); + expect(nextState.get('currentTopology').get('name')).toBe('Topo1'); + + nextState = reducer(nextState, ClickNodeAction); + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('nodeDetails').has('n1')).toBeTruthy(); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('n1'); + + nextState = reducer(nextState, ClickRelativeAction); + // stack relative, first node stays main node + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('rel1'); + expect(nextState.get('nodeDetails').size).toEqual(2); + expect(nextState.get('nodeDetails').has('rel1')).toBeTruthy(); + + // click switches over to relative's topology and selectes relative + nextState = reducer(nextState, ClickShowTopologyForNodeAction); + expect(nextState.get('selectedNodeId')).toBe('rel1'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('rel1'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('currentTopology').get('name')).toBe('Topo2'); + }); +}); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js new file mode 100644 index 000000000..e9711554b --- /dev/null +++ b/client/app/scripts/reducers/root.js @@ -0,0 +1,560 @@ +import _ from 'lodash'; +import debug from 'debug'; +import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap, + OrderedMap as makeOrderedMap, Set as makeSet } from 'immutable'; + +import ActionTypes from '../constants/action-types'; +import { EDGE_ID_SEPARATOR } from '../constants/naming'; +import { findTopologyById, getAdjacentNodes, setTopologyUrlsById, + updateTopologyIds, filterHiddenTopologies } from '../utils/topology-utils'; + +const log = debug('scope:app-store'); +const error = debug('scope:error'); + +// Helpers + +function makeNode(node) { + return { + id: node.id, + label: node.label, + label_minor: node.label_minor, + node_count: node.node_count, + rank: node.rank, + pseudo: node.pseudo, + stack: node.stack, + shape: node.shape, + adjacency: node.adjacency, + metrics: node.metrics + }; +} + +const topologySorter = topology => topology.get('rank'); + +// Initial values + +export const initialState = makeMap({ + topologyOptions: makeOrderedMap(), // topologyId -> options + controlStatus: makeMap(), + currentTopology: null, + currentTopologyId: 'containers', + errorUrl: null, + forceRelayout: false, + highlightedEdgeIds: makeSet(), + highlightedNodeIds: makeSet(), + hostname: '...', + version: '...', + versionUpdate: null, + plugins: makeList(), + mouseOverEdgeId: null, + mouseOverNodeId: null, + nodeDetails: makeOrderedMap(), // nodeId -> details + nodes: makeOrderedMap(), // nodeId -> node + selectedNodeId: null, + topologies: makeList(), + topologiesLoaded: false, + topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl + routeSet: false, + controlPipes: makeOrderedMap(), // pipeId -> controlPipe + updatePausedAt: null, // Date + websocketClosed: true, + showingHelp: false, + + selectedMetric: null, + pinnedMetric: null, + // class of metric, e.g. 'cpu', rather than 'host_cpu' or 'process_cpu'. + // allows us to keep the same metric "type" selected when the topology changes. + pinnedMetricType: null, + availableCanvasMetrics: makeList() +}); + +// adds ID field to topology (based on last part of URL path) and save urls in +// map for easy lookup +function processTopologies(state, nextTopologies) { + // filter out hidden topos + const visibleTopologies = filterHiddenTopologies(nextTopologies); + + // add IDs to topology objects in-place + const topologiesWithId = updateTopologyIds(visibleTopologies); + + // cache URLs by ID + state = state.set('topologyUrlsById', + setTopologyUrlsById(state.get('topologyUrlsById'), topologiesWithId)); + + const immNextTopologies = fromJS(topologiesWithId).sortBy(topologySorter); + return state.mergeDeepIn(['topologies'], immNextTopologies); +} + +function setTopology(state, topologyId) { + state = state.set('currentTopology', findTopologyById( + state.get('topologies'), topologyId)); + return state.set('currentTopologyId', topologyId); +} + +function setDefaultTopologyOptions(state, topologyList) { + topologyList.forEach(topology => { + let defaultOptions = makeOrderedMap(); + if (topology.has('options') && topology.get('options')) { + topology.get('options').forEach((option) => { + const optionId = option.get('id'); + const defaultValue = option.get('defaultValue'); + defaultOptions = defaultOptions.set(optionId, defaultValue); + }); + } + + if (defaultOptions.size) { + state = state.setIn(['topologyOptions', topology.get('id')], + defaultOptions + ); + } + }); + return state; +} + +function closeNodeDetails(state, nodeId) { + const nodeDetails = state.get('nodeDetails'); + if (nodeDetails.size > 0) { + const popNodeId = nodeId || nodeDetails.keySeq().last(); + // remove pipe if it belongs to the node being closed + state = state.update('controlPipes', + controlPipes => controlPipes.filter(pipe => pipe.get('nodeId') !== popNodeId)); + state = state.deleteIn(['nodeDetails', popNodeId]); + } + if (state.get('nodeDetails').size === 0 || state.get('selectedNodeId') === nodeId) { + state = state.set('selectedNodeId', null); + } + return state; +} + +function closeAllNodeDetails(state) { + while (state.get('nodeDetails').size) { + state = closeNodeDetails(state); + } + return state; +} + +function resumeUpdate(state) { + return state.set('updatePausedAt', null); +} + +export function rootReducer(state = initialState, action) { + if (!action.type) { + error('Payload missing a type!', action); + } + + switch (action.type) { + case ActionTypes.CHANGE_TOPOLOGY_OPTION: { + state = resumeUpdate(state); + // set option on parent topology + const topology = findTopologyById(state.get('topologies'), action.topologyId); + if (topology) { + const topologyId = topology.get('parentId') || topology.get('id'); + if (state.getIn(['topologyOptions', topologyId, action.option]) !== action.value) { + state = state.update('nodes', nodes => nodes.clear()); + } + state = state.setIn( + ['topologyOptions', topologyId, action.option], + action.value + ); + } + return state; + } + + case ActionTypes.CLEAR_CONTROL_ERROR: { + return state.removeIn(['controlStatus', action.nodeId, 'error']); + } + + case ActionTypes.CLICK_BACKGROUND: { + return closeAllNodeDetails(state); + } + + case ActionTypes.CLICK_CLOSE_DETAILS: { + return closeNodeDetails(state, action.nodeId); + } + + case ActionTypes.CLICK_CLOSE_TERMINAL: { + return state.update('controlPipes', controlPipes => controlPipes.clear()); + } + + case ActionTypes.CLICK_FORCE_RELAYOUT: { + return state.set('forceRelayout', action.forceRelayout); + } + + case ActionTypes.CLICK_NODE: { + const prevSelectedNodeId = state.get('selectedNodeId'); + const prevDetailsStackSize = state.get('nodeDetails').size; + + // click on sibling closes all + state = closeAllNodeDetails(state); + + // select new node if it's not the same (in that case just delesect) + if (prevDetailsStackSize > 1 || prevSelectedNodeId !== action.nodeId) { + // dont set origin if a node was already selected, suppresses animation + const origin = prevSelectedNodeId === null ? action.origin : null; + state = state.setIn(['nodeDetails', action.nodeId], + { + id: action.nodeId, + label: action.label, + origin, + topologyId: state.get('currentTopologyId') + } + ); + state = state.set('selectedNodeId', action.nodeId); + } + return state; + } + + case ActionTypes.CLICK_PAUSE_UPDATE: { + return state.set('updatePausedAt', new Date); + } + + case ActionTypes.CLICK_RELATIVE: { + if (state.hasIn(['nodeDetails', action.nodeId])) { + // bring to front + const details = state.getIn(['nodeDetails', action.nodeId]); + state = state.deleteIn(['nodeDetails', action.nodeId]); + state = state.setIn(['nodeDetails', action.nodeId], details); + } else { + state = state.setIn(['nodeDetails', action.nodeId], + { + id: action.nodeId, + label: action.label, + origin: action.origin, + topologyId: action.topologyId + } + ); + } + return state; + } + + case ActionTypes.CLICK_RESUME_UPDATE: { + return resumeUpdate(state); + } + + case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: { + state = resumeUpdate(state); + + state = state.update('nodeDetails', + nodeDetails => nodeDetails.filter((v, k) => k === action.nodeId)); + state = state.update('controlPipes', controlPipes => controlPipes.clear()); + state = state.set('selectedNodeId', action.nodeId); + + if (action.topologyId !== state.get('currentTopologyId')) { + state = setTopology(state, action.topologyId); + state = state.update('nodes', nodes => nodes.clear()); + } + state = state.set('availableCanvasMetrics', makeList()); + return state; + } + + case ActionTypes.CLICK_TOPOLOGY: { + state = resumeUpdate(state); + state = closeAllNodeDetails(state); + + if (action.topologyId !== state.get('currentTopologyId')) { + state = setTopology(state, action.topologyId); + state = state.update('nodes', nodes => nodes.clear()); + } + state = state.set('availableCanvasMetrics', makeList()); + return state; + } + + case ActionTypes.CLOSE_WEBSOCKET: { + if (!state.get('websocketClosed')) { + state = state.set('websocketClosed', true); + } + return state; + } + + case ActionTypes.SELECT_METRIC: { + return state.set('selectedMetric', action.metricId); + } + + case ActionTypes.PIN_METRIC: { + const metricTypes = makeMap( + state.get('availableCanvasMetrics').map(m => [m.get('id'), m.get('label')])); + return state.merge({ + pinnedMetric: action.metricId, + pinnedMetricType: metricTypes.get(action.metricId), + selectedMetric: action.metricId + }); + } + + case ActionTypes.UNPIN_METRIC: { + return state.merge({ + pinnedMetric: null, + pinnedMetricType: null + }); + } + + case ActionTypes.SHOW_HELP: { + return state.set('showingHelp', true); + } + + case ActionTypes.HIDE_HELP: { + return state.set('showingHelp', false); + } + + case ActionTypes.DESELECT_NODE: { + return closeNodeDetails(state); + } + + case ActionTypes.DO_CONTROL: { + return state.setIn(['controlStatus', action.nodeId], makeMap({ + pending: true, + error: null + })); + } + + case ActionTypes.ENTER_EDGE: { + // highlight adjacent nodes + state = state.update('highlightedNodeIds', highlightedNodeIds => { + highlightedNodeIds = highlightedNodeIds.clear(); + return highlightedNodeIds.union(action.edgeId.split(EDGE_ID_SEPARATOR)); + }); + + // highlight edge + state = state.update('highlightedEdgeIds', highlightedEdgeIds => { + highlightedEdgeIds = highlightedEdgeIds.clear(); + return highlightedEdgeIds.add(action.edgeId); + }); + + return state; + } + + case ActionTypes.ENTER_NODE: { + const nodeId = action.nodeId; + const adjacentNodes = getAdjacentNodes(state, nodeId); + + // highlight adjacent nodes + state = state.update('highlightedNodeIds', highlightedNodeIds => { + highlightedNodeIds = highlightedNodeIds.clear(); + highlightedNodeIds = highlightedNodeIds.add(nodeId); + return highlightedNodeIds.union(adjacentNodes); + }); + + // highlight edge + state = state.update('highlightedEdgeIds', highlightedEdgeIds => { + highlightedEdgeIds = highlightedEdgeIds.clear(); + if (adjacentNodes.size > 0) { + // all neighbour combinations because we dont know which direction exists + highlightedEdgeIds = highlightedEdgeIds.union(adjacentNodes.flatMap((adjacentId) => [ + [adjacentId, nodeId].join(EDGE_ID_SEPARATOR), + [nodeId, adjacentId].join(EDGE_ID_SEPARATOR) + ])); + } + return highlightedEdgeIds; + }); + + return state; + } + + case ActionTypes.LEAVE_EDGE: { + state = state.update('highlightedEdgeIds', highlightedEdgeIds => highlightedEdgeIds.clear()); + state = state.update('highlightedNodeIds', highlightedNodeIds => highlightedNodeIds.clear()); + return state; + } + + case ActionTypes.LEAVE_NODE: { + state = state.update('highlightedEdgeIds', highlightedEdgeIds => highlightedEdgeIds.clear()); + state = state.update('highlightedNodeIds', highlightedNodeIds => highlightedNodeIds.clear()); + return state; + } + + case ActionTypes.OPEN_WEBSOCKET: { + // flush nodes cache after re-connect + state = state.update('nodes', nodes => nodes.clear()); + state = state.set('websocketClosed', false); + return state; + } + + case ActionTypes.DO_CONTROL_ERROR: { + return state.setIn(['controlStatus', action.nodeId], makeMap({ + pending: false, + error: action.error + })); + } + case ActionTypes.DO_CONTROL_SUCCESS: { + return state.setIn(['controlStatus', action.nodeId], makeMap({ + pending: false, + error: null + })); + } + + case ActionTypes.RECEIVE_CONTROL_NODE_REMOVED: { + return closeNodeDetails(state, action.nodeId); + } + + case ActionTypes.RECEIVE_CONTROL_PIPE: { + return state.setIn(['controlPipes', action.pipeId], makeOrderedMap({ + id: action.pipeId, + nodeId: action.nodeId, + raw: action.rawTty + })); + } + + case ActionTypes.RECEIVE_CONTROL_PIPE_STATUS: { + if (state.hasIn(['controlPipes', action.pipeId])) { + state = state.setIn(['controlPipes', action.pipeId, 'status'], action.status); + } + return state; + } + + case ActionTypes.RECEIVE_ERROR: { + if (state.get('errorUrl') !== null) { + state = state.set('errorUrl', action.errorUrl); + } + return state; + } + + case ActionTypes.RECEIVE_NODE_DETAILS: { + state = state.set('errorUrl', null); + + // disregard if node is not selected anymore + if (state.hasIn(['nodeDetails', action.details.id])) { + state = state.updateIn(['nodeDetails', action.details.id], obj => { + const result = Object.assign({}, obj); + result.notFound = false; + result.details = action.details; + return result; + }); + } + return state; + } + + case ActionTypes.RECEIVE_NODES_DELTA: { + const emptyMessage = !action.delta.add && !action.delta.remove + && !action.delta.update; + + if (!emptyMessage) { + log('RECEIVE_NODES_DELTA', + 'remove', _.size(action.delta.remove), + 'update', _.size(action.delta.update), + 'add', _.size(action.delta.add)); + } + + state = state.set('errorUrl', null); + + // nodes that no longer exist + _.each(action.delta.remove, (nodeId) => { + // in case node disappears before mouseleave event + if (state.get('mouseOverNodeId') === nodeId) { + state = state.set('mouseOverNodeId', null); + } + if (state.hasIn(['nodes', nodeId]) && _.includes(state.get('mouseOverEdgeId'), nodeId)) { + state = state.set('mouseOverEdgeId', null); + } + state = state.deleteIn(['nodes', nodeId]); + }); + + // update existing nodes + _.each(action.delta.update, (node) => { + if (state.hasIn(['nodes', node.id])) { + state = state.updateIn(['nodes', node.id], n => n.merge(fromJS(node))); + } + }); + + // add new nodes + _.each(action.delta.add, (node) => { + state = state.setIn(['nodes', node.id], fromJS(makeNode(node))); + }); + + state = state.set('availableCanvasMetrics', state.get('nodes') + .valueSeq() + .flatMap(n => (n.get('metrics') || makeList()).map(m => ( + makeMap({id: m.get('id'), label: m.get('label')}) + ))) + .toSet() + .toList() + .sortBy(m => m.get('label'))); + + const similarTypeMetric = state.get('availableCanvasMetrics') + .find(m => m.get('label') === state.get('pinnedMetricType')); + state = state.set('pinnedMetric', similarTypeMetric && similarTypeMetric.get('id')); + // if something in the current topo is not already selected, select it. + if (!state.get('availableCanvasMetrics') + .map(m => m.get('id')) + .toSet() + .has(state.get('selectedMetric'))) { + state = state.set('selectedMetric', state.get('pinnedMetric')); + } + + return state; + } + + case ActionTypes.RECEIVE_NOT_FOUND: { + if (state.hasIn(['nodeDetails', action.nodeId])) { + state = state.updateIn(['nodeDetails', action.nodeId], obj => { + const result = Object.assign({}, obj); + result.notFound = true; + return result; + }); + } + return state; + } + + case ActionTypes.RECEIVE_TOPOLOGIES: { + state = state.set('errorUrl', null); + state = state.update('topologyUrlsById', topologyUrlsById => topologyUrlsById.clear()); + state = processTopologies(state, action.topologies); + state = setTopology(state, state.get('currentTopologyId')); + // only set on first load, if options are not already set via route + if (!state.get('topologiesLoaded') && state.get('topologyOptions').size === 0) { + state = setDefaultTopologyOptions(state, state.get('topologies')); + } + state = state.set('topologiesLoaded', true); + + return state; + } + + case ActionTypes.RECEIVE_API_DETAILS: { + state = state.set('errorUrl', null); + + return state.merge({ + hostname: action.hostname, + version: action.version, + plugins: action.plugins, + versionUpdate: action.newVersion + }); + } + + case ActionTypes.ROUTE_TOPOLOGY: { + state = state.set('routeSet', true); + if (state.get('currentTopologyId') !== action.state.topologyId) { + state = state.update('nodes', nodes => nodes.clear()); + } + state = setTopology(state, action.state.topologyId); + state = setDefaultTopologyOptions(state, state.get('topologies')); + state = state.merge({ + selectedNodeId: action.state.selectedNodeId, + pinnedMetricType: action.state.pinnedMetricType + }); + if (action.state.controlPipe) { + state = state.set('controlPipes', makeOrderedMap({ + [action.state.controlPipe.id]: + makeOrderedMap(action.state.controlPipe) + })); + } else { + state = state.update('controlPipes', controlPipes => controlPipes.clear()); + } + if (action.state.nodeDetails) { + const actionNodeDetails = makeOrderedMap( + action.state.nodeDetails.map(obj => [obj.id, obj])); + // check if detail IDs have changed + if (!isDeepEqual(state.get('nodeDetails').keySeq(), actionNodeDetails.keySeq())) { + state = state.set('nodeDetails', actionNodeDetails); + } + } else { + state = state.update('nodeDetails', nodeDetails => nodeDetails.clear()); + } + state = state.set('topologyOptions', + fromJS(action.state.topologyOptions) || state.get('topologyOptions')); + return state; + } + + default: { + return state; + } + } +} + +export default rootReducer; diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js deleted file mode 100644 index 80b54059e..000000000 --- a/client/app/scripts/stores/__tests__/app-store-test.js +++ /dev/null @@ -1,434 +0,0 @@ -jest.dontMock('../../utils/topology-utils'); -jest.dontMock('../../constants/action-types'); -jest.dontMock('../app-store'); - -// Appstore test suite using Jasmine matchers - -describe('AppStore', () => { - const ActionTypes = require('../../constants/action-types').default; - let AppStore; - let registeredCallback; - - // fixtures - - const NODE_SET = { - n1: { - id: 'n1', - rank: undefined, - adjacency: ['n1', 'n2'], - pseudo: undefined, - label: undefined, - label_minor: undefined - }, - n2: { - id: 'n2', - rank: undefined, - adjacency: undefined, - pseudo: undefined, - label: undefined, - label_minor: undefined - } - }; - - // actions - - const ChangeTopologyOptionAction = { - type: ActionTypes.CHANGE_TOPOLOGY_OPTION, - topologyId: 'topo1', - option: 'option1', - value: 'on' - }; - - const ChangeTopologyOptionAction2 = { - type: ActionTypes.CHANGE_TOPOLOGY_OPTION, - topologyId: 'topo1', - option: 'option1', - value: 'off' - }; - - const ClickNodeAction = { - type: ActionTypes.CLICK_NODE, - nodeId: 'n1' - }; - - const ClickNode2Action = { - type: ActionTypes.CLICK_NODE, - nodeId: 'n2' - }; - - const ClickRelativeAction = { - type: ActionTypes.CLICK_RELATIVE, - nodeId: 'rel1' - }; - - const ClickShowTopologyForNodeAction = { - type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE, - topologyId: 'topo2', - nodeId: 'rel1' - }; - - const ClickSubTopologyAction = { - type: ActionTypes.CLICK_TOPOLOGY, - topologyId: 'topo1-grouped' - }; - - const ClickTopologyAction = { - type: ActionTypes.CLICK_TOPOLOGY, - topologyId: 'topo1' - }; - - const ClickTopology2Action = { - type: ActionTypes.CLICK_TOPOLOGY, - topologyId: 'topo2' - }; - - const CloseWebsocketAction = { - type: ActionTypes.CLOSE_WEBSOCKET - }; - - const deSelectNode = { - type: ActionTypes.DESELECT_NODE - }; - - const OpenWebsocketAction = { - type: ActionTypes.OPEN_WEBSOCKET - }; - - const ReceiveNodesDeltaAction = { - type: ActionTypes.RECEIVE_NODES_DELTA, - delta: { - add: [{ - id: 'n1', - adjacency: ['n1', 'n2'] - }, { - id: 'n2' - }] - } - }; - - const ReceiveNodesDeltaUpdateAction = { - type: ActionTypes.RECEIVE_NODES_DELTA, - delta: { - update: [{ - id: 'n1', - adjacency: ['n1'] - }], - remove: ['n2'] - } - }; - - const ReceiveTopologiesAction = { - type: ActionTypes.RECEIVE_TOPOLOGIES, - topologies: [{ - url: '/topo1', - name: 'Topo1', - options: [{ - id: 'option1', - defaultValue: 'off', - options: [ - {value: 'on'}, - {value: 'off'} - ] - }], - stats: { - node_count: 1 - }, - sub_topologies: [{ - url: '/topo1-grouped', - name: 'topo 1 grouped' - }] - }, { - url: '/topo2', - name: 'Topo2', - stats: { - node_count: 0 - } - }] - }; - - const RouteAction = { - type: ActionTypes.ROUTE_TOPOLOGY, - state: {} - }; - - beforeEach(() => { - AppStore = require('../app-store').default; - const AppDispatcher = AppStore.getDispatcher(); - const callback = AppDispatcher.dispatch.bind(AppDispatcher); - registeredCallback = callback; - }); - - // topology tests - - it('init with no topologies', () => { - const topos = AppStore.getTopologies(); - expect(topos.size).toBe(0); - expect(AppStore.getCurrentTopology()).toBeUndefined(); - }); - - it('get current topology', () => { - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - - expect(AppStore.getTopologies().size).toBe(2); - expect(AppStore.getCurrentTopology().get('name')).toBe('Topo1'); - expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1'); - expect(AppStore.getCurrentTopologyOptions().first().get('id')).toBe('option1'); - }); - - it('get sub-topology', () => { - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickSubTopologyAction); - - expect(AppStore.getTopologies().size).toBe(2); - expect(AppStore.getCurrentTopology().get('name')).toBe('topo 1 grouped'); - expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1-grouped'); - expect(AppStore.getCurrentTopologyOptions().size).toBe(0); - }); - - // topology options - - it('changes topology option', () => { - // default options - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - expect(AppStore.getActiveTopologyOptions().has('option1')).toBeTruthy(); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('off'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('off'); - - // turn on - registeredCallback(ChangeTopologyOptionAction); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('on'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('on'); - - // turn off - registeredCallback(ChangeTopologyOptionAction2); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('off'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('off'); - - // sub-topology should retain main topo options - registeredCallback(ClickSubTopologyAction); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('off'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('off'); - - // other topology w/o options dont return options, but keep in app state - registeredCallback(ClickTopology2Action); - expect(AppStore.getActiveTopologyOptions()).toBeUndefined(); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('off'); - }); - - it('sets topology options from route', () => { - RouteAction.state = { - topologyId: 'topo1', - selectedNodeId: null, - topologyOptions: {topo1: {option1: 'on'}}}; - registeredCallback(RouteAction); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('on'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('on'); - - // stay same after topos have been received - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('on'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('on'); - }); - - it('uses default topology options from route', () => { - RouteAction.state = { - topologyId: 'topo1', - selectedNodeId: null, - topologyOptions: null}; - registeredCallback(RouteAction); - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('off'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('off'); - }); - - // nodes delta - - it('replaces adjacency on update', () => { - registeredCallback(ReceiveNodesDeltaAction); - expect(AppStore.getNodes().toJS().n1.adjacency).toEqual(['n1', 'n2']); - registeredCallback(ReceiveNodesDeltaUpdateAction); - expect(AppStore.getNodes().toJS().n1.adjacency).toEqual(['n1']); - }); - - // browsing - - it('shows nodes that were received', () => { - registeredCallback(ReceiveNodesDeltaAction); - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - }); - - it('knows a route was set', () => { - expect(AppStore.isRouteSet()).toBeFalsy(); - registeredCallback(RouteAction); - expect(AppStore.isRouteSet()).toBeTruthy(); - }); - - it('gets selected node after click', () => { - registeredCallback(ReceiveNodesDeltaAction); - - registeredCallback(ClickNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - - registeredCallback(deSelectNode); - expect(AppStore.getSelectedNodeId()).toBe(null); - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - }); - - it('keeps showing nodes on navigating back after node click', () => { - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - registeredCallback(ReceiveNodesDeltaAction); - - expect(AppStore.getAppState().selectedNodeId).toEqual(null); - - registeredCallback(ClickNodeAction); - expect(AppStore.getAppState().selectedNodeId).toEqual('n1'); - - // go back in browsing - RouteAction.state = {topologyId: 'topo1', selectedNodeId: null}; - registeredCallback(RouteAction); - expect(AppStore.getSelectedNodeId()).toBe(null); - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - }); - - it('closes details when changing topologies', () => { - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - registeredCallback(ReceiveNodesDeltaAction); - - expect(AppStore.getAppState().selectedNodeId).toEqual(null); - expect(AppStore.getAppState().topologyId).toEqual('topo1'); - - registeredCallback(ClickNodeAction); - expect(AppStore.getAppState().selectedNodeId).toEqual('n1'); - expect(AppStore.getAppState().topologyId).toEqual('topo1'); - - registeredCallback(ClickSubTopologyAction); - expect(AppStore.getAppState().selectedNodeId).toEqual(null); - expect(AppStore.getAppState().topologyId).toEqual('topo1-grouped'); - }); - - // connection errors - - it('resets topology on websocket reconnect', () => { - registeredCallback(ReceiveNodesDeltaAction); - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - - registeredCallback(CloseWebsocketAction); - expect(AppStore.isWebsocketClosed()).toBeTruthy(); - // keep showing old nodes - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - - registeredCallback(OpenWebsocketAction); - expect(AppStore.isWebsocketClosed()).toBeFalsy(); - // opened socket clears nodes - expect(AppStore.getNodes().toJS()).toEqual({}); - }); - - // adjacency test - - it('returns the correct adjacency set for a node', () => { - registeredCallback(ReceiveNodesDeltaAction); - expect(AppStore.getAdjacentNodes().size).toEqual(0); - - registeredCallback(ClickNodeAction); - expect(AppStore.getAdjacentNodes('n1').size).toEqual(2); - expect(AppStore.getAdjacentNodes('n1').has('n1')).toBeTruthy(); - expect(AppStore.getAdjacentNodes('n1').has('n2')).toBeTruthy(); - - registeredCallback(deSelectNode); - expect(AppStore.getAdjacentNodes().size).toEqual(0); - }); - - // empty topology - - it('detects that the topology is empty', () => { - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - expect(AppStore.isTopologyEmpty()).toBeFalsy(); - - registeredCallback(ClickTopology2Action); - expect(AppStore.isTopologyEmpty()).toBeTruthy(); - - registeredCallback(ClickTopologyAction); - expect(AppStore.isTopologyEmpty()).toBeFalsy(); - }); - - // selection of relatives - - it('keeps relatives as a stack', () => { - registeredCallback(ClickNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getNodeDetails().has('n1')).toBeTruthy(); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1'); - - registeredCallback(ClickRelativeAction); - // stack relative, first node stays main node - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1'); - expect(AppStore.getNodeDetails().size).toEqual(2); - expect(AppStore.getNodeDetails().has('rel1')).toBeTruthy(); - - // click on first node should clear the stack - registeredCallback(ClickNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getNodeDetails().has('rel1')).toBeFalsy(); - }); - - it('keeps clears stack when sibling is clicked', () => { - registeredCallback(ClickNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getNodeDetails().has('n1')).toBeTruthy(); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1'); - - registeredCallback(ClickRelativeAction); - // stack relative, first node stays main node - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1'); - expect(AppStore.getNodeDetails().size).toEqual(2); - expect(AppStore.getNodeDetails().has('rel1')).toBeTruthy(); - - // click on sibling node should clear the stack - registeredCallback(ClickNode2Action); - expect(AppStore.getSelectedNodeId()).toBe('n2'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n2'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getNodeDetails().has('n1')).toBeFalsy(); - expect(AppStore.getNodeDetails().has('rel1')).toBeFalsy(); - }); - - it('selectes relatives topology while keeping node selected', () => { - registeredCallback(ClickTopologyAction); - registeredCallback(ReceiveTopologiesAction); - expect(AppStore.getCurrentTopology().get('name')).toBe('Topo1'); - - registeredCallback(ClickNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getNodeDetails().has('n1')).toBeTruthy(); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1'); - - registeredCallback(ClickRelativeAction); - // stack relative, first node stays main node - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1'); - expect(AppStore.getNodeDetails().size).toEqual(2); - expect(AppStore.getNodeDetails().has('rel1')).toBeTruthy(); - - // click switches over to relative's topology and selectes relative - registeredCallback(ClickShowTopologyForNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('rel1'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getCurrentTopology().get('name')).toBe('Topo2'); - }); -}); diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js deleted file mode 100644 index cce23cb64..000000000 --- a/client/app/scripts/stores/app-store.js +++ /dev/null @@ -1,741 +0,0 @@ -import _ from 'lodash'; -import debug from 'debug'; -import { fromJS, is as isDeepEqual, List, Map, OrderedMap, Set } from 'immutable'; -import { Store } from 'flux/utils'; - -import AppDispatcher from '../dispatcher/app-dispatcher'; -import ActionTypes from '../constants/action-types'; -import { EDGE_ID_SEPARATOR } from '../constants/naming'; -import { findTopologyById, setTopologyUrlsById, updateTopologyIds, - filterHiddenTopologies } from '../utils/topology-utils'; - -const makeList = List; -const makeMap = Map; -const makeOrderedMap = OrderedMap; -const makeSet = Set; -const log = debug('scope:app-store'); - -const error = debug('scope:error'); - -// Helpers - -function makeNode(node) { - return { - id: node.id, - label: node.label, - label_minor: node.label_minor, - node_count: node.node_count, - rank: node.rank, - pseudo: node.pseudo, - stack: node.stack, - shape: node.shape, - adjacency: node.adjacency, - metrics: node.metrics - }; -} - -// Initial values - -let topologyOptions = makeOrderedMap(); // topologyId -> options -let controlStatus = makeMap(); -let currentTopology = null; -let currentTopologyId = 'containers'; -let errorUrl = null; -let forceRelayout = false; -let highlightedEdgeIds = makeSet(); -let highlightedNodeIds = makeSet(); -let hostname = '...'; -let version = '...'; -let versionUpdate = null; -let plugins = []; -let mouseOverEdgeId = null; -let mouseOverNodeId = null; -let nodeDetails = makeOrderedMap(); // nodeId -> details -let nodes = makeOrderedMap(); // nodeId -> node -let selectedNodeId = null; -let topologies = makeList(); -let topologiesLoaded = false; -let topologyUrlsById = makeOrderedMap(); // topologyId -> topologyUrl -let routeSet = false; -let controlPipes = makeOrderedMap(); // pipeId -> controlPipe -let updatePausedAt = null; // Date -let websocketClosed = true; -let showingHelp = false; - -let selectedMetric = null; -let pinnedMetric = selectedMetric; -// class of metric, e.g. 'cpu', rather than 'host_cpu' or 'process_cpu'. -// allows us to keep the same metric "type" selected when the topology changes. -let pinnedMetricType = null; -let availableCanvasMetrics = makeList(); - - -const topologySorter = topology => topology.get('rank'); - -// adds ID field to topology (based on last part of URL path) and save urls in -// map for easy lookup -function processTopologies(nextTopologies) { - // filter out hidden topos - const visibleTopologies = filterHiddenTopologies(nextTopologies); - - // add IDs to topology objects in-place - const topologiesWithId = updateTopologyIds(visibleTopologies); - - // cache URLs by ID - topologyUrlsById = setTopologyUrlsById(topologyUrlsById, topologiesWithId); - - const immNextTopologies = fromJS(topologiesWithId).sortBy(topologySorter); - topologies = topologies.mergeDeep(immNextTopologies); -} - -function setTopology(topologyId) { - currentTopology = findTopologyById(topologies, topologyId); - currentTopologyId = topologyId; -} - -function setDefaultTopologyOptions(topologyList) { - topologyList.forEach(topology => { - let defaultOptions = makeOrderedMap(); - if (topology.has('options') && topology.get('options')) { - topology.get('options').forEach((option) => { - const optionId = option.get('id'); - const defaultValue = option.get('defaultValue'); - defaultOptions = defaultOptions.set(optionId, defaultValue); - }); - } - - if (defaultOptions.size) { - topologyOptions = topologyOptions.set( - topology.get('id'), - defaultOptions - ); - } - }); -} - -function closeNodeDetails(nodeId) { - if (nodeDetails.size > 0) { - const popNodeId = nodeId || nodeDetails.keySeq().last(); - // remove pipe if it belongs to the node being closed - controlPipes = controlPipes.filter(pipe => pipe.get('nodeId') !== popNodeId); - nodeDetails = nodeDetails.delete(popNodeId); - } - if (nodeDetails.size === 0 || selectedNodeId === nodeId) { - selectedNodeId = null; - } -} - -function closeAllNodeDetails() { - while (nodeDetails.size) { - closeNodeDetails(); - } -} - -function resumeUpdate() { - updatePausedAt = null; -} - -// Store API - -export class AppStore extends Store { - - // keep at the top - getAppState() { - const cp = this.getControlPipe(); - return { - controlPipe: cp ? cp.toJS() : null, - nodeDetails: this.getNodeDetailsState().toJS(), - selectedNodeId, - pinnedMetricType, - topologyId: currentTopologyId, - topologyOptions: topologyOptions.toJS() // all options - }; - } - - getShowingHelp() { - return showingHelp; - } - - getActiveTopologyOptions() { - // options for current topology, sub-topologies share options with parent - if (currentTopology && currentTopology.get('parentId')) { - return topologyOptions.get(currentTopology.get('parentId')); - } - return topologyOptions.get(currentTopologyId); - } - - getAdjacentNodes(nodeId) { - let adjacentNodes = makeSet(); - - if (nodes.has(nodeId)) { - adjacentNodes = makeSet(nodes.getIn([nodeId, 'adjacency'])); - // fill up set with reverse edges - nodes.forEach((node, id) => { - if (node.get('adjacency') && node.get('adjacency').includes(nodeId)) { - adjacentNodes = adjacentNodes.add(id); - } - }); - } - - return adjacentNodes; - } - - getPinnedMetric() { - return pinnedMetric; - } - - getSelectedMetric() { - return selectedMetric; - } - - getAvailableCanvasMetrics() { - return availableCanvasMetrics; - } - - getAvailableCanvasMetricsTypes() { - return makeMap(this.getAvailableCanvasMetrics().map(m => [m.get('id'), m.get('label')])); - } - - getControlStatus() { - return controlStatus; - } - - getControlPipe() { - return controlPipes.last(); - } - - getCurrentTopology() { - if (!currentTopology) { - currentTopology = setTopology(currentTopologyId); - } - return currentTopology; - } - - getCurrentTopologyId() { - return currentTopologyId; - } - - getCurrentTopologyOptions() { - return currentTopology && currentTopology.get('options') || makeOrderedMap(); - } - - getCurrentTopologyUrl() { - return currentTopology && currentTopology.get('url'); - } - - getErrorUrl() { - return errorUrl; - } - - getHighlightedEdgeIds() { - return highlightedEdgeIds; - } - - getHighlightedNodeIds() { - return highlightedNodeIds; - } - - getHostname() { - return hostname; - } - - getNodeDetails() { - return nodeDetails; - } - - getNodeDetailsState() { - return nodeDetails.toIndexedSeq().map(details => ({ - id: details.id, label: details.label, topologyId: details.topologyId - })); - } - - getTopCardNodeId() { - return nodeDetails.last() && nodeDetails.last().id; - } - - getNodes() { - return nodes; - } - - getSelectedNodeId() { - return selectedNodeId; - } - - getTopologies() { - return topologies; - } - - getTopologyUrlsById() { - return topologyUrlsById; - } - - getUpdatePausedAt() { - return updatePausedAt; - } - - getVersion() { - return version; - } - - getVersionUpdate() { - return versionUpdate; - } - - getPlugins() { - return plugins; - } - - isForceRelayout() { - return forceRelayout; - } - - isRouteSet() { - return routeSet; - } - - isTopologiesLoaded() { - return topologiesLoaded; - } - - isTopologyEmpty() { - return currentTopology && currentTopology.get('stats') - && currentTopology.get('stats').get('node_count') === 0 && nodes.size === 0; - } - - isUpdatePaused() { - return updatePausedAt !== null; - } - - isWebsocketClosed() { - return websocketClosed; - } - - __onDispatch(payload) { - if (!payload.type) { - error('Payload missing a type!', payload); - } - - switch (payload.type) { - case ActionTypes.CHANGE_TOPOLOGY_OPTION: { - resumeUpdate(); - // set option on parent topology - const topology = findTopologyById(topologies, payload.topologyId); - if (topology) { - const topologyId = topology.get('parentId') || topology.get('id'); - if (topologyOptions.getIn([topologyId, payload.option]) !== payload.value) { - nodes = nodes.clear(); - } - topologyOptions = topologyOptions.setIn( - [topologyId, payload.option], - payload.value - ); - this.__emitChange(); - } - break; - } - case ActionTypes.CLEAR_CONTROL_ERROR: { - controlStatus = controlStatus.removeIn([payload.nodeId, 'error']); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_BACKGROUND: { - closeAllNodeDetails(); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_CLOSE_DETAILS: { - closeNodeDetails(payload.nodeId); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_CLOSE_TERMINAL: { - controlPipes = controlPipes.clear(); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_FORCE_RELAYOUT: { - forceRelayout = true; - // fire only once, reset after emitChange - setTimeout(() => { - forceRelayout = false; - }, 0); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_NODE: { - const prevSelectedNodeId = selectedNodeId; - const prevDetailsStackSize = nodeDetails.size; - // click on sibling closes all - closeAllNodeDetails(); - // select new node if it's not the same (in that case just delesect) - if (prevDetailsStackSize > 1 || prevSelectedNodeId !== payload.nodeId) { - // dont set origin if a node was already selected, suppresses animation - const origin = prevSelectedNodeId === null ? payload.origin : null; - nodeDetails = nodeDetails.set( - payload.nodeId, - { - id: payload.nodeId, - label: payload.label, - origin, - topologyId: currentTopologyId - } - ); - selectedNodeId = payload.nodeId; - } - this.__emitChange(); - break; - } - case ActionTypes.CLICK_PAUSE_UPDATE: { - updatePausedAt = new Date; - this.__emitChange(); - break; - } - case ActionTypes.CLICK_RELATIVE: { - if (nodeDetails.has(payload.nodeId)) { - // bring to front - const details = nodeDetails.get(payload.nodeId); - nodeDetails = nodeDetails.delete(payload.nodeId); - nodeDetails = nodeDetails.set(payload.nodeId, details); - } else { - nodeDetails = nodeDetails.set( - payload.nodeId, - { - id: payload.nodeId, - label: payload.label, - origin: payload.origin, - topologyId: payload.topologyId - } - ); - } - this.__emitChange(); - break; - } - case ActionTypes.CLICK_RESUME_UPDATE: { - resumeUpdate(); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: { - resumeUpdate(); - nodeDetails = nodeDetails.filter((v, k) => k === payload.nodeId); - controlPipes = controlPipes.clear(); - selectedNodeId = payload.nodeId; - if (payload.topologyId !== currentTopologyId) { - setTopology(payload.topologyId); - nodes = nodes.clear(); - } - availableCanvasMetrics = makeList(); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_TOPOLOGY: { - resumeUpdate(); - closeAllNodeDetails(); - if (payload.topologyId !== currentTopologyId) { - setTopology(payload.topologyId); - nodes = nodes.clear(); - } - availableCanvasMetrics = makeList(); - this.__emitChange(); - break; - } - case ActionTypes.CLOSE_WEBSOCKET: { - if (!websocketClosed) { - websocketClosed = true; - this.__emitChange(); - } - break; - } - case ActionTypes.SELECT_METRIC: { - selectedMetric = payload.metricId; - this.__emitChange(); - break; - } - case ActionTypes.PIN_METRIC: { - pinnedMetric = payload.metricId; - pinnedMetricType = this.getAvailableCanvasMetricsTypes().get(payload.metricId); - selectedMetric = payload.metricId; - this.__emitChange(); - break; - } - case ActionTypes.UNPIN_METRIC: { - pinnedMetric = null; - pinnedMetricType = null; - this.__emitChange(); - break; - } - case ActionTypes.SHOW_HELP: { - showingHelp = true; - this.__emitChange(); - break; - } - case ActionTypes.HIDE_HELP: { - showingHelp = false; - this.__emitChange(); - break; - } - case ActionTypes.DESELECT_NODE: { - closeNodeDetails(); - this.__emitChange(); - break; - } - case ActionTypes.DO_CONTROL: { - controlStatus = controlStatus.set(payload.nodeId, makeMap({ - pending: true, - error: null - })); - this.__emitChange(); - break; - } - case ActionTypes.ENTER_EDGE: { - // clear old highlights - highlightedNodeIds = highlightedNodeIds.clear(); - highlightedEdgeIds = highlightedEdgeIds.clear(); - - // highlight edge - highlightedEdgeIds = highlightedEdgeIds.add(payload.edgeId); - - // highlight adjacent nodes - highlightedNodeIds = highlightedNodeIds.union(payload.edgeId.split(EDGE_ID_SEPARATOR)); - - this.__emitChange(); - break; - } - case ActionTypes.ENTER_NODE: { - const nodeId = payload.nodeId; - const adjacentNodes = this.getAdjacentNodes(nodeId); - - // clear old highlights - highlightedNodeIds = highlightedNodeIds.clear(); - highlightedEdgeIds = highlightedEdgeIds.clear(); - - // highlight nodes - highlightedNodeIds = highlightedNodeIds.add(nodeId); - highlightedNodeIds = highlightedNodeIds.union(adjacentNodes); - - // highlight edges - if (adjacentNodes.size > 0) { - // all neighbour combinations because we dont know which direction exists - highlightedEdgeIds = highlightedEdgeIds.union(adjacentNodes.flatMap((adjacentId) => [ - [adjacentId, nodeId].join(EDGE_ID_SEPARATOR), - [nodeId, adjacentId].join(EDGE_ID_SEPARATOR) - ])); - } - - this.__emitChange(); - break; - } - case ActionTypes.LEAVE_EDGE: { - highlightedEdgeIds = highlightedEdgeIds.clear(); - highlightedNodeIds = highlightedNodeIds.clear(); - this.__emitChange(); - break; - } - case ActionTypes.LEAVE_NODE: { - highlightedEdgeIds = highlightedEdgeIds.clear(); - highlightedNodeIds = highlightedNodeIds.clear(); - this.__emitChange(); - break; - } - case ActionTypes.OPEN_WEBSOCKET: { - // flush nodes cache after re-connect - nodes = nodes.clear(); - websocketClosed = false; - - this.__emitChange(); - break; - } - case ActionTypes.DO_CONTROL_ERROR: { - controlStatus = controlStatus.set(payload.nodeId, makeMap({ - pending: false, - error: payload.error - })); - this.__emitChange(); - break; - } - case ActionTypes.DO_CONTROL_SUCCESS: { - controlStatus = controlStatus.set(payload.nodeId, makeMap({ - pending: false, - error: null - })); - this.__emitChange(); - break; - } - case ActionTypes.RECEIVE_CONTROL_NODE_REMOVED: { - closeNodeDetails(payload.nodeId); - this.__emitChange(); - break; - } - case ActionTypes.RECEIVE_CONTROL_PIPE: { - controlPipes = controlPipes.set(payload.pipeId, makeOrderedMap({ - id: payload.pipeId, - nodeId: payload.nodeId, - raw: payload.rawTty - })); - this.__emitChange(); - break; - } - case ActionTypes.RECEIVE_CONTROL_PIPE_STATUS: { - if (controlPipes.has(payload.pipeId)) { - controlPipes = controlPipes.setIn([payload.pipeId, 'status'], payload.status); - this.__emitChange(); - } - break; - } - case ActionTypes.RECEIVE_ERROR: { - if (errorUrl !== null) { - errorUrl = payload.errorUrl; - this.__emitChange(); - } - break; - } - case ActionTypes.RECEIVE_NODE_DETAILS: { - errorUrl = null; - - // disregard if node is not selected anymore - if (nodeDetails.has(payload.details.id)) { - nodeDetails = nodeDetails.update(payload.details.id, obj => { - const result = Object.assign({}, obj); - result.notFound = false; - result.details = payload.details; - return result; - }); - } - this.__emitChange(); - break; - } - case ActionTypes.RECEIVE_NODES_DELTA: { - const emptyMessage = !payload.delta.add && !payload.delta.remove - && !payload.delta.update; - // this action is called frequently, good to check if something changed - const emitChange = !emptyMessage || errorUrl !== null; - - if (!emptyMessage) { - log('RECEIVE_NODES_DELTA', - 'remove', _.size(payload.delta.remove), - 'update', _.size(payload.delta.update), - 'add', _.size(payload.delta.add)); - } - - errorUrl = null; - - // nodes that no longer exist - _.each(payload.delta.remove, (nodeId) => { - // in case node disappears before mouseleave event - if (mouseOverNodeId === nodeId) { - mouseOverNodeId = null; - } - if (nodes.has(nodeId) && _.includes(mouseOverEdgeId, nodeId)) { - mouseOverEdgeId = null; - } - nodes = nodes.delete(nodeId); - }); - - // update existing nodes - _.each(payload.delta.update, (node) => { - if (nodes.has(node.id)) { - nodes = nodes.set(node.id, nodes.get(node.id).merge(fromJS(node))); - } - }); - - // add new nodes - _.each(payload.delta.add, (node) => { - nodes = nodes.set(node.id, fromJS(makeNode(node))); - }); - - availableCanvasMetrics = nodes - .valueSeq() - .flatMap(n => (n.get('metrics') || makeList()).map(m => ( - makeMap({id: m.get('id'), label: m.get('label')}) - ))) - .toSet() - .toList() - .sortBy(m => m.get('label')); - - const similarTypeMetric = availableCanvasMetrics - .find(m => m.get('label') === pinnedMetricType); - pinnedMetric = similarTypeMetric && similarTypeMetric.get('id'); - // if something in the current topo is not already selected, select it. - if (!availableCanvasMetrics.map(m => m.get('id')).toSet().has(selectedMetric)) { - selectedMetric = pinnedMetric; - } - - if (emitChange) { - this.__emitChange(); - } - break; - } - case ActionTypes.RECEIVE_NOT_FOUND: { - if (nodeDetails.has(payload.nodeId)) { - nodeDetails = nodeDetails.update(payload.nodeId, obj => { - const result = Object.assign({}, obj); - result.notFound = true; - return result; - }); - this.__emitChange(); - } - break; - } - case ActionTypes.RECEIVE_TOPOLOGIES: { - errorUrl = null; - topologyUrlsById = topologyUrlsById.clear(); - processTopologies(payload.topologies); - setTopology(currentTopologyId); - // only set on first load, if options are not already set via route - if (!topologiesLoaded && topologyOptions.size === 0) { - setDefaultTopologyOptions(topologies); - } - topologiesLoaded = true; - - this.__emitChange(); - break; - } - case ActionTypes.RECEIVE_API_DETAILS: { - errorUrl = null; - hostname = payload.hostname; - version = payload.version; - plugins = payload.plugins; - versionUpdate = payload.newVersion; - this.__emitChange(); - break; - } - case ActionTypes.ROUTE_TOPOLOGY: { - routeSet = true; - if (currentTopologyId !== payload.state.topologyId) { - nodes = nodes.clear(); - } - setTopology(payload.state.topologyId); - setDefaultTopologyOptions(topologies); - selectedNodeId = payload.state.selectedNodeId; - pinnedMetricType = payload.state.pinnedMetricType; - if (payload.state.controlPipe) { - controlPipes = makeOrderedMap({ - [payload.state.controlPipe.id]: - makeOrderedMap(payload.state.controlPipe) - }); - } else { - controlPipes = controlPipes.clear(); - } - if (payload.state.nodeDetails) { - const payloadNodeDetails = makeOrderedMap( - payload.state.nodeDetails.map(obj => [obj.id, obj])); - // check if detail IDs have changed - if (!isDeepEqual(nodeDetails.keySeq(), payloadNodeDetails.keySeq())) { - nodeDetails = payloadNodeDetails; - } - } else { - nodeDetails = nodeDetails.clear(); - } - topologyOptions = fromJS(payload.state.topologyOptions) - || topologyOptions; - this.__emitChange(); - break; - } - default: { - break; - } - } - } -} - -export default new AppStore(AppDispatcher); diff --git a/client/app/scripts/stores/configureStore.dev.js b/client/app/scripts/stores/configureStore.dev.js new file mode 100644 index 000000000..e0b838959 --- /dev/null +++ b/client/app/scripts/stores/configureStore.dev.js @@ -0,0 +1,28 @@ +import { createStore, applyMiddleware, compose } from 'redux'; +import thunkMiddleware from 'redux-thunk'; +// import createLogger from 'redux-logger'; + +import DevTools from '../components/dev-tools'; +import { initialState, rootReducer } from '../reducers/root'; + +export default function configureStore() { + const store = createStore( + rootReducer, + initialState, + compose( + // applyMiddleware(thunkMiddleware, createLogger()), + applyMiddleware(thunkMiddleware), + DevTools.instrument() + ) + ); + + if (module.hot) { + // Enable Webpack hot module replacement for reducers + module.hot.accept('../reducers/root', () => { + const nextRootReducer = require('../reducers/root').default; + store.replaceReducer(nextRootReducer); + }); + } + + return store; +} diff --git a/client/app/scripts/stores/configureStore.js b/client/app/scripts/stores/configureStore.js new file mode 100644 index 000000000..78c9ea1fd --- /dev/null +++ b/client/app/scripts/stores/configureStore.js @@ -0,0 +1,5 @@ +if (process.env.NODE_ENV === 'production') { + module.exports = require('./configureStore.prod'); +} else { + module.exports = require('./configureStore.dev'); +} diff --git a/client/app/scripts/stores/configureStore.prod.js b/client/app/scripts/stores/configureStore.prod.js new file mode 100644 index 000000000..f61d8a847 --- /dev/null +++ b/client/app/scripts/stores/configureStore.prod.js @@ -0,0 +1,12 @@ +import { createStore, applyMiddleware } from 'redux'; +import thunkMiddleware from 'redux-thunk'; + +import { initialState, rootReducer } from '../reducers/root'; + +export default function configureStore() { + return createStore( + rootReducer, + initialState, + applyMiddleware(thunkMiddleware) + ); +} diff --git a/client/app/scripts/terminal-main.js b/client/app/scripts/terminal-main.js index 9e651ade8..3feeee7e4 100644 --- a/client/app/scripts/terminal-main.js +++ b/client/app/scripts/terminal-main.js @@ -1,9 +1,19 @@ require('../styles/main.less'); require('../images/favicon.ico'); +import 'babel-polyfill'; import React from 'react'; import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; -import { TerminalApp } from './components/terminal-app.js'; +import configureStore from './stores/configureStore'; +import TerminalApp from './components/terminal-app.js'; -ReactDOM.render(, document.getElementById('app')); +const store = configureStore(); + +ReactDOM.render( + + + , + document.getElementById('app') +); diff --git a/client/app/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js index 453249456..85cfc7d1f 100644 --- a/client/app/scripts/utils/router-utils.js +++ b/client/app/scripts/utils/router-utils.js @@ -1,7 +1,6 @@ import page from 'page'; import { route } from '../actions/app-actions'; -import AppStore from '../stores/app-store'; // // page.js won't match the routes below if ":state" has a slash in it, so replace those before we @@ -27,8 +26,24 @@ function shouldReplaceState(prevState, nextState) { return terminalToTerminal || closingTheTerminal; } -export function updateRoute() { - const state = AppStore.getAppState(); +export function getUrlState(state) { + const cp = state.get('controlPipes').last(); + const nodeDetails = state.get('nodeDetails').toIndexedSeq().map(details => ({ + id: details.id, label: details.label, topologyId: details.topologyId + })); + + return { + controlPipe: cp ? cp.toJS() : null, + nodeDetails: nodeDetails.toJS(), + selectedNodeId: state.get('selectedNodeId'), + pinnedMetricType: state.get('pinnedMetricType'), + topologyId: state.get('currentTopologyId'), + topologyOptions: state.get('topologyOptions').toJS() // all options + }; +} + +export function updateRoute(getState) { + const state = getUrlState(getState()); const stateUrl = encodeURL(JSON.stringify(state)); const dispatch = false; const urlStateString = window.location.hash @@ -44,17 +59,19 @@ export function updateRoute() { } } -page('/', () => { - updateRoute(); -}); -page('/state/:state', (ctx) => { - const state = JSON.parse(ctx.params.state); - route(state); -}); - -export function getRouter() { +export function getRouter(dispatch, initialState) { // strip any trailing '/'s. page.base(window.location.pathname.replace(/\/$/, '')); + + page('/', () => { + dispatch(route(initialState)); + }); + + page('/state/:state', (ctx) => { + const state = JSON.parse(ctx.params.state); + dispatch(route(state)); + }); + return page; } diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index 8c561285b..37307cdd6 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { is as isDeepEqual, Map as makeMap, Set as makeSet } from 'immutable'; /** * Returns a cache ID based on the topologyId and optionsQuery @@ -66,14 +67,16 @@ export function updateTopologyIds(topologies, parentId) { // map for easy lookup export function setTopologyUrlsById(topologyUrlsById, topologies) { let urlMap = topologyUrlsById; - topologies.forEach(topology => { - urlMap = urlMap.set(topology.id, topology.url); - if (topology.sub_topologies) { - topology.sub_topologies.forEach(subTopology => { - urlMap = urlMap.set(subTopology.id, subTopology.url); - }); - } - }); + if (topologies) { + topologies.forEach(topology => { + urlMap = urlMap.set(topology.id, topology.url); + if (topology.sub_topologies) { + topology.sub_topologies.forEach(subTopology => { + urlMap = urlMap.set(subTopology.id, subTopology.url); + }); + } + }); + } return urlMap; } @@ -81,3 +84,56 @@ export function filterHiddenTopologies(topologies) { return topologies.filter(t => (!t.hide_if_empty || t.stats.node_count > 0 || t.stats.filtered_nodes > 0)); } + +export function getActiveTopologyOptions(state) { + // options for current topology, sub-topologies share options with parent + const parentId = state.getIn(['currentTopology', 'parentId']); + if (parentId) { + return state.getIn(['topologyOptions', parentId]); + } + return state.getIn(['topologyOptions', state.get('currentTopologyId')]); +} + +export function getCurrentTopologyOptions(state) { + return state.getIn(['currentTopology', 'options']); +} + +export function isTopologyEmpty(state) { + return state.getIn(['currentTopology', 'stats', 'node_count'], 0) === 0 + && state.get('nodes').size === 0; +} + +export function getAdjacentNodes(state, originNodeId) { + let adjacentNodes = makeSet(); + const nodeId = originNodeId || state.get('selectedNodeId'); + + if (nodeId) { + if (state.hasIn(['nodes', nodeId])) { + adjacentNodes = makeSet(state.getIn(['nodes', nodeId, 'adjacency'])); + // fill up set with reverse edges + state.get('nodes').forEach((node, id) => { + if (node.get('adjacency') && node.get('adjacency').includes(nodeId)) { + adjacentNodes = adjacentNodes.add(id); + } + }); + } + } + + return adjacentNodes; +} + +export function hasSelectedNode(state) { + const selectedNodeId = state.get('selectedNodeId'); + return state.hasIn(['nodes', selectedNodeId]); +} + +export function getCurrentTopologyUrl(state) { + return state.getIn(['currentTopology', 'url']); +} + +export function isSameTopology(nodes, nextNodes) { + const mapper = node => makeMap({id: node.get('id'), adjacency: node.get('adjacency')}); + const topology = nodes.map(mapper); + const nextTopology = nextNodes.map(mapper); + return isDeepEqual(topology, nextTopology); +} diff --git a/client/app/scripts/utils/update-buffer-utils.js b/client/app/scripts/utils/update-buffer-utils.js index 52543695f..8f628aaea 100644 --- a/client/app/scripts/utils/update-buffer-utils.js +++ b/client/app/scripts/utils/update-buffer-utils.js @@ -3,7 +3,6 @@ import debug from 'debug'; import Immutable from 'immutable'; import { receiveNodesDelta } from '../actions/app-actions'; -import AppStore from '../stores/app-store'; const log = debug('scope:update-buffer-utils'); const makeList = Immutable.List; @@ -13,8 +12,8 @@ const bufferLength = 100; let deltaBuffer = makeList(); let updateTimer = null; -function isPaused() { - return AppStore.isUpdatePaused(); +function isPaused(getState) { + return getState().get('updatePausedAt') !== null; } export function resetUpdateBuffer() { @@ -22,8 +21,8 @@ export function resetUpdateBuffer() { deltaBuffer = deltaBuffer.clear(); } -function maybeUpdate() { - if (isPaused()) { +function maybeUpdate(getState) { + if (isPaused(getState)) { clearTimeout(updateTimer); resetUpdateBuffer(); } else { @@ -110,6 +109,6 @@ export function getUpdateBufferSize() { return deltaBuffer.size; } -export function resumeUpdate() { - maybeUpdate(); +export function resumeUpdate(getState) { + maybeUpdate(getState); } diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 885adb271..0f8810cf4 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -56,7 +56,7 @@ export function basePathSlash(urlPath) { const wsProto = location.protocol === 'https:' ? 'wss' : 'ws'; const wsUrl = `${wsProto}://${location.host}${basePath(location.pathname)}`; -function createWebsocket(topologyUrl, optionsQuery) { +function createWebsocket(topologyUrl, optionsQuery, dispatch) { if (socket) { socket.onclose = null; socket.onerror = null; @@ -68,67 +68,67 @@ function createWebsocket(topologyUrl, optionsQuery) { socket = new WebSocket(`${wsUrl}${topologyUrl}/ws?t=${updateFrequency}&${optionsQuery}`); socket.onopen = () => { - openWebsocket(); + dispatch(openWebsocket()); }; socket.onclose = () => { clearTimeout(reconnectTimer); log(`Closing websocket to ${topologyUrl}`, socket.readyState); socket = null; - closeWebsocket(); + dispatch(closeWebsocket()); reconnectTimer = setTimeout(() => { - createWebsocket(topologyUrl, optionsQuery); + createWebsocket(topologyUrl, optionsQuery, dispatch); }, reconnectTimerInterval); }; socket.onerror = () => { log(`Error in websocket to ${topologyUrl}`); - receiveError(currentUrl); + dispatch(receiveError(currentUrl)); }; socket.onmessage = (event) => { const msg = JSON.parse(event.data); - receiveNodesDelta(msg); + dispatch(receiveNodesDelta(msg)); }; } /* keep URLs relative */ -export function getTopologies(options) { +export function getTopologies(options, dispatch) { clearTimeout(topologyTimer); const optionsQuery = buildOptionsQuery(options); const url = `api/topology?${optionsQuery}`; reqwest({ url, success: (res) => { - receiveTopologies(res); + dispatch(receiveTopologies(res)); topologyTimer = setTimeout(() => { - getTopologies(options); + getTopologies(options, dispatch); }, TOPOLOGY_INTERVAL); }, error: (err) => { log(`Error in topology request: ${err.responseText}`); - receiveError(url); + dispatch(receiveError(url)); topologyTimer = setTimeout(() => { - getTopologies(options); + getTopologies(options, dispatch); }, TOPOLOGY_INTERVAL); } }); } -export function getNodesDelta(topologyUrl, options) { +export function getNodesDelta(topologyUrl, options, dispatch) { const optionsQuery = buildOptionsQuery(options); // only recreate websocket if url changed if (topologyUrl && (topologyUrl !== currentUrl || currentOptions !== optionsQuery)) { - createWebsocket(topologyUrl, optionsQuery); + createWebsocket(topologyUrl, optionsQuery, dispatch); currentUrl = topologyUrl; currentOptions = optionsQuery; } } -export function getNodeDetails(topologyUrlsById, nodeMap) { +export function getNodeDetails(topologyUrlsById, nodeMap, dispatch) { // get details for all opened nodes const obj = nodeMap.last(); if (obj && topologyUrlsById.has(obj.topologyId)) { @@ -140,42 +140,46 @@ export function getNodeDetails(topologyUrlsById, nodeMap) { success: (res) => { // make sure node is still selected if (nodeMap.has(res.node.id)) { - receiveNodeDetails(res.node); + dispatch(receiveNodeDetails(res.node)); } }, error: (err) => { log(`Error in node details request: ${err.responseText}`); // dont treat missing node as error if (err.status === 404) { - receiveNotFound(obj.id); + dispatch(receiveNotFound(obj.id)); } else { - receiveError(topologyUrl); + dispatch(receiveError(topologyUrl)); } } }); - } else { + } else if (obj) { log('No details or url found for ', obj); } } -export function getApiDetails() { +export function getApiDetails(dispatch) { clearTimeout(apiDetailsTimer); const url = 'api'; reqwest({ url, success: (res) => { - receiveApiDetails(res); - apiDetailsTimer = setTimeout(getApiDetails, API_INTERVAL); + dispatch(receiveApiDetails(res)); + apiDetailsTimer = setTimeout(() => { + getApiDetails(dispatch); + }, API_INTERVAL); }, error: (err) => { log(`Error in api details request: ${err.responseText}`); receiveError(url); - apiDetailsTimer = setTimeout(getApiDetails, API_INTERVAL / 2); + apiDetailsTimer = setTimeout(() => { + getApiDetails(dispatch); + }, API_INTERVAL / 2); } }); } -export function doControlRequest(nodeId, control) { +export function doControlRequest(nodeId, control, dispatch) { clearTimeout(controlErrorTimer); const url = `api/control/${encodeURIComponent(control.probeId)}/` + `${encodeURIComponent(control.nodeId)}/${control.id}`; @@ -183,26 +187,26 @@ export function doControlRequest(nodeId, control) { method: 'POST', url, success: (res) => { - receiveControlSuccess(nodeId); + dispatch(receiveControlSuccess(nodeId)); if (res) { if (res.pipe) { - receiveControlPipe(res.pipe, nodeId, res.raw_tty, true); + dispatch(receiveControlPipe(res.pipe, nodeId, res.raw_tty, true)); } if (res.removedNode) { - receiveControlNodeRemoved(nodeId); + dispatch(receiveControlNodeRemoved(nodeId)); } } }, error: (err) => { - receiveControlError(nodeId, err.response); + dispatch(receiveControlError(nodeId, err.response)); controlErrorTimer = setTimeout(() => { - clearControlError(nodeId); + dispatch(clearControlError(nodeId)); }, 10000); } }); } -export function deletePipe(pipeId) { +export function deletePipe(pipeId, dispatch) { const url = `api/pipe/${encodeURIComponent(pipeId)}`; reqwest({ method: 'DELETE', @@ -212,12 +216,12 @@ export function deletePipe(pipeId) { }, error: (err) => { log(`Error closing pipe:${err}`); - receiveError(url); + dispatch(receiveError(url)); } }); } -export function getPipeStatus(pipeId) { +export function getPipeStatus(pipeId, dispatch) { const url = `api/pipe/${encodeURIComponent(pipeId)}/check`; reqwest({ method: 'GET', @@ -233,7 +237,7 @@ export function getPipeStatus(pipeId) { return; } - receiveControlPipeStatus(pipeId, status); + dispatch(receiveControlPipeStatus(pipeId, status)); } }); } diff --git a/client/package.json b/client/package.json index fbcb34918..11896fda2 100644 --- a/client/package.json +++ b/client/package.json @@ -6,12 +6,12 @@ "license": "Apache-2.0", "private": true, "dependencies": { - "classnames": "^2.2.1", + "babel-polyfill": "6.7.4", + "classnames": "~2.2.1", "d3": "~3.5.5", "dagre": "0.7.4", "debug": "~2.2.0", "filesize": "3.2.1", - "flux": "2.1.1", "font-awesome": "4.5.0", "font-awesome-webpack": "0.0.4", "immutable": "~3.7.4", @@ -21,13 +21,17 @@ "page": "1.7.0", "react": "^0.14.7", "react-addons-pure-render-mixin": "^0.14.7", - "react-addons-transition-group": "^0.14.7", - "react-addons-update": "^0.14.7", "react-dom": "^0.14.7", - "react-motion": "0.3.1", "react-mixin": "^3.0.3", + "react-motion": "0.3.1", + "react-redux": "4.4.5", + "redux": "3.5.1", + "redux-immutable": "3.0.6", + "redux-logger": "2.6.1", + "redux-thunk": "2.0.1", "reqwest": "~2.0.5", - "timely": "0.1.0" + "timely": "0.1.0", + "whatwg-fetch": "0.11.0" }, "devDependencies": { "autoprefixer": "6.3.3", @@ -55,6 +59,9 @@ "less-loader": "2.2.2", "postcss-loader": "0.8.2", "react-addons-perf": "^0.14.0", + "redux-devtools": "^3.2.0", + "redux-devtools-dock-monitor": "^1.1.1", + "redux-devtools-log-monitor": "^1.0.11", "style-loader": "0.13.0", "url": "0.11.0", "url-loader": "0.5.7", diff --git a/client/webpack.local.config.js b/client/webpack.local.config.js index 4a58c9775..d669320d7 100644 --- a/client/webpack.local.config.js +++ b/client/webpack.local.config.js @@ -27,8 +27,7 @@ module.exports = { 'app': [ './app/scripts/main', 'webpack-dev-server/client?http://' + WEBPACK_SERVER_HOST + ':4041', - 'webpack/hot/only-dev-server', - './app/scripts/debug' + 'webpack/hot/only-dev-server' ], 'contrast-app': [ './app/scripts/contrast-main', @@ -40,8 +39,9 @@ module.exports = { 'webpack-dev-server/client?http://' + WEBPACK_SERVER_HOST + ':4041', 'webpack/hot/only-dev-server' ], - vendors: ['classnames', 'd3', 'dagre', 'flux', 'immutable', - 'lodash', 'page', 'react', 'react-dom', 'react-motion'] + vendors: ['babel-polyfill', 'classnames', 'd3', 'dagre', 'immutable', + 'lodash', 'page', 'react', 'react-dom', 'react-redux', 'react-motion', + 'redux', 'redux-thunk'] }, // This will not actually create a app.js file in ./build. It is used diff --git a/client/webpack.production.config.js b/client/webpack.production.config.js index 3b45e3990..7d2d1c5ff 100644 --- a/client/webpack.production.config.js +++ b/client/webpack.production.config.js @@ -24,8 +24,9 @@ module.exports = { app: './app/scripts/main', 'contrast-app': './app/scripts/contrast-main', 'terminal-app': './app/scripts/terminal-main', - vendors: ['classnames', 'd3', 'dagre', 'flux', 'immutable', - 'lodash', 'page', 'react', 'react-dom', 'react-motion'] + vendors: ['babel-polyfill', 'classnames', 'd3', 'dagre', 'immutable', + 'lodash', 'page', 'react', 'react-dom', 'react-redux', 'react-motion', + 'redux', 'redux-thunk'] }, output: { From bb972361bf3ea356f3f94d978e6d6bb84f5e2039 Mon Sep 17 00:00:00 2001 From: Alfonso Acosta Date: Wed, 27 Apr 2016 22:11:24 +0000 Subject: [PATCH 02/19] Add ECS instructions and badge to README --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 29a19efdf..922db5cb2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Weave Scope - Monitoring, visualisation & management for Docker & Kubernetes -[![Circle CI](https://circleci.com/gh/weaveworks/scope/tree/master.svg?style=shield)](https://circleci.com/gh/weaveworks/scope/tree/master) [![Coverage Status](https://coveralls.io/repos/weaveworks/scope/badge.svg)](https://coveralls.io/r/weaveworks/scope) [![Slack Status](https://weave-scope-slack.herokuapp.com/badge.svg)](https://weave-scope-slack.herokuapp.com) [![Go Report Card](https://goreportcard.com/badge/github.com/weaveworks/scope)](https://goreportcard.com/report/github.com/weaveworks/scope) +[![Circle CI](https://circleci.com/gh/weaveworks/scope/tree/master.svg?style=shield)](https://circleci.com/gh/weaveworks/scope/tree/master) [![Coverage Status](https://coveralls.io/repos/weaveworks/scope/badge.svg)](https://coveralls.io/r/weaveworks/scope) [![Slack Status](https://weave-scope-slack.herokuapp.com/badge.svg)](https://weave-scope-slack.herokuapp.com) [![Go Report Card](https://goreportcard.com/badge/github.com/weaveworks/scope)](https://goreportcard.com/report/github.com/weaveworks/scope) [](https://www.weave.works/deploy-weave-aws-cloudformation-template/) Weave Scope automatically generates a map of your application, enabling you to intuitively understand, monitor, and control your containerized, microservices based application. @@ -211,6 +211,14 @@ The SCOPE_SERVICE_TOKEN is found when you [log in to the Scope service](https:// - "--service-token" - "${SCOPE_SERVICE_TOKEN}" +## Using Weave Scope with Amazon's EC2 Container Service + +We currently provide three options for launching Weave Scope in ECS: + +* A [CloudFormation template](https://www.weave.works/deploy-weave-aws-cloudformation-template/) to launch and easily evaluate Scope directly from your browser. +* An [Amazon Machine Image (AMI)](https://github.com/weaveworks/integrations/tree/master/aws/ecs#weaves-ecs-amis) for each ECS region. +* [A simple way to tailor the AMIs to your needs](https://github.com/weaveworks/integrations/tree/master/aws/ecs#creating-your-own-customized-weave-ecs-ami). + ## Using Weave Scope with Kubernetes Scope comes with built-in Kubernetes support. We recommend to run Scope natively From df593b223ad4635a9081e066b974dcb28000e740 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 28 Apr 2016 11:55:07 +0200 Subject: [PATCH 03/19] Upgrade to react 15.0.1 --- client/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/package.json b/client/package.json index 11896fda2..c2e247d7b 100644 --- a/client/package.json +++ b/client/package.json @@ -19,9 +19,9 @@ "materialize-css": "0.97.5", "moment": "2.12.0", "page": "1.7.0", - "react": "^0.14.7", - "react-addons-pure-render-mixin": "^0.14.7", - "react-dom": "^0.14.7", + "react": "^15.0.1", + "react-addons-pure-render-mixin": "^15.0.1", + "react-dom": "^15.0.1", "react-mixin": "^3.0.3", "react-motion": "0.3.1", "react-redux": "4.4.5", @@ -58,7 +58,7 @@ "less": "~2.6.1", "less-loader": "2.2.2", "postcss-loader": "0.8.2", - "react-addons-perf": "^0.14.0", + "react-addons-perf": "^15.0.1", "redux-devtools": "^3.2.0", "redux-devtools-dock-monitor": "^1.1.1", "redux-devtools-log-monitor": "^1.0.11", From 1545b00394c8f15e10274fc234908bc72cdc88b3 Mon Sep 17 00:00:00 2001 From: Alfonso Acosta Date: Thu, 28 Apr 2016 11:31:20 +0000 Subject: [PATCH 04/19] Bump vendor/github.com/weaveworks/go-checkpoint --- .../weaveworks/go-checkpoint/Makefile | 22 ++- .../weaveworks/go-checkpoint/README.md | 37 +++-- .../go-checkpoint/backend/Dockerfile | 12 ++ .../weaveworks/go-checkpoint/backend/build.sh | 23 +++ .../weaveworks/go-checkpoint/checkpoint.go | 73 +++++++-- .../go-checkpoint/checkpoint_test.go | 153 ++++++++++++------ .../weaveworks/go-checkpoint/circle.yml | 13 ++ vendor/manifest | 2 +- 8 files changed, 254 insertions(+), 81 deletions(-) create mode 100644 vendor/github.com/weaveworks/go-checkpoint/backend/Dockerfile create mode 100644 vendor/github.com/weaveworks/go-checkpoint/backend/build.sh create mode 100644 vendor/github.com/weaveworks/go-checkpoint/circle.yml diff --git a/vendor/github.com/weaveworks/go-checkpoint/Makefile b/vendor/github.com/weaveworks/go-checkpoint/Makefile index 129dad6e7..294727626 100644 --- a/vendor/github.com/weaveworks/go-checkpoint/Makefile +++ b/vendor/github.com/weaveworks/go-checkpoint/Makefile @@ -1,24 +1,36 @@ +.PHONY: all build test lint + BUILD_IN_CONTAINER ?= true RM=--rm -BUILD_IMAGE=golang:1.5.3 +BUILD_UPTODATE=backend/.image.uptodate +BUILD_IMAGE=checkpoint_build + +all: build + +$(BUILD_UPTODATE): backend/* + docker build -t $(BUILD_IMAGE) backend + touch $@ ifeq ($(BUILD_IN_CONTAINER),true) -all test: +build test lint: $(BUILD_UPTODATE) $(SUDO) docker run $(RM) -ti \ -v $(shell pwd):/go/src/github.com/weaveworks/go-checkpoint \ -e GOARCH -e GOOS -e BUILD_IN_CONTAINER=false \ - $(BUILD_IMAGE) make -C /go/src/github.com/weaveworks/go-checkpoint $@ + $(BUILD_IMAGE) $@ else -all: +build: go get . go build . test: - go get . + go get -t . go test +lint: + ./tools/lint -notestpackage . + endif diff --git a/vendor/github.com/weaveworks/go-checkpoint/README.md b/vendor/github.com/weaveworks/go-checkpoint/README.md index ab8ebc0d3..f82a37095 100644 --- a/vendor/github.com/weaveworks/go-checkpoint/README.md +++ b/vendor/github.com/weaveworks/go-checkpoint/README.md @@ -1,22 +1,29 @@ # Go Checkpoint Client -[Checkpoint](http://checkpoint.hashicorp.com) is an internal service at -Hashicorp that we use to check version information, broadcoast security -bulletins, etc. +[![Circle CI](https://circleci.com/gh/weaveworks/go-checkpoint/tree/master.svg?style=shield)](https://circleci.com/gh/weaveworks/go-checkpoint/tree/master) -We understand that software making remote calls over the internet -for any reason can be undesirable. Because of this, Checkpoint can be -disabled in all of our software that includes it. You can view the source -of this client to see that we're not sending any private information. +Checkpoint is an internal service at +[Weaveworks](https://www.weave.works/) to check version information, +broadcast security bulletins, etc. This repository contains the client +code for accessing that service. It is a fork of +[Hashicorp's Go Checkpoint Client](https://github.com/hashicorp/go-checkpoint) +and is embedded in several +[Weaveworks open source projects](https://github.com/weaveworks/) and +proprietary software. + +We understand that software making remote calls over the internet for +any reason can be undesirable. Because of this, Checkpoint can be +disabled in all of Weavework's software that includes it. You can view +the source of this client to see that it is not sending any private +information. + +To disable checkpoint calls, set the `CHECKPOINT_DISABLE` environment +variable, e.g. -Each Hashicorp application has it's specific configuration option -to disable chekpoint calls, but the `CHECKPOINT_DISABLE` makes -the underlying checkpoint component itself disabled. For example -in the case of packer: ``` -CHECKPOINT_DISABLE=1 packer build +export CHECKPOINT_DISABLE=1 ``` -**Note:** This repository is probably useless outside of internal HashiCorp -use. It is open source for disclosure and because our open source projects -must be able to link to it. +**Note:** This repository is probably useless outside of internal +Weaveworks use. It is open source for disclosure and because +Weaveworks open source projects must be able to link to it. diff --git a/vendor/github.com/weaveworks/go-checkpoint/backend/Dockerfile b/vendor/github.com/weaveworks/go-checkpoint/backend/Dockerfile new file mode 100644 index 000000000..f3a5a7fc7 --- /dev/null +++ b/vendor/github.com/weaveworks/go-checkpoint/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.6.2 +RUN apt-get update && \ + apt-get install -y python-requests time file sudo && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +RUN go get -tags netgo \ + github.com/fzipp/gocyclo \ + github.com/golang/lint/golint \ + github.com/kisielk/errcheck \ + github.com/client9/misspell/cmd/misspell && \ + rm -rf /go/pkg/ /go/src/ +COPY build.sh / +ENTRYPOINT ["/build.sh"] diff --git a/vendor/github.com/weaveworks/go-checkpoint/backend/build.sh b/vendor/github.com/weaveworks/go-checkpoint/backend/build.sh new file mode 100644 index 000000000..7a6864086 --- /dev/null +++ b/vendor/github.com/weaveworks/go-checkpoint/backend/build.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +set -eu + +SRC=$GOPATH/src/github.com/weaveworks/go-checkpoint + +# Mount the checkpoint repo: +# -v $(pwd):/go/src/github.com/weaveworks/checkpoint + +# If we run make directly, any files created on the bind mount +# will have awkward ownership. So we switch to a user with the +# same user and group IDs as source directory. We have to set a +# few things up so that sudo works without complaining later on. +uid=$(stat --format="%u" $SRC) +gid=$(stat --format="%g" $SRC) +echo "weave:x:$uid:$gid::$SRC:/bin/sh" >>/etc/passwd +echo "weave:*:::::::" >>/etc/shadow +echo "weave ALL=(ALL) NOPASSWD: ALL" >>/etc/sudoers + +chmod o+rw $GOPATH/src +chmod o+rw $GOPATH/src/github.com + +su weave -c "PATH=$PATH make -C $SRC BUILD_IN_CONTAINER=false $*" diff --git a/vendor/github.com/weaveworks/go-checkpoint/checkpoint.go b/vendor/github.com/weaveworks/go-checkpoint/checkpoint.go index dbff7d20f..423640c6d 100644 --- a/vendor/github.com/weaveworks/go-checkpoint/checkpoint.go +++ b/vendor/github.com/weaveworks/go-checkpoint/checkpoint.go @@ -1,5 +1,5 @@ -// checkpoint is a package for checking version information and alerts -// for a HashiCorp product. +// Package checkpoint is a package for checking version information and alerts +// for a Weaveworks product. package checkpoint import ( @@ -19,12 +19,13 @@ import ( "reflect" "runtime" "strings" + "sync" "time" "github.com/hashicorp/go-cleanhttp" ) -var magicBytes [4]byte = [4]byte{0x35, 0x77, 0x69, 0xFB} +var magicBytes = [4]byte{0x35, 0x77, 0x69, 0xFB} // CheckParams are the parameters for configuring a check request. type CheckParams struct { @@ -34,6 +35,9 @@ type CheckParams struct { Product string Version string + // Generic product flags + Flags map[string]string + // Arch and OS are used to filter alerts potentially only to things // affecting a specific os/arch combination. If these aren't specified, // they'll be automatically filled in. @@ -95,9 +99,16 @@ type CheckAlert struct { Level string } +// Checker is a state of a checker. +type Checker struct { + doneCh chan struct{} + nextCheckAt time.Time + nextCheckAtLock sync.RWMutex +} + // Check checks for alerts and new version information. func Check(p *CheckParams) (*CheckResponse, error) { - if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" && !p.Force { + if IsCheckDisabled() && !p.Force { return &CheckResponse{}, nil } @@ -138,6 +149,9 @@ func Check(p *CheckParams) (*CheckResponse, error) { v.Set("arch", p.Arch) v.Set("os", p.OS) v.Set("signature", signature) + for flag, value := range p.Flags { + v.Set("flag_"+flag, value) + } u.Scheme = "https" u.Host = "checkpoint-api.weave.works" @@ -193,27 +207,56 @@ func Check(p *CheckParams) (*CheckResponse, error) { // CheckInterval is used to check for a response on a given interval duration. // The interval is not exact, and checks are randomized to prevent a thundering // herd. However, it is expected that on average one check is performed per -// interval. The returned channel may be closed to stop background checks. -func CheckInterval(p *CheckParams, interval time.Duration, cb func(*CheckResponse, error)) chan struct{} { - doneCh := make(chan struct{}) +// interval. +// The first check happens immediately after a goroutine which is responsible for +// making checks has been started. +func CheckInterval(p *CheckParams, interval time.Duration, + cb func(*CheckResponse, error)) *Checker { - if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" { - return doneCh + state := &Checker{ + doneCh: make(chan struct{}), + } + + if IsCheckDisabled() { + return state } go func() { + cb(Check(p)) + for { + after := randomStagger(interval) + state.nextCheckAtLock.Lock() + state.nextCheckAt = time.Now().Add(after) + state.nextCheckAtLock.Unlock() + select { - case <-time.After(randomStagger(interval)): - resp, err := Check(p) - cb(resp, err) - case <-doneCh: + case <-time.After(after): + cb(Check(p)) + case <-state.doneCh: return } } }() - return doneCh + return state +} + +// NextCheckAt returns at what time next check will happen. +func (c *Checker) NextCheckAt() time.Time { + c.nextCheckAtLock.RLock() + defer c.nextCheckAtLock.RUnlock() + return c.nextCheckAt +} + +// Stop stops the checker. +func (c *Checker) Stop() { + close(c.doneCh) +} + +// IsCheckDisabled returns true if checks are disabled. +func IsCheckDisabled() bool { + return os.Getenv("CHECKPOINT_DISABLE") != "" } // randomStagger returns an interval that is between 3/4 and 5/4 of @@ -369,7 +412,7 @@ func writeCacheHeader(f io.Writer, v string) error { } // Write out our current version length - var length uint32 = uint32(len(v)) + var length = uint32(len(v)) if err := binary.Write(f, binary.LittleEndian, length); err != nil { return err } diff --git a/vendor/github.com/weaveworks/go-checkpoint/checkpoint_test.go b/vendor/github.com/weaveworks/go-checkpoint/checkpoint_test.go index 6d0a5addc..b81855556 100644 --- a/vendor/github.com/weaveworks/go-checkpoint/checkpoint_test.go +++ b/vendor/github.com/weaveworks/go-checkpoint/checkpoint_test.go @@ -11,19 +11,47 @@ import ( func TestCheck(t *testing.T) { expected := &CheckResponse{ - Product: "test", - CurrentVersion: "1.0", - CurrentReleaseDate: 0, - CurrentDownloadURL: "http://www.hashicorp.com", - CurrentChangelogURL: "http://www.hashicorp.com", - ProjectWebsite: "http://www.hashicorp.com", + CurrentVersion: "1.0.0", + CurrentReleaseDate: 1460459932, // 2016-04-12 11:18:52 + CurrentDownloadURL: "https://test-app.used-for-testing", + CurrentChangelogURL: "https://test-app.used-for-testing", + ProjectWebsite: "https://test-app.used-for-testing", Outdated: false, - Alerts: []*CheckAlert{}, + Alerts: nil, } actual, err := Check(&CheckParams{ - Product: "test", - Version: "1.0", + Product: "test-app", + Version: "1.0.0", + }) + + if err != nil { + t.Fatalf("err: %s", err) + } + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: %#v", actual) + } +} + +func TestCheck_flags(t *testing.T) { + expected := &CheckResponse{ + CurrentVersion: "1.0.0", + CurrentReleaseDate: 1460459932, // 2016-04-12 11:18:52 + CurrentDownloadURL: "https://test-app.used-for-testing", + CurrentChangelogURL: "https://test-app.used-for-testing", + ProjectWebsite: "https://test-app.used-for-testing", + Outdated: false, + Alerts: nil, + } + + actual, err := Check(&CheckParams{ + Product: "test-app", + Version: "1.0.0", + Flags: map[string]string{ + "flag1": "value1", + "flag2": "value2", + }, }) if err != nil { @@ -42,8 +70,8 @@ func TestCheck_disabled(t *testing.T) { expected := &CheckResponse{} actual, err := Check(&CheckParams{ - Product: "test", - Version: "1.0", + Product: "test-app", + Version: "1.0.0", }) if err != nil { @@ -62,22 +90,21 @@ func TestCheck_cache(t *testing.T) { } expected := &CheckResponse{ - Product: "test", - CurrentVersion: "1.0", - CurrentReleaseDate: 0, - CurrentDownloadURL: "http://www.hashicorp.com", - CurrentChangelogURL: "http://www.hashicorp.com", - ProjectWebsite: "http://www.hashicorp.com", + CurrentVersion: "1.0.0", + CurrentReleaseDate: 1460459932, // 2016-04-12 11:18:52 + CurrentDownloadURL: "https://test-app.used-for-testing", + CurrentChangelogURL: "https://test-app.used-for-testing", + ProjectWebsite: "https://test-app.used-for-testing", Outdated: false, - Alerts: []*CheckAlert{}, + Alerts: nil, } var actual *CheckResponse for i := 0; i < 5; i++ { var err error actual, err = Check(&CheckParams{ - Product: "test", - Version: "1.0", + Product: "test-app", + Version: "1.0.0", CacheFile: filepath.Join(dir, "cache"), }) if err != nil { @@ -86,7 +113,7 @@ func TestCheck_cache(t *testing.T) { } if !reflect.DeepEqual(actual, expected) { - t.Fatalf("bad: %#v", actual) + t.Fatalf("bad: %#v %#v", actual, expected) } } @@ -97,22 +124,21 @@ func TestCheck_cacheNested(t *testing.T) { } expected := &CheckResponse{ - Product: "test", - CurrentVersion: "1.0", - CurrentReleaseDate: 0, - CurrentDownloadURL: "http://www.hashicorp.com", - CurrentChangelogURL: "http://www.hashicorp.com", - ProjectWebsite: "http://www.hashicorp.com", + CurrentVersion: "1.0.0", + CurrentReleaseDate: 1460459932, // 2016-04-12 11:18:52 + CurrentDownloadURL: "https://test-app.used-for-testing", + CurrentChangelogURL: "https://test-app.used-for-testing", + ProjectWebsite: "https://test-app.used-for-testing", Outdated: false, - Alerts: []*CheckAlert{}, + Alerts: nil, } var actual *CheckResponse for i := 0; i < 5; i++ { var err error actual, err = Check(&CheckParams{ - Product: "test", - Version: "1.0", + Product: "test-app", + Version: "1.0.0", CacheFile: filepath.Join(dir, "nested", "cache"), }) if err != nil { @@ -127,19 +153,18 @@ func TestCheck_cacheNested(t *testing.T) { func TestCheckInterval(t *testing.T) { expected := &CheckResponse{ - Product: "test", - CurrentVersion: "1.0", - CurrentReleaseDate: 0, - CurrentDownloadURL: "http://www.hashicorp.com", - CurrentChangelogURL: "http://www.hashicorp.com", - ProjectWebsite: "http://www.hashicorp.com", + CurrentVersion: "1.0.0", + CurrentReleaseDate: 1460459932, // 2016-04-12 11:18:52 + CurrentDownloadURL: "https://test-app.used-for-testing", + CurrentChangelogURL: "https://test-app.used-for-testing", + ProjectWebsite: "https://test-app.used-for-testing", Outdated: false, - Alerts: []*CheckAlert{}, + Alerts: nil, } params := &CheckParams{ - Product: "test", - Version: "1.0", + Product: "test-app", + Version: "1.0.0", } calledCh := make(chan struct{}) @@ -154,8 +179,8 @@ func TestCheckInterval(t *testing.T) { } } - doneCh := CheckInterval(params, 500*time.Millisecond, checkFn) - defer close(doneCh) + st := CheckInterval(params, 500*time.Millisecond, checkFn) + defer st.Stop() select { case <-calledCh: @@ -169,8 +194,8 @@ func TestCheckInterval_disabled(t *testing.T) { defer os.Setenv("CHECKPOINT_DISABLE", "") params := &CheckParams{ - Product: "test", - Version: "1.0", + Product: "test-app", + Version: "1.0.0", } calledCh := make(chan struct{}) @@ -178,8 +203,8 @@ func TestCheckInterval_disabled(t *testing.T) { defer close(calledCh) } - doneCh := CheckInterval(params, 500*time.Millisecond, checkFn) - defer close(doneCh) + st := CheckInterval(params, 500*time.Millisecond, checkFn) + defer st.Stop() select { case <-calledCh: @@ -188,6 +213,44 @@ func TestCheckInterval_disabled(t *testing.T) { } } +func TestCheckInterval_immediate(t *testing.T) { + expected := &CheckResponse{ + CurrentVersion: "1.0.0", + CurrentReleaseDate: 1460459932, // 2016-04-12 11:18:52 + CurrentDownloadURL: "https://test-app.used-for-testing", + CurrentChangelogURL: "https://test-app.used-for-testing", + ProjectWebsite: "https://test-app.used-for-testing", + Outdated: false, + Alerts: nil, + } + + params := &CheckParams{ + Product: "test-app", + Version: "1.0.0", + } + + calledCh := make(chan struct{}) + checkFn := func(actual *CheckResponse, err error) { + defer close(calledCh) + if err != nil { + t.Fatalf("err: %s", err) + } + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: %#v", actual) + } + } + + st := CheckInterval(params, 500*time.Second, checkFn) + defer st.Stop() + + select { + case <-calledCh: + case <-time.After(time.Second): + t.Fatalf("timeout") + } +} + func TestRandomStagger(t *testing.T) { intv := 24 * time.Hour min := 18 * time.Hour diff --git a/vendor/github.com/weaveworks/go-checkpoint/circle.yml b/vendor/github.com/weaveworks/go-checkpoint/circle.yml new file mode 100644 index 000000000..497e52eb2 --- /dev/null +++ b/vendor/github.com/weaveworks/go-checkpoint/circle.yml @@ -0,0 +1,13 @@ +machine: + services: + - docker + +dependencies: + override: + - git submodule update --init --recursive + +test: + override: + - make RM= lint + - make + - make test diff --git a/vendor/manifest b/vendor/manifest index fce63bbec..fb5ce2097 100644 --- a/vendor/manifest +++ b/vendor/manifest @@ -872,7 +872,7 @@ { "importpath": "github.com/weaveworks/go-checkpoint", "repository": "https://github.com/weaveworks/go-checkpoint", - "revision": "d99cc14f13e7845b370a7dc81d47cafb29cdc97f", + "revision": "62324982ab514860761ec81e618664580513ffad", "branch": "master" }, { From 3598532a4cedd86d7393ba7b27222575345e06b6 Mon Sep 17 00:00:00 2001 From: Alfonso Acosta Date: Wed, 27 Apr 2016 17:27:26 +0000 Subject: [PATCH 05/19] Add kubernetes checkpoint flag --- prog/probe.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/prog/probe.go b/prog/probe.go index e572a2975..f18768d55 100644 --- a/prog/probe.go +++ b/prog/probe.go @@ -42,7 +42,7 @@ const ( var pluginAPIVersion = "1" -func check() { +func check(flags map[string]string) { handleResponse := func(r *checkpoint.CheckResponse, err error) { if err != nil { log.Errorf("Error checking version: %v", err) @@ -56,6 +56,7 @@ func check() { params := checkpoint.CheckParams{ Product: "scope-probe", Version: version, + Flags: flags, } resp, err := checkpoint.Check(¶ms) handleResponse(resp, err) @@ -87,7 +88,11 @@ func probeMain(flags probeFlags) { ) log.Infof("probe starting, version %s, ID %s", version, probeID) log.Infof("command line: %v", os.Args) - go check() + checkpointFlags := map[string]string{} + if flags.kubernetesEnabled { + checkpointFlags["kubernetes_enabled"] = "true" + } + go check(checkpointFlags) var targets = []string{fmt.Sprintf("localhost:%d", xfer.AppPort)} if len(flag.Args()) > 0 { From cb52acbc468e97b078db5913bcfcdca3309402d4 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Thu, 21 Apr 2016 17:34:08 +0100 Subject: [PATCH 06/19] Add pod delete control - Push shortcut reports when pods are created/deleted - Buffer upto 2 reports instead of dropping them --- probe/appclient/app_client.go | 3 +- probe/docker/controls.go | 4 +- probe/kubernetes/client.go | 62 ++++++++++++++------ probe/kubernetes/controls.go | 34 ++++++++--- probe/kubernetes/pod.go | 4 +- probe/kubernetes/reporter.go | 32 ++++++++++- probe/kubernetes/reporter_test.go | 14 +++-- probe/kubernetes/service.go | 6 +- probe/kubernetes/store.go | 94 +++++++++++++++++++++++++++++++ prog/probe.go | 2 +- render/pod.go | 7 ++- report/id.go | 9 +++ 12 files changed, 232 insertions(+), 39 deletions(-) create mode 100644 probe/kubernetes/store.go diff --git a/probe/appclient/app_client.go b/probe/appclient/app_client.go index c2fec8b2c..31837093b 100644 --- a/probe/appclient/app_client.go +++ b/probe/appclient/app_client.go @@ -75,7 +75,7 @@ func NewAppClient(pc ProbeConfig, hostname, target string, control xfer.ControlH TLSClientConfig: httpTransport.TLSClientConfig, }, conns: map[string]xfer.Websocket{}, - readers: make(chan io.Reader), + readers: make(chan io.Reader, 2), control: control, }, nil } @@ -273,6 +273,7 @@ func (c *appClient) Publish(r io.Reader) error { select { case c.readers <- r: default: + log.Errorf("Dropping report to %s", c.target) } return nil } diff --git a/probe/docker/controls.go b/probe/docker/controls.go index de2f4fe30..8d681b3e6 100644 --- a/probe/docker/controls.go +++ b/probe/docker/controls.go @@ -49,7 +49,7 @@ func (r *registry) unpauseContainer(containerID string, _ xfer.Request) xfer.Res return xfer.ResponseError(r.client.UnpauseContainer(containerID)) } -func (r *registry) removeContainer(containerID string, _ xfer.Request) xfer.Response { +func (r *registry) removeContainer(containerID string, req xfer.Request) xfer.Response { log.Infof("Removing container %s", containerID) if err := r.client.RemoveContainer(docker_client.RemoveContainerOptions{ ID: containerID, @@ -57,7 +57,7 @@ func (r *registry) removeContainer(containerID string, _ xfer.Request) xfer.Resp return xfer.ResponseError(err) } return xfer.Response{ - RemovedNode: containerID, + RemovedNode: req.NodeID, } } diff --git a/probe/kubernetes/client.go b/probe/kubernetes/client.go index 7bcafa5b1..2b6129f0c 100644 --- a/probe/kubernetes/client.go +++ b/probe/kubernetes/client.go @@ -3,6 +3,7 @@ package kubernetes import ( "io" "strconv" + "sync" "time" log "github.com/Sirupsen/logrus" @@ -26,7 +27,11 @@ type Client interface { WalkPods(f func(Pod) error) error WalkServices(f func(Service) error) error WalkNodes(f func(*api.Node) error) error + + WatchPods(f func(Event, Pod)) + GetLogs(namespaceID, podID string) (io.ReadCloser, error) + DeletePod(namespaceID, podID string) error } type client struct { @@ -38,6 +43,9 @@ type client struct { podStore *cache.StoreToPodLister serviceStore *cache.StoreToServiceLister nodeStore *cache.StoreToNodeLister + + podWatchesMutex sync.Mutex + podWatches []func(Event, Pod) } // runReflectorUntil is equivalent to cache.Reflector.RunUntil, but it also logs @@ -72,33 +80,44 @@ func NewClient(addr string, resyncPeriod time.Duration) (Client, error) { return nil, err } + result := &client{ + quit: make(chan struct{}), + client: c, + } + podListWatch := cache.NewListWatchFromClient(c, "pods", api.NamespaceAll, fields.Everything()) - podStore := cache.NewStore(cache.MetaNamespaceKeyFunc) - podReflector := cache.NewReflector(podListWatch, &api.Pod{}, podStore, resyncPeriod) + podStore := NewEventStore(result.triggerPodWatches, cache.MetaNamespaceKeyFunc) + result.podStore = &cache.StoreToPodLister{Store: podStore} + result.podReflector = cache.NewReflector(podListWatch, &api.Pod{}, podStore, resyncPeriod) serviceListWatch := cache.NewListWatchFromClient(c, "services", api.NamespaceAll, fields.Everything()) serviceStore := cache.NewStore(cache.MetaNamespaceKeyFunc) - serviceReflector := cache.NewReflector(serviceListWatch, &api.Service{}, serviceStore, resyncPeriod) + result.serviceStore = &cache.StoreToServiceLister{Store: serviceStore} + result.serviceReflector = cache.NewReflector(serviceListWatch, &api.Service{}, serviceStore, resyncPeriod) nodeListWatch := cache.NewListWatchFromClient(c, "nodes", api.NamespaceAll, fields.Everything()) nodeStore := cache.NewStore(cache.MetaNamespaceKeyFunc) - nodeReflector := cache.NewReflector(nodeListWatch, &api.Node{}, nodeStore, resyncPeriod) + result.nodeStore = &cache.StoreToNodeLister{Store: nodeStore} + result.nodeReflector = cache.NewReflector(nodeListWatch, &api.Node{}, nodeStore, resyncPeriod) - quit := make(chan struct{}) - runReflectorUntil(podReflector, resyncPeriod, quit) - runReflectorUntil(serviceReflector, resyncPeriod, quit) - runReflectorUntil(nodeReflector, resyncPeriod, quit) + runReflectorUntil(result.podReflector, resyncPeriod, result.quit) + runReflectorUntil(result.serviceReflector, resyncPeriod, result.quit) + runReflectorUntil(result.nodeReflector, resyncPeriod, result.quit) + return result, nil +} - return &client{ - quit: quit, - client: c, - podReflector: podReflector, - podStore: &cache.StoreToPodLister{Store: podStore}, - serviceReflector: serviceReflector, - serviceStore: &cache.StoreToServiceLister{Store: serviceStore}, - nodeReflector: nodeReflector, - nodeStore: &cache.StoreToNodeLister{Store: nodeStore}, - }, nil +func (c *client) WatchPods(f func(Event, Pod)) { + c.podWatchesMutex.Lock() + defer c.podWatchesMutex.Unlock() + c.podWatches = append(c.podWatches, f) +} + +func (c *client) triggerPodWatches(e Event, pod interface{}) { + c.podWatchesMutex.Lock() + defer c.podWatchesMutex.Unlock() + for _, watch := range c.podWatches { + watch(e, NewPod(pod.(*api.Pod))) + } } func (c *client) WalkPods(f func(Pod) error) error { @@ -152,6 +171,13 @@ func (c *client) GetLogs(namespaceID, podID string) (io.ReadCloser, error) { Stream() } +func (c *client) DeletePod(namespaceID, podID string) error { + return c.client.RESTClient.Delete(). + Namespace(namespaceID). + Name(podID). + Resource("pods").Do().Error() +} + func (c *client) Stop() { close(c.quit) } diff --git a/probe/kubernetes/controls.go b/probe/kubernetes/controls.go index ef1b9ad6f..df139bf42 100644 --- a/probe/kubernetes/controls.go +++ b/probe/kubernetes/controls.go @@ -11,16 +11,12 @@ import ( // Control IDs used by the kubernetes integration. const ( - GetLogs = "kubernetes_get_logs" + GetLogs = "kubernetes_get_logs" + DeletePod = "kubernetes_delete_pod" ) // GetLogs is the control to get the logs for a kubernetes pod -func (r *Reporter) GetLogs(req xfer.Request) xfer.Response { - namespaceID, podID, ok := report.ParsePodNodeID(req.NodeID) - if !ok { - return xfer.ResponseErrorf("Invalid ID: %s", req.NodeID) - } - +func (r *Reporter) GetLogs(req xfer.Request, namespaceID, podID string) xfer.Response { readCloser, err := r.client.GetLogs(namespaceID, podID) if err != nil { return xfer.ResponseError(err) @@ -45,10 +41,32 @@ func (r *Reporter) GetLogs(req xfer.Request) xfer.Response { } } +func (r *Reporter) deletePod(req xfer.Request, namespaceID, podID string) xfer.Response { + if err := r.client.DeletePod(namespaceID, podID); err != nil { + return xfer.ResponseError(err) + } + return xfer.Response{ + RemovedNode: req.NodeID, + } +} + +// CapturePod is exported for testing +func CapturePod(f func(xfer.Request, string, string) xfer.Response) func(xfer.Request) xfer.Response { + return func(req xfer.Request) xfer.Response { + namespaceID, podID, ok := report.ParsePodNodeID(req.NodeID) + if !ok { + return xfer.ResponseErrorf("Invalid ID: %s", req.NodeID) + } + return f(req, namespaceID, podID) + } +} + func (r *Reporter) registerControls() { - controls.Register(GetLogs, r.GetLogs) + controls.Register(GetLogs, CapturePod(r.GetLogs)) + controls.Register(DeletePod, CapturePod(r.deletePod)) } func (r *Reporter) deregisterControls() { controls.Rm(GetLogs) + controls.Rm(DeletePod) } diff --git a/probe/kubernetes/pod.go b/probe/kubernetes/pod.go index 09a57f382..badea86c7 100644 --- a/probe/kubernetes/pod.go +++ b/probe/kubernetes/pod.go @@ -18,6 +18,8 @@ const ( PodState = "kubernetes_pod_state" PodLabelPrefix = "kubernetes_pod_labels_" ServiceIDs = "kubernetes_service_ids" + + StateDeleted = "deleted" ) // Pod represents a Kubernetes pod @@ -107,6 +109,6 @@ func (p *pod) GetNode(probeID string) report.Node { ) } n = n.AddTable(PodLabelPrefix, p.ObjectMeta.Labels) - n = n.WithControls(GetLogs) + n = n.WithControls(GetLogs, DeletePod) return n } diff --git a/probe/kubernetes/reporter.go b/probe/kubernetes/reporter.go index 7010070e1..ad47c0530 100644 --- a/probe/kubernetes/reporter.go +++ b/probe/kubernetes/reporter.go @@ -8,6 +8,7 @@ import ( "k8s.io/kubernetes/pkg/api" "k8s.io/kubernetes/pkg/labels" + "github.com/weaveworks/scope/probe" "github.com/weaveworks/scope/probe/controls" "github.com/weaveworks/scope/probe/docker" "github.com/weaveworks/scope/report" @@ -44,16 +45,19 @@ type Reporter struct { client Client pipes controls.PipeClient probeID string + probe *probe.Probe } // NewReporter makes a new Reporter -func NewReporter(client Client, pipes controls.PipeClient, probeID string) *Reporter { +func NewReporter(client Client, pipes controls.PipeClient, probeID string, probe *probe.Probe) *Reporter { reporter := &Reporter{ client: client, pipes: pipes, probeID: probeID, + probe: probe, } reporter.registerControls() + client.WatchPods(reporter.podEvent) return reporter } @@ -65,6 +69,26 @@ func (r *Reporter) Stop() { // Name of this reporter, for metrics gathering func (Reporter) Name() string { return "K8s" } +func (r *Reporter) podEvent(e Event, pod Pod) { + switch e { + case ADD: + rpt := report.MakeReport() + rpt.Shortcut = true + rpt.Pod.AddNode(pod.GetNode(r.probeID)) + r.probe.Publish(rpt) + case DELETE: + rpt := report.MakeReport() + rpt.Shortcut = true + rpt.Pod.AddNode( + report.MakeNodeWith( + report.MakePodNodeID(pod.Namespace(), pod.Name()), + map[string]string{PodState: StateDeleted}, + ), + ) + r.probe.Publish(rpt) + } +} + // Report generates a Report containing Container and ContainerImage topologies func (r *Reporter) Report() (report.Report, error) { result := report.MakeReport() @@ -132,6 +156,12 @@ func (r *Reporter) podTopology(services []Service) (report.Topology, report.Topo Icon: "fa-desktop", Rank: 0, }) + pods.Controls.AddControl(report.Control{ + ID: DeletePod, + Human: "Delete", + Icon: "fa-trash-o", + Rank: 1, + }) for _, service := range services { selectors[service.ID()] = service.Selector() } diff --git a/probe/kubernetes/reporter_test.go b/probe/kubernetes/reporter_test.go index a3610e744..ec6d26d01 100644 --- a/probe/kubernetes/reporter_test.go +++ b/probe/kubernetes/reporter_test.go @@ -125,6 +125,7 @@ func (c *mockClient) WalkServices(f func(kubernetes.Service) error) error { func (*mockClient) WalkNodes(f func(*api.Node) error) error { return nil } +func (*mockClient) WatchPods(func(kubernetes.Event, kubernetes.Pod)) {} func (c *mockClient) GetLogs(namespaceID, podName string) (io.ReadCloser, error) { r, ok := c.logs[report.MakePodNodeID(namespaceID, podName)] if !ok { @@ -132,6 +133,9 @@ func (c *mockClient) GetLogs(namespaceID, podName string) (io.ReadCloser, error) } return r, nil } +func (c *mockClient) DeletePod(namespaceID, podID string) error { + return nil +} type mockPipeClient map[string]xfer.Pipe @@ -156,7 +160,7 @@ func TestReporter(t *testing.T) { pod1ID := report.MakePodNodeID("ping", "pong-a") pod2ID := report.MakePodNodeID("ping", "pong-b") serviceID := report.MakeServiceNodeID("ping", "pongservice") - rpt, _ := kubernetes.NewReporter(newMockClient(), nil, "").Report() + rpt, _ := kubernetes.NewReporter(newMockClient(), nil, "", nil).Report() // Reporter should have added the following pods for _, pod := range []struct { @@ -261,11 +265,11 @@ func TestReporterGetLogs(t *testing.T) { client := newMockClient() pipes := mockPipeClient{} - reporter := kubernetes.NewReporter(client, pipes, "") + reporter := kubernetes.NewReporter(client, pipes, "", nil) // Should error on invalid IDs { - resp := reporter.GetLogs(xfer.Request{ + resp := kubernetes.CapturePod(reporter.GetLogs)(xfer.Request{ NodeID: "invalidID", Control: kubernetes.GetLogs, }) @@ -276,7 +280,7 @@ func TestReporterGetLogs(t *testing.T) { // Should pass through errors from k8s (e.g if pod does not exist) { - resp := reporter.GetLogs(xfer.Request{ + resp := kubernetes.CapturePod(reporter.GetLogs)(xfer.Request{ AppID: "appID", NodeID: report.MakePodNodeID("not", "found"), Control: kubernetes.GetLogs, @@ -302,7 +306,7 @@ func TestReporterGetLogs(t *testing.T) { }} // Should create a new pipe for the stream - resp := reporter.GetLogs(pod1Request) + resp := kubernetes.CapturePod(reporter.GetLogs)(pod1Request) if resp.Pipe == "" { t.Errorf("Expected pipe id to be returned, but got %#v", resp) } diff --git a/probe/kubernetes/service.go b/probe/kubernetes/service.go index d2bd38dfa..99886303d 100644 --- a/probe/kubernetes/service.go +++ b/probe/kubernetes/service.go @@ -66,5 +66,9 @@ func (s *service) GetNode() report.Node { if s.Spec.LoadBalancerIP != "" { latest[ServicePublicIP] = s.Spec.LoadBalancerIP } - return report.MakeNodeWith(report.MakeServiceNodeID(s.Namespace(), s.Name()), latest).AddTable(ServiceLabelPrefix, s.Labels) + return report.MakeNodeWith( + report.MakeServiceNodeID(s.Namespace(), s.Name()), + latest, + ). + AddTable(ServiceLabelPrefix, s.Labels) } diff --git a/probe/kubernetes/store.go b/probe/kubernetes/store.go new file mode 100644 index 000000000..56f5ae609 --- /dev/null +++ b/probe/kubernetes/store.go @@ -0,0 +1,94 @@ +package kubernetes + +import ( + "sync" + + "k8s.io/kubernetes/pkg/client/cache" +) + +// Event type is an enum of ADD, UPDATE and DELETE +type Event int + +// Watch type is for callbacks when somethings happens to the store. +type Watch func(Event, interface{}) + +// Event enum values. +const ( + ADD Event = iota + UPDATE + DELETE +) + +type eventStore struct { + mtx sync.Mutex + watch Watch + keyFunc cache.KeyFunc + cache.Store +} + +// NewEventStore creates a new Store which triggers watch whenever +// an object is added, removed or updated. +func NewEventStore(watch Watch, keyFunc cache.KeyFunc) cache.Store { + return &eventStore{ + keyFunc: keyFunc, + watch: watch, + Store: cache.NewStore(keyFunc), + } +} + +func (e *eventStore) Add(o interface{}) error { + e.mtx.Lock() + defer e.mtx.Unlock() + e.watch(ADD, o) + return e.Store.Add(o) +} + +func (e *eventStore) Update(o interface{}) error { + e.mtx.Lock() + defer e.mtx.Unlock() + e.watch(UPDATE, o) + return e.Store.Update(o) +} + +func (e *eventStore) Delete(o interface{}) error { + e.mtx.Lock() + defer e.mtx.Unlock() + e.watch(DELETE, o) + return e.Store.Delete(o) +} + +func (e *eventStore) Replace(os []interface{}, ver string) error { + e.mtx.Lock() + defer e.mtx.Unlock() + + indexed := map[string]interface{}{} + for _, o := range os { + key, err := e.keyFunc(o) + if err != nil { + return err + } + indexed[key] = o + } + + existing := map[string]interface{}{} + for _, o := range e.Store.List() { + key, err := e.keyFunc(o) + if err != nil { + return err + } + existing[key] = o + if _, ok := indexed[key]; !ok { + e.watch(DELETE, o) + } + } + + for key, o := range indexed { + if _, ok := existing[key]; !ok { + e.watch(ADD, o) + } else { + e.watch(UPDATE, o) + } + } + + return e.Store.Replace(os, ver) +} diff --git a/prog/probe.go b/prog/probe.go index e572a2975..1d4f321d8 100644 --- a/prog/probe.go +++ b/prog/probe.go @@ -144,7 +144,7 @@ func probeMain(flags probeFlags) { if flags.kubernetesEnabled { if client, err := kubernetes.NewClient(flags.kubernetesAPI, flags.kubernetesInterval); err == nil { defer client.Stop() - reporter := kubernetes.NewReporter(client, clients, probeID) + reporter := kubernetes.NewReporter(client, clients, probeID, p) defer reporter.Stop() p.AddReporter(reporter) } else { diff --git a/render/pod.go b/render/pod.go index 623eea80e..6d70be10f 100644 --- a/render/pod.go +++ b/render/pod.go @@ -15,7 +15,12 @@ const ( // PodRenderer is a Renderer which produces a renderable kubernetes // graph by merging the container graph and the pods topology. -var PodRenderer = FilterEmpty(report.Container, +var PodRenderer = MakeFilter( + func(n report.Node) bool { + // Drop deleted containers + state, ok := n.Latest.Lookup(kubernetes.PodState) + return HasChildren(report.Container)(n) && (!ok || state != kubernetes.StateDeleted) + }, MakeReduce( MakeFilter( func(n report.Node) bool { diff --git a/report/id.go b/report/id.go index 2ffc0b2a3..b3f0170ac 100644 --- a/report/id.go +++ b/report/id.go @@ -175,6 +175,15 @@ func ParsePodNodeID(podNodeID string) (namespaceID, podID string, ok bool) { return fields[0], fields[1], true } +// ParseServiceNodeID produces the namespace ID and service ID from an service node ID. +func ParseServiceNodeID(serviceNodeID string) (namespaceID, serviceID string, ok bool) { + fields := strings.SplitN(serviceNodeID, ScopeDelim, 2) + if len(fields) != 2 { + return "", "", false + } + return fields[0], fields[1], true +} + // ExtractHostID extracts the host id from Node func ExtractHostID(m Node) string { hostNodeID, _ := m.Latest.Lookup(HostNodeID) From 85939d12485513b06e89245ded9af6a2745b75aa Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 28 Apr 2016 20:00:42 +0200 Subject: [PATCH 07/19] Add /dev.html page w/ full redux dev tools * normal page wont have store instrumented to stay lean * reshuffled vendor deps to balance bundle sizes --- client/app/scripts/main.dev.js | 2 +- client/app/scripts/main.js | 27 +++++++++++++++---- client/app/scripts/main.prod.js | 22 --------------- client/app/scripts/stores/configureStore.js | 15 ++++++++--- .../app/scripts/stores/configureStore.prod.js | 12 --------- client/webpack.local.config.js | 17 +++++++++--- client/webpack.production.config.js | 5 ++-- 7 files changed, 51 insertions(+), 49 deletions(-) delete mode 100644 client/app/scripts/main.prod.js delete mode 100644 client/app/scripts/stores/configureStore.prod.js diff --git a/client/app/scripts/main.dev.js b/client/app/scripts/main.dev.js index 1aa991bb1..377885d63 100644 --- a/client/app/scripts/main.dev.js +++ b/client/app/scripts/main.dev.js @@ -7,7 +7,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; -import configureStore from './stores/configureStore'; +import configureStore from './stores/configureStore.dev'; import App from './components/app'; import DevTools from './components/dev-tools'; diff --git a/client/app/scripts/main.js b/client/app/scripts/main.js index 5c3028e81..0132b87bb 100644 --- a/client/app/scripts/main.js +++ b/client/app/scripts/main.js @@ -1,5 +1,22 @@ -if (process.env.NODE_ENV === 'production') { - module.exports = require('./main.prod'); -} else { - module.exports = require('./main.dev'); -} +require('font-awesome-webpack'); +require('../styles/main.less'); +require('../images/favicon.ico'); + +import 'babel-polyfill'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; + +import configureStore from './stores/configureStore'; +import App from './components/app'; + +const store = configureStore(); + +ReactDOM.render( + +
+ +
+
, + document.getElementById('app') +); diff --git a/client/app/scripts/main.prod.js b/client/app/scripts/main.prod.js deleted file mode 100644 index 0132b87bb..000000000 --- a/client/app/scripts/main.prod.js +++ /dev/null @@ -1,22 +0,0 @@ -require('font-awesome-webpack'); -require('../styles/main.less'); -require('../images/favicon.ico'); - -import 'babel-polyfill'; -import React from 'react'; -import ReactDOM from 'react-dom'; -import { Provider } from 'react-redux'; - -import configureStore from './stores/configureStore'; -import App from './components/app'; - -const store = configureStore(); - -ReactDOM.render( - -
- -
-
, - document.getElementById('app') -); diff --git a/client/app/scripts/stores/configureStore.js b/client/app/scripts/stores/configureStore.js index 78c9ea1fd..f61d8a847 100644 --- a/client/app/scripts/stores/configureStore.js +++ b/client/app/scripts/stores/configureStore.js @@ -1,5 +1,12 @@ -if (process.env.NODE_ENV === 'production') { - module.exports = require('./configureStore.prod'); -} else { - module.exports = require('./configureStore.dev'); +import { createStore, applyMiddleware } from 'redux'; +import thunkMiddleware from 'redux-thunk'; + +import { initialState, rootReducer } from '../reducers/root'; + +export default function configureStore() { + return createStore( + rootReducer, + initialState, + applyMiddleware(thunkMiddleware) + ); } diff --git a/client/app/scripts/stores/configureStore.prod.js b/client/app/scripts/stores/configureStore.prod.js deleted file mode 100644 index f61d8a847..000000000 --- a/client/app/scripts/stores/configureStore.prod.js +++ /dev/null @@ -1,12 +0,0 @@ -import { createStore, applyMiddleware } from 'redux'; -import thunkMiddleware from 'redux-thunk'; - -import { initialState, rootReducer } from '../reducers/root'; - -export default function configureStore() { - return createStore( - rootReducer, - initialState, - applyMiddleware(thunkMiddleware) - ); -} diff --git a/client/webpack.local.config.js b/client/webpack.local.config.js index d669320d7..95ae08f87 100644 --- a/client/webpack.local.config.js +++ b/client/webpack.local.config.js @@ -29,6 +29,11 @@ module.exports = { 'webpack-dev-server/client?http://' + WEBPACK_SERVER_HOST + ':4041', 'webpack/hot/only-dev-server' ], + 'dev-app': [ + './app/scripts/main.dev', + 'webpack-dev-server/client?http://' + WEBPACK_SERVER_HOST + ':4041', + 'webpack/hot/only-dev-server' + ], 'contrast-app': [ './app/scripts/contrast-main', 'webpack-dev-server/client?http://' + WEBPACK_SERVER_HOST + ':4041', @@ -39,9 +44,10 @@ module.exports = { 'webpack-dev-server/client?http://' + WEBPACK_SERVER_HOST + ':4041', 'webpack/hot/only-dev-server' ], - vendors: ['babel-polyfill', 'classnames', 'd3', 'dagre', 'immutable', - 'lodash', 'page', 'react', 'react-dom', 'react-redux', 'react-motion', - 'redux', 'redux-thunk'] + vendors: ['babel-polyfill', 'classnames', 'd3', 'dagre', 'filesize', + 'immutable', 'lodash', 'moment', 'page', 'react', + 'react-dom', 'react-motion', 'react-redux', 'redux', 'redux-thunk', + 'reqwest'] }, // This will not actually create a app.js file in ./build. It is used @@ -66,6 +72,11 @@ module.exports = { template: 'app/html/index.html', filename: 'terminal.html' }), + new HtmlWebpackPlugin({ + chunks: ['vendors', 'dev-app'], + template: 'app/html/index.html', + filename: 'dev.html' + }), new HtmlWebpackPlugin({ chunks: ['vendors', 'app'], template: 'app/html/index.html', diff --git a/client/webpack.production.config.js b/client/webpack.production.config.js index 7d2d1c5ff..8b2fb4c14 100644 --- a/client/webpack.production.config.js +++ b/client/webpack.production.config.js @@ -24,8 +24,9 @@ module.exports = { app: './app/scripts/main', 'contrast-app': './app/scripts/contrast-main', 'terminal-app': './app/scripts/terminal-main', - vendors: ['babel-polyfill', 'classnames', 'd3', 'dagre', 'immutable', - 'lodash', 'page', 'react', 'react-dom', 'react-redux', 'react-motion', + // keep only some in here, to make vendors and app bundles roughly same size + vendors: ['babel-polyfill', 'classnames', 'd3', 'immutable', + 'lodash', 'react', 'react-dom', 'react-redux', 'redux', 'redux-thunk'] }, From eed779abfa4a4f6da15cb6a1c517b19637c33993 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 28 Apr 2016 20:20:58 +0200 Subject: [PATCH 08/19] Replaced pure-render-mixin with redux connect * does the same shallowEqual optimization --- client/app/scripts/charts/edge-container.js | 7 +++---- client/app/scripts/charts/node-container.js | 7 +++---- client/app/scripts/charts/nodes-chart-elements.js | 7 +++---- client/app/scripts/components/details-card.js | 7 +++---- client/app/scripts/components/show-more.js | 7 +++---- client/package.json | 2 -- 6 files changed, 15 insertions(+), 22 deletions(-) diff --git a/client/app/scripts/charts/edge-container.js b/client/app/scripts/charts/edge-container.js index d19723070..b0545329d 100644 --- a/client/app/scripts/charts/edge-container.js +++ b/client/app/scripts/charts/edge-container.js @@ -1,9 +1,8 @@ import _ from 'lodash'; import d3 from 'd3'; import React from 'react'; +import { connect } from 'react-redux'; import { Motion, spring } from 'react-motion'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; import { Map as makeMap } from 'immutable'; import Edge from './edge'; @@ -29,7 +28,7 @@ const buildPath = (points, layoutPrecision) => { return extracted; }; -export default class EdgeContainer extends React.Component { +class EdgeContainer extends React.Component { constructor(props, context) { super(props, context); @@ -96,4 +95,4 @@ export default class EdgeContainer extends React.Component { } -reactMixin.onClass(EdgeContainer, PureRenderMixin); +export default connect()(EdgeContainer); diff --git a/client/app/scripts/charts/node-container.js b/client/app/scripts/charts/node-container.js index 75506b290..74820303d 100644 --- a/client/app/scripts/charts/node-container.js +++ b/client/app/scripts/charts/node-container.js @@ -1,13 +1,12 @@ import _ from 'lodash'; import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import d3 from 'd3'; import { Motion, spring } from 'react-motion'; import Node from './node'; -export default class NodeContainer extends React.Component { +class NodeContainer extends React.Component { render() { const { dx, dy, focused, layoutPrecision, zoomScale } = this.props; const animConfig = [80, 20]; // stiffness, damping @@ -30,4 +29,4 @@ export default class NodeContainer extends React.Component { } } -reactMixin.onClass(NodeContainer, PureRenderMixin); +export default connect()(NodeContainer); diff --git a/client/app/scripts/charts/nodes-chart-elements.js b/client/app/scripts/charts/nodes-chart-elements.js index 5934bc1bc..7d921f0a8 100644 --- a/client/app/scripts/charts/nodes-chart-elements.js +++ b/client/app/scripts/charts/nodes-chart-elements.js @@ -1,11 +1,10 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import NodesChartEdges from './nodes-chart-edges'; import NodesChartNodes from './nodes-chart-nodes'; -export default class NodesChartElements extends React.Component { +class NodesChartElements extends React.Component { render() { const props = this.props; return ( @@ -20,4 +19,4 @@ export default class NodesChartElements extends React.Component { } } -reactMixin.onClass(NodesChartElements, PureRenderMixin); +export default connect()(NodesChartElements); diff --git a/client/app/scripts/components/details-card.js b/client/app/scripts/components/details-card.js index bfee43e9a..a8469625d 100644 --- a/client/app/scripts/components/details-card.js +++ b/client/app/scripts/components/details-card.js @@ -1,12 +1,11 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import NodeDetails from './node-details'; import { DETAILS_PANEL_WIDTH as WIDTH, DETAILS_PANEL_OFFSET as OFFSET, DETAILS_PANEL_MARGINS as MARGINS } from '../constants/styles'; -export default class DetailsCard extends React.Component { +class DetailsCard extends React.Component { constructor(props, context) { super(props, context); @@ -54,4 +53,4 @@ export default class DetailsCard extends React.Component { } } -reactMixin.onClass(DetailsCard, PureRenderMixin); +export default connect()(DetailsCard); diff --git a/client/app/scripts/components/show-more.js b/client/app/scripts/components/show-more.js index a9d17bff6..5578cf5ff 100644 --- a/client/app/scripts/components/show-more.js +++ b/client/app/scripts/components/show-more.js @@ -1,8 +1,7 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; -export default class ShowMore extends React.Component { +class ShowMore extends React.Component { constructor(props, context) { super(props, context); @@ -31,4 +30,4 @@ export default class ShowMore extends React.Component { } } -reactMixin.onClass(ShowMore, PureRenderMixin); +export default connect()(ShowMore); diff --git a/client/package.json b/client/package.json index c2e247d7b..f09870513 100644 --- a/client/package.json +++ b/client/package.json @@ -20,9 +20,7 @@ "moment": "2.12.0", "page": "1.7.0", "react": "^15.0.1", - "react-addons-pure-render-mixin": "^15.0.1", "react-dom": "^15.0.1", - "react-mixin": "^3.0.3", "react-motion": "0.3.1", "react-redux": "4.4.5", "redux": "3.5.1", From 9b5ac56214a42e6ae87218998e46b5e5d0d777b1 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Fri, 29 Apr 2016 10:31:24 +0100 Subject: [PATCH 09/19] Review feedback --- render/pod.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render/pod.go b/render/pod.go index 6d70be10f..c3cc7e77f 100644 --- a/render/pod.go +++ b/render/pod.go @@ -17,7 +17,7 @@ const ( // graph by merging the container graph and the pods topology. var PodRenderer = MakeFilter( func(n report.Node) bool { - // Drop deleted containers + // Drop deleted or empty pods state, ok := n.Latest.Lookup(kubernetes.PodState) return HasChildren(report.Container)(n) && (!ok || state != kubernetes.StateDeleted) }, From 3f4f9b70d17af247a190b643e4851786b3b7eac2 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Fri, 29 Apr 2016 10:32:18 +0100 Subject: [PATCH 10/19] Review feedback --- report/id.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/report/id.go b/report/id.go index b3f0170ac..2ffc0b2a3 100644 --- a/report/id.go +++ b/report/id.go @@ -175,15 +175,6 @@ func ParsePodNodeID(podNodeID string) (namespaceID, podID string, ok bool) { return fields[0], fields[1], true } -// ParseServiceNodeID produces the namespace ID and service ID from an service node ID. -func ParseServiceNodeID(serviceNodeID string) (namespaceID, serviceID string, ok bool) { - fields := strings.SplitN(serviceNodeID, ScopeDelim, 2) - if len(fields) != 2 { - return "", "", false - } - return fields[0], fields[1], true -} - // ExtractHostID extracts the host id from Node func ExtractHostID(m Node) string { hostNodeID, _ := m.Latest.Lookup(HostNodeID) From cf879b268e8f71885bf70a8c877afda0c0816905 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Thu, 28 Apr 2016 16:13:10 +0100 Subject: [PATCH 11/19] Aggressively pass nil for the decorator in the rendering pipeline to improve performance. --- README.md | 47 +++++++++++++++++++++++++------------------ app/api_topologies.go | 13 +++++++++--- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 05523e304..9c1e65263 100644 --- a/README.md +++ b/README.md @@ -325,40 +325,47 @@ kill -USR1 $(pgrep -f scope-probe) docker logs weavescope ``` -- Both the Scope App and the Scope Probe offer - [HTTP endpoints with profiling information](https://golang.org/pkg/net/http/pprof/). - These cover things such as CPU usage and memory consumption: - * The Scope App enables its HTTP profiling endpoints by default, which - are accessible on the same port the Scope UI is served (4040). - * The Scope Probe doesn't enable its profiling endpoints by default. - To enable them, you must launch Scope with `--probe.http.listen addr:port`. - For instance, launching Scope with `scope launch --probe.http.listen :4041`, will - allow you access the Scope Probe's profiling endpoints on port 4041. +Both the Scope App and the Scope Probe offer [HTTP endpoints with profiling information](https://golang.org/pkg/net/http/pprof/). +These cover things such as CPU usage and memory consumption: +- The Scope App enables its HTTP profiling endpoints by default, which + are accessible on the same port the Scope UI is served (4040). +- The Scope Probe doesn't enable its profiling endpoints by default. + To enable them, you must launch Scope with `--probe.http.listen addr:port`. + For instance, launching Scope with `scope launch --probe.http.listen :4041`, will + allow you access the Scope Probe's profiling endpoints on port 4041. - Then, you can collect profiles in the usual way. For instance: +Then, you can collect profiles in the usual way. For instance: - * To collect the memory profile of the Scope App: +- To collect the memory profile of the Scope App: - ``` +``` go tool pprof http://localhost:4040/debug/pprof/heap ``` - * To collect the CPU profile of the Scope Probe: - ``` +- To collect the CPU profile of the Scope Probe: + +``` go tool pprof http://localhost:4041/debug/pprof/profile ``` - If you don't have `go` installed, you can use a Docker container instead: +If you don't have `go` installed, you can use a Docker container instead: - * To collect the memory profile of the Scope App: +- To collect the memory profile of the Scope App: - ``` +``` docker run --net=host -v $PWD:/root/pprof golang go tool pprof http://localhost:4040/debug/pprof/heap ``` - * To collect the CPU profile of the Scope Probe: - ``` +- To collect the CPU profile of the Scope Probe: + +``` docker run --net=host -v $PWD:/root/pprof golang go tool pprof http://localhost:4041/debug/pprof/profile ``` - You will find the output profiles in your working directory. +You will find the output profiles in your working directory. To analyse the dump, do something like: + +``` +go tool pprof prog/scope pprof.localhost\:4040.samples.cpu.001.pb.gz +Entering interactive mode (type "help" for commands) +(pprof) pdf >cpu.pdf +``` diff --git a/app/api_topologies.go b/app/api_topologies.go index 9c400e34e..0d4a61fdd 100644 --- a/app/api_topologies.go +++ b/app/api_topologies.go @@ -73,7 +73,7 @@ func init() { Options: []APITopologyOption{ // Show the user why there are filtered nodes in this view. // Don't give them the option to show those nodes. - {"hide", "Unconnected nodes hidden", render.Noop}, + {"hide", "Unconnected nodes hidden", nil}, }, }, } @@ -285,14 +285,21 @@ func renderedForRequest(r *http.Request, topology APITopologyDesc) (render.Rende for _, group := range topology.Options { value := r.FormValue(group.ID) for _, opt := range group.Options { + if opt.filter == nil { + continue + } if (value == "" && group.Default == opt.Value) || (opt.Value != "" && opt.Value == value) { filters = append(filters, opt.filter) } } } - return topology.renderer, func(renderer render.Renderer) render.Renderer { - return render.MakeFilter(render.ComposeFilterFuncs(filters...), renderer) + var decorator render.Decorator + if len(filters) > 0 { + decorator = func(renderer render.Renderer) render.Renderer { + return render.MakeFilter(render.ComposeFilterFuncs(filters...), renderer) + } } + return topology.renderer, decorator } type reportRenderHandler func( From b4a59f6e3632e6d1c20b934bff2bb781d5d87ce6 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Fri, 29 Apr 2016 12:06:33 +0100 Subject: [PATCH 12/19] Don't recursively gets stats beyond an ApplyDecorators decorator --- render/render.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render/render.go b/render/render.go index 13d38c147..4741f4ec5 100644 --- a/render/render.go +++ b/render/render.go @@ -143,7 +143,7 @@ func (ad applyDecorator) Stats(rpt report.Report, dct Decorator) Stats { if dct != nil { return dct(ad.Renderer).Stats(rpt, nil) } - return ad.Renderer.Stats(rpt, nil) + return Stats{} } // ApplyDecorators returns a renderer which will apply the given decorators From e917dd61a871b42fe75e8b95115b7f91e77a1a4c Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Fri, 29 Apr 2016 11:35:33 +0100 Subject: [PATCH 13/19] Show Pod IP in details panel. --- probe/kubernetes/pod.go | 14 ++++++++------ probe/kubernetes/reporter.go | 5 +++-- render/detailed/node_test.go | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/probe/kubernetes/pod.go b/probe/kubernetes/pod.go index 09a57f382..8f4fab139 100644 --- a/probe/kubernetes/pod.go +++ b/probe/kubernetes/pod.go @@ -17,6 +17,7 @@ const ( PodContainerIDs = "kubernetes_pod_container_ids" PodState = "kubernetes_pod_state" PodLabelPrefix = "kubernetes_pod_labels_" + PodIP = "kubernetes_pod_ip" ServiceIDs = "kubernetes_service_ids" ) @@ -86,12 +87,13 @@ func (p *pod) NodeName() string { func (p *pod) GetNode(probeID string) report.Node { n := report.MakeNodeWith(report.MakePodNodeID(p.Namespace(), p.Name()), map[string]string{ - PodID: p.ID(), - PodName: p.Name(), - Namespace: p.Namespace(), - PodCreated: p.Created(), - PodContainerIDs: strings.Join(p.ContainerIDs(), " "), - PodState: p.State(), + PodID: p.ID(), + PodName: p.Name(), + Namespace: p.Namespace(), + PodCreated: p.Created(), + PodContainerIDs: strings.Join(p.ContainerIDs(), " "), + PodState: p.State(), + PodIP: p.Status.PodIP, report.ControlProbeID: probeID, }) if len(p.serviceIDs) > 0 { diff --git a/probe/kubernetes/reporter.go b/probe/kubernetes/reporter.go index 7010070e1..0d6849520 100644 --- a/probe/kubernetes/reporter.go +++ b/probe/kubernetes/reporter.go @@ -18,8 +18,9 @@ var ( PodMetadataTemplates = report.MetadataTemplates{ PodID: {ID: PodID, Label: "ID", From: report.FromLatest, Priority: 1}, PodState: {ID: PodState, Label: "State", From: report.FromLatest, Priority: 2}, - Namespace: {ID: Namespace, Label: "Namespace", From: report.FromLatest, Priority: 3}, - PodCreated: {ID: PodCreated, Label: "Created", From: report.FromLatest, Priority: 4}, + PodIP: {ID: PodIP, Label: "IP", From: report.FromLatest, Priority: 3}, + Namespace: {ID: Namespace, Label: "Namespace", From: report.FromLatest, Priority: 5}, + PodCreated: {ID: PodCreated, Label: "Created", From: report.FromLatest, Priority: 6}, } ServiceMetadataTemplates = report.MetadataTemplates{ diff --git a/render/detailed/node_test.go b/render/detailed/node_test.go index 17b6b7d09..8e2d4e411 100644 --- a/render/detailed/node_test.go +++ b/render/detailed/node_test.go @@ -319,7 +319,7 @@ func TestMakeDetailedPodNode(t *testing.T) { Metadata: []report.MetadataRow{ {ID: "kubernetes_pod_id", Label: "ID", Value: "ping/pong-b", Priority: 1}, {ID: "kubernetes_pod_state", Label: "State", Value: "running", Priority: 2}, - {ID: "kubernetes_namespace", Label: "Namespace", Value: "ping", Priority: 3}, + {ID: "kubernetes_namespace", Label: "Namespace", Value: "ping", Priority: 5}, }, }, Controls: []detailed.ControlInstance{}, From 02554b1dcd4f90389168ba07c8499eaf87da5b3f Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Fri, 29 Apr 2016 18:13:55 +0100 Subject: [PATCH 14/19] Propagate network info for containers sharing network namespaces (#1401) - Add armon/go-radix library, use this to find containers by prefix - Deal with host net namespace in the same way --- probe/docker/container.go | 55 ++- probe/docker/container_test.go | 76 ++-- probe/docker/registry.go | 71 +++- probe/docker/registry_test.go | 9 +- probe/docker/reporter.go | 158 ++++--- probe/docker/reporter_test.go | 2 + render/container.go | 45 +- render/container_test.go | 22 - report/controls.go | 9 +- vendor/github.com/armon/go-radix/LICENSE | 20 + vendor/github.com/armon/go-radix/radix.go | 496 ++++++++++++++++++++++ vendor/manifest | 157 +++++++ 12 files changed, 929 insertions(+), 191 deletions(-) create mode 100644 vendor/github.com/armon/go-radix/LICENSE create mode 100644 vendor/github.com/armon/go-radix/radix.go diff --git a/probe/docker/container.go b/probe/docker/container.go index c5387e910..5b4fd29c1 100644 --- a/probe/docker/container.go +++ b/probe/docker/container.go @@ -100,13 +100,15 @@ type Container interface { Image() string PID() int Hostname() string - GetNode([]net.IP) report.Node + GetNode() report.Node State() string StateString() string HasTTY() bool Container() *docker.Container StartGatheringStats() error StopGatheringStats() + NetworkMode() (string, bool) + NetworkInfo([]net.IP) report.Sets } type container struct { @@ -284,6 +286,39 @@ func (c *container) ports(localAddrs []net.IP) report.StringSet { return report.MakeStringSet(ports...) } +func (c *container) NetworkMode() (string, bool) { + c.RLock() + defer c.RUnlock() + if c.container.HostConfig != nil { + return c.container.HostConfig.NetworkMode, true + } + return "", false +} + +func addScopeToIPs(hostID string, ips []string) []string { + ipsWithScopes := []string{} + for _, ip := range ips { + ipsWithScopes = append(ipsWithScopes, report.MakeScopedAddressNodeID(hostID, ip)) + } + return ipsWithScopes +} + +func (c *container) NetworkInfo(localAddrs []net.IP) report.Sets { + c.RLock() + defer c.RUnlock() + ips := c.container.NetworkSettings.SecondaryIPAddresses + if c.container.NetworkSettings.IPAddress != "" { + ips = append(ips, c.container.NetworkSettings.IPAddress) + } + // Treat all Docker IPs as local scoped. + ipsWithScopes := addScopeToIPs(c.hostID, ips) + return report.EmptySets. + Add(ContainerPorts, c.ports(localAddrs)). + Add(ContainerIPs, report.MakeStringSet(ips...)). + Add(ContainerIPsWithScopes, report.MakeStringSet(ipsWithScopes...)) + +} + func (c *container) memoryUsageMetric(stats []docker.Stats) report.Metric { result := report.MakeMetric() for _, s := range stats { @@ -345,19 +380,9 @@ func (c *container) env() map[string]string { return result } -func (c *container) GetNode(localAddrs []net.IP) report.Node { +func (c *container) GetNode() report.Node { c.RLock() defer c.RUnlock() - ips := c.container.NetworkSettings.SecondaryIPAddresses - if c.container.NetworkSettings.IPAddress != "" { - ips = append(ips, c.container.NetworkSettings.IPAddress) - } - // Treat all Docker IPs as local scoped. - ipsWithScopes := []string{} - for _, ip := range ips { - ipsWithScopes = append(ipsWithScopes, report.MakeScopedAddressNodeID(c.hostID, ip)) - } - result := report.MakeNodeWith(report.MakeContainerNodeID(c.ID()), map[string]string{ ContainerID: c.ID(), ContainerName: strings.TrimPrefix(c.container.Name, "/"), @@ -367,11 +392,7 @@ func (c *container) GetNode(localAddrs []net.IP) report.Node { ContainerHostname: c.Hostname(), ContainerState: c.StateString(), ContainerStateHuman: c.State(), - }).WithSets(report.EmptySets. - Add(ContainerPorts, c.ports(localAddrs)). - Add(ContainerIPs, report.MakeStringSet(ips...)). - Add(ContainerIPsWithScopes, report.MakeStringSet(ipsWithScopes...)), - ).WithMetrics( + }).WithMetrics( c.metrics(), ).WithParents(report.EmptySets. Add(report.ContainerImage, report.MakeStringSet(report.MakeContainerImageNodeID(c.Image()))), diff --git a/probe/docker/container_test.go b/probe/docker/container_test.go index 3c9f88b4f..ca462204b 100644 --- a/probe/docker/container_test.go +++ b/probe/docker/container_test.go @@ -74,42 +74,51 @@ func TestContainer(t *testing.T) { } // Now see if we go them - uptime := (now.Sub(startTime) / time.Second) * time.Second - want := report.MakeNodeWith("ping;", map[string]string{ - "docker_container_command": " ", - "docker_container_created": "01 Jan 01 00:00 UTC", - "docker_container_id": "ping", - "docker_container_name": "pong", - "docker_image_id": "baz", - "docker_label_foo1": "bar1", - "docker_label_foo2": "bar2", - "docker_container_state": "running", - "docker_container_state_human": "Up 6 years", - "docker_container_uptime": uptime.String(), - }). - WithSets(report.EmptySets. + { + uptime := (now.Sub(startTime) / time.Second) * time.Second + want := report.MakeNodeWith("ping;", map[string]string{ + "docker_container_command": " ", + "docker_container_created": "01 Jan 01 00:00 UTC", + "docker_container_id": "ping", + "docker_container_name": "pong", + "docker_image_id": "baz", + "docker_label_foo1": "bar1", + "docker_label_foo2": "bar2", + "docker_container_state": "running", + "docker_container_state_human": "Up 6 years", + "docker_container_uptime": uptime.String(), + }). + WithControls( + docker.RestartContainer, docker.StopContainer, docker.PauseContainer, + docker.AttachContainer, docker.ExecContainer, + ).WithMetrics(report.Metrics{ + "docker_cpu_total_usage": report.MakeMetric(), + "docker_memory_usage": report.MakeMetric().Add(now, 12345).WithMax(45678), + }).WithParents(report.EmptySets. + Add(report.ContainerImage, report.MakeStringSet(report.MakeContainerImageNodeID("baz"))), + ) + + test.Poll(t, 100*time.Millisecond, want, func() interface{} { + node := c.GetNode() + node.Latest.ForEach(func(k, v string) { + if v == "0" || v == "" { + node.Latest = node.Latest.Delete(k) + } + }) + return node + }) + } + + { + want := report.EmptySets. Add("docker_container_ports", report.MakeStringSet("1.2.3.4:80->80/tcp", "81/tcp")). Add("docker_container_ips", report.MakeStringSet("1.2.3.4")). - Add("docker_container_ips_with_scopes", report.MakeStringSet("scope;1.2.3.4")), - ).WithControls( - docker.RestartContainer, docker.StopContainer, docker.PauseContainer, - docker.AttachContainer, docker.ExecContainer, - ).WithMetrics(report.Metrics{ - "docker_cpu_total_usage": report.MakeMetric(), - "docker_memory_usage": report.MakeMetric().Add(now, 12345).WithMax(45678), - }).WithParents(report.EmptySets. - Add(report.ContainerImage, report.MakeStringSet(report.MakeContainerImageNodeID("baz"))), - ) + Add("docker_container_ips_with_scopes", report.MakeStringSet("scope;1.2.3.4")) - test.Poll(t, 100*time.Millisecond, want, func() interface{} { - node := c.GetNode([]net.IP{}) - node.Latest.ForEach(func(k, v string) { - if v == "0" || v == "" { - node.Latest = node.Latest.Delete(k) - } + test.Poll(t, 100*time.Millisecond, want, func() interface{} { + return c.NetworkInfo([]net.IP{}) }) - return node - }) + } if c.Image() != "baz" { t.Errorf("%s != baz", c.Image()) @@ -117,7 +126,8 @@ func TestContainer(t *testing.T) { if c.PID() != 2 { t.Errorf("%d != 2", c.PID()) } - if have := docker.ExtractContainerIPs(c.GetNode([]net.IP{})); !reflect.DeepEqual(have, []string{"1.2.3.4"}) { + node := c.GetNode().WithSets(c.NetworkInfo([]net.IP{})) + if have := docker.ExtractContainerIPs(node); !reflect.DeepEqual(have, []string{"1.2.3.4"}) { t.Errorf("%v != %v", have, []string{"1.2.3.4"}) } } diff --git a/probe/docker/registry.go b/probe/docker/registry.go index 1ee0c4ca5..37b750f28 100644 --- a/probe/docker/registry.go +++ b/probe/docker/registry.go @@ -5,6 +5,7 @@ import ( "time" log "github.com/Sirupsen/logrus" + "github.com/armon/go-radix" docker_client "github.com/fsouza/go-dockerclient" "github.com/weaveworks/scope/probe/controls" @@ -37,6 +38,7 @@ type Registry interface { WalkImages(f func(*docker_client.APIImages)) WatchContainerUpdates(ContainerUpdateWatcher) GetContainer(string) (Container, bool) + GetContainerByPrefix(string) (Container, bool) } // ContainerUpdateWatcher is the type of functions that get called when containers are updated. @@ -52,7 +54,7 @@ type registry struct { hostID string watchers []ContainerUpdateWatcher - containers map[string]Container + containers *radix.Tree containersByPID map[int]Container images map[string]*docker_client.APIImages } @@ -88,7 +90,7 @@ func NewRegistry(interval time.Duration, pipes controls.PipeClient, collectStats } r := ®istry{ - containers: map[string]Container{}, + containers: radix.New(), containersByPID: map[int]Container{}, images: map[string]*docker_client.APIImages{}, @@ -186,9 +188,10 @@ func (r *registry) listenForEvents() bool { defer r.Unlock() if r.collectStats { - for _, c := range r.containers { - c.StopGatheringStats() - } + r.containers.Walk(func(_ string, c interface{}) bool { + c.(Container).StopGatheringStats() + return false + }) } close(ch) return false @@ -201,12 +204,13 @@ func (r *registry) reset() { defer r.Unlock() if r.collectStats { - for _, c := range r.containers { - c.StopGatheringStats() - } + r.containers.Walk(func(_ string, c interface{}) bool { + c.(Container).StopGatheringStats() + return false + }) } - r.containers = map[string]Container{} + r.containers = radix.New() r.containersByPID = map[int]Container{} r.images = map[string]*docker_client.APIImages{} } @@ -270,12 +274,13 @@ func (r *registry) updateContainerState(containerID string, intendedState *strin } // Container doesn't exist anymore, so lets stop and remove it - container, ok := r.containers[containerID] + c, ok := r.containers.Get(containerID) if !ok { return } + container := c.(Container) - delete(r.containers, containerID) + r.containers.Delete(containerID) delete(r.containersByPID, container.PID()) if r.collectStats { container.StopGatheringStats() @@ -295,11 +300,13 @@ func (r *registry) updateContainerState(containerID string, intendedState *strin } // Container exists, ensure we have it - c, ok := r.containers[containerID] + o, ok := r.containers.Get(containerID) + var c Container if !ok { c = NewContainerStub(dockerContainer, r.hostID) - r.containers[containerID] = c + r.containers.Insert(containerID, c) } else { + c = o.(Container) // potentially remove existing pid mapping. delete(r.containersByPID, c.PID()) c.UpdateState(dockerContainer) @@ -311,9 +318,8 @@ func (r *registry) updateContainerState(containerID string, intendedState *strin } // Trigger anyone watching for updates - localAddrs, err := report.LocalAddresses() if err != nil { - node := c.GetNode(localAddrs) + node := c.GetNode() for _, f := range r.watchers { f(node) } @@ -350,16 +356,34 @@ func (r *registry) WalkContainers(f func(Container)) { r.RLock() defer r.RUnlock() - for _, container := range r.containers { - f(container) - } + r.containers.Walk(func(_ string, c interface{}) bool { + f(c.(Container)) + return false + }) } func (r *registry) GetContainer(id string) (Container, bool) { r.RLock() defer r.RUnlock() - c, ok := r.containers[id] - return c, ok + c, ok := r.containers.Get(id) + if ok { + return c.(Container), true + } + return nil, false +} + +func (r *registry) GetContainerByPrefix(prefix string) (Container, bool) { + r.RLock() + defer r.RUnlock() + out := []interface{}{} + r.containers.WalkPrefix(prefix, func(_ string, v interface{}) bool { + out = append(out, v) + return false + }) + if len(out) == 1 { + return out[0].(Container), true + } + return nil, false } // WalkImages runs f on every image of running containers the registry @@ -369,10 +393,11 @@ func (r *registry) WalkImages(f func(*docker_client.APIImages)) { defer r.RUnlock() // Loop over containers so we only emit images for running containers. - for _, container := range r.containers { - image, ok := r.images[container.Image()] + r.containers.Walk(func(_ string, c interface{}) bool { + image, ok := r.images[c.(Container).Image()] if ok { f(image) } - } + return false + }) } diff --git a/probe/docker/registry_test.go b/probe/docker/registry_test.go index 33319c30b..c1b494fb4 100644 --- a/probe/docker/registry_test.go +++ b/probe/docker/registry_test.go @@ -54,7 +54,7 @@ func (c *mockContainer) StartGatheringStats() error { func (c *mockContainer) StopGatheringStats() {} -func (c *mockContainer) GetNode(_ []net.IP) report.Node { +func (c *mockContainer) GetNode() report.Node { return report.MakeNodeWith(report.MakeContainerNodeID(c.c.ID), map[string]string{ docker.ContainerID: c.c.ID, docker.ContainerName: c.c.Name, @@ -64,6 +64,13 @@ func (c *mockContainer) GetNode(_ []net.IP) report.Node { ) } +func (c *mockContainer) NetworkMode() (string, bool) { + return "", false +} +func (c *mockContainer) NetworkInfo([]net.IP) report.Sets { + return report.EmptySets +} + func (c *mockContainer) Container() *client.Container { return c.c } diff --git a/probe/docker/reporter.go b/probe/docker/reporter.go index 653e9e836..640689707 100644 --- a/probe/docker/reporter.go +++ b/probe/docker/reporter.go @@ -49,6 +49,57 @@ var ( ContainerImageTableTemplates = report.TableTemplates{ ImageLabelPrefix: {ID: ImageLabelPrefix, Label: "Docker Labels", Prefix: ImageLabelPrefix}, } + + ContainerControls = []report.Control{ + { + ID: AttachContainer, + Human: "Attach", + Icon: "fa-desktop", + Rank: 1, + }, + { + ID: ExecContainer, + Human: "Exec shell", + Icon: "fa-terminal", + Rank: 2, + }, + { + ID: StartContainer, + Human: "Start", + Icon: "fa-play", + Rank: 3, + }, + { + ID: RestartContainer, + Human: "Restart", + Icon: "fa-repeat", + Rank: 4, + }, + { + ID: PauseContainer, + Human: "Pause", + Icon: "fa-pause", + Rank: 5, + }, + { + ID: UnpauseContainer, + Human: "Unpause", + Icon: "fa-play", + Rank: 6, + }, + { + ID: StopContainer, + Human: "Stop", + Icon: "fa-stop", + Rank: 7, + }, + { + ID: RemoveContainer, + Human: "Remove", + Icon: "fa-trash-o", + Rank: 8, + }, + } ) // Reporter generate Reports containing Container and ContainerImage topologies @@ -96,66 +147,73 @@ func (r *Reporter) Report() (report.Report, error) { return result, nil } +func getLocalIPs() ([]string, error) { + addrs, err := net.InterfaceAddrs() + if err != nil { + return nil, err + } + ips := []string{} + for _, addr := range addrs { + // Not all addrs are IPNets. + if ipNet, ok := addr.(*net.IPNet); ok { + ips = append(ips, ipNet.IP.String()) + } + } + return ips, nil +} + func (r *Reporter) containerTopology(localAddrs []net.IP) report.Topology { result := report.MakeTopology(). WithMetadataTemplates(ContainerMetadataTemplates). WithMetricTemplates(ContainerMetricTemplates). WithTableTemplates(ContainerTableTemplates) - result.Controls.AddControl(report.Control{ - ID: AttachContainer, - Human: "Attach", - Icon: "fa-desktop", - Rank: 1, - }) - result.Controls.AddControl(report.Control{ - ID: ExecContainer, - Human: "Exec shell", - Icon: "fa-terminal", - Rank: 2, - }) - result.Controls.AddControl(report.Control{ - ID: StartContainer, - Human: "Start", - Icon: "fa-play", - Rank: 3, - }) - result.Controls.AddControl(report.Control{ - ID: RestartContainer, - Human: "Restart", - Icon: "fa-repeat", - Rank: 4, - }) - result.Controls.AddControl(report.Control{ - ID: PauseContainer, - Human: "Pause", - Icon: "fa-pause", - Rank: 5, - }) - result.Controls.AddControl(report.Control{ - ID: UnpauseContainer, - Human: "Unpause", - Icon: "fa-play", - Rank: 6, - }) - result.Controls.AddControl(report.Control{ - ID: StopContainer, - Human: "Stop", - Icon: "fa-stop", - Rank: 7, - }) - result.Controls.AddControl(report.Control{ - ID: RemoveContainer, - Human: "Remove", - Icon: "fa-trash-o", - Rank: 8, - }) + result.Controls.AddControls(ContainerControls) metadata := map[string]string{report.ControlProbeID: r.probeID} - + nodes := []report.Node{} r.registry.WalkContainers(func(c Container) { - result.AddNode(c.GetNode(localAddrs).WithLatests(metadata)) + nodes = append(nodes, c.GetNode().WithLatests(metadata)) }) + // Copy the IP addresses from other containers where they share network + // namespaces & deal with containers in the host net namespace. This + // is recursive to deal with people who decide to be clever. + { + hostNetworkInfo := report.EmptySets + if hostIPs, err := getLocalIPs(); err == nil { + hostIPsWithScopes := addScopeToIPs(r.hostID, hostIPs) + hostNetworkInfo = hostNetworkInfo. + Add(ContainerIPs, report.MakeStringSet(hostIPs...)). + Add(ContainerIPsWithScopes, report.MakeStringSet(hostIPsWithScopes...)) + } + + var networkInfo func(prefix string) report.Sets + networkInfo = func(prefix string) report.Sets { + container, ok := r.registry.GetContainerByPrefix(prefix) + if !ok { + return report.EmptySets + } + + networkMode, ok := container.NetworkMode() + if ok && strings.HasPrefix(networkMode, "container:") { + return networkInfo(networkMode[10:]) + } else if ok && networkMode == NetworkModeHost { + return hostNetworkInfo + } + + return container.NetworkInfo(localAddrs) + } + + for _, node := range nodes { + id, ok := report.ParseContainerNodeID(node.ID) + if !ok { + continue + } + networkInfo := networkInfo(id) + result.AddNode(node.WithSets(networkInfo)) + } + } + return result } diff --git a/probe/docker/reporter_test.go b/probe/docker/reporter_test.go index f7b6d010a..3b5ff9eef 100644 --- a/probe/docker/reporter_test.go +++ b/probe/docker/reporter_test.go @@ -38,6 +38,8 @@ func (r *mockRegistry) WatchContainerUpdates(_ docker.ContainerUpdateWatcher) {} func (r *mockRegistry) GetContainer(_ string) (docker.Container, bool) { return nil, false } +func (r *mockRegistry) GetContainerByPrefix(_ string) (docker.Container, bool) { return nil, false } + var ( mockRegistryInstance = &mockRegistry{ containersByPID: map[int]docker.Container{ diff --git a/render/container.go b/render/container.go index 55fc9fc81..c16a4611e 100644 --- a/render/container.go +++ b/render/container.go @@ -8,7 +8,6 @@ import ( "github.com/weaveworks/scope/probe/docker" "github.com/weaveworks/scope/probe/endpoint" - "github.com/weaveworks/scope/probe/host" "github.com/weaveworks/scope/report" ) @@ -67,48 +66,6 @@ var ContainerRenderer = MakeFilter( ), ) -type containerWithHostIPsRenderer struct { - Renderer -} - -// Render produces a process graph where the ips for host network mode are set -// to the host's IPs. -func (r containerWithHostIPsRenderer) Render(rpt report.Report, dct Decorator) report.Nodes { - containers := r.Renderer.Render(rpt, dct) - hosts := SelectHost.Render(rpt, dct) - - outputs := report.Nodes{} - for id, c := range containers { - outputs[id] = c - networkMode, ok := c.Latest.Lookup(docker.ContainerNetworkMode) - if !ok || networkMode != docker.NetworkModeHost { - continue - } - - h, ok := hosts[report.MakeHostNodeID(report.ExtractHostID(c))] - if !ok { - continue - } - - newIPs := report.MakeStringSet() - hostNetworks, _ := h.Sets.Lookup(host.LocalNetworks) - for _, cidr := range hostNetworks { - if ip, _, err := net.ParseCIDR(cidr); err == nil { - newIPs = newIPs.Add(ip.String()) - } - } - - output := c.Copy() - output.Sets = c.Sets.Add(docker.ContainerIPs, newIPs) - outputs[id] = output - } - return outputs -} - -// ContainerWithHostIPsRenderer is a Renderer which produces a container graph -// enriched with host IPs on containers where NetworkMode is Host -var ContainerWithHostIPsRenderer = containerWithHostIPsRenderer{ContainerRenderer} - type containerWithImageNameRenderer struct { Renderer } @@ -140,7 +97,7 @@ func (r containerWithImageNameRenderer) Render(rpt report.Report, dct Decorator) // ContainerWithImageNameRenderer is a Renderer which produces a container // graph where the ranks are the image names, not their IDs -var ContainerWithImageNameRenderer = ApplyDecorators(containerWithImageNameRenderer{ContainerWithHostIPsRenderer}) +var ContainerWithImageNameRenderer = ApplyDecorators(containerWithImageNameRenderer{ContainerRenderer}) // ContainerImageRenderer is a Renderer which produces a renderable container // image graph by merging the container graph and the container image topology. diff --git a/render/container_test.go b/render/container_test.go index 75a60ae59..ec35de093 100644 --- a/render/container_test.go +++ b/render/container_test.go @@ -69,28 +69,6 @@ func TestContainerFilterRenderer(t *testing.T) { } } -func TestContainerWithHostIPsRenderer(t *testing.T) { - input := fixture.Report.Copy() - input.Container.Nodes[fixture.ClientContainerNodeID] = input.Container.Nodes[fixture.ClientContainerNodeID].WithLatests(map[string]string{ - docker.ContainerNetworkMode: "host", - }) - nodes := render.ContainerWithHostIPsRenderer.Render(input, render.FilterNoop) - - // Test host network nodes get the host IPs added. - haveNode, ok := nodes[fixture.ClientContainerNodeID] - if !ok { - t.Fatal("Expected output to have the client container node") - } - have, ok := haveNode.Sets.Lookup(docker.ContainerIPs) - if !ok { - t.Fatal("Container had no IPs set.") - } - want := report.MakeStringSet("10.10.10.0") - if !reflect.DeepEqual(want, have) { - t.Error(test.Diff(want, have)) - } -} - func TestContainerHostnameRenderer(t *testing.T) { have := Prune(render.ContainerHostnameRenderer.Render(fixture.Report, render.FilterNoop)) want := Prune(expected.RenderedContainerHostnames) diff --git a/report/controls.go b/report/controls.go index 4c294aa0a..0a3cf2626 100644 --- a/report/controls.go +++ b/report/controls.go @@ -37,11 +37,18 @@ func (cs Controls) Copy() Controls { return result } -// AddControl returns a fresh Controls, c added to cs. +// AddControl adds c added to cs. func (cs Controls) AddControl(c Control) { cs[c.ID] = c } +// AddControls adds a collection of controls to cs. +func (cs Controls) AddControls(controls []Control) { + for _, c := range controls { + cs[c.ID] = c + } +} + // NodeControls represent the individual controls that are valid for a given // node at a given point in time. Its is immutable. A zero-value for Timestamp // indicated this NodeControls is 'not set'. diff --git a/vendor/github.com/armon/go-radix/LICENSE b/vendor/github.com/armon/go-radix/LICENSE new file mode 100644 index 000000000..a5df10e67 --- /dev/null +++ b/vendor/github.com/armon/go-radix/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Armon Dadgar + +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. diff --git a/vendor/github.com/armon/go-radix/radix.go b/vendor/github.com/armon/go-radix/radix.go new file mode 100644 index 000000000..d2914c13b --- /dev/null +++ b/vendor/github.com/armon/go-radix/radix.go @@ -0,0 +1,496 @@ +package radix + +import ( + "sort" + "strings" +) + +// WalkFn is used when walking the tree. Takes a +// key and value, returning if iteration should +// be terminated. +type WalkFn func(s string, v interface{}) bool + +// leafNode is used to represent a value +type leafNode struct { + key string + val interface{} +} + +// edge is used to represent an edge node +type edge struct { + label byte + node *node +} + +type node struct { + // leaf is used to store possible leaf + leaf *leafNode + + // prefix is the common prefix we ignore + prefix string + + // Edges should be stored in-order for iteration. + // We avoid a fully materialized slice to save memory, + // since in most cases we expect to be sparse + edges edges +} + +func (n *node) isLeaf() bool { + return n.leaf != nil +} + +func (n *node) addEdge(e edge) { + n.edges = append(n.edges, e) + n.edges.Sort() +} + +func (n *node) replaceEdge(e edge) { + num := len(n.edges) + idx := sort.Search(num, func(i int) bool { + return n.edges[i].label >= e.label + }) + if idx < num && n.edges[idx].label == e.label { + n.edges[idx].node = e.node + return + } + panic("replacing missing edge") +} + +func (n *node) getEdge(label byte) *node { + num := len(n.edges) + idx := sort.Search(num, func(i int) bool { + return n.edges[i].label >= label + }) + if idx < num && n.edges[idx].label == label { + return n.edges[idx].node + } + return nil +} + +func (n *node) delEdge(label byte) { + num := len(n.edges) + idx := sort.Search(num, func(i int) bool { + return n.edges[i].label >= label + }) + if idx < num && n.edges[idx].label == label { + copy(n.edges[idx:], n.edges[idx+1:]) + n.edges[len(n.edges)-1] = edge{} + n.edges = n.edges[:len(n.edges)-1] + } +} + +type edges []edge + +func (e edges) Len() int { + return len(e) +} + +func (e edges) Less(i, j int) bool { + return e[i].label < e[j].label +} + +func (e edges) Swap(i, j int) { + e[i], e[j] = e[j], e[i] +} + +func (e edges) Sort() { + sort.Sort(e) +} + +// Tree implements a radix tree. This can be treated as a +// Dictionary abstract data type. The main advantage over +// a standard hash map is prefix-based lookups and +// ordered iteration, +type Tree struct { + root *node + size int +} + +// New returns an empty Tree +func New() *Tree { + return NewFromMap(nil) +} + +// NewFromMap returns a new tree containing the keys +// from an existing map +func NewFromMap(m map[string]interface{}) *Tree { + t := &Tree{root: &node{}} + for k, v := range m { + t.Insert(k, v) + } + return t +} + +// Len is used to return the number of elements in the tree +func (t *Tree) Len() int { + return t.size +} + +// longestPrefix finds the length of the shared prefix +// of two strings +func longestPrefix(k1, k2 string) int { + max := len(k1) + if l := len(k2); l < max { + max = l + } + var i int + for i = 0; i < max; i++ { + if k1[i] != k2[i] { + break + } + } + return i +} + +// Insert is used to add a newentry or update +// an existing entry. Returns if updated. +func (t *Tree) Insert(s string, v interface{}) (interface{}, bool) { + var parent *node + n := t.root + search := s + for { + // Handle key exhaution + if len(search) == 0 { + if n.isLeaf() { + old := n.leaf.val + n.leaf.val = v + return old, true + } + + n.leaf = &leafNode{ + key: s, + val: v, + } + t.size++ + return nil, false + } + + // Look for the edge + parent = n + n = n.getEdge(search[0]) + + // No edge, create one + if n == nil { + e := edge{ + label: search[0], + node: &node{ + leaf: &leafNode{ + key: s, + val: v, + }, + prefix: search, + }, + } + parent.addEdge(e) + t.size++ + return nil, false + } + + // Determine longest prefix of the search key on match + commonPrefix := longestPrefix(search, n.prefix) + if commonPrefix == len(n.prefix) { + search = search[commonPrefix:] + continue + } + + // Split the node + t.size++ + child := &node{ + prefix: search[:commonPrefix], + } + parent.replaceEdge(edge{ + label: search[0], + node: child, + }) + + // Restore the existing node + child.addEdge(edge{ + label: n.prefix[commonPrefix], + node: n, + }) + n.prefix = n.prefix[commonPrefix:] + + // Create a new leaf node + leaf := &leafNode{ + key: s, + val: v, + } + + // If the new key is a subset, add to to this node + search = search[commonPrefix:] + if len(search) == 0 { + child.leaf = leaf + return nil, false + } + + // Create a new edge for the node + child.addEdge(edge{ + label: search[0], + node: &node{ + leaf: leaf, + prefix: search, + }, + }) + return nil, false + } +} + +// Delete is used to delete a key, returning the previous +// value and if it was deleted +func (t *Tree) Delete(s string) (interface{}, bool) { + var parent *node + var label byte + n := t.root + search := s + for { + // Check for key exhaution + if len(search) == 0 { + if !n.isLeaf() { + break + } + goto DELETE + } + + // Look for an edge + parent = n + label = search[0] + n = n.getEdge(label) + if n == nil { + break + } + + // Consume the search prefix + if strings.HasPrefix(search, n.prefix) { + search = search[len(n.prefix):] + } else { + break + } + } + return nil, false + +DELETE: + // Delete the leaf + leaf := n.leaf + n.leaf = nil + t.size-- + + // Check if we should delete this node from the parent + if parent != nil && len(n.edges) == 0 { + parent.delEdge(label) + } + + // Check if we should merge this node + if n != t.root && len(n.edges) == 1 { + n.mergeChild() + } + + // Check if we should merge the parent's other child + if parent != nil && parent != t.root && len(parent.edges) == 1 && !parent.isLeaf() { + parent.mergeChild() + } + + return leaf.val, true +} + +func (n *node) mergeChild() { + e := n.edges[0] + child := e.node + n.prefix = n.prefix + child.prefix + n.leaf = child.leaf + n.edges = child.edges +} + +// Get is used to lookup a specific key, returning +// the value and if it was found +func (t *Tree) Get(s string) (interface{}, bool) { + n := t.root + search := s + for { + // Check for key exhaution + if len(search) == 0 { + if n.isLeaf() { + return n.leaf.val, true + } + break + } + + // Look for an edge + n = n.getEdge(search[0]) + if n == nil { + break + } + + // Consume the search prefix + if strings.HasPrefix(search, n.prefix) { + search = search[len(n.prefix):] + } else { + break + } + } + return nil, false +} + +// LongestPrefix is like Get, but instead of an +// exact match, it will return the longest prefix match. +func (t *Tree) LongestPrefix(s string) (string, interface{}, bool) { + var last *leafNode + n := t.root + search := s + for { + // Look for a leaf node + if n.isLeaf() { + last = n.leaf + } + + // Check for key exhaution + if len(search) == 0 { + break + } + + // Look for an edge + n = n.getEdge(search[0]) + if n == nil { + break + } + + // Consume the search prefix + if strings.HasPrefix(search, n.prefix) { + search = search[len(n.prefix):] + } else { + break + } + } + if last != nil { + return last.key, last.val, true + } + return "", nil, false +} + +// Minimum is used to return the minimum value in the tree +func (t *Tree) Minimum() (string, interface{}, bool) { + n := t.root + for { + if n.isLeaf() { + return n.leaf.key, n.leaf.val, true + } + if len(n.edges) > 0 { + n = n.edges[0].node + } else { + break + } + } + return "", nil, false +} + +// Maximum is used to return the maximum value in the tree +func (t *Tree) Maximum() (string, interface{}, bool) { + n := t.root + for { + if num := len(n.edges); num > 0 { + n = n.edges[num-1].node + continue + } + if n.isLeaf() { + return n.leaf.key, n.leaf.val, true + } + break + } + return "", nil, false +} + +// Walk is used to walk the tree +func (t *Tree) Walk(fn WalkFn) { + recursiveWalk(t.root, fn) +} + +// WalkPrefix is used to walk the tree under a prefix +func (t *Tree) WalkPrefix(prefix string, fn WalkFn) { + n := t.root + search := prefix + for { + // Check for key exhaution + if len(search) == 0 { + recursiveWalk(n, fn) + return + } + + // Look for an edge + n = n.getEdge(search[0]) + if n == nil { + break + } + + // Consume the search prefix + if strings.HasPrefix(search, n.prefix) { + search = search[len(n.prefix):] + + } else if strings.HasPrefix(n.prefix, search) { + // Child may be under our search prefix + recursiveWalk(n, fn) + return + } else { + break + } + } + +} + +// WalkPath is used to walk the tree, but only visiting nodes +// from the root down to a given leaf. Where WalkPrefix walks +// all the entries *under* the given prefix, this walks the +// entries *above* the given prefix. +func (t *Tree) WalkPath(path string, fn WalkFn) { + n := t.root + search := path + for { + // Visit the leaf values if any + if n.leaf != nil && fn(n.leaf.key, n.leaf.val) { + return + } + + // Check for key exhaution + if len(search) == 0 { + return + } + + // Look for an edge + n = n.getEdge(search[0]) + if n == nil { + return + } + + // Consume the search prefix + if strings.HasPrefix(search, n.prefix) { + search = search[len(n.prefix):] + } else { + break + } + } +} + +// recursiveWalk is used to do a pre-order walk of a node +// recursively. Returns true if the walk should be aborted +func recursiveWalk(n *node, fn WalkFn) bool { + // Visit the leaf values if any + if n.leaf != nil && fn(n.leaf.key, n.leaf.val) { + return true + } + + // Recurse on the children + for _, e := range n.edges { + if recursiveWalk(e.node, fn) { + return true + } + } + return false +} + +// ToMap is used to walk the tree and convert it into a map +func (t *Tree) ToMap() map[string]interface{} { + out := make(map[string]interface{}, t.size) + t.Walk(func(k string, v interface{}) bool { + out[k] = v + return false + }) + return out +} diff --git a/vendor/manifest b/vendor/manifest index fb5ce2097..9b578fea1 100644 --- a/vendor/manifest +++ b/vendor/manifest @@ -4,12 +4,14 @@ { "importpath": "bitbucket.org/ww/goautoneg", "repository": "https://bitbucket.org/ww/goautoneg", + "vcs": "", "revision": "75cd24fc2f2c", "branch": "default" }, { "importpath": "github.com/DataDog/datadog-go/statsd", "repository": "https://github.com/DataDog/datadog-go", + "vcs": "", "revision": "b050cd8f4d7c394545fd7d966c8e2909ce89d552", "branch": "master", "path": "/statsd" @@ -17,12 +19,14 @@ { "importpath": "github.com/PuerkitoBio/ghost", "repository": "https://github.com/PuerkitoBio/ghost", + "vcs": "", "revision": "a0146f2f931611b8bfe40f07018c97a7c881c76a", "branch": "master" }, { "importpath": "github.com/PuerkitoBio/ghost/handlers", "repository": "https://github.com/PuerkitoBio/ghost", + "vcs": "", "revision": "a0146f2f931611b8bfe40f07018c97a7c881c76a", "branch": "master", "path": "/handlers" @@ -30,24 +34,36 @@ { "importpath": "github.com/Sirupsen/logrus", "repository": "https://github.com/Sirupsen/logrus", + "vcs": "", "revision": "cdaedc68f2894175ac2b3221869685602c759e71", "branch": "master" }, { "importpath": "github.com/armon/go-metrics", "repository": "https://github.com/armon/go-metrics", + "vcs": "", "revision": "6c5fa0d8f48f4661c9ba8709799c88d425ad20f0", "branch": "master" }, + { + "importpath": "github.com/armon/go-radix", + "repository": "https://github.com/armon/go-radix", + "vcs": "git", + "revision": "4239b77079c7b5d1243b7b4736304ce8ddb6f0f2", + "branch": "master", + "notests": true + }, { "importpath": "github.com/aws/aws-sdk-go", "repository": "https://github.com/aws/aws-sdk-go", + "vcs": "", "revision": "9e7816464bb6044ae17fff44ce59387d2658e2cb", "branch": "master" }, { "importpath": "github.com/beorn7/perks/quantile", "repository": "https://github.com/beorn7/perks", + "vcs": "", "revision": "b965b613227fddccbfffe13eae360ed3fa822f8d", "branch": "master", "path": "/quantile" @@ -55,18 +71,21 @@ { "importpath": "github.com/blang/semver", "repository": "https://github.com/blang/semver", + "vcs": "", "revision": "aea32c919a18e5ef4537bbd283ff29594b1b0165", "branch": "master" }, { "importpath": "github.com/bluele/gcache", "repository": "https://github.com/bluele/gcache", + "vcs": "", "revision": "fb6c0b0e1ff03057a054886141927cdce6239dec", "branch": "master" }, { "importpath": "github.com/c9s/goprocinfo/linux", "repository": "https://github.com/c9s/goprocinfo", + "vcs": "", "revision": "19cb9f127a9c8d2034cf59ccb683cdb94b9deb6c", "branch": "master", "path": "/linux" @@ -74,18 +93,21 @@ { "importpath": "github.com/certifi/gocertifi", "repository": "https://github.com/certifi/gocertifi", + "vcs": "", "revision": "84c0a38a18fcd2d657f3eabc958433c1f37ecdc0", "branch": "master" }, { "importpath": "github.com/coocood/freecache", "repository": "https://github.com/coocood/freecache", + "vcs": "", "revision": "a27035d5537f1fa5518225e9373c9ec7450f2ea2", "branch": "master" }, { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/bitbucket.org/ww/goautoneg", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/bitbucket.org/ww/goautoneg" @@ -93,6 +115,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/beorn7/perks/quantile", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/beorn7/perks/quantile" @@ -100,6 +123,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/boltdb/bolt", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/boltdb/bolt" @@ -107,6 +131,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/bradfitz/http2", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/bradfitz/http2" @@ -114,6 +139,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/coreos/go-semver/semver", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/coreos/go-semver/semver" @@ -121,6 +147,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/coreos/go-systemd/journal", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/coreos/go-systemd/journal" @@ -128,6 +155,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/coreos/pkg/capnslog", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/coreos/pkg/capnslog" @@ -135,6 +163,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/gogo/protobuf/proto", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/gogo/protobuf/proto" @@ -142,6 +171,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/golang/glog", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/golang/glog" @@ -149,6 +179,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/golang/protobuf/proto", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/golang/protobuf/proto" @@ -156,6 +187,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/google/btree", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/google/btree" @@ -163,6 +195,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/jonboulle/clockwork", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/jonboulle/clockwork" @@ -170,6 +203,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/matttproud/golang_protobuf_extensions/pbutil", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/matttproud/golang_protobuf_extensions/pbutil" @@ -177,6 +211,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/prometheus/client_golang/prometheus", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/prometheus/client_golang/prometheus" @@ -184,6 +219,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/prometheus/client_model/go", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/prometheus/client_model/go" @@ -191,6 +227,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/prometheus/common/expfmt", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/prometheus/common/expfmt" @@ -198,6 +235,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/prometheus/common/model", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/prometheus/common/model" @@ -205,6 +243,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/prometheus/procfs", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/prometheus/procfs" @@ -212,6 +251,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/ugorji/go/codec", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/ugorji/go/codec" @@ -219,6 +259,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/github.com/xiang90/probing", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/github.com/xiang90/probing" @@ -226,6 +267,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/golang.org/x/crypto/bcrypt", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/golang.org/x/crypto/bcrypt" @@ -233,6 +275,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/golang.org/x/crypto/blowfish", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/golang.org/x/crypto/blowfish" @@ -240,6 +283,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/golang.org/x/net/context", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/golang.org/x/net/context" @@ -247,6 +291,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/golang.org/x/oauth2", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/golang.org/x/oauth2" @@ -254,6 +299,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/google.golang.org/cloud/compute/metadata", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/google.golang.org/cloud/compute/metadata" @@ -261,6 +307,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/google.golang.org/cloud/internal", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/google.golang.org/cloud/internal" @@ -268,6 +315,7 @@ { "importpath": "github.com/coreos/etcd/Godeps/_workspace/src/google.golang.org/grpc", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/Godeps/_workspace/src/google.golang.org/grpc" @@ -275,6 +323,7 @@ { "importpath": "github.com/coreos/etcd/client", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/client" @@ -282,6 +331,7 @@ { "importpath": "github.com/coreos/etcd/discovery", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/discovery" @@ -289,6 +339,7 @@ { "importpath": "github.com/coreos/etcd/error", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/error" @@ -296,6 +347,7 @@ { "importpath": "github.com/coreos/etcd/etcdserver", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/etcdserver" @@ -303,6 +355,7 @@ { "importpath": "github.com/coreos/etcd/pkg/crc", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/crc" @@ -310,6 +363,7 @@ { "importpath": "github.com/coreos/etcd/pkg/fileutil", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/fileutil" @@ -317,6 +371,7 @@ { "importpath": "github.com/coreos/etcd/pkg/httputil", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/httputil" @@ -324,6 +379,7 @@ { "importpath": "github.com/coreos/etcd/pkg/idutil", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/idutil" @@ -331,6 +387,7 @@ { "importpath": "github.com/coreos/etcd/pkg/ioutil", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/ioutil" @@ -338,6 +395,7 @@ { "importpath": "github.com/coreos/etcd/pkg/netutil", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/netutil" @@ -345,6 +403,7 @@ { "importpath": "github.com/coreos/etcd/pkg/pathutil", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/pathutil" @@ -352,6 +411,7 @@ { "importpath": "github.com/coreos/etcd/pkg/pbutil", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/pbutil" @@ -359,6 +419,7 @@ { "importpath": "github.com/coreos/etcd/pkg/runtime", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/runtime" @@ -366,6 +427,7 @@ { "importpath": "github.com/coreos/etcd/pkg/timeutil", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/timeutil" @@ -373,6 +435,7 @@ { "importpath": "github.com/coreos/etcd/pkg/transport", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/transport" @@ -380,6 +443,7 @@ { "importpath": "github.com/coreos/etcd/pkg/types", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/types" @@ -387,6 +451,7 @@ { "importpath": "github.com/coreos/etcd/pkg/wait", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/pkg/wait" @@ -394,6 +459,7 @@ { "importpath": "github.com/coreos/etcd/raft", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/raft" @@ -401,6 +467,7 @@ { "importpath": "github.com/coreos/etcd/rafthttp", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/rafthttp" @@ -408,6 +475,7 @@ { "importpath": "github.com/coreos/etcd/snap", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/snap" @@ -415,6 +483,7 @@ { "importpath": "github.com/coreos/etcd/storage", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/storage" @@ -422,6 +491,7 @@ { "importpath": "github.com/coreos/etcd/store", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/store" @@ -429,6 +499,7 @@ { "importpath": "github.com/coreos/etcd/version", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/version" @@ -436,6 +507,7 @@ { "importpath": "github.com/coreos/etcd/wal", "repository": "https://github.com/coreos/etcd", + "vcs": "", "revision": "ae62a77de61d70f434ed848ba48b44247cb54c94", "branch": "master", "path": "/wal" @@ -443,6 +515,7 @@ { "importpath": "github.com/coreos/go-etcd/etcd", "repository": "https://github.com/coreos/go-etcd", + "vcs": "", "revision": "2038b5942e8e7f4f244729ff9353afab8ba11afc", "branch": "master", "path": "/etcd" @@ -450,6 +523,7 @@ { "importpath": "github.com/cpuguy83/go-md2man/md2man", "repository": "https://github.com/cpuguy83/go-md2man", + "vcs": "", "revision": "71acacd42f85e5e82f70a55327789582a5200a90", "branch": "master", "path": "/md2man" @@ -457,12 +531,14 @@ { "importpath": "github.com/davecgh/go-spew", "repository": "https://github.com/davecgh/go-spew", + "vcs": "", "revision": "5215b55f46b2b919f50a1df0eaa5886afe4e3b3d", "branch": "master" }, { "importpath": "github.com/davecgh/go-spew/spew", "repository": "https://github.com/davecgh/go-spew", + "vcs": "", "revision": "2df174808ee097f90d259e432cc04442cf60be21", "branch": "master", "path": "/spew" @@ -470,6 +546,7 @@ { "importpath": "github.com/docker/docker/pkg/homedir", "repository": "https://github.com/docker/docker", + "vcs": "", "revision": "7c1c96551d41e369a588e365a9bb99acb5bc8fdb", "branch": "master", "path": "/pkg/homedir" @@ -477,6 +554,7 @@ { "importpath": "github.com/docker/docker/pkg/mflag", "repository": "https://github.com/docker/docker", + "vcs": "", "revision": "7c1c96551d41e369a588e365a9bb99acb5bc8fdb", "branch": "master", "path": "/pkg/mflag" @@ -484,6 +562,7 @@ { "importpath": "github.com/docker/docker/pkg/mount", "repository": "https://github.com/docker/docker", + "vcs": "", "revision": "60ea37d56f15d64961f0bc59dd2db1c968c80288", "branch": "master", "path": "/pkg/mount" @@ -491,6 +570,7 @@ { "importpath": "github.com/docker/docker/pkg/parsers", "repository": "https://github.com/docker/docker", + "vcs": "", "revision": "0f5c9d301b9b1cca66b3ea0f9dec3b5317d3686d", "branch": "HEAD", "path": "/pkg/parsers" @@ -498,6 +578,7 @@ { "importpath": "github.com/docker/docker/pkg/units", "repository": "https://github.com/docker/docker", + "vcs": "", "revision": "60ea37d56f15d64961f0bc59dd2db1c968c80288", "branch": "master", "path": "/pkg/units" @@ -505,12 +586,14 @@ { "importpath": "github.com/docker/go-units", "repository": "https://github.com/docker/go-units", + "vcs": "", "revision": "5d2041e26a699eaca682e2ea41c8f891e1060444", "branch": "master" }, { "importpath": "github.com/docker/libcontainer/cgroups", "repository": "https://github.com/docker/libcontainer", + "vcs": "", "revision": "83a102cc68a09d890cce3b6c2e5c14c49e6373a0", "branch": "master", "path": "/cgroups" @@ -518,6 +601,7 @@ { "importpath": "github.com/docker/libcontainer/cgroups/fs", "repository": "https://github.com/docker/libcontainer", + "vcs": "", "revision": "83a102cc68a09d890cce3b6c2e5c14c49e6373a0", "branch": "master", "path": "/cgroups/fs" @@ -525,6 +609,7 @@ { "importpath": "github.com/docker/libcontainer/configs", "repository": "https://github.com/docker/libcontainer", + "vcs": "", "revision": "83a102cc68a09d890cce3b6c2e5c14c49e6373a0", "branch": "master", "path": "/configs" @@ -532,6 +617,7 @@ { "importpath": "github.com/docker/libcontainer/system", "repository": "https://github.com/docker/libcontainer", + "vcs": "", "revision": "83a102cc68a09d890cce3b6c2e5c14c49e6373a0", "branch": "master", "path": "/system" @@ -539,12 +625,14 @@ { "importpath": "github.com/emicklei/go-restful", "repository": "https://github.com/emicklei/go-restful", + "vcs": "", "revision": "f3b10ff408486b3e248197254514778285fbdea1", "branch": "master" }, { "importpath": "github.com/emicklei/go-restful/swagger", "repository": "https://github.com/emicklei/go-restful", + "vcs": "", "revision": "f3b10ff408486b3e248197254514778285fbdea1", "branch": "master", "path": "/swagger" @@ -552,12 +640,14 @@ { "importpath": "github.com/fsouza/go-dockerclient", "repository": "https://github.com/fsouza/go-dockerclient", + "vcs": "", "revision": "1b46a3bbaf08d19385915795bae730a318f3dd69", "branch": "master" }, { "importpath": "github.com/garyburd/redigo/internal", "repository": "https://github.com/garyburd/redigo", + "vcs": "", "revision": "3d0709611e0e29c05a4e925e8447b065abb4eef6", "branch": "master", "path": "/internal" @@ -565,6 +655,7 @@ { "importpath": "github.com/garyburd/redigo/redis", "repository": "https://github.com/garyburd/redigo", + "vcs": "", "revision": "3d0709611e0e29c05a4e925e8447b065abb4eef6", "branch": "master", "path": "/redis" @@ -572,24 +663,28 @@ { "importpath": "github.com/ghodss/yaml", "repository": "https://github.com/ghodss/yaml", + "vcs": "", "revision": "73d445a93680fa1a78ae23a5839bad48f32ba1ee", "branch": "master" }, { "importpath": "github.com/go-ini/ini", "repository": "https://github.com/go-ini/ini", + "vcs": "", "revision": "467243bad6cb295e0fe72366da5ba85b069874cb", "branch": "master" }, { "importpath": "github.com/golang/glog", "repository": "https://github.com/golang/glog", + "vcs": "", "revision": "fca8c8854093a154ff1eb580aae10276ad6b1b5f", "branch": "master" }, { "importpath": "github.com/golang/protobuf/proto", "repository": "https://github.com/golang/protobuf", + "vcs": "", "revision": "5baca1b63153b1a82014546382edbdd302b138b6", "branch": "master", "path": "/proto" @@ -597,6 +692,7 @@ { "importpath": "github.com/google/cadvisor/info/v1", "repository": "https://github.com/google/cadvisor", + "vcs": "", "revision": "aa6f80814bc6fdb43a0ed12719658225420ffb7d", "branch": "master", "path": "/info/v1" @@ -604,48 +700,56 @@ { "importpath": "github.com/google/gofuzz", "repository": "https://github.com/google/gofuzz", + "vcs": "", "revision": "e4af62d086c303f2bed467b227fc0a034b218916", "branch": "master" }, { "importpath": "github.com/google/gopacket", "repository": "https://github.com/google/gopacket", + "vcs": "", "revision": "1b0b78901cdd351ecfc68bf1a2adafcd2ff30220", "branch": "master" }, { "importpath": "github.com/gorilla/context", "repository": "https://github.com/gorilla/context", + "vcs": "", "revision": "1c83b3eabd45b6d76072b66b746c20815fb2872d", "branch": "master" }, { "importpath": "github.com/gorilla/handlers", "repository": "https://github.com/gorilla/handlers", + "vcs": "", "revision": "9a8d6fa6e6479b56ec164996e54fa5d7eb3ef544", "branch": "master" }, { "importpath": "github.com/gorilla/mux", "repository": "https://github.com/jingweno/mux", + "vcs": "", "revision": "786d36e5ab042d67efe94022439cf6c91ee711dc", "branch": "master" }, { "importpath": "github.com/gorilla/securecookie", "repository": "https://github.com/gorilla/securecookie", + "vcs": "", "revision": "e95799a481bbcc3d01c2ad5178524cb8bec9f370", "branch": "master" }, { "importpath": "github.com/gorilla/websocket", "repository": "https://github.com/gorilla/websocket", + "vcs": "", "revision": "5c91b59efa232fa9a87b705d54101832c498a172", "branch": "master" }, { "importpath": "github.com/hashicorp/consul/api", "repository": "https://github.com/hashicorp/consul", + "vcs": "", "revision": "2a4436075dbb347f9d78ebfd6645d5c5898ad3aa", "branch": "master", "path": "/api" @@ -653,18 +757,21 @@ { "importpath": "github.com/hashicorp/go-cleanhttp", "repository": "https://github.com/hashicorp/go-cleanhttp", + "vcs": "", "revision": "ce617e79981a8fff618bb643d155133a8f38db96", "branch": "master" }, { "importpath": "github.com/hashicorp/go-version", "repository": "https://github.com/hashicorp/go-version", + "vcs": "", "revision": "2e7f5ea8e27bb3fdf9baa0881d16757ac4637332", "branch": "master" }, { "importpath": "github.com/hashicorp/serf/coordinate", "repository": "https://github.com/hashicorp/serf", + "vcs": "", "revision": "b00b7b98ce2bfe59534177e56a8e7d12c4a0ca70", "branch": "master", "path": "/coordinate" @@ -672,36 +779,42 @@ { "importpath": "github.com/inconshreveable/mousetrap", "repository": "https://github.com/inconshreveable/mousetrap", + "vcs": "", "revision": "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75", "branch": "master" }, { "importpath": "github.com/jmespath/go-jmespath", "repository": "https://github.com/jmespath/go-jmespath", + "vcs": "", "revision": "c01cf91b011868172fdcd9f41838e80c9d716264", "branch": "master" }, { "importpath": "github.com/juju/ratelimit", "repository": "https://github.com/juju/ratelimit", + "vcs": "", "revision": "77ed1c8a01217656d2080ad51981f6e99adaa177", "branch": "master" }, { "importpath": "github.com/kr/pty", "repository": "https://github.com/kr/pty", + "vcs": "", "revision": "f7ee69f31298ecbe5d2b349c711e2547a617d398", "branch": "master" }, { "importpath": "github.com/lsegal/gucumber", "repository": "https://github.com/lsegal/gucumber", + "vcs": "", "revision": "e8116c9c66e641e9f81fc0a79fac923dfc646378", "branch": "master" }, { "importpath": "github.com/matttproud/golang_protobuf_extensions/pbutil", "repository": "https://github.com/matttproud/golang_protobuf_extensions", + "vcs": "", "revision": "d0c3fe89de86839aecf2e0579c40ba3bb336a453", "branch": "master", "path": "/pbutil" @@ -709,24 +822,28 @@ { "importpath": "github.com/miekg/dns", "repository": "https://github.com/miekg/dns", + "vcs": "", "revision": "3d66e3747d22a77b24b119eb66e9d574ed99f86c", "branch": "master" }, { "importpath": "github.com/mndrix/ps", "repository": "https://github.com/mndrix/ps", + "vcs": "", "revision": "35fef6f28be7e47a87d8a71ef5b80cbf2c4c167a", "branch": "master" }, { "importpath": "github.com/nu7hatch/gouuid", "repository": "https://github.com/nu7hatch/gouuid", + "vcs": "", "revision": "179d4d0c4d8d407a32af483c2354df1d2c91e6c3", "branch": "master" }, { "importpath": "github.com/opencontainers/runc/libcontainer/cgroups", "repository": "https://github.com/opencontainers/runc", + "vcs": "", "revision": "361f9b7921665b5894faef36fc8430aec573dfa4", "branch": "master", "path": "/libcontainer/cgroups" @@ -734,6 +851,7 @@ { "importpath": "github.com/opencontainers/runc/libcontainer/cgroups/fs", "repository": "https://github.com/opencontainers/runc", + "vcs": "", "revision": "361f9b7921665b5894faef36fc8430aec573dfa4", "branch": "master", "path": "/libcontainer/cgroups/fs" @@ -741,6 +859,7 @@ { "importpath": "github.com/opencontainers/runc/libcontainer/configs", "repository": "https://github.com/opencontainers/runc", + "vcs": "", "revision": "361f9b7921665b5894faef36fc8430aec573dfa4", "branch": "master", "path": "/libcontainer/configs" @@ -748,6 +867,7 @@ { "importpath": "github.com/opencontainers/runc/libcontainer/system", "repository": "https://github.com/opencontainers/runc", + "vcs": "", "revision": "361f9b7921665b5894faef36fc8430aec573dfa4", "branch": "master", "path": "/libcontainer/system" @@ -755,6 +875,7 @@ { "importpath": "github.com/opencontainers/runc/libcontainer/user", "repository": "https://github.com/opencontainers/runc", + "vcs": "", "revision": "3317785f562b363eb386a2fa4909a55f267088c8", "branch": "master", "path": "/libcontainer/user" @@ -762,6 +883,7 @@ { "importpath": "github.com/opencontainers/runc/libcontainer/utils", "repository": "https://github.com/opencontainers/runc", + "vcs": "", "revision": "361f9b7921665b5894faef36fc8430aec573dfa4", "branch": "master", "path": "/libcontainer/utils" @@ -769,18 +891,21 @@ { "importpath": "github.com/paypal/ionet", "repository": "https://github.com/paypal/ionet", + "vcs": "", "revision": "ed0aaebc541736bab972353125af442dcf829af2", "branch": "master" }, { "importpath": "github.com/pborman/uuid", "repository": "https://github.com/pborman/uuid", + "vcs": "", "revision": "cccd189d45f7ac3368a0d127efb7f4d08ae0b655", "branch": "master" }, { "importpath": "github.com/pmezard/go-difflib/difflib", "repository": "https://github.com/pmezard/go-difflib", + "vcs": "", "revision": "f78a839676152fd9f4863704f5d516195c18fc14", "branch": "master", "path": "/difflib" @@ -788,6 +913,7 @@ { "importpath": "github.com/prometheus/client_golang/prometheus", "repository": "https://github.com/prometheus/client_golang", + "vcs": "", "revision": "e51041b3fa41cece0dca035740ba6411905be473", "branch": "master", "path": "/prometheus" @@ -795,6 +921,7 @@ { "importpath": "github.com/prometheus/client_model/go", "repository": "https://github.com/prometheus/client_model", + "vcs": "", "revision": "fa8ad6fec33561be4280a8f0514318c79d7f6cb6", "branch": "master", "path": "/go" @@ -802,6 +929,7 @@ { "importpath": "github.com/prometheus/common/expfmt", "repository": "https://github.com/prometheus/common", + "vcs": "", "revision": "369ec0491ce7be15431bd4f23b7fa17308f94190", "branch": "master", "path": "/expfmt" @@ -809,6 +937,7 @@ { "importpath": "github.com/prometheus/common/model", "repository": "https://github.com/prometheus/common", + "vcs": "", "revision": "369ec0491ce7be15431bd4f23b7fa17308f94190", "branch": "master", "path": "/model" @@ -816,48 +945,56 @@ { "importpath": "github.com/prometheus/procfs", "repository": "https://github.com/prometheus/procfs", + "vcs": "", "revision": "454a56f35412459b5e684fd5ec0f9211b94f002a", "branch": "master" }, { "importpath": "github.com/russross/blackfriday", "repository": "https://github.com/russross/blackfriday", + "vcs": "", "revision": "a18a46c9b94395417241e528fc53478ad914c670", "branch": "master" }, { "importpath": "github.com/shiena/ansicolor", "repository": "https://github.com/shiena/ansicolor", + "vcs": "", "revision": "a422bbe96644373c5753384a59d678f7d261ff10", "branch": "master" }, { "importpath": "github.com/shurcooL/sanitized_anchor_name", "repository": "https://github.com/shurcooL/sanitized_anchor_name", + "vcs": "", "revision": "244f5ac324cb97e1987ef901a0081a77bfd8e845", "branch": "master" }, { "importpath": "github.com/spaolacci/murmur3", "repository": "https://github.com/spaolacci/murmur3", + "vcs": "", "revision": "0d12bf811670bf6a1a63828dfbd003eded177fce", "branch": "master" }, { "importpath": "github.com/spf13/cobra", "repository": "https://github.com/spf13/cobra", + "vcs": "", "revision": "8b2293c74173adb0e12df6862c1ea36afac2401f", "branch": "master" }, { "importpath": "github.com/spf13/pflag", "repository": "https://github.com/spf13/pflag", + "vcs": "", "revision": "08b1a584251b5b62f458943640fc8ebd4d50aaa5", "branch": "master" }, { "importpath": "github.com/stretchr/testify/assert", "repository": "https://github.com/stretchr/testify", + "vcs": "", "revision": "e3a8ff8ce36581f87a15341206f205b1da467059", "branch": "master", "path": "/assert" @@ -865,6 +1002,7 @@ { "importpath": "github.com/ugorji/go/codec", "repository": "https://github.com/ugorji/go", + "vcs": "", "revision": "c062049c1793b01a3cc3fe786108edabbaf7756b", "branch": "master", "path": "/codec" @@ -872,12 +1010,14 @@ { "importpath": "github.com/weaveworks/go-checkpoint", "repository": "https://github.com/weaveworks/go-checkpoint", + "vcs": "", "revision": "62324982ab514860761ec81e618664580513ffad", "branch": "master" }, { "importpath": "github.com/weaveworks/go-odp/odp", "repository": "https://github.com/weaveworks/go-odp", + "vcs": "", "revision": "f8c8c40c18898d7c4f6be33978d68f5d2810f373", "branch": "master", "path": "/odp" @@ -885,6 +1025,7 @@ { "importpath": "github.com/weaveworks/weave/common", "repository": "https://github.com/weaveworks/weave", + "vcs": "", "revision": "29f3d711c65121f436a9d191af4633ba4600d0fd", "branch": "master", "path": "/common" @@ -892,12 +1033,14 @@ { "importpath": "github.com/willdonnelly/passwd", "repository": "https://github.com/willdonnelly/passwd", + "vcs": "", "revision": "7935dab3074ca1d47c8805e0230f8685116b6019", "branch": "master" }, { "importpath": "golang.org/x/crypto/curve25519", "repository": "https://go.googlesource.com/crypto", + "vcs": "", "revision": "c8b9e6388ef638d5a8a9d865c634befdc46a6784", "branch": "master", "path": "/curve25519" @@ -905,6 +1048,7 @@ { "importpath": "golang.org/x/crypto/pbkdf2", "repository": "https://go.googlesource.com/crypto", + "vcs": "", "revision": "1f22c0103821b9390939b6776727195525381532", "branch": "master", "path": "/pbkdf2" @@ -912,6 +1056,7 @@ { "importpath": "golang.org/x/crypto/scrypt", "repository": "https://go.googlesource.com/crypto", + "vcs": "", "revision": "1f22c0103821b9390939b6776727195525381532", "branch": "master", "path": "/scrypt" @@ -919,6 +1064,7 @@ { "importpath": "golang.org/x/crypto/ssh", "repository": "https://go.googlesource.com/crypto", + "vcs": "", "revision": "c8b9e6388ef638d5a8a9d865c634befdc46a6784", "branch": "master", "path": "/ssh" @@ -926,6 +1072,7 @@ { "importpath": "golang.org/x/net/context", "repository": "https://go.googlesource.com/net", + "vcs": "", "revision": "2cba614e8ff920c60240d2677bc019af32ee04e5", "branch": "master", "path": "/context" @@ -933,6 +1080,7 @@ { "importpath": "golang.org/x/tools/go/ast/astutil", "repository": "https://go.googlesource.com/tools", + "vcs": "", "revision": "1cdaff4a02c554c9fb39dda0b56241c5f0949d91", "branch": "master", "path": "/go/ast/astutil" @@ -940,6 +1088,7 @@ { "importpath": "golang.org/x/tools/go/buildutil", "repository": "https://go.googlesource.com/tools", + "vcs": "", "revision": "1cdaff4a02c554c9fb39dda0b56241c5f0949d91", "branch": "master", "path": "/go/buildutil" @@ -947,6 +1096,7 @@ { "importpath": "golang.org/x/tools/go/exact", "repository": "https://go.googlesource.com/tools", + "vcs": "", "revision": "1cdaff4a02c554c9fb39dda0b56241c5f0949d91", "branch": "master", "path": "/go/exact" @@ -954,6 +1104,7 @@ { "importpath": "golang.org/x/tools/go/loader", "repository": "https://go.googlesource.com/tools", + "vcs": "", "revision": "1cdaff4a02c554c9fb39dda0b56241c5f0949d91", "branch": "master", "path": "/go/loader" @@ -961,6 +1112,7 @@ { "importpath": "golang.org/x/tools/go/types", "repository": "https://go.googlesource.com/tools", + "vcs": "", "revision": "1cdaff4a02c554c9fb39dda0b56241c5f0949d91", "branch": "master", "path": "/go/types" @@ -968,12 +1120,14 @@ { "importpath": "gopkg.in/yaml.v2", "repository": "https://gopkg.in/yaml.v2", + "vcs": "", "revision": "53feefa2559fb8dfa8d81baad31be332c97d6c77", "branch": "v2" }, { "importpath": "k8s.io/kubernetes/pkg", "repository": "https://github.com/kubernetes/kubernetes", + "vcs": "", "revision": "8eb19c78890876e1e655aa1aeb0d4c267d2c2632", "branch": "master", "path": "/pkg" @@ -981,6 +1135,7 @@ { "importpath": "k8s.io/kubernetes/third_party/forked/reflect", "repository": "https://github.com/kubernetes/kubernetes", + "vcs": "", "revision": "47acbd62ecf7e3d15bdde0fce48cde9610666a69", "branch": "master", "path": "/third_party/forked/reflect" @@ -988,6 +1143,7 @@ { "importpath": "k8s.io/kubernetes/third_party/golang/template", "repository": "https://github.com/kubernetes/kubernetes", + "vcs": "", "revision": "47acbd62ecf7e3d15bdde0fce48cde9610666a69", "branch": "master", "path": "/third_party/golang/template" @@ -995,6 +1151,7 @@ { "importpath": "speter.net/go/exp/math/dec/inf", "repository": "https://code.google.com/p/go-decimal-inf.exp", + "vcs": "", "revision": "42ca6cd68aa922bc3f32f1e056e61b65945d9ad7", "branch": "master" } From 2cfd3ffd461d092a854f57452ed5efabcd12b919 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Mon, 2 May 2016 15:26:31 +0200 Subject: [PATCH 15/19] Fix toggleHelp action, ignore moment locale (saves 100KB) --- client/app/scripts/actions/app-actions.js | 3 ++- client/webpack.local.config.js | 1 + client/webpack.production.config.js | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 970a57bb2..1202cd371 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -25,8 +25,9 @@ export function toggleHelp() { return (dispatch, getState) => { if (getState().get('showingHelp')) { dispatch(hideHelp()); + } else { + dispatch(showHelp()); } - dispatch(showHelp()); }; } diff --git a/client/webpack.local.config.js b/client/webpack.local.config.js index 95ae08f87..4698cb5cb 100644 --- a/client/webpack.local.config.js +++ b/client/webpack.local.config.js @@ -62,6 +62,7 @@ module.exports = { new webpack.optimize.CommonsChunkPlugin('vendors', 'vendors.js'), new webpack.HotModuleReplacementPlugin(), new webpack.NoErrorsPlugin(), + new webpack.IgnorePlugin(/^\.\/locale$/, [/moment$/]), new HtmlWebpackPlugin({ chunks: ['vendors', 'contrast-app'], template: 'app/html/index.html', diff --git a/client/webpack.production.config.js b/client/webpack.production.config.js index 8b2fb4c14..21ad03ef0 100644 --- a/client/webpack.production.config.js +++ b/client/webpack.production.config.js @@ -87,6 +87,7 @@ module.exports = { new webpack.DefinePlugin(GLOBALS), new webpack.optimize.CommonsChunkPlugin('vendors', 'vendors.js'), new webpack.optimize.OccurenceOrderPlugin(true), + new webpack.IgnorePlugin(/^\.\/locale$/, [/moment$/]), new webpack.optimize.UglifyJsPlugin({ sourceMap: false, compress: { From 7bf3aacd4f1b518838747ce4b8f4077f3cd64853 Mon Sep 17 00:00:00 2001 From: Alfonso Acosta Date: Tue, 3 May 2016 09:00:44 +0000 Subject: [PATCH 16/19] Review: remove cloudformation badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 922db5cb2..b4f3d3ac5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Weave Scope - Monitoring, visualisation & management for Docker & Kubernetes -[![Circle CI](https://circleci.com/gh/weaveworks/scope/tree/master.svg?style=shield)](https://circleci.com/gh/weaveworks/scope/tree/master) [![Coverage Status](https://coveralls.io/repos/weaveworks/scope/badge.svg)](https://coveralls.io/r/weaveworks/scope) [![Slack Status](https://weave-scope-slack.herokuapp.com/badge.svg)](https://weave-scope-slack.herokuapp.com) [![Go Report Card](https://goreportcard.com/badge/github.com/weaveworks/scope)](https://goreportcard.com/report/github.com/weaveworks/scope) [](https://www.weave.works/deploy-weave-aws-cloudformation-template/) +[![Circle CI](https://circleci.com/gh/weaveworks/scope/tree/master.svg?style=shield)](https://circleci.com/gh/weaveworks/scope/tree/master) [![Coverage Status](https://coveralls.io/repos/weaveworks/scope/badge.svg)](https://coveralls.io/r/weaveworks/scope) [![Slack Status](https://weave-scope-slack.herokuapp.com/badge.svg)](https://weave-scope-slack.herokuapp.com) [![Go Report Card](https://goreportcard.com/badge/github.com/weaveworks/scope)](https://goreportcard.com/report/github.com/weaveworks/scope) Weave Scope automatically generates a map of your application, enabling you to intuitively understand, monitor, and control your containerized, microservices based application. From 8395c3ca4b9822858b9292f7b54c7fec7b09fa6f Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Fri, 29 Apr 2016 14:52:26 +0100 Subject: [PATCH 17/19] Index Pods by UID and join with containers based on this. --- probe/kubernetes/controls.go | 21 +++++++-- probe/kubernetes/pod.go | 48 +++++++++---------- probe/kubernetes/reporter.go | 39 ++++++++-------- probe/kubernetes/reporter_test.go | 77 ++++++++++--------------------- probe/kubernetes/service.go | 5 ++ prog/probe.go | 1 + render/detailed/node_test.go | 54 ++++++++++++---------- render/detailed/parents_test.go | 1 + render/pod.go | 44 ++++++++---------- report/id.go | 12 ++--- test/fixture/report_fixture.go | 58 +++++++++++------------ 11 files changed, 173 insertions(+), 187 deletions(-) diff --git a/probe/kubernetes/controls.go b/probe/kubernetes/controls.go index df139bf42..a7c40349c 100644 --- a/probe/kubernetes/controls.go +++ b/probe/kubernetes/controls.go @@ -51,19 +51,30 @@ func (r *Reporter) deletePod(req xfer.Request, namespaceID, podID string) xfer.R } // CapturePod is exported for testing -func CapturePod(f func(xfer.Request, string, string) xfer.Response) func(xfer.Request) xfer.Response { +func (r *Reporter) CapturePod(f func(xfer.Request, string, string) xfer.Response) func(xfer.Request) xfer.Response { return func(req xfer.Request) xfer.Response { - namespaceID, podID, ok := report.ParsePodNodeID(req.NodeID) + uid, ok := report.ParsePodNodeID(req.NodeID) if !ok { return xfer.ResponseErrorf("Invalid ID: %s", req.NodeID) } - return f(req, namespaceID, podID) + // find pod by UID + var pod Pod + r.client.WalkPods(func(p Pod) error { + if p.UID() == uid { + pod = p + } + return nil + }) + if pod == nil { + return xfer.ResponseErrorf("Pod not found: %s", uid) + } + return f(req, pod.Namespace(), pod.Name()) } } func (r *Reporter) registerControls() { - controls.Register(GetLogs, CapturePod(r.GetLogs)) - controls.Register(DeletePod, CapturePod(r.deletePod)) + controls.Register(GetLogs, r.CapturePod(r.GetLogs)) + controls.Register(DeletePod, r.CapturePod(r.deletePod)) } func (r *Reporter) deregisterControls() { diff --git a/probe/kubernetes/pod.go b/probe/kubernetes/pod.go index b739386a1..02db0e382 100644 --- a/probe/kubernetes/pod.go +++ b/probe/kubernetes/pod.go @@ -11,24 +11,23 @@ import ( // These constants are keys used in node metadata const ( - PodID = "kubernetes_pod_id" - PodName = "kubernetes_pod_name" - PodCreated = "kubernetes_pod_created" - PodContainerIDs = "kubernetes_pod_container_ids" - PodState = "kubernetes_pod_state" - PodLabelPrefix = "kubernetes_pod_labels_" - PodIP = "kubernetes_pod_ip" - ServiceIDs = "kubernetes_service_ids" + PodID = "kubernetes_pod_id" + PodName = "kubernetes_pod_name" + PodCreated = "kubernetes_pod_created" + PodState = "kubernetes_pod_state" + PodLabelPrefix = "kubernetes_pod_labels_" + PodIP = "kubernetes_pod_ip" + ServiceIDs = "kubernetes_service_ids" StateDeleted = "deleted" ) // Pod represents a Kubernetes pod type Pod interface { + UID() string ID() string Name() string Namespace() string - ContainerIDs() []string Created() string AddServiceID(id string) Labels() labels.Labels @@ -47,6 +46,14 @@ func NewPod(p *api.Pod) Pod { return &pod{Pod: p} } +func (p *pod) UID() string { + // Work around for master pod not reporting the right UID. + if hash, ok := p.ObjectMeta.Annotations["kubernetes.io/config.hash"]; ok { + return hash + } + return string(p.ObjectMeta.UID) +} + func (p *pod) ID() string { return p.ObjectMeta.Namespace + "/" + p.ObjectMeta.Name } @@ -63,14 +70,6 @@ func (p *pod) Created() string { return p.ObjectMeta.CreationTimestamp.Format(time.RFC822) } -func (p *pod) ContainerIDs() []string { - ids := []string{} - for _, container := range p.Status.ContainerStatuses { - ids = append(ids, strings.TrimPrefix(container.ContainerID, "docker://")) - } - return ids -} - func (p *pod) Labels() labels.Labels { return labels.Set(p.ObjectMeta.Labels) } @@ -88,14 +87,13 @@ func (p *pod) NodeName() string { } func (p *pod) GetNode(probeID string) report.Node { - n := report.MakeNodeWith(report.MakePodNodeID(p.Namespace(), p.Name()), map[string]string{ - PodID: p.ID(), - PodName: p.Name(), - Namespace: p.Namespace(), - PodCreated: p.Created(), - PodContainerIDs: strings.Join(p.ContainerIDs(), " "), - PodState: p.State(), - PodIP: p.Status.PodIP, + n := report.MakeNodeWith(report.MakePodNodeID(p.UID()), map[string]string{ + PodID: p.ID(), + PodName: p.Name(), + Namespace: p.Namespace(), + PodCreated: p.Created(), + PodState: p.State(), + PodIP: p.Status.PodIP, report.ControlProbeID: probeID, }) if len(p.serviceIDs) > 0 { diff --git a/probe/kubernetes/reporter.go b/probe/kubernetes/reporter.go index 6518928d5..624baac9d 100644 --- a/probe/kubernetes/reporter.go +++ b/probe/kubernetes/reporter.go @@ -82,7 +82,7 @@ func (r *Reporter) podEvent(e Event, pod Pod) { rpt.Shortcut = true rpt.Pod.AddNode( report.MakeNodeWith( - report.MakePodNodeID(pod.Namespace(), pod.Name()), + report.MakePodNodeID(pod.UID()), map[string]string{PodState: StateDeleted}, ), ) @@ -90,6 +90,21 @@ func (r *Reporter) podEvent(e Event, pod Pod) { } } +// Tag adds pod parents to container nodes. +func (r *Reporter) Tag(rpt report.Report) (report.Report, error) { + for id, n := range rpt.Container.Nodes { + uid, ok := n.Latest.Lookup(docker.LabelPrefix + "io.kubernetes.pod.uid") + if !ok { + continue + } + rpt.Container.Nodes[id] = n.WithParents(report.EmptySets.Add( + report.Pod, + report.EmptyStringSet.Add(report.MakePodNodeID(uid)), + )) + } + return rpt, nil +} + // Report generates a Report containing Container and ContainerImage topologies func (r *Reporter) Report() (report.Report, error) { result := report.MakeReport() @@ -97,13 +112,12 @@ func (r *Reporter) Report() (report.Report, error) { if err != nil { return result, err } - podTopology, containerTopology, err := r.podTopology(services) + podTopology, err := r.podTopology(services) if err != nil { return result, err } result.Service = result.Service.Merge(serviceTopology) result.Pod = result.Pod.Merge(podTopology) - result.Container = result.Container.Merge(containerTopology) return result, nil } @@ -143,13 +157,12 @@ var GetNodeName = func(r *Reporter) (string, error) { return nodeName, err } -func (r *Reporter) podTopology(services []Service) (report.Topology, report.Topology, error) { +func (r *Reporter) podTopology(services []Service) (report.Topology, error) { var ( pods = report.MakeTopology(). WithMetadataTemplates(PodMetadataTemplates). WithTableTemplates(PodTableTemplates) - containers = report.MakeTopology() - selectors = map[string]labels.Selector{} + selectors = map[string]labels.Selector{} ) pods.Controls.AddControl(report.Control{ ID: GetLogs, @@ -169,7 +182,7 @@ func (r *Reporter) podTopology(services []Service) (report.Topology, report.Topo thisNodeName, err := GetNodeName(r) if err != nil { - return pods, containers, err + return pods, err } err = r.client.WalkPods(func(p Pod) error { if p.NodeName() != thisNodeName { @@ -180,18 +193,8 @@ func (r *Reporter) podTopology(services []Service) (report.Topology, report.Topo p.AddServiceID(serviceID) } } - nodeID := report.MakePodNodeID(p.Namespace(), p.Name()) pods = pods.AddNode(p.GetNode(r.probeID)) - - for _, containerID := range p.ContainerIDs() { - container := report.MakeNodeWith(report.MakeContainerNodeID(containerID), map[string]string{ - PodID: p.ID(), - Namespace: p.Namespace(), - docker.ContainerID: containerID, - }).WithParents(report.EmptySets.Add(report.Pod, report.MakeStringSet(nodeID))) - containers.AddNode(container) - } return nil }) - return pods, containers, err + return pods, err } diff --git a/probe/kubernetes/reporter_test.go b/probe/kubernetes/reporter_test.go index ec6d26d01..34f2c07a5 100644 --- a/probe/kubernetes/reporter_test.go +++ b/probe/kubernetes/reporter_test.go @@ -25,6 +25,7 @@ var ( TypeMeta: podTypeMeta, ObjectMeta: api.ObjectMeta{ Name: "pong-a", + UID: "pong-a", Namespace: "ping", CreationTimestamp: unversioned.Now(), Labels: map[string]string{"ponger": "true"}, @@ -44,6 +45,7 @@ var ( TypeMeta: podTypeMeta, ObjectMeta: api.ObjectMeta{ Name: "pong-b", + UID: "pong-b", Namespace: "ping", CreationTimestamp: unversioned.Now(), Labels: map[string]string{"ponger": "true"}, @@ -127,7 +129,8 @@ func (*mockClient) WalkNodes(f func(*api.Node) error) error { } func (*mockClient) WatchPods(func(kubernetes.Event, kubernetes.Pod)) {} func (c *mockClient) GetLogs(namespaceID, podName string) (io.ReadCloser, error) { - r, ok := c.logs[report.MakePodNodeID(namespaceID, podName)] + fmt.Println("here", namespaceID, podName) + r, ok := c.logs[namespaceID+";"+podName] if !ok { return nil, fmt.Errorf("Not found") } @@ -157,8 +160,8 @@ func TestReporter(t *testing.T) { return nodeName, nil } - pod1ID := report.MakePodNodeID("ping", "pong-a") - pod2ID := report.MakePodNodeID("ping", "pong-b") + pod1ID := report.MakePodNodeID("pong-a") + pod2ID := report.MakePodNodeID("pong-b") serviceID := report.MakeServiceNodeID("ping", "pongservice") rpt, _ := kubernetes.NewReporter(newMockClient(), nil, "", nil).Report() @@ -169,20 +172,18 @@ func TestReporter(t *testing.T) { latest map[string]string }{ {pod1ID, serviceID, map[string]string{ - kubernetes.PodID: "ping/pong-a", - kubernetes.PodName: "pong-a", - kubernetes.Namespace: "ping", - kubernetes.PodCreated: pod1.Created(), - kubernetes.PodContainerIDs: "container1 container2", - kubernetes.ServiceIDs: "ping/pongservice", + kubernetes.PodID: "ping/pong-a", + kubernetes.PodName: "pong-a", + kubernetes.Namespace: "ping", + kubernetes.PodCreated: pod1.Created(), + kubernetes.ServiceIDs: "ping/pongservice", }}, {pod2ID, serviceID, map[string]string{ - kubernetes.PodID: "ping/pong-b", - kubernetes.PodName: "pong-b", - kubernetes.Namespace: "ping", - kubernetes.PodCreated: pod1.Created(), - kubernetes.PodContainerIDs: "container3 container4", - kubernetes.ServiceIDs: "ping/pongservice", + kubernetes.PodID: "ping/pong-b", + kubernetes.PodName: "pong-b", + kubernetes.Namespace: "ping", + kubernetes.PodCreated: pod1.Created(), + kubernetes.ServiceIDs: "ping/pongservice", }}, } { node, ok := rpt.Pod.Nodes[pod.id] @@ -219,34 +220,6 @@ func TestReporter(t *testing.T) { } } } - - // Reporter should have tagged the containers - for _, pod := range []struct { - id, nodeID string - containers []string - }{ - {"ping/pong-a", pod1ID, []string{"container1", "container2"}}, - {"ping/pong-b", pod2ID, []string{"container3", "container4"}}, - } { - for _, containerID := range pod.containers { - node, ok := rpt.Container.Nodes[report.MakeContainerNodeID(containerID)] - if !ok { - t.Errorf("Expected report to have container %q, but not found", containerID) - } - // container should have pod id - if have, ok := node.Latest.Lookup(kubernetes.PodID); !ok || have != pod.id { - t.Errorf("Expected container %s latest %q: %q, got %q", containerID, kubernetes.PodID, pod.id, have) - } - // container should have namespace - if have, ok := node.Latest.Lookup(kubernetes.Namespace); !ok || have != "ping" { - t.Errorf("Expected container %s latest %q: %q, got %q", containerID, kubernetes.Namespace, "ping", have) - } - // container should have pod parent - if parents, ok := node.Parents.Lookup(report.Pod); !ok || !parents.Contains(pod.nodeID) { - t.Errorf("Expected container %s to have parent service %q, got %q", containerID, pod.nodeID, parents) - } - } - } } type callbackReadCloser struct { @@ -269,7 +242,7 @@ func TestReporterGetLogs(t *testing.T) { // Should error on invalid IDs { - resp := kubernetes.CapturePod(reporter.GetLogs)(xfer.Request{ + resp := reporter.CapturePod(reporter.GetLogs)(xfer.Request{ NodeID: "invalidID", Control: kubernetes.GetLogs, }) @@ -280,39 +253,39 @@ func TestReporterGetLogs(t *testing.T) { // Should pass through errors from k8s (e.g if pod does not exist) { - resp := kubernetes.CapturePod(reporter.GetLogs)(xfer.Request{ + resp := reporter.CapturePod(reporter.GetLogs)(xfer.Request{ AppID: "appID", - NodeID: report.MakePodNodeID("not", "found"), + NodeID: report.MakePodNodeID("notfound"), Control: kubernetes.GetLogs, }) - if want := "Not found"; resp.Error != want { + if want := "Pod not found: notfound"; resp.Error != want { t.Errorf("Expected error on invalid ID: %q, got %q", want, resp.Error) } } - pod1ID := report.MakePodNodeID("ping", "pong-a") + podNamespaceAndID := "ping;pong-a" pod1Request := xfer.Request{ AppID: "appID", - NodeID: pod1ID, + NodeID: report.MakePodNodeID("pong-a"), Control: kubernetes.GetLogs, } // Inject our logs content, and watch for it to be closed closed := false wantContents := "logs: ping/pong-a" - client.logs[pod1ID] = &callbackReadCloser{Reader: strings.NewReader(wantContents), close: func() error { + client.logs[podNamespaceAndID] = &callbackReadCloser{Reader: strings.NewReader(wantContents), close: func() error { closed = true return nil }} // Should create a new pipe for the stream - resp := kubernetes.CapturePod(reporter.GetLogs)(pod1Request) + resp := reporter.CapturePod(reporter.GetLogs)(pod1Request) if resp.Pipe == "" { t.Errorf("Expected pipe id to be returned, but got %#v", resp) } pipe, ok := pipes[resp.Pipe] if !ok { - t.Errorf("Expected pipe %q to have been created, but wasn't", resp.Pipe) + t.Fatalf("Expected pipe %q to have been created, but wasn't", resp.Pipe) } // Should push logs from k8s client into the pipe diff --git a/probe/kubernetes/service.go b/probe/kubernetes/service.go index 99886303d..c5ca38a3f 100644 --- a/probe/kubernetes/service.go +++ b/probe/kubernetes/service.go @@ -20,6 +20,7 @@ const ( // Service represents a Kubernetes service type Service interface { + UID() string ID() string Name() string Namespace() string @@ -36,6 +37,10 @@ func NewService(s *api.Service) Service { return &service{Service: s} } +func (s *service) UID() string { + return string(s.ObjectMeta.UID) +} + func (s *service) ID() string { return s.ObjectMeta.Namespace + "/" + s.ObjectMeta.Name } diff --git a/prog/probe.go b/prog/probe.go index fdf987458..075255f97 100644 --- a/prog/probe.go +++ b/prog/probe.go @@ -152,6 +152,7 @@ func probeMain(flags probeFlags) { reporter := kubernetes.NewReporter(client, clients, probeID, p) defer reporter.Stop() p.AddReporter(reporter) + p.AddTagger(reporter) } else { log.Errorf("Kubernetes: failed to start client: %v", err) log.Errorf("Kubernetes: make sure to run Scope inside a POD with a service account or provide a valid kubernetes.api url") diff --git a/render/detailed/node_test.go b/render/detailed/node_test.go index 8e2d4e411..9345a5c2c 100644 --- a/render/detailed/node_test.go +++ b/render/detailed/node_test.go @@ -236,6 +236,11 @@ func TestMakeDetailedContainerNode(t *testing.T) { Label: fixture.ServerHostName, TopologyID: "hosts", }, + { + ID: fixture.ServerPodNodeID, + Label: "pong-b", + TopologyID: "pods", + }, }, Connections: []detailed.ConnectionsSummary{ { @@ -310,12 +315,13 @@ func TestMakeDetailedPodNode(t *testing.T) { serverProcessNodeSummary.Linkable = true // Temporary workaround for: https://github.com/weaveworks/scope/issues/1295 want := detailed.Node{ NodeSummary: detailed.NodeSummary{ - ID: id, - Label: "pong-b", - Rank: "ping/pong-b", - Shape: "heptagon", - Linkable: true, - Pseudo: false, + ID: id, + Label: "pong-b", + LabelMinor: "1 container", + Rank: "ping/pong-b", + Shape: "heptagon", + Linkable: true, + Pseudo: false, Metadata: []report.MetadataRow{ {ID: "kubernetes_pod_id", Label: "ID", Value: "ping/pong-b", Priority: 1}, {ID: "kubernetes_pod_state", Label: "State", Value: "running", Priority: 2}, @@ -358,24 +364,6 @@ func TestMakeDetailedPodNode(t *testing.T) { Label: "Inbound", Columns: detailed.NormalColumns, Connections: []detailed.Connection{ - { - ID: fmt.Sprintf("%s:%s-%s:%s-%d", render.IncomingInternetID, "", fixture.ServerPodNodeID, "", 80), - NodeID: render.IncomingInternetID, - Label: render.InboundMajor, - Linkable: true, - Metadata: []report.MetadataRow{ - { - ID: "port", - Value: "80", - Datatype: "number", - }, - { - ID: "count", - Value: "1", - Datatype: "number", - }, - }, - }, { ID: fmt.Sprintf("%s:%s-%s:%s-%d", fixture.ClientPodNodeID, "", fixture.ServerPodNodeID, "", 80), NodeID: fixture.ClientPodNodeID, @@ -394,6 +382,24 @@ func TestMakeDetailedPodNode(t *testing.T) { }, }, }, + { + ID: fmt.Sprintf("%s:%s-%s:%s-%d", render.IncomingInternetID, "", fixture.ServerPodNodeID, "", 80), + NodeID: render.IncomingInternetID, + Label: render.InboundMajor, + Linkable: true, + Metadata: []report.MetadataRow{ + { + ID: "port", + Value: "80", + Datatype: "number", + }, + { + ID: "count", + Value: "1", + Datatype: "number", + }, + }, + }, }, }, { diff --git a/render/detailed/parents_test.go b/render/detailed/parents_test.go index 5351acdb6..1fbd5e3d2 100644 --- a/render/detailed/parents_test.go +++ b/render/detailed/parents_test.go @@ -40,6 +40,7 @@ func TestParents(t *testing.T) { want: []detailed.Parent{ {ID: fixture.ClientContainerImageNodeID, Label: fixture.ClientContainerImageName, TopologyID: "containers-by-image"}, {ID: fixture.ClientHostNodeID, Label: fixture.ClientHostName, TopologyID: "hosts"}, + {ID: fixture.ClientPodNodeID, Label: "pong-a", TopologyID: "pods"}, }, }, { diff --git a/render/pod.go b/render/pod.go index c3cc7e77f..fdb1fda37 100644 --- a/render/pod.go +++ b/render/pod.go @@ -3,6 +3,7 @@ package render import ( "strings" + "github.com/weaveworks/scope/probe/docker" "github.com/weaveworks/scope/probe/kubernetes" "github.com/weaveworks/scope/report" ) @@ -61,7 +62,7 @@ var PodServiceRenderer = FilterEmpty(report.Pod, // It does not have enough info to do that, and the resulting graph // must be merged with a container graph to get that info. func MapContainer2Pod(n report.Node, _ report.Networks) report.Nodes { - // Uncontainerd becomes unmanaged in the pods view + // Uncontained becomes unmanaged in the pods view if strings.HasPrefix(n.ID, MakePseudoNodeID(UncontainedID)) { id := MakePseudoNodeID(UnmanagedID, report.ExtractHostID(n)) node := NewDerivedPseudoNode(id, n) @@ -73,34 +74,25 @@ func MapContainer2Pod(n report.Node, _ report.Networks) report.Nodes { return report.Nodes{n.ID: n} } - // Otherwise, if some some reason the container doesn't have a pod_id (maybe - // slightly out of sync reports, or its not in a pod), just drop it - namespace, ok := n.Latest.Lookup(kubernetes.Namespace) - if !ok { - id := MakePseudoNodeID(UnmanagedID, report.ExtractHostID(n)) - node := NewDerivedPseudoNode(id, n) - return report.Nodes{id: node} + // Ignore non-running containers + if state, ok := n.Latest.Lookup(docker.ContainerState); ok && state != docker.StateRunning { + return report.Nodes{} } - podID, ok := n.Latest.Lookup(kubernetes.PodID) - if !ok { - id := MakePseudoNodeID(UnmanagedID, report.ExtractHostID(n)) - node := NewDerivedPseudoNode(id, n) - return report.Nodes{id: node} - } - podName := strings.TrimPrefix(podID, namespace+"/") - id := report.MakePodNodeID(namespace, podName) - // Due to a bug in kubernetes, addon pods on the master node are not returned - // from the API. Adding the namespace and pod name is a workaround until - // https://github.com/kubernetes/kubernetes/issues/14738 is fixed. - return report.Nodes{ - id: NewDerivedNode(id, n). - WithTopology(report.Pod). - WithLatests(map[string]string{ - kubernetes.Namespace: namespace, - kubernetes.PodName: podName, - }), + // Otherwise, if some some reason the container doesn't have a pod uid (maybe + // slightly out of sync reports, or its not in a pod), make it part of unmanaged. + uid, ok := n.Latest.Lookup(docker.LabelPrefix + "io.kubernetes.pod.uid") + if !ok { + id := MakePseudoNodeID(UnmanagedID, report.ExtractHostID(n)) + node := NewDerivedPseudoNode(id, n) + return report.Nodes{id: node} } + + id := report.MakePodNodeID(uid) + node := NewDerivedNode(id, n). + WithTopology(report.Pod) + node.Counters = node.Counters.Add(n.Topology, 1) + return report.Nodes{id: node} } // MapPod2Service maps pod Nodes to service Nodes. diff --git a/report/id.go b/report/id.go index 2ffc0b2a3..899ff427c 100644 --- a/report/id.go +++ b/report/id.go @@ -112,8 +112,8 @@ func MakeContainerImageNodeID(containerImageID string) string { } // MakePodNodeID produces a pod node ID from its composite parts. -func MakePodNodeID(namespaceID, podID string) string { - return namespaceID + ScopeDelim + podID +func MakePodNodeID(uid string) string { + return uid + ScopeDelim + "" } // MakeServiceNodeID produces a service node ID from its composite parts. @@ -167,12 +167,12 @@ func ParseAddressNodeID(addressNodeID string) (hostID, address string, ok bool) } // ParsePodNodeID produces the namespace ID and pod ID from an pod node ID. -func ParsePodNodeID(podNodeID string) (namespaceID, podID string, ok bool) { +func ParsePodNodeID(podNodeID string) (uid string, ok bool) { fields := strings.SplitN(podNodeID, ScopeDelim, 2) - if len(fields) != 2 { - return "", "", false + if len(fields) != 2 || fields[1] != "" { + return "", false } - return fields[0], fields[1], true + return fields[0], true } // ExtractHostID extracts the host id from Node diff --git a/test/fixture/report_fixture.go b/test/fixture/report_fixture.go index f6dc2f9be..679d3b718 100644 --- a/test/fixture/report_fixture.go +++ b/test/fixture/report_fixture.go @@ -92,8 +92,10 @@ var ( KubernetesNamespace = "ping" ClientPodID = "ping/pong-a" ServerPodID = "ping/pong-b" - ClientPodNodeID = report.MakePodNodeID(KubernetesNamespace, "pong-a") - ServerPodNodeID = report.MakePodNodeID(KubernetesNamespace, "pong-b") + ClientPodUID = "5d4c3b2a1" + ServerPodUID = "i9h8g7f6e" + ClientPodNodeID = report.MakePodNodeID(ClientPodUID) + ServerPodNodeID = report.MakePodNodeID(ServerPodUID) ServiceID = "ping/pongservice" ServiceNodeID = report.MakeServiceNodeID(KubernetesNamespace, "pongservice") @@ -258,21 +260,20 @@ var ( ClientContainerNodeID: report.MakeNodeWith( ClientContainerNodeID, map[string]string{ - docker.ContainerID: ClientContainerID, - docker.ContainerName: ClientContainerName, - docker.ContainerHostname: ClientContainerHostname, - docker.ImageID: ClientContainerImageID, - report.HostNodeID: ClientHostNodeID, - docker.LabelPrefix + "io.kubernetes.pod.name": ClientPodID, - kubernetes.PodID: ClientPodID, - kubernetes.Namespace: KubernetesNamespace, - docker.ContainerState: docker.StateRunning, - docker.ContainerStateHuman: docker.StateRunning, + docker.ContainerID: ClientContainerID, + docker.ContainerName: ClientContainerName, + docker.ContainerHostname: ClientContainerHostname, + docker.ImageID: ClientContainerImageID, + report.HostNodeID: ClientHostNodeID, + docker.LabelPrefix + "io.kubernetes.pod.uid": ClientPodUID, + kubernetes.Namespace: KubernetesNamespace, + docker.ContainerState: docker.StateRunning, + docker.ContainerStateHuman: docker.StateRunning, }). WithTopology(report.Container).WithParents(report.EmptySets. Add("host", report.MakeStringSet(ClientHostNodeID)). Add("container_image", report.MakeStringSet(ClientContainerImageNodeID)). - Add("pod", report.MakeStringSet(ClientPodID)), + Add("pod", report.MakeStringSet(ClientPodNodeID)), ).WithMetrics(report.Metrics{ docker.CPUTotalUsage: ClientContainerCPUMetric, docker.MemoryUsage: ClientContainerMemoryMetric, @@ -290,14 +291,13 @@ var ( docker.LabelPrefix + detailed.AmazonECSContainerNameLabel: "server", docker.LabelPrefix + "foo1": "bar1", docker.LabelPrefix + "foo2": "bar2", - docker.LabelPrefix + "io.kubernetes.pod.name": ServerPodID, - kubernetes.PodID: ServerPodID, + docker.LabelPrefix + "io.kubernetes.pod.uid": ServerPodUID, kubernetes.Namespace: KubernetesNamespace, }). WithTopology(report.Container).WithParents(report.EmptySets. Add("host", report.MakeStringSet(ServerHostNodeID)). Add("container_image", report.MakeStringSet(ServerContainerImageNodeID)). - Add("pod", report.MakeStringSet(ServerPodID)), + Add("pod", report.MakeStringSet(ServerPodNodeID)), ).WithMetrics(report.Metrics{ docker.CPUTotalUsage: ServerContainerCPUMetric, docker.MemoryUsage: ServerContainerMemoryMetric, @@ -366,29 +366,25 @@ var ( Pod: report.Topology{ Nodes: report.Nodes{ ClientPodNodeID: report.MakeNodeWith( - ClientPodNodeID, map[string]string{ - kubernetes.PodID: ClientPodID, - kubernetes.PodName: "pong-a", - kubernetes.Namespace: KubernetesNamespace, - kubernetes.PodContainerIDs: ClientContainerID, - kubernetes.ServiceIDs: ServiceID, - report.HostNodeID: ClientHostNodeID, + kubernetes.PodID: ClientPodID, + kubernetes.PodName: "pong-a", + kubernetes.Namespace: KubernetesNamespace, + kubernetes.ServiceIDs: ServiceID, + report.HostNodeID: ClientHostNodeID, }). WithTopology(report.Pod).WithParents(report.EmptySets. Add("host", report.MakeStringSet(ClientHostNodeID)). Add("service", report.MakeStringSet(ServiceID)), ), ServerPodNodeID: report.MakeNodeWith( - ServerPodNodeID, map[string]string{ - kubernetes.PodID: ServerPodID, - kubernetes.PodName: "pong-b", - kubernetes.Namespace: KubernetesNamespace, - kubernetes.PodState: "running", - kubernetes.PodContainerIDs: ServerContainerID, - kubernetes.ServiceIDs: ServiceID, - report.HostNodeID: ServerHostNodeID, + kubernetes.PodID: ServerPodID, + kubernetes.PodName: "pong-b", + kubernetes.Namespace: KubernetesNamespace, + kubernetes.PodState: "running", + kubernetes.ServiceIDs: ServiceID, + report.HostNodeID: ServerHostNodeID, }). WithTopology(report.Pod).WithParents(report.EmptySets. Add("host", report.MakeStringSet(ServerHostNodeID)). From 1c74dc2c05c8803cb6b9cad51ff2f102eb264e62 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Tue, 3 May 2016 11:51:43 +0100 Subject: [PATCH 18/19] Review feedback --- probe/kubernetes/reporter_test.go | 34 +++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/probe/kubernetes/reporter_test.go b/probe/kubernetes/reporter_test.go index 34f2c07a5..aa7fc14a3 100644 --- a/probe/kubernetes/reporter_test.go +++ b/probe/kubernetes/reporter_test.go @@ -9,14 +9,19 @@ import ( "k8s.io/kubernetes/pkg/api" "k8s.io/kubernetes/pkg/api/unversioned" + "k8s.io/kubernetes/pkg/types" "github.com/weaveworks/scope/common/xfer" + "github.com/weaveworks/scope/probe/docker" "github.com/weaveworks/scope/probe/kubernetes" "github.com/weaveworks/scope/report" + "github.com/weaveworks/scope/test/reflect" ) var ( nodeName = "nodename" + pod1UID = "a1b2c3d4e5" + pod2UID = "f6g7h8i9j0" podTypeMeta = unversioned.TypeMeta{ Kind: "Pod", APIVersion: "v1", @@ -25,7 +30,7 @@ var ( TypeMeta: podTypeMeta, ObjectMeta: api.ObjectMeta{ Name: "pong-a", - UID: "pong-a", + UID: types.UID(pod1UID), Namespace: "ping", CreationTimestamp: unversioned.Now(), Labels: map[string]string{"ponger": "true"}, @@ -45,7 +50,7 @@ var ( TypeMeta: podTypeMeta, ObjectMeta: api.ObjectMeta{ Name: "pong-b", - UID: "pong-b", + UID: types.UID(pod2UID), Namespace: "ping", CreationTimestamp: unversioned.Now(), Labels: map[string]string{"ponger": "true"}, @@ -129,7 +134,6 @@ func (*mockClient) WalkNodes(f func(*api.Node) error) error { } func (*mockClient) WatchPods(func(kubernetes.Event, kubernetes.Pod)) {} func (c *mockClient) GetLogs(namespaceID, podName string) (io.ReadCloser, error) { - fmt.Println("here", namespaceID, podName) r, ok := c.logs[namespaceID+";"+podName] if !ok { return nil, fmt.Errorf("Not found") @@ -160,8 +164,8 @@ func TestReporter(t *testing.T) { return nodeName, nil } - pod1ID := report.MakePodNodeID("pong-a") - pod2ID := report.MakePodNodeID("pong-b") + pod1ID := report.MakePodNodeID(pod1UID) + pod2ID := report.MakePodNodeID(pod2UID) serviceID := report.MakeServiceNodeID("ping", "pongservice") rpt, _ := kubernetes.NewReporter(newMockClient(), nil, "", nil).Report() @@ -222,6 +226,24 @@ func TestReporter(t *testing.T) { } } +func TestTagger(t *testing.T) { + rpt := report.MakeReport() + rpt.Container.AddNode(report.MakeNodeWith("container1", map[string]string{ + docker.LabelPrefix + "io.kubernetes.pod.uid": "123456", + })) + + rpt, err := kubernetes.NewReporter(newMockClient(), nil, "", nil).Tag(rpt) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + have, ok := rpt.Container.Nodes["container1"].Parents.Lookup(report.Pod) + want := report.EmptyStringSet.Add(report.MakePodNodeID("123456")) + if !ok || !reflect.DeepEqual(have, want) { + t.Errorf("Expected container to have pod parent %v %v", have, want) + } +} + type callbackReadCloser struct { io.Reader close func() error @@ -266,7 +288,7 @@ func TestReporterGetLogs(t *testing.T) { podNamespaceAndID := "ping;pong-a" pod1Request := xfer.Request{ AppID: "appID", - NodeID: report.MakePodNodeID("pong-a"), + NodeID: report.MakePodNodeID(pod1UID), Control: kubernetes.GetLogs, } From 1c5f0ac0411796e11bb8a523c70651468b1cc261 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Fri, 29 Apr 2016 19:25:28 +0100 Subject: [PATCH 19/19] Don't attribute conntracked connections to k8s pause containers. --- probe/docker/registry.go | 13 +++++++++++++ probe/docker/registry_test.go | 15 +++++++++++++++ probe/kubernetes/reporter.go | 28 ++++++++++++++++++++++++++++ render/container.go | 18 ++++++------------ render/container_internal_test.go | 20 -------------------- render/detailed/parents.go | 3 +-- render/detailed/summary.go | 4 ++-- report/id.go | 3 +++ 8 files changed, 68 insertions(+), 36 deletions(-) delete mode 100644 render/container_internal_test.go diff --git a/probe/docker/registry.go b/probe/docker/registry.go index 37b750f28..8014a87ab 100644 --- a/probe/docker/registry.go +++ b/probe/docker/registry.go @@ -1,6 +1,8 @@ package docker import ( + "fmt" + "strings" "sync" "time" @@ -401,3 +403,14 @@ func (r *registry) WalkImages(f func(*docker_client.APIImages)) { return false }) } + +// ImageNameWithoutVersion splits the image name apart, returning the name +// without the version, if possible +func ImageNameWithoutVersion(name string) string { + parts := strings.SplitN(name, "/", 3) + if len(parts) == 3 { + name = fmt.Sprintf("%s/%s", parts[1], parts[2]) + } + parts = strings.SplitN(name, ":", 2) + return parts[0] +} diff --git a/probe/docker/registry_test.go b/probe/docker/registry_test.go index c1b494fb4..44471f8d8 100644 --- a/probe/docker/registry_test.go +++ b/probe/docker/registry_test.go @@ -451,3 +451,18 @@ func TestRegistryDelete(t *testing.T) { } }) } + +func TestDockerImageName(t *testing.T) { + for _, input := range []struct{ in, name string }{ + {"foo/bar", "foo/bar"}, + {"foo/bar:baz", "foo/bar"}, + {"reg:123/foo/bar:baz", "foo/bar"}, + {"docker-registry.domain.name:5000/repo/image1:ver", "repo/image1"}, + {"foo", "foo"}, + } { + name := docker.ImageNameWithoutVersion(input.in) + if name != input.name { + t.Fatalf("%s: %s != %s", input.in, name, input.name) + } + } +} diff --git a/probe/kubernetes/reporter.go b/probe/kubernetes/reporter.go index 624baac9d..fcf7ea9fa 100644 --- a/probe/kubernetes/reporter.go +++ b/probe/kubernetes/reporter.go @@ -8,6 +8,7 @@ import ( "k8s.io/kubernetes/pkg/api" "k8s.io/kubernetes/pkg/labels" + "github.com/weaveworks/scope/common/mtime" "github.com/weaveworks/scope/probe" "github.com/weaveworks/scope/probe/controls" "github.com/weaveworks/scope/probe/docker" @@ -90,6 +91,27 @@ func (r *Reporter) podEvent(e Event, pod Pod) { } } +func isPauseContainer(n report.Node, rpt report.Report) bool { + containerImageIDs, ok := n.Sets.Lookup(report.ContainerImage) + if !ok { + return false + } + for _, imageNodeID := range containerImageIDs { + imageNode, ok := rpt.ContainerImage.Nodes[imageNodeID] + if !ok { + continue + } + imageName, ok := imageNode.Latest.Lookup(docker.ImageName) + if !ok { + continue + } + if docker.ImageNameWithoutVersion(imageName) == "google_containers/pause" { + return true + } + } + return false +} + // Tag adds pod parents to container nodes. func (r *Reporter) Tag(rpt report.Report) (report.Report, error) { for id, n := range rpt.Container.Nodes { @@ -97,6 +119,12 @@ func (r *Reporter) Tag(rpt report.Report) (report.Report, error) { if !ok { continue } + + // Tag the pause containers with "does-not-make-connections" + if isPauseContainer(n, rpt) { + n = n.WithLatest(report.DoesNotMakeConnections, mtime.Now(), "") + } + rpt.Container.Nodes[id] = n.WithParents(report.EmptySets.Add( report.Pod, report.EmptyStringSet.Add(report.MakePodNodeID(uid)), diff --git a/render/container.go b/render/container.go index c16a4611e..e337b615d 100644 --- a/render/container.go +++ b/render/container.go @@ -1,7 +1,6 @@ package render import ( - "fmt" "net" "regexp" "strings" @@ -173,6 +172,12 @@ func MapContainer2IP(m report.Node, _ report.Networks) report.Nodes { return report.Nodes{} } + // if this container doesn't make connections, we can ignore it + _, doesntMakeConnections := m.Latest.Lookup(report.DoesNotMakeConnections) + if doesntMakeConnections { + return report.Nodes{} + } + result := report.Nodes{} if addrs, ok := m.Sets.Lookup(docker.ContainerIPsWithScopes); ok { for _, addr := range addrs { @@ -326,17 +331,6 @@ func MapContainer2Hostname(n report.Node, _ report.Networks) report.Nodes { return report.Nodes{id: node} } -// ImageNameWithoutVersion splits the image name apart, returning the name -// without the version, if possible -func ImageNameWithoutVersion(name string) string { - parts := strings.SplitN(name, "/", 3) - if len(parts) == 3 { - name = fmt.Sprintf("%s/%s", parts[1], parts[2]) - } - parts = strings.SplitN(name, ":", 2) - return parts[0] -} - // MapToEmpty removes all the attributes, children, etc, of a node. Useful when // we just want to count the presence of nodes. func MapToEmpty(n report.Node, _ report.Networks) report.Nodes { diff --git a/render/container_internal_test.go b/render/container_internal_test.go deleted file mode 100644 index c04e2778d..000000000 --- a/render/container_internal_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package render - -import ( - "testing" -) - -func TestDockerImageName(t *testing.T) { - for _, input := range []struct{ in, name string }{ - {"foo/bar", "foo/bar"}, - {"foo/bar:baz", "foo/bar"}, - {"reg:123/foo/bar:baz", "foo/bar"}, - {"docker-registry.domain.name:5000/repo/image1:ver", "repo/image1"}, - {"foo", "foo"}, - } { - name := ImageNameWithoutVersion(input.in) - if name != input.name { - t.Fatalf("%s: %s != %s", input.in, name, input.name) - } - } -} diff --git a/render/detailed/parents.go b/render/detailed/parents.go index e24aaf938..d813acce3 100644 --- a/render/detailed/parents.go +++ b/render/detailed/parents.go @@ -6,7 +6,6 @@ import ( "github.com/weaveworks/scope/probe/docker" "github.com/weaveworks/scope/probe/host" "github.com/weaveworks/scope/probe/kubernetes" - "github.com/weaveworks/scope/render" "github.com/weaveworks/scope/report" ) @@ -85,7 +84,7 @@ func containerImageParent(n report.Node) Parent { imageName, _ := n.Latest.Lookup(docker.ImageName) return Parent{ ID: n.ID, - Label: render.ImageNameWithoutVersion(imageName), + Label: docker.ImageNameWithoutVersion(imageName), TopologyID: "containers-by-image", } } diff --git a/render/detailed/summary.go b/render/detailed/summary.go index 7779cc1e2..c00962de2 100644 --- a/render/detailed/summary.go +++ b/render/detailed/summary.go @@ -203,7 +203,7 @@ func containerNodeSummary(base NodeSummary, n report.Node) (NodeSummary, bool) { base.LabelMinor = report.ExtractHostID(n) if imageName, ok := n.Latest.Lookup(docker.ImageName); ok { - base.Rank = render.ImageNameWithoutVersion(imageName) + base.Rank = docker.ImageNameWithoutVersion(imageName) } return base, true @@ -215,7 +215,7 @@ func containerImageNodeSummary(base NodeSummary, n report.Node) (NodeSummary, bo return NodeSummary{}, false } - imageNameWithoutVersion := render.ImageNameWithoutVersion(imageName) + imageNameWithoutVersion := docker.ImageNameWithoutVersion(imageName) base.Label = imageNameWithoutVersion base.Rank = imageNameWithoutVersion base.Stack = true diff --git a/report/id.go b/report/id.go index 899ff427c..9152286dd 100644 --- a/report/id.go +++ b/report/id.go @@ -24,6 +24,9 @@ const ( // EdgeDelim separates two node IDs when they need to exist in the same key. // Concretely, it separates node IDs in keys that represent edges. EdgeDelim = "|" + + // Key added to nodes to prevent them being joined with conntracked connections + DoesNotMakeConnections = "does_not_make_connections" ) var (