From 702220f2ce7820023a97cde318eda894f306ace0 Mon Sep 17 00:00:00 2001 From: Roland Schilter Date: Thu, 29 Jun 2017 11:11:33 +0200 Subject: [PATCH] Link scope-ui graphs clickable to prometheus queries scope-app: - Adds `-app.metrics-graph` cli flag for configuring the base url to use for graph links; supports `:orgID` and `:query` placeholders - Renders `metric_links` in node detail API response scope-ui: - Extends `` with option `alwaysShow` and adds boolean `isCloud` property - Links metric graphs in the ui's node details view for all k8s toplogies; or displays placeholder graph if no metrics available --- .../app/scripts/components/cloud-feature.js | 8 +- client/app/scripts/components/node-details.js | 16 +- .../node-details/node-details-health-item.js | 10 +- .../node-details-health-link-item.js | 47 +++++ .../node-details/node-details-health.js | 23 ++- client/app/styles/_base.scss | 27 ++- prog/app.go | 8 +- prog/main.go | 2 + prog/probe.go | 4 +- render/detailed/links.go | 167 ++++++++++++++++++ render/detailed/links_test.go | 76 ++++++++ render/detailed/summary.go | 47 ++--- render/expected/expected.go | 1 - 13 files changed, 396 insertions(+), 40 deletions(-) create mode 100644 client/app/scripts/components/node-details/node-details-health-link-item.js create mode 100644 render/detailed/links.go create mode 100644 render/detailed/links_test.go diff --git a/client/app/scripts/components/cloud-feature.js b/client/app/scripts/components/cloud-feature.js index b9c8f3524..c34277b8f 100644 --- a/client/app/scripts/components/cloud-feature.js +++ b/client/app/scripts/components/cloud-feature.js @@ -13,10 +13,16 @@ class CloudFeature extends React.Component { if (process.env.WEAVE_CLOUD) { return React.cloneElement(React.Children.only(this.props.children), { params: this.context.router.params, - router: this.context.router + router: this.context.router, + isCloud: true }); } + // also show if not in weave cloud? + if (this.props.alwaysShow) { + return React.cloneElement(React.Children.only(this.props.children), {isCloud: false}); + } + return null; } } diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index 6feafe0d0..421155396 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -165,6 +165,14 @@ class NodeDetails extends React.Component { } }; + // collect by metric id (id => link) + const metricLinks = (details.metric_links || []) + .reduce((agg, link) => Object.assign(agg, {[link.id]: link}), {}); + + // collect links with no corresponding metric + const unattachedLinks = Object.assign({}, metricLinks); + (details.metrics || []).forEach(metric => delete unattachedLinks[metric.id]); + return (
{tools} @@ -190,9 +198,13 @@ class NodeDetails extends React.Component {
}
- {details.metrics &&
+ {Object.keys(metricLinks).length > 0 &&
Status
- +
} {details.metadata &&
Info
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 index 04767a00c..fe572591c 100644 --- a/client/app/scripts/components/node-details/node-details-health-item.js +++ b/client/app/scripts/components/node-details/node-details-health-item.js @@ -6,13 +6,17 @@ import { formatMetric } from '../../utils/string-utils'; function NodeDetailsHealthItem(props) { return (
-
{formatMetric(props.value, props)}
-
+ {props.value !== undefined &&
{formatMetric(props.value, props)}
} + {props.samples &&
+
} + {!props.samples &&
} +
+ {props.label} + {props.icon && }
-
{props.label}
); } 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 ; + + return ( + this.handleClick(e, href)}> + {!withoutGraph && } + {withoutGraph && } + + ); + } +} 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"