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 `<CloudFeature />` 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
This commit is contained in:
Roland Schilter
2017-06-29 11:11:33 +02:00
parent 0b283b0698
commit 702220f2ce
13 changed files with 396 additions and 40 deletions

167
render/detailed/links.go Normal file
View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import (
"github.com/weaveworks/scope/test/fixture"
)
// Exported for testing.
var (
circle = "circle"
square = "square"