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

View File

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

View File

@@ -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 (
<div className="node-details">
{tools}
@@ -190,9 +198,13 @@ class NodeDetails extends React.Component {
</div>}
<div className="node-details-content">
{details.metrics && <div className="node-details-content-section">
{Object.keys(metricLinks).length > 0 && <div className="node-details-content-section">
<div className="node-details-content-section-header">Status</div>
<NodeDetailsHealth metrics={details.metrics} />
<NodeDetailsHealth
metrics={details.metrics}
metricLinks={metricLinks}
unattachedLinks={unattachedLinks}
/>
</div>}
{details.metadata && <div className="node-details-content-section">
<div className="node-details-content-section-header">Info</div>

View File

@@ -6,13 +6,17 @@ import { formatMetric } from '../../utils/string-utils';
function NodeDetailsHealthItem(props) {
return (
<div className="node-details-health-item">
<div className="node-details-health-item-value">{formatMetric(props.value, props)}</div>
<div className="node-details-health-item-sparkline">
{props.value !== undefined && <div className="node-details-health-item-value">{formatMetric(props.value, props)}</div>}
{props.samples && <div className="node-details-health-item-sparkline">
<Sparkline
data={props.samples} max={props.max} format={props.format}
first={props.first} last={props.last} />
</div>}
{!props.samples && <div className="node-details-health-item-placeholder"><span className="fa fa-circle-thin" /></div>}
<div className="node-details-health-item-label">
{props.label}
{props.icon && <span className={`fa ${props.icon}`} />}
</div>
<div className="node-details-health-item-label">{props.label}</div>
</div>
);
}

View File

@@ -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 <NodeDetailsHealthItem {...props} />;
return (
<a
className="node-details-health-link-item"
href={href}
onClick={e => this.handleClick(e, href)}>
{!withoutGraph && <NodeDetailsHealthItem {...props} icon="fa-expand" />}
{withoutGraph && <NodeDetailsHealthItem label={props.label} icon="fa-expand" />}
</a>
);
}
}

View File

@@ -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 (
<div className="node-details-health" style={{flexWrap, justifyContent}}>
<div className="node-details-health-wrapper">
{primeMetrics.map(item => <NodeDetailsHealthItem key={item.id} {...item} />)}
{primeMetrics.map(item => <CloudFeature alwaysShow key={item.id}>
<NodeDetailsHealthLinkItem
{...item}
links={metricLinks}
/>
</CloudFeature>)}
{showOverflow && <NodeDetailsHealthOverflow
items={overflowMetrics}
handleClick={this.handleClickMore}
@@ -41,6 +50,16 @@ export default class NodeDetailsHealth extends React.Component {
<ShowMore
handleClick={this.handleClickMore} collection={this.props.metrics}
expanded={this.state.expanded} notShown={notShown} hideNumber />
{hasUnattached && <div className="node-details-health-wrapper">
{Object.keys(unattachedLinks).map(id => <CloudFeature alwaysShow key={id}>
<NodeDetailsHealthLinkItem
withoutGraph
{...unattachedLinks[id]}
links={unattachedLinks}
/>
</CloudFeature>)}
</div>}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

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"