Link scope-ui graphs clickable to prometheus queries (#2664)

scope-app:
* Adds `-app.metrics-graph` cli flag for configuring the base url to
use for graph links; supports :orgID and :query placeholders
* Assigns query URLs to existing metrics and appends empty metrics if missing

scope-ui:
* Extends <CloudFeature /> with option alwaysShow
* Adds <CloudLink /> to simplify routing when in cloud vs not in cloud
* Links metric graphs in the ui's node details view for all k8s
toplogies and containers so far
* Tracks metric graph click in mixpanel `scope.node.metric.click`
* Uses percentages and MB for CPU/Memory urls
* Passes timetravel timestamp to cortex in deeplink
This commit is contained in:
Roland Schilter
2017-08-15 18:56:23 +01:00
committed by GitHub
parent 4be194d236
commit 0d381a34d6
38 changed files with 863 additions and 197 deletions

View File

@@ -556,7 +556,7 @@ func captureReporter(rep Reporter, f reporterHandler) CtxHandlerFunc {
}
}
type rendererHandler func(context.Context, render.Renderer, render.Decorator, report.Report, http.ResponseWriter, *http.Request)
type rendererHandler func(context.Context, render.Renderer, render.Decorator, report.RenderContext, http.ResponseWriter, *http.Request)
func (r *Registry) captureRenderer(rep Reporter, f rendererHandler) CtxHandlerFunc {
return func(ctx context.Context, w http.ResponseWriter, req *http.Request) {
@@ -579,6 +579,6 @@ func (r *Registry) captureRenderer(rep Reporter, f rendererHandler) CtxHandlerFu
respondWith(w, http.StatusInternalServerError, err)
return
}
f(ctx, renderer, decorator, rpt, w, req)
f(ctx, renderer, decorator, RenderContextForReporter(rep, rpt), w, req)
}
}

View File

