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.name}
+
+ {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 {