Merge pull request #222 from weaveworks/redesign-grouping

Grouping redesign
This commit is contained in:
David
2015-06-15 10:50:55 +02:00
8 changed files with 178 additions and 114 deletions

View File

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

View File

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

View File

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

View File

@@ -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({
<div className="header">
<Logo />
<Topologies topologies={this.state.topologies} currentTopology={this.state.currentTopology} />
<Groupings active={this.state.currentGrouping} currentTopology={this.state.currentTopology} />
<Status connectionState={this.state.connectionState} />
</div>

View File

@@ -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 (
<div className={className} key={topologyId} rel={topologyId} onClick={this.onTopologyClick}>
<div title={title}>
<div className={className} title={title} key={topologyId} rel={topologyId}
onClick={this.onTopologyClick}>
<div className="topologies-sub-item-label">
{subTopology.name}
</div>
</div>
);
},
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 (
<div className="topologies-item" key={topologyId}>
<div className={className} title={title} rel={topologyId} onClick={this.onTopologyClick}>
<div className="topologies-item-label">
{topology.name}
</div>
</div>
<div className="topologies-sub">
{topology.sub_topologies && topology.sub_topologies.map(this.renderSubTopology)}
</div>
</div>
);
},
render: function() {
const topologies = _.sortBy(this.props.topologies, function(topology) {
return topology.name;
});
return topology.name;
});
return (
<div className="topologies">

View File

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

View File

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

View File

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