);
}
diff --git a/client/app/scripts/components/node-details/node-details-health-link-item.js b/client/app/scripts/components/node-details/node-details-health-link-item.js
new file mode 100644
index 000000000..a70ef35bf
--- /dev/null
+++ b/client/app/scripts/components/node-details/node-details-health-link-item.js
@@ -0,0 +1,47 @@
+import React from 'react';
+
+import NodeDetailsHealthItem from './node-details-health-item';
+
+export default class NodeDetailsHealthLinkItem extends React.Component {
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.handleClick = this.handleClick.bind(this);
+ this.buildHref = this.buildHref.bind(this);
+ }
+
+ handleClick(ev, href) {
+ ev.preventDefault();
+ if (!href) return;
+
+ const {router} = this.props;
+ if (router && href[0] === '/') {
+ router.push(href);
+ } else {
+ location.href = href;
+ }
+ }
+
+ buildHref(url) {
+ if (!url || !this.props.isCloud) return url;
+ return url.replace(/:orgid/gi, encodeURIComponent(this.props.params.orgId));
+ }
+
+ render() {
+ const {links, withoutGraph, ...props} = this.props;
+ const href = this.buildHref(links[props.id] && links[props.id].url);
+
+ if (!href) return
+ );
+ }
+}
diff --git a/client/app/scripts/components/node-details/node-details-health.js b/client/app/scripts/components/node-details/node-details-health.js
index c51556264..193e1dc6d 100644
--- a/client/app/scripts/components/node-details/node-details-health.js
+++ b/client/app/scripts/components/node-details/node-details-health.js
@@ -2,7 +2,8 @@ import React from 'react';
import ShowMore from '../show-more';
import NodeDetailsHealthOverflow from './node-details-health-overflow';
-import NodeDetailsHealthItem from './node-details-health-item';
+import NodeDetailsHealthLinkItem from './node-details-health-link-item';
+import CloudFeature from '../cloud-feature';
export default class NodeDetailsHealth extends React.Component {
@@ -21,6 +22,9 @@ export default class NodeDetailsHealth extends React.Component {
render() {
const metrics = this.props.metrics || [];
+ const metricLinks = this.props.metricLinks || {};
+ const unattachedLinks = this.props.unattachedLinks || {};
+ const hasUnattached = Object.keys(unattachedLinks).length > 0;
const primeCutoff = metrics.length > 3 && !this.state.expanded ? 2 : metrics.length;
const primeMetrics = metrics.slice(0, primeCutoff);
const overflowMetrics = metrics.slice(primeCutoff);
@@ -32,7 +36,12 @@ export default class NodeDetailsHealth extends React.Component {
return (
- {primeMetrics.map(item =>
)}
+ {primeMetrics.map(item =>
+
+ )}
{showOverflow &&
+
+ {hasUnattached &&
+ {Object.keys(unattachedLinks).map(id =>
+
+ )}
+
}
);
}
diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss
index 63c31c36b..a62fd53d0 100644
--- a/client/app/styles/_base.scss
+++ b/client/app/styles/_base.scss
@@ -931,12 +931,31 @@
color: $text-secondary-color;
text-transform: uppercase;
font-size: 80%;
+
+ .fa {
+ margin-left: 0.5em;
+ }
}
- &-value {
- color: $text-secondary-color;
- font-size: 150%;
- padding-bottom: 0.5em;
+ &-placeholder {
+ font-size: 200%;
+ opacity: 0.2;
+ margin-bottom: 0.2em;
+ }
+ }
+
+ &-link-item {
+ @extend .btn-opacity;
+ cursor: pointer;
+ opacity: $link-opacity-default;
+ width: 33%;
+
+ .label {
+ text-transform: uppercase;
+ }
+
+ .node-details-health-item {
+ width: auto;
}
}
}
diff --git a/prog/app.go b/prog/app.go
index 5b06948d4..2647ddb41 100644
--- a/prog/app.go
+++ b/prog/app.go
@@ -18,17 +18,18 @@ import (
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus"
"github.com/tylerb/graceful"
- "github.com/weaveworks/go-checkpoint"
- "github.com/weaveworks/scope/common/xfer"
- "github.com/weaveworks/weave/common"
billing "github.com/weaveworks/billing-client"
"github.com/weaveworks/common/middleware"
"github.com/weaveworks/common/network"
+ "github.com/weaveworks/go-checkpoint"
"github.com/weaveworks/scope/app"
"github.com/weaveworks/scope/app/multitenant"
"github.com/weaveworks/scope/common/weave"
+ "github.com/weaveworks/scope/common/xfer"
"github.com/weaveworks/scope/probe/docker"
+ "github.com/weaveworks/scope/render/detailed"
+ "github.com/weaveworks/weave/common"
)
const (
@@ -216,6 +217,7 @@ func appMain(flags appFlags) {
setLogLevel(flags.logLevel)
setLogFormatter(flags.logPrefix)
runtime.SetBlockProfileRate(flags.blockProfileRate)
+ detailed.SetMetricsGraphURL(flags.metricsGraphURL)
defer log.Info("app exiting")
rand.Seed(time.Now().UnixNano())
diff --git a/prog/main.go b/prog/main.go
index 82e1a6b98..02d254e16 100644
--- a/prog/main.go
+++ b/prog/main.go
@@ -160,6 +160,7 @@ type appFlags struct {
memcachedCompressionLevel int
userIDHeader string
externalUI bool
+ metricsGraphURL string
blockProfileRate int
@@ -355,6 +356,7 @@ func setupFlags(flags *flags) {
flag.IntVar(&flags.app.memcachedCompressionLevel, "app.memcached.compression", gzip.DefaultCompression, "How much to compress reports stored in memcached.")
flag.StringVar(&flags.app.userIDHeader, "app.userid.header", "", "HTTP header to use as userid")
flag.BoolVar(&flags.app.externalUI, "app.externalUI", false, "Point to externally hosted static UI assets")
+ flag.StringVar(&flags.app.metricsGraphURL, "app.metrics-graph", "", "Enable extended metrics graph by providing a templated URL (supports :orgID and :query)")
flag.IntVar(&flags.app.blockProfileRate, "app.block.profile.rate", 0, "If more than 0, enable block profiling. The profiler aims to sample an average of one blocking event per rate nanoseconds spent blocked.")
diff --git a/prog/probe.go b/prog/probe.go
index a3f9806d5..3eed53cff 100644
--- a/prog/probe.go
+++ b/prog/probe.go
@@ -13,11 +13,10 @@ import (
log "github.com/Sirupsen/logrus"
"github.com/armon/go-metrics"
"github.com/prometheus/client_golang/prometheus"
- "github.com/weaveworks/go-checkpoint"
- "github.com/weaveworks/weave/common"
"github.com/weaveworks/common/network"
"github.com/weaveworks/common/sanitize"
+ "github.com/weaveworks/go-checkpoint"
"github.com/weaveworks/scope/common/hostname"
"github.com/weaveworks/scope/common/weave"
"github.com/weaveworks/scope/common/xfer"
@@ -33,6 +32,7 @@ import (
"github.com/weaveworks/scope/probe/plugins"
"github.com/weaveworks/scope/probe/process"
"github.com/weaveworks/scope/report"
+ "github.com/weaveworks/weave/common"
)
const (
diff --git a/render/detailed/links.go b/render/detailed/links.go
new file mode 100644
index 000000000..f811fc429
--- /dev/null
+++ b/render/detailed/links.go
@@ -0,0 +1,167 @@
+package detailed
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/url"
+ "strings"
+ "text/template"
+
+ "github.com/weaveworks/scope/probe/docker"
+ "github.com/weaveworks/scope/report"
+)
+
+// MetricLink describes a link referencing a metric.
+type MetricLink struct {
+ // References the metric id
+ ID string `json:"id,omitempty"`
+ Label string `json:"label"`
+ URL string `json:"url"`
+ Priority int `json:"priority"`
+}
+
+// Variable name for the query within the metrics graph url
+const urlQueryVarName = ":query"
+
+var (
+ // As configured by the user
+ metricsGraphURL = ""
+
+ // Available metric links
+ linkTemplates = []MetricLink{
+ {ID: docker.CPUTotalUsage, Label: "CPU", Priority: 1},
+ {ID: docker.MemoryUsage, Label: "Memory", Priority: 2},
+ }
+
+ // Prometheus queries for topologies
+ topologyQueries = map[string]map[string]*template.Template{
+ report.Pod: {
+ docker.MemoryUsage: prepareTemplate(`sum(container_memory_usage_bytes{pod_name="{{.Label}}"})`),
+ docker.CPUTotalUsage: prepareTemplate(`sum(rate(container_cpu_usage_seconds_total{pod_name="{{.Label}}"}[1m]))`),
+ },
+ report.ReplicaSet: {
+ docker.MemoryUsage: prepareTemplate(`sum(container_memory_usage_bytes{pod_name=~"{{.Label}}-.+"})`),
+ docker.CPUTotalUsage: prepareTemplate(`sum(rate(container_cpu_usage_seconds_total{pod_name=~"{{.Label}}-.+"}[1m]))`),
+ },
+ report.Deployment: {
+ docker.MemoryUsage: prepareTemplate(`sum(container_memory_usage_bytes{pod_name=~"{{.Label}}-[0-9]+-[^-]+"})`),
+ docker.CPUTotalUsage: prepareTemplate(`sum(rate(container_cpu_usage_seconds_total{pod_name=~"{{.Label}}-[0-9]+-[^-]+"}[1m]))`),
+ },
+ report.DaemonSet: {
+ docker.MemoryUsage: prepareTemplate(`namespace_name:container_memory_usage_bytes:sum{name="{{.Label}}",monitor=""}`),
+ docker.CPUTotalUsage: prepareTemplate(`namespace_name:container_cpu_usage_seconds_total:sum_rate{name="{{.Label}}"}`),
+ },
+ report.Service: {
+ docker.MemoryUsage: prepareTemplate(`namespace_name:container_memory_usage_bytes:sum{name="{{.Label}}",monitor=""}`),
+ docker.CPUTotalUsage: prepareTemplate(`namespace_name:container_cpu_usage_seconds_total:sum_rate{name="{{.Label}}"}`),
+ },
+ }
+)
+
+// SetMetricsGraphURL sets the URL we deduce our eventual metric link from.
+// Supports placeholders such as `:orgID` and `:query`. An empty url disables
+// this feature. If the `:query` part is missing, a JSON version will be
+// appended, see `queryParamsAsJSON()` for more info.
+func SetMetricsGraphURL(url string) {
+ metricsGraphURL = url
+}
+
+// NodeLinks returns the links of a node. The links are collected
+// by a predefined set but filtered depending on whether a query
+// is configured or not for the particular topology.
+func NodeMetricLinks(_ report.Report, n report.Node) []MetricLink {
+ if metricsGraphURL == "" {
+ return nil
+ }
+
+ queries := topologyQueries[n.Topology]
+ if len(queries) == 0 {
+ return nil
+ }
+
+ links := []MetricLink{}
+ for _, link := range linkTemplates {
+ if _, ok := queries[link.ID]; ok {
+ links = append(links, link)
+ }
+ }
+
+ return links
+}
+
+// RenderLinks executes the templated links by supplying the node summary as data.
+// It returns the modified summary.
+func RenderMetricLinks(summary NodeSummary, n report.Node) NodeSummary {
+ queries := topologyQueries[n.Topology]
+ if len(queries) == 0 || len(summary.MetricLinks) == 0 {
+ return summary
+ }
+
+ links := []MetricLink{}
+ var bs bytes.Buffer
+ for _, link := range summary.MetricLinks {
+ tpl := queries[link.ID]
+ if tpl == nil {
+ continue
+ }
+
+ bs.Reset()
+ if err := tpl.Execute(&bs, summary); err != nil {
+ continue
+ }
+
+ link.URL = buildURL(bs.String())
+ links = append(links, link)
+ }
+ summary.MetricLinks = links
+
+ return summary
+}
+
+// buildURL puts together the URL by looking at the configured
+// `metricsGraphURL`.
+func buildURL(query string) string {
+ if strings.Contains(metricsGraphURL, urlQueryVarName) {
+ return strings.Replace(metricsGraphURL, urlQueryVarName, url.PathEscape(query), -1)
+ }
+
+ params, err := queryParamsAsJSON(query)
+ if err != nil {
+ return ""
+ }
+
+ if metricsGraphURL[len(metricsGraphURL)-1] != '/' {
+ metricsGraphURL += "/"
+ }
+
+ return metricsGraphURL + url.PathEscape(params)
+}
+
+// queryParamsAsJSON packs the query into a JSON of the
+// format `{"cells":[{"queries":[$query]}]}`.
+func queryParamsAsJSON(query string) (string, error) {
+ type cell struct {
+ Queries []string `json:"queries"`
+ }
+ type queryParams struct {
+ Cells []cell `json:"cells"`
+ }
+ params := &queryParams{[]cell{{[]string{query}}}}
+
+ bs, err := json.Marshal(params)
+ if err != nil {
+ return "", err
+ }
+
+ return string(bs), nil
+}
+
+// prepareTemplate initializes unnamed text templates.
+func prepareTemplate(query string) *template.Template {
+ tpl, err := template.New("").Parse(query)
+ if err != nil {
+ panic(err)
+ }
+
+ return tpl
+}
diff --git a/render/detailed/links_test.go b/render/detailed/links_test.go
new file mode 100644
index 000000000..f17f4d9a9
--- /dev/null
+++ b/render/detailed/links_test.go
@@ -0,0 +1,76 @@
+package detailed_test
+
+import (
+ "testing"
+
+ "github.com/weaveworks/scope/probe/docker"
+ "github.com/weaveworks/scope/render/detailed"
+ "github.com/weaveworks/scope/report"
+ "github.com/weaveworks/scope/test/fixture"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNodeMetricLinks_DefaultDisabled(t *testing.T) {
+ links := detailed.NodeMetricLinks(fixture.Report, fixture.Report.Pod.Nodes[fixture.ClientPodNodeID])
+ assert.Nil(t, links)
+}
+
+func TestNodeMetricLinks_UnknownTopology(t *testing.T) {
+ detailed.SetMetricsGraphURL("/foo")
+ node := report.MakeNode("foo").WithTopology("bar")
+
+ links := detailed.NodeMetricLinks(report.Report{}, node)
+ assert.Nil(t, links)
+}
+
+func TestNodeMetricLinks(t *testing.T) {
+ detailed.SetMetricsGraphURL("/foo")
+ defer detailed.SetMetricsGraphURL("")
+ node := fixture.Report.Pod.Nodes[fixture.ClientPodNodeID]
+ expected := []detailed.MetricLink{
+ {ID: docker.CPUTotalUsage, Label: "CPU", Priority: 1, URL: ""},
+ {ID: docker.MemoryUsage, Label: "Memory", Priority: 2, URL: ""},
+ }
+
+ links := detailed.NodeMetricLinks(fixture.Report, node)
+ assert.Equal(t, expected, links)
+}
+
+func TestRenderMetricLinks_UnknownTopology(t *testing.T) {
+ summary := detailed.NodeSummary{}
+ node := report.MakeNode("foo").WithTopology("bar")
+
+ result := detailed.RenderMetricLinks(summary, node)
+ assert.Equal(t, summary, result)
+}
+
+func TestRenderMetricLinks_Pod(t *testing.T) {
+ detailed.SetMetricsGraphURL("/prom/:orgID/notebook/new")
+ defer detailed.SetMetricsGraphURL("")
+ node := fixture.Report.Pod.Nodes[fixture.ClientPodNodeID]
+ summary := detailed.NodeSummary{Label: "woo", MetricLinks: detailed.NodeMetricLinks(fixture.Report, node)}
+
+ result := detailed.RenderMetricLinks(summary, node)
+ assert.Equal(t,
+ "/prom/:orgID/notebook/new/%7B%22cells%22:%5B%7B%22queries%22:%5B%22sum%28rate%28container_cpu_usage_seconds_total%7Bpod_name=%5C%22woo%5C%22%7D%5B1m%5D%29%29%22%5D%7D%5D%7D",
+ result.MetricLinks[0].URL)
+ assert.Equal(t,
+ "/prom/:orgID/notebook/new/%7B%22cells%22:%5B%7B%22queries%22:%5B%22sum%28container_memory_usage_bytes%7Bpod_name=%5C%22woo%5C%22%7D%29%22%5D%7D%5D%7D",
+ result.MetricLinks[1].URL)
+}
+
+func TestRenderMetricLinks_QueryReplacement(t *testing.T) {
+ detailed.SetMetricsGraphURL("/foo/:orgID/bar?q=:query")
+ defer detailed.SetMetricsGraphURL("")
+ node := fixture.Report.Pod.Nodes[fixture.ClientPodNodeID]
+ summary := detailed.NodeSummary{Label: "boo", MetricLinks: detailed.NodeMetricLinks(fixture.Report, node)}
+
+ result := detailed.RenderMetricLinks(summary, node)
+ assert.Equal(t,
+ "/foo/:orgID/bar?q=sum%28rate%28container_cpu_usage_seconds_total%7Bpod_name=%22boo%22%7D%5B1m%5D%29%29",
+ result.MetricLinks[0].URL)
+ assert.Equal(t,
+ "/foo/:orgID/bar?q=sum%28container_memory_usage_bytes%7Bpod_name=%22boo%22%7D%29",
+ result.MetricLinks[1].URL)
+}
diff --git a/render/detailed/summary.go b/render/detailed/summary.go
index de46963cc..5a0a8d115 100644
--- a/render/detailed/summary.go
+++ b/render/detailed/summary.go
@@ -44,19 +44,20 @@ type Column struct {
// NodeSummary is summary information about a child for a Node.
type NodeSummary struct {
- ID string `json:"id"`
- Label string `json:"label"`
- LabelMinor string `json:"labelMinor"`
- Rank string `json:"rank"`
- Shape string `json:"shape,omitempty"`
- Stack bool `json:"stack,omitempty"`
- Linkable bool `json:"linkable,omitempty"` // Whether this node can be linked-to
- Pseudo bool `json:"pseudo,omitempty"`
- Metadata []report.MetadataRow `json:"metadata,omitempty"`
- Parents []Parent `json:"parents,omitempty"`
- Metrics []report.MetricRow `json:"metrics,omitempty"`
- Tables []report.Table `json:"tables,omitempty"`
- Adjacency report.IDList `json:"adjacency,omitempty"`
+ ID string `json:"id"`
+ Label string `json:"label"`
+ LabelMinor string `json:"labelMinor"`
+ Rank string `json:"rank"`
+ Shape string `json:"shape,omitempty"`
+ Stack bool `json:"stack,omitempty"`
+ Linkable bool `json:"linkable,omitempty"` // Whether this node can be linked-to
+ Pseudo bool `json:"pseudo,omitempty"`
+ Metadata []report.MetadataRow `json:"metadata,omitempty"`
+ Parents []Parent `json:"parents,omitempty"`
+ Metrics []report.MetricRow `json:"metrics,omitempty"`
+ Tables []report.Table `json:"tables,omitempty"`
+ Adjacency report.IDList `json:"adjacency,omitempty"`
+ MetricLinks []MetricLink `json:"metric_links,omitempty"`
}
var renderers = map[string]func(NodeSummary, report.Node) (NodeSummary, bool){
@@ -106,7 +107,8 @@ func MakeNodeSummary(r report.Report, n report.Node) (NodeSummary, bool) {
if renderer, ok := renderers[n.Topology]; ok {
// Skip (and don't fall through to fallback) if renderer maps to nil
if renderer != nil {
- return renderer(baseNodeSummary(r, n), n)
+ summary, b := renderer(baseNodeSummary(r, n), n)
+ return RenderMetricLinks(summary, n), b
}
} else if _, ok := r.Topology(n.Topology); ok {
summary := baseNodeSummary(r, n)
@@ -133,14 +135,15 @@ func (n NodeSummary) SummarizeMetrics() NodeSummary {
func baseNodeSummary(r report.Report, n report.Node) NodeSummary {
t, _ := r.Topology(n.Topology)
return NodeSummary{
- ID: n.ID,
- Shape: t.GetShape(),
- Linkable: true,
- Metadata: NodeMetadata(r, n),
- Metrics: NodeMetrics(r, n),
- Parents: Parents(r, n),
- Tables: NodeTables(r, n),
- Adjacency: n.Adjacency,
+ ID: n.ID,
+ Shape: t.GetShape(),
+ Linkable: true,
+ Metadata: NodeMetadata(r, n),
+ Metrics: NodeMetrics(r, n),
+ MetricLinks: NodeMetricLinks(r, n),
+ Parents: Parents(r, n),
+ Tables: NodeTables(r, n),
+ Adjacency: n.Adjacency,
}
}
diff --git a/render/expected/expected.go b/render/expected/expected.go
index f687f501d..3024f3127 100644
--- a/render/expected/expected.go
+++ b/render/expected/expected.go
@@ -9,7 +9,6 @@ import (
"github.com/weaveworks/scope/test/fixture"
)
-// Exported for testing.
var (
circle = "circle"
square = "square"