mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-03 02:00:43 +00:00
Merge branch 'master' of github.com:weaveworks/scope
This commit is contained in:
55
README.md
55
README.md
@@ -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
|
||||
```
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
client/app/scripts/components/dev-tools.js
Normal file
11
client/app/scripts/components/dev-tools.js
Normal 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>
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import Immutable from 'immutable';
|
||||
import installDevTools from 'immutable-devtools';
|
||||
installDevTools(Immutable);
|
||||
@@ -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);
|
||||
28
client/app/scripts/main.dev.js
Normal file
28
client/app/scripts/main.dev.js
Normal 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')
|
||||
);
|
||||
@@ -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')
|
||||
);
|
||||
|
||||
461
client/app/scripts/reducers/__tests__/root-test.js
Normal file
461
client/app/scripts/reducers/__tests__/root-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
560
client/app/scripts/reducers/root.js
Normal file
560
client/app/scripts/reducers/root.js
Normal 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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
28
client/app/scripts/stores/configureStore.dev.js
Normal file
28
client/app/scripts/stores/configureStore.dev.js
Normal 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;
|
||||
}
|
||||
12
client/app/scripts/stores/configureStore.js
Normal file
12
client/app/scripts/stores/configureStore.js
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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')
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()))),
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 := ®istry{
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
94
probe/kubernetes/store.go
Normal 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)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ const (
|
||||
|
||||
var pluginAPIVersion = "1"
|
||||
|
||||
func check() {
|
||||
func check(flags map[string]string) {
|
||||
handleResponse := func(r *checkpoint.CheckResponse, err error) {
|
||||
if err != nil {
|
||||
log.Errorf("Error checking version: %v", err)
|
||||
@@ -56,6 +56,7 @@ func check() {
|
||||
params := checkpoint.CheckParams{
|
||||
Product: "scope-probe",
|
||||
Version: version,
|
||||
Flags: flags,
|
||||
}
|
||||
resp, err := checkpoint.Check(¶ms)
|
||||
handleResponse(resp, err)
|
||||
@@ -87,7 +88,11 @@ func probeMain(flags probeFlags) {
|
||||
)
|
||||
log.Infof("probe starting, version %s, ID %s", version, probeID)
|
||||
log.Infof("command line: %v", os.Args)
|
||||
go check()
|
||||
checkpointFlags := map[string]string{}
|
||||
if flags.kubernetesEnabled {
|
||||
checkpointFlags["kubernetes_enabled"] = "true"
|
||||
}
|
||||
go check(checkpointFlags)
|
||||
|
||||
var targets = []string{fmt.Sprintf("localhost:%d", xfer.AppPort)}
|
||||
if len(flag.Args()) > 0 {
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'.
|
||||
|
||||
15
report/id.go
15
report/id.go
@@ -24,6 +24,9 @@ const (
|
||||
// EdgeDelim separates two node IDs when they need to exist in the same key.
|
||||
// Concretely, it separates node IDs in keys that represent edges.
|
||||
EdgeDelim = "|"
|
||||
|
||||
// Key added to nodes to prevent them being joined with conntracked connections
|
||||
DoesNotMakeConnections = "does_not_make_connections"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -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
|
||||
|
||||
@@ -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
20
vendor/github.com/armon/go-radix/LICENSE
generated
vendored
Normal 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
496
vendor/github.com/armon/go-radix/radix.go
generated
vendored
Normal 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
|
||||
}
|
||||
22
vendor/github.com/weaveworks/go-checkpoint/Makefile
generated
vendored
22
vendor/github.com/weaveworks/go-checkpoint/Makefile
generated
vendored
@@ -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
|
||||
|
||||
|
||||
37
vendor/github.com/weaveworks/go-checkpoint/README.md
generated
vendored
37
vendor/github.com/weaveworks/go-checkpoint/README.md
generated
vendored
@@ -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.
|
||||
[](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.
|
||||
|
||||
12
vendor/github.com/weaveworks/go-checkpoint/backend/Dockerfile
generated
vendored
Normal file
12
vendor/github.com/weaveworks/go-checkpoint/backend/Dockerfile
generated
vendored
Normal 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"]
|
||||
23
vendor/github.com/weaveworks/go-checkpoint/backend/build.sh
generated
vendored
Normal file
23
vendor/github.com/weaveworks/go-checkpoint/backend/build.sh
generated
vendored
Normal 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 $*"
|
||||
73
vendor/github.com/weaveworks/go-checkpoint/checkpoint.go
generated
vendored
73
vendor/github.com/weaveworks/go-checkpoint/checkpoint.go
generated
vendored
@@ -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
|
||||
}
|
||||
|
||||
153
vendor/github.com/weaveworks/go-checkpoint/checkpoint_test.go
generated
vendored
153
vendor/github.com/weaveworks/go-checkpoint/checkpoint_test.go
generated
vendored
@@ -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
13
vendor/github.com/weaveworks/go-checkpoint/circle.yml
generated
vendored
Normal 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
159
vendor/manifest
vendored
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user