diff --git a/packages/cp-frontend/package.json b/packages/cp-frontend/package.json
index d23967cb..d109c924 100644
--- a/packages/cp-frontend/package.json
+++ b/packages/cp-frontend/package.json
@@ -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",
diff --git a/packages/cp-frontend/src/components/service/index.js b/packages/cp-frontend/src/components/service/index.js
index 43c4de71..c0aed185 100644
--- a/packages/cp-frontend/src/components/service/index.js
+++ b/packages/cp-frontend/src/components/service/index.js
@@ -1,2 +1,3 @@
export { default as ServiceScale } from './scale';
export { default as ServiceDelete } from './delete';
+export { default as ServiceMetrics} from './metrics';
diff --git a/packages/cp-frontend/src/components/service/metrics.js b/packages/cp-frontend/src/components/service/metrics.js
new file mode 100644
index 00000000..e9ee275f
--- /dev/null
+++ b/packages/cp-frontend/src/components/service/metrics.js
@@ -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)
+
+ ))
+ // This needs layout!!!
+ return (
+
+ {metricGraphs}
+
+ )
+}
+
+ServiceMetrics.propTypes = {
+ // metricsData should prob be an array rather than an object
+ metricsData: PropTypes.object.isRequired,
+ graphDurationSeconds: PropTypes.number.isRequired
+}
+
+export default ServiceMetrics;
diff --git a/packages/cp-frontend/src/containers/metrics/index.js b/packages/cp-frontend/src/containers/metrics/index.js
new file mode 100644
index 00000000..66ff90e0
--- /dev/null
+++ b/packages/cp-frontend/src/containers/metrics/index.js
@@ -0,0 +1 @@
+export * from './metrics-data-hoc';
diff --git a/packages/cp-frontend/src/containers/metrics/metrics-data-hoc.js b/packages/cp-frontend/src/containers/metrics/metrics-data-hoc.js
new file mode 100644
index 00000000..89a0f77b
--- /dev/null
+++ b/packages/cp-frontend/src/containers/metrics/metrics-data-hoc.js
@@ -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
+ }
+ }
+ }
+}
+
+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
+ })
+ }
+ });
+}
diff --git a/packages/cp-frontend/src/containers/service/index.js b/packages/cp-frontend/src/containers/service/index.js
index 43c4de71..030c1f8f 100644
--- a/packages/cp-frontend/src/containers/service/index.js
+++ b/packages/cp-frontend/src/containers/service/index.js
@@ -1,2 +1,3 @@
export { default as ServiceScale } from './scale';
export { default as ServiceDelete } from './delete';
+export { default as ServiceMetrics } from './metrics';
diff --git a/packages/cp-frontend/src/containers/service/metrics.js b/packages/cp-frontend/src/containers/service/metrics.js
new file mode 100644
index 00000000..1dc6ef72
--- /dev/null
+++ b/packages/cp-frontend/src/containers/service/metrics.js
@@ -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 (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ );
+ }
+
+ // 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 (
+
+
+
+ );
+};
+
+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 (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ );
+ }
+
+ // 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 (
+
+
+
+ );
+ }
+};
+
+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); */
diff --git a/packages/cp-frontend/src/graphql/ServiceMetrics.gql b/packages/cp-frontend/src/graphql/ServiceMetrics.gql
new file mode 100644
index 00000000..6c6cb232
--- /dev/null
+++ b/packages/cp-frontend/src/graphql/ServiceMetrics.gql
@@ -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
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/cp-frontend/src/router.js b/packages/cp-frontend/src/router.js
index cf50a1fb..7e781717 100644
--- a/packages/cp-frontend/src/router.js
+++ b/packages/cp-frontend/src/router.js
@@ -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}
/>
+
+
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);
}
diff --git a/packages/cp-gql-schema/schema.gql b/packages/cp-gql-schema/schema.gql
index 28c96387..c48b2912 100644
--- a/packages/cp-gql-schema/schema.gql
+++ b/packages/cp-gql-schema/schema.gql
@@ -181,6 +181,8 @@ enum MetricName {
type InstanceMetric {
instance: String!
name: MetricName!
+ start: String!
+ end: String!
metrics: [Metric]
}
diff --git a/packages/ui-toolkit/src/index.js b/packages/ui-toolkit/src/index.js
index 67ebb298..6ee24ad8 100644
--- a/packages/ui-toolkit/src/index.js
+++ b/packages/ui-toolkit/src/index.js
@@ -107,3 +107,7 @@ export {
UnhealthyIcon,
BinIcon
} from './icons';
+
+export {
+ MetricGraph
+} from './metrics';
diff --git a/packages/ui-toolkit/src/metrics/graph.js b/packages/ui-toolkit/src/metrics/graph.js
new file mode 100644
index 00000000..c53839c8
--- /dev/null
+++ b/packages/ui-toolkit/src/metrics/graph.js
@@ -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 (
+
+ );
+ }
+}
+
+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;
diff --git a/packages/ui-toolkit/src/metrics/index.js b/packages/ui-toolkit/src/metrics/index.js
new file mode 100644
index 00000000..e5d40d1d
--- /dev/null
+++ b/packages/ui-toolkit/src/metrics/index.js
@@ -0,0 +1 @@
+export { default as MetricGraph } from './graph';