diff --git a/app/api_topologies.go b/app/api_topologies.go
index 70b9636cb..f7ac4157b 100644
--- a/app/api_topologies.go
+++ b/app/api_topologies.go
@@ -2,17 +2,16 @@ package main
import (
"net/http"
- "strings"
"github.com/weaveworks/scope/report"
)
// APITopologyDesc is returned in a list by the /api/topology handler.
type APITopologyDesc struct {
- Name string `json:"name"`
- URL string `json:"url"`
- GroupedURL string `json:"grouped_url,omitempty"`
- Stats topologyStats `json:"stats"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ SubTopologies []APITopologyDesc `json:"sub_topologies,omitempty"`
+ Stats *topologyStats `json:"stats,omitempty"`
}
type topologyStats struct {
@@ -24,32 +23,36 @@ type topologyStats struct {
// makeTopologyList returns a handler that yields an APITopologyList.
func makeTopologyList(rep Reporter) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
- rpt := rep.Report()
-
- var a []APITopologyDesc
+ var (
+ rpt = rep.Report()
+ topologies = []APITopologyDesc{}
+ )
for name, def := range topologyRegistry {
- if strings.HasSuffix(name, "grouped") {
- continue
+ if def.parent != "" {
+ continue // subtopology, don't show at top level
}
-
- url := "/api/topology/" + name
- var groupedURL string
- if def.groupedTopology != "" {
- groupedURL = "/api/topology/" + def.groupedTopology
+ subTopologies := []APITopologyDesc{}
+ for subName, subDef := range topologyRegistry {
+ if subDef.parent == name {
+ subTopologies = append(subTopologies, APITopologyDesc{
+ Name: subDef.human,
+ URL: "/api/topology/" + subName,
+ Stats: stats(render(rpt, subDef.maps)),
+ })
+ }
}
-
- a = append(a, APITopologyDesc{
- Name: def.human,
- URL: url,
- GroupedURL: groupedURL,
- Stats: stats(render(rpt, def.maps)),
+ topologies = append(topologies, APITopologyDesc{
+ Name: def.human,
+ URL: "/api/topology/" + name,
+ SubTopologies: subTopologies,
+ Stats: stats(render(rpt, def.maps)),
})
}
- respondWith(w, http.StatusOK, a)
+ respondWith(w, http.StatusOK, topologies)
}
}
-func stats(r report.RenderableNodes) topologyStats {
+func stats(r report.RenderableNodes) *topologyStats {
var (
nodes int
realNodes int
@@ -64,7 +67,7 @@ func stats(r report.RenderableNodes) topologyStats {
edges += len(n.Adjacency)
}
- return topologyStats{
+ return &topologyStats{
NodeCount: nodes,
NonpseudoNodeCount: realNodes,
EdgeCount: edges,
diff --git a/app/api_topologies_test.go b/app/api_topologies_test.go
index 0c9b67da4..6b5aff968 100644
--- a/app/api_topologies_test.go
+++ b/app/api_topologies_test.go
@@ -11,23 +11,29 @@ func TestAPITopology(t *testing.T) {
defer ts.Close()
body := getRawJSON(t, ts, "/api/topology")
- var topos []APITopologyDesc
- if err := json.Unmarshal(body, &topos); err != nil {
+
+ var topologies []APITopologyDesc
+ if err := json.Unmarshal(body, &topologies); err != nil {
t.Fatalf("JSON parse error: %s", err)
}
- equals(t, 3, len(topos))
- for _, topo := range topos {
- is200(t, ts, topo.URL)
- if topo.GroupedURL != "" {
- is200(t, ts, topo.GroupedURL)
+ equals(t, 3, len(topologies))
+
+ for _, topology := range topologies {
+ is200(t, ts, topology.URL)
+
+ for _, subTopology := range topology.SubTopologies {
+ is200(t, ts, subTopology.URL)
}
- if have := topo.Stats.EdgeCount; have <= 0 {
+
+ if have := topology.Stats.EdgeCount; have <= 0 {
t.Errorf("EdgeCount isn't positive: %d", have)
}
- if have := topo.Stats.NodeCount; have <= 0 {
+
+ if have := topology.Stats.NodeCount; have <= 0 {
t.Errorf("NodeCount isn't positive: %d", have)
}
- if have := topo.Stats.NonpseudoNodeCount; have <= 0 {
+
+ if have := topology.Stats.NonpseudoNodeCount; have <= 0 {
t.Errorf("NonpseudoNodeCount isn't positive: %d", have)
}
}
diff --git a/app/router.go b/app/router.go
index ff4fa7b56..4616eb8bc 100644
--- a/app/router.go
+++ b/app/router.go
@@ -45,10 +45,49 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
respondWith(w, http.StatusOK, APIDetails{Version: version})
}
+var topologyRegistry = map[string]topologyView{
+ "applications": {
+ human: "Applications",
+ parent: "",
+ maps: []topologyMapper{
+ {report.SelectEndpoint, report.ProcessPID, report.GenericPseudoNode},
+ },
+ },
+ "applications-by-name": {
+ human: "by name",
+ parent: "applications",
+ maps: []topologyMapper{
+ {report.SelectEndpoint, report.ProcessName, report.GenericGroupedPseudoNode},
+ },
+ },
+ "containers": {
+ human: "Containers",
+ parent: "",
+ maps: []topologyMapper{
+ {report.SelectEndpoint, report.MapEndpoint2Container, report.InternetOnlyPseudoNode},
+ {report.SelectContainer, report.MapContainerIdentity, report.InternetOnlyPseudoNode},
+ },
+ },
+ "containers-by-image": {
+ human: "by image",
+ parent: "containers",
+ maps: []topologyMapper{
+ {report.SelectEndpoint, report.ProcessContainerImage, report.InternetOnlyPseudoNode},
+ },
+ },
+ "hosts": {
+ human: "Hosts",
+ parent: "",
+ maps: []topologyMapper{
+ {report.SelectAddress, report.NetworkHostname, report.GenericPseudoNode},
+ },
+ },
+}
+
type topologyView struct {
- human string
- groupedTopology string
- maps []topologyMapper
+ human string
+ parent string
+ maps []topologyMapper
}
type topologyMapper struct {
@@ -56,14 +95,3 @@ type topologyMapper struct {
mapper report.MapFunc
pseudo report.PseudoFunc
}
-
-var topologyRegistry = map[string]topologyView{
- "applications": {"Applications", "applications-grouped", []topologyMapper{{report.SelectEndpoint, report.ProcessPID, report.GenericPseudoNode}}},
- "applications-grouped": {"Applications", "", []topologyMapper{{report.SelectEndpoint, report.ProcessName, report.GenericGroupedPseudoNode}}},
- "containers": {"Containers", "containers-grouped", []topologyMapper{
- {report.SelectEndpoint, report.MapEndpoint2Container, report.InternetOnlyPseudoNode},
- {report.SelectContainer, report.MapContainerIdentity, report.InternetOnlyPseudoNode},
- }},
- "containers-grouped": {"Containers", "", []topologyMapper{{report.SelectEndpoint, report.ProcessContainerImage, report.InternetOnlyPseudoNode}}},
- "hosts": {"Hosts", "", []topologyMapper{{report.SelectAddress, report.NetworkHostname, report.GenericPseudoNode}}},
-}
diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js
index c8a78a080..febbebb16 100644
--- a/client/app/scripts/components/app.js
+++ b/client/app/scripts/components/app.js
@@ -2,7 +2,6 @@ const React = require('react');
const Logo = require('./logo');
const AppStore = require('../stores/app-store');
-const Groupings = require('./groupings.js');
const Status = require('./status.js');
const Topologies = require('./topologies.js');
const WebapiUtils = require('../utils/web-api-utils');
@@ -67,7 +66,6 @@ const App = React.createClass({
-
diff --git a/client/app/scripts/components/topologies.js b/client/app/scripts/components/topologies.js
index 152e4245c..52abd307d 100644
--- a/client/app/scripts/components/topologies.js
+++ b/client/app/scripts/components/topologies.js
@@ -11,29 +11,51 @@ const Topologies = React.createClass({
AppActions.clickTopology(ev.currentTarget.getAttribute('rel'));
},
- renderTopology: function(topology) {
- const isActive = topology.name === this.props.currentTopology.name;
- const className = isActive ? 'topologies-item topologies-item-active' : 'topologies-item';
- const topologyId = AppStore.getTopologyIdForUrl(topology.url);
- const title = ['Topology: ' + topology.name,
- 'Nodes: ' + topology.stats.node_count,
- 'Connections: ' + topology.stats.node_count].join('\n');
+ renderSubTopology: function(subTopology) {
+ const isActive = subTopology.name === this.props.currentTopology.name;
+ const topologyId = AppStore.getTopologyIdForUrl(subTopology.url);
+ const title = this.renderTitle(subTopology);
+ const className = isActive ? 'topologies-sub-item topologies-sub-item-active' : 'topologies-sub-item';
return (
-
-
+
+
+ {subTopology.name}
+
+
+ );
+ },
+
+ renderTitle: function(topology) {
+ return ['Nodes: ' + topology.stats.node_count,
+ 'Connections: ' + topology.stats.node_count].join('\n');
+ },
+
+ renderTopology: function(topology) {
+ const isActive = topology.name === this.props.currentTopology.name;
+ const className = isActive ? 'topologies-item-main topologies-item-main-active' : 'topologies-item-main';
+ const topologyId = AppStore.getTopologyIdForUrl(topology.url);
+ const title = this.renderTitle(topology);
+
+ return (
+
+
+
+ {topology.sub_topologies && topology.sub_topologies.map(this.renderSubTopology)}
+
);
},
render: function() {
const topologies = _.sortBy(this.props.topologies, function(topology) {
- return topology.name;
- });
+ return topology.name;
+ });
return (
diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js
index bcca13750..7ca3c6668 100644
--- a/client/app/scripts/stores/__tests__/app-store-test.js
+++ b/client/app/scripts/stores/__tests__/app-store-test.js
@@ -16,6 +16,11 @@ describe('AppStore', function() {
nodeId: 'n1'
};
+ const ClickSubTopologyAction = {
+ type: ActionTypes.CLICK_TOPOLOGY,
+ topologyId: 'topo1-grouped'
+ };
+
const ClickTopologyAction = {
type: ActionTypes.CLICK_TOPOLOGY,
topologyId: 'topo1'
@@ -45,8 +50,11 @@ describe('AppStore', function() {
type: ActionTypes.RECEIVE_TOPOLOGIES,
topologies: [{
url: '/topo1',
- grouped_url: '/topo1grouped',
- name: 'Topo1'
+ name: 'Topo1',
+ sub_topologies: [{
+ url: '/topo1-grouped',
+ name: 'topo 1 grouped'
+ }]
}]
};
@@ -77,14 +85,13 @@ describe('AppStore', function() {
expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1');
});
- it('get grouped topology', function() {
- registeredCallback(ClickTopologyAction);
+ it('get sub-topology', function() {
registeredCallback(ReceiveTopologiesAction);
- registeredCallback(ClickGroupingAction);
+ registeredCallback(ClickSubTopologyAction);
expect(AppStore.getTopologies().length).toBe(1);
- expect(AppStore.getCurrentTopology().name).toBe('Topo1');
- expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1grouped');
+ expect(AppStore.getCurrentTopology().name).toBe('topo 1 grouped');
+ expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1-grouped');
});
// browsing
@@ -110,14 +117,14 @@ describe('AppStore', function() {
registeredCallback(ReceiveNodesDeltaAction);
// TODO clear AppStore cache
expect(AppStore.getAppState())
- .toEqual({"topologyId":"topo1","grouping":"grouped","selectedNodeId": null});
+ .toEqual({"topologyId":"topo1-grouped","grouping":"none","selectedNodeId": null});
registeredCallback(ClickNodeAction);
expect(AppStore.getAppState())
- .toEqual({"topologyId":"topo1","grouping":"grouped","selectedNodeId": 'n1'});
+ .toEqual({"topologyId":"topo1-grouped","grouping":"none","selectedNodeId": 'n1'});
// go back in browsing
- RouteAction.state = {"topologyId":"topo1","grouping":"grouped","selectedNodeId": null};
+ RouteAction.state = {"topologyId":"topo1-grouped","grouping":"none","selectedNodeId": null};
registeredCallback(RouteAction);
expect(AppStore.getSelectedNodeId()).toBe(null);
expect(AppStore.getNodes()).toEqual(NODE_SET);
diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js
index 4670b823d..8db061b74 100644
--- a/client/app/scripts/stores/app-store.js
+++ b/client/app/scripts/stores/app-store.js
@@ -9,8 +9,22 @@ const Naming = require('../constants/naming');
// Helpers
-function isUrlForTopologyId(url, topologyId) {
- return _.endsWith(url, topologyId);
+function findCurrentTopology(subTree, topologyId) {
+ let foundTopology;
+
+ _.each(subTree, function(topology) {
+ if (_.endsWith(topology.url, topologyId)) {
+ foundTopology = topology;
+ }
+ if (!foundTopology) {
+ foundTopology = findCurrentTopology(topology.sub_topologies, topologyId);
+ }
+ if (foundTopology) {
+ return false;
+ }
+ });
+
+ return foundTopology;
}
// Initial values
@@ -45,16 +59,14 @@ const AppStore = assign({}, EventEmitter.prototype, {
},
getCurrentTopology: function() {
- return _.find(topologies, function(topology) {
- return isUrlForTopologyId(topology.url, currentTopologyId);
- });
+ return findCurrentTopology(topologies, currentTopologyId);
},
getCurrentTopologyUrl: function() {
const topology = this.getCurrentTopology();
if (topology) {
- return topology.grouped_url && currentGrouping === 'grouped' ? topology.grouped_url : topology.url;
+ return topology.url;
}
},
diff --git a/client/app/styles/main.less b/client/app/styles/main.less
index a32740da5..58b88f27d 100644
--- a/client/app/styles/main.less
+++ b/client/app/styles/main.less
@@ -81,59 +81,47 @@ body {
float: left;
}
-.topologies,
-.groupings {
- float: left;
- margin-top: 7px;
- margin-left: 48px;
-}
-
.topologies {
- &-icon {
- font-size: 12px;
- color: @text-secondary-color;
- margin-right: 16px;
- position: relative;
- top: -1px;
- }
+ float: left;
+ margin: 4px 64px;
.topologies-item {
- margin: 8px 16px 6px 0;
- cursor: pointer;
- display: inline-block;
+ margin: 0px 16px;
+ float: left;
&-label {
color: @text-secondary-color;
- font-size: 15px;
+ font-size: 16px;
text-transform: uppercase;
}
+ }
+
+ .topologies-sub {
+ margin-top: 4px;
+
+ &-item {
+ &-label {
+ color: @text-secondary-color;
+ font-size: 12px;
+ text-transform: uppercase;
+ }
+ }
+ }
+
+ .topologies-item-main,
+ .topologies-sub-item {
+ cursor: pointer;
+
&-active, &:hover {
+ .topologies-sub-item-label,
.topologies-item-label {
color: @text-color;
}
}
+
}
-}
-.groupings {
- &-item {
- font-size: 15px;
- margin: 8px 12px 6px 0;
- cursor: pointer;
- display: inline-block;
- color: @text-tertiary-color;
-
- &-disabled {
- color: @text-tertiary-color;
- cursor: default;
- }
-
- &-default:hover,
- &-active {
- color: @text-color;
- }
- }
}
.status {