diff --git a/app/api_topologies.go b/app/api_topologies.go index 8c51f21e3..a76c851da 100644 --- a/app/api_topologies.go +++ b/app/api_topologies.go @@ -21,14 +21,60 @@ var ( ) func init() { - containerFilters := map[string][]APITopologyOption{ - "system": { - {"show", "System containers shown", false, render.FilterNoop}, - {"hide", "System containers hidden", true, render.FilterSystem}, + serviceFilters := []APITopologyOptionGroup{ + { + ID: "system", + Default: "application", + Options: []APITopologyOption{ + {"system", "System services", render.FilterApplication}, + {"application", "Application services", render.FilterSystem}, + {"both", "Both", render.FilterNoop}, + }, }, - "stopped": { - {"show", "Stopped containers shown", false, render.FilterNoop}, - {"hide", "Stopped containers hidden", true, render.FilterStopped}, + } + + podFilters := []APITopologyOptionGroup{ + { + ID: "system", + Default: "application", + Options: []APITopologyOption{ + {"system", "System pods", render.FilterApplication}, + {"application", "Application pods", render.FilterSystem}, + {"both", "Both", render.FilterNoop}, + }, + }, + } + + containerFilters := []APITopologyOptionGroup{ + { + ID: "system", + Default: "application", + Options: []APITopologyOption{ + {"system", "System containers", render.FilterApplication}, + {"application", "Application containers", render.FilterSystem}, + {"both", "Both", render.FilterNoop}, + }, + }, + { + ID: "stopped", + Default: "running", + Options: []APITopologyOption{ + {"stopped", "Stopped containers", render.FilterRunning}, + {"running", "Running containers", render.FilterStopped}, + {"both", "Both", render.FilterNoop}, + }, + }, + } + + unconnectedFilter := []APITopologyOptionGroup{ + { + ID: "unconnected", + Default: "hide", + Options: []APITopologyOption{ + // Show the user why there are filtered nodes in this view. + // Don't give them the option to show those nodes. + {"hide", "Unconnected nodes hidden", render.FilterNoop}, + }, }, } @@ -40,21 +86,14 @@ func init() { renderer: render.FilterUnconnected(render.ProcessWithContainerNameRenderer), Name: "Processes", Rank: 1, - Options: map[string][]APITopologyOption{"unconnected": { - // Show the user why there are filtered nodes in this view. - // Don't give them the option to show those nodes. - {"hide", "Unconnected nodes hidden", true, render.FilterNoop}, - }}, + Options: unconnectedFilter, }, APITopologyDesc{ id: "processes-by-name", parent: "processes", renderer: render.FilterUnconnected(render.ProcessNameRenderer), Name: "by name", - Options: map[string][]APITopologyOption{"unconnected": { - // Ditto above. - {"hide", "Unconnected nodes hidden", true, render.FilterNoop}, - }}, + Options: unconnectedFilter, }, APITopologyDesc{ id: "containers", @@ -82,7 +121,6 @@ func init() { renderer: render.HostRenderer, Name: "Hosts", Rank: 4, - Options: map[string][]APITopologyOption{}, }, APITopologyDesc{ id: "pods", @@ -90,10 +128,7 @@ func init() { Name: "Pods", Rank: 3, HideIfEmpty: true, - Options: map[string][]APITopologyOption{"system": { - {"show", "System pods shown", false, render.FilterNoop}, - {"hide", "System pods hidden", true, render.FilterSystem}, - }}, + Options: podFilters, }, APITopologyDesc{ id: "pods-by-service", @@ -101,10 +136,7 @@ func init() { renderer: render.PodServiceRenderer, Name: "by service", HideIfEmpty: true, - Options: map[string][]APITopologyOption{"system": { - {"show", "System services shown", false, render.FilterNoop}, - {"hide", "System services hidden", true, render.FilterSystem}, - }}, + Options: serviceFilters, }, ) } @@ -121,10 +153,10 @@ type APITopologyDesc struct { parent string renderer render.Renderer - Name string `json:"name"` - Rank int `json:"rank"` - HideIfEmpty bool `json:"hide_if_empty"` - Options map[string][]APITopologyOption `json:"options"` + Name string `json:"name"` + Rank int `json:"rank"` + HideIfEmpty bool `json:"hide_if_empty"` + Options []APITopologyOptionGroup `json:"options"` URL string `json:"url"` SubTopologies []APITopologyDesc `json:"sub_topologies,omitempty"` @@ -137,11 +169,17 @@ func (a byName) Len() int { return len(a) } func (a byName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a byName) Less(i, j int) bool { return a[i].Name < a[j].Name } +// APITopologyOptionGroup describes a group of APITopologyOptions +type APITopologyOptionGroup struct { + ID string `json:"id"` + Default string `json:"defaultValue,omitempty"` + Options []APITopologyOption `json:"options,omitempty"` +} + // APITopologyOption describes a ¶m=value to a given topology. type APITopologyOption struct { - Value string `json:"value"` - Display string `json:"display"` - Default bool `json:"default,omitempty"` + Value string `json:"value"` + Label string `json:"label"` decorator func(render.Renderer) render.Renderer } @@ -244,10 +282,10 @@ func decorateWithStats(rpt report.Report, renderer render.Renderer) topologyStat func renderedForRequest(r *http.Request, topology APITopologyDesc) render.Renderer { renderer := topology.renderer - for param, opts := range topology.Options { - value := r.FormValue(param) - for _, opt := range opts { - if (value == "" && opt.Default) || (opt.Value != "" && opt.Value == value) { + for _, group := range topology.Options { + value := r.FormValue(group.ID) + for _, opt := range group.Options { + if (value == "" && group.Default == opt.Value) || (opt.Value != "" && opt.Value == value) { renderer = opt.decorator(renderer) } } diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 6eebefec0..8e70a45a0 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -410,7 +410,8 @@ export default class NodesChart extends React.Component { scale: nodeScale, margins: MARGINS, forceRelayout: props.forceRelayout, - topologyId: this.props.topologyId + topologyId: this.props.topologyId, + topologyOptions: this.props.topologyOptions }; const timedLayouter = timely(doLayout); diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index 97b13fe4e..9707f1c66 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -26,6 +26,17 @@ function fromGraphNodeId(encodedId) { return encodedId.replace('', '.'); } +function buildCacheIdFromOptions(options) { + if (options) { + let id = options.topologyId; + if (options.topologyOptions) { + id += JSON.stringify(options.topologyOptions); + } + return id; + } + return ''; +} + /** * Layout engine runner * After the layout engine run nodes and edges have x-y-coordinates. Engine is @@ -343,18 +354,18 @@ function copyLayoutProperties(layout, nodeCache, edgeCache) { */ export function doLayout(immNodes, immEdges, opts) { const options = opts || {}; - const topologyId = options.topologyId || 'noId'; + const cacheId = buildCacheIdFromOptions(options); // one engine and node and edge caches per topology, to keep renderings similar - if (!topologyCaches[topologyId]) { - topologyCaches[topologyId] = { + if (!topologyCaches[cacheId]) { + topologyCaches[cacheId] = { nodeCache: makeMap(), edgeCache: makeMap(), graph: new dagre.graphlib.Graph({}) }; } - const cache = topologyCaches[topologyId]; + const cache = topologyCaches[cacheId]; const cachedLayout = options.cachedLayout || cache.cachedLayout; const nodeCache = options.nodeCache || cache.nodeCache; const edgeCache = options.edgeCache || cache.edgeCache; diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index cf54673a6..d2e37fa58 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -107,6 +107,7 @@ export default class App extends React.Component { highlightedEdgeIds={this.state.highlightedEdgeIds} detailsWidth={detailsWidth} selectedNodeId={this.state.selectedNodeId} topMargin={topMargin} forceRelayout={this.state.forceRelayout} + topologyOptions={this.state.activeTopologyOptions} topologyId={this.state.currentTopologyId} /> diff --git a/client/app/scripts/components/topology-option-action.js b/client/app/scripts/components/topology-option-action.js index 665bbe251..65196d5c5 100644 --- a/client/app/scripts/components/topology-option-action.js +++ b/client/app/scripts/components/topology-option-action.js @@ -3,6 +3,7 @@ import React from 'react'; import { changeTopologyOption } from '../actions/app-actions'; export default class TopologyOptionAction extends React.Component { + constructor(props, context) { super(props, context); this.onClick = this.onClick.bind(this); @@ -10,14 +11,18 @@ export default class TopologyOptionAction extends React.Component { onClick(ev) { ev.preventDefault(); - changeTopologyOption(this.props.option, this.props.value, this.props.topologyId); + const { optionId, topologyId, item } = this.props; + changeTopologyOption(optionId, item.get('value'), topologyId); } render() { + const { activeValue, item } = this.props; + const className = activeValue === item.get('value') + ? 'topology-option-action topology-option-action-selected' : 'topology-option-action'; return ( - - {this.props.value} - +
+ {item.get('label')} +
); } } diff --git a/client/app/scripts/components/topology-options.js b/client/app/scripts/components/topology-options.js index af67b6186..31f02e583 100644 --- a/client/app/scripts/components/topology-options.js +++ b/client/app/scripts/components/topology-options.js @@ -3,66 +3,28 @@ import React from 'react'; import TopologyOptionAction from './topology-option-action'; export default class TopologyOptions extends React.Component { - renderAction(action, option, topologyId) { - return ( - - ); - } - /** - * transforms a list of options into one sidebar-item. - * The sidebar text comes from the active option. the actions come from the - * remaining items. - */ - renderOption(items) { - let activeText; - let activeValue; - const actions = []; - const activeOptions = this.props.activeOptions; - const topologyId = this.props.topologyId; - const option = items.first().get('option'); - - // find active option value - if (activeOptions && activeOptions.has(option)) { - activeValue = activeOptions.get(option); - } else { - // get default value - items.forEach(item => { - if (item.get('default')) { - activeValue = item.get('value'); - } - }); - } - - // render active option as text, add other options as actions - items.forEach(item => { - if (item.get('value') === activeValue) { - activeText = item.get('display'); - } else { - actions.push(this.renderAction(item.get('value'), item.get('option'), topologyId)); - } - }, this); + renderOption(option) { + const { activeOptions, topologyId } = this.props; + const optionId = option.get('id'); + const activeValue = activeOptions && activeOptions.has(optionId) + ? activeOptions.get(optionId) : option.get('defaultValue'); return ( -
- {activeText} - - {actions} - +
+
+ {option.get('options').map(item => )} +
); } render() { - const options = this.props.options.map((items, optionId) => { - let itemsMap = items.map(item => item.set('option', optionId)); - itemsMap = itemsMap.set('option', optionId); - return itemsMap; - }); - return (
- {options.toIndexedSeq().map(items => this.renderOption(items))} + {this.props.options.toIndexedSeq().map(option => this.renderOption(option))}
); } diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js index 85065a7f8..5ccee481b 100644 --- a/client/app/scripts/stores/__tests__/app-store-test.js +++ b/client/app/scripts/stores/__tests__/app-store-test.js @@ -122,12 +122,14 @@ describe('AppStore', () => { topologies: [{ url: '/topo1', name: 'Topo1', - options: { - option1: [ + options: [{ + id: 'option1', + defaultValue: 'off', + options: [ {value: 'on'}, - {value: 'off', default: true} + {value: 'off'} ] - }, + }], stats: { node_count: 1 }, @@ -165,13 +167,13 @@ describe('AppStore', () => { }); it('get current topology', () => { - registeredCallback(ClickTopologyAction); registeredCallback(ReceiveTopologiesAction); + registeredCallback(ClickTopologyAction); expect(AppStore.getTopologies().size).toBe(2); expect(AppStore.getCurrentTopology().get('name')).toBe('Topo1'); expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1'); - expect(AppStore.getCurrentTopologyOptions().get('option1')).toBeDefined(); + expect(AppStore.getCurrentTopologyOptions().first().get('id')).toBe('option1'); }); it('get sub-topology', () => { diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index cd79d84d2..66abc2c2a 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -84,13 +84,11 @@ function setTopology(topologyId) { function setDefaultTopologyOptions(topologyList) { topologyList.forEach(topology => { let defaultOptions = makeOrderedMap(); - if (topology.has('options')) { - topology.get('options').forEach((items, option) => { - items.forEach(item => { - if (item.get('default') === true) { - defaultOptions = defaultOptions.set(option, item.get('value')); - } - }); + if (topology.has('options') && topology.get('options')) { + topology.get('options').forEach((option) => { + const optionId = option.get('id'); + const defaultValue = option.get('defaultValue'); + defaultOptions = defaultOptions.set(optionId, defaultValue); }); } diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 27198b429..ff19b8562 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -36,6 +36,7 @@ @details-window-width: 420px; @details-window-padding-left: 36px; +@border-radius: 4px; @terminal-header-height: 34px; @@ -259,7 +260,7 @@ h2 { .btn-opacity; cursor: pointer; padding: 4px 8px; - border-radius: 4px; + border-radius: @border-radius; opacity: 0.8; margin-bottom: 3px; @@ -943,48 +944,76 @@ h2 { .status { text-transform: uppercase; + padding: 2px 12px; + border-radius: @border-radius; + color: @text-secondary-color; + display: inline-block; &-icon { - font-size: 16px; + font-size: 1rem; position: relative; - top: 1px; - margin-right: 0.5em; + top: 0.125rem; + margin-right: 0.25rem; } &.status-loading { - animation: status-loading 2.0s infinite ease-in-out; + animation: blinking 2.0s infinite ease-in-out; text-transform: none; + color: @text-color; } } +.topology-options { + + .topology-option { + color: @text-secondary-color; + margin: 6px 0; + + &:last-child { + margin-bottom: 0; + } + + &-wrapper { + border-radius: @border-radius; + border: 1px solid @background-darker-color; + display: inline-block; + } + + &-action { + .btn-opacity; + padding: 3px 12px; + cursor: pointer; + display: inline-block; + + &-selected, &:hover { + color: @text-darker-color; + background-color: @background-darker-color; + } + + &-selected { + cursor: default; + } + + &:first-child { + border-left: none; + border-top-left-radius: @border-radius; + border-bottom-left-radius: @border-radius; + } + + &:last-child { + border-top-right-radius: @border-radius; + border-bottom-right-radius: @border-radius; + } + } + } + +} + .sidebar { position: fixed; bottom: 16px; left: 16px; font-size: .7rem; - - &-item { - color: @text-secondary-color; - border-radius: 2px; - padding: 2px 8px; - width: 100%; - - &.status { - padding: 4px 8px; - margin-bottom: 4px; - } - - &-action { - .btn-opacity; - text-transform: uppercase; - font-weight: bold; - color: darken(@weave-orange, 25%); - cursor: pointer; - font-size: 90%; - margin-left: 0.5em; - opacity: @link-opacity-default; - } - } } @keyframes blinking { @@ -995,16 +1024,6 @@ h2 { } } -@keyframes status-loading { - 0%, 100% { - background-color: @background-darker-secondary-color; - color: @text-secondary-color; - } 50% { - background-color: @background-darker-color; - color: @text-color; - } -} - // // Debug panel! // diff --git a/render/filters.go b/render/filters.go index 4aeb2bdc8..d91800066 100644 --- a/render/filters.go +++ b/render/filters.go @@ -1,10 +1,6 @@ package render import ( - "strings" - - "github.com/weaveworks/scope/probe/docker" - "github.com/weaveworks/scope/probe/kubernetes" "github.com/weaveworks/scope/report" ) @@ -125,6 +121,12 @@ 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" +// Complement takes a FilterFunc f and returns a FilterFunc that has the same +// effects, if any, and returns the opposite truth value. +func Complement(f func(RenderableNode) bool) func(RenderableNode) bool { + return func(node RenderableNode) bool { return !f(node) } +} + // FilterPseudo produces a renderer that removes pseudo nodes from the given // renderer func FilterPseudo(r Renderer) Renderer { @@ -155,44 +157,22 @@ func FilterNoop(in Renderer) Renderer { // FilterStopped filters out stopped containers. func FilterStopped(r Renderer) Renderer { - return MakeFilter( - func(node RenderableNode) bool { - containerState, ok := node.Latest.Lookup(docker.ContainerState) - return !ok || containerState != docker.StateStopped - }, - r, - ) + return MakeFilter(RenderableNode.IsStopped, r) +} + +// FilterRunning filters out running containers. +func FilterRunning(r Renderer) Renderer { + return MakeFilter(Complement(RenderableNode.IsStopped), r) } // FilterSystem is a Renderer which filters out system nodes. func FilterSystem(r Renderer) Renderer { - return MakeFilter( - func(node RenderableNode) bool { - containerName, _ := node.Latest.Lookup(docker.ContainerName) - if _, ok := systemContainerNames[containerName]; ok { - return false - } - imageName, _ := node.Latest.Lookup(docker.ImageName) - imagePrefix := strings.SplitN(imageName, ":", 2)[0] // :( - if _, ok := systemImagePrefixes[imagePrefix]; ok { - return false - } - roleLabel, _ := node.Latest.Lookup(docker.LabelPrefix + "works.weave.role") - if roleLabel == "system" { - return false - } - namespace, _ := node.Latest.Lookup(kubernetes.Namespace) - if namespace == "kube-system" { - return false - } - podName, _ := node.Latest.Lookup(docker.LabelPrefix + "io.kubernetes.pod.name") - if strings.HasPrefix(podName, "kube-system/") { - return false - } - return true - }, - r, - ) + return MakeFilter(RenderableNode.IsSystem, r) +} + +// FilterApplication is a Renderer which filters out system nodes. +func FilterApplication(r Renderer) Renderer { + return MakeFilter(Complement(RenderableNode.IsSystem), r) } var systemContainerNames = map[string]struct{}{ diff --git a/render/renderable_node.go b/render/renderable_node.go index 847057e00..97af4e136 100644 --- a/render/renderable_node.go +++ b/render/renderable_node.go @@ -1,6 +1,10 @@ package render import ( + "strings" + + "github.com/weaveworks/scope/probe/docker" + "github.com/weaveworks/scope/probe/kubernetes" "github.com/weaveworks/scope/report" ) @@ -153,6 +157,38 @@ func (rn RenderableNode) Copy() RenderableNode { } } +// IsStopped checks if the RenderableNode is a stopped docker container. +func (rn RenderableNode) IsStopped() bool { + containerState, ok := rn.Latest.Lookup(docker.ContainerState) + return !ok || containerState != docker.StateStopped +} + +// IsSystem checks if the RenderableNode is a system container/pod/etc. +func (rn RenderableNode) IsSystem() bool { + containerName, _ := rn.Latest.Lookup(docker.ContainerName) + if _, ok := systemContainerNames[containerName]; ok { + return false + } + imageName, _ := rn.Latest.Lookup(docker.ImageName) + imagePrefix := strings.SplitN(imageName, ":", 2)[0] // :( + if _, ok := systemImagePrefixes[imagePrefix]; ok { + return false + } + roleLabel, _ := rn.Latest.Lookup(docker.LabelPrefix + "works.weave.role") + if roleLabel == "system" { + return false + } + namespace, _ := rn.Latest.Lookup(kubernetes.Namespace) + if namespace == "kube-system" { + return false + } + podName, _ := rn.Latest.Lookup(docker.LabelPrefix + "io.kubernetes.pod.name") + if strings.HasPrefix(podName, "kube-system/") { + return false + } + return true +} + // Prune returns a copy of the RenderableNode with all information not // strictly necessary for rendering nodes and edges stripped away. // Specifically, that means cutting out parts of the Node.