mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-02 17:50:39 +00:00
Merge pull request #795 from weaveworks/638-animate-sparklines
Animate sparklines
This commit is contained in:
@@ -1,17 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import Sparkline from '../sparkline';
|
||||
import metricFeeder from '../../hoc/metric-feeder';
|
||||
import { formatMetric } from '../../utils/string-utils';
|
||||
|
||||
export default (props) => {
|
||||
return (
|
||||
<div className="node-details-health-item">
|
||||
<div className="node-details-health-item-value">{formatMetric(props.item.value, props.item)}</div>
|
||||
<div className="node-details-health-item-sparkline">
|
||||
<Sparkline data={props.item.samples} min={0} max={props.item.max}
|
||||
first={props.item.first} last={props.item.last} interpolate="none" />
|
||||
class NodeDetailsHealthItem extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="node-details-health-item">
|
||||
<div className="node-details-health-item-value">{formatMetric(this.props.value, this.props)}</div>
|
||||
<div className="node-details-health-item-sparkline">
|
||||
<Sparkline data={this.props.samples} max={this.props.max}
|
||||
first={this.props.first} last={this.props.last} />
|
||||
</div>
|
||||
<div className="node-details-health-item-label">{this.props.label}</div>
|
||||
</div>
|
||||
<div className="node-details-health-item-label">{props.item.label}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default metricFeeder(NodeDetailsHealthItem);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import metricFeeder from '../../hoc/metric-feeder';
|
||||
import { formatMetric } from '../../utils/string-utils';
|
||||
|
||||
export default class NodeDetailsHealthOverflowItem extends React.Component {
|
||||
class NodeDetailsHealthOverflowItem extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="node-details-health-overflow-item">
|
||||
<div className="node-details-health-overflow-item-value">{formatMetric(this.props.item.value, this.props.item)}</div>
|
||||
<div className="node-details-health-overflow-item-label truncate">{this.props.item.label}</div>
|
||||
<div className="node-details-health-overflow-item-value">{formatMetric(this.props.value, this.props)}</div>
|
||||
<div className="node-details-health-overflow-item-label truncate">{this.props.label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default metricFeeder(NodeDetailsHealthOverflowItem);
|
||||
|
||||
@@ -8,7 +8,7 @@ export default class NodeDetailsHealthOverflow extends React.Component {
|
||||
|
||||
return (
|
||||
<div className="node-details-health-overflow" onClick={this.props.handleClickMore}>
|
||||
{items.map(item => <NodeDetailsHealthOverflowItem key={item.id} item={item} />)}
|
||||
{items.map(item => <NodeDetailsHealthOverflowItem key={item.id} {...item} />)}
|
||||
<div className="node-details-health-overflow-expand">
|
||||
Show more
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default class NodeDetailsHealth extends React.Component {
|
||||
return (
|
||||
<div className="node-details-health" style={{flexWrap, justifyContent}}>
|
||||
{primeMetrics.map(item => {
|
||||
return <NodeDetailsHealthItem key={item.id} item={item} />;
|
||||
return <NodeDetailsHealthItem key={item.id} {...item} />;
|
||||
})}
|
||||
{showOverflow && <NodeDetailsHealthOverflow items={overflowMetrics} handleClickMore={this.handleClickMore} />}
|
||||
{showLess && <div className="node-details-health-expand" onClick={this.handleClickMore}>show less</div>}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
import { formatMetric } from '../../utils/string-utils';
|
||||
|
||||
class NodeDetailsTableNodeMetric extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<td className="node-details-table-node-metric">
|
||||
{formatMetric(this.props.value, this.props)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default NodeDetailsTableNodeMetric;
|
||||
@@ -2,7 +2,7 @@ import _ from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import NodeDetailsTableNodeLink from './node-details-table-node-link';
|
||||
import { formatMetric } from '../../utils/string-utils';
|
||||
import NodeDetailsTableNodeMetric from './node-details-table-node-metric';
|
||||
|
||||
export default class NodeDetailsTable extends React.Component {
|
||||
|
||||
@@ -60,6 +60,7 @@ export default class NodeDetailsTable extends React.Component {
|
||||
['metrics', 'metadata'].forEach(collection => {
|
||||
if (node[collection]) {
|
||||
node[collection].forEach(field => {
|
||||
field.valueType = collection;
|
||||
values[field.id] = field;
|
||||
});
|
||||
}
|
||||
@@ -116,11 +117,14 @@ export default class NodeDetailsTable extends React.Component {
|
||||
return this.props.columns.map(col => {
|
||||
const field = fields[col];
|
||||
if (field) {
|
||||
return (
|
||||
<td className="node-details-table-node-value" key={field.id}>
|
||||
{formatMetric(field.value, field)}
|
||||
</td>
|
||||
);
|
||||
if (field.valueType === 'metadata') {
|
||||
return (
|
||||
<td className="node-details-table-node-value" key={field.id}>
|
||||
{field.value}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return <NodeDetailsTableNodeMetric key={field.id} {...field} />;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,126 +1,101 @@
|
||||
// Forked from: https://github.com/KyleAMathews/react-sparkline at commit a9d7c5203d8f240938b9f2288287aaf0478df013
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import d3 from 'd3';
|
||||
|
||||
const parseDate = d3.time.format.iso.parse;
|
||||
|
||||
export default class Sparkline extends React.Component {
|
||||
componentDidMount() {
|
||||
return this.renderSparkline();
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.x = d3.scale.linear();
|
||||
this.y = d3.scale.linear();
|
||||
this.line = d3.svg.line()
|
||||
.x(d => this.x(d.date))
|
||||
.y(d => this.y(d.value));
|
||||
}
|
||||
|
||||
renderSparkline() {
|
||||
// If the sparkline has already been rendered, remove it.
|
||||
const el = ReactDOM.findDOMNode(this);
|
||||
while (el.firstChild) {
|
||||
el.removeChild(el.firstChild);
|
||||
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.length === 0 || data[0].date === undefined) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const data = this.props.data.slice();
|
||||
// adjust scales
|
||||
this.x.range([2, this.props.width - 2]);
|
||||
this.y.range([this.props.height - 2, 2]);
|
||||
this.line.interpolate(this.props.interpolate);
|
||||
|
||||
// Do nothing if no data is passed in.
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
// Convert dates into D3 dates
|
||||
data = data.map(d => {
|
||||
return {
|
||||
date: parseDate(d.date),
|
||||
value: d.value
|
||||
};
|
||||
});
|
||||
|
||||
// determine date range
|
||||
let firstDate = this.props.first ? parseDate(this.props.first) : data[0].date;
|
||||
let lastDate = this.props.last ? parseDate(this.props.last) : data[data.length - 1].date;
|
||||
// if last prop is after last value, we need to add that difference as
|
||||
// padding before first value to right-align sparkline
|
||||
const skip = lastDate - data[data.length - 1].date;
|
||||
if (skip > 0) {
|
||||
firstDate -= skip;
|
||||
lastDate -= skip;
|
||||
}
|
||||
this.x.domain([firstDate, lastDate]);
|
||||
|
||||
const x = d3.scale.linear().range([2, this.props.width - 2]);
|
||||
const y = d3.scale.linear().range([this.props.height - 2, 2]);
|
||||
// determine value range
|
||||
const minValue = this.props.min !== undefined ? this.props.min : d3.min(data, d => d.value);
|
||||
const maxValue = this.props.max !== undefined ? Math.max(this.props.max, d3.max(data, d => d.value)) : d3.max(data, d => d.value);
|
||||
this.y.domain([minValue, maxValue]);
|
||||
|
||||
// react-sparkline allows you to pass in two types of data.
|
||||
// Data tied to dates and linear data. We need to change our line and x/y
|
||||
// functions depending on the type of data.
|
||||
const lastValue = data[data.length - 1].value;
|
||||
const lastX = this.x(lastDate);
|
||||
const lastY = this.y(lastValue);
|
||||
const title = 'Last ' + d3.round((lastDate - firstDate) / 1000) + ' seconds, ' +
|
||||
data.length + ' samples, min: ' + d3.round(d3.min(data, d => d.value), 2) +
|
||||
', max: ' + d3.round(d3.max(data, d => d.value), 2) +
|
||||
', mean: ' + d3.round(d3.mean(data, d => d.value), 2);
|
||||
|
||||
// These are objects with a date key
|
||||
let line;
|
||||
let lastX;
|
||||
let lastY;
|
||||
let title;
|
||||
if (data[0].date) {
|
||||
// Convert dates into D3 dates
|
||||
data.forEach(d => {
|
||||
d.date = d3.time.format.iso.parse(d.date);
|
||||
});
|
||||
|
||||
line = d3.svg.line().
|
||||
interpolate(this.props.interpolate).
|
||||
x(d => x(d.date)).
|
||||
y(d => y(d.value));
|
||||
|
||||
const first = this.props.first ? d3.time.format.iso.parse(this.props.first) : d3.min(data, d => d.date);
|
||||
const last = this.props.last ? d3.time.format.iso.parse(this.props.last) : d3.max(data, d => d.date);
|
||||
x.domain([first, last]);
|
||||
|
||||
y.domain([
|
||||
this.props.min || d3.min(data, d => d.value),
|
||||
this.props.max || d3.max(data, d => d.value)
|
||||
]);
|
||||
|
||||
lastX = x(data[data.length - 1].date);
|
||||
lastY = y(data[data.length - 1].value);
|
||||
title = 'Last ' + d3.round((last - first) / 1000) + ' seconds, ' + data.length + ' samples, min: ' + d3.round(d3.min(data, d => d.value), 2) + ', max: ' + d3.round(d3.max(data, d => d.value), 2) + ', mean: ' + d3.round(d3.mean(data, d => d.value), 2);
|
||||
} else {
|
||||
line = d3.svg.line().
|
||||
interpolate(this.props.interpolate).
|
||||
x((d, i) => x(i)).
|
||||
y(d => y(d));
|
||||
|
||||
x.domain([
|
||||
this.props.first || 0,
|
||||
this.props.last || data.length
|
||||
]);
|
||||
|
||||
y.domain([
|
||||
this.props.min || d3.min(data),
|
||||
this.props.max || d3.max(data)
|
||||
]);
|
||||
|
||||
lastX = x(data.length - 1);
|
||||
lastY = y(data[data.length - 1]);
|
||||
title = data.length + ' samples, min: ' + d3.round(d3.min(data), 2) + ', max: ' + d3.round(d3.max(data), 2) + ', mean: ' + d3.round(d3.mean(data), 2);
|
||||
}
|
||||
|
||||
d3.select(ReactDOM.findDOMNode(this)).attr('title', title);
|
||||
|
||||
const svg = d3.select(ReactDOM.findDOMNode(this)).
|
||||
append('svg').
|
||||
attr('width', this.props.width).
|
||||
attr('height', this.props.height).
|
||||
append('g');
|
||||
|
||||
svg.append('path').
|
||||
datum(data).
|
||||
attr('class', 'sparkline').
|
||||
style('fill', 'none').
|
||||
style('stroke', this.props.strokeColor).
|
||||
style('stroke-width', this.props.strokeWidth).
|
||||
attr('d', line);
|
||||
|
||||
svg.append('circle').
|
||||
attr('class', 'sparkcircle').
|
||||
attr('cx', lastX).
|
||||
attr('cy', lastY).
|
||||
attr('fill', '#46466a').
|
||||
attr('fill-opacity', 0.6).
|
||||
attr('stroke', 'none').
|
||||
attr('r', this.props.circleDiameter);
|
||||
return {title, lastX, lastY, data};
|
||||
}
|
||||
|
||||
render() {
|
||||
// Do nothing if no data or data w/o date are passed in.
|
||||
if (this.props.data.length === 0 || this.props.data[0].date === undefined) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const {lastX, lastY, title, data} = this.getGraphData();
|
||||
|
||||
return (
|
||||
<div/>
|
||||
<div title={title}>
|
||||
<svg width={this.props.width} height={this.props.height}>
|
||||
<path className="sparkline" fill="none" stroke={this.props.strokeColor}
|
||||
strokeWidth={this.props.strokeWidth} ref="path" d={this.line(data)} />
|
||||
<circle className="sparkcircle" cx={lastX} cy={lastY} fill="#46466a"
|
||||
fillOpacity="0.6" stroke="none" r={this.props.circleDiameter} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
return this.renderSparkline();
|
||||
}
|
||||
}
|
||||
|
||||
Sparkline.propTypes = {
|
||||
data: React.PropTypes.array.isRequired
|
||||
};
|
||||
|
||||
Sparkline.defaultProps = {
|
||||
width: 80,
|
||||
height: 16,
|
||||
height: 24,
|
||||
strokeColor: '#7d7da8',
|
||||
strokeWidth: '0.5px',
|
||||
interpolate: 'basis',
|
||||
circleDiameter: 1.75,
|
||||
data: [1, 23, 5, 5, 23, 0, 0, 0, 4, 32, 3, 12, 3, 1, 24, 1, 5, 5, 24, 23] // Some semi-random data.
|
||||
interpolate: 'none',
|
||||
circleDiameter: 1.75
|
||||
};
|
||||
|
||||
3
client/app/scripts/constants/timer.js
Normal file
3
client/app/scripts/constants/timer.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* Intervals in ms */
|
||||
export const API_INTERVAL = 30000;
|
||||
export const TOPOLOGY_INTERVAL = 5000;
|
||||
155
client/app/scripts/hoc/metric-feeder.js
Normal file
155
client/app/scripts/hoc/metric-feeder.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import d3 from 'd3';
|
||||
import { OrderedMap } from 'immutable';
|
||||
|
||||
const makeOrderedMap = OrderedMap;
|
||||
const parseDate = d3.time.format.iso.parse;
|
||||
const sortDate = (v, d) => d;
|
||||
const DEFAULT_TICK_INTERVAL = 1000; // DEFAULT_TICK_INTERVAL + renderTime < 1000ms
|
||||
const WINDOW_LENGTH = 60;
|
||||
|
||||
/**
|
||||
* Higher-order component that buffers a metrics series and feeds a sliding
|
||||
* window of the series to the wrapped component.
|
||||
*
|
||||
* Initial samples `[t0, t1, t2, ...]` will be passed as is. When new data
|
||||
* `[t2, t3, t4, ...]` comes in, it will be merged into the buffer:
|
||||
* `[t0, t1, t2, t3, t4, ...]`. On next `tick()` the window shifts and
|
||||
* `[t1, t2, t3, ...]` will be fed to the wrapped component.
|
||||
* The window slides between the dates provided by the first date of the buffer
|
||||
* and `this.props.last` so that the following invariant is true:
|
||||
* `this.state.movingFirst <= this.props.first < this.state.movingLast <= this.props.last`.
|
||||
*
|
||||
* Samples have to be of type `[{date: String, value: Number}, ...]`.
|
||||
* This component also keeps a historic max of all samples it sees over time.
|
||||
*/
|
||||
export default ComposedComponent => class extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.tickTimer = null;
|
||||
this.state = {
|
||||
buffer: makeOrderedMap(),
|
||||
max: 0,
|
||||
movingFirst: null,
|
||||
movingLast: null
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.setState(this.updateBuffer(this.props));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.tickTimer);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.setState(this.updateBuffer(nextProps));
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.tick();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.tick();
|
||||
}
|
||||
|
||||
updateBuffer(props) {
|
||||
// merge new samples into buffer
|
||||
let buffer = this.state.buffer;
|
||||
const nextSamples = makeOrderedMap(props.samples.map(d => [d.date, d.value]));
|
||||
// need to sort again after merge, some new data may have different times for old values
|
||||
buffer = buffer.merge(nextSamples).sortBy(sortDate);
|
||||
const state = {};
|
||||
|
||||
// remove old values from buffer
|
||||
if (this.state.movingFirst !== null) {
|
||||
buffer = buffer.filter((v, d) => d > this.state.movingFirst);
|
||||
}
|
||||
state.buffer = buffer;
|
||||
|
||||
// set historic max
|
||||
state.max = Math.max(buffer.max(), this.state.max);
|
||||
|
||||
// set first/last marker of sliding window
|
||||
if (buffer.size > 1) {
|
||||
const bufferKeys = buffer.keySeq();
|
||||
const firstPart = bufferKeys.slice(0, Math.floor(buffer.size / 3));
|
||||
|
||||
if (this.state.movingFirst === null) {
|
||||
state.movingFirst = firstPart.first();
|
||||
}
|
||||
if (this.state.movingLast === null) {
|
||||
state.movingLast = firstPart.last();
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
tick() {
|
||||
// only tick after setTimeout -> setState -> componentDidUpdate
|
||||
if (!this.tickTimer) {
|
||||
const { buffer } = this.state;
|
||||
let { movingFirst, movingLast } = this.state;
|
||||
const bufferKeys = buffer.keySeq();
|
||||
|
||||
// move the sliding window one tick, make sure to keep WINDOW_LENGTH values
|
||||
if (buffer.size > 0 && movingLast < bufferKeys.last()) {
|
||||
let firstIndex = bufferKeys.indexOf(movingFirst);
|
||||
let lastIndex = bufferKeys.indexOf(movingLast);
|
||||
|
||||
// speed up the window if it falls behind
|
||||
const step = lastIndex > 0 ? Math.round(buffer.size / lastIndex) : 1;
|
||||
|
||||
// only move first if we have enough values in window
|
||||
const windowLength = lastIndex - firstIndex;
|
||||
if (firstIndex > -1 && firstIndex < bufferKeys.size - 1 && windowLength >= WINDOW_LENGTH) {
|
||||
firstIndex += step + (windowLength - WINDOW_LENGTH);
|
||||
} else {
|
||||
firstIndex = 0;
|
||||
}
|
||||
movingFirst = bufferKeys.get(firstIndex);
|
||||
if (!movingFirst) {
|
||||
movingFirst = bufferKeys.first();
|
||||
}
|
||||
|
||||
if (lastIndex > -1) {
|
||||
lastIndex += step;
|
||||
} else {
|
||||
lastIndex = bufferKeys.size - 1;
|
||||
}
|
||||
movingLast = bufferKeys.get(lastIndex);
|
||||
if (!movingLast) {
|
||||
movingLast = bufferKeys.last();
|
||||
}
|
||||
|
||||
this.tickTimer = setTimeout(() => {
|
||||
this.tickTimer = null;
|
||||
this.setState({movingFirst, movingLast});
|
||||
}, DEFAULT_TICK_INTERVAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { buffer, max } = this.state;
|
||||
const movingFirstDate = parseDate(this.state.movingFirst);
|
||||
const movingLastDate = parseDate(this.state.movingLast);
|
||||
|
||||
const dateFilter = d => d.date > movingFirstDate && d.date <= movingLastDate;
|
||||
const samples = buffer
|
||||
.map((v, k) => ({value: v, date: +parseDate(k)}))
|
||||
.toIndexedSeq()
|
||||
.toJS()
|
||||
.filter(dateFilter);
|
||||
|
||||
const lastValue = samples.length > 0 ? samples[samples.length - 1].value : null;
|
||||
const slidingWindow = {first: movingFirstDate, last: movingLastDate, max, samples, value: lastValue};
|
||||
|
||||
return <ComposedComponent {...this.props} {...slidingWindow} />;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import filesize from 'filesize';
|
||||
import d3 from 'd3';
|
||||
|
||||
const formatLargeValue = d3.format('s');
|
||||
|
||||
const formatters = {
|
||||
filesize(value) {
|
||||
@@ -8,11 +11,14 @@ const formatters = {
|
||||
},
|
||||
|
||||
number(value) {
|
||||
return value;
|
||||
if (value < 1100 && value >= 0) {
|
||||
return Number(value).toFixed(2);
|
||||
}
|
||||
return formatLargeValue(value);
|
||||
},
|
||||
|
||||
percent(value) {
|
||||
return formatters.metric(value, '%');
|
||||
return formatters.metric(formatters.number(value), '%');
|
||||
},
|
||||
|
||||
metric(text, unit) {
|
||||
|
||||
@@ -6,13 +6,13 @@ import { clearControlError, closeWebsocket, openWebsocket, receiveError,
|
||||
receiveControlPipe, receiveControlPipeStatus, receiveControlSuccess,
|
||||
receiveTopologies, receiveNotFound } from '../actions/app-actions';
|
||||
|
||||
import { API_INTERVAL, TOPOLOGY_INTERVAL } from '../constants/timer';
|
||||
|
||||
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsUrl = wsProto + '://' + location.host + location.pathname.replace(/\/$/, '');
|
||||
const log = debug('scope:web-api-utils');
|
||||
|
||||
const apiTimerInterval = 10000;
|
||||
const reconnectTimerInterval = 5000;
|
||||
const topologyTimerInterval = apiTimerInterval;
|
||||
const updateFrequency = '5s';
|
||||
|
||||
let socket;
|
||||
@@ -95,14 +95,14 @@ export function getTopologies(options) {
|
||||
receiveTopologies(res);
|
||||
topologyTimer = setTimeout(function() {
|
||||
getTopologies(options);
|
||||
}, topologyTimerInterval / 2);
|
||||
}, TOPOLOGY_INTERVAL);
|
||||
},
|
||||
error: function(err) {
|
||||
log('Error in topology request: ' + err);
|
||||
receiveError(url);
|
||||
topologyTimer = setTimeout(function() {
|
||||
getTopologies(options);
|
||||
}, topologyTimerInterval / 2);
|
||||
}, TOPOLOGY_INTERVAL / 2);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -155,12 +155,12 @@ export function getApiDetails() {
|
||||
url: url,
|
||||
success: function(res) {
|
||||
receiveApiDetails(res);
|
||||
apiDetailsTimer = setTimeout(getApiDetails, apiTimerInterval);
|
||||
apiDetailsTimer = setTimeout(getApiDetails, API_INTERVAL);
|
||||
},
|
||||
error: function(err) {
|
||||
log('Error in api details request: ' + err);
|
||||
receiveError(url);
|
||||
apiDetailsTimer = setTimeout(getApiDetails, apiTimerInterval / 2);
|
||||
apiDetailsTimer = setTimeout(getApiDetails, API_INTERVAL / 2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -717,7 +717,7 @@ h2 {
|
||||
}
|
||||
}
|
||||
|
||||
&-value {
|
||||
&-value, &-metric {
|
||||
flex: 1;
|
||||
margin-left: 0.5em;
|
||||
text-align: right;
|
||||
|
||||
Reference in New Issue
Block a user