Merge pull request #1159 from weaveworks/node-states

Topology Filters
This commit is contained in:
David
2016-03-29 13:54:38 +02:00
11 changed files with 237 additions and 184 deletions

View File

@@ -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 &param=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)
}
}

View File

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

View File

@@ -26,6 +26,17 @@ function fromGraphNodeId(encodedId) {
return encodedId.replace('<DOT>', '.');
}
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;

View File

@@ -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} />
<Sidebar>

View File

@@ -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 (
<span className="sidebar-item-action" onClick={this.onClick}>
{this.props.value}
</span>
<div className={className} onClick={this.onClick}>
{item.get('label')}
</div>
);
}
}

View File

@@ -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 (
<TopologyOptionAction option={option} value={action} topologyId={topologyId} key={action} />
);
}
/**
* 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 (
<div className="sidebar-item" key={option}>
{activeText}
<span className="sidebar-item-actions">
{actions}
</span>
<div className="topology-option" key={optionId}>
<div className="topology-option-wrapper">
{option.get('options').map(item => <TopologyOptionAction
optionId={optionId} topologyId={topologyId} key={item.get('value')}
activeValue={activeValue} item={item} />)}
</div>
</div>
);
}
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 (
<div className="topology-options">
{options.toIndexedSeq().map(items => this.renderOption(items))}
{this.props.options.toIndexedSeq().map(option => this.renderOption(option))}
</div>
);
}

View File

@@ -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', () => {

View File

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

View File

@@ -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!
//

View File

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

View File

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