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';