mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-03 18:20:27 +00:00
Merge pull request #222 from weaveworks/redesign-grouping
Grouping redesign
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user