@@ -183,7 +183,7 @@ func getTestContainerLabelFilterTopologySummary(t *testing.T, exclude bool) (det
return nil, err
}
return detailed.Summaries(fixture.Report, renderer.Render(fixture.Report, decorator)), nil
return detailed.Summaries(report.RenderContext{Report: fixture.Report}, renderer.Render(fixture.Report, decorator)), nil
}
func TestAPITopologyAddsKubernetes(t *testing.T) {

View File

@@ -29,27 +29,27 @@ type APINode struct {
}
// Full topology.
func handleTopology(ctx context.Context, renderer render.Renderer, decorator render.Decorator, report report.Report, w http.ResponseWriter, r *http.Request) {
func handleTopology(ctx context.Context, renderer render.Renderer, decorator render.Decorator, rc report.RenderContext, w http.ResponseWriter, r *http.Request) {
respondWith(w, http.StatusOK, APITopology{
Nodes: detailed.Summaries(report, renderer.Render(report, decorator)),
Nodes: detailed.Summaries(rc, renderer.Render(rc.Report, decorator)),
})
}
// Individual nodes.
func handleNode(ctx context.Context, renderer render.Renderer, decorator render.Decorator, report report.Report, w http.ResponseWriter, r *http.Request) {
func handleNode(ctx context.Context, renderer render.Renderer, decorator render.Decorator, rc report.RenderContext, w http.ResponseWriter, r *http.Request) {
var (
vars = mux.Vars(r)
topologyID = vars["topology"]
nodeID = vars["id"]
preciousRenderer = render.PreciousNodeRenderer{PreciousNodeID: nodeID, Renderer: renderer}
rendered = preciousRenderer.Render(report, decorator)
rendered = preciousRenderer.Render(rc.Report, decorator)
node, ok = rendered[nodeID]
)
if !ok {
http.NotFound(w, r)
return
}
respondWith(w, http.StatusOK, APINode{Node: detailed.MakeNode(topologyID, report, rendered, node)})
respondWith(w, http.StatusOK, APINode{Node: detailed.MakeNode(topologyID, rc, rendered, node)})
}
// Websocket for the full topology.
@@ -113,17 +113,17 @@ func handleWebsocket(
// might be interested in implementing in the future.
timestampDelta := time.Since(channelOpenedAt)
reportTimestamp := startReportingAt.Add(timestampDelta)
report, err := rep.Report(ctx, reportTimestamp)
re, err := rep.Report(ctx, reportTimestamp)
if err != nil {
log.Errorf("Error generating report: %v", err)
return
}
renderer, decorator, err := topologyRegistry.RendererForTopology(topologyID, r.Form, report)
renderer, decorator, err := topologyRegistry.RendererForTopology(topologyID, r.Form, re)
if err != nil {
log.Errorf("Error generating report: %v", err)
return
}
newTopo := detailed.Summaries(report, renderer.Render(report, decorator))
newTopo := detailed.Summaries(RenderContextForReporter(rep, re), renderer.Render(re, decorator))
diff := detailed.TopoDiff(previousTopo, newTopo)
previousTopo = newTopo

View File

@@ -34,6 +34,23 @@ type Reporter interface {
UnWait(context.Context, chan struct{})
}
// WebReporter is a reporter that creates reports whose data is eventually
// displayed on websites. It carries fields that will be forwarded to the
// report.RenderContext
type WebReporter struct {
Reporter
MetricsGraphURL string
}
// RenderContextForReporter creates the rendering context for the given reporter.
func RenderContextForReporter(rep Reporter, r report.Report) report.RenderContext {
rc := report.RenderContext{Report: r}
if wrep, ok := rep.(WebReporter); ok {
rc.MetricsGraphURL = wrep.MetricsGraphURL
}
return rc
}
// Adder is something that can accept reports. It's a convenient interface for
// parts of the app, and several experimental components. It takes the following
// arguments:

View File

@@ -23,6 +23,7 @@ function getColumns(nodes) {
.toList()
.flatMap((n) => {
const metrics = (n.get('metrics') || makeList())
.filter(m => !m.get('valueEmpty'))
.map(m => makeMap({ id: m.get('id'), label: m.get('label'), dataType: 'number' }));
return metrics;
})
@@ -87,10 +88,6 @@ class NodesGrid extends React.Component {
}
onClickRow(ev, node) {
// TODO: do this better
if (ev.target.className === 'node-details-table-node-link') {
return;
}
trackMixpanelEvent('scope.node.click', {
layout: TABLE_VIEW_MODE,
topologyId: this.props.currentTopology.get('id'),

View File

@@ -17,6 +17,11 @@ class CloudFeature extends React.Component {
});
}
// also show if not in weave cloud?
if (this.props.alwaysShow) {
return React.cloneElement(React.Children.only(this.props.children));
}
return null;
}
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { connect } from 'react-redux';
import filterInvalidDOMProps from 'filter-invalid-dom-props';
import CloudFeature from './cloud-feature';
/**
* CloudLink provides an anchor that allows to set a target
* that is comprised of Weave Cloud related pieces.
*
* We support here relative links with a leading `/` that rewrite
* the browser url as well as cloud-related placeholders (:orgId).
*
* If no `url` is given, only the children is rendered (no anchor).
*
* If you want to render the content even if not on the cloud, set
* the `alwaysShow` property. A location redirect will be made for
* clicks instead.
*/
const CloudLink = ({ alwaysShow, ...props }) => (
<CloudFeature alwaysShow={alwaysShow}>
<LinkWrapper {...props} />
</CloudFeature>
);
class LinkWrapper 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, onClick } = this.props;
if (onClick) {
onClick();
}
if (router && href[0] === '/') {
router.push(href);
} else {
location.href = href;
}
}
buildHref(url) {
const { params } = this.props;
if (!url || !params || !params.orgId) return url;
return url.replace(/:orgid/gi, encodeURIComponent(this.props.params.orgId));
}
render() {
const { url, children, ...props } = this.props;
if (!url) {
return children;
}
const href = this.buildHref(url);
return (
<a {...filterInvalidDOMProps(props)} href={href} onClick={e => this.handleClick(e, href)}>
{children}
</a>
);
}
}
export default connect()(CloudLink);

View File

@@ -158,7 +158,7 @@ class NodeDetails extends React.Component {
}
renderDetails() {
const { details, nodeControlStatus, nodeMatches = makeMap() } = this.props;
const { details, nodeControlStatus, nodeMatches = makeMap(), topologyId } = this.props;
const showControls = details.controls && details.controls.length > 0;
const nodeColor = getNodeColorDark(details.rank, details.label, details.pseudo);
const {error, pending} = nodeControlStatus ? nodeControlStatus.toJS() : {};
@@ -199,7 +199,10 @@ class NodeDetails extends React.Component {
<div className="node-details-content">
{details.metrics && <div className="node-details-content-section">
<div className="node-details-content-section-header">Status</div>
<NodeDetailsHealth metrics={details.metrics} />
<NodeDetailsHealth
metrics={details.metrics}
topologyId={topologyId}
/>
</div>}
{details.metadata && <div className="node-details-content-section">
<div className="node-details-content-section-header">Info</div>

View File

@@ -0,0 +1,29 @@
import moment from 'moment';
import { appendTime } from '../node-details-health-link-item';
describe('NodeDetailsHealthLinkItem', () => {
describe('appendTime', () => {
const time = moment.unix(1496275200);
it('returns url for empty url or time', () => {
expect(appendTime('', time)).toEqual('');
expect(appendTime('foo', null)).toEqual('foo');
expect(appendTime('', null)).toEqual('');
});
it('appends as json for cloud link', () => {
const url = appendTime('/prom/:orgid/notebook/new/%7B%22cells%22%3A%5B%7B%22queries%22%3A%5B%22go_goroutines%22%5D%7D%5D%7D', time);
expect(url).toContain(time.unix());
const payload = JSON.parse(decodeURIComponent(url.substr(url.indexOf('new/') + 4)));
expect(payload.time.queryEnd).toEqual(time.unix());
});
it('appends as GET parameter', () => {
expect(appendTime('http://example.test?q=foo', time)).toEqual('http://example.test?q=foo&time=1496275200');
expect(appendTime('http://example.test/q=foo/', time)).toEqual('http://example.test/q=foo/?time=1496275200');
});
});
});

View File

@@ -4,15 +4,20 @@ import Sparkline from '../sparkline';
import { formatMetric } from '../../utils/string-utils';
function NodeDetailsHealthItem(props) {
const labelStyle = { color: props.labelColor };
return (
<div className="node-details-health-item">
<div className="node-details-health-item-value">{formatMetric(props.value, props)}</div>
{!props.valueEmpty && <div className="node-details-health-item-value" style={labelStyle}>{formatMetric(props.value, props)}</div>}
<div className="node-details-health-item-sparkline">
<Sparkline
data={props.samples} max={props.max} format={props.format}
first={props.first} last={props.last} />
first={props.first} last={props.last} hoverColor={props.metricColor}
hovered={props.hovered}
/>
</div>
<div className="node-details-health-item-label" style={labelStyle}>
{props.label}
</div>
<div className="node-details-health-item-label">{props.label}</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { connect } from 'react-redux';
import NodeDetailsHealthItem from './node-details-health-item';
import CloudLink from '../cloud-link';
import { getMetricColor } from '../../utils/metric-utils';
import { darkenColor } from '../../utils/color-utils';
import { trackMixpanelEvent } from '../../utils/tracking-utils';
/**
* @param {string} url
* @param {Moment} time
* @returns {string}
*/
export function appendTime(url, time) {
if (!url || !time) return url;
// rudimentary check whether we have a cloud link
const cloudLinkPathEnd = 'notebook/new/';
const pos = url.indexOf(cloudLinkPathEnd);
if (pos !== -1) {
let payload;
const json = decodeURIComponent(url.substr(pos + cloudLinkPathEnd.length));
try {
payload = JSON.parse(json);
payload.time = { queryEnd: time.unix() };
} catch (e) {
return url;
}
return `${url.substr(0, pos + cloudLinkPathEnd.length)}${encodeURIComponent(JSON.stringify(payload) || '')}`;
}
if (url.indexOf('?') !== -1) {
return `${url}&time=${time.unix()}`;
}
return `${url}?time=${time.unix()}`;
}
class NodeDetailsHealthLinkItem extends React.Component {
constructor(props) {
super(props);
this.state = {
hovered: false
};
this.onMouseOver = this.onMouseOver.bind(this);
this.onMouseOut = this.onMouseOut.bind(this);
this.onClick = this.onClick.bind(this);
}
onMouseOver() {
this.setState({hovered: true});
}
onMouseOut() {
this.setState({hovered: false});
}
onClick() {
trackMixpanelEvent('scope.node.metric.click', { topologyId: this.props.topologyId });
}
render() {
const { id, url, pausedAt, ...props } = this.props;
const metricColor = getMetricColor(id);
const labelColor = this.state.hovered && !props.valueEmpty && darkenColor(metricColor);
const timedUrl = appendTime(url, pausedAt);
return (
<CloudLink
alwaysShow
className="node-details-health-link-item"
onMouseOver={this.onMouseOver}
onMouseOut={this.onMouseOut}
onClick={this.onClick}
url={timedUrl}
>
<NodeDetailsHealthItem
{...props}
hovered={this.state.hovered}
labelColor={labelColor}
metricColor={metricColor}
/>
</CloudLink>
);
}
}
function mapStateToProps(state) {
return {
pausedAt: state.get('pausedAt'),
};
}
export default connect(mapStateToProps)(NodeDetailsHealthLinkItem);

View File

@@ -1,16 +0,0 @@
import React from 'react';
import { formatMetric } from '../../utils/string-utils';
function NodeDetailsHealthOverflowItem(props) {
return (
<div className="node-details-health-overflow-item">
<div className="node-details-health-overflow-item-value">
{formatMetric(props.value, props)}
</div>
<div className="node-details-health-overflow-item-label truncate">{props.label}</div>
</div>
);
}
export default NodeDetailsHealthOverflowItem;

View File

@@ -1,26 +0,0 @@
import React from 'react';
import NodeDetailsHealthOverflowItem from './node-details-health-overflow-item';
export default class NodeDetailsHealthOverflow extends React.Component {
constructor(props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
}
handleClick(ev) {
ev.preventDefault();
this.props.handleClick();
}
render() {
const items = this.props.items.slice(0, 4);
return (
<div className="node-details-health-overflow" onClick={this.handleClick}>
{items.map(item => <NodeDetailsHealthOverflowItem key={item.id} {...item} />)}
</div>
);
}
}

View File

@@ -1,8 +1,7 @@
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';
export default class NodeDetailsHealth extends React.Component {
@@ -20,27 +19,43 @@ export default class NodeDetailsHealth extends React.Component {
}
render() {
const metrics = this.props.metrics || [];
const primeCutoff = metrics.length > 3 && !this.state.expanded ? 2 : metrics.length;
const primeMetrics = metrics.slice(0, primeCutoff);
const overflowMetrics = metrics.slice(primeCutoff);
const showOverflow = overflowMetrics.length > 0 && !this.state.expanded;
const flexWrap = showOverflow || !this.state.expanded ? 'nowrap' : 'wrap';
const justifyContent = showOverflow || !this.state.expanded ? 'space-around' : 'flex-start';
const notShown = overflowMetrics.length;
const {
metrics = [],
topologyId,
} = this.props;
let primeMetrics = metrics.filter(m => !m.valueEmpty);
let emptyMetrics = metrics.filter(m => m.valueEmpty);
if (primeMetrics.length === 0 && emptyMetrics.length > 0) {
primeMetrics = emptyMetrics;
emptyMetrics = [];
}
const shownWithData = this.state.expanded ? primeMetrics : primeMetrics.slice(0, 3);
const shownEmpty = this.state.expanded ? emptyMetrics : [];
const notShown = metrics.length - shownWithData.length - shownEmpty.length;
return (
<div className="node-details-health" style={{flexWrap, justifyContent}}>
<div className="node-details-health" style={{ justifyContent: 'space-around' }}>
<div className="node-details-health-wrapper">
{primeMetrics.map(item => <NodeDetailsHealthItem key={item.id} {...item} />)}
{showOverflow && <NodeDetailsHealthOverflow
items={overflowMetrics}
handleClick={this.handleClickMore}
/>}
{shownWithData.map(item => <NodeDetailsHealthLinkItem
{...item}
key={item.id}
topologyId={topologyId}
/>)}
</div>
<div className="node-details-health-wrapper">
{shownEmpty.map(item => <NodeDetailsHealthLinkItem
{...item}
key={item.id}
topologyId={topologyId}
/>)}
</div>
<ShowMore
handleClick={this.handleClickMore} collection={this.props.metrics}
expanded={this.state.expanded} notShown={notShown} hideNumber />
handleClick={this.handleClickMore} collection={metrics}
expanded={this.state.expanded} notShown={notShown} hideNumber={this.state.expanded}
/>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import { clickRelative } from '../../actions/app-actions';
import { trackMixpanelEvent } from '../../utils/tracking-utils';
import { dismissRowClickProps } from './node-details-table-row';
class NodeDetailsTableNodeLink extends React.Component {
@@ -38,7 +39,9 @@ class NodeDetailsTableNodeLink extends React.Component {
return (
<span
className="node-details-table-node-link" title={title}
ref={this.saveNodeRef} onClick={this.handleClick}>
ref={this.saveNodeRef} onClick={this.handleClick}
{...dismissRowClickProps}
>
{label}
</span>
);

View File

@@ -0,0 +1,41 @@
import React from 'react';
import CloudLink from '../cloud-link';
import { formatMetric } from '../../utils/string-utils';
import { trackMixpanelEvent } from '../../utils/tracking-utils';
import { dismissRowClickProps } from './node-details-table-row';
class NodeDetailsTableNodeMetricLink extends React.Component {
constructor(props) {
super(props);
this.onClick = this.onClick.bind(this);
}
onClick() {
trackMixpanelEvent('scope.node.metric.click', { topologyId: this.props.topologyId });
}
render() {
const { url, style, value, valueEmpty } = this.props;
return (
<td
className="node-details-table-node-metric"
style={style}
{...dismissRowClickProps}
>
<CloudLink
alwaysShow
url={url}
className={url && 'node-details-table-node-metric-link'}
onClick={this.onClick}
>
{!valueEmpty && formatMetric(value, this.props)}
</CloudLink>
</td>
);
}
}
export default NodeDetailsTableNodeMetricLink;

View File

@@ -1,13 +0,0 @@
import React from 'react';
import { formatMetric } from '../../utils/string-utils';
function NodeDetailsTableNodeMetric(props) {
return (
<td className="node-details-table-node-metric" style={props.style}>
{formatMetric(props.value, props)}
</td>
);
}
export default NodeDetailsTableNodeMetric;

View File

@@ -5,7 +5,7 @@ import { intersperse } from '../../utils/array-utils';
import NodeDetailsTableNodeLink from './node-details-table-node-link';
import NodeDetailsTableNodeMetric from './node-details-table-node-metric';
import NodeDetailsTableNodeMetricLink from './node-details-table-node-metric-link';
import { formatDataType } from '../../utils/string-utils';
function getValuesForNode(node) {
@@ -40,7 +40,7 @@ function getValuesForNode(node) {
}
function renderValues(node, columns = [], columnStyles = [], timestamp = null) {
function renderValues(node, columns = [], columnStyles = [], timestamp = null, topologyId = null) {
const fields = getValuesForNode(node);
return columns.map(({ id }, i) => {
const field = fields[id];
@@ -76,8 +76,10 @@ function renderValues(node, columns = [], columnStyles = [], timestamp = null) {
</td>
);
}
// valueType === 'metrics'
return (
<NodeDetailsTableNodeMetric style={style} key={field.id} {...field} />
<NodeDetailsTableNodeMetricLink
style={style} key={field.id} topologyId={topologyId} {...field} />
);
}
// empty cell to complete the row for proper hover
@@ -87,6 +89,19 @@ function renderValues(node, columns = [], columnStyles = [], timestamp = null) {
});
}
/**
* Table row children may react to onClick events but the row
* itself does detect a click by looking at onMouseUp. To stop
* the bubbling of clicks on child elements we need to dismiss
* the onMouseUp event.
*/
export const dismissRowClickProps = {
onMouseUp: (ev) => {
ev.preventDefault();
ev.stopPropagation();
}
};
export default class NodeDetailsTableRow extends React.Component {
constructor(props, context) {
super(props, context);
@@ -142,7 +157,7 @@ export default class NodeDetailsTableRow extends React.Component {
render() {
const { node, nodeIdKey, topologyId, columns, onClick, colStyles, timestamp } = this.props;
const [firstColumnStyle, ...columnStyles] = colStyles;
const values = renderValues(node, columns, columnStyles, timestamp);
const values = renderValues(node, columns, columnStyles, timestamp, topologyId);
const nodeId = node[nodeIdKey];
const className = classNames('node-details-table-node', {

View File

@@ -108,6 +108,9 @@ function sortNodes(nodes, getValue, sortedDesc) {
function getSortedNodes(nodes, sortedByHeader, sortedDesc) {
const getValue = getValueForSortedBy(sortedByHeader);
const withAndWithoutValues = groupBy(nodes, (n) => {
if (!n || n.valueEmpty) {
return 'withoutValues';
}
const v = getValue(n);
return v !== null && v !== undefined ? 'withValues' : 'withoutValues';
});

View File

@@ -9,6 +9,10 @@ import { scaleLinear } from 'd3-scale';
import { formatMetricSvg } from '../utils/string-utils';
const HOVER_RADIUS_MULTIPLY = 1.5;
const HOVER_STROKE_MULTIPLY = 5;
const MARGIN = 2;
export default class Sparkline extends React.Component {
constructor(props, context) {
super(props, context);
@@ -20,19 +24,23 @@ export default class Sparkline extends React.Component {
.y(d => this.y(d.value));
}
initRanges(hasCircle) {
// adjust scales and leave some room for the circle on the right, upper, and lower edge
let circleSpace = MARGIN;
if (hasCircle) {
circleSpace += Math.ceil(this.props.circleRadius * HOVER_RADIUS_MULTIPLY);
}
this.x.range([MARGIN, this.props.width - circleSpace]);
this.y.range([this.props.height - circleSpace, circleSpace]);
this.line.curve(this.props.curve);
}
getGraphData() {
// data is of shape [{date, value}, ...] and is sorted by date (ASC)
let data = this.props.data;
// Do nothing if no data or data w/o date are passed in.
if (data === undefined || data.length === 0 || data[0].date === undefined) {
return <div />;
}
// adjust scales
this.x.range([2, this.props.width - 2]);
this.y.range([this.props.height - 2, 2]);
this.line.curve(this.props.curve);
this.initRanges(true);
// Convert dates into D3 dates
data = data.map(d => ({
@@ -70,30 +78,53 @@ export default class Sparkline extends React.Component {
return {title, lastX, lastY, data};
}
render() {
// Do nothing if no data or data w/o date are passed in.
if (!this.props.data || this.props.data.length === 0 || this.props.data[0].date === undefined) {
return <div />;
}
getEmptyGraphData() {
this.initRanges(false);
const first = new Date(0);
const last = new Date(15);
this.x.domain([first, last]);
this.y.domain([0, 1]);
const {lastX, lastY, title, data} = this.getGraphData();
return {
title: '',
lastX: this.x(last),
lastY: this.y(0),
data: [
{date: first, value: 0},
{date: last, value: 0},
],
};
}
render() {
const dash = 5;
const hasData = this.props.data && this.props.data.length > 0;
const strokeColor = this.props.hovered && hasData
? this.props.hoverColor
: this.props.strokeColor;
const strokeWidth = this.props.strokeWidth * (this.props.hovered ? HOVER_STROKE_MULTIPLY : 1);
const strokeDasharray = hasData || `${dash}, ${dash}`;
const radius = this.props.circleRadius * (this.props.hovered ? HOVER_RADIUS_MULTIPLY : 1);
const fillOpacity = this.props.hovered ? 1 : 0.6;
const circleColor = hasData && this.props.hovered ? strokeColor : strokeColor;
const graph = hasData ? this.getGraphData() : this.getEmptyGraphData();
return (
<div title={title}>
<div title={graph.title}>
<svg width={this.props.width} height={this.props.height}>
<path
className="sparkline" fill="none" stroke={this.props.strokeColor}
strokeWidth={this.props.strokeWidth} d={this.line(data)}
/>
<circle
className="sparkcircle" cx={lastX} cy={lastY} fill="#46466a"
fillOpacity="0.6" stroke="none" r={this.props.circleDiameter}
className="sparkline" fill="none" stroke={strokeColor}
strokeWidth={strokeWidth} strokeDasharray={strokeDasharray}
d={this.line(graph.data)}
/>
{hasData && <circle
className="sparkcircle" cx={graph.lastX} cy={graph.lastY} fill={circleColor}
fillOpacity={fillOpacity} stroke="none" r={radius}
/>}
</svg>
</div>
);
}
}
Sparkline.propTypes = {
@@ -104,8 +135,10 @@ Sparkline.defaultProps = {
width: 80,
height: 24,
strokeColor: '#7d7da8',
strokeWidth: '0.5px',
strokeWidth: 0.5,
hoverColor: '#7d7da8',
curve: curveLinear,
circleDiameter: 1.75,
circleRadius: 1.75,
hovered: false,
data: [],
};

View File

@@ -23,6 +23,7 @@ export const availableMetricsSelector = createSelector(
return nodes
.valueSeq()
.flatMap(n => n.get('metrics', makeList()))
.filter(m => !m.get('valueEmpty'))
.map(m => makeMap({ id: m.get('id'), label: m.get('label') }))
.toSet()
.toList()

View File

@@ -82,3 +82,13 @@ export function brightenColor(c) {
}
return color.toString();
}
export function darkenColor(c) {
let color = hsl(c);
if (hsl.l < 0.5) {
color = color.darker(0.5);
} else {
color = color.darker(0.8);
}
return color.toString();
}

View File

@@ -52,16 +52,18 @@ export function getMetricValue(metric) {
export function getMetricColor(metric) {
const metricId = metric && metric.get('id');
const metricId = typeof metric === 'string'
? metric
: metric && metric.get('id');
if (/mem/.test(metricId)) {
return 'steelBlue';
} else if (/cpu/.test(metricId)) {
return colors('cpu');
return colors('cpu').toString();
} else if (/files/.test(metricId)) {
// purple
return '#9467bd';
} else if (/load/.test(metricId)) {
return colors('load');
return colors('load').toString();
}
return 'steelBlue';
}

View File

@@ -12,6 +12,10 @@
// TODO: Remove this line once Service UI CONFIGURE button stops being added to Scope.
.scope-wrapper .setup-nav-button { display: none; }
a {
text-decoration: none;
}
.browsehappy {
margin: 0.2em 0;
background: #ccc;
@@ -930,17 +934,45 @@
&-item {
padding: 8px 16px;
width: 33%;
display: flex;
flex-direction: column;
flex-grow: 1;
&-label {
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;
&-sparkline {
margin-top: auto;
}
&-placeholder {
font-size: 200%;
opacity: 0.2;
margin-bottom: 0.2em;
}
}
&-link-item {
@extend .btn-opacity;
cursor: pointer;
opacity: $link-opacity-default;
width: 33%;
display: flex;
color: inherit;
.label {
text-transform: uppercase;
}
.node-details-health-item {
width: auto;
}
}
}
@@ -1087,6 +1119,14 @@
text-align: right;
}
&-metric-link {
@extend .btn-opacity;
text-decoration: underline;
cursor: pointer;
opacity: $link-opacity-default;
color: $text-color;
}
&-value-scalar {
// width: 2em;
text-align: right;

View File

@@ -23,6 +23,7 @@
"dagre": "0.7.4",
"debug": "2.6.6",
"filesize": "3.5.9",
"filter-invalid-dom-props": "2.0.0",
"font-awesome": "4.7.0",
"immutable": "3.8.1",
"lcp": "1.1.0",

View File

@@ -2499,6 +2499,12 @@ fill-range@^2.1.0:
repeat-element "^1.1.2"
repeat-string "^1.5.2"
filter-invalid-dom-props@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/filter-invalid-dom-props/-/filter-invalid-dom-props-2.0.0.tgz#527f1494cb3c4f282a73c43804153eb80c42dc2c"
dependencies:
html-attributes "1.1.0"
finalhandler@~1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.1.tgz#bcd15d1689c0e5ed729b6f7f541a6df984117db8"
@@ -2889,6 +2895,10 @@ hosted-git-info@^2.1.4:
version "2.3.1"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.3.1.tgz#ac439421605f0beb0ea1349de7d8bb28e50be1dd"
html-attributes@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/html-attributes/-/html-attributes-1.1.0.tgz#82027a4fac7a6070ea6c18cc3886aea18d6dea09"
html-comment-regex@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e"

View File

@@ -18,17 +18,17 @@ 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/weave/common"
)
const (
@@ -51,7 +51,7 @@ func init() {
}
// Router creates the mux for all the various app components.
func router(collector app.Collector, controlRouter app.ControlRouter, pipeRouter app.PipeRouter, externalUI bool, capabilities map[string]bool) http.Handler {
func router(collector app.Collector, controlRouter app.ControlRouter, pipeRouter app.PipeRouter, externalUI bool, capabilities map[string]bool, metricsGraphURL string) http.Handler {
router := mux.NewRouter().SkipClean(true)
// We pull in the http.DefaultServeMux to get the pprof routes
@@ -61,7 +61,7 @@ func router(collector app.Collector, controlRouter app.ControlRouter, pipeRouter
app.RegisterReportPostHandler(collector, router)
app.RegisterControlRoutes(router, controlRouter)
app.RegisterPipeRoutes(router, pipeRouter)
app.RegisterTopologyRoutes(router, collector, capabilities)
app.RegisterTopologyRoutes(router, app.WebReporter{Reporter: collector, MetricsGraphURL: metricsGraphURL}, capabilities)
uiHandler := http.FileServer(GetFS(externalUI))
router.PathPrefix("/ui").Name("static").Handler(
@@ -297,7 +297,7 @@ func appMain(flags appFlags) {
capabilities := map[string]bool{
xfer.HistoricReportsCapability: collector.HasHistoricReports(),
}
handler := router(collector, controlRouter, pipeRouter, flags.externalUI, capabilities)
handler := router(collector, controlRouter, pipeRouter, flags.externalUI, capabilities, flags.metricsGraphURL)
if flags.logHTTP {
handler = middleware.Log{
LogRequestHeaders: flags.logHTTPHeaders,

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). Example: --app.metric-graph=/prom/:orgID/notebook/new")
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 (

View File

@@ -127,7 +127,7 @@ func (c *connectionCounters) rows(r report.Report, ns report.Nodes, includeLocal
// Use MakeNodeSummary to render the id and label of this node
// TODO(paulbellamy): Would be cleaner if we hade just a
// MakeNodeID(ns[row.remoteNodeID]). As we don't need the whole summary.
summary, _ := MakeNodeSummary(r, ns[row.remoteNodeID])
summary, _ := MakeNodeSummary(report.RenderContext{Report: r}, ns[row.remoteNodeID])
connection := Connection{
ID: fmt.Sprintf("%s-%s-%s-%s", row.remoteNodeID, row.remoteAddr, row.localAddr, row.port),
NodeID: summary.ID,

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

@@ -0,0 +1,211 @@
package detailed
import (
"bytes"
"fmt"
"net/url"
"strings"
"github.com/weaveworks/scope/probe/docker"
"github.com/weaveworks/scope/report"
"github.com/ugorji/go/codec"
)
const (
// Replacement variable name for the query in the metrics graph url
urlQueryVarName = ":query"
idReceiveBytes = "receive_bytes"
idTransmitBytes = "transmit_bytes"
)
var (
// Metadata for shown queries
shownQueries = []struct {
ID string
Label string
}{
{
ID: docker.CPUTotalUsage,
Label: "CPU",
},
{
ID: docker.MemoryUsage,
Label: "Memory",
},
{
ID: idReceiveBytes,
Label: "Rx/s",
},
{
ID: idTransmitBytes,
Label: "Tx/s",
},
}
// Prometheus queries for topologies
topologyQueries = map[string]map[string]string{
// Containers
report.Container: formatMetricQueries(`container_name="{{label}}"`, []string{docker.MemoryUsage, docker.CPUTotalUsage}),
report.ContainerImage: formatMetricQueries(`image="{{label}}"`, []string{docker.MemoryUsage, docker.CPUTotalUsage}),
// Kubernetes topologies
report.Pod: formatMetricQueries(
`pod_name="{{label}}"`,
[]string{docker.MemoryUsage, docker.CPUTotalUsage},
),
// Pod naming: // https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#pod-template-hash-label
"__k8s_controllers": formatMetricQueries(`pod_name=~"^{{label}}-[^-]+-[^-]+$"}`, []string{docker.MemoryUsage, docker.CPUTotalUsage}),
report.DaemonSet: formatMetricQueries(`pod_name=~"^{{label}}-[^-]+$"}`, []string{docker.MemoryUsage, docker.CPUTotalUsage}),
report.Service: {
// These recording rules must be defined in the prometheus config.
// NB: Pods need to be labeled and selected by their respective Service name, meaning:
// - The Service's `spec.selector` needs to select on `name`
// - The Service's `metadata.name` needs to be the same value as `spec.selector.name`
docker.MemoryUsage: `namespace_label_name:container_memory_usage_bytes:sum{label_name="{{label}}"}`,
docker.CPUTotalUsage: `namespace_label_name:container_cpu_usage_seconds_total:sum_rate{label_name="{{label}}"}`,
},
}
k8sControllers = map[string]struct{}{
report.Deployment: {},
report.StatefulSet: {},
report.CronJob: {},
}
)
func formatMetricQueries(filter string, ids []string) map[string]string {
queries := make(map[string]string)
for _, id := range ids {
// All `container_*`metrics are provided by cAdvisor in Kubelets
switch id {
case docker.MemoryUsage:
queries[id] = fmt.Sprintf("sum(container_memory_usage_bytes{%s})/1024/1024", filter)
case docker.CPUTotalUsage:
queries[id] = fmt.Sprintf(
"sum(rate(container_cpu_usage_seconds_total{%s}[1m]))/count(container_cpu_usage_seconds_total{%s})*100",
filter,
filter,
)
case idReceiveBytes:
queries[id] = fmt.Sprintf(`sum(rate(container_network_receive_bytes_total{%s}[5m]))`, filter)
case idTransmitBytes:
queries[id] = fmt.Sprintf(`sum(rate(container_network_transmit_bytes_total{%s}[5m]))`, filter)
}
}
return queries
}
// RenderMetricURLs sets respective URLs for metrics in a node summary. Missing metrics
// where we have a query for will be appended as an empty metric (no values or samples).
func RenderMetricURLs(summary NodeSummary, n report.Node, metricsGraphURL string) NodeSummary {
if metricsGraphURL == "" {
return summary
}
var maxprio float64
var ms []report.MetricRow
found := make(map[string]struct{})
// Set URL on existing metrics
for _, metric := range summary.Metrics {
if metric.Priority > maxprio {
maxprio = metric.Priority
}
query := metricQuery(summary, n, metric.ID)
ms = append(ms, metric)
if query != "" {
ms[len(ms)-1].URL = metricURL(query, metricsGraphURL)
}
found[metric.ID] = struct{}{}
}
// Append empty metrics for unattached queries
for _, metadata := range shownQueries {
if _, ok := found[metadata.ID]; ok {
continue
}
query := metricQuery(summary, n, metadata.ID)
if query == "" {
continue
}
maxprio++
ms = append(ms, report.MetricRow{
ID: metadata.ID,
Label: metadata.Label,
URL: metricURL(query, metricsGraphURL),
Metric: &report.Metric{},
Priority: maxprio,
ValueEmpty: true,
})
}
summary.Metrics = ms
return summary
}
// metricQuery returns the query for the given node and metric.
func metricQuery(summary NodeSummary, n report.Node, metricID string) string {
t := n.Topology
if _, ok := k8sControllers[n.Topology]; ok {
t = "__k8s_controllers"
}
queries := topologyQueries[t]
if len(queries) == 0 {
return ""
}
return strings.Replace(queries[metricID], "{{label}}", summary.Label, -1)
}
// metricURL builds the URL by embedding it into the configured `metricsGraphURL`.
func metricURL(query, metricsGraphURL string) string {
if strings.Contains(metricsGraphURL, urlQueryVarName) {
return strings.Replace(metricsGraphURL, urlQueryVarName, queryEscape(query), -1)
}
params, err := queryParamsAsJSON(query)
if err != nil {
return ""
}
if metricsGraphURL[len(metricsGraphURL)-1] != '/' {
metricsGraphURL += "/"
}
return metricsGraphURL + queryEscape(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}}}}
buf := &bytes.Buffer{}
encoder := codec.NewEncoder(buf, &codec.JsonHandle{})
if err := encoder.Encode(params); err != nil {
return "", err
}
return buf.String(), nil
}
// queryEscape uses `%20` instead of `+` to encode whitespaces. Both are
// valid but react-router does not decode `+` properly.
func queryEscape(query string) string {
return url.QueryEscape(strings.Replace(query, " ", "%20", -1))
}

View File

@@ -0,0 +1,92 @@
package detailed_test
import (
"strings"
"testing"
"github.com/weaveworks/scope/probe/docker"
"github.com/weaveworks/scope/render/detailed"
"github.com/weaveworks/scope/report"
"github.com/stretchr/testify/assert"
)
const (
sampleMetricsGraphURL = "/prom/:orgID/notebook/new"
)
var (
sampleUnknownNode = report.MakeNode("???").WithTopology("foo")
samplePodNode = report.MakeNode("noo").WithTopology(report.Pod)
sampleMetrics = []report.MetricRow{
{ID: docker.MemoryUsage},
{ID: docker.CPUTotalUsage},
}
)
func TestRenderMetricURLs_Disabled(t *testing.T) {
s := detailed.NodeSummary{Label: "foo", Metrics: sampleMetrics}
result := detailed.RenderMetricURLs(s, samplePodNode, "")
assert.Empty(t, result.Metrics[0].URL)
assert.Empty(t, result.Metrics[1].URL)
}
func TestRenderMetricURLs_UnknownTopology(t *testing.T) {
s := detailed.NodeSummary{Label: "foo", Metrics: sampleMetrics}
result := detailed.RenderMetricURLs(s, sampleUnknownNode, sampleMetricsGraphURL)
assert.Empty(t, result.Metrics[0].URL)
assert.Empty(t, result.Metrics[1].URL)
}
func TestRenderMetricURLs(t *testing.T) {
s := detailed.NodeSummary{Label: "foo", Metrics: sampleMetrics}
result := detailed.RenderMetricURLs(s, samplePodNode, sampleMetricsGraphURL)
assert.Equal(t, 0, strings.Index(result.Metrics[0].URL, sampleMetricsGraphURL))
assert.Contains(t, result.Metrics[0].URL, "container_memory_usage_bytes%7Bpod_name%3D%5C%22foo%5C%22%7D")
assert.Equal(t, 0, strings.Index(result.Metrics[1].URL, sampleMetricsGraphURL))
assert.Contains(t, result.Metrics[1].URL, "container_cpu_usage_seconds_total%7Bpod_name%3D%5C%22foo%5C%22%7D")
}
func TestRenderMetricURLs_EmptyMetrics(t *testing.T) {
result := detailed.RenderMetricURLs(detailed.NodeSummary{}, samplePodNode, sampleMetricsGraphURL)
m := result.Metrics[0]
assert.Equal(t, docker.CPUTotalUsage, m.ID)
assert.Equal(t, "CPU", m.Label)
assert.NotEmpty(t, m.URL)
assert.True(t, m.ValueEmpty)
assert.Equal(t, float64(1), m.Priority)
m = result.Metrics[1]
assert.NotEmpty(t, m.URL)
assert.True(t, m.ValueEmpty)
assert.Equal(t, float64(2), m.Priority)
}
func TestRenderMetricURLs_CombinedEmptyMetrics(t *testing.T) {
s := detailed.NodeSummary{
Label: "foo",
Metrics: []report.MetricRow{{ID: docker.MemoryUsage, Priority: 1}},
}
result := detailed.RenderMetricURLs(s, samplePodNode, sampleMetricsGraphURL)
assert.NotEmpty(t, result.Metrics[0].URL)
assert.False(t, result.Metrics[0].ValueEmpty)
assert.NotEmpty(t, result.Metrics[1].URL)
assert.True(t, result.Metrics[1].ValueEmpty)
assert.Equal(t, float64(2), result.Metrics[1].Priority) // first empty metric starts at non-empty prio + 1
}
func TestRenderMetricURLs_QueryReplacement(t *testing.T) {
s := detailed.NodeSummary{Label: "foo", Metrics: sampleMetrics}
result := detailed.RenderMetricURLs(s, samplePodNode, "http://example.test/?q=:query")
assert.Contains(t, result.Metrics[0].URL, "http://example.test/?q=")
assert.Contains(t, result.Metrics[0].URL, "container_memory_usage_bytes%7Bpod_name%3D%22foo%22%7D")
assert.Contains(t, result.Metrics[1].URL, "http://example.test/?q=")
assert.Contains(t, result.Metrics[1].URL, "container_cpu_usage_seconds_total%7Bpod_name%3D%22foo%22%7D")
}

View File

@@ -80,15 +80,15 @@ func (c *ControlInstance) CodecDecodeSelf(decoder *codec.Decoder) {
// MakeNode transforms a renderable node to a detailed node. It uses
// aggregate metadata, plus the set of origin node IDs, to produce tables.
func MakeNode(topologyID string, r report.Report, ns report.Nodes, n report.Node) Node {
summary, _ := MakeNodeSummary(r, n)
func MakeNode(topologyID string, rc report.RenderContext, ns report.Nodes, n report.Node) Node {
summary, _ := MakeNodeSummary(rc, n)
return Node{
NodeSummary: summary,
Controls: controls(r, n),
Children: children(r, n),
Controls: controls(rc.Report, n),
Children: children(rc, n),
Connections: []ConnectionsSummary{
incomingConnectionsSummary(topologyID, r, n, ns),
outgoingConnectionsSummary(topologyID, r, n, ns),
incomingConnectionsSummary(topologyID, rc.Report, n, ns),
outgoingConnectionsSummary(topologyID, rc.Report, n, ns),
},
}
}
@@ -181,13 +181,13 @@ var nodeSummaryGroupSpecs = []struct {
},
}
func children(r report.Report, n report.Node) []NodeSummaryGroup {
func children(rc report.RenderContext, n report.Node) []NodeSummaryGroup {
summaries := map[string][]NodeSummary{}
n.Children.ForEach(func(child report.Node) {
if child.ID == n.ID {
return
}
summary, ok := MakeNodeSummary(r, child)
summary, ok := MakeNodeSummary(rc, child)
if !ok {
return
}
@@ -216,7 +216,7 @@ func children(r report.Report, n report.Node) []NodeSummaryGroup {
if len(nodeSummaries) == 0 {
continue
}
topology, ok := r.Topology(topologyID)
topology, ok := rc.Topology(topologyID)
if !ok {
continue
}

View File

@@ -18,7 +18,7 @@ import (
)
func child(t *testing.T, r render.Renderer, id string) detailed.NodeSummary {
s, ok := detailed.MakeNodeSummary(fixture.Report, r.Render(fixture.Report, nil)[id])
s, ok := detailed.MakeNodeSummary(report.RenderContext{Report: fixture.Report}, r.Render(fixture.Report, nil)[id])
if !ok {
t.Fatalf("Expected node %s to be summarizable, but wasn't", id)
}
@@ -32,7 +32,7 @@ func connectionID(nodeID string, addr string) string {
func TestMakeDetailedHostNode(t *testing.T) {
renderableNodes := render.HostRenderer.Render(fixture.Report, nil)
renderableNode := renderableNodes[fixture.ClientHostNodeID]
have := detailed.MakeNode("hosts", fixture.Report, renderableNodes, renderableNode)
have := detailed.MakeNode("hosts", report.RenderContext{Report: fixture.Report}, renderableNodes, renderableNode)
containerImageNodeSummary := child(t, render.ContainerImageRenderer, expected.ClientContainerImageNodeID)
containerNodeSummary := child(t, render.ContainerRenderer, fixture.ClientContainerNodeID)
@@ -183,7 +183,7 @@ func TestMakeDetailedContainerNode(t *testing.T) {
if !ok {
t.Fatalf("Node not found: %s", id)
}
have := detailed.MakeNode("containers", fixture.Report, renderableNodes, renderableNode)
have := detailed.MakeNode("containers", report.RenderContext{Report: fixture.Report}, renderableNodes, renderableNode)
serverProcessNodeSummary := child(t, render.ProcessRenderer, fixture.ServerProcessNodeID)
serverProcessNodeSummary.Linkable = true
@@ -313,7 +313,7 @@ func TestMakeDetailedPodNode(t *testing.T) {
if !ok {
t.Fatalf("Node not found: %s", id)
}
have := detailed.MakeNode("pods", fixture.Report, renderableNodes, renderableNode)
have := detailed.MakeNode("pods", report.RenderContext{Report: fixture.Report}, renderableNodes, renderableNode)
containerNodeSummary := child(t, render.ContainerWithImageNameRenderer, fixture.ServerContainerNodeID)
serverProcessNodeSummary := child(t, render.ProcessRenderer, fixture.ServerProcessNodeID)

View File

@@ -102,19 +102,22 @@ var primaryAPITopology = map[string]string{
}
// MakeNodeSummary summarizes a node, if possible.
func MakeNodeSummary(r report.Report, n report.Node) (NodeSummary, bool) {
func MakeNodeSummary(rc report.RenderContext, n report.Node) (NodeSummary, bool) {
r := rc.Report
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 RenderMetricURLs(summary, n, rc.MetricsGraphURL), b
}
} else if _, ok := r.Topology(n.Topology); ok {
} else if _, ok := rc.Topology(n.Topology); ok {
summary := baseNodeSummary(r, n)
summary.Label = n.ID // This is unlikely to look very good, but is a reasonable fallback
return summary, true
}
if strings.HasPrefix(n.Topology, "group:") {
return groupNodeSummary(baseNodeSummary(r, n), r, n)
summary, b := groupNodeSummary(baseNodeSummary(r, n), r, n)
return RenderMetricURLs(summary, n, rc.MetricsGraphURL), b
}
return NodeSummary{}, false
}
@@ -370,11 +373,11 @@ func (s nodeSummariesByID) Less(i, j int) bool { return s[i].ID < s[j].ID }
type NodeSummaries map[string]NodeSummary
// Summaries converts RenderableNodes into a set of NodeSummaries
func Summaries(r report.Report, rns report.Nodes) NodeSummaries {
func Summaries(rc report.RenderContext, rns report.Nodes) NodeSummaries {
result := NodeSummaries{}
for id, node := range rns {
if summary, ok := MakeNodeSummary(r, node); ok {
if summary, ok := MakeNodeSummary(rc, node); ok {
for i, m := range summary.Metrics {
summary.Metrics[i] = m.Summary()
}

View File

@@ -21,7 +21,7 @@ import (
func TestSummaries(t *testing.T) {
{
// Just a convenient source of some rendered nodes
have := detailed.Summaries(fixture.Report, render.ProcessRenderer.Render(fixture.Report, nil))
have := detailed.Summaries(report.RenderContext{Report: fixture.Report}, render.ProcessRenderer.Render(fixture.Report, nil))
// The ids of the processes rendered above
expectedIDs := []string{
fixture.ClientProcess1NodeID,
@@ -51,7 +51,7 @@ func TestSummaries(t *testing.T) {
input := fixture.Report.Copy()
input.Process.Nodes[fixture.ClientProcess1NodeID].Metrics[process.CPUUsage] = metric
have := detailed.Summaries(input, render.ProcessRenderer.Render(input, nil))
have := detailed.Summaries(report.RenderContext{Report: input}, render.ProcessRenderer.Render(input, nil))
node, ok := have[fixture.ClientProcess1NodeID]
if !ok {
@@ -184,7 +184,7 @@ func TestMakeNodeSummary(t *testing.T) {
},
}
for _, testcase := range testcases {
have, ok := detailed.MakeNodeSummary(fixture.Report, testcase.input)
have, ok := detailed.MakeNodeSummary(report.RenderContext{Report: fixture.Report}, testcase.input)
if ok != testcase.ok {
t.Errorf("%s: MakeNodeSummary failed: expected ok value to be: %v", testcase.name, testcase.ok)
continue

View File

@@ -16,13 +16,15 @@ const (
// MetricRow is a tuple of data used to render a metric as a sparkline and
// accoutrements.
type MetricRow struct {
ID string
Label string
Format string
Group string
Value float64
Priority float64
Metric *Metric
ID string
Label string
Format string
Group string
Value float64
ValueEmpty bool
Priority float64
URL string
Metric *Metric
}
// Summary returns a copy of the MetricRow, without the samples, just the value if there is one.
@@ -49,17 +51,19 @@ func (*MetricRow) UnmarshalJSON(b []byte) error {
// Needed to flatten the fields for backwards compatibility with probes
// (time.Time is encoded in binary in MsgPack)
type wiredMetricRow struct {
ID string `json:"id"`
Label string `json:"label"`
Format string `json:"format,omitempty"`
Group string `json:"group,omitempty"`
Value float64 `json:"value"`
Priority float64 `json:"priority,omitempty"`
Samples []Sample `json:"samples"`
Min float64 `json:"min"`
Max float64 `json:"max"`
First string `json:"first,omitempty"`
Last string `json:"last,omitempty"`
ID string `json:"id"`
Label string `json:"label"`
Format string `json:"format,omitempty"`
Group string `json:"group,omitempty"`
Value float64 `json:"value"`
ValueEmpty bool `json:"valueEmpty,omitempty"`
Priority float64 `json:"priority,omitempty"`
Samples []Sample `json:"samples"`
Min float64 `json:"min"`
Max float64 `json:"max"`
First string `json:"first,omitempty"`
Last string `json:"last,omitempty"`
URL string `json:"url"`
}
// CodecEncodeSelf marshals this MetricRow. It takes the basic Metric
@@ -67,17 +71,19 @@ type wiredMetricRow struct {
func (m *MetricRow) CodecEncodeSelf(encoder *codec.Encoder) {
in := m.Metric.ToIntermediate()
encoder.Encode(wiredMetricRow{
ID: m.ID,
Label: m.Label,
Format: m.Format,
Group: m.Group,
Value: m.Value,
Priority: m.Priority,
Samples: in.Samples,
Min: in.Min,
Max: in.Max,
First: in.First,
Last: in.Last,
ID: m.ID,
Label: m.Label,
Format: m.Format,
Group: m.Group,
Value: m.Value,
ValueEmpty: m.ValueEmpty,
Priority: m.Priority,
URL: m.URL,
Samples: in.Samples,
Min: in.Min,
Max: in.Max,
First: in.First,
Last: in.Last,
})
}
@@ -94,13 +100,14 @@ func (m *MetricRow) CodecDecodeSelf(decoder *codec.Decoder) {
}
metric := w.FromIntermediate()
*m = MetricRow{
ID: in.ID,
Label: in.Label,
Format: in.Format,
Group: in.Group,
Value: in.Value,
Priority: in.Priority,
Metric: &metric,
ID: in.ID,
Label: in.Label,
Format: in.Format,
Group: in.Group,
Value: in.Value,
ValueEmpty: in.ValueEmpty,
Priority: in.Priority,
Metric: &metric,
}
}

View File

@@ -148,6 +148,12 @@ type Report struct {
ID string `deepequal:"skip"`
}
// RenderContext carries contextual data that is needed when rendering parts of the report.
type RenderContext struct {
Report
MetricsGraphURL string
}
// MakeReport makes a clean report, ready to Merge() other reports into.
func MakeReport() Report {
return Report{