mirror of
https://github.com/weaveworks/scope.git
synced 2026-02-14 18:09:59 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
72
client/app/scripts/components/cloud-link.js
Normal file
72
client/app/scripts/components/cloud-link.js
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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', {
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
12
prog/app.go
12
prog/app.go
@@ -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,
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
211
render/detailed/links.go
Normal 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))
|
||||
}
|
||||
92
render/detailed/links_test.go
Normal file
92
render/detailed/links_test.go
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user