From 56122dd0cc6e2fb6a5b1dd146c141cadeef7eb8a Mon Sep 17 00:00:00 2001 From: Paul Bellamy Date: Mon, 7 Dec 2015 13:27:08 +0000 Subject: [PATCH 01/12] Details panel backend redesign Megasquish: [app] remove unused edge endpoint [WIP] refactoring node details api endpoint [WIP] plumbing the children through the rendering process adding IDList.Remove and StringSet.Remove [WIP] working on adding parents to detailed node renderings WIP UI components with mock backend data for new details grouping children by type UI components for node details health and info metric formatters for details panel Column headers and links for details table [WIP] started on rendering node metadata and metrics in the detail view DetailedNode.LabelMajor -> DetailedNode.Label rendering decent labels for parents of detailed nodes render metrics onto the top-level detailed node removing dead code Links to relatives metrics have a Format not Unit Show more/less actions for tables and relatives adjusted metric formatter TopologyTagger should tag k8s topology nodes make renderablenode ids more consistent, e.g. container:abcd1234 working on rendering correct summaries for each node adding report.Node.Rank, so that merging is independent of order rendering children and parents correctly output child renderableNode ids, so we can link to them add group field to metrics, so they can be grouped Refactored details health items to prepare for grouping add metrics to processNodeSummaries hide summary section if there is no data for it fixing up tests moving detailed node rendering into a separate package Node ID/Topology are fields not metadata - This way I think we don't have to care about Metadata being non-commutative. - ID and topology are still non-commutative, as I'm not sure how to sanely merge them, but it's possible we don't care. host memory usage is a filesize, not a percent working on fixing some tests adding children to hosts detail panel - Had to redo how parents are calculated, so that children wouldn't interfere with it - have to have the host at the end because it is non-commutative only render links for linkable children (i.e. not unconnected processes) resolving TODOs fixing up lint errors make nil a valid value for render.Children so tests are cleaner working on backend tests make client handle missing metrics property Stop rendering container image nodes with process summaries/parents fix parent link to container images Calculate parents as a set on report.Node (except k8s) refactoring detailed.NodeSummary stuff removing RenderableNode.Summary*, we already track it on report.Node working on tests add Columns field to NodeSummaryGroup fixing up render/topologies_test fix children links to container images get children of hosts rendering right working on host renderer tests Change container report.Node.ID to a1b2c3; The id should be globally unique, so we don't need the host id. This lets the kubernetes probe return a container node with the pod id, which will get merged into the real containers with other reports. The catch is that the kubernetes api doesn't tell us which hostname the container is running on, so we can't populate the old-style node ids. change terminology of system pods and services Fix kubernetes services with no selector Fixes handling of kubernetes service, which has no pods fix parent links for pods/services refactor detailed metadata to include sets and latest data fixing up host rendering tests fleshing out tests for node metadata and metrics don't render container pseudo-nodes as processes Update test for id format change. --- app/api_topology.go | 5 +- app/api_topology_test.go | 9 +- integration/410_container_control_test.sh | 2 +- probe/docker/container.go | 6 +- probe/docker/container_test.go | 2 + probe/docker/controls.go | 2 +- probe/docker/controls_test.go | 4 +- probe/docker/reporter.go | 8 +- probe/docker/reporter_test.go | 8 +- probe/docker/tagger.go | 7 +- probe/docker/tagger_test.go | 9 +- probe/host/tagger.go | 15 +- probe/host/tagger_test.go | 2 + probe/kubernetes/pod.go | 9 + probe/kubernetes/reporter.go | 21 +- probe/kubernetes/reporter_test.go | 28 +- probe/overlay/weave.go | 2 +- probe/overlay/weave_test.go | 2 +- probe/probe_internal_test.go | 4 +- probe/topology_tagger.go | 12 +- prog/probe.go | 2 +- render/detailed/metadata.go | 135 +++++ render/detailed/metadata_test.go | 53 ++ render/detailed/metrics.go | 176 +++++++ render/detailed/metrics_test.go | 121 +++++ render/detailed/node.go | 208 ++++++++ render/detailed/node_test.go | 185 +++++++ render/detailed/summary.go | 140 ++++++ render/detailed_node.go | 572 ---------------------- render/detailed_node_test.go | 249 ---------- render/expected/expected.go | 281 +++++------ render/filters.go | 11 + render/filters_test.go | 20 +- render/id.go | 36 +- render/mapping.go | 131 +++-- render/mapping_internal_test.go | 2 +- render/renderable_node.go | 39 +- render/renderable_node_test.go | 8 +- render/selectors.go | 7 +- render/short_lived_connections_test.go | 18 +- render/topologies.go | 50 +- render/topologies_test.go | 8 +- report/id.go | 24 +- report/id_list.go | 5 + report/merge_test.go | 25 +- report/metrics.go | 8 +- report/node_set.go | 69 +++ report/node_set_test.go | 152 ++++++ report/topology.go | 60 ++- report/topology_test.go | 21 + test/fixture/report_fixture.go | 105 +++- 51 files changed, 1894 insertions(+), 1184 deletions(-) create mode 100644 render/detailed/metadata.go create mode 100644 render/detailed/metadata_test.go create mode 100644 render/detailed/metrics.go create mode 100644 render/detailed/metrics_test.go create mode 100644 render/detailed/node.go create mode 100644 render/detailed/node_test.go create mode 100644 render/detailed/summary.go delete mode 100644 render/detailed_node.go delete mode 100644 render/detailed_node_test.go create mode 100644 report/node_set.go create mode 100644 report/node_set_test.go diff --git a/app/api_topology.go b/app/api_topology.go index 1e682294d..f0769976f 100644 --- a/app/api_topology.go +++ b/app/api_topology.go @@ -7,6 +7,7 @@ import ( "github.com/gorilla/websocket" "github.com/weaveworks/scope/render" + "github.com/weaveworks/scope/render/detailed" ) const ( @@ -21,7 +22,7 @@ type APITopology struct { // APINode is returned by the /api/topology/{name}/{id} handler. type APINode struct { - Node render.DetailedNode `json:"node"` + Node detailed.Node `json:"node"` } // Full topology. @@ -59,7 +60,7 @@ func handleNode(nodeID string) func(Reporter, render.Renderer, http.ResponseWrit http.NotFound(w, r) return } - respondWith(w, http.StatusOK, APINode{Node: render.MakeDetailedNode(rpt, node)}) + respondWith(w, http.StatusOK, APINode{Node: detailed.MakeNode(rpt, node)}) } } diff --git a/app/api_topology_test.go b/app/api_topology_test.go index 600d01f07..0282710fa 100644 --- a/app/api_topology_test.go +++ b/app/api_topology_test.go @@ -82,8 +82,7 @@ func TestAPITopologyApplications(t *testing.T) { t.Fatal(err) } equals(t, expected.ServerProcessID, node.Node.ID) - equals(t, "apache", node.Node.LabelMajor) - equals(t, fmt.Sprintf("%s (server:%s)", fixture.ServerHostID, fixture.ServerPID), node.Node.LabelMinor) + equals(t, "apache", node.Node.Label) equals(t, false, node.Node.Pseudo) // Let's not unit-test the specific content of the detail tables } @@ -96,8 +95,7 @@ func TestAPITopologyApplications(t *testing.T) { t.Fatal(err) } equals(t, fixture.Client1Name, node.Node.ID) - equals(t, fixture.Client1Name, node.Node.LabelMajor) - equals(t, "2 processes", node.Node.LabelMinor) + equals(t, fixture.Client1Name, node.Node.Label) equals(t, false, node.Node.Pseudo) // Let's not unit-test the specific content of the detail tables } @@ -125,8 +123,7 @@ func TestAPITopologyHosts(t *testing.T) { t.Fatal(err) } equals(t, expected.ServerHostRenderedID, node.Node.ID) - equals(t, "server", node.Node.LabelMajor) - equals(t, "hostname.com", node.Node.LabelMinor) + equals(t, "server", node.Node.Label) equals(t, false, node.Node.Pseudo) // Let's not unit-test the specific content of the detail tables } diff --git a/integration/410_container_control_test.sh b/integration/410_container_control_test.sh index b96e434b9..fa28a33cd 100755 --- a/integration/410_container_control_test.sh +++ b/integration/410_container_control_test.sh @@ -14,7 +14,7 @@ wait_for_containers $HOST1 60 alpine assert "docker_on $HOST1 inspect --format='{{.State.Running}}' alpine" "true" PROBEID=$(docker_on $HOST1 logs weavescope 2>&1 | grep "probe starting" | sed -n 's/^.*ID \([0-9a-f]*\)$/\1/p') HOSTID=$(echo $HOST1 | cut -d"." -f1) -assert_raises "curl -f -X POST 'http://$HOST1:4040/api/control/$PROBEID/$HOSTID;$CID/docker_stop_container'" +assert_raises "curl -f -X POST 'http://$HOST1:4040/api/control/$PROBEID/$CID;/docker_stop_container'" sleep 5 assert "docker_on $HOST1 inspect --format='{{.State.Running}}' alpine" "false" diff --git a/probe/docker/container.go b/probe/docker/container.go index f848c2f46..16e8ea3fa 100644 --- a/probe/docker/container.go +++ b/probe/docker/container.go @@ -331,7 +331,11 @@ func (c *container) GetNode(hostID string, localAddrs []net.IP) report.Node { ContainerIPsWithScopes: report.MakeStringSet(ipsWithScopes...), }).WithLatest( ContainerState, mtime.Now(), state, - ).WithMetrics(c.metrics()) + ).WithMetrics( + c.metrics(), + ).WithParents(report.Sets{ + "container_image": report.MakeStringSet(report.MakeContainerImageNodeID(hostID, c.container.Image)), + }) if c.container.State.Paused { result = result.WithControls(UnpauseContainer) diff --git a/probe/docker/container_test.go b/probe/docker/container_test.go index c83dbe8a6..609fbb05e 100644 --- a/probe/docker/container_test.go +++ b/probe/docker/container_test.go @@ -92,6 +92,8 @@ func TestContainer(t *testing.T) { ).WithMetrics(report.Metrics{ "cpu_total_usage": report.MakeMetric(), "memory_usage": report.MakeMetric().Add(now, 12345), + }).WithParents(report.Sets{ + "container_image": report.MakeStringSet(report.MakeContainerImageNodeID("scope", "baz")), }) test.Poll(t, 100*time.Millisecond, want, func() interface{} { node := c.GetNode("scope", []net.IP{}) diff --git a/probe/docker/controls.go b/probe/docker/controls.go index 2057069e4..b0871e77f 100644 --- a/probe/docker/controls.go +++ b/probe/docker/controls.go @@ -142,7 +142,7 @@ func (r *registry) execContainer(containerID string, req xfer.Request) xfer.Resp func captureContainerID(f func(string, xfer.Request) xfer.Response) func(xfer.Request) xfer.Response { return func(req xfer.Request) xfer.Response { - _, containerID, ok := report.ParseContainerNodeID(req.NodeID) + containerID, ok := report.ParseContainerNodeID(req.NodeID) if !ok { return xfer.ResponseErrorf("Invalid ID: %s", req.NodeID) } diff --git a/probe/docker/controls_test.go b/probe/docker/controls_test.go index 97ab285f6..f6b355704 100644 --- a/probe/docker/controls_test.go +++ b/probe/docker/controls_test.go @@ -30,7 +30,7 @@ func TestControls(t *testing.T) { } { result := controls.HandleControlRequest(xfer.Request{ Control: tc.command, - NodeID: report.MakeContainerNodeID("", "a1b2c3d4e5"), + NodeID: report.MakeContainerNodeID("a1b2c3d4e5"), }) if !reflect.DeepEqual(result, xfer.Response{ Error: tc.result, @@ -72,7 +72,7 @@ func TestPipes(t *testing.T) { } { result := controls.HandleControlRequest(xfer.Request{ Control: tc, - NodeID: report.MakeContainerNodeID("", "ping"), + NodeID: report.MakeContainerNodeID("ping"), }) want := xfer.Response{ Pipe: "pipeid", diff --git a/probe/docker/reporter.go b/probe/docker/reporter.go index 0de846be9..bef4d1160 100644 --- a/probe/docker/reporter.go +++ b/probe/docker/reporter.go @@ -48,7 +48,7 @@ func (r *Reporter) ContainerUpdated(c Container) { // Publish a 'short cut' report container just this container rpt := report.MakeReport() rpt.Shortcut = true - rpt.Container.AddNode(report.MakeContainerNodeID(r.hostID, c.ID()), c.GetNode(r.hostID, localAddrs)) + rpt.Container.AddNode(report.MakeContainerNodeID(c.ID()), c.GetNode(r.hostID, localAddrs)) r.probe.Publish(rpt) } @@ -104,7 +104,7 @@ func (r *Reporter) containerTopology(localAddrs []net.IP) report.Topology { }) r.registry.WalkContainers(func(c Container) { - nodeID := report.MakeContainerNodeID(r.hostID, c.ID()) + nodeID := report.MakeContainerNodeID(c.ID()) result.AddNode(nodeID, c.GetNode(r.hostID, localAddrs)) }) @@ -117,6 +117,8 @@ func (r *Reporter) containerImageTopology() report.Topology { r.registry.WalkImages(func(image *docker_client.APIImages) { nmd := report.MakeNodeWith(map[string]string{ ImageID: image.ID, + }).WithParents(report.Sets{ + "host": report.MakeStringSet(report.MakeHostNodeID(r.hostID)), }) AddLabels(nmd, image.Labels) @@ -124,7 +126,7 @@ func (r *Reporter) containerImageTopology() report.Topology { nmd.Metadata[ImageName] = image.RepoTags[0] } - nodeID := report.MakeContainerNodeID(r.hostID, image.ID) + nodeID := report.MakeContainerImageNodeID(r.hostID, image.ID) result.AddNode(nodeID, nmd) }) diff --git a/probe/docker/reporter_test.go b/probe/docker/reporter_test.go index 6af889887..c19eee696 100644 --- a/probe/docker/reporter_test.go +++ b/probe/docker/reporter_test.go @@ -55,7 +55,7 @@ func TestReporter(t *testing.T) { want := report.MakeReport() want.Container = report.Topology{ Nodes: report.Nodes{ - report.MakeContainerNodeID("", "ping"): report.MakeNodeWith(map[string]string{ + report.MakeContainerNodeID("ping"): report.MakeNodeWith(map[string]string{ docker.ContainerID: "ping", docker.ContainerName: "pong", docker.ImageID: "baz", @@ -101,15 +101,17 @@ func TestReporter(t *testing.T) { } want.ContainerImage = report.Topology{ Nodes: report.Nodes{ - report.MakeContainerNodeID("", "baz"): report.MakeNodeWith(map[string]string{ + report.MakeContainerImageNodeID("host1", "baz"): report.MakeNodeWith(map[string]string{ docker.ImageID: "baz", docker.ImageName: "bang", + }).WithParents(report.Sets{ + "host": report.MakeStringSet(report.MakeHostNodeID("host1")), }), }, Controls: report.Controls{}, } - reporter := docker.NewReporter(mockRegistryInstance, "", nil) + reporter := docker.NewReporter(mockRegistryInstance, "host1", nil) have, _ := reporter.Report() if !reflect.DeepEqual(want, have) { t.Errorf("%s", test.Diff(want, have)) diff --git a/probe/docker/tagger.go b/probe/docker/tagger.go index 4582d70c1..1c7d3564e 100644 --- a/probe/docker/tagger.go +++ b/probe/docker/tagger.go @@ -23,13 +23,15 @@ var ( // nodes that have a PID. type Tagger struct { registry Registry + hostID string procWalker process.Walker } // NewTagger returns a usable Tagger. -func NewTagger(registry Registry, procWalker process.Walker) *Tagger { +func NewTagger(registry Registry, hostID string, procWalker process.Walker) *Tagger { return &Tagger{ registry: registry, + hostID: hostID, procWalker: procWalker, } } @@ -84,6 +86,9 @@ func (t *Tagger) tag(tree process.Tree, topology *report.Topology) { topology.AddNode(nodeID, report.MakeNodeWith(map[string]string{ ContainerID: c.ID(), + }).WithParents(report.Sets{ + "container": report.MakeStringSet(report.MakeContainerNodeID(c.ID())), + "container_image": report.MakeStringSet(report.MakeContainerImageNodeID(t.hostID, c.Image())), })) } } diff --git a/probe/docker/tagger_test.go b/probe/docker/tagger_test.go index 1415d4e1b..f58e7df05 100644 --- a/probe/docker/tagger_test.go +++ b/probe/docker/tagger_test.go @@ -38,7 +38,12 @@ func TestTagger(t *testing.T) { var ( pid1NodeID = report.MakeProcessNodeID("somehost.com", "2") pid2NodeID = report.MakeProcessNodeID("somehost.com", "3") - wantNode = report.MakeNodeWith(map[string]string{docker.ContainerID: "ping"}) + wantNode = report.MakeNodeWith(map[string]string{ + docker.ContainerID: "ping", + }).WithParents(report.Sets{ + "container": report.MakeStringSet(report.MakeContainerNodeID("ping")), + "container_image": report.MakeStringSet(report.MakeContainerImageNodeID("somehost.com", "baz")), + }) ) input := report.MakeReport() @@ -49,7 +54,7 @@ func TestTagger(t *testing.T) { want.Process.AddNode(pid1NodeID, report.MakeNodeWith(map[string]string{process.PID: "2"}).Merge(wantNode)) want.Process.AddNode(pid2NodeID, report.MakeNodeWith(map[string]string{process.PID: "3"}).Merge(wantNode)) - tagger := docker.NewTagger(mockRegistryInstance, nil) + tagger := docker.NewTagger(mockRegistryInstance, "somehost.com", nil) have, err := tagger.Tag(input) if err != nil { t.Errorf("%v", err) diff --git a/probe/host/tagger.go b/probe/host/tagger.go index 6b5237896..af5feb9ec 100644 --- a/probe/host/tagger.go +++ b/probe/host/tagger.go @@ -26,16 +26,21 @@ func (Tagger) Name() string { return "Host" } // Tag implements Tagger. func (t Tagger) Tag(r report.Report) (report.Report, error) { - metadata := map[string]string{ - report.HostNodeID: t.hostNodeID, - report.ProbeID: t.probeID, - } + var ( + metadata = map[string]string{ + report.HostNodeID: t.hostNodeID, + report.ProbeID: t.probeID, + } + parents = report.Sets{ + "host": report.MakeStringSet(t.hostNodeID), + } + ) // Explicity don't tag Endpoints and Addresses - These topologies include pseudo nodes, // and as such do their own host tagging for _, topology := range []report.Topology{r.Process, r.Container, r.ContainerImage, r.Host, r.Overlay} { for id, node := range topology.Nodes { - topology.AddNode(id, node.WithMetadata(metadata)) + topology.AddNode(id, node.WithMetadata(metadata).WithParents(parents)) } } return r, nil diff --git a/probe/host/tagger_test.go b/probe/host/tagger_test.go index 0a736f4ce..453486105 100644 --- a/probe/host/tagger_test.go +++ b/probe/host/tagger_test.go @@ -22,6 +22,8 @@ func TestTagger(t *testing.T) { want := nodeMetadata.Merge(report.MakeNodeWith(map[string]string{ report.HostNodeID: report.MakeHostNodeID(hostID), report.ProbeID: probeID, + }).WithParents(report.Sets{ + "host": report.MakeStringSet(report.MakeHostNodeID(hostID)), })) rpt, _ := host.NewTagger(hostID, probeID).Tag(r) have := rpt.Process.Nodes[endpointNodeID].Copy() diff --git a/probe/kubernetes/pod.go b/probe/kubernetes/pod.go index 6cf86a205..0f1dd5f9c 100644 --- a/probe/kubernetes/pod.go +++ b/probe/kubernetes/pod.go @@ -84,5 +84,14 @@ func (p *pod) GetNode() report.Node { if len(p.serviceIDs) > 0 { n.Metadata[ServiceIDs] = strings.Join(p.serviceIDs, " ") } + for _, serviceID := range p.serviceIDs { + segments := strings.SplitN(serviceID, "/", 2) + if len(segments) != 2 { + continue + } + n = n.WithParents(report.Sets{ + "service": report.MakeStringSet(report.MakeServiceNodeID(p.Namespace(), segments[1])), + }) + } return n } diff --git a/probe/kubernetes/reporter.go b/probe/kubernetes/reporter.go index 816c0f005..5673bf5ff 100644 --- a/probe/kubernetes/reporter.go +++ b/probe/kubernetes/reporter.go @@ -26,12 +26,13 @@ func (r *Reporter) Report() (report.Report, error) { if err != nil { return result, err } - podTopology, err := r.podTopology(services) + podTopology, containerTopology, 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 } @@ -49,8 +50,8 @@ func (r *Reporter) serviceTopology() (report.Topology, []Service, error) { return result, services, err } -func (r *Reporter) podTopology(services []Service) (report.Topology, error) { - result := report.MakeTopology() +func (r *Reporter) podTopology(services []Service) (report.Topology, report.Topology, error) { + pods, containers := report.MakeTopology(), report.MakeTopology() err := r.client.WalkPods(func(p Pod) error { for _, service := range services { if service.Selector().Matches(p.Labels()) { @@ -58,8 +59,18 @@ func (r *Reporter) podTopology(services []Service) (report.Topology, error) { } } nodeID := report.MakePodNodeID(p.Namespace(), p.Name()) - result = result.AddNode(nodeID, p.GetNode()) + pods = pods.AddNode(nodeID, p.GetNode()) + + container := report.MakeNodeWith(map[string]string{ + PodID: p.ID(), + Namespace: p.Namespace(), + }).WithParents(report.Sets{ + "pod": report.MakeStringSet(nodeID), + }) + for _, containerID := range p.ContainerIDs() { + containers.AddNode(report.MakeContainerNodeID(containerID), container) + } return nil }) - return result, err + return pods, containers, err } diff --git a/probe/kubernetes/reporter_test.go b/probe/kubernetes/reporter_test.go index 36b61f59f..49a5a2507 100644 --- a/probe/kubernetes/reporter_test.go +++ b/probe/kubernetes/reporter_test.go @@ -111,6 +111,7 @@ func TestReporter(t *testing.T) { want := report.MakeReport() pod1ID := report.MakePodNodeID("ping", "pong-a") pod2ID := report.MakePodNodeID("ping", "pong-b") + serviceID := report.MakeServiceNodeID("ping", "pongservice") want.Pod = report.MakeTopology().AddNode(pod1ID, report.MakeNodeWith(map[string]string{ kubernetes.PodID: "ping/pong-a", kubernetes.PodName: "pong-a", @@ -118,6 +119,8 @@ func TestReporter(t *testing.T) { kubernetes.PodCreated: pod1.Created(), kubernetes.PodContainerIDs: "container1 container2", kubernetes.ServiceIDs: "ping/pongservice", + }).WithParents(report.Sets{ + "service": report.MakeStringSet(serviceID), })).AddNode(pod2ID, report.MakeNodeWith(map[string]string{ kubernetes.PodID: "ping/pong-b", kubernetes.PodName: "pong-b", @@ -125,13 +128,36 @@ func TestReporter(t *testing.T) { kubernetes.PodCreated: pod1.Created(), kubernetes.PodContainerIDs: "container3 container4", kubernetes.ServiceIDs: "ping/pongservice", + }).WithParents(report.Sets{ + "service": report.MakeStringSet(serviceID), })) - want.Service = report.MakeTopology().AddNode(report.MakeServiceNodeID("ping", "pongservice"), report.MakeNodeWith(map[string]string{ + want.Service = report.MakeTopology().AddNode(serviceID, report.MakeNodeWith(map[string]string{ kubernetes.ServiceID: "ping/pongservice", kubernetes.ServiceName: "pongservice", kubernetes.Namespace: "ping", kubernetes.ServiceCreated: pod1.Created(), })) + want.Container = report.MakeTopology().AddNode(report.MakeContainerNodeID("container1"), report.MakeNodeWith(map[string]string{ + kubernetes.PodID: "ping/pong-a", + kubernetes.Namespace: "ping", + }).WithParents(report.Sets{ + "pod": report.MakeStringSet(pod1ID), + })).AddNode(report.MakeContainerNodeID("container2"), report.MakeNodeWith(map[string]string{ + kubernetes.PodID: "ping/pong-a", + kubernetes.Namespace: "ping", + }).WithParents(report.Sets{ + "pod": report.MakeStringSet(pod1ID), + })).AddNode(report.MakeContainerNodeID("container3"), report.MakeNodeWith(map[string]string{ + kubernetes.PodID: "ping/pong-b", + kubernetes.Namespace: "ping", + }).WithParents(report.Sets{ + "pod": report.MakeStringSet(pod2ID), + })).AddNode(report.MakeContainerNodeID("container4"), report.MakeNodeWith(map[string]string{ + kubernetes.PodID: "ping/pong-b", + kubernetes.Namespace: "ping", + }).WithParents(report.Sets{ + "pod": report.MakeStringSet(pod2ID), + })) reporter := kubernetes.NewReporter(mockClientInstance) have, _ := reporter.Report() diff --git a/probe/overlay/weave.go b/probe/overlay/weave.go index 67ccdb505..f72c83762 100644 --- a/probe/overlay/weave.go +++ b/probe/overlay/weave.go @@ -195,7 +195,7 @@ func (w *Weave) Tag(r report.Report) (report.Report, error) { if entry.Tombstone > 0 { continue } - nodeID := report.MakeContainerNodeID(w.hostID, entry.ContainerID) + nodeID := report.MakeContainerNodeID(entry.ContainerID) node, ok := r.Container.Nodes[nodeID] if !ok { continue diff --git a/probe/overlay/weave_test.go b/probe/overlay/weave_test.go index 9d551f793..9449a5dbe 100644 --- a/probe/overlay/weave_test.go +++ b/probe/overlay/weave_test.go @@ -46,7 +46,7 @@ func TestWeaveTaggerOverlayTopology(t *testing.T) { } { - nodeID := report.MakeContainerNodeID(mockHostID, mockContainerID) + nodeID := report.MakeContainerNodeID(mockContainerID) want := report.Report{ Container: report.MakeTopology().AddNode(nodeID, report.MakeNodeWith(map[string]string{ docker.ContainerID: mockContainerID, diff --git a/probe/probe_internal_test.go b/probe/probe_internal_test.go index 3370447e5..c50559d1e 100644 --- a/probe/probe_internal_test.go +++ b/probe/probe_internal_test.go @@ -33,8 +33,8 @@ func TestApply(t *testing.T) { from report.Topology via string }{ - {endpointNode.Merge(report.MakeNodeWith(map[string]string{"topology": "endpoint"})), r.Endpoint, endpointNodeID}, - {addressNode.Merge(report.MakeNodeWith(map[string]string{"topology": "address"})), r.Address, addressNodeID}, + {endpointNode.Merge(report.MakeNode().WithID("c").WithTopology("endpoint")), r.Endpoint, endpointNodeID}, + {addressNode.Merge(report.MakeNode().WithID("d").WithTopology("address")), r.Address, addressNodeID}, } { if want, have := tuple.want, tuple.from.Nodes[tuple.via]; !reflect.DeepEqual(want, have) { t.Errorf("want %+v, have %+v", want, have) diff --git a/probe/topology_tagger.go b/probe/topology_tagger.go index 1c8f975f9..d117a6f26 100644 --- a/probe/topology_tagger.go +++ b/probe/topology_tagger.go @@ -4,9 +4,6 @@ import ( "github.com/weaveworks/scope/report" ) -// Topology is the Node key for the origin topology. -const Topology = "topology" - type topologyTagger struct{} // NewTopologyTagger tags each node with the topology that it comes from. It's @@ -19,18 +16,19 @@ func (topologyTagger) Name() string { return "Topology" } // Tag implements Tagger func (topologyTagger) Tag(r report.Report) (report.Report, error) { - for val, topology := range map[string]*report.Topology{ + for name, t := range map[string]*report.Topology{ "endpoint": &(r.Endpoint), "address": &(r.Address), "process": &(r.Process), "container": &(r.Container), "container_image": &(r.ContainerImage), + "pod": &(r.Pod), + "service": &(r.Service), "host": &(r.Host), "overlay": &(r.Overlay), } { - metadata := map[string]string{Topology: val} - for id, node := range topology.Nodes { - topology.AddNode(id, node.WithMetadata(metadata)) + for id, node := range t.Nodes { + t.AddNode(id, node.WithID(id).WithTopology(name)) } } return r, nil diff --git a/prog/probe.go b/prog/probe.go index f8aa9ed1b..3742e80cf 100644 --- a/prog/probe.go +++ b/prog/probe.go @@ -131,7 +131,7 @@ func probeMain() { } if registry, err := docker.NewRegistry(*dockerInterval, clients); err == nil { defer registry.Stop() - p.AddTagger(docker.NewTagger(registry, processCache)) + p.AddTagger(docker.NewTagger(registry, hostID, processCache)) p.AddReporter(docker.NewReporter(registry, hostID, p)) } else { log.Printf("Docker: failed to start registry: %v", err) diff --git a/render/detailed/metadata.go b/render/detailed/metadata.go new file mode 100644 index 000000000..e0dc35940 --- /dev/null +++ b/render/detailed/metadata.go @@ -0,0 +1,135 @@ +package detailed + +import ( + "fmt" + "sort" + "strings" + + "github.com/weaveworks/scope/probe/docker" + "github.com/weaveworks/scope/probe/host" + "github.com/weaveworks/scope/probe/kubernetes" + "github.com/weaveworks/scope/probe/overlay" + "github.com/weaveworks/scope/probe/process" + "github.com/weaveworks/scope/report" +) + +var ( + processNodeMetadata = renderMetadata( + meta(process.PID, "PID"), + meta(process.PPID, "Parent PID"), + meta(process.Cmdline, "Command"), + meta(process.Threads, "# Threads"), + ) + containerNodeMetadata = renderMetadata( + meta(docker.ContainerID, "ID"), + meta(docker.ImageID, "Image ID"), + ltst(docker.ContainerState, "State"), + sets(docker.ContainerIPs, "IPs"), + sets(docker.ContainerPorts, "Ports"), + meta(docker.ContainerCreated, "Created"), + meta(docker.ContainerCommand, "Command"), + meta(overlay.WeaveMACAddress, "Weave MAC"), + meta(overlay.WeaveDNSHostname, "Weave DNS Hostname"), + getDockerLabelRows, + ) + containerImageNodeMetadata = renderMetadata( + meta(docker.ImageID, "Image ID"), + getDockerLabelRows, + ) + podNodeMetadata = renderMetadata( + meta(kubernetes.PodID, "ID"), + meta(kubernetes.Namespace, "Namespace"), + meta(kubernetes.PodCreated, "Created"), + ) + hostNodeMetadata = renderMetadata( + meta(host.HostName, "Hostname"), + meta(host.OS, "Operating system"), + meta(host.KernelVersion, "Kernel version"), + meta(host.Uptime, "Uptime"), + sets(host.LocalNetworks, "Local Networks"), + ) +) + +// MetadataRow is a row for the metadata table. +type MetadataRow struct { + ID string `json:"id"` + Label string `json:"label"` + Value string `json:"value"` +} + +// Copy returns a value copy of a metadata row. +func (m MetadataRow) Copy() MetadataRow { + return MetadataRow{ + ID: m.ID, + Label: m.Label, + Value: m.Value, + } +} + +// NodeMetadata produces a table (to be consumed directly by the UI) based on +// an origin ID, which is (optimistically) a node ID in one of our topologies. +func NodeMetadata(n report.Node) []MetadataRow { + renderers := map[string]func(report.Node) []MetadataRow{ + "process": processNodeMetadata, + "container": containerNodeMetadata, + "container_image": containerImageNodeMetadata, + "pod": podNodeMetadata, + "host": hostNodeMetadata, + } + if renderer, ok := renderers[n.Topology]; ok { + return renderer(n) + } + return nil +} + +func renderMetadata(templates ...func(report.Node) []MetadataRow) func(report.Node) []MetadataRow { + return func(nmd report.Node) []MetadataRow { + rows := []MetadataRow{} + for _, template := range templates { + rows = append(rows, template(nmd)...) + } + return rows + } +} + +func meta(id, label string) func(report.Node) []MetadataRow { + return func(n report.Node) []MetadataRow { + if val, ok := n.Metadata[id]; ok { + return []MetadataRow{{ID: id, Label: label, Value: val}} + } + return nil + } +} + +func sets(id, label string) func(report.Node) []MetadataRow { + return func(n report.Node) []MetadataRow { + if val, ok := n.Sets[id]; ok && len(val) > 0 { + return []MetadataRow{{ID: id, Label: label, Value: strings.Join(val, ", ")}} + } + return nil + } +} + +func ltst(id, label string) func(report.Node) []MetadataRow { + return func(n report.Node) []MetadataRow { + if val, ok := n.Latest.Lookup(id); ok { + return []MetadataRow{{ID: id, Label: label, Value: val}} + } + return nil + } +} + +func getDockerLabelRows(nmd report.Node) []MetadataRow { + rows := []MetadataRow{} + // Add labels in alphabetical order + labels := docker.ExtractLabels(nmd) + labelKeys := make([]string, 0, len(labels)) + for k := range labels { + labelKeys = append(labelKeys, k) + } + sort.Strings(labelKeys) + for _, labelKey := range labelKeys { + rows = append(rows, MetadataRow{ID: "label_" + labelKey, Label: fmt.Sprintf("Label %q", labelKey), Value: labels[labelKey]}) + } + return rows +} diff --git a/render/detailed/metadata_test.go b/render/detailed/metadata_test.go new file mode 100644 index 000000000..e4f86bebc --- /dev/null +++ b/render/detailed/metadata_test.go @@ -0,0 +1,53 @@ +package detailed_test + +import ( + "reflect" + "testing" + + "github.com/weaveworks/scope/probe/docker" + "github.com/weaveworks/scope/render/detailed" + "github.com/weaveworks/scope/report" + "github.com/weaveworks/scope/test" + "github.com/weaveworks/scope/test/fixture" +) + +func TestNodeMetadata(t *testing.T) { + inputs := []struct { + name string + node report.Node + want []detailed.MetadataRow + }{ + { + name: "container", + node: report.MakeNodeWith(map[string]string{ + docker.ContainerID: fixture.ClientContainerID, + docker.LabelPrefix + "label1": "label1value", + }).WithTopology("container").WithSets(report.Sets{ + docker.ContainerIPs: report.MakeStringSet("10.10.10.0/24", "10.10.10.1/24"), + }).WithLatest(docker.ContainerState, fixture.Now, docker.StateRunning), + want: []detailed.MetadataRow{ + {ID: docker.ContainerID, Label: "ID", Value: fixture.ClientContainerID}, + {ID: docker.ContainerState, Label: "State", Value: "running"}, + {ID: docker.ContainerIPs, Label: "IPs", Value: "10.10.10.0/24, 10.10.10.1/24"}, + { + ID: "label_label1", + Label: "Label \"label1\"", + Value: "label1value", + }, + }, + }, + { + name: "unknown topology", + node: report.MakeNodeWith(map[string]string{ + docker.ContainerID: fixture.ClientContainerID, + }).WithTopology("foobar").WithID(fixture.ClientContainerNodeID), + want: nil, + }, + } + for _, input := range inputs { + have := detailed.NodeMetadata(input.node) + if !reflect.DeepEqual(input.want, have) { + t.Errorf("%s: %s", input.name, test.Diff(input.want, have)) + } + } +} diff --git a/render/detailed/metrics.go b/render/detailed/metrics.go new file mode 100644 index 000000000..c38cef3f3 --- /dev/null +++ b/render/detailed/metrics.go @@ -0,0 +1,176 @@ +package detailed + +import ( + "encoding/json" + "math" + + "github.com/weaveworks/scope/probe/docker" + "github.com/weaveworks/scope/probe/host" + "github.com/weaveworks/scope/probe/process" + "github.com/weaveworks/scope/report" +) + +const ( + defaultFormat = "" + filesizeFormat = "filesize" + percentFormat = "percent" +) + +// MetricRow is a tuple of data used to render a metric as a sparkline and +// accoutrements. +type MetricRow struct { + ID string + Label string + Format string + Group string + Value float64 + Metric *report.Metric +} + +// Copy returns a value copy of the MetricRow +func (m MetricRow) Copy() MetricRow { + metric := m.Metric.Copy() + return MetricRow{ + ID: m.ID, + Label: m.Label, + Format: m.Format, + Group: m.Group, + Value: m.Value, + Metric: &metric, + } +} + +// MarshalJSON marshals this MetricRow to json. It takes the basic Metric +// rendering, then adds some row-specific fields. +func (m MetricRow) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + ID string `json:"id"` + Label string `json:"label"` + Format string `json:"format,omitempty"` + Group string `json:"group,omitempty"` + Value float64 `json:"value"` + report.WireMetrics + }{ + ID: m.ID, + Label: m.Label, + Format: m.Format, + Group: m.Group, + Value: m.Value, + WireMetrics: m.Metric.ToIntermediate(), + }) +} + +func metricRow(id, label string, metric report.Metric, format, group string) MetricRow { + var last float64 + if s := metric.LastSample(); s != nil { + last = s.Value + } + return MetricRow{ + ID: id, + Label: label, + Format: format, + Group: group, + Value: toFixed(last, 2), + Metric: &metric, + } +} + +// toFixed truncates decimals of float64 down to specified precision +func toFixed(num float64, precision int) float64 { + output := math.Pow(10, float64(precision)) + return float64(int64(num*output)) / output +} + +// NodeMetrics produces a table (to be consumed directly by the UI) based on +// an origin ID, which is (optimistically) a node ID in one of our topologies. +func NodeMetrics(n report.Node) []MetricRow { + renderers := map[string]func(report.Node) []MetricRow{ + "process": processNodeMetrics, + "container": containerNodeMetrics, + "host": hostNodeMetrics, + } + if renderer, ok := renderers[n.Topology]; ok { + return renderer(n) + } + return nil +} + +func processNodeMetrics(nmd report.Node) []MetricRow { + rows := []MetricRow{} + for _, tuple := range []struct { + ID, Label, fmt string + }{ + {process.CPUUsage, "CPU Usage", percentFormat}, + {process.MemoryUsage, "Memory Usage", filesizeFormat}, + } { + if val, ok := nmd.Metrics[tuple.ID]; ok { + rows = append(rows, metricRow( + tuple.ID, + tuple.Label, + val, + tuple.fmt, + "", + )) + } + } + return rows +} + +func containerNodeMetrics(nmd report.Node) []MetricRow { + rows := []MetricRow{} + if val, ok := nmd.Metrics[docker.CPUTotalUsage]; ok { + rows = append(rows, metricRow( + docker.CPUTotalUsage, + "CPU Usage", + val, + percentFormat, + "", + )) + } + if val, ok := nmd.Metrics[docker.MemoryUsage]; ok { + rows = append(rows, metricRow( + docker.MemoryUsage, + "Memory Usage", + val, + filesizeFormat, + "", + )) + } + return rows +} + +func hostNodeMetrics(nmd report.Node) []MetricRow { + // Ensure that all metrics have the same max + maxLoad := 0.0 + for _, id := range []string{host.Load1, host.Load5, host.Load15} { + if metric, ok := nmd.Metrics[id]; ok { + if metric.Len() == 0 { + continue + } + if metric.Max > maxLoad { + maxLoad = metric.Max + } + } + } + + rows := []MetricRow{} + for _, tuple := range []struct{ ID, Label, fmt string }{ + {host.CPUUsage, "CPU Usage", percentFormat}, + {host.MemUsage, "Memory Usage", filesizeFormat}, + } { + if val, ok := nmd.Metrics[tuple.ID]; ok { + rows = append(rows, metricRow(tuple.ID, tuple.Label, val, tuple.fmt, "")) + } + } + for _, tuple := range []struct{ ID, Label string }{ + {host.Load1, "Load (1m)"}, + {host.Load5, "Load (5m)"}, + {host.Load15, "Load (15m)"}, + } { + if val, ok := nmd.Metrics[tuple.ID]; ok { + val.Max = maxLoad + rows = append(rows, metricRow(tuple.ID, tuple.Label, val, defaultFormat, "load")) + } + } + return rows +} diff --git a/render/detailed/metrics_test.go b/render/detailed/metrics_test.go new file mode 100644 index 000000000..f61c8c5f4 --- /dev/null +++ b/render/detailed/metrics_test.go @@ -0,0 +1,121 @@ +package detailed_test + +import ( + "reflect" + "testing" + + "github.com/weaveworks/scope/probe/docker" + "github.com/weaveworks/scope/probe/host" + "github.com/weaveworks/scope/probe/process" + "github.com/weaveworks/scope/render/detailed" + "github.com/weaveworks/scope/report" + "github.com/weaveworks/scope/test" + "github.com/weaveworks/scope/test/fixture" +) + +func TestNodeMetrics(t *testing.T) { + inputs := []struct { + name string + node report.Node + want []detailed.MetricRow + }{ + { + name: "process", + node: fixture.Report.Process.Nodes[fixture.ClientProcess1NodeID], + want: []detailed.MetricRow{ + { + ID: process.CPUUsage, + Label: "CPU Usage", + Format: "percent", + Group: "", + Value: 0.01, + Metric: &fixture.CPUMetric, + }, + { + ID: process.MemoryUsage, + Label: "Memory Usage", + Format: "filesize", + Group: "", + Value: 0.01, + Metric: &fixture.MemoryMetric, + }, + }, + }, + { + name: "container", + node: fixture.Report.Container.Nodes[fixture.ClientContainerNodeID], + want: []detailed.MetricRow{ + { + ID: docker.CPUTotalUsage, + Label: "CPU Usage", + Format: "percent", + Group: "", + Value: 0.01, + Metric: &fixture.CPUMetric, + }, + { + ID: docker.MemoryUsage, + Label: "Memory Usage", + Format: "filesize", + Group: "", + Value: 0.01, + Metric: &fixture.MemoryMetric, + }, + }, + }, + { + name: "host", + node: fixture.Report.Host.Nodes[fixture.ClientHostNodeID], + want: []detailed.MetricRow{ + { + ID: host.CPUUsage, + Label: "CPU Usage", + Format: "percent", + Group: "", + Value: 0.01, + Metric: &fixture.CPUMetric, + }, + { + ID: host.MemUsage, + Label: "Memory Usage", + Format: "filesize", + Group: "", + Value: 0.01, + Metric: &fixture.MemoryMetric, + }, + { + ID: host.Load1, + Label: "Load (1m)", + Group: "load", + Value: 0.01, + Metric: &fixture.LoadMetric, + }, + { + ID: host.Load5, + Label: "Load (5m)", + Group: "load", + Value: 0.01, + Metric: &fixture.LoadMetric, + }, + { + ID: host.Load15, + Label: "Load (15m)", + Group: "load", + Value: 0.01, + Metric: &fixture.LoadMetric, + }, + }, + }, + { + name: "unknown topology", + node: report.MakeNode().WithTopology("foobar").WithID(fixture.ClientContainerNodeID), + want: nil, + }, + } + for _, input := range inputs { + have := detailed.NodeMetrics(input.node) + if !reflect.DeepEqual(input.want, have) { + t.Errorf("%s: %s", input.name, test.Diff(input.want, have)) + } + } +} diff --git a/render/detailed/node.go b/render/detailed/node.go new file mode 100644 index 000000000..29b5e7c8b --- /dev/null +++ b/render/detailed/node.go @@ -0,0 +1,208 @@ +package detailed + +import ( + "sort" + + "github.com/weaveworks/scope/probe/docker" + "github.com/weaveworks/scope/probe/host" + "github.com/weaveworks/scope/probe/kubernetes" + "github.com/weaveworks/scope/probe/process" + "github.com/weaveworks/scope/render" + "github.com/weaveworks/scope/report" +) + +// Node is the data type that's yielded to the JavaScript layer when +// we want deep information about an individual node. +type Node struct { + ID string `json:"id"` + Label string `json:"label"` + Rank string `json:"rank,omitempty"` + Pseudo bool `json:"pseudo,omitempty"` + Controls []ControlInstance `json:"controls"` + Metadata []MetadataRow `json:"metadata,omitempty"` + Metrics []MetricRow `json:"metrics,omitempty"` + Children []NodeSummaryGroup `json:"children,omitempty"` + Parents []Parent `json:"parents,omitempty"` +} + +// Parent is the information needed to build a link to the parent of a Node. +type Parent struct { + ID string `json:"id"` + Label string `json:"label"` + TopologyID string `json:"topologyId"` +} + +// ControlInstance contains a control description, and all the info +// needed to execute it. +type ControlInstance struct { + ProbeID string `json:"probeId"` + NodeID string `json:"nodeId"` + report.Control +} + +// MakeNode transforms a renderable node to a detailed node. It uses +// aggregate metadata, plus the set of origin node IDs, to produce tables. +func MakeNode(r report.Report, n render.RenderableNode) Node { + return Node{ + ID: n.ID, + Label: n.LabelMajor, + Rank: n.Rank, + Pseudo: n.Pseudo, + Controls: controls(r, n), + Metadata: NodeMetadata(n.Node), + Metrics: NodeMetrics(n.Node), + Children: children(n), + Parents: parents(r, n), + } +} + +func controlsFor(topology report.Topology, nodeID string) []ControlInstance { + result := []ControlInstance{} + node, ok := topology.Nodes[nodeID] + if !ok { + return result + } + + for _, id := range node.Controls.Controls { + if control, ok := topology.Controls[id]; ok { + result = append(result, ControlInstance{ + ProbeID: node.Metadata[report.ProbeID], + NodeID: nodeID, + Control: control, + }) + } + } + return result +} + +func controls(r report.Report, n render.RenderableNode) []ControlInstance { + if _, ok := r.Process.Nodes[n.ControlNode]; ok { + return controlsFor(r.Process, n.ControlNode) + } else if _, ok := r.Container.Nodes[n.ControlNode]; ok { + return controlsFor(r.Container, n.ControlNode) + } else if _, ok := r.ContainerImage.Nodes[n.ControlNode]; ok { + return controlsFor(r.ContainerImage, n.ControlNode) + } else if _, ok := r.Host.Nodes[n.ControlNode]; ok { + return controlsFor(r.Host, n.ControlNode) + } + return []ControlInstance{} +} + +var ( + nodeSummaryGroupSpecs = []struct { + topologyID string + NodeSummaryGroup + }{ + {"host", NodeSummaryGroup{TopologyID: "hosts", Label: "Hosts", Columns: []string{host.CPUUsage, host.MemUsage}}}, + {"pod", NodeSummaryGroup{TopologyID: "pods", Label: "Pods", Columns: []string{}}}, + {"container_image", NodeSummaryGroup{TopologyID: "containers-by-image", Label: "Container Images", Columns: []string{}}}, + {"container", NodeSummaryGroup{TopologyID: "containers", Label: "Containers", Columns: []string{docker.CPUTotalUsage, docker.MemoryUsage}}}, + {"process", NodeSummaryGroup{TopologyID: "applications", Label: "Applications", Columns: []string{process.PID, process.CPUUsage, process.MemoryUsage}}}, + } +) + +func children(n render.RenderableNode) []NodeSummaryGroup { + summaries := map[string][]NodeSummary{} + for _, child := range n.Children { + if child.ID == n.ID { + continue + } + + if summary, ok := MakeNodeSummary(child); ok { + summaries[child.Topology] = append(summaries[child.Topology], summary) + } + } + + nodeSummaryGroups := []NodeSummaryGroup{} + for _, spec := range nodeSummaryGroupSpecs { + if len(summaries[spec.topologyID]) > 0 { + sort.Sort(nodeSummariesByID(summaries[spec.TopologyID])) + group := spec.NodeSummaryGroup.Copy() + group.Nodes = summaries[spec.topologyID] + nodeSummaryGroups = append(nodeSummaryGroups, group) + } + } + return nodeSummaryGroups +} + +// parents is a total a hack to find the parents of a node (which is +// ill-defined). +func parents(r report.Report, n render.RenderableNode) (result []Parent) { + defer func() { + for i, parent := range result { + if parent.ID == n.ID { + result = append(result[:i], result[i+1:]...) + } + } + }() + + topologies := map[string]struct { + report.Topology + render func(report.Node) Parent + }{ + "container": {r.Container, containerParent}, + "pod": {r.Pod, podParent}, + "service": {r.Service, serviceParent}, + "container_image": {r.ContainerImage, containerImageParent}, + "host": {r.Host, hostParent}, + } + topologyIDs := []string{} + for topologyID := range topologies { + topologyIDs = append(topologyIDs, topologyID) + } + sort.Strings(topologyIDs) + for _, topologyID := range topologyIDs { + t := topologies[topologyID] + for _, id := range n.Node.Parents[topologyID] { + parent, ok := t.Nodes[id] + if !ok { + continue + } + + result = append(result, t.render(parent)) + } + } + return result +} + +func containerParent(n report.Node) Parent { + label, _ := render.GetRenderableContainerName(n) + return Parent{ + ID: render.MakeContainerID(n.Metadata[docker.ContainerID]), + Label: label, + TopologyID: "containers", + } +} + +func podParent(n report.Node) Parent { + return Parent{ + ID: render.MakePodID(n.Metadata[kubernetes.PodID]), + Label: n.Metadata[kubernetes.PodName], + TopologyID: "pods", + } +} + +func serviceParent(n report.Node) Parent { + return Parent{ + ID: render.MakeServiceID(n.Metadata[kubernetes.ServiceID]), + Label: n.Metadata[kubernetes.ServiceName], + TopologyID: "pods-by-service", + } +} + +func containerImageParent(n report.Node) Parent { + imageName := n.Metadata[docker.ImageName] + return Parent{ + ID: render.MakeContainerImageID(render.ImageNameWithoutVersion(imageName)), + Label: imageName, + TopologyID: "containers-by-image", + } +} + +func hostParent(n report.Node) Parent { + return Parent{ + ID: render.MakeHostID(n.Metadata[host.HostName]), + Label: n.Metadata[host.HostName], + TopologyID: "hosts", + } +} diff --git a/render/detailed/node_test.go b/render/detailed/node_test.go new file mode 100644 index 000000000..04051fdc9 --- /dev/null +++ b/render/detailed/node_test.go @@ -0,0 +1,185 @@ +package detailed_test + +import ( + "fmt" + "reflect" + "testing" + + "github.com/weaveworks/scope/probe/docker" + "github.com/weaveworks/scope/probe/host" + "github.com/weaveworks/scope/probe/process" + "github.com/weaveworks/scope/render" + "github.com/weaveworks/scope/render/detailed" + "github.com/weaveworks/scope/test" + "github.com/weaveworks/scope/test/fixture" +) + +func TestMakeDetailedHostNode(t *testing.T) { + renderableNode := render.HostRenderer.Render(fixture.Report)[render.MakeHostID(fixture.ClientHostID)] + have := detailed.MakeNode(fixture.Report, renderableNode) + + containerImageNodeSummary, _ := detailed.MakeNodeSummary(fixture.Report.ContainerImage.Nodes[fixture.ClientContainerImageNodeID]) + containerNodeSummary, _ := detailed.MakeNodeSummary(fixture.Report.Container.Nodes[fixture.ClientContainerNodeID]) + process1NodeSummary, _ := detailed.MakeNodeSummary(fixture.Report.Process.Nodes[fixture.ClientProcess1NodeID]) + process1NodeSummary.Linkable = true + process2NodeSummary, _ := detailed.MakeNodeSummary(fixture.Report.Process.Nodes[fixture.ClientProcess2NodeID]) + process2NodeSummary.Linkable = true + want := detailed.Node{ + ID: render.MakeHostID(fixture.ClientHostID), + Label: "client", + Rank: "hostname.com", + Pseudo: false, + Controls: []detailed.ControlInstance{}, + Metadata: []detailed.MetadataRow{ + { + ID: "host_name", + Label: "Hostname", + Value: "client.hostname.com", + }, + { + ID: "os", + Label: "Operating system", + Value: "Linux", + }, + { + ID: "local_networks", + Label: "Local Networks", + Value: "10.10.10.0/24", + }, + }, + Metrics: []detailed.MetricRow{ + { + ID: host.CPUUsage, + Format: "percent", + Label: "CPU Usage", + Value: 0.01, + Metric: &fixture.CPUMetric, + }, + { + ID: host.MemUsage, + Format: "filesize", + Label: "Memory Usage", + Value: 0.01, + Metric: &fixture.MemoryMetric, + }, + { + ID: host.Load1, + Group: "load", + Label: "Load (1m)", + Value: 0.01, + Metric: &fixture.LoadMetric, + }, + { + ID: host.Load5, + Group: "load", + Label: "Load (5m)", + Value: 0.01, + Metric: &fixture.LoadMetric, + }, + { + ID: host.Load15, + Label: "Load (15m)", + Group: "load", + Value: 0.01, + Metric: &fixture.LoadMetric, + }, + }, + Children: []detailed.NodeSummaryGroup{ + { + Label: "Container Images", + TopologyID: "containers-by-image", + Columns: []string{}, + Nodes: []detailed.NodeSummary{containerImageNodeSummary}, + }, + { + Label: "Containers", + TopologyID: "containers", + Columns: []string{docker.CPUTotalUsage, docker.MemoryUsage}, + Nodes: []detailed.NodeSummary{containerNodeSummary}, + }, + { + Label: "Applications", + TopologyID: "applications", + Columns: []string{process.PID, process.CPUUsage, process.MemoryUsage}, + Nodes: []detailed.NodeSummary{process1NodeSummary, process2NodeSummary}, + }, + }, + } + if !reflect.DeepEqual(want, have) { + t.Errorf("%s", test.Diff(want, have)) + } +} + +func TestMakeDetailedContainerNode(t *testing.T) { + id := render.MakeContainerID(fixture.ServerContainerID) + renderableNode, ok := render.ContainerRenderer.Render(fixture.Report)[id] + if !ok { + t.Fatalf("Node not found: %s", id) + } + have := detailed.MakeNode(fixture.Report, renderableNode) + want := detailed.Node{ + ID: id, + Label: "server", + Rank: "imageid456", + Pseudo: false, + Controls: []detailed.ControlInstance{}, + Metadata: []detailed.MetadataRow{ + {ID: "docker_container_id", Label: "ID", Value: fixture.ServerContainerID}, + {ID: "docker_image_id", Label: "Image ID", Value: fixture.ServerContainerImageID}, + {ID: "docker_container_state", Label: "State", Value: "running"}, + {ID: "label_" + render.AmazonECSContainerNameLabel, Label: fmt.Sprintf(`Label %q`, render.AmazonECSContainerNameLabel), Value: `server`}, + {ID: "label_foo1", Label: `Label "foo1"`, Value: `bar1`}, + {ID: "label_foo2", Label: `Label "foo2"`, Value: `bar2`}, + {ID: "label_io.kubernetes.pod.name", Label: `Label "io.kubernetes.pod.name"`, Value: "ping/pong-b"}, + }, + Metrics: []detailed.MetricRow{ + { + ID: docker.CPUTotalUsage, + Format: "percent", + Label: "CPU Usage", + Value: 0.01, + Metric: &fixture.CPUMetric, + }, + { + ID: docker.MemoryUsage, + Format: "filesize", + Label: "Memory Usage", + Value: 0.01, + Metric: &fixture.MemoryMetric, + }, + }, + Children: []detailed.NodeSummaryGroup{ + { + Label: "Applications", + TopologyID: "applications", + Columns: []string{process.PID, process.CPUUsage, process.MemoryUsage}, + Nodes: []detailed.NodeSummary{ + { + ID: fmt.Sprintf("process:%s:%s", "server.hostname.com", fixture.ServerPID), + Label: "apache", + Linkable: true, + Metadata: []detailed.MetadataRow{ + {ID: process.PID, Label: "PID", Value: fixture.ServerPID}, + }, + Metrics: []detailed.MetricRow{}, + }, + }, + }, + }, + Parents: []detailed.Parent{ + { + ID: render.MakeContainerImageID(fixture.ServerContainerImageName), + Label: fixture.ServerContainerImageName, + TopologyID: "containers-by-image", + }, + { + ID: render.MakeHostID(fixture.ServerHostName), + Label: fixture.ServerHostName, + TopologyID: "hosts", + }, + }, + } + if !reflect.DeepEqual(want, have) { + t.Errorf("%s", test.Diff(want, have)) + } +} diff --git a/render/detailed/summary.go b/render/detailed/summary.go new file mode 100644 index 000000000..b1bc7df8a --- /dev/null +++ b/render/detailed/summary.go @@ -0,0 +1,140 @@ +package detailed + +import ( + "fmt" + + "github.com/weaveworks/scope/probe/docker" + "github.com/weaveworks/scope/probe/host" + "github.com/weaveworks/scope/probe/kubernetes" + "github.com/weaveworks/scope/probe/process" + "github.com/weaveworks/scope/render" + "github.com/weaveworks/scope/report" +) + +// NodeSummaryGroup is a topology-typed group of children for a Node. +type NodeSummaryGroup struct { + Label string `json:"label"` + Nodes []NodeSummary `json:"nodes"` + TopologyID string `json:"topologyId"` + Columns []string `json:"columns"` +} + +// Copy returns a value copy of the NodeSummaryGroup +func (g NodeSummaryGroup) Copy() NodeSummaryGroup { + result := NodeSummaryGroup{ + TopologyID: g.TopologyID, + Label: g.Label, + Columns: g.Columns, + } + for _, node := range g.Nodes { + result.Nodes = append(result.Nodes, node.Copy()) + } + return result +} + +// NodeSummary is summary information about a child for a Node. +type NodeSummary struct { + ID string `json:"id"` + Label string `json:"label"` + Linkable bool `json:"linkable"` // Whether this node can be linked-to + Metadata []MetadataRow `json:"metadata,omitempty"` + Metrics []MetricRow `json:"metrics,omitempty"` +} + +// MakeNodeSummary summarizes a node, if possible. +func MakeNodeSummary(n report.Node) (NodeSummary, bool) { + renderers := map[string]func(report.Node) NodeSummary{ + "process": processNodeSummary, + "container": containerNodeSummary, + "container_image": containerImageNodeSummary, + "pod": podNodeSummary, + "host": hostNodeSummary, + } + if renderer, ok := renderers[n.Topology]; ok { + return renderer(n), true + } + return NodeSummary{}, false +} + +// Copy returns a value copy of the NodeSummary +func (n NodeSummary) Copy() NodeSummary { + result := NodeSummary{ + ID: n.ID, + Label: n.Label, + Linkable: n.Linkable, + } + for _, row := range n.Metadata { + result.Metadata = append(result.Metadata, row.Copy()) + } + for _, row := range n.Metrics { + result.Metrics = append(result.Metrics, row.Copy()) + } + return result +} + +func processNodeSummary(nmd report.Node) NodeSummary { + var ( + id string + label, nameFound = nmd.Metadata[process.Name] + ) + if pid, ok := nmd.Metadata[process.PID]; ok { + if !nameFound { + label = fmt.Sprintf("(%s)", pid) + } + id = render.MakeProcessID(report.ExtractHostID(nmd), pid) + } + _, isConnected := nmd.Metadata[render.IsConnected] + return NodeSummary{ + ID: id, + Label: label, + Linkable: isConnected, + Metadata: processNodeMetadata(nmd), + Metrics: processNodeMetrics(nmd), + } +} + +func containerNodeSummary(nmd report.Node) NodeSummary { + label, _ := render.GetRenderableContainerName(nmd) + return NodeSummary{ + ID: render.MakeContainerID(nmd.Metadata[docker.ContainerID]), + Label: label, + Linkable: true, + Metadata: containerNodeMetadata(nmd), + Metrics: containerNodeMetrics(nmd), + } +} + +func containerImageNodeSummary(nmd report.Node) NodeSummary { + imageName := nmd.Metadata[docker.ImageName] + return NodeSummary{ + ID: render.MakeContainerImageID(render.ImageNameWithoutVersion(imageName)), + Label: imageName, + Linkable: true, + Metadata: containerImageNodeMetadata(nmd), + } +} + +func podNodeSummary(nmd report.Node) NodeSummary { + return NodeSummary{ + ID: render.MakePodID(nmd.Metadata[kubernetes.PodID]), + Label: nmd.Metadata[kubernetes.PodName], + Linkable: true, + Metadata: podNodeMetadata(nmd), + } +} + +func hostNodeSummary(nmd report.Node) NodeSummary { + return NodeSummary{ + ID: render.MakeHostID(nmd.Metadata[host.HostName]), + Label: nmd.Metadata[host.HostName], + Linkable: true, + Metadata: hostNodeMetadata(nmd), + Metrics: hostNodeMetrics(nmd), + } +} + +type nodeSummariesByID []NodeSummary + +func (s nodeSummariesByID) Len() int { return len(s) } +func (s nodeSummariesByID) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s nodeSummariesByID) Less(i, j int) bool { return s[i].ID < s[j].ID } diff --git a/render/detailed_node.go b/render/detailed_node.go deleted file mode 100644 index e8516562b..000000000 --- a/render/detailed_node.go +++ /dev/null @@ -1,572 +0,0 @@ -package render - -import ( - "fmt" - "sort" - "strconv" - - "github.com/weaveworks/scope/probe/docker" - "github.com/weaveworks/scope/probe/host" - "github.com/weaveworks/scope/probe/overlay" - "github.com/weaveworks/scope/probe/process" - "github.com/weaveworks/scope/report" -) - -const ( - containerImageRank = 4 - containerRank = 3 - processRank = 2 - hostRank = 1 - connectionsRank = 0 // keep connections at the bottom until they are expandable in the UI -) - -// DetailedNode is the data type that's yielded to the JavaScript layer when -// we want deep information about an individual node. -type DetailedNode struct { - ID string `json:"id"` - LabelMajor string `json:"label_major"` - LabelMinor string `json:"label_minor,omitempty"` - Rank string `json:"rank,omitempty"` - Pseudo bool `json:"pseudo,omitempty"` - Tables []Table `json:"tables"` - Controls []ControlInstance `json:"controls"` -} - -// Table is a dataset associated with a node. It will be displayed in the -// detail panel when a user clicks on a node. -type Table struct { - Title string `json:"title"` // e.g. Bandwidth - Numeric bool `json:"numeric"` // should the major column be right-aligned? - Rank int `json:"-"` // used to sort tables; not emitted. - Rows []Row `json:"rows"` -} - -// Row is a single entry in a Table dataset. -type Row struct { - Key string `json:"key"` // e.g. Ingress - ValueMajor string `json:"value_major"` // e.g. 25 - ValueMinor string `json:"value_minor,omitempty"` // e.g. KB/s - Expandable bool `json:"expandable,omitempty"` // Whether it can be expanded (hidden by default) - ValueType string `json:"value_type,omitempty"` // e.g. sparkline - Metric *report.Metric `json:"metric,omitempty"` // e.g. sparkline data samples -} - -// ControlInstance contains a control description, and all the info -// needed to execute it. -type ControlInstance struct { - ProbeID string `json:"probeId"` - NodeID string `json:"nodeId"` - report.Control -} - -type sortableRows []Row - -func (r sortableRows) Len() int { return len(r) } -func (r sortableRows) Swap(i, j int) { r[i], r[j] = r[j], r[i] } -func (r sortableRows) Less(i, j int) bool { - switch { - case r[i].Key != r[j].Key: - return r[i].Key < r[j].Key - - case r[i].ValueMajor != r[j].ValueMajor: - return r[i].ValueMajor < r[j].ValueMajor - - default: - return r[i].ValueMinor < r[j].ValueMinor - } -} - -type sortableTables []Table - -func (t sortableTables) Len() int { return len(t) } -func (t sortableTables) Swap(i, j int) { t[i], t[j] = t[j], t[i] } -func (t sortableTables) Less(i, j int) bool { return t[i].Rank > t[j].Rank } - -// MakeDetailedNode transforms a renderable node to a detailed node. It uses -// aggregate metadata, plus the set of origin node IDs, to produce tables. -func MakeDetailedNode(r report.Report, n RenderableNode) DetailedNode { - tables := sortableTables{} - - // Figure out if multiple hosts/containers are referenced by the renderableNode - multiContainer, multiHost := getRenderingContext(r, n) - - // RenderableNode may be the result of merge operation(s), and so may have - // multiple origins. The ultimate goal here is to generate tables to view - // in the UI, so we skip the intermediate representations, but we could - // add them later. - connections := []Row{} - for _, id := range n.Origins { - if table, ok := OriginTable(r, id, multiHost, multiContainer); ok { - tables = append(tables, table) - } else if _, ok := r.Endpoint.Nodes[id]; ok { - connections = append(connections, connectionDetailsRows(r.Endpoint, id)...) - } else if _, ok := r.Address.Nodes[id]; ok { - connections = append(connections, connectionDetailsRows(r.Address, id)...) - } - } - - if table, ok := connectionsTable(connections, r, n); ok { - tables = append(tables, table) - } - - // Sort tables by rank - sort.Sort(tables) - - return DetailedNode{ - ID: n.ID, - LabelMajor: n.LabelMajor, - LabelMinor: n.LabelMinor, - Rank: n.Rank, - Pseudo: n.Pseudo, - Tables: tables, - Controls: controls(r, n), - } -} - -func getRenderingContext(r report.Report, n RenderableNode) (multiContainer, multiHost bool) { - var ( - originHosts = map[string]struct{}{} - originContainers = map[string]struct{}{} - ) - for _, id := range n.Origins { - for _, topology := range r.Topologies() { - if nmd, ok := topology.Nodes[id]; ok { - originHosts[report.ExtractHostID(nmd)] = struct{}{} - if id, ok := nmd.Metadata[docker.ContainerID]; ok { - originContainers[id] = struct{}{} - } - } - // Return early if possible - multiHost = len(originHosts) > 1 - multiContainer = len(originContainers) > 1 - if multiHost && multiContainer { - return - } - } - } - return -} - -func connectionsTable(connections []Row, r report.Report, n RenderableNode) (Table, bool) { - sec := r.Window.Seconds() - rate := func(u *uint64) (float64, bool) { - if u == nil { - return 0.0, false - } - if sec <= 0 { - return 0.0, true - } - return float64(*u) / sec, true - } - shortenByteRate := func(rate float64) (major, minor string) { - switch { - case rate > 1024*1024: - return fmt.Sprintf("%.2f", rate/1024/1024), "MBps" - case rate > 1024: - return fmt.Sprintf("%.1f", rate/1024), "KBps" - default: - return fmt.Sprintf("%.0f", rate), "Bps" - } - } - - rows := []Row{} - if n.EdgeMetadata.MaxConnCountTCP != nil { - rows = append(rows, Row{Key: "TCP connections", ValueMajor: strconv.FormatUint(*n.EdgeMetadata.MaxConnCountTCP, 10)}) - } - if rate, ok := rate(n.EdgeMetadata.EgressPacketCount); ok { - rows = append(rows, Row{Key: "Egress packet rate", ValueMajor: fmt.Sprintf("%.0f", rate), ValueMinor: "packets/sec"}) - } - if rate, ok := rate(n.EdgeMetadata.IngressPacketCount); ok { - rows = append(rows, Row{Key: "Ingress packet rate", ValueMajor: fmt.Sprintf("%.0f", rate), ValueMinor: "packets/sec"}) - } - if rate, ok := rate(n.EdgeMetadata.EgressByteCount); ok { - s, unit := shortenByteRate(rate) - rows = append(rows, Row{Key: "Egress byte rate", ValueMajor: s, ValueMinor: unit}) - } - if rate, ok := rate(n.EdgeMetadata.IngressByteCount); ok { - s, unit := shortenByteRate(rate) - rows = append(rows, Row{Key: "Ingress byte rate", ValueMajor: s, ValueMinor: unit}) - } - if len(connections) > 0 { - sort.Sort(sortableRows(connections)) - rows = append(rows, Row{Key: "Client", ValueMajor: "Server", Expandable: true}) - rows = append(rows, connections...) - } - if len(rows) > 0 { - return Table{ - Title: "Connections", - Numeric: false, - Rank: connectionsRank, - Rows: rows, - }, true - } - return Table{}, false -} - -func controlsFor(topology report.Topology, nodeID string) []ControlInstance { - result := []ControlInstance{} - node, ok := topology.Nodes[nodeID] - if !ok { - return result - } - - for _, id := range node.Controls.Controls { - if control, ok := topology.Controls[id]; ok { - result = append(result, ControlInstance{ - ProbeID: node.Metadata[report.ProbeID], - NodeID: nodeID, - Control: control, - }) - } - } - return result -} - -func controls(r report.Report, n RenderableNode) []ControlInstance { - if _, ok := r.Process.Nodes[n.ControlNode]; ok { - return controlsFor(r.Process, n.ControlNode) - } else if _, ok := r.Container.Nodes[n.ControlNode]; ok { - return controlsFor(r.Container, n.ControlNode) - } else if _, ok := r.ContainerImage.Nodes[n.ControlNode]; ok { - return controlsFor(r.ContainerImage, n.ControlNode) - } else if _, ok := r.Host.Nodes[n.ControlNode]; ok { - return controlsFor(r.Host, n.ControlNode) - } - return []ControlInstance{} -} - -// OriginTable produces a table (to be consumed directly by the UI) based on -// an origin ID, which is (optimistically) a node ID in one of our topologies. -func OriginTable(r report.Report, originID string, addHostTags bool, addContainerTags bool) (Table, bool) { - result, show := Table{}, false - if nmd, ok := r.Process.Nodes[originID]; ok { - result, show = processOriginTable(nmd, addHostTags, addContainerTags) - } - if nmd, ok := r.Container.Nodes[originID]; ok { - result, show = containerOriginTable(nmd, addHostTags) - } - if nmd, ok := r.ContainerImage.Nodes[originID]; ok { - result, show = containerImageOriginTable(nmd) - } - if nmd, ok := r.Host.Nodes[originID]; ok { - result, show = hostOriginTable(nmd) - } - return result, show -} - -func connectionDetailsRows(topology report.Topology, originID string) []Row { - rows := []Row{} - labeler := func(nodeID string, sets report.Sets) (string, bool) { - if _, addr, port, ok := report.ParseEndpointNodeID(nodeID); ok { - if names, ok := sets["name"]; ok { - return fmt.Sprintf("%s:%s", names[0], port), true - } - return fmt.Sprintf("%s:%s", addr, port), true - } - if _, addr, ok := report.ParseAddressNodeID(nodeID); ok { - return addr, true - } - return "", false - } - local, ok := labeler(originID, topology.Nodes[originID].Sets) - if !ok { - return rows - } - // Firstly, collection outgoing connections from this node. - for _, serverNodeID := range topology.Nodes[originID].Adjacency { - remote, ok := labeler(serverNodeID, topology.Nodes[serverNodeID].Sets) - if !ok { - continue - } - rows = append(rows, Row{ - Key: local, - ValueMajor: remote, - Expandable: true, - }) - } - // Next, scan the topology for incoming connections to this node. - for clientNodeID, clientNode := range topology.Nodes { - if clientNodeID == originID { - continue - } - serverNodeIDs := clientNode.Adjacency - if !serverNodeIDs.Contains(originID) { - continue - } - remote, ok := labeler(clientNodeID, clientNode.Sets) - if !ok { - continue - } - rows = append(rows, Row{ - Key: remote, - ValueMajor: local, - ValueMinor: "", - Expandable: true, - }) - } - return rows -} - -func processOriginTable(nmd report.Node, addHostTag bool, addContainerTag bool) (Table, bool) { - rows := []Row{} - for _, tuple := range []struct{ key, human string }{ - {process.PPID, "Parent PID"}, - {process.Cmdline, "Command"}, - {process.Threads, "# Threads"}, - } { - if val, ok := nmd.Metadata[tuple.key]; ok { - rows = append(rows, Row{Key: tuple.human, ValueMajor: val, ValueMinor: ""}) - } - } - - if containerID, ok := nmd.Metadata[docker.ContainerID]; ok && addContainerTag { - rows = append([]Row{{Key: "Container ID", ValueMajor: containerID}}, rows...) - } - - if addHostTag { - rows = append([]Row{{Key: "Host", ValueMajor: report.ExtractHostID(nmd)}}, rows...) - } - - for _, tuple := range []struct { - key, human string - fmt formatter - }{ - {process.CPUUsage, "CPU Usage", formatPercent}, - {process.MemoryUsage, "Memory Usage", formatMemory}, - } { - if val, ok := nmd.Metrics[tuple.key]; ok { - rows = append(rows, sparklineRow(tuple.human, val, tuple.fmt)) - } - } - - var ( - title = "Process" - name, commFound = nmd.Metadata[process.Name] - pid, pidFound = nmd.Metadata[process.PID] - ) - if commFound { - title += ` "` + name + `"` - } - if pidFound { - title += " (" + pid + ")" - } - return Table{ - Title: title, - Numeric: false, - Rows: rows, - Rank: processRank, - }, len(rows) > 0 || commFound || pidFound -} - -type formatter func(report.Metric) (report.Metric, string) - -func sparklineRow(human string, metric report.Metric, format formatter) Row { - if format == nil { - format = formatDefault - } - metric, lastStr := format(metric) - return Row{Key: human, ValueMajor: lastStr, Metric: &metric, ValueType: "sparkline"} -} - -func formatDefault(m report.Metric) (report.Metric, string) { - if s := m.LastSample(); s != nil { - return m, fmt.Sprintf("%0.2f", s.Value) - } - return m, "" -} - -func memoryScale(n float64) (string, float64) { - brackets := []struct { - human string - shift uint - }{ - {"bytes", 0}, - {"KB", 10}, - {"MB", 20}, - {"GB", 30}, - {"TB", 40}, - {"PB", 50}, - } - for _, bracket := range brackets { - unit := (1 << bracket.shift) - if n < float64(unit<<10) { - return bracket.human, float64(unit) - } - } - return "PB", float64(1 << 50) -} - -func formatMemory(m report.Metric) (report.Metric, string) { - s := m.LastSample() - if s == nil { - return m, "" - } - human, divisor := memoryScale(s.Value) - return m.Div(divisor), fmt.Sprintf("%0.2f %s", s.Value/divisor, human) -} - -func formatPercent(m report.Metric) (report.Metric, string) { - if s := m.LastSample(); s != nil { - return m, fmt.Sprintf("%0.2f%%", s.Value) - } - return m, "" -} - -func containerOriginTable(nmd report.Node, addHostTag bool) (Table, bool) { - rows := []Row{} - for _, tuple := range []struct{ key, human string }{ - {docker.ContainerState, "State"}, - } { - if val, ok := nmd.Latest.Lookup(tuple.key); ok && val != "" { - rows = append(rows, Row{Key: tuple.human, ValueMajor: val, ValueMinor: ""}) - } - } - - for _, tuple := range []struct{ key, human string }{ - {docker.ContainerID, "ID"}, - {docker.ImageID, "Image ID"}, - {docker.ContainerPorts, "Ports"}, - {docker.ContainerCreated, "Created"}, - {docker.ContainerCommand, "Command"}, - {overlay.WeaveMACAddress, "Weave MAC"}, - {overlay.WeaveDNSHostname, "Weave DNS Hostname"}, - } { - if val, ok := nmd.Metadata[tuple.key]; ok && val != "" { - rows = append(rows, Row{Key: tuple.human, ValueMajor: val, ValueMinor: ""}) - } - } - - for _, ip := range docker.ExtractContainerIPs(nmd) { - rows = append(rows, Row{Key: "IP Address", ValueMajor: ip, ValueMinor: ""}) - } - rows = append(rows, getDockerLabelRows(nmd)...) - - if addHostTag { - rows = append([]Row{{Key: "Host", ValueMajor: report.ExtractHostID(nmd)}}, rows...) - } - - if val, ok := nmd.Metrics[docker.MemoryUsage]; ok { - rows = append(rows, sparklineRow("Memory Usage", val, formatMemory)) - } - if val, ok := nmd.Metrics[docker.CPUTotalUsage]; ok { - rows = append(rows, sparklineRow("CPU Usage", val, formatPercent)) - } - - var ( - title = "Container" - name, nameFound = GetRenderableContainerName(nmd) - ) - if nameFound { - title += ` "` + name + `"` - } - - return Table{ - Title: title, - Numeric: false, - Rows: rows, - Rank: containerRank, - }, len(rows) > 0 || nameFound -} - -func containerImageOriginTable(nmd report.Node) (Table, bool) { - rows := []Row{} - for _, tuple := range []struct{ key, human string }{ - {docker.ImageID, "Image ID"}, - } { - if val, ok := nmd.Metadata[tuple.key]; ok { - rows = append(rows, Row{Key: tuple.human, ValueMajor: val, ValueMinor: ""}) - } - } - rows = append(rows, getDockerLabelRows(nmd)...) - title := "Container Image" - var ( - nameFound bool - name string - ) - if name, nameFound = nmd.Metadata[docker.ImageName]; nameFound { - title += ` "` + name + `"` - } - return Table{ - Title: title, - Numeric: false, - Rows: rows, - Rank: containerImageRank, - }, len(rows) > 0 || nameFound -} - -func getDockerLabelRows(nmd report.Node) []Row { - rows := []Row{} - // Add labels in alphabetical order - labels := docker.ExtractLabels(nmd) - labelKeys := make([]string, 0, len(labels)) - for k := range labels { - labelKeys = append(labelKeys, k) - } - sort.Strings(labelKeys) - for _, labelKey := range labelKeys { - rows = append(rows, Row{Key: fmt.Sprintf("Label %q", labelKey), ValueMajor: labels[labelKey]}) - } - return rows -} - -func hostOriginTable(nmd report.Node) (Table, bool) { - // Ensure that all metrics have the same max - maxLoad := 0.0 - for _, key := range []string{host.Load1, host.Load5, host.Load15} { - if metric, ok := nmd.Metrics[key]; ok { - if metric.Len() == 0 { - continue - } - if metric.Max > maxLoad { - maxLoad = metric.Max - } - } - } - - rows := []Row{} - for _, tuple := range []struct{ key, human string }{ - {host.Load1, "Load (1m)"}, - {host.Load5, "Load (5m)"}, - {host.Load15, "Load (15m)"}, - } { - if val, ok := nmd.Metrics[tuple.key]; ok { - val.Max = maxLoad - rows = append(rows, sparklineRow(tuple.human, val, nil)) - } - } - for _, tuple := range []struct { - key, human string - fmt formatter - }{ - {host.CPUUsage, "CPU Usage", formatPercent}, - {host.MemUsage, "Memory Usage", formatMemory}, - } { - if val, ok := nmd.Metrics[tuple.key]; ok { - rows = append(rows, sparklineRow(tuple.human, val, tuple.fmt)) - } - } - for _, tuple := range []struct{ key, human string }{ - {host.OS, "Operating system"}, - {host.KernelVersion, "Kernel version"}, - {host.Uptime, "Uptime"}, - } { - if val, ok := nmd.Metadata[tuple.key]; ok { - rows = append(rows, Row{Key: tuple.human, ValueMajor: val, ValueMinor: ""}) - } - } - - title := "Host" - var ( - name string - foundName bool - ) - if name, foundName = nmd.Metadata[host.HostName]; foundName { - title += ` "` + name + `"` - } - return Table{ - Title: title, - Numeric: false, - Rows: rows, - Rank: hostRank, - }, len(rows) > 0 || foundName -} diff --git a/render/detailed_node_test.go b/render/detailed_node_test.go deleted file mode 100644 index fa31f411e..000000000 --- a/render/detailed_node_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package render_test - -import ( - "fmt" - "reflect" - "testing" - - "github.com/weaveworks/scope/render" - "github.com/weaveworks/scope/test" - "github.com/weaveworks/scope/test/fixture" -) - -func TestOriginTable(t *testing.T) { - if _, ok := render.OriginTable(fixture.Report, "not-found", false, false); ok { - t.Errorf("unknown origin ID gave unexpected success") - } - for originID, want := range map[string]render.Table{ - fixture.ServerProcessNodeID: { - Title: fmt.Sprintf(`Process "apache" (%s)`, fixture.ServerPID), - Numeric: false, - Rank: 2, - Rows: []render.Row{}, - }, - fixture.ServerHostNodeID: { - Title: fmt.Sprintf("Host %q", fixture.ServerHostName), - Numeric: false, - Rank: 1, - Rows: []render.Row{ - {Key: "Load (1m)", ValueMajor: "0.01", Metric: &fixture.LoadMetric, ValueType: "sparkline"}, - {Key: "Load (5m)", ValueMajor: "0.01", Metric: &fixture.LoadMetric, ValueType: "sparkline"}, - {Key: "Load (15m)", ValueMajor: "0.01", Metric: &fixture.LoadMetric, ValueType: "sparkline"}, - {Key: "Operating system", ValueMajor: "Linux"}, - }, - }, - } { - have, ok := render.OriginTable(fixture.Report, originID, false, false) - if !ok { - t.Errorf("%q: not OK", originID) - continue - } - if !reflect.DeepEqual(want, have) { - t.Errorf("%q: %s", originID, test.Diff(want, have)) - } - } - - // Test host/container tags - for originID, want := range map[string]render.Table{ - fixture.ServerProcessNodeID: { - Title: fmt.Sprintf(`Process "apache" (%s)`, fixture.ServerPID), - Numeric: false, - Rank: 2, - Rows: []render.Row{ - {Key: "Host", ValueMajor: fixture.ServerHostID}, - {Key: "Container ID", ValueMajor: fixture.ServerContainerID}, - }, - }, - fixture.ServerContainerNodeID: { - Title: `Container "server"`, - Numeric: false, - Rank: 3, - Rows: []render.Row{ - {Key: "Host", ValueMajor: fixture.ServerHostID}, - {Key: "State", ValueMajor: "running"}, - {Key: "ID", ValueMajor: fixture.ServerContainerID}, - {Key: "Image ID", ValueMajor: fixture.ServerContainerImageID}, - {Key: fmt.Sprintf(`Label %q`, render.AmazonECSContainerNameLabel), ValueMajor: `server`}, - {Key: `Label "foo1"`, ValueMajor: `bar1`}, - {Key: `Label "foo2"`, ValueMajor: `bar2`}, - {Key: `Label "io.kubernetes.pod.name"`, ValueMajor: "ping/pong-b"}, - }, - }, - } { - have, ok := render.OriginTable(fixture.Report, originID, true, true) - if !ok { - t.Errorf("%q: not OK", originID) - continue - } - if !reflect.DeepEqual(want, have) { - t.Errorf("%q: %s", originID, test.Diff(want, have)) - } - } -} - -func TestMakeDetailedHostNode(t *testing.T) { - renderableNode := render.HostRenderer.Render(fixture.Report)[render.MakeHostID(fixture.ClientHostID)] - have := render.MakeDetailedNode(fixture.Report, renderableNode) - want := render.DetailedNode{ - ID: render.MakeHostID(fixture.ClientHostID), - LabelMajor: "client", - LabelMinor: "hostname.com", - Rank: "hostname.com", - Pseudo: false, - Controls: []render.ControlInstance{}, - Tables: []render.Table{ - { - Title: fmt.Sprintf("Host %q", fixture.ClientHostName), - Numeric: false, - Rank: 1, - Rows: []render.Row{ - { - Key: "Load (1m)", - ValueMajor: "0.01", - Metric: &fixture.LoadMetric, - ValueType: "sparkline", - }, - { - Key: "Load (5m)", - ValueMajor: "0.01", - Metric: &fixture.LoadMetric, - ValueType: "sparkline", - }, - { - Key: "Load (15m)", - ValueMajor: "0.01", - Metric: &fixture.LoadMetric, - ValueType: "sparkline", - }, - { - Key: "Operating system", - ValueMajor: "Linux", - }, - }, - }, - { - Title: "Connections", - Numeric: false, - Rank: 0, - Rows: []render.Row{ - { - Key: "TCP connections", - ValueMajor: "3", - }, - { - Key: "Client", - ValueMajor: "Server", - Expandable: true, - }, - { - Key: "10.10.10.20", - ValueMajor: "192.168.1.1", - Expandable: true, - }, - }, - }, - }, - } - if !reflect.DeepEqual(want, have) { - t.Errorf("%s", test.Diff(want, have)) - } -} - -func TestMakeDetailedContainerNode(t *testing.T) { - renderableNode := render.ContainerRenderer.Render(fixture.Report)[fixture.ServerContainerID] - have := render.MakeDetailedNode(fixture.Report, renderableNode) - want := render.DetailedNode{ - ID: fixture.ServerContainerID, - LabelMajor: "server", - LabelMinor: fixture.ServerHostName, - Rank: "imageid456", - Pseudo: false, - Controls: []render.ControlInstance{}, - Tables: []render.Table{ - { - Title: `Container Image "image/server"`, - Numeric: false, - Rank: 4, - Rows: []render.Row{ - {Key: "Image ID", ValueMajor: fixture.ServerContainerImageID}, - {Key: `Label "foo1"`, ValueMajor: `bar1`}, - {Key: `Label "foo2"`, ValueMajor: `bar2`}, - }, - }, - { - Title: `Container "server"`, - Numeric: false, - Rank: 3, - Rows: []render.Row{ - {Key: "State", ValueMajor: "running"}, - {Key: "ID", ValueMajor: fixture.ServerContainerID}, - {Key: "Image ID", ValueMajor: fixture.ServerContainerImageID}, - {Key: fmt.Sprintf(`Label %q`, render.AmazonECSContainerNameLabel), ValueMajor: `server`}, - {Key: `Label "foo1"`, ValueMajor: `bar1`}, - {Key: `Label "foo2"`, ValueMajor: `bar2`}, - {Key: `Label "io.kubernetes.pod.name"`, ValueMajor: "ping/pong-b"}, - }, - }, - { - Title: fmt.Sprintf(`Process "apache" (%s)`, fixture.ServerPID), - Numeric: false, - Rank: 2, - Rows: []render.Row{}, - }, - { - Title: fmt.Sprintf("Host %q", fixture.ServerHostName), - Numeric: false, - Rank: 1, - Rows: []render.Row{ - {Key: "Load (1m)", ValueMajor: "0.01", Metric: &fixture.LoadMetric, ValueType: "sparkline"}, - {Key: "Load (5m)", ValueMajor: "0.01", Metric: &fixture.LoadMetric, ValueType: "sparkline"}, - {Key: "Load (15m)", ValueMajor: "0.01", Metric: &fixture.LoadMetric, ValueType: "sparkline"}, - {Key: "Operating system", ValueMajor: "Linux"}, - }, - }, - { - Title: "Connections", - Numeric: false, - Rank: 0, - Rows: []render.Row{ - {Key: "Ingress packet rate", ValueMajor: "105", ValueMinor: "packets/sec"}, - {Key: "Ingress byte rate", ValueMajor: "1.0", ValueMinor: "KBps"}, - {Key: "Client", ValueMajor: "Server", Expandable: true}, - { - Key: fmt.Sprintf("%s:%s", fixture.UnknownClient1IP, fixture.UnknownClient1Port), - ValueMajor: fmt.Sprintf("%s:%s", fixture.ServerIP, fixture.ServerPort), - Expandable: true, - }, - { - Key: fmt.Sprintf("%s:%s", fixture.UnknownClient2IP, fixture.UnknownClient2Port), - ValueMajor: fmt.Sprintf("%s:%s", fixture.ServerIP, fixture.ServerPort), - Expandable: true, - }, - { - Key: fmt.Sprintf("%s:%s", fixture.UnknownClient3IP, fixture.UnknownClient3Port), - ValueMajor: fmt.Sprintf("%s:%s", fixture.ServerIP, fixture.ServerPort), - Expandable: true, - }, - { - Key: fmt.Sprintf("%s:%s", fixture.ClientIP, fixture.ClientPort54001), - ValueMajor: fmt.Sprintf("%s:%s", fixture.ServerIP, fixture.ServerPort), - Expandable: true, - }, - { - Key: fmt.Sprintf("%s:%s", fixture.ClientIP, fixture.ClientPort54002), - ValueMajor: fmt.Sprintf("%s:%s", fixture.ServerIP, fixture.ServerPort), - Expandable: true, - }, - { - Key: fmt.Sprintf("%s:%s", fixture.RandomClientIP, fixture.RandomClientPort), - ValueMajor: fmt.Sprintf("%s:%s", fixture.ServerIP, fixture.ServerPort), - Expandable: true, - }, - }, - }, - }, - } - if !reflect.DeepEqual(want, have) { - t.Errorf("%s", test.Diff(want, have)) - } -} diff --git a/render/expected/expected.go b/render/expected/expected.go index a863d657d..b4946d7a9 100644 --- a/render/expected/expected.go +++ b/render/expected/expected.go @@ -23,10 +23,6 @@ var ( EgressPacketCount: newu64(70), EgressByteCount: newu64(700), }, - Origins: report.MakeIDList( - fixture.UnknownClient1NodeID, - fixture.UnknownClient2NodeID, - ), } } unknownPseudoNode2 = func(adjacent string) render.RenderableNode { @@ -39,9 +35,6 @@ var ( EgressPacketCount: newu64(50), EgressByteCount: newu64(500), }, - Origins: report.MakeIDList( - fixture.UnknownClient3NodeID, - ), } } theInternetNode = func(adjacent string) render.RenderableNode { @@ -54,10 +47,6 @@ var ( EgressPacketCount: newu64(60), EgressByteCount: newu64(600), }, - Origins: report.MakeIDList( - fixture.RandomClientNodeID, - fixture.GoogleEndpointNodeID, - ), } } ClientProcess1ID = render.MakeProcessID(fixture.ClientHostID, fixture.Client1PID) @@ -72,12 +61,7 @@ var ( LabelMinor: fmt.Sprintf("%s (%s)", fixture.ClientHostID, fixture.Client1PID), Rank: fixture.Client1Name, Pseudo: false, - Origins: report.MakeIDList( - fixture.Client54001NodeID, - fixture.ClientProcess1NodeID, - fixture.ClientHostNodeID, - ), - Node: report.MakeNode().WithAdjacent(ServerProcessID), + Node: report.MakeNode().WithAdjacent(ServerProcessID), EdgeMetadata: report.EdgeMetadata{ EgressPacketCount: newu64(10), EgressByteCount: newu64(100), @@ -89,12 +73,7 @@ var ( LabelMinor: fmt.Sprintf("%s (%s)", fixture.ClientHostID, fixture.Client2PID), Rank: fixture.Client2Name, Pseudo: false, - Origins: report.MakeIDList( - fixture.Client54002NodeID, - fixture.ClientProcess2NodeID, - fixture.ClientHostNodeID, - ), - Node: report.MakeNode().WithAdjacent(ServerProcessID), + Node: report.MakeNode().WithAdjacent(ServerProcessID), EdgeMetadata: report.EdgeMetadata{ EgressPacketCount: newu64(20), EgressByteCount: newu64(200), @@ -106,28 +85,18 @@ var ( LabelMinor: fmt.Sprintf("%s (%s)", fixture.ServerHostID, fixture.ServerPID), Rank: fixture.ServerName, Pseudo: false, - Origins: report.MakeIDList( - fixture.Server80NodeID, - fixture.ServerProcessNodeID, - fixture.ServerHostNodeID, - ), - Node: report.MakeNode(), + Node: report.MakeNode(), EdgeMetadata: report.EdgeMetadata{ IngressPacketCount: newu64(210), IngressByteCount: newu64(2100), }, }, nonContainerProcessID: { - ID: nonContainerProcessID, - LabelMajor: fixture.NonContainerName, - LabelMinor: fmt.Sprintf("%s (%s)", fixture.ServerHostID, fixture.NonContainerPID), - Rank: fixture.NonContainerName, - Pseudo: false, - Origins: report.MakeIDList( - fixture.NonContainerProcessNodeID, - fixture.ServerHostNodeID, - fixture.NonContainerNodeID, - ), + ID: nonContainerProcessID, + LabelMajor: fixture.NonContainerName, + LabelMinor: fmt.Sprintf("%s (%s)", fixture.ServerHostID, fixture.NonContainerPID), + Rank: fixture.NonContainerName, + Pseudo: false, Node: report.MakeNode().WithAdjacent(render.TheInternetID), EdgeMetadata: report.EdgeMetadata{}, }, @@ -136,6 +105,10 @@ var ( render.TheInternetID: theInternetNode(ServerProcessID), }).Prune() + ServerProcessRenderedID = render.MakeProcessID(fixture.ServerHostID, fixture.ServerPID) + ClientProcess1RenderedID = render.MakeProcessID(fixture.ClientHostID, fixture.Client1PID) + ClientProcess2RenderedID = render.MakeProcessID(fixture.ClientHostID, fixture.Client2PID) + RenderedProcessNames = (render.RenderableNodes{ fixture.Client1Name: { ID: fixture.Client1Name, @@ -143,12 +116,9 @@ var ( LabelMinor: "2 processes", Rank: fixture.Client1Name, Pseudo: false, - Origins: report.MakeIDList( - fixture.Client54001NodeID, - fixture.Client54002NodeID, - fixture.ClientProcess1NodeID, - fixture.ClientProcess2NodeID, - fixture.ClientHostNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.ClientProcess1NodeID], + fixture.Report.Process.Nodes[fixture.ClientProcess2NodeID], ), Node: report.MakeNode().WithAdjacent(fixture.ServerName), EdgeMetadata: report.EdgeMetadata{ @@ -162,10 +132,8 @@ var ( LabelMinor: "1 process", Rank: fixture.ServerName, Pseudo: false, - Origins: report.MakeIDList( - fixture.Server80NodeID, - fixture.ServerProcessNodeID, - fixture.ServerHostNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.ServerProcessNodeID], ), Node: report.MakeNode(), EdgeMetadata: report.EdgeMetadata{ @@ -179,10 +147,8 @@ var ( LabelMinor: "1 process", Rank: fixture.NonContainerName, Pseudo: false, - Origins: report.MakeIDList( - fixture.NonContainerProcessNodeID, - fixture.ServerHostNodeID, - fixture.NonContainerNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.NonContainerProcessNodeID], ), Node: report.MakeNode().WithAdjacent(render.TheInternetID), EdgeMetadata: report.EdgeMetadata{}, @@ -192,41 +158,35 @@ var ( render.TheInternetID: theInternetNode(fixture.ServerName), }).Prune() + ServerContainerRenderedID = render.MakeContainerID(fixture.ServerContainerID) + ClientContainerRenderedID = render.MakeContainerID(fixture.ClientContainerID) + RenderedContainers = (render.RenderableNodes{ - fixture.ClientContainerID: { - ID: fixture.ClientContainerID, + ClientContainerRenderedID: { + ID: ClientContainerRenderedID, LabelMajor: "client", LabelMinor: fixture.ClientHostName, Rank: fixture.ClientContainerImageName, Pseudo: false, - Origins: report.MakeIDList( - fixture.ClientContainerImageNodeID, - fixture.ClientContainerNodeID, - fixture.Client54001NodeID, - fixture.Client54002NodeID, - fixture.ClientProcess1NodeID, - fixture.ClientProcess2NodeID, - fixture.ClientHostNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.ClientProcess1NodeID], + fixture.Report.Process.Nodes[fixture.ClientProcess2NodeID], ), - Node: report.MakeNode().WithAdjacent(fixture.ServerContainerID), + Node: report.MakeNode().WithAdjacent(ServerContainerRenderedID), EdgeMetadata: report.EdgeMetadata{ EgressPacketCount: newu64(30), EgressByteCount: newu64(300), }, ControlNode: fixture.ClientContainerNodeID, }, - fixture.ServerContainerID: { - ID: fixture.ServerContainerID, + ServerContainerRenderedID: { + ID: ServerContainerRenderedID, LabelMajor: "server", LabelMinor: fixture.ServerHostName, Rank: fixture.ServerContainerImageName, Pseudo: false, - Origins: report.MakeIDList( - fixture.ServerContainerImageNodeID, - fixture.ServerContainerNodeID, - fixture.Server80NodeID, - fixture.ServerProcessNodeID, - fixture.ServerHostNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.ServerProcessNodeID], ), Node: report.MakeNode(), EdgeMetadata: report.EdgeMetadata{ @@ -241,51 +201,46 @@ var ( LabelMinor: fixture.ServerHostName, Rank: "", Pseudo: true, - Origins: report.MakeIDList( - fixture.NonContainerProcessNodeID, - fixture.ServerHostNodeID, - fixture.NonContainerNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.NonContainerProcessNodeID], ), Node: report.MakeNode().WithAdjacent(render.TheInternetID), EdgeMetadata: report.EdgeMetadata{}, }, - render.TheInternetID: theInternetNode(fixture.ServerContainerID), + render.TheInternetID: theInternetNode(ServerContainerRenderedID), }).Prune() + ClientContainerImageRenderedName = render.MakeContainerImageID(fixture.ClientContainerImageName) + ServerContainerImageRenderedName = render.MakeContainerImageID(fixture.ServerContainerImageName) + RenderedContainerImages = (render.RenderableNodes{ - fixture.ClientContainerImageName: { - ID: fixture.ClientContainerImageName, + ClientContainerImageRenderedName: { + ID: ClientContainerImageRenderedName, LabelMajor: fixture.ClientContainerImageName, LabelMinor: "1 container", Rank: fixture.ClientContainerImageName, Pseudo: false, - Origins: report.MakeIDList( - fixture.ClientContainerImageNodeID, - fixture.ClientContainerNodeID, - fixture.Client54001NodeID, - fixture.Client54002NodeID, - fixture.ClientProcess1NodeID, - fixture.ClientProcess2NodeID, - fixture.ClientHostNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.ClientProcess1NodeID], + fixture.Report.Process.Nodes[fixture.ClientProcess2NodeID], + fixture.Report.Container.Nodes[fixture.ClientContainerNodeID], ), - Node: report.MakeNode().WithAdjacent(fixture.ServerContainerImageName), + Node: report.MakeNode().WithAdjacent(ServerContainerImageRenderedName), EdgeMetadata: report.EdgeMetadata{ EgressPacketCount: newu64(30), EgressByteCount: newu64(300), }, }, - fixture.ServerContainerImageName: { - ID: fixture.ServerContainerImageName, + ServerContainerImageRenderedName: { + ID: ServerContainerImageRenderedName, LabelMajor: fixture.ServerContainerImageName, LabelMinor: "1 container", Rank: fixture.ServerContainerImageName, Pseudo: false, - Origins: report.MakeIDList( - fixture.ServerContainerImageNodeID, - fixture.ServerContainerNodeID, - fixture.Server80NodeID, - fixture.ServerProcessNodeID, - fixture.ServerHostNodeID), + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.ServerProcessNodeID], + fixture.Report.Container.Nodes[fixture.ServerContainerNodeID], + ), Node: report.MakeNode(), EdgeMetadata: report.EdgeMetadata{ IngressPacketCount: newu64(210), @@ -298,15 +253,13 @@ var ( LabelMinor: fixture.ServerHostName, Rank: "", Pseudo: true, - Origins: report.MakeIDList( - fixture.NonContainerNodeID, - fixture.NonContainerProcessNodeID, - fixture.ServerHostNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.NonContainerProcessNodeID], ), Node: report.MakeNode().WithAdjacent(render.TheInternetID), EdgeMetadata: report.EdgeMetadata{}, }, - render.TheInternetID: theInternetNode(fixture.ServerContainerImageName), + render.TheInternetID: theInternetNode(ServerContainerImageRenderedName), }).Prune() ServerHostRenderedID = render.MakeHostID(fixture.ServerHostID) @@ -321,13 +274,15 @@ var ( LabelMinor: "hostname.com", // after first . Rank: "hostname.com", Pseudo: false, - Origins: report.MakeIDList( - fixture.ServerHostNodeID, - fixture.ServerAddressNodeID, + Children: report.MakeNodeSet( + fixture.Report.Container.Nodes[fixture.ServerContainerNodeID], + fixture.Report.Container.Nodes[fixture.ServerProcessNodeID], ), Node: report.MakeNode(), EdgeMetadata: report.EdgeMetadata{ - MaxConnCountTCP: newu64(3), + IngressPacketCount: newu64(210), + IngressByteCount: newu64(2100), + MaxConnCountTCP: newu64(3), }, }, ClientHostRenderedID: { @@ -336,13 +291,16 @@ var ( LabelMinor: "hostname.com", // after first . Rank: "hostname.com", Pseudo: false, - Origins: report.MakeIDList( - fixture.ClientHostNodeID, - fixture.ClientAddressNodeID, + Children: report.MakeNodeSet( + fixture.Report.Container.Nodes[fixture.ClientContainerNodeID], + fixture.Report.Process.Nodes[fixture.ClientProcess1NodeID], + fixture.Report.Process.Nodes[fixture.ClientProcess2NodeID], ), Node: report.MakeNode().WithAdjacent(ServerHostRenderedID), EdgeMetadata: report.EdgeMetadata{ - MaxConnCountTCP: newu64(3), + EgressPacketCount: newu64(30), + EgressByteCount: newu64(300), + MaxConnCountTCP: newu64(3), }, }, pseudoHostID1: { @@ -351,7 +309,10 @@ var ( Pseudo: true, Node: report.MakeNode().WithAdjacent(ServerHostRenderedID), EdgeMetadata: report.EdgeMetadata{}, - Origins: report.MakeIDList(fixture.UnknownAddress1NodeID, fixture.UnknownAddress2NodeID), + Children: report.MakeNodeSet( + fixture.Report.Container.Nodes[fixture.ServerContainerNodeID], + fixture.Report.Process.Nodes[fixture.ServerProcessNodeID], + ), }, pseudoHostID2: { ID: pseudoHostID2, @@ -359,7 +320,6 @@ var ( Pseudo: true, Node: report.MakeNode().WithAdjacent(ServerHostRenderedID), EdgeMetadata: report.EdgeMetadata{}, - Origins: report.MakeIDList(fixture.UnknownAddress3NodeID), }, render.TheInternetID: { ID: render.TheInternetID, @@ -367,46 +327,43 @@ var ( Pseudo: true, Node: report.MakeNode().WithAdjacent(ServerHostRenderedID), EdgeMetadata: report.EdgeMetadata{}, - Origins: report.MakeIDList(fixture.RandomAddressNodeID), }, }).Prune() + ClientPodRenderedID = render.MakePodID("ping/pong-a") + ServerPodRenderedID = render.MakePodID("ping/pong-b") + RenderedPods = (render.RenderableNodes{ - "ping/pong-a": { - ID: "ping/pong-a", + ClientPodRenderedID: { + ID: ClientPodRenderedID, LabelMajor: "pong-a", LabelMinor: "1 container", Rank: "ping/pong-a", Pseudo: false, - Origins: report.MakeIDList( - fixture.Client54001NodeID, - fixture.Client54002NodeID, - fixture.ClientProcess1NodeID, - fixture.ClientProcess2NodeID, - fixture.ClientHostNodeID, - fixture.ClientContainerNodeID, - fixture.ClientContainerImageNodeID, - fixture.ClientPodNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.ClientProcess1NodeID], + fixture.Report.Process.Nodes[fixture.ClientProcess2NodeID], + fixture.Report.Container.Nodes[fixture.ClientContainerNodeID], + fixture.Report.ContainerImage.Nodes[fixture.ClientContainerImageNodeID], + fixture.Report.Pod.Nodes[fixture.ClientPodNodeID], ), - Node: report.MakeNode().WithAdjacent("ping/pong-b"), + Node: report.MakeNode().WithAdjacent(ServerPodRenderedID), EdgeMetadata: report.EdgeMetadata{ EgressPacketCount: newu64(30), EgressByteCount: newu64(300), }, }, - "ping/pong-b": { - ID: "ping/pong-b", + ServerPodRenderedID: { + ID: ServerPodRenderedID, LabelMajor: "pong-b", LabelMinor: "1 container", Rank: "ping/pong-b", Pseudo: false, - Origins: report.MakeIDList( - fixture.Server80NodeID, - fixture.ServerPodNodeID, - fixture.ServerProcessNodeID, - fixture.ServerContainerNodeID, - fixture.ServerHostNodeID, - fixture.ServerContainerImageNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.ServerProcessNodeID], + fixture.Report.Container.Nodes[fixture.ServerContainerNodeID], + fixture.Report.ContainerImage.Nodes[fixture.ServerContainerImageNodeID], + fixture.Report.Pod.Nodes[fixture.ServerPodNodeID], ), Node: report.MakeNode(), EdgeMetadata: report.EdgeMetadata{ @@ -420,10 +377,8 @@ var ( LabelMinor: fixture.ServerHostName, Rank: "", Pseudo: true, - Origins: report.MakeIDList( - fixture.ServerHostNodeID, - fixture.NonContainerProcessNodeID, - fixture.NonContainerNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.NonContainerProcessNodeID], ), Node: report.MakeNode().WithAdjacent(render.TheInternetID), EdgeMetadata: report.EdgeMetadata{}, @@ -432,43 +387,35 @@ var ( ID: render.TheInternetID, LabelMajor: render.TheInternetMajor, Pseudo: true, - Node: report.MakeNode().WithAdjacent("ping/pong-b"), + Node: report.MakeNode().WithAdjacent(ServerPodRenderedID), EdgeMetadata: report.EdgeMetadata{ EgressPacketCount: newu64(60), EgressByteCount: newu64(600), }, - Origins: report.MakeIDList( - fixture.RandomClientNodeID, - fixture.GoogleEndpointNodeID, - ), }, }).Prune() + ServiceRenderedID = render.MakeServiceID("ping/pongservice") + RenderedPodServices = (render.RenderableNodes{ - "ping/pongservice": { - ID: fixture.ServiceID, + ServiceRenderedID: { + ID: ServiceRenderedID, LabelMajor: "pongservice", LabelMinor: "2 pods", Rank: fixture.ServiceID, Pseudo: false, - Origins: report.MakeIDList( - fixture.Client54001NodeID, - fixture.Client54002NodeID, - fixture.ClientProcess1NodeID, - fixture.ClientProcess2NodeID, - fixture.ClientHostNodeID, - fixture.ClientContainerNodeID, - fixture.ClientContainerImageNodeID, - fixture.ClientPodNodeID, - fixture.Server80NodeID, - fixture.ServerPodNodeID, - fixture.ServiceNodeID, - fixture.ServerProcessNodeID, - fixture.ServerContainerNodeID, - fixture.ServerHostNodeID, - fixture.ServerContainerImageNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.ClientProcess1NodeID], + fixture.Report.Process.Nodes[fixture.ClientProcess2NodeID], + fixture.Report.Container.Nodes[fixture.ClientContainerNodeID], + fixture.Report.ContainerImage.Nodes[fixture.ClientContainerImageNodeID], + fixture.Report.Pod.Nodes[fixture.ClientPodNodeID], + fixture.Report.Process.Nodes[fixture.ServerProcessNodeID], + fixture.Report.Container.Nodes[fixture.ServerContainerNodeID], + fixture.Report.ContainerImage.Nodes[fixture.ServerContainerImageNodeID], + fixture.Report.Pod.Nodes[fixture.ServerPodNodeID], ), - Node: report.MakeNode().WithAdjacent(fixture.ServiceID), // ?? Shouldn't be adjacent to itself? + Node: report.MakeNode().WithAdjacent(ServiceRenderedID), EdgeMetadata: report.EdgeMetadata{ EgressPacketCount: newu64(30), EgressByteCount: newu64(300), @@ -482,10 +429,8 @@ var ( LabelMinor: fixture.ServerHostName, Rank: "", Pseudo: true, - Origins: report.MakeIDList( - fixture.ServerHostNodeID, - fixture.NonContainerProcessNodeID, - fixture.NonContainerNodeID, + Children: report.MakeNodeSet( + fixture.Report.Process.Nodes[fixture.NonContainerProcessNodeID], ), Node: report.MakeNode().WithAdjacent(render.TheInternetID), EdgeMetadata: report.EdgeMetadata{}, @@ -494,15 +439,11 @@ var ( ID: render.TheInternetID, LabelMajor: render.TheInternetMajor, Pseudo: true, - Node: report.MakeNode().WithAdjacent(fixture.ServiceID), + Node: report.MakeNode().WithAdjacent(ServiceRenderedID), EdgeMetadata: report.EdgeMetadata{ EgressPacketCount: newu64(60), EgressByteCount: newu64(600), }, - Origins: report.MakeIDList( - fixture.RandomClientNodeID, - fixture.GoogleEndpointNodeID, - ), }, }).Prune() ) diff --git a/render/filters.go b/render/filters.go index 58f04045b..4db23be33 100644 --- a/render/filters.go +++ b/render/filters.go @@ -119,6 +119,17 @@ func (f Filter) Stats(rpt report.Report) Stats { // to indicate a node has an edge pointing to it or from it const IsConnected = "is_connected" +// FilterPseudo produces a renderer that removes pseudo nodes from the given +// renderer +func FilterPseudo(r Renderer) Renderer { + return Filter{ + Renderer: r, + FilterFunc: func(node RenderableNode) bool { + return !node.Pseudo + }, + } +} + // FilterUnconnected produces a renderer that filters unconnected nodes // from the given renderer func FilterUnconnected(r Renderer) Renderer { diff --git a/render/filters_test.go b/render/filters_test.go index 325e29d25..6722462cf 100644 --- a/render/filters_test.go +++ b/render/filters_test.go @@ -48,7 +48,7 @@ func TestFilterRender2(t *testing.T) { } } -func TestFilterUnconnectedPesudoNodes(t *testing.T) { +func TestFilterUnconnectedPseudoNodes(t *testing.T) { // Test pseudo nodes that are made unconnected by filtering // are also removed. { @@ -123,3 +123,21 @@ func TestFilterUnconnectedSelf(t *testing.T) { } } } + +func TestFilterPseudo(t *testing.T) { + // Test pseudonodes are removed + { + nodes := render.RenderableNodes{ + "foo": {ID: "foo", Node: report.MakeNode()}, + "bar": {ID: "bar", Pseudo: true, Node: report.MakeNode()}, + } + renderer := render.FilterPseudo(mockRenderer{RenderableNodes: nodes}) + want := render.RenderableNodes{ + "foo": {ID: "foo", Node: report.MakeNode()}, + } + have := renderer.Render(report.MakeReport()).Prune() + if !reflect.DeepEqual(want, have) { + t.Error(test.Diff(want, have)) + } + } +} diff --git a/render/id.go b/render/id.go index 5bbf4c06a..952c3d369 100644 --- a/render/id.go +++ b/render/id.go @@ -1,32 +1,56 @@ package render import ( - "fmt" "strings" ) +// makeID is the generic ID maker +func makeID(prefix string, parts ...string) string { + return strings.Join(append([]string{prefix}, parts...), ":") +} + // MakeEndpointID makes an endpoint node ID for rendered nodes. func MakeEndpointID(hostID, addr, port string) string { - return fmt.Sprintf("endpoint:%s:%s:%s", hostID, addr, port) + return makeID("endpoint", hostID, addr, port) } // MakeProcessID makes a process node ID for rendered nodes. func MakeProcessID(hostID, pid string) string { - return fmt.Sprintf("process:%s:%s", hostID, pid) + return makeID("process", hostID, pid) } // MakeAddressID makes an address node ID for rendered nodes. func MakeAddressID(hostID, addr string) string { - return fmt.Sprintf("address:%s:%s", hostID, addr) + return makeID("address", hostID, addr) +} + +// MakeContainerID makes a container node ID for rendered nodes. +func MakeContainerID(containerID string) string { + return makeID("container", containerID) +} + +// MakeContainerImageID makes a container image node ID for rendered nodes. +func MakeContainerImageID(imageID string) string { + return makeID("container_image", imageID) +} + +// MakePodID makes a pod node ID for rendered nodes. +func MakePodID(podID string) string { + return makeID("pod", podID) +} + +// MakeServiceID makes a service node ID for rendered nodes. +func MakeServiceID(serviceID string) string { + return makeID("service", serviceID) } // MakeHostID makes a host node ID for rendered nodes. func MakeHostID(hostID string) string { - return fmt.Sprintf("host:%s", hostID) + return makeID("host", hostID) } // MakePseudoNodeID produces a pseudo node ID from its composite parts, // for use in rendered nodes. func MakePseudoNodeID(parts ...string) string { - return strings.Join(append([]string{"pseudo"}, parts...), ":") + return makeID("pseudo", parts...) } diff --git a/render/mapping.go b/render/mapping.go index 74b3feb81..a1b675007 100644 --- a/render/mapping.go +++ b/render/mapping.go @@ -123,12 +123,13 @@ func MapProcessIdentity(m RenderableNode, _ report.Networks) RenderableNodes { // renderable node. As it is only ever run on container topology nodes, we // expect that certain keys are present. func MapContainerIdentity(m RenderableNode, _ report.Networks) RenderableNodes { - id, ok := m.Metadata[docker.ContainerID] + containerID, ok := m.Metadata[docker.ContainerID] if !ok { return RenderableNodes{} } var ( + id = MakeContainerID(containerID) major, _ = GetRenderableContainerName(m.Node) minor = report.ExtractHostID(m.Node) rank = m.Metadata[docker.ImageID] @@ -136,10 +137,6 @@ func MapContainerIdentity(m RenderableNode, _ report.Networks) RenderableNodes { node := NewRenderableNodeWith(id, major, minor, rank, m) node.ControlNode = m.ID - if imageID, ok := m.Metadata[docker.ImageID]; ok { - hostID, _, _ := report.ParseContainerNodeID(m.ID) - node.Origins = node.Origins.Add(report.MakeContainerNodeID(hostID, imageID)) - } return RenderableNodes{id: node} } @@ -171,14 +168,15 @@ func GetRenderableContainerName(nmd report.Node) (string, bool) { // image renderable node. As it is only ever run on container image topology // nodes, we expect that certain keys are present. func MapContainerImageIdentity(m RenderableNode, _ report.Networks) RenderableNodes { - id, ok := m.Metadata[docker.ImageID] + imageID, ok := m.Metadata[docker.ImageID] if !ok { return RenderableNodes{} } var ( + id = MakeContainerImageID(imageID) major = m.Metadata[docker.ImageName] - rank = m.Metadata[docker.ImageID] + rank = imageID ) return RenderableNodes{id: NewRenderableNodeWith(id, major, "", rank, m)} @@ -188,12 +186,13 @@ func MapContainerImageIdentity(m RenderableNode, _ report.Networks) RenderableNo // only ever run on pod topology nodes, we expect that certain keys // are present. func MapPodIdentity(m RenderableNode, _ report.Networks) RenderableNodes { - id, ok := m.Metadata[kubernetes.PodID] + podID, ok := m.Metadata[kubernetes.PodID] if !ok { return RenderableNodes{} } var ( + id = MakePodID(podID) major = m.Metadata[kubernetes.PodName] rank = m.Metadata[kubernetes.PodID] ) @@ -205,12 +204,13 @@ func MapPodIdentity(m RenderableNode, _ report.Networks) RenderableNodes { // only ever run on service topology nodes, we expect that certain keys // are present. func MapServiceIdentity(m RenderableNode, _ report.Networks) RenderableNodes { - id, ok := m.Metadata[kubernetes.ServiceID] + serviceID, ok := m.Metadata[kubernetes.ServiceID] if !ok { return RenderableNodes{} } var ( + id = MakeServiceID(serviceID) major = m.Metadata[kubernetes.ServiceName] rank = m.Metadata[kubernetes.ServiceID] ) @@ -308,6 +308,7 @@ func MapEndpoint2IP(m RenderableNode, local report.Networks) RenderableNodes { // So we need to emit two nodes, for two different cases. id := report.MakeScopedEndpointNodeID(scope, addr, "") idWithPort := report.MakeScopedEndpointNodeID(scope, addr, port) + m = m.WithParents(nil) return RenderableNodes{ id: NewRenderableNodeWith(id, "", "", "", m), idWithPort: NewRenderableNodeWith(idWithPort, "", "", "", m), @@ -340,7 +341,7 @@ func MapContainer2IP(m RenderableNode, _ report.Networks) RenderableNodes { if mapping := portMappingMatch.FindStringSubmatch(portMapping); mapping != nil { ip, port := mapping[1], mapping[2] id := report.MakeScopedEndpointNodeID("", ip, port) - node := NewRenderableNodeWith(id, "", "", "", m) + node := NewRenderableNodeWith(id, "", "", "", m.WithParents(nil)) node.Counters[containersKey] = 1 result[id] = node } @@ -367,12 +368,14 @@ func MapIP2Container(n RenderableNode, _ report.Networks) RenderableNodes { // If this node is not a container, exclude it. // This excludes all the nodes we've dragged in from endpoint // that we failed to join to a container. - id, ok := n.Node.Metadata[docker.ContainerID] + containerID, ok := n.Node.Metadata[docker.ContainerID] if !ok { return RenderableNodes{} } - return RenderableNodes{id: NewDerivedNode(id, n)} + id := MakeContainerID(containerID) + + return RenderableNodes{id: NewDerivedNode(id, n.WithParents(nil))} } // MapEndpoint2Process maps endpoint RenderableNodes to process @@ -397,7 +400,7 @@ func MapEndpoint2Process(n RenderableNode, _ report.Networks) RenderableNodes { } id := MakeProcessID(report.ExtractHostID(n.Node), pid) - return RenderableNodes{id: NewDerivedNode(id, n)} + return RenderableNodes{id: NewDerivedNode(id, n.WithParents(nil))} } // MapProcess2Container maps process RenderableNodes to container @@ -426,16 +429,23 @@ func MapProcess2Container(n RenderableNode, _ report.Networks) RenderableNodes { // into an per-host "Uncontained" node. If for whatever reason // this node doesn't have a host id in their nodemetadata, it'll // all get grouped into a single uncontained node. - id, ok := n.Node.Metadata[docker.ContainerID] - if !ok { - hostID := report.ExtractHostID(n.Node) + var ( + id string + node RenderableNode + hostID = report.ExtractHostID(n.Node) + ) + n = n.WithParents(nil) + if containerID, ok := n.Node.Metadata[docker.ContainerID]; ok { + id = MakeContainerID(containerID) + node = NewDerivedNode(id, n) + } else { id = MakePseudoNodeID(UncontainedID, hostID) - node := newDerivedPseudoNode(id, UncontainedMajor, n) + node = newDerivedPseudoNode(id, UncontainedMajor, n) node.LabelMinor = hostID - return RenderableNodes{id: node} } - return RenderableNodes{id: NewDerivedNode(id, n)} + node.Children = node.Children.Add(n.Node) + return RenderableNodes{id: node} } // MapProcess2Name maps process RenderableNodes to RenderableNodes @@ -458,6 +468,9 @@ func MapProcess2Name(n RenderableNode, _ report.Networks) RenderableNodes { node.LabelMajor = name node.Rank = name node.Node.Counters[processesKey] = 1 + node.Node.Topology = "process_name" + node.Node.ID = name + node.Children = node.Children.Add(n.Node) return RenderableNodes{name: node} } @@ -497,14 +510,21 @@ func MapContainer2ContainerImage(n RenderableNode, _ report.Networks) Renderable // Otherwise, if some some reason the container doesn't have a image_id // (maybe slightly out of sync reports), just drop it - id, ok := n.Node.Metadata[docker.ImageID] + imageID, ok := n.Node.Metadata[docker.ImageID] if !ok { return RenderableNodes{} } // Add container id key to the counters, which will later be counted to produce the minor label - result := NewDerivedNode(id, n) + id := MakeContainerImageID(imageID) + result := NewDerivedNode(id, n.WithParents(nil)) result.Node.Counters[containersKey] = 1 + + // Add the container as a child of the new image node + result.Children = result.Children.Add(n.Node) + + result.Node.Topology = "container_image" + result.Node.ID = report.MakeContainerImageNodeID(report.ExtractHostID(n.Node), imageID) return RenderableNodes{id: result} } @@ -532,15 +552,19 @@ func MapPod2Service(n RenderableNode, _ report.Networks) RenderableNodes { } result := RenderableNodes{} - for _, id := range strings.Fields(ids) { - n := NewDerivedNode(id, n) + for _, serviceID := range strings.Fields(ids) { + id := MakeServiceID(serviceID) + n := NewDerivedNode(id, n.WithParents(nil)) n.Node.Counters[podsKey] = 1 + n.Children = n.Children.Add(n.Node) result[id] = n } return result } -func imageNameWithoutVersion(name string) string { +// 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]) @@ -565,13 +589,38 @@ func MapContainerImage2Name(n RenderableNode, _ report.Networks) RenderableNodes return RenderableNodes{} } - name = imageNameWithoutVersion(name) + name = ImageNameWithoutVersion(name) + id := MakeContainerImageID(name) - node := NewDerivedNode(name, n) + node := NewDerivedNode(id, n) node.LabelMajor = name node.Rank = name node.Node = n.Node.Copy() // Propagate NMD for container counting. - return RenderableNodes{name: node} + return RenderableNodes{id: node} +} + +// MapX2Host maps any RenderableNodes to host +// RenderableNodes. +// +// If this function is given a node without a hostname +// (including other pseudo nodes), it will drop the node. +// +// Otherwise, this function will produce a node with the correct ID +// format for a container, but without any Major or Minor labels. +// 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 MapX2Host(n RenderableNode, _ report.Networks) RenderableNodes { + // Propogate all pseudo nodes + if n.Pseudo { + return RenderableNodes{n.ID: n} + } + if _, ok := n.Node.Metadata[report.HostNodeID]; !ok { + return RenderableNodes{} + } + id := MakeHostID(report.ExtractHostID(n.Node)) + result := NewDerivedNode(id, n.WithParents(nil)) + result.Children = result.Children.Add(n.Node) + return RenderableNodes{id: result} } // MapContainer2Pod maps container RenderableNodes to pod @@ -593,23 +642,27 @@ func MapContainer2Pod(n RenderableNode, _ report.Networks) RenderableNodes { // 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 - id, ok := n.Node.Metadata["docker_label_io.kubernetes.pod.name"] + podID, ok := n.Node.Metadata[kubernetes.PodID] if !ok { return RenderableNodes{} } + id := MakePodID(podID) // Add container- key to NMD, which will later be counted to produce the // minor label - result := NewRenderableNodeWith(id, "", "", id, n) + result := NewRenderableNodeWith(id, "", "", podID, n.WithParents(nil)) result.Node.Counters[containersKey] = 1 // Due to a bug in kubernetes, addon pods on the master node are not returned // from the API. This is a workaround until // https://github.com/kubernetes/kubernetes/issues/14738 is fixed. - if s := strings.SplitN(id, "/", 2); len(s) == 2 { + if s := strings.SplitN(podID, "/", 2); len(s) == 2 { result.LabelMajor = s[1] result.Node.Metadata[kubernetes.Namespace] = s[0] result.Node.Metadata[kubernetes.PodName] = s[1] } + + result.Children = result.Children.Add(n.Node) + return RenderableNodes{id: result} } @@ -633,6 +686,12 @@ func MapContainer2Hostname(n RenderableNode, _ report.Networks) RenderableNodes // Add container id key to the counters, which will later be counted to produce the minor label result.Node.Counters[containersKey] = 1 + + result.Node.Topology = "container_hostname" + result.Node.ID = id + + result.Children = result.Children.Add(n.Node) + return RenderableNodes{id: result} } @@ -669,18 +728,6 @@ func MapCountPods(n RenderableNode, _ report.Networks) RenderableNodes { return RenderableNodes{n.ID: n} } -// MapAddress2Host maps address RenderableNodes to host RenderableNodes. -// -// Otherthan pseudo nodes, we can assume all nodes have a HostID -func MapAddress2Host(n RenderableNode, _ report.Networks) RenderableNodes { - if n.Pseudo { - return RenderableNodes{n.ID: n} - } - - id := MakeHostID(report.ExtractHostID(n.Node)) - return RenderableNodes{id: NewDerivedNode(id, n)} -} - // trySplitAddr is basically ParseArbitraryNodeID, since its callsites // (pseudo funcs) just have opaque node IDs and don't know what topology they // come from. Without changing how pseudo funcs work, we can't make it much diff --git a/render/mapping_internal_test.go b/render/mapping_internal_test.go index 75d2bfc8d..c04e2778d 100644 --- a/render/mapping_internal_test.go +++ b/render/mapping_internal_test.go @@ -12,7 +12,7 @@ func TestDockerImageName(t *testing.T) { {"docker-registry.domain.name:5000/repo/image1:ver", "repo/image1"}, {"foo", "foo"}, } { - name := imageNameWithoutVersion(input.in) + name := ImageNameWithoutVersion(input.in) if name != input.name { t.Fatalf("%s: %s != %s", input.in, name, input.name) } diff --git a/render/renderable_node.go b/render/renderable_node.go index 8519f3687..368e0353b 100644 --- a/render/renderable_node.go +++ b/render/renderable_node.go @@ -8,13 +8,13 @@ import ( // an element of a topology. It should contain information that's relevant // to rendering a node when there are many nodes visible at once. type RenderableNode struct { - ID string `json:"id"` // - LabelMajor string `json:"label_major"` // e.g. "process", human-readable - LabelMinor string `json:"label_minor,omitempty"` // e.g. "hostname", human-readable, optional - Rank string `json:"rank"` // to help the layout engine - Pseudo bool `json:"pseudo,omitempty"` // sort-of a placeholder node, for rendering purposes - Origins report.IDList `json:"origins,omitempty"` // Core node IDs that contributed information - ControlNode string `json:"-"` // ID of node from which to show the controls in the UI + ID string `json:"id"` // + LabelMajor string `json:"label_major"` // e.g. "process", human-readable + LabelMinor string `json:"label_minor,omitempty"` // e.g. "hostname", human-readable, optional + Rank string `json:"rank"` // to help the layout engine + Pseudo bool `json:"pseudo,omitempty"` // sort-of a placeholder node, for rendering purposes + Children report.NodeSet `json:"children,omitempty"` // Nodes which have been grouped into this one + ControlNode string `json:"-"` // ID of node from which to show the controls in the UI report.EdgeMetadata `json:"metadata"` // Numeric sums report.Node @@ -28,23 +28,22 @@ func NewRenderableNode(id string) RenderableNode { LabelMinor: "", Rank: "", Pseudo: false, - Origins: report.MakeIDList(), EdgeMetadata: report.EdgeMetadata{}, Node: report.MakeNode(), } } // NewRenderableNodeWith makes a new RenderableNode with some fields filled in -func NewRenderableNodeWith(id, major, minor, rank string, rn RenderableNode) RenderableNode { +func NewRenderableNodeWith(id, major, minor, rank string, node RenderableNode) RenderableNode { return RenderableNode{ ID: id, LabelMajor: major, LabelMinor: minor, Rank: rank, Pseudo: false, - Origins: rn.Origins.Copy(), - EdgeMetadata: rn.EdgeMetadata.Copy(), - Node: rn.Node.Copy(), + Children: node.Children.Copy(), + EdgeMetadata: node.EdgeMetadata.Copy(), + Node: node.Node.Copy(), } } @@ -56,7 +55,7 @@ func NewDerivedNode(id string, node RenderableNode) RenderableNode { LabelMinor: "", Rank: "", Pseudo: node.Pseudo, - Origins: node.Origins.Copy(), + Children: node.Children.Copy(), EdgeMetadata: node.EdgeMetadata.Copy(), Node: node.Node.Copy(), ControlNode: "", // Do not propagate ControlNode when making a derived node! @@ -70,7 +69,7 @@ func newDerivedPseudoNode(id, major string, node RenderableNode) RenderableNode LabelMinor: "", Rank: "", Pseudo: true, - Origins: node.Origins.Copy(), + Children: node.Children.Copy(), EdgeMetadata: node.EdgeMetadata.Copy(), Node: node.Node.Copy(), } @@ -83,6 +82,13 @@ func (rn RenderableNode) WithNode(n report.Node) RenderableNode { return result } +// WithParents creates a new RenderableNode based on rn, where n has the given parents set +func (rn RenderableNode) WithParents(p report.Sets) RenderableNode { + result := rn.Copy() + result.Node.Parents = p + return result +} + // Merge merges rn with other and returns a new RenderableNode func (rn RenderableNode) Merge(other RenderableNode) RenderableNode { result := rn.Copy() @@ -107,7 +113,7 @@ func (rn RenderableNode) Merge(other RenderableNode) RenderableNode { panic(result.ID) } - result.Origins = rn.Origins.Merge(other.Origins) + result.Children = rn.Children.Merge(other.Children) result.EdgeMetadata = rn.EdgeMetadata.Merge(other.EdgeMetadata) result.Node = rn.Node.Merge(other.Node) @@ -122,7 +128,7 @@ func (rn RenderableNode) Copy() RenderableNode { LabelMinor: rn.LabelMinor, Rank: rn.Rank, Pseudo: rn.Pseudo, - Origins: rn.Origins.Copy(), + Children: rn.Children.Copy(), EdgeMetadata: rn.EdgeMetadata.Copy(), Node: rn.Node.Copy(), ControlNode: rn.ControlNode, @@ -135,6 +141,7 @@ func (rn RenderableNode) Copy() RenderableNode { func (rn RenderableNode) Prune() RenderableNode { cp := rn.Copy() cp.Node = report.MakeNode().WithAdjacent(cp.Node.Adjacency...) + cp.Children = nil return cp } diff --git a/render/renderable_node_test.go b/render/renderable_node_test.go index d955dec6b..9a9ad49a0 100644 --- a/render/renderable_node_test.go +++ b/render/renderable_node_test.go @@ -37,7 +37,7 @@ func TestMergeRenderableNode(t *testing.T) { Rank: "", Pseudo: false, Node: report.MakeNode().WithAdjacent("a1"), - Origins: report.MakeIDList("o1"), + Children: report.MakeNodeSet(report.MakeNode().WithID("child1")), } node2 := render.RenderableNode{ ID: "foo", @@ -46,7 +46,7 @@ func TestMergeRenderableNode(t *testing.T) { Rank: "rank", Pseudo: false, Node: report.MakeNode().WithAdjacent("a2"), - Origins: report.MakeIDList("o2"), + Children: report.MakeNodeSet(report.MakeNode().WithID("child2")), } want := render.RenderableNode{ ID: "foo", @@ -54,8 +54,8 @@ func TestMergeRenderableNode(t *testing.T) { LabelMinor: "minor", Rank: "rank", Pseudo: false, - Node: report.MakeNode().WithAdjacent("a1").WithAdjacent("a2"), - Origins: report.MakeIDList("o1", "o2"), + Node: report.MakeNode().WithID("foo").WithAdjacent("a1").WithAdjacent("a2"), + Children: report.MakeNodeSet(report.MakeNode().WithID("child1"), report.MakeNode().WithID("child2")), EdgeMetadata: report.EdgeMetadata{}, }.Prune() have := node1.Merge(node2).Prune() diff --git a/render/selectors.go b/render/selectors.go index 9274aaa45..18032aac9 100644 --- a/render/selectors.go +++ b/render/selectors.go @@ -22,12 +22,7 @@ func (t TopologySelector) Stats(r report.Report) Stats { func MakeRenderableNodes(t report.Topology) RenderableNodes { result := RenderableNodes{} for id, nmd := range t.Nodes { - rn := NewRenderableNode(id).WithNode(nmd) - rn.Origins = report.MakeIDList(id) - if hostNodeID, ok := nmd.Metadata[report.HostNodeID]; ok { - rn.Origins = rn.Origins.Add(hostNodeID) - } - result[id] = rn + result[id] = NewRenderableNode(id).WithNode(nmd) } // Push EdgeMetadata to both ends of the edges diff --git a/render/short_lived_connections_test.go b/render/short_lived_connections_test.go index 0b98b4f5f..00b3a2bba 100644 --- a/render/short_lived_connections_test.go +++ b/render/short_lived_connections_test.go @@ -28,7 +28,7 @@ var ( containerID = "a1b2c3d4e5" containerIP = "192.168.0.1" containerName = "foo" - containerNodeID = report.MakeContainerNodeID(serverHostID, containerID) + containerNodeID = report.MakeContainerNodeID(containerID) rpt = report.Report{ Endpoint: report.Topology{ @@ -37,13 +37,13 @@ var ( endpoint.Addr: randomIP, endpoint.Port: randomPort, endpoint.Conntracked: "true", - }).WithAdjacent(serverEndpointNodeID), + }).WithAdjacent(serverEndpointNodeID).WithID(randomEndpointNodeID).WithTopology("endpoint"), serverEndpointNodeID: report.MakeNode().WithMetadata(map[string]string{ endpoint.Addr: serverIP, endpoint.Port: serverPort, endpoint.Conntracked: "true", - }), + }).WithID(serverEndpointNodeID).WithTopology("endpoint"), }, }, Container: report.Topology{ @@ -55,7 +55,7 @@ var ( }).WithSets(report.Sets{ docker.ContainerIPs: report.MakeStringSet(containerIP), docker.ContainerPorts: report.MakeStringSet(fmt.Sprintf("%s:%s->%s/tcp", serverIP, serverPort, serverPort)), - }), + }).WithID(containerNodeID).WithTopology("container"), }, }, Host: report.Topology{ @@ -64,7 +64,7 @@ var ( report.HostNodeID: serverHostNodeID, }).WithSets(report.Sets{ host.LocalNetworks: report.MakeStringSet("192.168.0.0/16"), - }), + }).WithID(serverHostNodeID).WithTopology("host"), }, }, } @@ -74,16 +74,14 @@ var ( ID: render.TheInternetID, LabelMajor: render.TheInternetMajor, Pseudo: true, - Node: report.MakeNode().WithAdjacent(containerID), - Origins: report.MakeIDList(randomEndpointNodeID), + Node: report.MakeNode().WithAdjacent(render.MakeContainerID(containerID)), }, - containerID: { - ID: containerID, + render.MakeContainerID(containerID): { + ID: render.MakeContainerID(containerID), LabelMajor: containerName, LabelMinor: serverHostID, Rank: "", Pseudo: false, - Origins: report.MakeIDList(containerNodeID, serverEndpointNodeID, serverHostNodeID), Node: report.MakeNode(), ControlNode: containerNodeID, }, diff --git a/render/topologies.go b/render/topologies.go index d01121efc..edc914605 100644 --- a/render/topologies.go +++ b/render/topologies.go @@ -49,7 +49,7 @@ func (r processWithContainerNameRenderer) Render(rpt report.Report) RenderableNo if !ok { continue } - container, ok := containers[containerID] + container, ok := containers[MakeContainerID(containerID)] if !ok { continue } @@ -86,15 +86,10 @@ var ContainerRenderer = MakeReduce( _, isConnected := n.Node.Metadata[IsConnected] return inContainer || isConnected }, - Renderer: ColorConnected(Map{ + Renderer: Map{ MapFunc: MapProcess2Container, - Renderer: ProcessRenderer, - }), - }, - - Map{ - MapFunc: MapContainerIdentity, - Renderer: SelectContainer, + Renderer: ColorConnected(ProcessRenderer), + }, }, // This mapper brings in short lived connections by joining with container IPs. @@ -114,6 +109,11 @@ var ContainerRenderer = MakeReduce( }, ), }), + + Map{ + MapFunc: MapContainerIdentity, + Renderer: SelectContainer, + }, ) type containerWithImageNameRenderer struct { @@ -135,11 +135,11 @@ func (r containerWithImageNameRenderer) Render(rpt report.Report) RenderableNode if !ok { continue } - image, ok := images[imageID] + image, ok := images[MakeContainerImageID(imageID)] if !ok { continue } - c.Rank = imageNameWithoutVersion(image.LabelMajor) + c.Rank = ImageNameWithoutVersion(image.LabelMajor) c.Metadata = image.Metadata.Merge(c.Metadata) containers[id] = c } @@ -191,7 +191,25 @@ var AddressRenderer = Map{ // graph from the host topology and address graph. var HostRenderer = MakeReduce( Map{ - MapFunc: MapAddress2Host, + MapFunc: MapX2Host, + Renderer: Map{ + MapFunc: MapContainerImageIdentity, + Renderer: SelectContainerImage, + }, + }, + Map{ + MapFunc: MapX2Host, + Renderer: FilterPseudo(ContainerRenderer), + }, + Map{ + MapFunc: MapX2Host, + Renderer: Map{ + MapFunc: MapPodIdentity, + Renderer: SelectPod, + }, + }, + Map{ + MapFunc: MapX2Host, Renderer: AddressRenderer, }, Map{ @@ -205,14 +223,14 @@ var HostRenderer = MakeReduce( var PodRenderer = Map{ MapFunc: MapCountContainers, Renderer: MakeReduce( - Map{ - MapFunc: MapPodIdentity, - Renderer: SelectPod, - }, Map{ MapFunc: MapContainer2Pod, Renderer: ContainerRenderer, }, + Map{ + MapFunc: MapPodIdentity, + Renderer: SelectPod, + }, ), } diff --git a/render/topologies_test.go b/render/topologies_test.go index 2a70ce68f..7a74424df 100644 --- a/render/topologies_test.go +++ b/render/topologies_test.go @@ -43,7 +43,7 @@ func TestContainerFilterRenderer(t *testing.T) { input.Container.Nodes[fixture.ClientContainerNodeID].Metadata[docker.LabelPrefix+"works.weave.role"] = "system" have := render.FilterSystem(render.ContainerWithImageNameRenderer).Render(input).Prune() want := expected.RenderedContainers.Copy() - delete(want, fixture.ClientContainerID) + delete(want, expected.ClientContainerRenderedID) if !reflect.DeepEqual(want, have) { t.Error(test.Diff(want, have)) } @@ -77,14 +77,14 @@ func TestPodFilterRenderer(t *testing.T) { // tag on containers or pod namespace in the topology and ensure // it is filtered out correctly. input := fixture.Report.Copy() - input.Pod.Nodes[fixture.ClientPodNodeID].Metadata[kubernetes.PodID] = "kube-system/foo" + input.Pod.Nodes[fixture.ClientPodNodeID].Metadata[kubernetes.PodID] = "pod:kube-system/foo" input.Pod.Nodes[fixture.ClientPodNodeID].Metadata[kubernetes.Namespace] = "kube-system" input.Pod.Nodes[fixture.ClientPodNodeID].Metadata[kubernetes.PodName] = "foo" input.Container.Nodes[fixture.ClientContainerNodeID].Metadata[docker.LabelPrefix+"io.kubernetes.pod.name"] = "kube-system/foo" have := render.FilterSystem(render.PodRenderer).Render(input).Prune() want := expected.RenderedPods.Copy() - delete(want, fixture.ClientPodID) - delete(want, fixture.ClientContainerID) + delete(want, expected.ClientPodRenderedID) + delete(want, expected.ClientContainerRenderedID) if !reflect.DeepEqual(want, have) { t.Error(test.Diff(want, have)) } diff --git a/report/id.go b/report/id.go index 42b383611..f8401cb5a 100644 --- a/report/id.go +++ b/report/id.go @@ -102,13 +102,18 @@ func MakeHostNodeID(hostID string) string { } // MakeContainerNodeID produces a container node ID from its composite parts. -func MakeContainerNodeID(hostID, containerID string) string { - return hostID + ScopeDelim + containerID +func MakeContainerNodeID(containerID string) string { + return containerID + ScopeDelim + "" +} + +// MakeContainerImageNodeID produces a container image node ID from its composite parts. +func MakeContainerImageNodeID(hostID, containerImageID string) string { + return hostID + ScopeDelim + containerImageID } // MakePodNodeID produces a pod node ID from its composite parts. -func MakePodNodeID(hostID, podID string) string { - return hostID + ScopeDelim + podID +func MakePodNodeID(namespaceID, podID string) string { + return namespaceID + ScopeDelim + podID } // MakeServiceNodeID produces a service node ID from its composite parts. @@ -143,14 +148,13 @@ func ParseEndpointNodeID(endpointNodeID string) (hostID, address, port string, o return fields[0], fields[1], fields[2], true } -// ParseContainerNodeID produces the host and container id from an container -// node ID. -func ParseContainerNodeID(containerNodeID string) (hostID, containerID string, ok bool) { +// ParseContainerNodeID produces the container id from an container node ID. +func ParseContainerNodeID(containerNodeID string) (containerID string, ok bool) { fields := strings.SplitN(containerNodeID, ScopeDelim, 2) - if len(fields) != 2 { - return "", "", false + if len(fields) != 2 || fields[1] != "" { + return "", false } - return fields[0], fields[1], true + return fields[0], true } // ParseAddressNodeID produces the host ID, address from an address node ID. diff --git a/report/id_list.go b/report/id_list.go index a530d3c92..9c740e69b 100644 --- a/report/id_list.go +++ b/report/id_list.go @@ -15,6 +15,11 @@ func (a IDList) Add(ids ...string) IDList { return IDList(StringSet(a).Add(ids...)) } +// Remove is the only correct way to remove IDs from an IDList. +func (a IDList) Remove(ids ...string) IDList { + return IDList(StringSet(a).Remove(ids...)) +} + // Copy returns a copy of the IDList. func (a IDList) Copy() IDList { return IDList(StringSet(a).Copy()) diff --git a/report/merge_test.go b/report/merge_test.go index a97585887..e38d89bd1 100644 --- a/report/merge_test.go +++ b/report/merge_test.go @@ -193,7 +193,30 @@ func TestMergeNodes(t *testing.T) { }), }, }, - "Merge conflict": { + "Merge conflict with rank difference": { + a: report.Nodes{ + ":192.168.1.1:12345": report.MakeNodeWith(map[string]string{ + PID: "23128", + Name: "curl", + Domain: "node-a.local", + }), + }, + b: report.Nodes{ + ":192.168.1.1:12345": report.MakeNodeWith(map[string]string{ // <-- same ID + PID: "0", + Name: "curl", + Domain: "node-a.local", + }), + }, + want: report.Nodes{ + ":192.168.1.1:12345": report.MakeNodeWith(map[string]string{ + PID: "23128", + Name: "curl", + Domain: "node-a.local", + }), + }, + }, + "Merge conflict with no rank difference": { a: report.Nodes{ ":192.168.1.1:12345": report.MakeNodeWith(map[string]string{ PID: "23128", diff --git a/report/metrics.go b/report/metrics.go index a09893925..3c7f893c5 100644 --- a/report/metrics.go +++ b/report/metrics.go @@ -235,7 +235,9 @@ func parseTime(s string) time.Time { return t } -func (m Metric) toIntermediate() WireMetrics { +// ToIntermediate converts the metric to a representation suitable +// for serialization. +func (m Metric) ToIntermediate() WireMetrics { samples := []Sample{} if m.Samples != nil { m.Samples.Reverse().ForEach(func(s interface{}) { @@ -268,7 +270,7 @@ func (m WireMetrics) fromIntermediate() Metric { // MarshalJSON implements json.Marshaller func (m Metric) MarshalJSON() ([]byte, error) { buf := bytes.Buffer{} - in := m.toIntermediate() + in := m.ToIntermediate() err := json.NewEncoder(&buf).Encode(in) return buf.Bytes(), err } @@ -286,7 +288,7 @@ func (m *Metric) UnmarshalJSON(input []byte) error { // GobEncode implements gob.Marshaller func (m Metric) GobEncode() ([]byte, error) { buf := bytes.Buffer{} - err := gob.NewEncoder(&buf).Encode(m.toIntermediate()) + err := gob.NewEncoder(&buf).Encode(m.ToIntermediate()) return buf.Bytes(), err } diff --git a/report/node_set.go b/report/node_set.go new file mode 100644 index 000000000..9a36b864b --- /dev/null +++ b/report/node_set.go @@ -0,0 +1,69 @@ +package report + +import ( + "sort" +) + +// NodeSet is a sorted set of nodes keyed on (Topology, ID). Clients must use +// the Add method to add nodes +type NodeSet []Node + +// MakeNodeSet makes a new NodeSet with the given nodes. +// TODO: Make this more efficient +func MakeNodeSet(nodes ...Node) NodeSet { + if len(nodes) <= 0 { + return nil + } + result := NodeSet{} + for _, node := range nodes { + result = result.Add(node) + } + return result +} + +// Add adds the nodes to the NodeSet. Add is the only valid way to grow a +// NodeSet. Add returns the NodeSet to enable chaining. +func (n NodeSet) Add(nodes ...Node) NodeSet { + for _, node := range nodes { + i := sort.Search(len(n), func(i int) bool { + return n[i].Topology >= node.Topology && n[i].ID >= node.ID + }) + if i < len(n) && n[i].Topology == node.Topology && n[i].ID == node.ID { + // The list already has the element. + continue + } + // It a new element, insert it in order. + n = append(n, Node{}) + copy(n[i+1:], n[i:]) + n[i] = node.Copy() + } + return n +} + +// Merge combines the two NodeSets and returns a new result. +// TODO: Make this more efficient +func (n NodeSet) Merge(other NodeSet) NodeSet { + switch { + case len(other) <= 0: // Optimise special case, to avoid allocating + return n // (note unit test DeepEquals breaks if we don't do this) + case len(n) <= 0: + return other + } + result := n.Copy() + for _, node := range other { + result = result.Add(node) + } + return result +} + +// Copy returns a value copy of the NodeSet. +func (n NodeSet) Copy() NodeSet { + if n == nil { + return n + } + result := make(NodeSet, len(n)) + for i, node := range n { + result[i] = node.Copy() + } + return result +} diff --git a/report/node_set_test.go b/report/node_set_test.go new file mode 100644 index 000000000..518701299 --- /dev/null +++ b/report/node_set_test.go @@ -0,0 +1,152 @@ +package report_test + +import ( + "reflect" + "testing" + + "github.com/weaveworks/scope/report" +) + +type nodeSpec struct { + topology string + id string +} + +func TestMakeNodeSet(t *testing.T) { + for _, testcase := range []struct { + inputs []nodeSpec + wants []nodeSpec + }{ + {inputs: nil, wants: nil}, + {inputs: []nodeSpec{}, wants: []nodeSpec{}}, + { + inputs: []nodeSpec{{"", "a"}}, + wants: []nodeSpec{{"", "a"}}, + }, + { + inputs: []nodeSpec{{"", "a"}, {"", "a"}, {"1", "a"}}, + wants: []nodeSpec{{"", "a"}, {"1", "a"}}, + }, + { + inputs: []nodeSpec{{"", "b"}, {"", "c"}, {"", "a"}}, + wants: []nodeSpec{{"", "a"}, {"", "b"}, {"", "c"}}, + }, + { + inputs: []nodeSpec{{"2", "a"}, {"3", "a"}, {"1", "a"}}, + wants: []nodeSpec{{"1", "a"}, {"2", "a"}, {"3", "a"}}, + }, + } { + var ( + inputs []report.Node + wants []report.Node + ) + for _, spec := range testcase.inputs { + inputs = append(inputs, report.MakeNode().WithTopology(spec.topology).WithID(spec.id)) + } + for _, spec := range testcase.wants { + wants = append(wants, report.MakeNode().WithTopology(spec.topology).WithID(spec.id)) + } + if want, have := report.NodeSet(wants), report.MakeNodeSet(inputs...); !reflect.DeepEqual(want, have) { + t.Errorf("%#v: want %#v, have %#v", testcase.inputs, want, have) + } + } +} + +func TestNodeSetAdd(t *testing.T) { + for _, testcase := range []struct { + input report.NodeSet + nodes []report.Node + want report.NodeSet + }{ + {input: report.NodeSet(nil), nodes: []report.Node{}, want: report.NodeSet(nil)}, + { + input: report.MakeNodeSet(), + nodes: []report.Node{}, + want: report.MakeNodeSet(), + }, + { + input: report.MakeNodeSet(report.MakeNode().WithID("a")), + nodes: []report.Node{}, + want: report.MakeNodeSet(report.MakeNode().WithID("a")), + }, + { + input: report.MakeNodeSet(), + nodes: []report.Node{report.MakeNode().WithID("a")}, + want: report.MakeNodeSet(report.MakeNode().WithID("a")), + }, + { + input: report.MakeNodeSet(report.MakeNode().WithID("a")), + nodes: []report.Node{report.MakeNode().WithID("a")}, + want: report.MakeNodeSet(report.MakeNode().WithID("a")), + }, + { + input: report.MakeNodeSet(report.MakeNode().WithID("b")), + nodes: []report.Node{report.MakeNode().WithID("a"), report.MakeNode().WithID("b")}, + want: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("b")), + }, + { + input: report.MakeNodeSet(report.MakeNode().WithID("a")), + nodes: []report.Node{report.MakeNode().WithID("c"), report.MakeNode().WithID("b")}, + want: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("b"), report.MakeNode().WithID("c")), + }, + { + input: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("c")), + nodes: []report.Node{report.MakeNode().WithID("b"), report.MakeNode().WithID("b"), report.MakeNode().WithID("b")}, + want: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("b"), report.MakeNode().WithID("c")), + }, + } { + if want, have := testcase.want, testcase.input.Add(testcase.nodes...); !reflect.DeepEqual(want, have) { + t.Errorf("%v + %v: want %v, have %v", testcase.input, testcase.nodes, want, have) + } + } +} + +func TestNodeSetMerge(t *testing.T) { + for _, testcase := range []struct { + input report.NodeSet + other report.NodeSet + want report.NodeSet + }{ + {input: report.NodeSet(nil), other: report.NodeSet(nil), want: report.NodeSet(nil)}, + {input: report.MakeNodeSet(), other: report.MakeNodeSet(), want: report.MakeNodeSet()}, + { + input: report.MakeNodeSet(report.MakeNode().WithID("a")), + other: report.MakeNodeSet(), + want: report.MakeNodeSet(report.MakeNode().WithID("a")), + }, + { + input: report.MakeNodeSet(), + other: report.MakeNodeSet(report.MakeNode().WithID("a")), + want: report.MakeNodeSet(report.MakeNode().WithID("a")), + }, + { + input: report.MakeNodeSet(report.MakeNode().WithID("a")), + other: report.MakeNodeSet(report.MakeNode().WithID("b")), + want: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("b")), + }, + { + input: report.MakeNodeSet(report.MakeNode().WithID("b")), + other: report.MakeNodeSet(report.MakeNode().WithID("a")), + want: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("b")), + }, + { + input: report.MakeNodeSet(report.MakeNode().WithID("a")), + other: report.MakeNodeSet(report.MakeNode().WithID("a")), + want: report.MakeNodeSet(report.MakeNode().WithID("a")), + }, + { + input: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("c")), + other: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("b")), + want: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("b"), report.MakeNode().WithID("c")), + }, + { + input: report.MakeNodeSet(report.MakeNode().WithID("b")), + other: report.MakeNodeSet(report.MakeNode().WithID("a")), + want: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("b")), + }, + } { + if want, have := testcase.want, testcase.input.Merge(testcase.other); !reflect.DeepEqual(want, have) { + t.Errorf("%v + %v: want %v, have %v", testcase.input, testcase.other, want, have) + } + } +} diff --git a/report/topology.go b/report/topology.go index 567ae2c41..dc2dd1767 100644 --- a/report/topology.go +++ b/report/topology.go @@ -84,6 +84,8 @@ func (n Nodes) Merge(other Nodes) Nodes { // given node in a given topology, along with the edges emanating from the // node and metadata about those edges. type Node struct { + ID string `json:"id,omitempty"` + Topology string `json:"topology,omitempty"` Metadata Metadata `json:"metadata,omitempty"` Counters Counters `json:"counters,omitempty"` Sets Sets `json:"sets,omitempty"` @@ -92,6 +94,7 @@ type Node struct { Controls NodeControls `json:"controls,omitempty"` Latest LatestMap `json:"latest,omitempty"` Metrics Metrics `json:"metrics,omitempty"` + Parents Sets `json:"parents,omitempty"` } // MakeNode creates a new Node with no initial metadata. @@ -99,12 +102,12 @@ func MakeNode() Node { return Node{ Metadata: Metadata{}, Counters: Counters{}, - Sets: Sets{}, Adjacency: MakeIDList(), Edges: EdgeMetadatas{}, Controls: MakeNodeControls(), Latest: MakeLatestMap(), Metrics: Metrics{}, + Parents: Sets{}, } } @@ -113,6 +116,20 @@ func MakeNodeWith(m map[string]string) Node { return MakeNode().WithMetadata(m) } +// WithID returns a fresh copy of n, with ID changed. +func (n Node) WithID(id string) Node { + result := n.Copy() + result.ID = id + return result +} + +// WithTopology returns a fresh copy of n, with ID changed. +func (n Node) WithTopology(topology string) Node { + result := n.Copy() + result.Topology = topology + return result +} + // WithMetadata returns a fresh copy of n, with Metadata m merged in. func (n Node) WithMetadata(m map[string]string) Node { result := n.Copy() @@ -130,8 +147,7 @@ func (n Node) WithCounters(c map[string]int) Node { // WithSet returns a fresh copy of n, with set merged in at key. func (n Node) WithSet(key string, set StringSet) Node { result := n.Copy() - existing := n.Sets[key] - result.Sets[key] = existing.Merge(set) + result.Sets = result.Sets.Merge(Sets{key: set}) return result } @@ -186,9 +202,18 @@ func (n Node) WithLatest(k string, ts time.Time, v string) Node { return result } +// WithParents returns a fresh copy of n, with sets merged in. +func (n Node) WithParents(parents Sets) Node { + result := n.Copy() + result.Parents = result.Parents.Merge(parents) + return result +} + // Copy returns a value copy of the Node. func (n Node) Copy() Node { cp := MakeNode() + cp.ID = n.ID + cp.Topology = n.Topology cp.Metadata = n.Metadata.Copy() cp.Counters = n.Counters.Copy() cp.Sets = n.Sets.Copy() @@ -197,6 +222,7 @@ func (n Node) Copy() Node { cp.Controls = n.Controls.Copy() cp.Latest = n.Latest.Copy() cp.Metrics = n.Metrics.Copy() + cp.Parents = n.Parents.Copy() return cp } @@ -204,6 +230,12 @@ func (n Node) Copy() Node { // fresh node. func (n Node) Merge(other Node) Node { cp := n.Copy() + if cp.ID == "" { + cp.ID = other.ID + } + if cp.Topology == "" { + cp.Topology = other.Topology + } cp.Metadata = cp.Metadata.Merge(other.Metadata) cp.Counters = cp.Counters.Merge(other.Counters) cp.Sets = cp.Sets.Merge(other.Sets) @@ -212,6 +244,7 @@ func (n Node) Merge(other Node) Node { cp.Controls = cp.Controls.Merge(other.Controls) cp.Latest = cp.Latest.Merge(other.Latest) cp.Metrics = cp.Metrics.Merge(other.Metrics) + cp.Parents = cp.Parents.Merge(other.Parents) return cp } @@ -268,6 +301,9 @@ type Sets map[string]StringSet func (s Sets) Merge(other Sets) Sets { result := s.Copy() for k, v := range other { + if result == nil { + result = Sets{} + } result[k] = result[k].Merge(v) } return result @@ -275,6 +311,9 @@ func (s Sets) Merge(other Sets) Sets { // Copy returns a value copy of the sets map. func (s Sets) Copy() Sets { + if s == nil { + return s + } result := Sets{} for k, v := range s { result[k] = v.Copy() @@ -321,6 +360,21 @@ func (s StringSet) Add(strs ...string) StringSet { return s } +// Remove removes the strings from the StringSet. Remove is the only valid way +// to shrink a StringSet. Remove returns the StringSet to enable chaining. +func (s StringSet) Remove(strs ...string) StringSet { + for _, str := range strs { + i := sort.Search(len(s), func(i int) bool { return s[i] >= str }) + if i >= len(s) || s[i] != str { + // The list does not have the element. + continue + } + // has the element, remove it. + s = append(s[:i], s[i+1:]...) + } + return s +} + // Merge combines the two StringSets and returns a new result. func (s StringSet) Merge(other StringSet) StringSet { switch { diff --git a/report/topology_test.go b/report/topology_test.go index c965f9b7d..72beecc5d 100644 --- a/report/topology_test.go +++ b/report/topology_test.go @@ -45,6 +45,27 @@ func TestStringSetAdd(t *testing.T) { } } +func TestStringSetRemove(t *testing.T) { + for _, testcase := range []struct { + input report.StringSet + strs []string + want report.StringSet + }{ + {input: report.StringSet(nil), strs: []string{}, want: report.StringSet(nil)}, + {input: report.MakeStringSet(), strs: []string{}, want: report.MakeStringSet()}, + {input: report.MakeStringSet("a"), strs: []string{}, want: report.MakeStringSet("a")}, + {input: report.MakeStringSet(), strs: []string{"a"}, want: report.MakeStringSet()}, + {input: report.MakeStringSet("a"), strs: []string{"a"}, want: report.StringSet{}}, + {input: report.MakeStringSet("b"), strs: []string{"a", "b"}, want: report.StringSet{}}, + {input: report.MakeStringSet("a"), strs: []string{"c", "b"}, want: report.MakeStringSet("a")}, + {input: report.MakeStringSet("a", "c"), strs: []string{"b", "b", "b"}, want: report.MakeStringSet("a", "c")}, + } { + if want, have := testcase.want, testcase.input.Remove(testcase.strs...); !reflect.DeepEqual(want, have) { + t.Errorf("%v - %v: want %#v, have %#v", testcase.input, testcase.strs, want, have) + } + } +} + func TestStringSetMerge(t *testing.T) { for _, testcase := range []struct { input report.StringSet diff --git a/test/fixture/report_fixture.go b/test/fixture/report_fixture.go index bf4f2eeed..fa9785a5e 100644 --- a/test/fixture/report_fixture.go +++ b/test/fixture/report_fixture.go @@ -74,13 +74,13 @@ var ( ClientContainerID = "a1b2c3d4e5" ServerContainerID = "5e4d3c2b1a" - ClientContainerNodeID = report.MakeContainerNodeID(ClientHostID, ClientContainerID) - ServerContainerNodeID = report.MakeContainerNodeID(ServerHostID, ServerContainerID) + ClientContainerNodeID = report.MakeContainerNodeID(ClientContainerID) + ServerContainerNodeID = report.MakeContainerNodeID(ServerContainerID) ClientContainerImageID = "imageid123" ServerContainerImageID = "imageid456" - ClientContainerImageNodeID = report.MakeContainerNodeID(ClientHostID, ClientContainerImageID) - ServerContainerImageNodeID = report.MakeContainerNodeID(ServerHostID, ServerContainerImageID) + ClientContainerImageNodeID = report.MakeContainerImageNodeID(ClientHostID, ClientContainerImageID) + ServerContainerImageNodeID = report.MakeContainerImageNodeID(ServerHostID, ServerContainerImageID) ClientContainerImageName = "image/client" ServerContainerImageName = "image/server" @@ -91,12 +91,13 @@ var ( UnknownAddress3NodeID = report.MakeAddressNodeID(ServerHostID, UnknownClient3IP) RandomAddressNodeID = report.MakeAddressNodeID(ServerHostID, RandomClientIP) // this should become an internet node - ClientPodID = "ping/pong-a" - ServerPodID = "ping/pong-b" - ClientPodNodeID = report.MakePodNodeID("ping", "pong-a") - ServerPodNodeID = report.MakePodNodeID("ping", "pong-b") - ServiceID = "ping/pongservice" - ServiceNodeID = report.MakeServiceNodeID("ping", "pongservice") + KubernetesNamespace = "ping" + ClientPodID = "ping/pong-a" + ServerPodID = "ping/pong-b" + ClientPodNodeID = report.MakePodNodeID(KubernetesNamespace, "pong-a") + ServerPodNodeID = report.MakePodNodeID(KubernetesNamespace, "pong-b") + ServiceID = "ping/pongservice" + ServiceNodeID = report.MakeServiceNodeID(KubernetesNamespace, "pongservice") LoadMetric = report.MakeMetric().Add(Now, 0.01).WithFirst(Now.Add(-15 * time.Second)) LoadMetrics = report.Metrics{ @@ -105,6 +106,10 @@ var ( host.Load15: LoadMetric, } + CPUMetric = report.MakeMetric().Add(Now, 0.01).WithFirst(Now.Add(-15 * time.Second)) + + MemoryMetric = report.MakeMetric().Add(Now, 0.01).WithFirst(Now.Add(-15 * time.Second)) + Report = report.Report{ Endpoint: report.Topology{ Nodes: report.Nodes{ @@ -200,23 +205,40 @@ var ( process.Name: Client1Name, docker.ContainerID: ClientContainerID, report.HostNodeID: ClientHostNodeID, + }).WithID(ClientProcess1NodeID).WithTopology("process").WithParents(report.Sets{ + "host": report.MakeStringSet(ClientHostNodeID), + "container": report.MakeStringSet(ClientContainerNodeID), + "container_image": report.MakeStringSet(ClientContainerImageNodeID), + }).WithMetrics(report.Metrics{ + process.CPUUsage: CPUMetric, + process.MemoryUsage: MemoryMetric, }), ClientProcess2NodeID: report.MakeNodeWith(map[string]string{ process.PID: Client2PID, process.Name: Client2Name, docker.ContainerID: ClientContainerID, report.HostNodeID: ClientHostNodeID, + }).WithID(ClientProcess2NodeID).WithTopology("process").WithParents(report.Sets{ + "host": report.MakeStringSet(ClientHostNodeID), + "container": report.MakeStringSet(ClientContainerNodeID), + "container_image": report.MakeStringSet(ClientContainerImageNodeID), }), ServerProcessNodeID: report.MakeNodeWith(map[string]string{ process.PID: ServerPID, process.Name: ServerName, docker.ContainerID: ServerContainerID, report.HostNodeID: ServerHostNodeID, + }).WithID(ServerProcessNodeID).WithTopology("process").WithParents(report.Sets{ + "host": report.MakeStringSet(ServerHostNodeID), + "container": report.MakeStringSet(ServerContainerNodeID), + "container_image": report.MakeStringSet(ServerContainerImageNodeID), }), NonContainerProcessNodeID: report.MakeNodeWith(map[string]string{ process.PID: NonContainerPID, process.Name: NonContainerName, report.HostNodeID: ServerHostNodeID, + }).WithID(NonContainerProcessNodeID).WithTopology("process").WithParents(report.Sets{ + "host": report.MakeStringSet(ServerHostNodeID), }), }, }, @@ -228,17 +250,36 @@ var ( docker.ImageID: ClientContainerImageID, report.HostNodeID: ClientHostNodeID, docker.LabelPrefix + "io.kubernetes.pod.name": ClientPodID, - }).WithLatest(docker.ContainerState, Now, docker.StateRunning), + kubernetes.PodID: ClientPodID, + kubernetes.Namespace: KubernetesNamespace, + }).WithLatest(docker.ContainerState, Now, docker.StateRunning).WithID(ClientContainerNodeID).WithTopology("container").WithParents(report.Sets{ + "host": report.MakeStringSet(ClientHostNodeID), + "container_image": report.MakeStringSet(ClientContainerImageNodeID), + "pod": report.MakeStringSet(ClientPodID), + }).WithMetrics(report.Metrics{ + docker.CPUTotalUsage: CPUMetric, + docker.MemoryUsage: MemoryMetric, + }), ServerContainerNodeID: report.MakeNodeWith(map[string]string{ docker.ContainerID: ServerContainerID, docker.ContainerName: "task-name-5-server-aceb93e2f2b797caba01", + docker.ContainerState: "running", docker.ImageID: ServerContainerImageID, report.HostNodeID: ServerHostNodeID, docker.LabelPrefix + render.AmazonECSContainerNameLabel: "server", docker.LabelPrefix + "foo1": "bar1", docker.LabelPrefix + "foo2": "bar2", docker.LabelPrefix + "io.kubernetes.pod.name": ServerPodID, - }).WithLatest(docker.ContainerState, Now, docker.StateRunning), + kubernetes.PodID: ServerPodID, + kubernetes.Namespace: KubernetesNamespace, + }).WithLatest(docker.ContainerState, Now, docker.StateRunning).WithID(ServerContainerNodeID).WithTopology("container").WithParents(report.Sets{ + "host": report.MakeStringSet(ServerHostNodeID), + "container_image": report.MakeStringSet(ServerContainerImageNodeID), + "pod": report.MakeStringSet(ServerPodID), + }).WithMetrics(report.Metrics{ + docker.CPUTotalUsage: CPUMetric, + docker.MemoryUsage: MemoryMetric, + }), }, }, ContainerImage: report.Topology{ @@ -247,14 +288,18 @@ var ( docker.ImageID: ClientContainerImageID, docker.ImageName: ClientContainerImageName, report.HostNodeID: ClientHostNodeID, - }), + }).WithParents(report.Sets{ + "host": report.MakeStringSet(ClientHostNodeID), + }).WithID(ClientContainerImageNodeID).WithTopology("container_image"), ServerContainerImageNodeID: report.MakeNodeWith(map[string]string{ docker.ImageID: ServerContainerImageID, docker.ImageName: ServerContainerImageName, report.HostNodeID: ServerHostNodeID, docker.LabelPrefix + "foo1": "bar1", docker.LabelPrefix + "foo2": "bar2", - }), + }).WithParents(report.Sets{ + "host": report.MakeStringSet(ServerHostNodeID), + }).WithID(ServerContainerImageNodeID).WithTopology("container_image"), }, }, Address: report.Topology{ @@ -294,23 +339,27 @@ var ( "host_name": ClientHostName, "os": "Linux", report.HostNodeID: ClientHostNodeID, - }).WithSets(report.Sets{ + }).WithID(ClientHostNodeID).WithTopology("host").WithSets(report.Sets{ host.LocalNetworks: report.MakeStringSet("10.10.10.0/24"), }).WithMetrics(report.Metrics{ - host.Load1: LoadMetric, - host.Load5: LoadMetric, - host.Load15: LoadMetric, + host.CPUUsage: CPUMetric, + host.MemUsage: MemoryMetric, + host.Load1: LoadMetric, + host.Load5: LoadMetric, + host.Load15: LoadMetric, }), ServerHostNodeID: report.MakeNodeWith(map[string]string{ "host_name": ServerHostName, "os": "Linux", report.HostNodeID: ServerHostNodeID, - }).WithSets(report.Sets{ + }).WithID(ServerHostNodeID).WithTopology("host").WithSets(report.Sets{ host.LocalNetworks: report.MakeStringSet("10.10.10.0/24"), }).WithMetrics(report.Metrics{ - host.Load1: LoadMetric, - host.Load5: LoadMetric, - host.Load15: LoadMetric, + host.CPUUsage: CPUMetric, + host.MemUsage: MemoryMetric, + host.Load1: LoadMetric, + host.Load5: LoadMetric, + host.Load15: LoadMetric, }), }, }, @@ -319,16 +368,22 @@ var ( ClientPodNodeID: report.MakeNodeWith(map[string]string{ kubernetes.PodID: ClientPodID, kubernetes.PodName: "pong-a", - kubernetes.Namespace: "ping", + kubernetes.Namespace: KubernetesNamespace, kubernetes.PodContainerIDs: ClientContainerID, kubernetes.ServiceIDs: ServiceID, + }).WithID(ClientPodNodeID).WithTopology("pod").WithParents(report.Sets{ + "host": report.MakeStringSet(ClientHostNodeID), + "service": report.MakeStringSet(ServiceID), }), ServerPodNodeID: report.MakeNodeWith(map[string]string{ kubernetes.PodID: ServerPodID, kubernetes.PodName: "pong-b", - kubernetes.Namespace: "ping", + kubernetes.Namespace: KubernetesNamespace, kubernetes.PodContainerIDs: ServerContainerID, kubernetes.ServiceIDs: ServiceID, + }).WithID(ServerPodNodeID).WithTopology("pod").WithParents(report.Sets{ + "host": report.MakeStringSet(ServerHostNodeID), + "service": report.MakeStringSet(ServiceID), }), }, }, @@ -338,7 +393,7 @@ var ( kubernetes.ServiceID: ServiceID, kubernetes.ServiceName: "pongservice", kubernetes.Namespace: "ping", - }), + }).WithID(ServiceNodeID).WithTopology("service"), }, }, Sampling: report.Sampling{ From d39fd847b7e7b1b7b51ac6c75c77d36c3de013e4 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 15 Jan 2016 13:50:44 -0800 Subject: [PATCH 02/12] Details Panel UI Redesign Refactored nodedetails to support multiple data sets, probably broke some tests Allow api requests to out-of-view topologies Fix ESC behavior with details panel Stack details panel like cards Details pain side-by-side Details panel piles Fix node details table header styles Render load and not-found captions like relatives Fix topology click action Make node detail tables sortable Grouped metrics for details health Group metrics in same style Link node details children Fix scroll issues on double-details Fix DESC sort order for node details table Save selected node labels in state - allows rendering of node labels from other topologies before details are loaded Change detail card UX, newest one at top, pile at bottom Details panel one pile w/ animation Sort details table nodes by metadata too Animate sidepanel from children too Fix radial layout Dont set origin if a node was already selected, suppresses animation stack effect: shift top cards to the left, shrink lower cards vertically Clear details card stack if sibling is selected Check if node is still selected on API response Make detail table sorters robust against non-uniform metadata Dont show scrollbar all the time, fix sort icon issue Button to show topology for relative Overflow metrics for details panel health Fix JS error when no metrics are available for container image details Column-based rendering of node details table Fix JS tests Review feedback (UI) --- client/app/scripts/actions/app-actions.js | 72 +++- client/app/scripts/charts/node.js | 11 +- client/app/scripts/charts/nodes-chart.js | 4 +- .../components/__tests__/node-details-test.js | 7 +- client/app/scripts/components/app.js | 7 +- client/app/scripts/components/details-card.js | 58 ++++ client/app/scripts/components/details.js | 31 +- client/app/scripts/components/node-details.js | 105 ++++-- .../node-details/node-details-health-item.js | 17 + .../node-details-health-overflow-item.js | 14 + .../node-details-health-overflow.js | 18 + .../node-details/node-details-health.js | 42 +++ .../node-details/node-details-info.js | 24 ++ .../node-details-relatives-link.js | 27 ++ .../node-details/node-details-relatives.js | 40 +++ .../node-details-table-node-link.js | 40 +++ .../node-details-table-row-number.js | 13 - .../node-details-table-row-sparkline.js | 15 - .../node-details-table-row-value.js | 17 - .../node-details/node-details-table.js | 173 ++++++++-- client/app/scripts/components/sparkline.js | 2 +- client/app/scripts/constants/action-types.js | 4 + .../stores/__tests__/app-store-test.js | 89 +++++ client/app/scripts/stores/app-store.js | 170 ++++++++-- .../utils/__tests__/string-utils-test.js | 13 + client/app/scripts/utils/string-utils.js | 31 ++ client/app/scripts/utils/web-api-utils.js | 22 +- client/app/styles/main.less | 321 ++++++++++++++---- client/package.json | 1 + 29 files changed, 1150 insertions(+), 238 deletions(-) create mode 100644 client/app/scripts/components/details-card.js create mode 100644 client/app/scripts/components/node-details/node-details-health-item.js create mode 100644 client/app/scripts/components/node-details/node-details-health-overflow-item.js create mode 100644 client/app/scripts/components/node-details/node-details-health-overflow.js create mode 100644 client/app/scripts/components/node-details/node-details-health.js create mode 100644 client/app/scripts/components/node-details/node-details-info.js create mode 100644 client/app/scripts/components/node-details/node-details-relatives-link.js create mode 100644 client/app/scripts/components/node-details/node-details-relatives.js create mode 100644 client/app/scripts/components/node-details/node-details-table-node-link.js delete mode 100644 client/app/scripts/components/node-details/node-details-table-row-number.js delete mode 100644 client/app/scripts/components/node-details/node-details-table-row-sparkline.js delete mode 100644 client/app/scripts/components/node-details/node-details-table-row-value.js create mode 100644 client/app/scripts/utils/__tests__/string-utils-test.js create mode 100644 client/app/scripts/utils/string-utils.js diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index e8ccc77f6..436d9efe3 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -26,14 +26,22 @@ export function changeTopologyOption(option, value, topologyId) { AppStore.getActiveTopologyOptions() ); getNodeDetails( - AppStore.getCurrentTopologyUrl(), - AppStore.getSelectedNodeId() + AppStore.getTopologyUrlsById(), + AppStore.getNodeDetails() ); } -export function clickCloseDetails() { +export function clickBackground() { AppDispatcher.dispatch({ - type: ActionTypes.CLICK_CLOSE_DETAILS + type: ActionTypes.CLICK_BACKGROUND + }); + updateRoute(); +} + +export function clickCloseDetails(nodeId) { + AppDispatcher.dispatch({ + type: ActionTypes.CLICK_CLOSE_DETAILS, + nodeId }); updateRoute(); } @@ -49,15 +57,45 @@ export function clickCloseTerminal(pipeId, closePipe) { updateRoute(); } -export function clickNode(nodeId) { +export function clickNode(nodeId, label, origin) { AppDispatcher.dispatch({ type: ActionTypes.CLICK_NODE, - nodeId: nodeId + origin, + label, + nodeId }); updateRoute(); getNodeDetails( + AppStore.getTopologyUrlsById(), + AppStore.getNodeDetails() + ); +} + +export function clickRelative(nodeId, topologyId, label, origin) { + AppDispatcher.dispatch({ + type: ActionTypes.CLICK_RELATIVE, + label, + origin, + nodeId, + topologyId + }); + updateRoute(); + getNodeDetails( + AppStore.getTopologyUrlsById(), + AppStore.getNodeDetails() + ); +} + +export function clickShowTopologyForNode(topologyId, nodeId) { + AppDispatcher.dispatch({ + type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE, + topologyId, + nodeId + }); + updateRoute(); + getNodesDelta( AppStore.getCurrentTopologyUrl(), - AppStore.getSelectedNodeId() + AppStore.getActiveTopologyOptions() ); } @@ -121,10 +159,7 @@ export function hitEsc() { type: ActionTypes.CLICK_CLOSE_TERMINAL, pipeId: controlPipe.id }); - // Dont deselect node on ESC if there is a controlPipe (keep terminal open) } else if (AppStore.getSelectedNodeId() && !controlPipe) { - AppDispatcher.dispatch({type: ActionTypes.DESELECT_NODE}); - } updateRoute(); } @@ -181,8 +216,8 @@ export function receiveTopologies(topologies) { AppStore.getActiveTopologyOptions() ); getNodeDetails( - AppStore.getCurrentTopologyUrl(), - AppStore.getSelectedNodeId() + AppStore.getTopologyUrlsById(), + AppStore.getNodeDetails() ); } @@ -195,6 +230,7 @@ export function receiveApiDetails(apiDetails) { } export function receiveControlPipeFromParams(pipeId, rawTty) { + // TODO add nodeId AppDispatcher.dispatch({ type: ActionTypes.RECEIVE_CONTROL_PIPE, pipeId: pipeId, @@ -216,6 +252,7 @@ export function receiveControlPipe(pipeId, nodeId, rawTty) { AppDispatcher.dispatch({ type: ActionTypes.RECEIVE_CONTROL_PIPE, + nodeId: nodeId, pipeId: pipeId, rawTty: rawTty }); @@ -238,6 +275,13 @@ export function receiveError(errorUrl) { }); } +export function receiveNotFound(nodeId) { + AppDispatcher.dispatch({ + nodeId, + type: ActionTypes.RECEIVE_NOT_FOUND + }); +} + export function route(state) { AppDispatcher.dispatch({ state: state, @@ -251,7 +295,7 @@ export function route(state) { AppStore.getActiveTopologyOptions() ); getNodeDetails( - AppStore.getCurrentTopologyUrl(), - AppStore.getSelectedNodeId() + AppStore.getTopologyUrlsById(), + AppStore.getNodeDetails() ); } diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index faf0cfb8d..39150e6fe 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -1,4 +1,5 @@ import React from 'react'; +import ReactDOM from 'react-dom'; import { Motion, spring } from 'react-motion'; import { clickNode, enterNode, leaveNode } from '../actions/app-actions'; @@ -99,14 +100,14 @@ export default class Node extends React.Component { handleMouseClick(ev) { ev.stopPropagation(); - clickNode(ev.currentTarget.id); + clickNode(this.props.id, this.props.label, ReactDOM.findDOMNode(this).getBoundingClientRect()); } - handleMouseEnter(ev) { - enterNode(ev.currentTarget.id); + handleMouseEnter() { + enterNode(this.props.id); } - handleMouseLeave(ev) { - leaveNode(ev.currentTarget.id); + handleMouseLeave() { + leaveNode(this.props.id); } } diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 5208c3862..914bffa33 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -5,7 +5,7 @@ import React from 'react'; import { Map as makeMap } from 'immutable'; import timely from 'timely'; -import { clickCloseDetails } from '../actions/app-actions'; +import { clickBackground } from '../actions/app-actions'; import AppStore from '../stores/app-store'; import Edge from './edge'; import { EDGE_ID_SEPARATOR } from '../constants/naming'; @@ -357,7 +357,7 @@ export default class NodesChart extends React.Component { handleMouseClick() { if (!this.isZooming) { - clickCloseDetails(); + clickBackground(); } else { this.isZooming = false; } diff --git a/client/app/scripts/components/__tests__/node-details-test.js b/client/app/scripts/components/__tests__/node-details-test.js index c1b22a3e2..0984aaefd 100644 --- a/client/app/scripts/components/__tests__/node-details-test.js +++ b/client/app/scripts/components/__tests__/node-details-test.js @@ -4,6 +4,9 @@ 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'); +jest.dontMock('../node-details/node-details-table.js'); jest.dontMock('../../utils/color-utils'); jest.dontMock('../../utils/title-utils'); @@ -22,14 +25,14 @@ describe('NodeDetails', () => { }); it('shows n/a when node was not found', () => { - const c = TestUtils.renderIntoDocument(); + const c = TestUtils.renderIntoDocument(); const notFound = TestUtils.findRenderedDOMComponentWithClass(c, 'node-details-header-notavailable'); expect(notFound).toBeDefined(); }); it('show label of node with title', () => { nodes = nodes.set(nodeId, Immutable.fromJS({id: nodeId})); - details = {label_major: 'Node 1', tables: []}; + details = {label: 'Node 1'}; const c = TestUtils.renderIntoDocument(); diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index b6f3bfcd4..8259325b1 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -28,9 +28,9 @@ function getStateFromStores() { highlightedEdgeIds: AppStore.getHighlightedEdgeIds(), highlightedNodeIds: AppStore.getHighlightedNodeIds(), hostname: AppStore.getHostname(), - selectedNodeId: AppStore.getSelectedNodeId(), nodeDetails: AppStore.getNodeDetails(), nodes: AppStore.getNodes(), + selectedNodeId: AppStore.getSelectedNodeId(), topologies: AppStore.getTopologies(), topologiesLoaded: AppStore.isTopologiesLoaded(), version: AppStore.getVersion(), @@ -70,7 +70,7 @@ export default class App extends React.Component { } render() { - const showingDetails = this.state.selectedNodeId; + const showingDetails = this.state.nodeDetails.size > 0; const showingTerminal = this.state.controlPipe; const footer = `Version ${this.state.version} on ${this.state.hostname}`; // width of details panel blocking a view @@ -81,13 +81,12 @@ export default class App extends React.Component {
{showingDebugToolbar() && } {showingDetails &&
} {showingTerminal && }
diff --git a/client/app/scripts/components/details-card.js b/client/app/scripts/components/details-card.js new file mode 100644 index 000000000..256311213 --- /dev/null +++ b/client/app/scripts/components/details-card.js @@ -0,0 +1,58 @@ +import React from 'react'; + +import NodeDetails from './node-details'; + +// card dimensions in px +const marginTop = 24; +const marginBottom = 48; +const marginRight = 36; +const panelWidth = 420; +const offset = 8; + +export default class DetailsCard extends React.Component { + + constructor(props, context) { + super(props, context); + this.state = { + mounted: null + }; + } + + componentDidMount() { + setTimeout(() => { + this.setState({mounted: true}); + }); + } + + render() { + let transform; + const origin = this.props.origin; + const panelHeight = window.innerHeight - marginBottom - marginTop; + if (origin && !this.state.mounted) { + // render small panel near origin, will transition into normal panel after being mounted + const scaleY = origin.height / (window.innerHeight - marginBottom - marginTop) / 2; + const scaleX = origin.width / panelWidth / 2; + const centerX = window.innerWidth - marginRight - (panelWidth / 2); + const centerY = (panelHeight) / 2 + marginTop; + const dx = (origin.left + origin.width / 2) - centerX; + const dy = (origin.top + origin.height / 2) - centerY; + transform = `translate(${dx}px, ${dy}px) scale(${scaleX},${scaleY})`; + } else { + // stack effect: shift top cards to the left, shrink lower cards vertically + const shiftX = -1 * this.props.index * offset; + const position = this.props.cardCount - this.props.index - 1; // reverse index + const scaleY = position === 0 ? 1 : (panelHeight - 2 * offset * position) / panelHeight; + if (scaleY !== 1) { + transform = `translateX(${shiftX}px) scaleY(${scaleY})`; + } else { + // scale(1) is sometimes blurry + transform = `translateX(${shiftX}px)`; + } + } + return ( +
+ +
+ ); + } +} diff --git a/client/app/scripts/components/details.js b/client/app/scripts/components/details.js index f9cdaab60..44c6f2bbe 100644 --- a/client/app/scripts/components/details.js +++ b/client/app/scripts/components/details.js @@ -1,30 +1,21 @@ import React from 'react'; -import { clickCloseDetails } from '../actions/app-actions'; -import NodeDetails from './node-details'; +import DetailsCard from './details-card'; export default class Details extends React.Component { - constructor(props, context) { - super(props, context); - this.handleClickClose = this.handleClickClose.bind(this); - } - - handleClickClose(ev) { - ev.preventDefault(); - clickCloseDetails(); - } + // render all details as cards, later cards go on top render() { + const details = this.props.details.toIndexedSeq(); return ( -
-
-
-
- -
-
- -
+
+ {details.map((obj, index) => { + return ( + + ); + })}
); } diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index 7cf31f704..d08fb9576 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -2,11 +2,32 @@ import _ from 'lodash'; import React from 'react'; import NodeDetailsControls from './node-details/node-details-controls'; +import NodeDetailsHealth from './node-details/node-details-health'; +import NodeDetailsInfo from './node-details/node-details-info'; +import NodeDetailsRelatives from './node-details/node-details-relatives'; import NodeDetailsTable from './node-details/node-details-table'; -import { brightenColor, getNodeColorDark } from '../utils/color-utils'; +import { clickCloseDetails, clickShowTopologyForNode } from '../actions/app-actions'; +import { brightenColor, getNeutralColor, getNodeColorDark } from '../utils/color-utils'; import { resetDocumentTitle, setDocumentTitle } from '../utils/title-utils'; export default class NodeDetails extends React.Component { + + constructor(props, context) { + super(props, context); + this.handleClickClose = this.handleClickClose.bind(this); + this.handleShowTopologyForNode = this.handleShowTopologyForNode.bind(this); + } + + handleClickClose(ev) { + ev.preventDefault(); + clickCloseDetails(this.props.nodeId); + } + + handleShowTopologyForNode(ev) { + ev.preventDefault(); + clickShowTopologyForNode(this.props.topologyId, this.props.nodeId); + } + componentDidMount() { this.updateTitle(); } @@ -15,9 +36,25 @@ export default class NodeDetails extends React.Component { resetDocumentTitle(); } + renderTools() { + const showSwitchTopology = this.props.index > 0; + const topologyTitle = `View ${this.props.label} in ${this.props.topologyId}`; + + return ( +
+
+ {showSwitchTopology && } + +
+
+ ); + } + renderLoading() { const node = this.props.nodes.get(this.props.nodeId); - const nodeColor = getNodeColorDark(node.get('rank'), node.get('label_major')); + const label = node ? node.get('label_major') : this.props.label; + const nodeColor = node ? getNodeColorDark(node.get('rank'), label) : getNeutralColor(); + const tools = this.renderTools(); const styles = { header: { 'backgroundColor': nodeColor @@ -26,13 +63,14 @@ export default class NodeDetails extends React.Component { return (
+ {tools}

- {node.get('label_major')} + {label}

-
- {node.get('label_minor')} +
+ Loading...
@@ -46,38 +84,42 @@ export default class NodeDetails extends React.Component { } renderNotAvailable() { + const tools = this.renderTools(); return (
+ {tools}

- n/a + {this.props.label}

-
- {this.props.nodeId} +
+ n/a

- This node is not visible to Scope anymore. - The node will re-appear if it communicates again. + {this.props.label} is not visible to Scope when it is not communicating. + Details will become available here when it communicates again.

); } - render() { - const details = this.props.details; - const nodeExists = this.props.nodes && this.props.nodes.has(this.props.nodeId); + renderTable(table) { + const key = _.snakeCase(table.title); + return ; + } - if (details) { - return this.renderDetails(); + render() { + if (this.props.notFound) { + return this.renderNotAvailable(); } - if (!nodeExists) { - return this.renderNotAvailable(); + if (this.props.details) { + return this.renderDetails(); } return this.renderLoading(); @@ -85,8 +127,11 @@ export default class NodeDetails extends React.Component { renderDetails() { const details = this.props.details; + const showSummary = details.metadata !== undefined && details.metrics !== undefined; + const showControls = details.controls && details.controls.length > 0; const nodeColor = getNodeColorDark(details.rank, details.label_major); const {error, pending} = (this.props.controlStatus || {}); + const tools = this.renderTools(); const styles = { controls: { 'backgroundColor': brightenColor(nodeColor) @@ -98,18 +143,19 @@ export default class NodeDetails extends React.Component { return (
+ {tools}
-

- {details.label_major} +

+ {details.label}

-
- {details.label_minor} +
+ {details.parents && }
- {details.controls && details.controls.length > 0 &&
+ {showControls &&
}
- {details.tables.map(function(table) { - const key = _.snakeCase(table.title); - return ; + {showSummary &&
+
Status
+ {details.metrics && } + {details.metadata && } +
} + + {details.children && details.children.map(children => { + return ( +
+ +
+ ); })}
diff --git a/client/app/scripts/components/node-details/node-details-health-item.js b/client/app/scripts/components/node-details/node-details-health-item.js new file mode 100644 index 000000000..7ddeba23f --- /dev/null +++ b/client/app/scripts/components/node-details/node-details-health-item.js @@ -0,0 +1,17 @@ +import React from 'react'; + +import Sparkline from '../sparkline'; +import { formatMetric } from '../../utils/string-utils'; + +export default (props) => { + return ( +
+
{formatMetric(props.item.value, props.item)}
+
+ +
+
{props.item.label}
+
+ ); +}; diff --git a/client/app/scripts/components/node-details/node-details-health-overflow-item.js b/client/app/scripts/components/node-details/node-details-health-overflow-item.js new file mode 100644 index 000000000..1e59d6cfe --- /dev/null +++ b/client/app/scripts/components/node-details/node-details-health-overflow-item.js @@ -0,0 +1,14 @@ +import React from 'react'; + +import { formatMetric } from '../../utils/string-utils'; + +export default class NodeDetailsHealthOverflowItem extends React.Component { + render() { + return ( +
+
{formatMetric(this.props.item.value, this.props.item)}
+
{this.props.item.label}
+
+ ); + } +} diff --git a/client/app/scripts/components/node-details/node-details-health-overflow.js b/client/app/scripts/components/node-details/node-details-health-overflow.js new file mode 100644 index 000000000..260786fdc --- /dev/null +++ b/client/app/scripts/components/node-details/node-details-health-overflow.js @@ -0,0 +1,18 @@ +import React from 'react'; + +import NodeDetailsHealthOverflowItem from './node-details-health-overflow-item'; + +export default class NodeDetailsHealthOverflow extends React.Component { + render() { + const items = this.props.items.slice(0, 4); + + return ( +
+ {items.map(item => )} +
+ Show more +
+
+ ); + } +} diff --git a/client/app/scripts/components/node-details/node-details-health.js b/client/app/scripts/components/node-details/node-details-health.js new file mode 100644 index 000000000..3f4ecc444 --- /dev/null +++ b/client/app/scripts/components/node-details/node-details-health.js @@ -0,0 +1,42 @@ +import React from 'react'; + +import NodeDetailsHealthOverflow from './node-details-health-overflow'; +import NodeDetailsHealthItem from './node-details-health-item'; + +export default class NodeDetailsHealth extends React.Component { + + constructor(props, context) { + super(props, context); + this.state = { + expanded: false + }; + this.handleClickMore = this.handleClickMore.bind(this); + } + + handleClickMore(ev) { + ev.preventDefault(); + const expanded = !this.state.expanded; + this.setState({expanded}); + } + + render() { + const metrics = this.props.metrics || []; + const primeCutoff = metrics.length > 3 && !this.state.expanded ? 2 : metrics.length; + const primeMetrics = metrics.slice(0, primeCutoff); + const overflowMetrics = metrics.slice(primeCutoff); + const showOverflow = overflowMetrics.length > 0 && !this.state.expanded; + const showLess = this.state.expanded; + const flexWrap = showOverflow || !this.state.expanded ? 'nowrap' : 'wrap'; + const justifyContent = showOverflow || !this.state.expanded ? 'space-around' : 'flex-start'; + + return ( +
+ {primeMetrics.map(item => { + return ; + })} + {showOverflow && } + {showLess &&
show less
} +
+ ); + } +} diff --git a/client/app/scripts/components/node-details/node-details-info.js b/client/app/scripts/components/node-details/node-details-info.js new file mode 100644 index 000000000..1f9b2e27d --- /dev/null +++ b/client/app/scripts/components/node-details/node-details-info.js @@ -0,0 +1,24 @@ +import React from 'react'; + +export default class NodeDetailsInfo extends React.Component { + render() { + return ( +
+ {this.props.metadata && this.props.metadata.map(field => { + return ( +
+
+ {field.label} +
+
+
+ {field.value} +
+
+
+ ); + })} +
+ ); + } +} diff --git a/client/app/scripts/components/node-details/node-details-relatives-link.js b/client/app/scripts/components/node-details/node-details-relatives-link.js new file mode 100644 index 000000000..315f7d026 --- /dev/null +++ b/client/app/scripts/components/node-details/node-details-relatives-link.js @@ -0,0 +1,27 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { clickRelative } from '../../actions/app-actions'; + +export default class NodeDetailsRelativesLink extends React.Component { + + constructor(props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick(ev) { + ev.preventDefault(); + clickRelative(this.props.id, this.props.topologyId, this.props.label, + ReactDOM.findDOMNode(this).getBoundingClientRect()); + } + + render() { + const title = `View in ${this.props.topologyId}: ${this.props.label}`; + return ( + + {this.props.label} + + ); + } +} diff --git a/client/app/scripts/components/node-details/node-details-relatives.js b/client/app/scripts/components/node-details/node-details-relatives.js new file mode 100644 index 000000000..b0813bc27 --- /dev/null +++ b/client/app/scripts/components/node-details/node-details-relatives.js @@ -0,0 +1,40 @@ +import React from 'react'; + +import NodeDetailsRelativesLink from './node-details-relatives-link'; + +export default class NodeDetailsRelatives extends React.Component { + + constructor(props, context) { + super(props, context); + this.DEFAULT_LIMIT = 5; + this.state = { + limit: this.DEFAULT_LIMIT + }; + this.handleLimitClick = this.handleLimitClick.bind(this); + } + + handleLimitClick(ev) { + ev.preventDefault(); + const limit = this.state.limit ? 0 : this.DEFAULT_LIMIT; + this.setState({limit: limit}); + } + + render() { + let relatives = this.props.relatives; + const limited = this.state.limit > 0 && relatives.length > this.state.limit; + const showLimitAction = limited || (this.state.limit === 0 && relatives.length > this.DEFAULT_LIMIT); + const limitActionText = limited ? 'Show more' : 'Show less'; + if (limited) { + relatives = relatives.slice(0, this.state.limit); + } + + return ( +
+ {relatives.map(relative => { + return ; + })} + {showLimitAction && {limitActionText}} +
+ ); + } +} diff --git a/client/app/scripts/components/node-details/node-details-table-node-link.js b/client/app/scripts/components/node-details/node-details-table-node-link.js new file mode 100644 index 000000000..3e2c1f554 --- /dev/null +++ b/client/app/scripts/components/node-details/node-details-table-node-link.js @@ -0,0 +1,40 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { clickRelative } from '../../actions/app-actions'; + +export default class NodeDetailsTableNodeLink extends React.Component { + + constructor(props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick(ev) { + ev.preventDefault(); + clickRelative(this.props.id, this.props.topologyId, this.props.label, + ReactDOM.findDOMNode(this).getBoundingClientRect()); + } + + render() { + const titleLines = [`${this.props.label} (${this.props.topologyId})`]; + this.props.metadata.forEach(data => { + titleLines.push(`${data.label}: ${data.value}`); + }); + const title = titleLines.join('\n'); + + if (this.props.linkable) { + return ( + + {this.props.label} + + ); + } + return ( + + {this.props.label} + + ); + } +} diff --git a/client/app/scripts/components/node-details/node-details-table-row-number.js b/client/app/scripts/components/node-details/node-details-table-row-number.js deleted file mode 100644 index d41eaa10a..000000000 --- a/client/app/scripts/components/node-details/node-details-table-row-number.js +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -export default class NodeDetailsTableRowNumber extends React.Component { - render() { - const row = this.props.row; - return ( -
-
{row.value_major}
-
{row.value_minor}
-
- ); - } -} diff --git a/client/app/scripts/components/node-details/node-details-table-row-sparkline.js b/client/app/scripts/components/node-details/node-details-table-row-sparkline.js deleted file mode 100644 index f40b77d0f..000000000 --- a/client/app/scripts/components/node-details/node-details-table-row-sparkline.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; - -import Sparkline from '../sparkline'; - -export default class NodeDetailsTableRowSparkline extends React.Component { - render() { - const row = this.props.row; - return ( -
-
{row.value_major}
-
{row.value_minor}
-
- ); - } -} diff --git a/client/app/scripts/components/node-details/node-details-table-row-value.js b/client/app/scripts/components/node-details/node-details-table-row-value.js deleted file mode 100644 index 1dc765eb5..000000000 --- a/client/app/scripts/components/node-details/node-details-table-row-value.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -export default class NodeDetailsTableRowValue extends React.Component { - render() { - const row = this.props.row; - return ( -
-
- {row.value_major} -
- {row.value_minor &&
- {row.value_minor} -
} -
- ); - } -} diff --git a/client/app/scripts/components/node-details/node-details-table.js b/client/app/scripts/components/node-details/node-details-table.js index b95281b26..26b290c99 100644 --- a/client/app/scripts/components/node-details/node-details-table.js +++ b/client/app/scripts/components/node-details/node-details-table.js @@ -1,33 +1,156 @@ +import _ from 'lodash'; import React from 'react'; -import NodeDetailsTableRowValue from './node-details-table-row-value'; -import NodeDetailsTableRowNumber from './node-details-table-row-number'; -import NodeDetailsTableRowSparkline from './node-details-table-row-sparkline'; +import NodeDetailsTableNodeLink from './node-details-table-node-link'; +import { formatMetric } from '../../utils/string-utils'; export default class NodeDetailsTable extends React.Component { - render() { - return ( -
-

- {this.props.title} -

- {this.props.rows.map(function(row) { - let valueComponent; - if (row.value_type === 'numeric') { - valueComponent = ; - } else if (row.value_type === 'sparkline') { - valueComponent = ; - } else { - valueComponent = ; - } - return ( -
-
{row.key}
- {valueComponent} -
- ); - })} + constructor(props, context) { + super(props, context); + this.DEFAULT_LIMIT = 5; + this.state = { + limit: this.DEFAULT_LIMIT, + sortedDesc: true, + sortBy: null + }; + this.handleLimitClick = this.handleLimitClick.bind(this); + this.getValueForSortBy = this.getValueForSortBy.bind(this); + } + + handleHeaderClick(ev, headerId) { + ev.preventDefault(); + const sortedDesc = headerId === this.state.sortBy ? !this.state.sortedDesc : this.state.sortedDesc; + const sortBy = headerId; + this.setState({sortBy, sortedDesc}); + } + + handleLimitClick(ev) { + ev.preventDefault(); + const limit = this.state.limit ? 0 : this.DEFAULT_LIMIT; + this.setState({limit}); + } + + getDefaultSortBy() { + // first metric + return _.get(this.props.nodes, [0, 'metrics', 0, 'id']); + } + + getMetaDataSorters() { + // returns an array of sorters that will take a node + return _.get(this.props.nodes, [0, 'metadata'], []).map((field, index) => { + return node => node.metadata[index] ? node.metadata[index].value : null; + }); + } + + getValueForSortBy(node) { + // return the node's value based on the sortBy field + const sortBy = this.state.sortBy || this.getDefaultSortBy(); + if (sortBy !== null) { + const field = _.union(node.metrics, node.metadata).find(f => f.id === sortBy); + if (field) { + return field.value; + } + } + return 0; + } + + getValuesForNode(node) { + const values = {}; + ['metrics', 'metadata'].forEach(collection => { + if (node[collection]) { + node[collection].forEach(field => { + values[field.id] = field; + }); + } + }); + return values; + } + + renderHeaders() { + if (this.props.nodes && this.props.nodes.length > 0) { + let headers = [{id: 'label', label: this.props.label}]; + // gather header labels from metrics and metadata + const firstValues = this.getValuesForNode(this.props.nodes[0]); + headers = headers.concat(this.props.columns.map(column => ({id: column, label: firstValues[column].label}))); + const defaultSortBy = this.getDefaultSortBy(); + + return ( + + {headers.map(header => { + const headerClasses = ['node-details-table-header', 'truncate']; + const onHeaderClick = ev => { + this.handleHeaderClick(ev, header.id); + }; + // sort by first metric by default + const isSorted = this.state.sortBy !== null ? header.id === this.state.sortBy : header.id === defaultSortBy; + const isSortedDesc = isSorted && this.state.sortedDesc; + const isSortedAsc = isSorted && !isSortedDesc; + if (isSorted) { + headerClasses.push('node-details-table-header-sorted'); + } + return ( + + {isSortedAsc && } + {isSortedDesc && } + {header.label} + + ); + })} + + ); + } + return ''; + } + + renderValues(node) { + const fields = this.getValuesForNode(node); + return this.props.columns.map(col => { + const field = fields[col]; + if (field) { + return ( + + {formatMetric(field.value, field)} + + ); + } + }); + } + + render() { + const headers = this.renderHeaders(); + let nodes = _.sortByAll(this.props.nodes, this.getValueForSortBy, 'label', this.getMetaDataSorters()); + const limited = nodes && this.state.limit > 0 && nodes.length > this.state.limit; + const showLimitAction = nodes && (limited || (this.state.limit === 0 && nodes.length > this.DEFAULT_LIMIT)); + const limitActionText = limited ? 'Show more' : 'Show less'; + if (this.state.sortedDesc) { + nodes.reverse(); + } + if (nodes && limited) { + nodes = nodes.slice(0, this.state.limit); + } + + return ( +
+ + + {headers} + + + {nodes && nodes.map(node => { + const values = this.renderValues(node); + return ( + + + {values} + + ); + })} + +
+ +
+ {showLimitAction &&
{limitActionText}
}
); } diff --git a/client/app/scripts/components/sparkline.js b/client/app/scripts/components/sparkline.js index f72fbe748..6831d5654 100644 --- a/client/app/scripts/components/sparkline.js +++ b/client/app/scripts/components/sparkline.js @@ -116,7 +116,7 @@ export default class Sparkline extends React.Component { } Sparkline.defaultProps = { - width: 100, + width: 80, height: 16, strokeColor: '#7d7da8', strokeWidth: '0.5px', diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index a4019dea4..0cdf0b39a 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -3,9 +3,12 @@ import _ from 'lodash'; const ACTION_TYPES = [ 'CHANGE_TOPOLOGY_OPTION', 'CLEAR_CONTROL_ERROR', + 'CLICK_BACKGROUND', 'CLICK_CLOSE_DETAILS', 'CLICK_CLOSE_TERMINAL', 'CLICK_NODE', + 'CLICK_RELATIVE', + 'CLICK_SHOW_TOPOLOGY_FOR_NODE', 'CLICK_TERMINAL', 'CLICK_TOPOLOGY', 'CLOSE_WEBSOCKET', @@ -23,6 +26,7 @@ const ACTION_TYPES = [ 'RECEIVE_NODE_DETAILS', 'RECEIVE_NODES', 'RECEIVE_NODES_DELTA', + 'RECEIVE_NOT_FOUND', 'RECEIVE_TOPOLOGIES', 'RECEIVE_API_DETAILS', 'RECEIVE_ERROR', diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js index 760426664..ca31a2fdc 100644 --- a/client/app/scripts/stores/__tests__/app-store-test.js +++ b/client/app/scripts/stores/__tests__/app-store-test.js @@ -51,6 +51,22 @@ describe('AppStore', function() { 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' @@ -335,4 +351,77 @@ describe('AppStore', function() { registeredCallback(ClickTopologyAction); expect(AppStore.isTopologyEmpty()).toBeFalsy(); }); + + // selection of relatives + + it('keeps relatives as a stack', function() { + 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', function() { + 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', function() { + registeredCallback(ClickTopologyAction); + registeredCallback(ReceiveTopologiesAction); + expect(AppStore.getCurrentTopology().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().name).toBe('Topo2'); + }); }); diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index fca513c1e..db93167d4 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -16,7 +16,7 @@ const error = debug('scope:error'); // Helpers -function findCurrentTopology(subTree, topologyId) { +function findTopologyById(subTree, topologyId) { let foundTopology; _.each(subTree, function(topology) { @@ -24,7 +24,7 @@ function findCurrentTopology(subTree, topologyId) { foundTopology = topology; } if (!foundTopology) { - foundTopology = findCurrentTopology(topology.sub_topologies, topologyId); + foundTopology = findTopologyById(topology.sub_topologies, topologyId); } if (foundTopology) { return false; @@ -57,19 +57,22 @@ let hostname = '...'; let version = '...'; let mouseOverEdgeId = null; let mouseOverNodeId = null; +let nodeDetails = makeOrderedMap(); // nodeId -> details let nodes = makeOrderedMap(); // nodeId -> node -let nodeDetails = null; let selectedNodeId = null; let topologies = []; let topologiesLoaded = false; +let topologyUrlsById = makeOrderedMap(); // topologyId -> topologyUrl let routeSet = false; -let controlPipe = null; +let controlPipes = makeOrderedMap(); // pipeId -> controlPipe let websocketClosed = true; +// adds ID field to topology (based on last part of URL path) and save urls in +// map for easy lookup function processTopologies(topologyList) { - // adds ID field to topology, based on last part of URL path _.each(topologyList, function(topology) { topology.id = topology.url.split('/').pop(); + topologyUrlsById = topologyUrlsById.set(topology.id, topology.url); processTopologies(topology.sub_topologies); }); return topologyList; @@ -77,7 +80,7 @@ function processTopologies(topologyList) { function setTopology(topologyId) { currentTopologyId = topologyId; - currentTopology = findCurrentTopology(topologies, topologyId); + currentTopology = findTopologyById(topologies, topologyId); } function setDefaultTopologyOptions(topologyList) { @@ -102,10 +105,24 @@ function setDefaultTopologyOptions(topologyList) { }); } -function deSelectNode() { - selectedNodeId = null; - nodeDetails = null; - controlPipe = null; +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 => { + return pipe.nodeId !== popNodeId; + }); + nodeDetails = nodeDetails.delete(popNodeId); + } + if (nodeDetails.size === 0 || selectedNodeId === nodeId) { + selectedNodeId = null; + } +} + +function closeAllNodeDetails() { + while (nodeDetails.size) { + closeNodeDetails(); + } } // Store API @@ -115,9 +132,10 @@ export class AppStore extends Store { // keep at the top getAppState() { return { - topologyId: currentTopologyId, - selectedNodeId: this.getSelectedNodeId(), controlPipe: this.getControlPipe(), + nodeDetails: this.getNodeDetailsState(), + selectedNodeId: selectedNodeId, + topologyId: currentTopologyId, topologyOptions: topologyOptions.toJS() // all options }; } @@ -148,7 +166,7 @@ export class AppStore extends Store { } getControlPipe() { - return controlPipe; + return controlPipes.last(); } getCurrentTopology() { @@ -214,6 +232,12 @@ export class AppStore extends Store { return nodeDetails; } + getNodeDetailsState() { + return nodeDetails.toIndexedSeq().map(details => { + return {id: details.id, label: details.label, topologyId: details.topologyId}; + }).toJS(); + } + getNodes() { return nodes; } @@ -226,6 +250,10 @@ export class AppStore extends Store { return topologies; } + getTopologyUrlsById() { + return topologyUrlsById; + } + getVersion() { return version; } @@ -269,27 +297,76 @@ export class AppStore extends Store { this.__emitChange(); break; + case ActionTypes.CLICK_BACKGROUND: + closeAllNodeDetails(); + this.__emitChange(); + break; + case ActionTypes.CLICK_CLOSE_DETAILS: - deSelectNode(); + closeNodeDetails(payload.nodeId); this.__emitChange(); break; case ActionTypes.CLICK_CLOSE_TERMINAL: - controlPipe = null; + controlPipes = controlPipes.clear(); this.__emitChange(); break; case ActionTypes.CLICK_NODE: - deSelectNode(); - if (payload.nodeId !== selectedNodeId) { - // select new node if it's not the same (in that case just delesect) + 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_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_SHOW_TOPOLOGY_FOR_NODE: + nodeDetails = nodeDetails.filter((v, k) => k === payload.nodeId); + selectedNodeId = payload.nodeId; + if (payload.topologyId !== currentTopologyId) { + setTopology(payload.topologyId); + nodes = nodes.clear(); + } + this.__emitChange(); + break; + case ActionTypes.CLICK_TOPOLOGY: - deSelectNode(); + closeAllNodeDetails(); if (payload.topologyId !== currentTopologyId) { setTopology(payload.topologyId); nodes = nodes.clear(); @@ -302,6 +379,11 @@ export class AppStore extends Store { this.__emitChange(); break; + case ActionTypes.DESELECT_NODE: + closeNodeDetails(); + this.__emitChange(); + break; + case ActionTypes.DO_CONTROL: controlStatus = controlStatus.set(payload.nodeId, makeMap({ pending: true, @@ -320,11 +402,6 @@ export class AppStore extends Store { this.__emitChange(); break; - case ActionTypes.DESELECT_NODE: - deSelectNode(); - this.__emitChange(); - break; - case ActionTypes.LEAVE_EDGE: mouseOverEdgeId = null; this.__emitChange(); @@ -360,16 +437,17 @@ export class AppStore extends Store { break; case ActionTypes.RECEIVE_CONTROL_PIPE: - controlPipe = { + controlPipes = controlPipes.set(payload.pipeId, { id: payload.pipeId, + nodeId: payload.nodeId, raw: payload.rawTty - }; + }); this.__emitChange(); break; case ActionTypes.RECEIVE_CONTROL_PIPE_STATUS: - if (controlPipe) { - controlPipe.status = payload.status; + if (controlPipes.has(payload.pipeId)) { + controlPipes = controlPipes.setIn([payload.pipeId, 'status'], payload.status); this.__emitChange(); } break; @@ -382,8 +460,12 @@ export class AppStore extends Store { case ActionTypes.RECEIVE_NODE_DETAILS: errorUrl = null; // disregard if node is not selected anymore - if (payload.details.id === selectedNodeId) { - nodeDetails = payload.details; + if (nodeDetails.has(payload.details.id)) { + nodeDetails = nodeDetails.update(payload.details.id, obj => { + obj.notFound = false; + obj.details = payload.details; + return obj; + }); } this.__emitChange(); break; @@ -415,7 +497,9 @@ export class AppStore extends Store { // update existing nodes _.each(payload.delta.update, function(node) { - nodes = nodes.set(node.id, nodes.get(node.id).merge(makeNode(node))); + if (nodes.has(node.id)) { + nodes = nodes.set(node.id, nodes.get(node.id).merge(makeNode(node))); + } }); // add new nodes @@ -426,8 +510,19 @@ export class AppStore extends Store { this.__emitChange(); break; + case ActionTypes.RECEIVE_NOT_FOUND: + if (nodeDetails.has(payload.nodeId)) { + nodeDetails = nodeDetails.update(payload.nodeId, obj => { + obj.notFound = true; + return obj; + }); + this.__emitChange(); + } + break; + case ActionTypes.RECEIVE_TOPOLOGIES: errorUrl = null; + topologyUrlsById = topologyUrlsById.clear(); topologies = processTopologies(payload.topologies); setTopology(currentTopologyId); // only set on first load, if options are not already set via route @@ -453,7 +548,18 @@ export class AppStore extends Store { setTopology(payload.state.topologyId); setDefaultTopologyOptions(topologies); selectedNodeId = payload.state.selectedNodeId; - controlPipe = payload.state.controlPipe; + if (payload.state.controlPipe) { + controlPipes = makeOrderedMap( + [[payload.state.controlPipe.pipeId, payload.state.controlPipe]] + ); + } else { + controlPipes = controlPipes.clear(); + } + if (payload.state.nodeDetails) { + nodeDetails = makeOrderedMap(payload.state.nodeDetails.map(obj => [obj.id, obj])); + } else { + nodeDetails = nodeDetails.clear(); + } topologyOptions = Immutable.fromJS(payload.state.topologyOptions) || topologyOptions; this.__emitChange(); diff --git a/client/app/scripts/utils/__tests__/string-utils-test.js b/client/app/scripts/utils/__tests__/string-utils-test.js new file mode 100644 index 000000000..4bf7d418e --- /dev/null +++ b/client/app/scripts/utils/__tests__/string-utils-test.js @@ -0,0 +1,13 @@ +jest.dontMock('../string-utils'); + +describe('StringUtils', function() { + const StringUtils = require('../string-utils'); + + describe('formatMetric', function() { + const formatMetric = StringUtils.formatMetric; + + it('it should render 0', function() { + expect(formatMetric(0)).toBe(0); + }); + }); +}); diff --git a/client/app/scripts/utils/string-utils.js b/client/app/scripts/utils/string-utils.js new file mode 100644 index 000000000..e317641e9 --- /dev/null +++ b/client/app/scripts/utils/string-utils.js @@ -0,0 +1,31 @@ +import React from 'react'; +import filesize from 'filesize'; + +const formatters = { + filesize(value) { + const obj = filesize(value, {output: 'object'}); + return formatters.metric(obj.value, obj.suffix); + }, + + number(value) { + return value; + }, + + percent(value) { + return formatters.metric(value, '%'); + }, + + metric(text, unit) { + return ( + + {text} + {unit} + + ); + } +}; + +export function formatMetric(value, opts) { + const formatter = opts && formatters[opts.format] ? opts.format : 'number'; + return formatters[formatter](value); +} diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 2269aa8c9..b23d2dba1 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -4,7 +4,7 @@ import reqwest from 'reqwest'; import { clearControlError, closeWebsocket, openWebsocket, receiveError, receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError, receiveControlPipe, receiveControlPipeStatus, receiveControlSuccess, - receiveTopologies } from '../actions/app-actions'; + receiveTopologies, receiveNotFound } from '../actions/app-actions'; const wsProto = location.protocol === 'https:' ? 'wss' : 'ws'; const wsUrl = wsProto + '://' + location.host + location.pathname.replace(/\/$/, ''); @@ -118,23 +118,33 @@ export function getNodesDelta(topologyUrl, options) { } } -export function getNodeDetails(topologyUrl, nodeId) { - if (topologyUrl && nodeId) { - const url = [topologyUrl, '/', encodeURIComponent(nodeId)] +export function getNodeDetails(topologyUrlsById, nodeMap) { + // get details for all opened nodes + const obj = nodeMap.last(); + if (obj && topologyUrlsById.has(obj.topologyId)) { + const topologyUrl = topologyUrlsById.get(obj.topologyId); + const url = [topologyUrl, '/', encodeURIComponent(obj.id)] .join('').substr(1); reqwest({ url: url, success: function(res) { - receiveNodeDetails(res.node); + // make sure node is still selected + if (nodeMap.has(res.node.id)) { + receiveNodeDetails(res.node); + } }, error: function(err) { log('Error in node details request: ' + err.responseText); // dont treat missing node as error - if (err.status !== 404) { + if (err.status === 404) { + receiveNotFound(obj.id); + } else { receiveError(topologyUrl); } } }); + } else { + log('No details or url found for ', obj); } } diff --git a/client/app/styles/main.less b/client/app/styles/main.less index ecd0178d2..51082f9c6 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -29,6 +29,7 @@ @text-color: lighten(@primary-color, 10%); @text-secondary-color: lighten(@primary-color, 33%); @text-tertiary-color: lighten(@primary-color, 50%); +@border-light-color: lighten(@primary-color, 66%); @text-darker-color: @primary-color; @white: @background-secondary-color; @@ -338,20 +339,38 @@ h2 { } -#details { - position: fixed; - z-index: 1024; - display: block; - right: @details-window-padding-left; - top: 24px; - bottom: 48px; - width: @details-window-width; +.details { + &-wrapper { + position: fixed; + z-index: 1024; + right: @details-window-padding-left; + top: 24px; + bottom: 48px; + width: @details-window-width; + transition: transform 0.33333s cubic-bezier(0,0,0.21,1); + } +} - .details-tools-wrapper { +.node-details { + height: 100%; + background-color: rgba(255, 255, 255, 0.86); + display: flex; + flex-flow: column; + margin-bottom: 12px; + padding-bottom: 2px; + border-radius: 2px; + background-color: #fff; + .shadow-2; + + &:last-child { + margin-bottom: 0; + } + + &-tools-wrapper { position: relative; } - .details-tools { + &-tools { position: absolute; top: 6px; right: 8px; @@ -374,31 +393,11 @@ h2 { } } - .details-wrapper { - height: 100%; - padding-bottom: 8px; - border-radius: 2px; - background-color: #fff; - .shadow-2; - } -} - -.node-details { - height: 100%; - width: 100%; - background-color: rgba(255, 255, 255, 0.86); - display: flex; - flex-flow: column; - &-header { .colorable; &-wrapper { - padding: 36px 36px 16px 36px; - } - - &-row { - display: flex; + padding: 36px 36px 8px 36px; } &-label { @@ -406,12 +405,6 @@ h2 { margin: 0; width: 348px; padding-top: 0; - - &-minor { - flex: 1; - font-size: 120%; - color: @white; - } } .details-tools { @@ -426,11 +419,50 @@ h2 { } + &-relatives { + margin-top: 4px; + font-size: 120%; + color: @white; + + &-link { + .truncate; + .palable; + display: inline-block; + margin-right: 0.5em; + cursor: pointer; + text-decoration: underline; + opacity: 0.8; + max-width: 12em; + + &:hover { + opacity: 1; + } + } + + &-more { + .palable; + padding: 0 2px; + text-transform: uppercase; + cursor: pointer; + opacity: 0.7; + font-size: 60%; + font-weight: bold; + display: inline-block; + position: relative; + top: -5px; + + &:hover { + opacity: 1; + } + } + } + &-controls { white-space: nowrap; + padding: 8px 0; &-wrapper { - padding: 8px 36px 8px 32px; + padding: 0 36px 0 32px; } .node-control-button { @@ -478,8 +510,7 @@ h2 { &-content { flex: 1; padding: 0 36px 0 36px; - overflow-y: scroll; - width: 100%; + overflow-y: auto; &-info { margin-top: 16px; @@ -492,36 +523,206 @@ h2 { color: @background-medium-color; opacity: 0.7; } + + &-section { + margin: 16px 0; + + &-header { + text-transform: uppercase; + font-size: 90%; + color: @text-tertiary-color; + padding: 4px 0; + } + } + } + + &-health { + display: flex; + justify-content: space-around; + align-content: center; + text-align: center; + + &-expand { + .palable; + margin: 4px 16px 0; + border-top: 1px solid @border-light-color; + text-transform: uppercase; + font-size: 80%; + color: @text-secondary-color; + width: 100%; + cursor: pointer; + opacity: 0.8; + + &:hover { + opacity: 1.0; + } + } + + &-overflow { + .palable; + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + border-left: 1px solid @border-light-color; + opacity: 0.85; + cursor: pointer; + position: relative; + padding-bottom: 16px; + + &:hover { + opacity: 1; + } + + &-expand { + text-transform: uppercase; + font-size: 70%; + color: @text-secondary-color; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + } + + &-item { + padding: 4px 8px; + line-height: 1.2; + flex-basis: 48%; + + &-value { + color: @text-secondary-color; + font-size: 100%; + } + + &-label { + color: @text-secondary-color; + text-transform: uppercase; + font-size: 60%; + } + } + } + + &-item { + padding: 8px 16px; + width: 33%; + + &-label { + color: @text-secondary-color; + text-transform: uppercase; + font-size: 80%; + } + + &-value { + color: @text-secondary-color; + font-size: 150%; + padding-bottom: 0.5em; + } + } + } + + &-info { + margin: 16px 0; + + &-field { + display: flex; + align-items: baseline; + + &-label { + text-align: right; + width: 30%; + color: @text-secondary-color; + padding: 0 0.5em 0 0; + white-space: nowrap; + text-transform: uppercase; + font-size: 80%; + + &::after { + content: ':'; + } + } + + &-value { + font-size: 105%; + flex: 1; + color: @text-color; + } + } } &-table { + width: 100%; + border-spacing: 0; + /* need fixed for truncating, but that does not extend wide columns dynamically */ + table-layout: fixed; - &:last-child { - margin-bottom: 1em; + &-wrapper { + margin: 24px 0; } - &-title { + &-header { text-transform: uppercase; - margin-bottom: 0; - color: @text-secondary-color; - font-size: 100%; - } + color: @text-tertiary-color; + font-size: 90%; + text-align: right; + cursor: pointer; + padding: 0; - &-row { - white-space: nowrap; - clear: left; - - &-key { - width: 11em; - float: left; + &-sorted { + color: @text-secondary-color; } - &-value-major { - margin-right: 0.5em; + &-sorter { + margin: 0 0.25em; + } + + &:first-child { + margin-right: 0; + text-align: left; + } + } + + &-more { + .palable; + padding: 2px 0; + text-transform: uppercase; + cursor: pointer; + color: @text-secondary-color; + opacity: 0.7; + font-size: 80%; + font-weight: bold; + + &:hover { + opacity: 1; + } + } + + &-node { + font-size: 105%; + line-height: 1.5; + + > * { + padding: 0; + } + + &-link { + .palable; + text-decoration: underline; + cursor: pointer; + opacity: 0.8; + + &:hover { + opacity: 1; + } + } + + &-value { + flex: 1; + margin-left: 0.5em; + text-align: right; } &-value-scalar { - width: 2em; + // width: 2em; text-align: right; margin-right: 0.5em; } @@ -665,6 +866,12 @@ h2 { visibility: hidden; } +.metric { + &-unit { + padding-left: 0.25em; + } +} + .sidebar { position: fixed; bottom: 16px; diff --git a/client/package.json b/client/package.json index 435b617d4..bdb70ca42 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,7 @@ "d3": "~3.5.5", "dagre": "0.7.4", "debug": "~2.2.0", + "filesize": "3.1.4", "flux": "2.1.1", "font-awesome": "4.4.0", "font-awesome-webpack": "0.0.4", From 9e61ad37f49aa4eae030a9e7c06d3f77849eb2a1 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Fri, 15 Jan 2016 15:25:41 -0800 Subject: [PATCH 03/12] Get Pipes working again. They stopped working because of the change to container node IDs (and rendered nodes IDs for containers). The UI was comparing the IDs, which was never safe. I have just removed that code. This does leave the possibility of us having the pipe control operation take a long time, the user navigate to a different node, and then the terminal pop up, but I think thats better that teaching the UI to understand the format of the node IDs. --- client/app/scripts/actions/app-actions.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 436d9efe3..e8cd058dd 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -1,5 +1,3 @@ -import debug from 'debug'; - import AppDispatcher from '../dispatcher/app-dispatcher'; import ActionTypes from '../constants/action-types'; import { updateRoute } from '../utils/router-utils'; @@ -7,8 +5,6 @@ import { doControlRequest, getNodesDelta, getNodeDetails, getTopologies, deletePipe } from '../utils/web-api-utils'; import AppStore from '../stores/app-store'; -const log = debug('scope:app-actions'); - export function changeTopologyOption(option, value, topologyId) { AppDispatcher.dispatch({ type: ActionTypes.CHANGE_TOPOLOGY_OPTION, From 3cda3289760bf858a65bb006db41619f157bf591 Mon Sep 17 00:00:00 2001 From: Paul Bellamy Date: Mon, 18 Jan 2016 10:36:31 +0000 Subject: [PATCH 04/12] remove hostID from container image node ids --- probe/docker/container.go | 2 +- probe/docker/container_test.go | 2 +- probe/docker/reporter.go | 4 +--- probe/docker/reporter_test.go | 4 +--- probe/docker/tagger.go | 6 ++---- probe/docker/tagger_test.go | 4 ++-- prog/probe.go | 2 +- render/mapping.go | 2 +- report/id.go | 4 ++-- test/fixture/report_fixture.go | 4 ++-- 10 files changed, 14 insertions(+), 20 deletions(-) diff --git a/probe/docker/container.go b/probe/docker/container.go index 16e8ea3fa..0abe6baec 100644 --- a/probe/docker/container.go +++ b/probe/docker/container.go @@ -334,7 +334,7 @@ func (c *container) GetNode(hostID string, localAddrs []net.IP) report.Node { ).WithMetrics( c.metrics(), ).WithParents(report.Sets{ - "container_image": report.MakeStringSet(report.MakeContainerImageNodeID(hostID, c.container.Image)), + "container_image": report.MakeStringSet(report.MakeContainerImageNodeID(c.container.Image)), }) if c.container.State.Paused { diff --git a/probe/docker/container_test.go b/probe/docker/container_test.go index 609fbb05e..0dc624596 100644 --- a/probe/docker/container_test.go +++ b/probe/docker/container_test.go @@ -93,7 +93,7 @@ func TestContainer(t *testing.T) { "cpu_total_usage": report.MakeMetric(), "memory_usage": report.MakeMetric().Add(now, 12345), }).WithParents(report.Sets{ - "container_image": report.MakeStringSet(report.MakeContainerImageNodeID("scope", "baz")), + "container_image": report.MakeStringSet(report.MakeContainerImageNodeID("baz")), }) test.Poll(t, 100*time.Millisecond, want, func() interface{} { node := c.GetNode("scope", []net.IP{}) diff --git a/probe/docker/reporter.go b/probe/docker/reporter.go index bef4d1160..fd7800f7d 100644 --- a/probe/docker/reporter.go +++ b/probe/docker/reporter.go @@ -117,8 +117,6 @@ func (r *Reporter) containerImageTopology() report.Topology { r.registry.WalkImages(func(image *docker_client.APIImages) { nmd := report.MakeNodeWith(map[string]string{ ImageID: image.ID, - }).WithParents(report.Sets{ - "host": report.MakeStringSet(report.MakeHostNodeID(r.hostID)), }) AddLabels(nmd, image.Labels) @@ -126,7 +124,7 @@ func (r *Reporter) containerImageTopology() report.Topology { nmd.Metadata[ImageName] = image.RepoTags[0] } - nodeID := report.MakeContainerImageNodeID(r.hostID, image.ID) + nodeID := report.MakeContainerImageNodeID(image.ID) result.AddNode(nodeID, nmd) }) diff --git a/probe/docker/reporter_test.go b/probe/docker/reporter_test.go index c19eee696..dc944e6b6 100644 --- a/probe/docker/reporter_test.go +++ b/probe/docker/reporter_test.go @@ -101,11 +101,9 @@ func TestReporter(t *testing.T) { } want.ContainerImage = report.Topology{ Nodes: report.Nodes{ - report.MakeContainerImageNodeID("host1", "baz"): report.MakeNodeWith(map[string]string{ + report.MakeContainerImageNodeID("baz"): report.MakeNodeWith(map[string]string{ docker.ImageID: "baz", docker.ImageName: "bang", - }).WithParents(report.Sets{ - "host": report.MakeStringSet(report.MakeHostNodeID("host1")), }), }, Controls: report.Controls{}, diff --git a/probe/docker/tagger.go b/probe/docker/tagger.go index 1c7d3564e..320ea9991 100644 --- a/probe/docker/tagger.go +++ b/probe/docker/tagger.go @@ -23,15 +23,13 @@ var ( // nodes that have a PID. type Tagger struct { registry Registry - hostID string procWalker process.Walker } // NewTagger returns a usable Tagger. -func NewTagger(registry Registry, hostID string, procWalker process.Walker) *Tagger { +func NewTagger(registry Registry, procWalker process.Walker) *Tagger { return &Tagger{ registry: registry, - hostID: hostID, procWalker: procWalker, } } @@ -88,7 +86,7 @@ func (t *Tagger) tag(tree process.Tree, topology *report.Topology) { ContainerID: c.ID(), }).WithParents(report.Sets{ "container": report.MakeStringSet(report.MakeContainerNodeID(c.ID())), - "container_image": report.MakeStringSet(report.MakeContainerImageNodeID(t.hostID, c.Image())), + "container_image": report.MakeStringSet(report.MakeContainerImageNodeID(c.Image())), })) } } diff --git a/probe/docker/tagger_test.go b/probe/docker/tagger_test.go index f58e7df05..55445729c 100644 --- a/probe/docker/tagger_test.go +++ b/probe/docker/tagger_test.go @@ -42,7 +42,7 @@ func TestTagger(t *testing.T) { docker.ContainerID: "ping", }).WithParents(report.Sets{ "container": report.MakeStringSet(report.MakeContainerNodeID("ping")), - "container_image": report.MakeStringSet(report.MakeContainerImageNodeID("somehost.com", "baz")), + "container_image": report.MakeStringSet(report.MakeContainerImageNodeID("baz")), }) ) @@ -54,7 +54,7 @@ func TestTagger(t *testing.T) { want.Process.AddNode(pid1NodeID, report.MakeNodeWith(map[string]string{process.PID: "2"}).Merge(wantNode)) want.Process.AddNode(pid2NodeID, report.MakeNodeWith(map[string]string{process.PID: "3"}).Merge(wantNode)) - tagger := docker.NewTagger(mockRegistryInstance, "somehost.com", nil) + tagger := docker.NewTagger(mockRegistryInstance, nil) have, err := tagger.Tag(input) if err != nil { t.Errorf("%v", err) diff --git a/prog/probe.go b/prog/probe.go index 3742e80cf..f8aa9ed1b 100644 --- a/prog/probe.go +++ b/prog/probe.go @@ -131,7 +131,7 @@ func probeMain() { } if registry, err := docker.NewRegistry(*dockerInterval, clients); err == nil { defer registry.Stop() - p.AddTagger(docker.NewTagger(registry, hostID, processCache)) + p.AddTagger(docker.NewTagger(registry, processCache)) p.AddReporter(docker.NewReporter(registry, hostID, p)) } else { log.Printf("Docker: failed to start registry: %v", err) diff --git a/render/mapping.go b/render/mapping.go index a1b675007..b3ded7a74 100644 --- a/render/mapping.go +++ b/render/mapping.go @@ -524,7 +524,7 @@ func MapContainer2ContainerImage(n RenderableNode, _ report.Networks) Renderable result.Children = result.Children.Add(n.Node) result.Node.Topology = "container_image" - result.Node.ID = report.MakeContainerImageNodeID(report.ExtractHostID(n.Node), imageID) + result.Node.ID = report.MakeContainerImageNodeID(imageID) return RenderableNodes{id: result} } diff --git a/report/id.go b/report/id.go index f8401cb5a..23561a7f6 100644 --- a/report/id.go +++ b/report/id.go @@ -107,8 +107,8 @@ func MakeContainerNodeID(containerID string) string { } // MakeContainerImageNodeID produces a container image node ID from its composite parts. -func MakeContainerImageNodeID(hostID, containerImageID string) string { - return hostID + ScopeDelim + containerImageID +func MakeContainerImageNodeID(containerImageID string) string { + return containerImageID + ScopeDelim + "" } // MakePodNodeID produces a pod node ID from its composite parts. diff --git a/test/fixture/report_fixture.go b/test/fixture/report_fixture.go index fa9785a5e..7597cbd5f 100644 --- a/test/fixture/report_fixture.go +++ b/test/fixture/report_fixture.go @@ -79,8 +79,8 @@ var ( ClientContainerImageID = "imageid123" ServerContainerImageID = "imageid456" - ClientContainerImageNodeID = report.MakeContainerImageNodeID(ClientHostID, ClientContainerImageID) - ServerContainerImageNodeID = report.MakeContainerImageNodeID(ServerHostID, ServerContainerImageID) + ClientContainerImageNodeID = report.MakeContainerImageNodeID(ClientContainerImageID) + ServerContainerImageNodeID = report.MakeContainerImageNodeID(ServerContainerImageID) ClientContainerImageName = "image/client" ServerContainerImageName = "image/server" From e30c9dc2648fb964c3eb73dbe2ded9251009e3aa Mon Sep 17 00:00:00 2001 From: Paul Bellamy Date: Mon, 18 Jan 2016 10:56:36 +0000 Subject: [PATCH 05/12] use constants for the topology names in Parents/metadata/metrics --- probe/docker/container.go | 2 +- probe/docker/container_test.go | 2 +- probe/docker/tagger.go | 4 ++-- probe/docker/tagger_test.go | 4 ++-- probe/host/tagger.go | 2 +- probe/host/tagger_test.go | 2 +- probe/kubernetes/pod.go | 2 +- probe/kubernetes/reporter.go | 2 +- probe/kubernetes/reporter_test.go | 12 ++++++------ probe/probe_internal_test.go | 4 ++-- probe/topology_tagger.go | 18 +++++++++--------- render/detailed/metadata.go | 10 +++++----- render/detailed/metadata_test.go | 2 +- render/detailed/metrics.go | 6 +++--- render/detailed/node.go | 20 ++++++++++---------- render/short_lived_connections_test.go | 8 ++++---- report/report.go | 13 +++++++++++++ test/fixture/report_fixture.go | 26 +++++++++++++------------- 18 files changed, 76 insertions(+), 63 deletions(-) diff --git a/probe/docker/container.go b/probe/docker/container.go index 0abe6baec..95535f57a 100644 --- a/probe/docker/container.go +++ b/probe/docker/container.go @@ -334,7 +334,7 @@ func (c *container) GetNode(hostID string, localAddrs []net.IP) report.Node { ).WithMetrics( c.metrics(), ).WithParents(report.Sets{ - "container_image": report.MakeStringSet(report.MakeContainerImageNodeID(c.container.Image)), + report.ContainerImage: report.MakeStringSet(report.MakeContainerImageNodeID(c.container.Image)), }) if c.container.State.Paused { diff --git a/probe/docker/container_test.go b/probe/docker/container_test.go index 0dc624596..91a540fd3 100644 --- a/probe/docker/container_test.go +++ b/probe/docker/container_test.go @@ -93,7 +93,7 @@ func TestContainer(t *testing.T) { "cpu_total_usage": report.MakeMetric(), "memory_usage": report.MakeMetric().Add(now, 12345), }).WithParents(report.Sets{ - "container_image": report.MakeStringSet(report.MakeContainerImageNodeID("baz")), + report.ContainerImage: report.MakeStringSet(report.MakeContainerImageNodeID("baz")), }) test.Poll(t, 100*time.Millisecond, want, func() interface{} { node := c.GetNode("scope", []net.IP{}) diff --git a/probe/docker/tagger.go b/probe/docker/tagger.go index 320ea9991..cd982fd41 100644 --- a/probe/docker/tagger.go +++ b/probe/docker/tagger.go @@ -85,8 +85,8 @@ func (t *Tagger) tag(tree process.Tree, topology *report.Topology) { topology.AddNode(nodeID, report.MakeNodeWith(map[string]string{ ContainerID: c.ID(), }).WithParents(report.Sets{ - "container": report.MakeStringSet(report.MakeContainerNodeID(c.ID())), - "container_image": report.MakeStringSet(report.MakeContainerImageNodeID(c.Image())), + report.Container: report.MakeStringSet(report.MakeContainerNodeID(c.ID())), + report.ContainerImage: report.MakeStringSet(report.MakeContainerImageNodeID(c.Image())), })) } } diff --git a/probe/docker/tagger_test.go b/probe/docker/tagger_test.go index 55445729c..3dc042725 100644 --- a/probe/docker/tagger_test.go +++ b/probe/docker/tagger_test.go @@ -41,8 +41,8 @@ func TestTagger(t *testing.T) { wantNode = report.MakeNodeWith(map[string]string{ docker.ContainerID: "ping", }).WithParents(report.Sets{ - "container": report.MakeStringSet(report.MakeContainerNodeID("ping")), - "container_image": report.MakeStringSet(report.MakeContainerImageNodeID("baz")), + report.Container: report.MakeStringSet(report.MakeContainerNodeID("ping")), + report.ContainerImage: report.MakeStringSet(report.MakeContainerImageNodeID("baz")), }) ) diff --git a/probe/host/tagger.go b/probe/host/tagger.go index af5feb9ec..c84654e4b 100644 --- a/probe/host/tagger.go +++ b/probe/host/tagger.go @@ -32,7 +32,7 @@ func (t Tagger) Tag(r report.Report) (report.Report, error) { report.ProbeID: t.probeID, } parents = report.Sets{ - "host": report.MakeStringSet(t.hostNodeID), + report.Host: report.MakeStringSet(t.hostNodeID), } ) diff --git a/probe/host/tagger_test.go b/probe/host/tagger_test.go index 453486105..011d56162 100644 --- a/probe/host/tagger_test.go +++ b/probe/host/tagger_test.go @@ -23,7 +23,7 @@ func TestTagger(t *testing.T) { report.HostNodeID: report.MakeHostNodeID(hostID), report.ProbeID: probeID, }).WithParents(report.Sets{ - "host": report.MakeStringSet(report.MakeHostNodeID(hostID)), + report.Host: report.MakeStringSet(report.MakeHostNodeID(hostID)), })) rpt, _ := host.NewTagger(hostID, probeID).Tag(r) have := rpt.Process.Nodes[endpointNodeID].Copy() diff --git a/probe/kubernetes/pod.go b/probe/kubernetes/pod.go index 0f1dd5f9c..0cb8dcc4d 100644 --- a/probe/kubernetes/pod.go +++ b/probe/kubernetes/pod.go @@ -90,7 +90,7 @@ func (p *pod) GetNode() report.Node { continue } n = n.WithParents(report.Sets{ - "service": report.MakeStringSet(report.MakeServiceNodeID(p.Namespace(), segments[1])), + report.Service: report.MakeStringSet(report.MakeServiceNodeID(p.Namespace(), segments[1])), }) } return n diff --git a/probe/kubernetes/reporter.go b/probe/kubernetes/reporter.go index 5673bf5ff..afb2f5a9a 100644 --- a/probe/kubernetes/reporter.go +++ b/probe/kubernetes/reporter.go @@ -65,7 +65,7 @@ func (r *Reporter) podTopology(services []Service) (report.Topology, report.Topo PodID: p.ID(), Namespace: p.Namespace(), }).WithParents(report.Sets{ - "pod": report.MakeStringSet(nodeID), + report.Pod: report.MakeStringSet(nodeID), }) for _, containerID := range p.ContainerIDs() { containers.AddNode(report.MakeContainerNodeID(containerID), container) diff --git a/probe/kubernetes/reporter_test.go b/probe/kubernetes/reporter_test.go index 49a5a2507..092ae9481 100644 --- a/probe/kubernetes/reporter_test.go +++ b/probe/kubernetes/reporter_test.go @@ -120,7 +120,7 @@ func TestReporter(t *testing.T) { kubernetes.PodContainerIDs: "container1 container2", kubernetes.ServiceIDs: "ping/pongservice", }).WithParents(report.Sets{ - "service": report.MakeStringSet(serviceID), + report.Service: report.MakeStringSet(serviceID), })).AddNode(pod2ID, report.MakeNodeWith(map[string]string{ kubernetes.PodID: "ping/pong-b", kubernetes.PodName: "pong-b", @@ -129,7 +129,7 @@ func TestReporter(t *testing.T) { kubernetes.PodContainerIDs: "container3 container4", kubernetes.ServiceIDs: "ping/pongservice", }).WithParents(report.Sets{ - "service": report.MakeStringSet(serviceID), + report.Service: report.MakeStringSet(serviceID), })) want.Service = report.MakeTopology().AddNode(serviceID, report.MakeNodeWith(map[string]string{ kubernetes.ServiceID: "ping/pongservice", @@ -141,22 +141,22 @@ func TestReporter(t *testing.T) { kubernetes.PodID: "ping/pong-a", kubernetes.Namespace: "ping", }).WithParents(report.Sets{ - "pod": report.MakeStringSet(pod1ID), + report.Pod: report.MakeStringSet(pod1ID), })).AddNode(report.MakeContainerNodeID("container2"), report.MakeNodeWith(map[string]string{ kubernetes.PodID: "ping/pong-a", kubernetes.Namespace: "ping", }).WithParents(report.Sets{ - "pod": report.MakeStringSet(pod1ID), + report.Pod: report.MakeStringSet(pod1ID), })).AddNode(report.MakeContainerNodeID("container3"), report.MakeNodeWith(map[string]string{ kubernetes.PodID: "ping/pong-b", kubernetes.Namespace: "ping", }).WithParents(report.Sets{ - "pod": report.MakeStringSet(pod2ID), + report.Pod: report.MakeStringSet(pod2ID), })).AddNode(report.MakeContainerNodeID("container4"), report.MakeNodeWith(map[string]string{ kubernetes.PodID: "ping/pong-b", kubernetes.Namespace: "ping", }).WithParents(report.Sets{ - "pod": report.MakeStringSet(pod2ID), + report.Pod: report.MakeStringSet(pod2ID), })) reporter := kubernetes.NewReporter(mockClientInstance) diff --git a/probe/probe_internal_test.go b/probe/probe_internal_test.go index c50559d1e..f27e7e92c 100644 --- a/probe/probe_internal_test.go +++ b/probe/probe_internal_test.go @@ -33,8 +33,8 @@ func TestApply(t *testing.T) { from report.Topology via string }{ - {endpointNode.Merge(report.MakeNode().WithID("c").WithTopology("endpoint")), r.Endpoint, endpointNodeID}, - {addressNode.Merge(report.MakeNode().WithID("d").WithTopology("address")), r.Address, addressNodeID}, + {endpointNode.Merge(report.MakeNode().WithID("c").WithTopology(report.Endpoint)), r.Endpoint, endpointNodeID}, + {addressNode.Merge(report.MakeNode().WithID("d").WithTopology(report.Address)), r.Address, addressNodeID}, } { if want, have := tuple.want, tuple.from.Nodes[tuple.via]; !reflect.DeepEqual(want, have) { t.Errorf("want %+v, have %+v", want, have) diff --git a/probe/topology_tagger.go b/probe/topology_tagger.go index d117a6f26..764875e15 100644 --- a/probe/topology_tagger.go +++ b/probe/topology_tagger.go @@ -17,15 +17,15 @@ func (topologyTagger) Name() string { return "Topology" } // Tag implements Tagger func (topologyTagger) Tag(r report.Report) (report.Report, error) { for name, t := range map[string]*report.Topology{ - "endpoint": &(r.Endpoint), - "address": &(r.Address), - "process": &(r.Process), - "container": &(r.Container), - "container_image": &(r.ContainerImage), - "pod": &(r.Pod), - "service": &(r.Service), - "host": &(r.Host), - "overlay": &(r.Overlay), + report.Endpoint: &(r.Endpoint), + report.Address: &(r.Address), + report.Process: &(r.Process), + report.Container: &(r.Container), + report.ContainerImage: &(r.ContainerImage), + report.Pod: &(r.Pod), + report.Service: &(r.Service), + report.Host: &(r.Host), + report.Overlay: &(r.Overlay), } { for id, node := range t.Nodes { t.AddNode(id, node.WithID(id).WithTopology(name)) diff --git a/render/detailed/metadata.go b/render/detailed/metadata.go index e0dc35940..55ae0c09d 100644 --- a/render/detailed/metadata.go +++ b/render/detailed/metadata.go @@ -70,11 +70,11 @@ func (m MetadataRow) Copy() MetadataRow { // an origin ID, which is (optimistically) a node ID in one of our topologies. func NodeMetadata(n report.Node) []MetadataRow { renderers := map[string]func(report.Node) []MetadataRow{ - "process": processNodeMetadata, - "container": containerNodeMetadata, - "container_image": containerImageNodeMetadata, - "pod": podNodeMetadata, - "host": hostNodeMetadata, + report.Process: processNodeMetadata, + report.Container: containerNodeMetadata, + report.ContainerImage: containerImageNodeMetadata, + report.Pod: podNodeMetadata, + report.Host: hostNodeMetadata, } if renderer, ok := renderers[n.Topology]; ok { return renderer(n) diff --git a/render/detailed/metadata_test.go b/render/detailed/metadata_test.go index e4f86bebc..9d2622a06 100644 --- a/render/detailed/metadata_test.go +++ b/render/detailed/metadata_test.go @@ -22,7 +22,7 @@ func TestNodeMetadata(t *testing.T) { node: report.MakeNodeWith(map[string]string{ docker.ContainerID: fixture.ClientContainerID, docker.LabelPrefix + "label1": "label1value", - }).WithTopology("container").WithSets(report.Sets{ + }).WithTopology(report.Container).WithSets(report.Sets{ docker.ContainerIPs: report.MakeStringSet("10.10.10.0/24", "10.10.10.1/24"), }).WithLatest(docker.ContainerState, fixture.Now, docker.StateRunning), want: []detailed.MetadataRow{ diff --git a/render/detailed/metrics.go b/render/detailed/metrics.go index c38cef3f3..2a110d077 100644 --- a/render/detailed/metrics.go +++ b/render/detailed/metrics.go @@ -85,9 +85,9 @@ func toFixed(num float64, precision int) float64 { // an origin ID, which is (optimistically) a node ID in one of our topologies. func NodeMetrics(n report.Node) []MetricRow { renderers := map[string]func(report.Node) []MetricRow{ - "process": processNodeMetrics, - "container": containerNodeMetrics, - "host": hostNodeMetrics, + report.Process: processNodeMetrics, + report.Container: containerNodeMetrics, + report.Host: hostNodeMetrics, } if renderer, ok := renderers[n.Topology]; ok { return renderer(n) diff --git a/render/detailed/node.go b/render/detailed/node.go index 29b5e7c8b..c57a66977 100644 --- a/render/detailed/node.go +++ b/render/detailed/node.go @@ -93,11 +93,11 @@ var ( topologyID string NodeSummaryGroup }{ - {"host", NodeSummaryGroup{TopologyID: "hosts", Label: "Hosts", Columns: []string{host.CPUUsage, host.MemUsage}}}, - {"pod", NodeSummaryGroup{TopologyID: "pods", Label: "Pods", Columns: []string{}}}, - {"container_image", NodeSummaryGroup{TopologyID: "containers-by-image", Label: "Container Images", Columns: []string{}}}, - {"container", NodeSummaryGroup{TopologyID: "containers", Label: "Containers", Columns: []string{docker.CPUTotalUsage, docker.MemoryUsage}}}, - {"process", NodeSummaryGroup{TopologyID: "applications", Label: "Applications", Columns: []string{process.PID, process.CPUUsage, process.MemoryUsage}}}, + {report.Host, NodeSummaryGroup{TopologyID: "hosts", Label: "Hosts", Columns: []string{host.CPUUsage, host.MemUsage}}}, + {report.Pod, NodeSummaryGroup{TopologyID: "pods", Label: "Pods", Columns: []string{}}}, + {report.ContainerImage, NodeSummaryGroup{TopologyID: "containers-by-image", Label: "Container Images", Columns: []string{}}}, + {report.Container, NodeSummaryGroup{TopologyID: "containers", Label: "Containers", Columns: []string{docker.CPUTotalUsage, docker.MemoryUsage}}}, + {report.Process, NodeSummaryGroup{TopologyID: "applications", Label: "Applications", Columns: []string{process.PID, process.CPUUsage, process.MemoryUsage}}}, } ) @@ -140,11 +140,11 @@ func parents(r report.Report, n render.RenderableNode) (result []Parent) { report.Topology render func(report.Node) Parent }{ - "container": {r.Container, containerParent}, - "pod": {r.Pod, podParent}, - "service": {r.Service, serviceParent}, - "container_image": {r.ContainerImage, containerImageParent}, - "host": {r.Host, hostParent}, + report.Container: {r.Container, containerParent}, + report.Pod: {r.Pod, podParent}, + report.Service: {r.Service, serviceParent}, + report.ContainerImage: {r.ContainerImage, containerImageParent}, + report.Host: {r.Host, hostParent}, } topologyIDs := []string{} for topologyID := range topologies { diff --git a/render/short_lived_connections_test.go b/render/short_lived_connections_test.go index 00b3a2bba..74fa46a9b 100644 --- a/render/short_lived_connections_test.go +++ b/render/short_lived_connections_test.go @@ -37,13 +37,13 @@ var ( endpoint.Addr: randomIP, endpoint.Port: randomPort, endpoint.Conntracked: "true", - }).WithAdjacent(serverEndpointNodeID).WithID(randomEndpointNodeID).WithTopology("endpoint"), + }).WithAdjacent(serverEndpointNodeID).WithID(randomEndpointNodeID).WithTopology(report.Endpoint), serverEndpointNodeID: report.MakeNode().WithMetadata(map[string]string{ endpoint.Addr: serverIP, endpoint.Port: serverPort, endpoint.Conntracked: "true", - }).WithID(serverEndpointNodeID).WithTopology("endpoint"), + }).WithID(serverEndpointNodeID).WithTopology(report.Endpoint), }, }, Container: report.Topology{ @@ -55,7 +55,7 @@ var ( }).WithSets(report.Sets{ docker.ContainerIPs: report.MakeStringSet(containerIP), docker.ContainerPorts: report.MakeStringSet(fmt.Sprintf("%s:%s->%s/tcp", serverIP, serverPort, serverPort)), - }).WithID(containerNodeID).WithTopology("container"), + }).WithID(containerNodeID).WithTopology(report.Container), }, }, Host: report.Topology{ @@ -64,7 +64,7 @@ var ( report.HostNodeID: serverHostNodeID, }).WithSets(report.Sets{ host.LocalNetworks: report.MakeStringSet("192.168.0.0/16"), - }).WithID(serverHostNodeID).WithTopology("host"), + }).WithID(serverHostNodeID).WithTopology(report.Host), }, }, } diff --git a/report/report.go b/report/report.go index 88876ea6d..a63639d20 100644 --- a/report/report.go +++ b/report/report.go @@ -6,6 +6,19 @@ import ( "time" ) +// Names of the various topologies. +const ( + Endpoint = "endpoint" + Address = "address" + Process = "process" + Container = "container" + Pod = "pod" + Service = "service" + ContainerImage = "container_image" + Host = "host" + Overlay = "overlay" +) + // Report is the core data type. It's produced by probes, and consumed and // stored by apps. It's composed of multiple topologies, each representing // a different (related, but not equivalent) view of the network. diff --git a/test/fixture/report_fixture.go b/test/fixture/report_fixture.go index 7597cbd5f..4239bc5f9 100644 --- a/test/fixture/report_fixture.go +++ b/test/fixture/report_fixture.go @@ -205,7 +205,7 @@ var ( process.Name: Client1Name, docker.ContainerID: ClientContainerID, report.HostNodeID: ClientHostNodeID, - }).WithID(ClientProcess1NodeID).WithTopology("process").WithParents(report.Sets{ + }).WithID(ClientProcess1NodeID).WithTopology(report.Process).WithParents(report.Sets{ "host": report.MakeStringSet(ClientHostNodeID), "container": report.MakeStringSet(ClientContainerNodeID), "container_image": report.MakeStringSet(ClientContainerImageNodeID), @@ -218,7 +218,7 @@ var ( process.Name: Client2Name, docker.ContainerID: ClientContainerID, report.HostNodeID: ClientHostNodeID, - }).WithID(ClientProcess2NodeID).WithTopology("process").WithParents(report.Sets{ + }).WithID(ClientProcess2NodeID).WithTopology(report.Process).WithParents(report.Sets{ "host": report.MakeStringSet(ClientHostNodeID), "container": report.MakeStringSet(ClientContainerNodeID), "container_image": report.MakeStringSet(ClientContainerImageNodeID), @@ -228,7 +228,7 @@ var ( process.Name: ServerName, docker.ContainerID: ServerContainerID, report.HostNodeID: ServerHostNodeID, - }).WithID(ServerProcessNodeID).WithTopology("process").WithParents(report.Sets{ + }).WithID(ServerProcessNodeID).WithTopology(report.Process).WithParents(report.Sets{ "host": report.MakeStringSet(ServerHostNodeID), "container": report.MakeStringSet(ServerContainerNodeID), "container_image": report.MakeStringSet(ServerContainerImageNodeID), @@ -237,7 +237,7 @@ var ( process.PID: NonContainerPID, process.Name: NonContainerName, report.HostNodeID: ServerHostNodeID, - }).WithID(NonContainerProcessNodeID).WithTopology("process").WithParents(report.Sets{ + }).WithID(NonContainerProcessNodeID).WithTopology(report.Process).WithParents(report.Sets{ "host": report.MakeStringSet(ServerHostNodeID), }), }, @@ -252,7 +252,7 @@ var ( docker.LabelPrefix + "io.kubernetes.pod.name": ClientPodID, kubernetes.PodID: ClientPodID, kubernetes.Namespace: KubernetesNamespace, - }).WithLatest(docker.ContainerState, Now, docker.StateRunning).WithID(ClientContainerNodeID).WithTopology("container").WithParents(report.Sets{ + }).WithLatest(docker.ContainerState, Now, docker.StateRunning).WithID(ClientContainerNodeID).WithTopology(report.Container).WithParents(report.Sets{ "host": report.MakeStringSet(ClientHostNodeID), "container_image": report.MakeStringSet(ClientContainerImageNodeID), "pod": report.MakeStringSet(ClientPodID), @@ -272,7 +272,7 @@ var ( docker.LabelPrefix + "io.kubernetes.pod.name": ServerPodID, kubernetes.PodID: ServerPodID, kubernetes.Namespace: KubernetesNamespace, - }).WithLatest(docker.ContainerState, Now, docker.StateRunning).WithID(ServerContainerNodeID).WithTopology("container").WithParents(report.Sets{ + }).WithLatest(docker.ContainerState, Now, docker.StateRunning).WithID(ServerContainerNodeID).WithTopology(report.Container).WithParents(report.Sets{ "host": report.MakeStringSet(ServerHostNodeID), "container_image": report.MakeStringSet(ServerContainerImageNodeID), "pod": report.MakeStringSet(ServerPodID), @@ -290,7 +290,7 @@ var ( report.HostNodeID: ClientHostNodeID, }).WithParents(report.Sets{ "host": report.MakeStringSet(ClientHostNodeID), - }).WithID(ClientContainerImageNodeID).WithTopology("container_image"), + }).WithID(ClientContainerImageNodeID).WithTopology(report.ContainerImage), ServerContainerImageNodeID: report.MakeNodeWith(map[string]string{ docker.ImageID: ServerContainerImageID, docker.ImageName: ServerContainerImageName, @@ -299,7 +299,7 @@ var ( docker.LabelPrefix + "foo2": "bar2", }).WithParents(report.Sets{ "host": report.MakeStringSet(ServerHostNodeID), - }).WithID(ServerContainerImageNodeID).WithTopology("container_image"), + }).WithID(ServerContainerImageNodeID).WithTopology(report.ContainerImage), }, }, Address: report.Topology{ @@ -339,7 +339,7 @@ var ( "host_name": ClientHostName, "os": "Linux", report.HostNodeID: ClientHostNodeID, - }).WithID(ClientHostNodeID).WithTopology("host").WithSets(report.Sets{ + }).WithID(ClientHostNodeID).WithTopology(report.Host).WithSets(report.Sets{ host.LocalNetworks: report.MakeStringSet("10.10.10.0/24"), }).WithMetrics(report.Metrics{ host.CPUUsage: CPUMetric, @@ -352,7 +352,7 @@ var ( "host_name": ServerHostName, "os": "Linux", report.HostNodeID: ServerHostNodeID, - }).WithID(ServerHostNodeID).WithTopology("host").WithSets(report.Sets{ + }).WithID(ServerHostNodeID).WithTopology(report.Host).WithSets(report.Sets{ host.LocalNetworks: report.MakeStringSet("10.10.10.0/24"), }).WithMetrics(report.Metrics{ host.CPUUsage: CPUMetric, @@ -371,7 +371,7 @@ var ( kubernetes.Namespace: KubernetesNamespace, kubernetes.PodContainerIDs: ClientContainerID, kubernetes.ServiceIDs: ServiceID, - }).WithID(ClientPodNodeID).WithTopology("pod").WithParents(report.Sets{ + }).WithID(ClientPodNodeID).WithTopology(report.Pod).WithParents(report.Sets{ "host": report.MakeStringSet(ClientHostNodeID), "service": report.MakeStringSet(ServiceID), }), @@ -381,7 +381,7 @@ var ( kubernetes.Namespace: KubernetesNamespace, kubernetes.PodContainerIDs: ServerContainerID, kubernetes.ServiceIDs: ServiceID, - }).WithID(ServerPodNodeID).WithTopology("pod").WithParents(report.Sets{ + }).WithID(ServerPodNodeID).WithTopology(report.Pod).WithParents(report.Sets{ "host": report.MakeStringSet(ServerHostNodeID), "service": report.MakeStringSet(ServiceID), }), @@ -393,7 +393,7 @@ var ( kubernetes.ServiceID: ServiceID, kubernetes.ServiceName: "pongservice", kubernetes.Namespace: "ping", - }).WithID(ServiceNodeID).WithTopology("service"), + }).WithID(ServiceNodeID).WithTopology(report.Service), }, }, Sampling: report.Sampling{ From 54d0db04418ee6fe0f4c2d850560e0f141b4ebd8 Mon Sep 17 00:00:00 2001 From: Paul Bellamy Date: Mon, 18 Jan 2016 11:33:25 +0000 Subject: [PATCH 06/12] templatize metric rendering and shorten column names --- render/detailed/metrics.go | 139 ++++++++++---------------------- render/detailed/metrics_test.go | 12 +-- render/detailed/node_test.go | 8 +- 3 files changed, 52 insertions(+), 107 deletions(-) diff --git a/render/detailed/metrics.go b/render/detailed/metrics.go index 2a110d077..c023f3862 100644 --- a/render/detailed/metrics.go +++ b/render/detailed/metrics.go @@ -16,6 +16,24 @@ const ( percentFormat = "percent" ) +var ( + processNodeMetrics = renderMetrics( + MetricRow{ID: process.CPUUsage, Label: "CPU", Format: percentFormat}, + MetricRow{ID: process.MemoryUsage, Label: "Memory", Format: filesizeFormat}, + ) + containerNodeMetrics = renderMetrics( + MetricRow{ID: docker.CPUTotalUsage, Label: "CPU", Format: percentFormat}, + MetricRow{ID: docker.MemoryUsage, Label: "Memory", Format: filesizeFormat}, + ) + hostNodeMetrics = renderMetrics( + MetricRow{ID: host.CPUUsage, Label: "CPU", Format: percentFormat}, + MetricRow{ID: host.MemUsage, Label: "Memory", Format: filesizeFormat}, + MetricRow{ID: host.Load1, Label: "Load (1m)", Format: defaultFormat, Group: "load"}, + MetricRow{ID: host.Load5, Label: "Load (5m)", Format: defaultFormat, Group: "load"}, + MetricRow{ID: host.Load15, Label: "Load (15m)", Format: defaultFormat, Group: "load"}, + ) +) + // MetricRow is a tuple of data used to render a metric as a sparkline and // accoutrements. type MetricRow struct { @@ -29,15 +47,18 @@ type MetricRow struct { // Copy returns a value copy of the MetricRow func (m MetricRow) Copy() MetricRow { - metric := m.Metric.Copy() - return MetricRow{ + row := MetricRow{ ID: m.ID, Label: m.Label, Format: m.Format, Group: m.Group, Value: m.Value, - Metric: &metric, } + if m.Metric != nil { + var metric = m.Metric.Copy() + row.Metric = &metric + } + return row } // MarshalJSON marshals this MetricRow to json. It takes the basic Metric @@ -60,27 +81,6 @@ func (m MetricRow) MarshalJSON() ([]byte, error) { }) } -func metricRow(id, label string, metric report.Metric, format, group string) MetricRow { - var last float64 - if s := metric.LastSample(); s != nil { - last = s.Value - } - return MetricRow{ - ID: id, - Label: label, - Format: format, - Group: group, - Value: toFixed(last, 2), - Metric: &metric, - } -} - -// toFixed truncates decimals of float64 down to specified precision -func toFixed(num float64, precision int) float64 { - output := math.Pow(10, float64(precision)) - return float64(int64(num*output)) / output -} - // NodeMetrics produces a table (to be consumed directly by the UI) based on // an origin ID, which is (optimistically) a node ID in one of our topologies. func NodeMetrics(n report.Node) []MetricRow { @@ -95,82 +95,27 @@ func NodeMetrics(n report.Node) []MetricRow { return nil } -func processNodeMetrics(nmd report.Node) []MetricRow { - rows := []MetricRow{} - for _, tuple := range []struct { - ID, Label, fmt string - }{ - {process.CPUUsage, "CPU Usage", percentFormat}, - {process.MemoryUsage, "Memory Usage", filesizeFormat}, - } { - if val, ok := nmd.Metrics[tuple.ID]; ok { - rows = append(rows, metricRow( - tuple.ID, - tuple.Label, - val, - tuple.fmt, - "", - )) - } - } - return rows -} - -func containerNodeMetrics(nmd report.Node) []MetricRow { - rows := []MetricRow{} - if val, ok := nmd.Metrics[docker.CPUTotalUsage]; ok { - rows = append(rows, metricRow( - docker.CPUTotalUsage, - "CPU Usage", - val, - percentFormat, - "", - )) - } - if val, ok := nmd.Metrics[docker.MemoryUsage]; ok { - rows = append(rows, metricRow( - docker.MemoryUsage, - "Memory Usage", - val, - filesizeFormat, - "", - )) - } - return rows -} - -func hostNodeMetrics(nmd report.Node) []MetricRow { - // Ensure that all metrics have the same max - maxLoad := 0.0 - for _, id := range []string{host.Load1, host.Load5, host.Load15} { - if metric, ok := nmd.Metrics[id]; ok { - if metric.Len() == 0 { +func renderMetrics(templates ...MetricRow) func(report.Node) []MetricRow { + return func(n report.Node) []MetricRow { + rows := []MetricRow{} + for _, template := range templates { + metric, ok := n.Metrics[template.ID] + if !ok { continue } - if metric.Max > maxLoad { - maxLoad = metric.Max + t := template.Copy() + if s := metric.LastSample(); s != nil { + t.Value = toFixed(s.Value, 2) } + t.Metric = &metric + rows = append(rows, t) } + return rows } - - rows := []MetricRow{} - for _, tuple := range []struct{ ID, Label, fmt string }{ - {host.CPUUsage, "CPU Usage", percentFormat}, - {host.MemUsage, "Memory Usage", filesizeFormat}, - } { - if val, ok := nmd.Metrics[tuple.ID]; ok { - rows = append(rows, metricRow(tuple.ID, tuple.Label, val, tuple.fmt, "")) - } - } - for _, tuple := range []struct{ ID, Label string }{ - {host.Load1, "Load (1m)"}, - {host.Load5, "Load (5m)"}, - {host.Load15, "Load (15m)"}, - } { - if val, ok := nmd.Metrics[tuple.ID]; ok { - val.Max = maxLoad - rows = append(rows, metricRow(tuple.ID, tuple.Label, val, defaultFormat, "load")) - } - } - return rows +} + +// toFixed truncates decimals of float64 down to specified precision +func toFixed(num float64, precision int) float64 { + output := math.Pow(10, float64(precision)) + return float64(int64(num*output)) / output } diff --git a/render/detailed/metrics_test.go b/render/detailed/metrics_test.go index f61c8c5f4..a199ee0ca 100644 --- a/render/detailed/metrics_test.go +++ b/render/detailed/metrics_test.go @@ -25,7 +25,7 @@ func TestNodeMetrics(t *testing.T) { want: []detailed.MetricRow{ { ID: process.CPUUsage, - Label: "CPU Usage", + Label: "CPU", Format: "percent", Group: "", Value: 0.01, @@ -33,7 +33,7 @@ func TestNodeMetrics(t *testing.T) { }, { ID: process.MemoryUsage, - Label: "Memory Usage", + Label: "Memory", Format: "filesize", Group: "", Value: 0.01, @@ -47,7 +47,7 @@ func TestNodeMetrics(t *testing.T) { want: []detailed.MetricRow{ { ID: docker.CPUTotalUsage, - Label: "CPU Usage", + Label: "CPU", Format: "percent", Group: "", Value: 0.01, @@ -55,7 +55,7 @@ func TestNodeMetrics(t *testing.T) { }, { ID: docker.MemoryUsage, - Label: "Memory Usage", + Label: "Memory", Format: "filesize", Group: "", Value: 0.01, @@ -69,7 +69,7 @@ func TestNodeMetrics(t *testing.T) { want: []detailed.MetricRow{ { ID: host.CPUUsage, - Label: "CPU Usage", + Label: "CPU", Format: "percent", Group: "", Value: 0.01, @@ -77,7 +77,7 @@ func TestNodeMetrics(t *testing.T) { }, { ID: host.MemUsage, - Label: "Memory Usage", + Label: "Memory", Format: "filesize", Group: "", Value: 0.01, diff --git a/render/detailed/node_test.go b/render/detailed/node_test.go index 04051fdc9..2cd49bf68 100644 --- a/render/detailed/node_test.go +++ b/render/detailed/node_test.go @@ -51,14 +51,14 @@ func TestMakeDetailedHostNode(t *testing.T) { { ID: host.CPUUsage, Format: "percent", - Label: "CPU Usage", + Label: "CPU", Value: 0.01, Metric: &fixture.CPUMetric, }, { ID: host.MemUsage, Format: "filesize", - Label: "Memory Usage", + Label: "Memory", Value: 0.01, Metric: &fixture.MemoryMetric, }, @@ -136,14 +136,14 @@ func TestMakeDetailedContainerNode(t *testing.T) { { ID: docker.CPUTotalUsage, Format: "percent", - Label: "CPU Usage", + Label: "CPU", Value: 0.01, Metric: &fixture.CPUMetric, }, { ID: docker.MemoryUsage, Format: "filesize", - Label: "Memory Usage", + Label: "Memory", Value: 0.01, Metric: &fixture.MemoryMetric, }, From cb9d558665d13999c901385d15ff5041a3e89639 Mon Sep 17 00:00:00 2001 From: Paul Bellamy Date: Mon, 18 Jan 2016 11:51:06 +0000 Subject: [PATCH 07/12] Review feedback refactoring --- render/detailed/node.go | 39 ++++---- render/detailed/node_test.go | 172 ++++++++++++++++++----------------- 2 files changed, 105 insertions(+), 106 deletions(-) diff --git a/render/detailed/node.go b/render/detailed/node.go index c57a66977..5d9836ef1 100644 --- a/render/detailed/node.go +++ b/render/detailed/node.go @@ -14,13 +14,10 @@ import ( // Node is the data type that's yielded to the JavaScript layer when // we want deep information about an individual node. type Node struct { - ID string `json:"id"` - Label string `json:"label"` + NodeSummary Rank string `json:"rank,omitempty"` Pseudo bool `json:"pseudo,omitempty"` Controls []ControlInstance `json:"controls"` - Metadata []MetadataRow `json:"metadata,omitempty"` - Metrics []MetricRow `json:"metrics,omitempty"` Children []NodeSummaryGroup `json:"children,omitempty"` Parents []Parent `json:"parents,omitempty"` } @@ -43,16 +40,16 @@ type ControlInstance struct { // MakeNode transforms a renderable node to a detailed node. It uses // aggregate metadata, plus the set of origin node IDs, to produce tables. func MakeNode(r report.Report, n render.RenderableNode) Node { + summary, _ := MakeNodeSummary(n.Node) + summary.ID = n.ID + summary.Label = n.LabelMajor return Node{ - ID: n.ID, - Label: n.LabelMajor, - Rank: n.Rank, - Pseudo: n.Pseudo, - Controls: controls(r, n), - Metadata: NodeMetadata(n.Node), - Metrics: NodeMetrics(n.Node), - Children: children(n), - Parents: parents(r, n), + NodeSummary: summary, + Rank: n.Rank, + Pseudo: n.Pseudo, + Controls: controls(r, n), + Children: children(n), + Parents: parents(r, n), } } @@ -125,17 +122,9 @@ func children(n render.RenderableNode) []NodeSummaryGroup { return nodeSummaryGroups } -// parents is a total a hack to find the parents of a node (which is -// ill-defined). +// parents renders the parents of this report.Node, which have been aggregated +// from the probe reports. func parents(r report.Report, n render.RenderableNode) (result []Parent) { - defer func() { - for i, parent := range result { - if parent.ID == n.ID { - result = append(result[:i], result[i+1:]...) - } - } - }() - topologies := map[string]struct { report.Topology render func(report.Node) Parent @@ -154,6 +143,10 @@ func parents(r report.Report, n render.RenderableNode) (result []Parent) { for _, topologyID := range topologyIDs { t := topologies[topologyID] for _, id := range n.Node.Parents[topologyID] { + if topologyID == n.Node.Topology && id == n.ID { + continue + } + parent, ok := t.Nodes[id] if !ok { continue diff --git a/render/detailed/node_test.go b/render/detailed/node_test.go index 2cd49bf68..127817fcb 100644 --- a/render/detailed/node_test.go +++ b/render/detailed/node_test.go @@ -25,65 +25,68 @@ func TestMakeDetailedHostNode(t *testing.T) { process2NodeSummary, _ := detailed.MakeNodeSummary(fixture.Report.Process.Nodes[fixture.ClientProcess2NodeID]) process2NodeSummary.Linkable = true want := detailed.Node{ - ID: render.MakeHostID(fixture.ClientHostID), - Label: "client", + NodeSummary: detailed.NodeSummary{ + ID: render.MakeHostID(fixture.ClientHostID), + Label: "client", + Linkable: true, + Metadata: []detailed.MetadataRow{ + { + ID: "host_name", + Label: "Hostname", + Value: "client.hostname.com", + }, + { + ID: "os", + Label: "Operating system", + Value: "Linux", + }, + { + ID: "local_networks", + Label: "Local Networks", + Value: "10.10.10.0/24", + }, + }, + Metrics: []detailed.MetricRow{ + { + ID: host.CPUUsage, + Format: "percent", + Label: "CPU", + Value: 0.01, + Metric: &fixture.CPUMetric, + }, + { + ID: host.MemUsage, + Format: "filesize", + Label: "Memory", + Value: 0.01, + Metric: &fixture.MemoryMetric, + }, + { + ID: host.Load1, + Group: "load", + Label: "Load (1m)", + Value: 0.01, + Metric: &fixture.LoadMetric, + }, + { + ID: host.Load5, + Group: "load", + Label: "Load (5m)", + Value: 0.01, + Metric: &fixture.LoadMetric, + }, + { + ID: host.Load15, + Label: "Load (15m)", + Group: "load", + Value: 0.01, + Metric: &fixture.LoadMetric, + }, + }, + }, Rank: "hostname.com", Pseudo: false, Controls: []detailed.ControlInstance{}, - Metadata: []detailed.MetadataRow{ - { - ID: "host_name", - Label: "Hostname", - Value: "client.hostname.com", - }, - { - ID: "os", - Label: "Operating system", - Value: "Linux", - }, - { - ID: "local_networks", - Label: "Local Networks", - Value: "10.10.10.0/24", - }, - }, - Metrics: []detailed.MetricRow{ - { - ID: host.CPUUsage, - Format: "percent", - Label: "CPU", - Value: 0.01, - Metric: &fixture.CPUMetric, - }, - { - ID: host.MemUsage, - Format: "filesize", - Label: "Memory", - Value: 0.01, - Metric: &fixture.MemoryMetric, - }, - { - ID: host.Load1, - Group: "load", - Label: "Load (1m)", - Value: 0.01, - Metric: &fixture.LoadMetric, - }, - { - ID: host.Load5, - Group: "load", - Label: "Load (5m)", - Value: 0.01, - Metric: &fixture.LoadMetric, - }, - { - ID: host.Load15, - Label: "Load (15m)", - Group: "load", - Value: 0.01, - Metric: &fixture.LoadMetric, - }, - }, Children: []detailed.NodeSummaryGroup{ { Label: "Container Images", @@ -118,36 +121,39 @@ func TestMakeDetailedContainerNode(t *testing.T) { } have := detailed.MakeNode(fixture.Report, renderableNode) want := detailed.Node{ - ID: id, - Label: "server", + NodeSummary: detailed.NodeSummary{ + ID: id, + Label: "server", + Linkable: true, + Metadata: []detailed.MetadataRow{ + {ID: "docker_container_id", Label: "ID", Value: fixture.ServerContainerID}, + {ID: "docker_image_id", Label: "Image ID", Value: fixture.ServerContainerImageID}, + {ID: "docker_container_state", Label: "State", Value: "running"}, + {ID: "label_" + render.AmazonECSContainerNameLabel, Label: fmt.Sprintf(`Label %q`, render.AmazonECSContainerNameLabel), Value: `server`}, + {ID: "label_foo1", Label: `Label "foo1"`, Value: `bar1`}, + {ID: "label_foo2", Label: `Label "foo2"`, Value: `bar2`}, + {ID: "label_io.kubernetes.pod.name", Label: `Label "io.kubernetes.pod.name"`, Value: "ping/pong-b"}, + }, + Metrics: []detailed.MetricRow{ + { + ID: docker.CPUTotalUsage, + Format: "percent", + Label: "CPU", + Value: 0.01, + Metric: &fixture.CPUMetric, + }, + { + ID: docker.MemoryUsage, + Format: "filesize", + Label: "Memory", + Value: 0.01, + Metric: &fixture.MemoryMetric, + }, + }, + }, Rank: "imageid456", Pseudo: false, Controls: []detailed.ControlInstance{}, - Metadata: []detailed.MetadataRow{ - {ID: "docker_container_id", Label: "ID", Value: fixture.ServerContainerID}, - {ID: "docker_image_id", Label: "Image ID", Value: fixture.ServerContainerImageID}, - {ID: "docker_container_state", Label: "State", Value: "running"}, - {ID: "label_" + render.AmazonECSContainerNameLabel, Label: fmt.Sprintf(`Label %q`, render.AmazonECSContainerNameLabel), Value: `server`}, - {ID: "label_foo1", Label: `Label "foo1"`, Value: `bar1`}, - {ID: "label_foo2", Label: `Label "foo2"`, Value: `bar2`}, - {ID: "label_io.kubernetes.pod.name", Label: `Label "io.kubernetes.pod.name"`, Value: "ping/pong-b"}, - }, - Metrics: []detailed.MetricRow{ - { - ID: docker.CPUTotalUsage, - Format: "percent", - Label: "CPU", - Value: 0.01, - Metric: &fixture.CPUMetric, - }, - { - ID: docker.MemoryUsage, - Format: "filesize", - Label: "Memory", - Value: 0.01, - Metric: &fixture.MemoryMetric, - }, - }, Children: []detailed.NodeSummaryGroup{ { Label: "Applications", From bf13a824d4e373893a717418759041cdb73e58a3 Mon Sep 17 00:00:00 2001 From: Paul Bellamy Date: Mon, 18 Jan 2016 15:18:49 +0000 Subject: [PATCH 08/12] render detail status section for metadata or metrics --- client/app/scripts/components/node-details.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index d08fb9576..a90c33766 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -127,7 +127,7 @@ export default class NodeDetails extends React.Component { renderDetails() { const details = this.props.details; - const showSummary = details.metadata !== undefined && details.metrics !== undefined; + const showSummary = details.metadata !== undefined || details.metrics !== undefined; const showControls = details.controls && details.controls.length > 0; const nodeColor = getNodeColorDark(details.rank, details.label_major); const {error, pending} = (this.props.controlStatus || {}); From e7c9d4b7714712a2d4e21838ca9875cf725dc6bc Mon Sep 17 00:00:00 2001 From: Paul Bellamy Date: Mon, 18 Jan 2016 17:40:51 +0000 Subject: [PATCH 09/12] adding a benchmark for the various render topologies --- render/benchmark_internal_test.go | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 render/benchmark_internal_test.go diff --git a/render/benchmark_internal_test.go b/render/benchmark_internal_test.go new file mode 100644 index 000000000..03dc0a345 --- /dev/null +++ b/render/benchmark_internal_test.go @@ -0,0 +1,66 @@ +package render_test + +import ( + "testing" + + "github.com/weaveworks/scope/render" + "github.com/weaveworks/scope/test/fixture" +) + +func BenchmarkEndpointRender(b *testing.B) { benchmarkRender(b, render.EndpointRenderer) } +func BenchmarkEndpointStats(b *testing.B) { benchmarkStats(b, render.EndpointRenderer) } +func BenchmarkProcessRender(b *testing.B) { benchmarkRender(b, render.ProcessRenderer) } +func BenchmarkProcessStats(b *testing.B) { benchmarkStats(b, render.ProcessRenderer) } +func BenchmarkProcessWithContainerNameRender(b *testing.B) { + benchmarkRender(b, render.ProcessWithContainerNameRenderer) +} +func BenchmarkProcessWithContainerNameStats(b *testing.B) { + benchmarkStats(b, render.ProcessWithContainerNameRenderer) +} +func BenchmarkProcessNameRender(b *testing.B) { benchmarkRender(b, render.ProcessNameRenderer) } +func BenchmarkProcessNameStats(b *testing.B) { benchmarkStats(b, render.ProcessNameRenderer) } +func BenchmarkContainerRender(b *testing.B) { benchmarkRender(b, render.ContainerRenderer) } +func BenchmarkContainerStats(b *testing.B) { benchmarkStats(b, render.ContainerRenderer) } +func BenchmarkContainerWithImageNameRender(b *testing.B) { + benchmarkRender(b, render.ContainerWithImageNameRenderer) +} +func BenchmarkContainerWithImageNameStats(b *testing.B) { + benchmarkStats(b, render.ContainerWithImageNameRenderer) +} +func BenchmarkContainerImageRender(b *testing.B) { benchmarkRender(b, render.ContainerImageRenderer) } +func BenchmarkContainerImageStats(b *testing.B) { benchmarkStats(b, render.ContainerImageRenderer) } +func BenchmarkContainerHostnameRender(b *testing.B) { + benchmarkRender(b, render.ContainerHostnameRenderer) +} +func BenchmarkContainerHostnameStats(b *testing.B) { + benchmarkStats(b, render.ContainerHostnameRenderer) +} +func BenchmarkHostRender(b *testing.B) { benchmarkRender(b, render.HostRenderer) } +func BenchmarkHostStats(b *testing.B) { benchmarkStats(b, render.HostRenderer) } +func BenchmarkPodRender(b *testing.B) { benchmarkRender(b, render.PodRenderer) } +func BenchmarkPodStats(b *testing.B) { benchmarkStats(b, render.PodRenderer) } +func BenchmarkPodServiceRender(b *testing.B) { benchmarkRender(b, render.PodServiceRenderer) } +func BenchmarkPodServiceStats(b *testing.B) { benchmarkStats(b, render.PodServiceRenderer) } + +func benchmarkRender(b *testing.B, r render.Renderer) { + var result map[string]render.RenderableNode + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + result = r.Render(fixture.Report) + if len(result) == 0 { + b.Errorf("Rendered topology contained no nodes") + } + } +} + +func benchmarkStats(b *testing.B, r render.Renderer) { + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + // No way to tell if this was successful :( + r.Stats(fixture.Report) + } +} From 0785d5393a642fe964d5199a98b5f03834b926aa Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Tue, 19 Jan 2016 09:48:22 +0100 Subject: [PATCH 10/12] Fixes pipes again - Treat control objects that come back from the server as little black boxes. - Pass our local client nodeId around more instead, use that for comparisons etc, (vs. inspecting the control object and doing brittle magic w/ the ids). --- client/app/scripts/actions/app-actions.js | 4 ++++ client/app/scripts/components/embedded-terminal.js | 2 +- client/app/scripts/stores/app-store.js | 14 ++++++++------ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index e8cd058dd..436d9efe3 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -1,3 +1,5 @@ +import debug from 'debug'; + import AppDispatcher from '../dispatcher/app-dispatcher'; import ActionTypes from '../constants/action-types'; import { updateRoute } from '../utils/router-utils'; @@ -5,6 +7,8 @@ import { doControlRequest, getNodesDelta, getNodeDetails, getTopologies, deletePipe } from '../utils/web-api-utils'; import AppStore from '../stores/app-store'; +const log = debug('scope:app-actions'); + export function changeTopologyOption(option, value, topologyId) { AppDispatcher.dispatch({ type: ActionTypes.CHANGE_TOPOLOGY_OPTION, diff --git a/client/app/scripts/components/embedded-terminal.js b/client/app/scripts/components/embedded-terminal.js index edac51117..7c3af84d1 100644 --- a/client/app/scripts/components/embedded-terminal.js +++ b/client/app/scripts/components/embedded-terminal.js @@ -4,7 +4,7 @@ import { getNodeColor, getNodeColorDark } from '../utils/color-utils'; import Terminal from './terminal'; export default function EmeddedTerminal({pipe, nodeId, nodes}) { - const node = nodes.get(nodeId && nodeId.split(';').pop()); + const node = nodes.get(nodeId); const titleBarColor = node && getNodeColorDark(node.get('rank'), node.get('label_major')); const statusBarColor = node && getNodeColor(node.get('rank'), node.get('label_major')); const title = node && node.get('label_major'); diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index db93167d4..549c3da00 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -166,7 +166,8 @@ export class AppStore extends Store { } getControlPipe() { - return controlPipes.last(); + const cp = controlPipes.last(); + return cp && cp.toJS(); } getCurrentTopology() { @@ -437,11 +438,11 @@ export class AppStore extends Store { break; case ActionTypes.RECEIVE_CONTROL_PIPE: - controlPipes = controlPipes.set(payload.pipeId, { + controlPipes = controlPipes.set(payload.pipeId, makeOrderedMap({ id: payload.pipeId, nodeId: payload.nodeId, raw: payload.rawTty - }); + })); this.__emitChange(); break; @@ -549,9 +550,10 @@ export class AppStore extends Store { setDefaultTopologyOptions(topologies); selectedNodeId = payload.state.selectedNodeId; if (payload.state.controlPipe) { - controlPipes = makeOrderedMap( - [[payload.state.controlPipe.pipeId, payload.state.controlPipe]] - ); + controlPipes = makeOrderedMap({ + [payload.state.controlPipe.pipeId]: + makeOrderedMap(payload.state.controlPipe) + }); } else { controlPipes = controlPipes.clear(); } From 3d32d10e2d9880cdfebc34455f7c13b9564e9bd5 Mon Sep 17 00:00:00 2001 From: Paul Bellamy Date: Tue, 19 Jan 2016 11:02:51 +0000 Subject: [PATCH 11/12] fixing up some performance issues in NodeSet --- ...ark_internal_test.go => benchmark_test.go} | 39 ++++++++- report/node_set.go | 45 ++++++++--- report/node_set_test.go | 81 ++++++++++++++++++- report/topology.go | 15 ++++ report/topology_test.go | 22 +++++ 5 files changed, 187 insertions(+), 15 deletions(-) rename render/{benchmark_internal_test.go => benchmark_test.go} (76%) diff --git a/render/benchmark_internal_test.go b/render/benchmark_test.go similarity index 76% rename from render/benchmark_internal_test.go rename to render/benchmark_test.go index 03dc0a345..baad3f498 100644 --- a/render/benchmark_internal_test.go +++ b/render/benchmark_test.go @@ -1,12 +1,22 @@ package render_test import ( + "encoding/json" + "flag" + "io/ioutil" "testing" "github.com/weaveworks/scope/render" + "github.com/weaveworks/scope/report" "github.com/weaveworks/scope/test/fixture" ) +var ( + benchReportFile = flag.String("bench-report-file", "", "json report file to use for benchmarking (relative to this package)") + benchmarkRenderResult map[string]render.RenderableNode + benchmarkStatsResult render.Stats +) + func BenchmarkEndpointRender(b *testing.B) { benchmarkRender(b, render.EndpointRenderer) } func BenchmarkEndpointStats(b *testing.B) { benchmarkStats(b, render.EndpointRenderer) } func BenchmarkProcessRender(b *testing.B) { benchmarkRender(b, render.ProcessRenderer) } @@ -43,24 +53,45 @@ func BenchmarkPodServiceRender(b *testing.B) { benchmarkRender(b, render.PodServ func BenchmarkPodServiceStats(b *testing.B) { benchmarkStats(b, render.PodServiceRenderer) } func benchmarkRender(b *testing.B, r render.Renderer) { - var result map[string]render.RenderableNode + report, err := loadReport() + if err != nil { + b.Fatal(err) + } b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - result = r.Render(fixture.Report) - if len(result) == 0 { + benchmarkRenderResult = r.Render(report) + if len(benchmarkRenderResult) == 0 { b.Errorf("Rendered topology contained no nodes") } } } func benchmarkStats(b *testing.B, r render.Renderer) { + report, err := loadReport() + if err != nil { + b.Fatal(err) + } b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { // No way to tell if this was successful :( - r.Stats(fixture.Report) + benchmarkStatsResult = r.Stats(report) } } + +func loadReport() (report.Report, error) { + if *benchReportFile == "" { + return fixture.Report, nil + } + + var rpt report.Report + b, err := ioutil.ReadFile(*benchReportFile) + if err != nil { + return rpt, err + } + err = json.Unmarshal(b, &rpt) + return rpt, err +} diff --git a/report/node_set.go b/report/node_set.go index 9a36b864b..c35950c16 100644 --- a/report/node_set.go +++ b/report/node_set.go @@ -9,18 +9,28 @@ import ( type NodeSet []Node // MakeNodeSet makes a new NodeSet with the given nodes. -// TODO: Make this more efficient func MakeNodeSet(nodes ...Node) NodeSet { if len(nodes) <= 0 { return nil } - result := NodeSet{} - for _, node := range nodes { - result = result.Add(node) + result := make(NodeSet, len(nodes)) + copy(result, nodes) + sort.Sort(result) + for i := 1; i < len(result); { // remove any duplicates + if result[i-1].Equal(result[i]) { + result = append(result[:i-1], result[i:]...) + continue + } + i++ } return result } +// Implementation of sort.Interface +func (n NodeSet) Len() int { return len(n) } +func (n NodeSet) Swap(i, j int) { n[i], n[j] = n[j], n[i] } +func (n NodeSet) Less(i, j int) bool { return n[i].Before(n[j]) } + // Add adds the nodes to the NodeSet. Add is the only valid way to grow a // NodeSet. Add returns the NodeSet to enable chaining. func (n NodeSet) Add(nodes ...Node) NodeSet { @@ -35,13 +45,12 @@ func (n NodeSet) Add(nodes ...Node) NodeSet { // It a new element, insert it in order. n = append(n, Node{}) copy(n[i+1:], n[i:]) - n[i] = node.Copy() + n[i] = node } return n } // Merge combines the two NodeSets and returns a new result. -// TODO: Make this more efficient func (n NodeSet) Merge(other NodeSet) NodeSet { switch { case len(other) <= 0: // Optimise special case, to avoid allocating @@ -49,9 +58,25 @@ func (n NodeSet) Merge(other NodeSet) NodeSet { case len(n) <= 0: return other } - result := n.Copy() - for _, node := range other { - result = result.Add(node) + + result := make([]Node, 0, len(n)+len(other)) + for len(n) > 0 || len(other) > 0 { + switch { + case len(n) == 0: + return append(result, other...) + case len(other) == 0: + return append(result, n...) + case n[0].Before(other[0]): + result = append(result, n[0]) + n = n[1:] + case n[0].After(other[0]): + result = append(result, other[0]) + other = other[1:] + default: // equal + result = append(result, other[0]) + n = n[1:] + other = other[1:] + } } return result } @@ -63,7 +88,7 @@ func (n NodeSet) Copy() NodeSet { } result := make(NodeSet, len(n)) for i, node := range n { - result[i] = node.Copy() + result[i] = node } return result } diff --git a/report/node_set_test.go b/report/node_set_test.go index 518701299..0bb0ee825 100644 --- a/report/node_set_test.go +++ b/report/node_set_test.go @@ -1,12 +1,15 @@ package report_test import ( + "fmt" "reflect" "testing" "github.com/weaveworks/scope/report" ) +var benchmarkResult report.NodeSet + type nodeSpec struct { topology string id string @@ -47,11 +50,28 @@ func TestMakeNodeSet(t *testing.T) { wants = append(wants, report.MakeNode().WithTopology(spec.topology).WithID(spec.id)) } if want, have := report.NodeSet(wants), report.MakeNodeSet(inputs...); !reflect.DeepEqual(want, have) { - t.Errorf("%#v: want %#v, have %#v", testcase.inputs, want, have) + t.Errorf("%#v: want %#v, have %#v", inputs, wants, have) } } } +func BenchmarkMakeNodeSet(b *testing.B) { + nodes := []report.Node{} + for i := 1000; i >= 0; i-- { + node := report.MakeNode().WithID(fmt.Sprint(i)).WithMetadata(map[string]string{ + "a": "1", + "b": "2", + }) + nodes = append(nodes, node) + } + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + benchmarkResult = report.MakeNodeSet(nodes...) + } +} + func TestNodeSetAdd(t *testing.T) { for _, testcase := range []struct { input report.NodeSet @@ -95,9 +115,37 @@ func TestNodeSetAdd(t *testing.T) { want: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("b"), report.MakeNode().WithID("c")), }, } { + originalLen := len(testcase.input) if want, have := testcase.want, testcase.input.Add(testcase.nodes...); !reflect.DeepEqual(want, have) { t.Errorf("%v + %v: want %v, have %v", testcase.input, testcase.nodes, want, have) } + if len(testcase.input) != originalLen { + t.Errorf("%v + %v: modified the original input!", testcase.input, testcase.nodes) + } + } +} + +func BenchmarkNodeSetAdd(b *testing.B) { + n := report.MakeNodeSet() + for i := 0; i < 600; i++ { + n = n.Add( + report.MakeNode().WithID(fmt.Sprint(i)).WithMetadata(map[string]string{ + "a": "1", + "b": "2", + }), + ) + } + + node := report.MakeNode().WithID("401.5").WithMetadata(map[string]string{ + "a": "1", + "b": "2", + }) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + benchmarkResult = n.Add(node) } } @@ -145,8 +193,39 @@ func TestNodeSetMerge(t *testing.T) { want: report.MakeNodeSet(report.MakeNode().WithID("a"), report.MakeNode().WithID("b")), }, } { + originalLen := len(testcase.input) if want, have := testcase.want, testcase.input.Merge(testcase.other); !reflect.DeepEqual(want, have) { t.Errorf("%v + %v: want %v, have %v", testcase.input, testcase.other, want, have) } + if len(testcase.input) != originalLen { + t.Errorf("%v + %v: modified the original input!", testcase.input, testcase.other) + } + } +} + +func BenchmarkNodeSetMerge(b *testing.B) { + n, other := report.MakeNodeSet(), report.MakeNodeSet() + for i := 0; i < 600; i++ { + n = n.Add( + report.MakeNode().WithID(fmt.Sprint(i)).WithMetadata(map[string]string{ + "a": "1", + "b": "2", + }), + ) + } + + for i := 400; i < 1000; i++ { + other = other.Add( + report.MakeNode().WithID(fmt.Sprint(i)).WithMetadata(map[string]string{ + "c": "1", + "d": "2", + }), + ) + } + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + benchmarkResult = n.Merge(other) } } diff --git a/report/topology.go b/report/topology.go index dc2dd1767..8ad064955 100644 --- a/report/topology.go +++ b/report/topology.go @@ -130,6 +130,21 @@ func (n Node) WithTopology(topology string) Node { return result } +// Before is used for sorting nodes by topology and id +func (n Node) Before(other Node) bool { + return n.Topology < other.Topology || (n.Topology == other.Topology && n.ID < other.ID) +} + +// Equal is used for comparing nodes by topology and id +func (n Node) Equal(other Node) bool { + return n.Topology == other.Topology && n.ID == other.ID +} + +// After is used for sorting nodes by topology and id +func (n Node) After(other Node) bool { + return other.Topology < n.Topology || (other.Topology == n.Topology && other.ID < n.ID) +} + // WithMetadata returns a fresh copy of n, with Metadata m merged in. func (n Node) WithMetadata(m map[string]string) Node { result := n.Copy() diff --git a/report/topology_test.go b/report/topology_test.go index 72beecc5d..19cbf244b 100644 --- a/report/topology_test.go +++ b/report/topology_test.go @@ -87,3 +87,25 @@ func TestStringSetMerge(t *testing.T) { } } } + +func TestNodeOrdering(t *testing.T) { + ids := [][2]string{{}, {"a", "0"}, {"a", "1"}, {"b", "0"}, {"b", "1"}, {"c", "3"}} + nodes := []report.Node{} + for _, id := range ids { + nodes = append(nodes, report.MakeNode().WithTopology(id[0]).WithID(id[1])) + } + + for i, node := range nodes { + if !node.Equal(node) { + t.Errorf("Expected %q %q == %q %q, but was not", node.Topology, node.ID, node.Topology, node.ID) + } + if i > 0 { + if !node.After(nodes[i-1]) { + t.Errorf("Expected %q %q > %q %q, but was not", node.Topology, node.ID, nodes[i-1].Topology, nodes[i-1].ID) + } + if !nodes[i-1].Before(node) { + t.Errorf("Expected %q %q < %q %q, but was not", nodes[i-1].Topology, nodes[i-1].ID, node.Topology, node.ID) + } + } + } +} From c43abd607598ec67a7f9ee3a4a045d4e2d2946f8 Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Tue, 19 Jan 2016 17:07:56 +0100 Subject: [PATCH 12/12] Reconcile some details-panel stuff after the rebase. --- client/app/scripts/actions/app-actions.js | 3 +++ client/app/scripts/components/app.js | 2 +- client/app/scripts/components/details.js | 6 +++--- client/app/scripts/components/node-details.js | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 436d9efe3..6a28ac297 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -159,7 +159,10 @@ export function hitEsc() { type: ActionTypes.CLICK_CLOSE_TERMINAL, pipeId: controlPipe.id }); + // Dont deselect node on ESC if there is a controlPipe (keep terminal open) } else if (AppStore.getSelectedNodeId() && !controlPipe) { + AppDispatcher.dispatch({type: ActionTypes.DESELECT_NODE}); + } updateRoute(); } diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 8259325b1..fbad26b07 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -81,7 +81,7 @@ export default class App extends React.Component {
{showingDebugToolbar() && } {showingDetails &&
} {showingTerminal && {details.map((obj, index) => { return ( - + ); })}
diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index a90c33766..1e96f284c 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -130,7 +130,7 @@ export default class NodeDetails extends React.Component { const showSummary = details.metadata !== undefined || details.metrics !== undefined; const showControls = details.controls && details.controls.length > 0; const nodeColor = getNodeColorDark(details.rank, details.label_major); - const {error, pending} = (this.props.controlStatus || {}); + const {error, pending} = (this.props.nodeControlStatus || {}); const tools = this.renderTools(); const styles = { controls: {