Merge branch 'master' of github.com:weaveworks/scope

This commit is contained in:
Paul Bellamy
2016-05-03 13:46:14 +01:00
92 changed files with 3882 additions and 2487 deletions

View File

@@ -211,6 +211,14 @@ The SCOPE_SERVICE_TOKEN is found when you [log in to the Scope service](https://
- "--service-token"
- "${SCOPE_SERVICE_TOKEN}"
## <a name="using-weave-scope-with-amazon-ecs"></a>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).
## <a name="using-weave-scope-with-kubernetes"></a>Using Weave Scope with Kubernetes
Scope comes with built-in Kubernetes support. We recommend to run Scope natively
@@ -325,40 +333,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
```

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,123 @@ 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());
} else {
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 +132,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
);
};
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -1,31 +1,22 @@
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 (
<g className="nodes-chart-elements" transform={props.transform}>
<NodesChartEdges layoutEdges={props.edges} selectedNodeId={props.selectedNodeId}
highlightedEdgeIds={props.highlightedEdgeIds}
hasSelectedNode={props.hasSelectedNode}
<NodesChartEdges layoutEdges={props.layoutEdges}
layoutPrecision={props.layoutPrecision} />
<NodesChartNodes layoutNodes={props.nodes} selectedNodeId={props.selectedNodeId}
selectedMetric={props.selectedMetric}
topCardNode={props.topCardNode}
highlightedNodeIds={props.highlightedNodeIds}
hasSelectedNode={props.hasSelectedNode}
adjacentNodes={props.adjacentNodes}
nodeScale={props.nodeScale} onNodeClick={props.onNodeClick}
<NodesChartNodes layoutNodes={props.layoutNodes} nodeScale={props.nodeScale}
scale={props.scale} selectedNodeScale={props.selectedNodeScale}
topologyId={props.topologyId} layoutPrecision={props.layoutPrecision} />
layoutPrecision={props.layoutPrecision} />
</g>
);
}
}
reactMixin.onClass(NodesChartElements, PureRenderMixin);
export default connect()(NodesChartElements);

View File

@@ -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);

View File

@@ -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 {
<g transform="translate(24,24) scale(0.25)">
<Logo />
</g>
<NodesChartElements
edges={edges}
nodes={nodes}
transform={transform}
adjacentNodes={this.props.adjacentNodes}
layoutPrecision={this.props.layoutPrecision}
selectedMetric={this.props.selectedMetric}
selectedNodeId={this.props.selectedNodeId}
highlightedEdgeIds={this.props.highlightedEdgeIds}
highlightedNodeIds={this.props.highlightedNodeIds}
hasSelectedNode={this.props.hasSelectedNode}
nodeScale={this.state.nodeScale}
scale={this.state.scale}
<NodesChartElements layoutNodes={nodes} layoutEdges={edges}
nodeScale={this.state.nodeScale} scale={scale} transform={transform}
selectedNodeScale={this.state.selectedNodeScale}
topCardNode={this.props.topCardNode}
topologyId={this.props.topologyId} />
layoutPrecision={this.props.layoutPrecision} />
</svg>
</div>
);
@@ -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);

View File

@@ -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;

View File

@@ -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 (
<div className="app">
{showingDebugToolbar() && <DebugToolbar />}
{this.state.showingHelp && <HelpPanel />}
{showingHelp && <HelpPanel />}
{showingDetails && <Details nodes={this.state.nodes}
controlStatus={this.state.controlStatus}
details={this.state.nodeDetails} />}
{showingDetails && <Details />}
{showingTerminal && <EmbeddedTerminal
pipe={this.state.controlPipe}
details={this.state.nodeDetails} />}
{showingTerminal && <EmbeddedTerminal />}
<div className="header">
<div className="logo">
@@ -151,45 +104,35 @@ export default class App extends React.Component {
<Logo />
</svg>
</div>
<Topologies topologies={this.state.topologies}
currentTopology={this.state.currentTopology} />
<Topologies />
</div>
<Nodes
nodes={this.state.nodes}
highlightedNodeIds={this.state.highlightedNodeIds}
highlightedEdgeIds={this.state.highlightedEdgeIds}
detailsWidth={detailsWidth}
selectedNodeId={this.state.selectedNodeId}
topMargin={topMargin}
topCardNode={topCardNode}
selectedMetric={this.state.selectedMetric}
forceRelayout={this.state.forceRelayout}
topologyOptions={this.state.activeTopologyOptions}
topologyEmpty={this.state.topologyEmpty}
adjacentNodes={this.state.adjacentNodes}
topologyId={this.state.currentTopologyId} />
<Nodes />
<Sidebar>
<Status errorUrl={this.state.errorUrl} topology={this.state.currentTopology}
topologiesLoaded={this.state.topologiesLoaded}
websocketClosed={this.state.websocketClosed} />
{this.state.availableCanvasMetrics.count() > 0 && <MetricSelector
availableCanvasMetrics={this.state.availableCanvasMetrics}
pinnedMetric={this.state.pinnedMetric}
selectedMetric={this.state.selectedMetric}
/>}
<TopologyOptions options={this.state.currentTopologyOptions}
topologyId={this.state.currentTopologyId}
activeOptions={this.state.activeTopologyOptions} />
<Status />
{showingMetricsSelector && <MetricSelector />}
<TopologyOptions />
</Sidebar>
<Footer hostname={this.state.hostname} plugins={this.state.plugins}
updatePaused={this.state.updatePaused} updatePausedAt={this.state.updatePausedAt}
version={this.state.version} versionUpdate={this.state.versionUpdate} />
<Footer />
</div>
);
}
}
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);

View File

@@ -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 (
<div className="debug-panel">
<div>
<label>Add nodes </label>
<button onClick={() => addNodes(1)}>+1</button>
<button onClick={() => addNodes(10)}>+10</button>
<button onClick={() => this.addNodes(1)}>+1</button>
<button onClick={() => this.addNodes(10)}>+10</button>
<input type="number" onChange={this.onChange} value={this.state.nodesToAdd} />
<button onClick={() => addNodes(this.state.nodesToAdd)}>+</button>
<button onClick={() => addAllVariants()}>Variants</button>
<button onClick={() => addAllMetricVariants()}>Metric Variants</button>
<button onClick={() => addNodes(1, LOREM)}>Long name</button>
<button onClick={() => this.addNodes(this.state.nodesToAdd)}>+</button>
<button onClick={() => addAllVariants(this.props.dispatch)}>Variants</button>
<button onClick={() => addAllMetricVariants(availableCanvasMetrics, this.props.dispatch)}>
Metric Variants
</button>
<button onClick={() => this.addNodes(1, LOREM)}>Long name</button>
</div>
<div>
@@ -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);

View File

@@ -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);

View File

@@ -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 (
<div className="details">
{details.toIndexedSeq().map((obj, index) => <DetailsCard key={obj.id}
index={index} cardCount={details.size} nodes={nodes}
nodeControlStatus={controlStatus.get(obj.id)} {...obj} />
)}
</div>
);
class Details extends React.Component {
render() {
const { controlStatus, details } = this.props;
// render all details as cards, later cards go on top
return (
<div className="details">
{details.toIndexedSeq().map((obj, index) => <DetailsCard key={obj.id}
index={index} cardCount={details.size}
nodeControlStatus={controlStatus.get(obj.id)} {...obj} />
)}
</div>
);
}
}
function mapStateToProps(state) {
return {
controlStatus: state.get('controlStatus'),
details: state.get('nodeDetails')
};
}
export default connect(
mapStateToProps
)(Details);

View File

@@ -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(
<DockMonitor defaultIsVisible={false}
toggleVisibilityKey="ctrl-h" changePositionKey="ctrl-w">
<LogMonitor />
</DockMonitor>
);

View File

@@ -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 (
<div className="terminal-embedded" style={style}>
<Terminal key={pipe.get('id')} pipe={pipe} titleBarColor={titleBarColor}
statusBarColor={statusBarColor} containerMargin={style.right} title={title} />
</div>
);
// React unmount/remounts when key changes, this is important for cleaning up
// the term.js and creating a new one for the new pipe.
return (
<div className="terminal-embedded" style={style}>
<Terminal key={pipe.get('id')} pipe={pipe} titleBarColor={titleBarColor}
statusBarColor={statusBarColor} containerMargin={style.right}
title={title} />
</div>
);
}
}
function mapStateToProps(state) {
return {
details: state.get('nodeDetails'),
pipe: state.get('controlPipes').last()
};
}
export default connect(
mapStateToProps
)(EmeddedTerminal);

View File

@@ -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 (
<div className="footer">
<div className="footer-status">
{versionUpdate && <a className="footer-versionupdate"
title={versionUpdateTitle} href={versionUpdate.downloadUrl} target="_blank">
Update available: {versionUpdate.version}
</a>}
<span className="footer-label">Version</span>
{version}
<span className="footer-label">on</span>
{hostname}
</div>
<div className="footer-plugins">
<Plugins />
</div>
<div className="footer-tools">
<a className={pauseClassName} onClick={pauseAction} title={pauseTitle}>
{pauseLabel !== '' && <span className="footer-label">{pauseLabel}</span>}
<span className="fa fa-pause" />
</a>
<a className="footer-icon" onClick={this.props.clickForceRelayout}
title={forceRelayoutTitle}>
<span className="fa fa-refresh" />
</a>
<a className="footer-icon" onClick={this.props.clickDownloadGraph}
title="Save canvas as SVG">
<span className="fa fa-download" />
</a>
<a className="footer-icon" href="api/report" download title="Save raw data as JSON">
<span className="fa fa-code" />
</a>
<a className="footer-icon" href={otherContrastModeUrl} title={otherContrastModeTitle}>
<span className="fa fa-adjust" />
</a>
<a className="footer-icon" href="https://gitreports.com/issue/weaveworks/scope" target="_blank" title="Report an issue">
<span className="fa fa-bug" />
</a>
<a className="footer-icon" onClick={this.props.toggleHelp}
title="Show help">
<span className="fa fa-question" />
</a>
</div>
</div>
);
}
const versionUpdateTitle = versionUpdate
? `New version available: ${versionUpdate.version}. Click to download`
: '';
return (
<div className="footer">
<div className="footer-status">
{versionUpdate && <a className="footer-versionupdate"
title={versionUpdateTitle} href={versionUpdate.downloadUrl} target="_blank">
Update available: {versionUpdate.version}
</a>}
<span className="footer-label">Version</span>
{version}
<span className="footer-label">on</span>
{hostname}
</div>
<div className="footer-plugins">
<Plugins plugins={plugins} />
</div>
<div className="footer-tools">
<a className={pauseClassName} onClick={pauseAction} title={pauseTitle}>
{pauseLabel !== '' && <span className="footer-label">{pauseLabel}</span>}
<span className="fa fa-pause" />
</a>
<a className="footer-icon" onClick={clickForceRelayout} title={forceRelayoutTitle}>
<span className="fa fa-refresh" />
</a>
<a className="footer-icon" onClick={clickDownloadGraph} title="Save canvas as SVG">
<span className="fa fa-download" />
</a>
<a className="footer-icon" href="api/report" download title="Save raw data as JSON">
<span className="fa fa-code" />
</a>
<a className="footer-icon" href={otherContrastModeUrl} title={otherContrastModeTitle}>
<span className="fa fa-adjust" />
</a>
<a className="footer-icon" href="https://gitreports.com/issue/weaveworks/scope" target="_blank" title="Report an issue">
<span className="fa fa-bug" />
</a>
<a className="footer-icon" onClick={toggleHelp} title="Show help">
<span className="fa fa-question" />
</a>
</div>
</div>
);
}
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);

View File

@@ -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);

View File

@@ -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 => (
<MetricSelectorItem key={metric.get('id')} metric={metric} {...this.props} />
<MetricSelectorItem key={metric.get('id')} metric={metric} />
));
return (
<div
className="metric-selector">
<div className="metric-selector">
<div className="metric-selector-wrapper" onMouseLeave={this.onMouseOut}>
{items}
</div>
@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 (
<div className="nodes-wrapper">
{topologyEmpty && errorEmpty}
<NodesChart {...this.props} {...this.state}
<NodesChart {...this.state}
detailsWidth={detailsWidth}
layoutPrecision={layoutPrecision}
hasSelectedNode={hasSelectedNode}
/>
@@ -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);

View File

@@ -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 (
<div className="plugins">
<span className="plugins-label">
Plugins:
</span>
{hasPlugins && this.props.plugins.map((plugin, index) => this.renderPlugin(plugin, index))}
{hasPlugins && this.props.plugins.toIndexedSeq()
.map((plugin, index) => this.renderPlugin(plugin, index))}
{!hasPlugins && <span className="plugins-empty">n/a</span>}
</div>
);
}
}
function mapStateToProps(state) {
return {
plugins: state.get('plugins')
};
}
export default connect(
mapStateToProps
)(Plugins);

View File

@@ -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);

View File

@@ -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 (
<div className={classNames}>
{showWarningIcon && <span className="status-icon fa fa-exclamation-circle" />}
<span className="status-label" title={title}>{text}</span>
</div>
);
return (
<div className={classNames}>
{showWarningIcon && <span className="status-icon fa fa-exclamation-circle" />}
<span className="status-label" title={title}>{text}</span>
</div>
);
}
}
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);

View File

@@ -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 (
<div className="terminal-app" style={style}>
{this.state.controlPipe && <Terminal
pipe={this.state.controlPipe}
{this.props.controlPipe && <Terminal
pipe={this.props.controlPipe}
titleBarColor={this.state.titleBarColor}
statusBarColor={this.state.statusBarColor}
title={this.state.title}
@@ -51,3 +35,14 @@ export class TerminalApp extends React.Component {
);
}
}
function mapStateToProps(state) {
return {
controlPipe: state.get('controlPipes').last()
};
}
export default connect(
mapStateToProps,
{ receiveControlPipeFromParams }
)(TerminalApp);

View File

@@ -2,6 +2,7 @@
import debug from 'debug';
import React from 'react';
import ReactDOM from 'react-dom';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { clickCloseTerminal } from '../actions/app-actions';
@@ -73,7 +74,7 @@ function openNewWindow(url, bcr, minWidth = 200) {
window.open(url, '', windowOptionsString);
}
export default class Terminal extends React.Component {
class Terminal extends React.Component {
constructor(props, context) {
super(props, context);
@@ -96,7 +97,7 @@ export default class Terminal extends React.Component {
const socket = new WebSocket(`${wsUrl}/api/pipe/${this.getPipeId()}`);
socket.binaryType = 'arraybuffer';
getPipeStatus(this.getPipeId());
getPipeStatus(this.getPipeId(), this.props.dispatch);
socket.onopen = () => {
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);

View File

@@ -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 {
</div>
<div className="topologies-sub">
{topology.has('sub_topologies')
&& topology.get('sub_topologies').map(this.renderSubTopology)}
&& topology.get('sub_topologies').map(subTop => this.renderSubTopology(subTop))}
</div>
</div>
);
@@ -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);

View File

@@ -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);

View File

@@ -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 (
<div className="topology-options">
{this.props.options.toIndexedSeq().map(option => this.renderOption(option))}
{this.props.options && this.props.options.toIndexedSeq().map(
option => this.renderOption(option))}
</div>
);
}
}
reactMixin.onClass(TopologyOptions, PureRenderMixin);
function mapStateToProps(state) {
return {
options: getCurrentTopologyOptions(state),
topologyId: state.get('currentTopologyId'),
activeOptions: getActiveTopologyOptions(state)
};
}
export default connect(
mapStateToProps
)(TopologyOptions);

View File

@@ -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(<App />, document.getElementById('app'));
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('app')
);

View File

@@ -1,3 +0,0 @@
import Immutable from 'immutable';
import installDevTools from 'immutable-devtools';
installDevTools(Immutable);

View File

@@ -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);

View File

@@ -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.dev';
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(
<Provider store={store}>
<div>
<App />
<DevTools />
</div>
</Provider>,
document.getElementById('app')
);

View File

@@ -2,9 +2,21 @@ 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 App from './components/app.js';
import configureStore from './stores/configureStore';
import App from './components/app';
ReactDOM.render(<App />, document.getElementById('app'));
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<div>
<App />
</div>
</Provider>,
document.getElementById('app')
);

View File

@@ -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');
});
});

View File

@@ -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;

View File

@@ -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');
});
});

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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)
);
}

View File

@@ -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(<TerminalApp />, document.getElementById('app'));
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<TerminalApp />
</Provider>,
document.getElementById('app')
);

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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));
}
});
}

View File

@@ -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",
@@ -19,15 +19,17 @@
"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-addons-transition-group": "^0.14.7",
"react-addons-update": "^0.14.7",
"react-dom": "^0.14.7",
"react": "^15.0.1",
"react-dom": "^15.0.1",
"react-motion": "0.3.1",
"react-mixin": "^3.0.3",
"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",
@@ -54,7 +56,10 @@
"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",
"style-loader": "0.13.0",
"url": "0.11.0",
"url-loader": "0.5.7",

View File

@@ -27,8 +27,12 @@ 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'
],
'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',
@@ -40,8 +44,10 @@ 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', '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
@@ -56,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',
@@ -66,6 +73,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',

View File

@@ -24,8 +24,10 @@ 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']
// 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']
},
output: {
@@ -85,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: {

View File

@@ -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
}

View File

@@ -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()))),

View File

@@ -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;<container>", 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;<container>", 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"})
}
}

View File

@@ -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,
}
}

View File

@@ -1,10 +1,13 @@
package docker
import (
"fmt"
"strings"
"sync"
"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 +40,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 +56,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 +92,7 @@ func NewRegistry(interval time.Duration, pipes controls.PipeClient, collectStats
}
r := &registry{
containers: map[string]Container{},
containers: radix.New(),
containersByPID: map[int]Container{},
images: map[string]*docker_client.APIImages{},
@@ -186,9 +190,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 +206,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 +276,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 +302,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 +320,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 +358,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 +395,22 @@ 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
})
}
// 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]
}

View File

@@ -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
}
@@ -444,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)
}
}
}

View File

@@ -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
}

View File

@@ -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{

View File

@@ -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)
}

View File

@@ -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,43 @@ 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 (r *Reporter) CapturePod(f func(xfer.Request, string, string) xfer.Response) func(xfer.Request) xfer.Response {
return func(req xfer.Request) xfer.Response {
uid, ok := report.ParsePodNodeID(req.NodeID)
if !ok {
return xfer.ResponseErrorf("Invalid ID: %s", req.NodeID)
}
// 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, r.GetLogs)
controls.Register(GetLogs, r.CapturePod(r.GetLogs))
controls.Register(DeletePod, r.CapturePod(r.deletePod))
}
func (r *Reporter) deregisterControls() {
controls.Rm(GetLogs)
controls.Rm(DeletePod)
}

View File

@@ -11,21 +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_"
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
@@ -44,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
}
@@ -60,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)
}
@@ -85,13 +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(),
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 {
@@ -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
}

View File

@@ -8,6 +8,8 @@ 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"
"github.com/weaveworks/scope/report"
@@ -18,8 +20,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{
@@ -44,16 +47,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 +71,68 @@ 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.UID()),
map[string]string{PodState: StateDeleted},
),
)
r.probe.Publish(rpt)
}
}
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 {
uid, ok := n.Latest.Lookup(docker.LabelPrefix + "io.kubernetes.pod.uid")
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)),
))
}
return rpt, nil
}
// Report generates a Report containing Container and ContainerImage topologies
func (r *Reporter) Report() (report.Report, error) {
result := report.MakeReport()
@@ -72,13 +140,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
}
@@ -118,13 +185,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,
@@ -132,13 +198,19 @@ 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()
}
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 {
@@ -149,18 +221,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
}

View File

@@ -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,6 +30,7 @@ var (
TypeMeta: podTypeMeta,
ObjectMeta: api.ObjectMeta{
Name: "pong-a",
UID: types.UID(pod1UID),
Namespace: "ping",
CreationTimestamp: unversioned.Now(),
Labels: map[string]string{"ponger": "true"},
@@ -44,6 +50,7 @@ var (
TypeMeta: podTypeMeta,
ObjectMeta: api.ObjectMeta{
Name: "pong-b",
UID: types.UID(pod2UID),
Namespace: "ping",
CreationTimestamp: unversioned.Now(),
Labels: map[string]string{"ponger": "true"},
@@ -125,13 +132,17 @@ 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)]
r, ok := c.logs[namespaceID+";"+podName]
if !ok {
return nil, fmt.Errorf("Not found")
}
return r, nil
}
func (c *mockClient) DeletePod(namespaceID, podID string) error {
return nil
}
type mockPipeClient map[string]xfer.Pipe
@@ -153,10 +164,10 @@ func TestReporter(t *testing.T) {
return nodeName, nil
}
pod1ID := report.MakePodNodeID("ping", "pong-a")
pod2ID := report.MakePodNodeID("ping", "pong-b")
pod1ID := report.MakePodNodeID(pod1UID)
pod2ID := report.MakePodNodeID(pod2UID)
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 {
@@ -165,20 +176,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]
@@ -215,33 +224,23 @@ 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)
}
}
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)
}
}
@@ -261,11 +260,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 := reporter.CapturePod(reporter.GetLogs)(xfer.Request{
NodeID: "invalidID",
Control: kubernetes.GetLogs,
})
@@ -276,39 +275,39 @@ func TestReporterGetLogs(t *testing.T) {
// Should pass through errors from k8s (e.g if pod does not exist)
{
resp := 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(pod1UID),
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 := 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

View File

@@ -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
}
@@ -66,5 +71,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)
}

94
probe/kubernetes/store.go Normal file
View File

@@ -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)
}

View File

@@ -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(&params)
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 {
@@ -144,9 +149,10 @@ 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)
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")

View File

@@ -1,14 +1,12 @@
package render
import (
"fmt"
"net"
"regexp"
"strings"
"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 +65,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 +96,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.
@@ -216,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 {
@@ -367,17 +329,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 {

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

@@ -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,16 +315,17 @@ 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},
{ID: "kubernetes_namespace", Label: "Namespace", Value: "ping", Priority: 3},
{ID: "kubernetes_namespace", Label: "Namespace", Value: "ping", Priority: 5},
},
},
Controls: []detailed.ControlInstance{},
@@ -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",
},
},
},
},
},
{

View File

@@ -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",
}
}

View File

@@ -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"},
},
},
{

View File

@@ -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

View File

@@ -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"
)
@@ -15,7 +16,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 or empty pods
state, ok := n.Latest.Lookup(kubernetes.PodState)
return HasChildren(report.Container)(n) && (!ok || state != kubernetes.StateDeleted)
},
MakeReduce(
MakeFilter(
func(n report.Node) bool {
@@ -56,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)
@@ -68,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.

View File

@@ -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

View File

@@ -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'.

View File

@@ -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 (
@@ -112,8 +115,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 + "<pod>"
}
// MakeServiceNodeID produces a service node ID from its composite parts.
@@ -167,12 +170,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] != "<pod>" {
return "", false
}
return fields[0], fields[1], true
return fields[0], true
}
// ExtractHostID extracts the host id from Node

View File

@@ -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)).

20
vendor/github.com/armon/go-radix/LICENSE generated vendored Normal file
View File

@@ -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.

496
vendor/github.com/armon/go-radix/radix.go generated vendored Normal file
View File

@@ -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
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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"]

View File

@@ -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 $*"

View File

@@ -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
}

View File

@@ -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

13
vendor/github.com/weaveworks/go-checkpoint/circle.yml generated vendored Normal file
View File

@@ -0,0 +1,13 @@
machine:
services:
- docker
dependencies:
override:
- git submodule update --init --recursive
test:
override:
- make RM= lint
- make
- make test

159
vendor/manifest vendored

File diff suppressed because it is too large Load Diff