feat: initial metrics implementation

This commit is contained in:
JUDIT GRESKOVITS 2017-08-22 16:24:49 +01:00 committed by Sérgio Ramos
parent 0d8a282248
commit 1eac90c79a
19 changed files with 92625 additions and 7 deletions

View File

@ -21,6 +21,7 @@
"dependencies": {
"apollo": "^0.2.2",
"apr-intercept": "^1.0.4",
"chart.js": "^2.6.0",
"constant-case": "^2.0.0",
"force-array": "^3.1.0",
"graphql-tag": "^2.4.0",

View File

@ -1,2 +1,3 @@
export { default as ServiceScale } from './scale';
export { default as ServiceDelete } from './delete';
export { default as ServiceMetrics} from './metrics';

View File

@ -0,0 +1,35 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { MetricGraph } from 'joyent-ui-toolkit';
const ServiceMetrics = ({
metricsData,
graphDurationSeconds
}) => {
// metricsData should prob be an array rather than an object
const metricGraphs = Object.keys(metricsData).map((key) => (
// should also have a header, w metric name and number of instances (omit everything else from design for copilot)
<MetricGraph
key={key}
metricsData={metricsData[key]}
width={954}
height={292}
graphDurationSeconds={graphDurationSeconds}
/>
))
// This needs layout!!!
return (
<div>
{metricGraphs}
</div>
)
}
ServiceMetrics.propTypes = {
// metricsData should prob be an array rather than an object
metricsData: PropTypes.object.isRequired,
graphDurationSeconds: PropTypes.number.isRequired
}
export default ServiceMetrics;

View File

@ -0,0 +1 @@
export * from './metrics-data-hoc';

View File

@ -0,0 +1,128 @@
import React, { Component } from 'react';
import { compose, graphql } from 'react-apollo';
import moment from 'moment';
export const MetricNames = [
'AVG_MEM_BYTES',
'AVG_LOAD_PERCENT',
'AGG_NETWORK_BYTES'
];
export const withServiceMetricsPolling = ({
pollingInterval = 1000 // in milliseconds
}) => {
return (WrappedComponent) => {
return class extends Component {
componentDidMount() {
this._poll = setInterval(() => {
const {
loading,
error,
service,
fetchMoreMetrics
} = this.props;
if(!loading && !error && service) {
const previousEnd = service.instances[0].metrics[0].end;
fetchMoreMetrics(previousEnd);
}
}, pollingInterval); // TODO this is the polling interval - think about amount is the todo I guess...
}
componentWillUnmount() {
clearInterval(this._poll);
}
render() {
return <WrappedComponent {...this.props} />
}
}
}
}
export const withServiceMetricsGql = ({
gqlQuery,
graphDurationSeconds,
updateIntervalSeconds
}) => {
const getPreviousMetrics = (previousResult, serviceId, instanceId, metricName) => {
return previousResult.deploymentGroup.services
.find(s => s.id === serviceId).instances
.find(i => i.id === instanceId).metrics
.find(m => m.name === metricName).metrics;
}
const getNextResult = (previousResult, fetchNextResult) => {
const deploymentGroup = fetchNextResult.deploymentGroup;
const nextResult = {
deploymentGroup: {
...deploymentGroup,
services: deploymentGroup.services.map(service => ({
...service,
instances: service.instances.map(instance => ({
...instance,
metrics: instance.metrics.map(metric => ({
...metric,
metrics: getPreviousMetrics(
previousResult,
service.id,
instance.id,
metric.name
).concat(metric.metrics)
}))
}))
}))
}
}
return nextResult;
}
return graphql(gqlQuery, {
options(props) {
const params = props.match.params;
const deploymentGroupSlug = params.deploymentGroup;
const serviceSlug = params.service;
// this is potentially prone to overfetching if we already have data within timeframe and we leave the page then come back to it
const end = moment();
const start = moment(end).subtract(graphDurationSeconds + updateIntervalSeconds, 'seconds'); // TODO initial amount of data we wanna get - should be the same as what we display + 15 secs
return {
variables: {
deploymentGroupSlug,
serviceSlug,
metricNames: MetricNames,
start: start.utc().format(),
end: end.utc().format()
}
};
},
props: ({ data: { deploymentGroup, loading, error, variables, fetchMore }}) => {
const fetchMoreMetrics = (previousEnd) => {
fetchMore({
variables: {
...variables,
start: previousEnd,
end: moment().utc().format()
},
updateQuery: (previousResult, { fetchMoreResult, queryVariables }) => {
return getNextResult(previousResult, fetchMoreResult);
}
});
}
return ({
deploymentGroup,
service: !loading && deploymentGroup ? deploymentGroup.services[0] : null,
loading,
error,
fetchMoreMetrics
})
}
});
}

View File

@ -1,2 +1,3 @@
export { default as ServiceScale } from './scale';
export { default as ServiceDelete } from './delete';
export { default as ServiceMetrics } from './metrics';

View File

@ -0,0 +1,239 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { compose, graphql } from 'react-apollo';
import { connect } from 'react-redux';
import moment from 'moment';
import ServiceMetricsQuery from '@graphql/ServiceMetrics.gql';
import { withNotFound, GqlPaths } from '@containers/navigation';
import { LayoutContainer } from '@components/layout';
import { ServiceMetrics as ServiceMetricsComponent } from '@components/service';
import { Button } from 'joyent-ui-toolkit';
import { Loader, ErrorMessage } from '@components/messaging';
import { withServiceMetricsPolling, withServiceMetricsGql } from '@containers/metrics';
// 'width' of graph, i.e. total duration of time it'll display and truncate data to
// amount of data we'll need to initially fetch
const GraphDurationSeconds = 90;
const ServiceMetrics = ({
service,
loading,
error
}) => {
if (loading || !service) {
return (
<LayoutContainer center>
<Loader />
</LayoutContainer>
);
}
if (error) {
return (
<LayoutContainer>
<ErrorMessage
title="Ooops!"
message="An error occurred while loading your metrics."
/>
</LayoutContainer>
);
}
// metricsData should prob be an array rather than an object
const metricsData = service.instances.reduce((metrics, instance) => {
// gather metrics of instances according to type
instance.metrics.forEach((instanceMetrics) => {
if(!metrics[instanceMetrics.name]) {
metrics[instanceMetrics.name] = [];
}
metrics[instanceMetrics.name].push(instanceMetrics);
});
return metrics;
}, {});
return (
<LayoutContainer>
<ServiceMetricsComponent
metricsData={metricsData}
graphDurationSeconds={GraphDurationSeconds}
/>
</LayoutContainer>
);
};
export default compose(
withServiceMetricsGql({
gqlQuery: ServiceMetricsQuery,
graphDurationSeconds: GraphDurationSeconds,
updateIntervalSeconds: 15
}),
withServiceMetricsPolling({ pollingInterval: 1000 }),
withNotFound([
GqlPaths.DEPLOYMENT_GROUP,
GqlPaths.SERVICES
])
)(ServiceMetrics);
/*
const metricNames = [
'AVG_MEM_BYTES',
'AVG_LOAD_PERCENT',
'AGG_NETWORK_BYTES'
];
class ServiceMetrics extends Component {
componentDidMount() {
this._poll = setInterval(() => {
const {
loading,
deploymentGroup,
service,
fetchMoreMetrics
} = this.props;
if(!loading && service) {
const previousEnd = service.instances[0].metrics[0].end;
fetchMoreMetrics(previousEnd);
}
}, 1000); // TODO this is the polling interval - think about amount is the todo I guess...
}
componentWillUnmount() {
clearInterval(this._poll);
}
render () {
const {
service,
loading,
error,
fetchMoreMetrics
} = this.props;
if (loading || !service) {
return (
<LayoutContainer center>
<Loader />
</LayoutContainer>
);
}
if (error) {
return (
<LayoutContainer>
<ErrorMessage
title="Ooops!"
message="An error occurred while loading your metrics."
/>
</LayoutContainer>
);
}
// metricsData should prob be an array rather than an object
const metricsData = service.instances.reduce((metrics, instance) => {
// gather metrics of instances according to type
instance.metrics.forEach((instanceMetrics) => {
if(!metrics[instanceMetrics.name]) {
metrics[instanceMetrics.name] = [];
}
metrics[instanceMetrics.name].push(instanceMetrics);
});
return metrics;
}, {});
return (
<LayoutContainer>
<ServiceMetricsComponent metricsData={metricsData} />
</LayoutContainer>
);
}
};
const getPreviousMetrics = (previousResult, serviceId, instanceId, metricName) => {
return previousResult.deploymentGroup.services
.find(s => s.id === serviceId).instances
.find(i => i.id === instanceId).metrics
.find(m => m.name === metricName).metrics;
}
const getNextResult = (previousResult, fetchNextResult) => {
const deploymentGroup = fetchNextResult.deploymentGroup;
const nextResult = {
deploymentGroup: {
...deploymentGroup,
services: deploymentGroup.services.map(service => ({
...service,
instances: service.instances.map(instance => ({
...instance,
metrics: instance.metrics.map(metric => ({
...metric,
metrics: getPreviousMetrics(
previousResult,
service.id,
instance.id,
metric.name
).concat(metric.metrics)
}))
}))
}))
}
}
return nextResult;
}
const ServiceMetricsGql = graphql(ServiceMetricsQuery, {
options(props) {
const params = props.match.params;
const deploymentGroupSlug = params.deploymentGroup;
const serviceSlug = params.service;
// this is potentially prone to overfetching if we already have data within timeframe and we leave the page then come back to it
const end = moment();
const start = moment(end).subtract(105, 'seconds'); // TODO initial amount of data we wanna get - should be the same as what we display + 15 secs
return {
variables: {
deploymentGroupSlug,
serviceSlug,
metricNames,
start: start.utc().format(),
end: end.utc().format()
}
};
},
props: ({ data: { deploymentGroup, loading, error, variables, fetchMore }}) => {
const fetchMoreMetrics = (previousEnd) => {
fetchMore({
variables: {
...variables,
start: previousEnd,
end: moment().utc().format()
},
updateQuery: (previousResult, { fetchMoreResult, queryVariables }) => {
return getNextResult(previousResult, fetchMoreResult);
}
});
}
return ({
deploymentGroup,
service: !loading && deploymentGroup ? deploymentGroup.services[0] : null,
loading,
error,
fetchMoreMetrics
})
}
});
export default compose(
ServiceMetricsGql,
withNotFound([
GqlPaths.DEPLOYMENT_GROUP,
GqlPaths.SERVICES
])
)(ServiceMetrics); */

View File

@ -0,0 +1,32 @@
query Instances(
$deploymentGroupSlug: String!,
$serviceSlug: String!,
$metricNames: [MetricName]!,
$start: String!,
$end: String!
) {
deploymentGroup(slug: $deploymentGroupSlug) {
id
name
slug
services(slug: $serviceSlug) {
id
name
slug
instances {
id
name
metrics(names: $metricNames, start: $start, end: $end) {
instance
name
start
end
metrics {
time
value
}
}
}
}
}
}

View File

@ -3,7 +3,6 @@ import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom';
import styled from 'styled-components';
import { Header, Breadcrumb, Menu } from '@containers/navigation';
import { ServiceScale, ServiceDelete } from '@containers/service';
import Manifest from '@containers/manifest';
import Environment from '@containers/environment';
@ -20,6 +19,12 @@ import {
ServicesQuickActions
} from '@containers/services';
import {
ServiceScale,
ServiceDelete,
ServiceMetrics
} from '@containers/service';
import {
InstanceList,
InstancesTooltip
@ -167,6 +172,12 @@ const App = p =>
component={InstanceList}
/>
<Route
path="/deployment-groups/:deploymentGroup/services/:service/metrics"
exact
component={ServiceMetrics}
/>
<Route
path="/deployment-groups/:deploymentGroup/services/:service"
component={serviceRedirect}

View File

@ -23,6 +23,10 @@ const state = {
{
pathname: 'instances',
name: 'Instances'
},
{
pathname: 'metrics',
name: 'Metrics'
}
]
},

View File

@ -28,7 +28,10 @@ export const client = new ApolloClient({
? o.uuid
: o.timestamp
? o.timestamp
: o.name ? o.name : 'apollo-cache-key-not-defined';
: o.name && o.instance
? `${o.name}-${o.instance}`
: o.name ? o.name : o.time && o.value
? `${o.time}-${o.value}` : 'apollo-cache-key-not-defined';
return `${o.__typename}:${id}`;
},
networkInterface: createNetworkInterface({

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -11,11 +11,16 @@ const uniq = require('lodash.uniq');
const yaml = require('js-yaml');
const hasha = require('hasha');
const Boom = require('boom');
const moment = require('moment');
const wpData = require('./wp-data.json');
const cpData = require('./cp-data.json');
const complexData = require('./complex-data.json');
const metricData = require('./metric-data.json');
const metricData = [
require('./metric-datasets-0.json'),
require('./metric-datasets-1.json'),
require('./metric-datasets-2.json')
];
const { datacenter, portal } = require('./data.json');
@ -40,16 +45,55 @@ const find = (query = {}) => item =>
const cleanQuery = (q = {}) => JSON.parse(JSON.stringify(q));
let metricDataIndex = 0;
const getMetrics = query => {
const {
names,
start,
end
end,
instanceId
} = query;
const metrics = names.reduce((metrics, name) =>
metrics.concat(metricData.filter(md =>
md.name === name)), []);
const metrics = names.reduce((metrics, name) => {
// pick one of the three metric data jsons, so there's variety
const index = metricDataIndex%metricData.length;
metricDataIndex++;
const md = metricData[index].find(md => md.name === name);
const m = md.metrics;
const s = moment.utc(start);
const e = moment.utc(end);
// how many records do we need?
const duration = e.diff(s); // duration for which we need data
const records = Math.floor(duration/15000); // new metric record every 15 secs
const requiredMetrics = [];
let i = 0;
const time = moment(s);
// start at a random point within the dataset for variety
const randomIndex = Math.round(Math.random() * m.length);
while(i < records) {
const index = (randomIndex + i)%m.length; // loop if not enough data
const requiredMetric = m[index];
requiredMetric.time = time.add(15, 'seconds').utc().format(); // we should have a new record every 15 secs
requiredMetrics.push(requiredMetric);
i++;
}
const requiredMetricData = {
instance: instanceId,
name,
start: s.utc().format(),
end: time.utc().format(), // this will be used by the frontend for the next fetch
metrics: requiredMetrics
}
metrics.push(requiredMetricData);
return metrics;
}, []);
return Promise.resolve(metrics);
}

View File

@ -181,6 +181,8 @@ enum MetricName {
type InstanceMetric {
instance: String!
name: MetricName!
start: String!
end: String!
metrics: [Metric]
}

View File

@ -107,3 +107,7 @@ export {
UnhealthyIcon,
BinIcon
} from './icons';
export {
MetricGraph
} from './metrics';

View File

@ -0,0 +1,153 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Chart from 'chart.js';
import moment from 'moment';
// colours to come from theme
// AJ to supply friendly colours - these were nicked from chartjs utils
const chartColors = [
'rgb(255, 99, 132)',
'rgb(255, 159, 64)',
'rgb(255, 205, 86)',
'rgb(75, 192, 192)',
'rgb(54, 162, 235)',
'rgb(153, 102, 255)',
'rgb(201, 203, 207)'
];
// TODO DISPLAY TIMES SHOULD NOT BE UTC
class MetricGraph extends Component {
componentDidMount() {
const {
xMin,
xMax,
datasets
} = this.processProps(this.props);
const config = {
type: 'line',
data: { datasets },
options: {
responsive: false, // this needs to be played with
legend: {
display: false
},
tooltip: {
display: false // this config doesn't seem to work???
},
scales: {
xAxes: [{
display: true, // config for mini should be false
type: 'time',
distribution: 'linear',
time: {
unit: 'minute', // this also needs to be played with
min: xMin,
max: xMax
}
}],
yAxes: [{
display: true // needs min / max and measurement
}]
}
}
};
const ctx = this._refs.chart.getContext('2d');
this._chart = new Chart(ctx, config);
}
processProps(props) {
const {
metricsData,
graphDurationSeconds
} = props;
const xMax = metricsData[0].end;
const xMin = moment.utc(xMax).subtract(graphDurationSeconds, 'seconds').utc().format();
const datasets = metricsData.map((data, i) => ({
fill: false,
borderColor: chartColors[i],
data: this.truncateAndConvertMetrics(data.metrics, xMin, xMax)
}));
return {
xMax,
xMin,
datasets
};
}
truncateAndConvertMetrics(metrics, xMin, xMax) {
const xMinMoment = moment.utc(xMin);
return metrics.reduce((metrics, metric) => {
const diff = moment.utc(metric.time).diff(xMinMoment);
if(diff > -10000) { // diff comparison is less than zero - otherwise no data for beginning of chart - bug or charjs weirdness?
metrics.push({
x: metric.time,
y: metric.value // value should be converted here to a readable format
})
}
return metrics;
}, []);
}
componentWillReceiveProps(nextProps) {
const {
xMin,
xMax,
datasets
} = this.processProps(nextProps);
this._chart.data.datasets = datasets;
// these need to be set, but don't seem to truncate the data that's displayed
this._chart.options.scales.xAxes[0].time.max = xMax;
this._chart.options.scales.xAxes[0].time.min = xMin;
this._chart.update(0);
}
// should not rerender ever, we update only the canvas via chartjs
shouldComponentUpdate() {
return false;
}
ref(name) {
this._refs = this._refs || {};
return el => {
this._refs[name] = el;
};
}
render() {
const {
width,
height
} = this.props;
return (
<canvas
ref={this.ref('chart')}
width={width}
height={height}
/>
);
}
}
MetricGraph.propTypes = {
metricsData: PropTypes.object.isRequired,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
graphDurationSeconds: PropTypes.number.isRequired // 'width' of graph, i.e. total duration of time it'll display and truncate data to
};
export default MetricGraph;

View File

@ -0,0 +1 @@
export { default as MetricGraph } from './graph';