feat(cp-frontend): display metrics in service list

This commit is contained in:
Sérgio Ramos 2017-08-30 01:22:35 +01:00
parent 7f22dea0b8
commit b89d1ad686
15 changed files with 314 additions and 275 deletions

View File

@ -83,6 +83,15 @@ const StyledCard = Card.extend`
}
`;
const StatusContainer = styled.div`
height: 100%;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: center;
align-content: center;
`;
const InstanceCard = ({
instance,
onHealthMouseOver = () => {},
@ -117,22 +126,25 @@ const InstanceCard = ({
<CardView>
<CardTitle>{instance.name}</CardTitle>
<CardDescription>
<div onMouseOver={handleHealthMouseOver} onMouseOut={handleMouseOut}>
<CardInfo
icon={icon}
iconPosition="left"
label={label}
color="dark"
/>
</div>
<CardInfo
icon={icon}
iconPosition="left"
label={label}
color="dark"
onMouseOver={handleHealthMouseOver}
onMouseOut={handleMouseOut}
/>
</CardDescription>
<CardDescription>
<div onMouseOver={handleStatusMouseOver} onMouseOut={handleMouseOut}>
<StatusContainer
onMouseOver={handleStatusMouseOver}
onMouseOut={handleMouseOut}
>
<Label>
<Dot {...statusProps} />
{titleCase(instance.status)}
</Label>
</div>
</StatusContainer>
</CardDescription>
</CardView>
</StyledCard>

View File

@ -1,19 +1,50 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { MetricGraph } from 'joyent-ui-toolkit';
import styled from 'styled-components';
import remcalc from 'remcalc';
import {
MetricGraph,
Card,
CardView,
CardTitle,
CardSubTitle,
CardDescription,
CardGroupView,
CardOptions,
CardHeader,
CardInfo,
Anchor
} from 'joyent-ui-toolkit';
const MetricView = styled(CardView)`
padding-top: ${remcalc(48)};
& canvas {
margin: 0 auto;
}
`;
const ServiceMetrics = ({ metricsData, graphDurationSeconds }) => {
// metricsData should prob be an array rather than an object
// should also have a header, w metric name and number of instances (omit everything else from design for copilot)
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}
/>
<Card key={key} headed active>
<CardHeader>
<CardTitle>{key}</CardTitle>
</CardHeader>
<MetricView>
<MetricGraph
key={key}
metricsData={metricsData[key]}
graphDurationSeconds={graphDurationSeconds}
displayY
displayX
/>
</MetricView>
</Card>
));
// This needs layout!!!
return <div>{metricGraphs}</div>;
};

View File

@ -4,11 +4,15 @@ import styled from 'styled-components';
import forceArray from 'force-array';
import sortBy from 'lodash.sortby';
import { isNot } from 'styled-is';
import { Col, Row } from 'react-styled-flexboxgrid';
import remcalc from 'remcalc';
import { InstancesIcon, HealthyIcon } from 'joyent-ui-toolkit';
import Status from './status';
import {
Small,
MetricGraph,
Card,
CardView,
CardTitle,
@ -38,6 +42,73 @@ const StyledAnchor = styled(Anchor)`
`};
`;
const GraphsContainer = styled(Row)`
background: #f6f7fe;
width: 50%;
margin: 0;
flex: 1;
`;
const GraphContainer = styled(Col)`
position: relative;
border-left: ${remcalc(1)} solid #d8d8d8;
padding-top: ${remcalc(20)};
`;
const GraphLeftShaddow = styled.div`
z-index: 99;
position: absolute;
margin-left: ${remcalc(-8)};
margin-top: ${remcalc(-20)};
width: ${remcalc(12)};
height: 100%;
background-image: linear-gradient(
to right,
rgba(213, 216, 231, 0.8),
rgba(243, 244, 249, 0)
);
`;
const GraphTitle = Small.extend`
z-index: 99;
position: absolute;
top: 0;
left: 0;
right: 0;
height: ${remcalc(20)};
border-bottom: ${remcalc(1)} solid #d8d8d8;
font-size: ${remcalc(13)};
text-align: center;
color: #494949;
`;
const ChildTitle = styled(CardTitle)`
padding: 0;
flex: 0 1 auto;
align-self: stretch;
`;
const ServiceView = styled(CardView)`
height: ${remcalc(120)};
`;
const StatusContainer = styled(CardDescription)`
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: center;
align-content: center;
align-items: stretch;
`;
const HealthInfoContainer = styled.div`
flex: 0 1 auto;
align-self: flex-end;
position: absolute;
bottom: 0;
`;
const ServiceListItem = ({
onQuickActionsClick = () => {},
deploymentGroup = '',
@ -68,7 +139,7 @@ const ServiceListItem = ({
: null;
const title = isChild ? (
<CardTitle>{service.name}</CardTitle>
<ChildTitle>{service.name}</ChildTitle>
) : (
<CardTitle>
<TitleInnerContainer>
@ -79,13 +150,6 @@ const ServiceListItem = ({
</CardTitle>
);
const subtitle = (
<CardSubTitle>
{service.instances.length}{' '}
{service.instances.length > 1 ? 'instances' : 'instance'}
</CardSubTitle>
);
const header = !isChild ? (
<StyledCardHeader>
{title}
@ -115,17 +179,31 @@ const ServiceListItem = ({
);
}
const metrics = !children.length
? Object.keys(service.metrics).map(key => (
<GraphContainer xs={4}>
<GraphLeftShaddow />
<GraphTitle>{key}</GraphTitle>
<MetricGraph
key={key}
metricsData={service.metrics[key]}
graphDurationSeconds={90}
/>
</GraphContainer>
))
: null;
const view = children.length ? (
<CardGroupView>{childrenItems}</CardGroupView>
) : (
<CardView>
{isChild && title}
{isChild && subtitle}
<CardDescription>
<ServiceView>
<StatusContainer>
{isChild && title}
<Status service={service} />
{healthyInfo}
</CardDescription>
</CardView>
<HealthInfoContainer>{healthyInfo}</HealthInfoContainer>
</StatusContainer>
<GraphsContainer>{metrics}</GraphsContainer>
</ServiceView>
);
return (

View File

@ -6,9 +6,10 @@ import { StatusLoader, P } from 'joyent-ui-toolkit';
const StyledStatusContainer = styled.div`
display: inline-block;
margin: 0 0 ${remcalc(15)} 0;
height: ${remcalc(54)};
width: ${remcalc(200)};
margin: 0;
flex: 1 1 auto;
align-self: stretch;
`;
const StyledStatus = P.extend`

View File

@ -1,5 +1,6 @@
import React, { Component } from 'react';
import { compose, graphql } from 'react-apollo';
import get from 'lodash.get';
import moment from 'moment';
export const MetricNames = [
@ -18,7 +19,13 @@ export const withServiceMetricsPolling = ({
const { loading, error, service, fetchMoreMetrics } = this.props;
if (!loading && !error && service) {
const previousEnd = service.instances[0].metrics[0].end;
const previousEnd = get(
service,
'instances[0].metrics[0].end',
moment()
.utc()
.format()
);
fetchMoreMetrics(previousEnd);
}
}, pollingInterval); // TODO this is the polling interval - think about amount is the todo I guess...
@ -38,7 +45,9 @@ export const withServiceMetricsPolling = ({
export const withServiceMetricsGql = ({
gqlQuery,
graphDurationSeconds,
updateIntervalSeconds
updateIntervalSeconds,
variables = () => ({}),
props = () => ({})
}) => {
const getPreviousMetrics = (
previousResult,
@ -81,7 +90,6 @@ export const withServiceMetricsGql = ({
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();
@ -93,16 +101,14 @@ export const withServiceMetricsGql = ({
return {
variables: {
deploymentGroupSlug,
serviceSlug,
metricNames: MetricNames,
start: start.utc().format(),
end: end.utc().format()
end: end.utc().format(),
...variables(props)
}
};
},
props: ({
data: { deploymentGroup, loading, error, variables, fetchMore }
}) => {
props: ({ data: { variables, fetchMore, ...rest } }) => {
const fetchMoreMetrics = previousEnd => {
fetchMore({
variables: {
@ -120,13 +126,10 @@ export const withServiceMetricsGql = ({
}
});
};
return {
deploymentGroup,
service:
!loading && deploymentGroup ? deploymentGroup.services[0] : null,
loading,
error,
fetchMoreMetrics
fetchMoreMetrics,
...props(rest)
};
}
});

View File

@ -6,9 +6,12 @@ import moment from 'moment';
import ServiceMetricsQuery from '@graphql/ServiceMetrics.gql';
import { withNotFound, GqlPaths } from '@containers/navigation';
import { LayoutContainer } from '@components/layout';
import { Title } from '@components/navigation';
import { ServiceMetrics as ServiceMetricsComponent } from '@components/service';
import { Button } from 'joyent-ui-toolkit';
import { Loader, ErrorMessage } from '@components/messaging';
import { processInstancesMetrics } from '@state/selectors';
import get from 'lodash.get';
import {
withServiceMetricsPolling,
@ -20,9 +23,12 @@ import {
const GraphDurationSeconds = 90;
const ServiceMetrics = ({ service, loading, error }) => {
const _title = <Title>Metrics</Title>;
if (loading || !service) {
return (
<LayoutContainer center>
{_title}
<Loader />
</LayoutContainer>
);
@ -31,6 +37,7 @@ const ServiceMetrics = ({ service, loading, error }) => {
if (error) {
return (
<LayoutContainer>
{_title}
<ErrorMessage
title="Ooops!"
message="An error occurred while loading your metrics."
@ -39,22 +46,11 @@ const ServiceMetrics = ({ service, loading, error }) => {
);
}
// 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>
{_title}
<ServiceMetricsComponent
metricsData={metricsData}
metricsData={processInstancesMetrics(service.instances)}
graphDurationSeconds={GraphDurationSeconds}
/>
</LayoutContainer>
@ -65,171 +61,15 @@ export default compose(
withServiceMetricsGql({
gqlQuery: ServiceMetricsQuery,
graphDurationSeconds: GraphDurationSeconds,
updateIntervalSeconds: 15
updateIntervalSeconds: 15,
variables: ({ match }) => ({ serviceSlug: match.params.service }),
props: ({ deploymentGroup, loading, error }) => ({
deploymentGroup,
service: get(deploymentGroup || {}, 'services', [])[0],
loading,
error
})
}),
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

@ -8,13 +8,25 @@ import sortBy from 'lodash.sortby';
import ServicesQuery from '@graphql/Services.gql';
import { processServices } from '@root/state/selectors';
import {
processServices,
processInstancesMetrics
} from '@root/state/selectors';
import { toggleServicesQuickActions } from '@root/state/actions';
import { withNotFound, GqlPaths } from '@containers/navigation';
import { LayoutContainer } from '@components/layout';
import { Loader, ErrorMessage } from '@components/messaging';
import { ServiceListItem } from '@components/services';
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 StyledContainer = styled.div`
position: relative;
`;
@ -107,16 +119,29 @@ export class ServiceList extends Component {
);
}
const serviceList = sortBy(services, ['slug']).map(service => {
return (
const serviceList = sortBy(services, ['slug'])
.map(service =>
Object.assign(service, {
metrics: !service.children
? processInstancesMetrics(service.instances)
: null,
children: service.children
? service.children.map(children =>
Object.assign(children, {
metrics: processInstancesMetrics(children.instances)
})
)
: null
})
)
.map(service => (
<ServiceListItem
key={service.id}
deploymentGroup={deploymentGroup.slug}
service={service}
onQuickActionsClick={handleQuickActionsClick}
/>
);
});
));
return (
<LayoutContainer>
@ -143,29 +168,21 @@ const mapDispatchToProps = dispatch => ({
const UiConnect = connect(mapStateToProps, mapDispatchToProps);
const ServicesGql = graphql(ServicesQuery, {
options(props) {
return {
pollInterval: 1000,
variables: {
deploymentGroupSlug: props.match.params.deploymentGroup
}
};
},
props: ({ data: { deploymentGroup, loading, error } }) => ({
deploymentGroup,
services: deploymentGroup
? processServices(deploymentGroup.services, null)
: null,
loading,
error
})
});
const ServiceListWithData = compose(
ServicesGql,
export default compose(
withServiceMetricsGql({
gqlQuery: ServicesQuery,
graphDurationSeconds: GraphDurationSeconds,
updateIntervalSeconds: 15,
props: ({ deploymentGroup, loading, error }) => ({
deploymentGroup,
services: deploymentGroup
? processServices(deploymentGroup.services, null)
: null,
loading,
error
})
}),
withServiceMetricsPolling({ pollingInterval: 1000 }),
UiConnect,
withNotFound([GqlPaths.DEPLOYMENT_GROUP])
)(ServiceList);
export default ServiceListWithData;

View File

@ -1,7 +1,12 @@
#import "./DeploymentGroupInfo.gql"
#import "./ServiceInfo.gql"
query Services($deploymentGroupSlug: String!) {
query Services(
$deploymentGroupSlug: String!
$metricNames: [MetricName]!
$start: String!
$end: String!
) {
deploymentGroup(slug: $deploymentGroupSlug) {
...DeploymentGroupInfo
services {
@ -13,6 +18,16 @@ query Services($deploymentGroupSlug: String!) {
id
status
healthy
metrics(names: $metricNames, start: $start, end: $end) {
instance
name
start
end
metrics {
time
value
}
}
}
}
connections
@ -20,6 +35,16 @@ query Services($deploymentGroupSlug: String!) {
id
status
healthy
metrics(names: $metricNames, start: $start, end: $end) {
instance
name
start
end
metrics {
time
value
}
}
}
}
}

View File

@ -174,6 +174,18 @@ const processServicesForTopology = services => {
}));
};
// metricsData should prob be an array rather than an object
const processInstancesMetrics = instances =>
forceArray(instances).reduce((metrics, instance) => {
instance.metrics.forEach(instanceMetrics => {
metrics[instanceMetrics.name] = forceArray(
metrics[instanceMetrics.name]
).concat([instanceMetrics]);
});
return metrics;
}, {});
/* ,
instancesByServiceId */
export {
@ -184,5 +196,6 @@ export {
getInstancesHealthy,
getService,
processServices,
processServicesForTopology
processServicesForTopology,
processInstancesMetrics
};

View File

@ -163,7 +163,7 @@ const StyledAnchor = A.extend`
const StyledLink = styled(Link)`
display: inline-block;
${style}
${style};
`;
/**

View File

@ -22,6 +22,8 @@ const StyledTitle = Title.extend`
const InnerDescription = styled.div`
justify-content: flex-start;
height: 100%;
position: relative;
`;
const Description = ({ children, ...rest }) => {

View File

@ -23,16 +23,33 @@ const StyledIconContainer = styled.div`
}
`;
const CardInfo = ({ label, icon, iconPosition = 'left', color = 'light' }) => {
const CardInfoContainer = styled.div`
height: 100%;
float: right;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: center;
align-content: center;
`;
const CardInfo = ({
label,
icon,
iconPosition = 'left',
color = 'light',
onMouseOver,
onMouseOut
}) => {
return (
<div>
<CardInfoContainer onMouseOver={onMouseOver} onMouseOut={onMouseOver}>
<StyledIconContainer iconPosition={iconPosition} color={color}>
{icon}
</StyledIconContainer>
<StyledLabel iconPosition={iconPosition} color={color}>
{label}
</StyledLabel>
</div>
</CardInfoContainer>
);
};

View File

@ -13,9 +13,5 @@ export default props => {
return <StyledLabel {...props} htmlFor={id} />;
};
return (
<Subscriber channel="input-group">
{render}
</Subscriber>
);
return <Subscriber channel="input-group">{render}</Subscriber>;
};

View File

@ -19,12 +19,13 @@ const chartColors = [
class MetricGraph extends Component {
componentDidMount() {
const { xMin, xMax, datasets } = this.processProps(this.props);
const { displayX = false, displayY = false } = this.props;
const config = {
type: 'line',
data: { datasets },
options: {
responsive: false, // this needs to be played with
responsive: true, // this needs to be played with
legend: {
display: false
},
@ -34,7 +35,7 @@ class MetricGraph extends Component {
scales: {
xAxes: [
{
display: true, // config for mini should be false
display: displayX, // config for mini should be false
type: 'time',
distribution: 'linear',
time: {
@ -46,7 +47,7 @@ class MetricGraph extends Component {
],
yAxes: [
{
display: true // needs min / max and measurement
display: displayY // needs min / max and measurement
}
]
}

View File

@ -25,15 +25,16 @@ const StyledInnerContainer = styled.div`
left: -50%;
margin: 0;
padding: ${unitcalc(2)} 0;
background-color: ${props => props.secondary ? props.theme.secondary : props.theme.white};
border: ${props => props.secondary ? border.secondary : border.unchecked};
background-color: ${props =>
props.secondary ? props.theme.secondary : props.theme.white};
border: ${props => (props.secondary ? border.secondary : border.unchecked)};
box-shadow: ${tooltipShadow};
border-radius: ${borderRadius};
z-index: 1000;
&:after,
&:before {
content: "";
content: '';
position: absolute;
bottom: 100%;
left: 50%;
@ -43,13 +44,15 @@ const StyledInnerContainer = styled.div`
}
&:after {
border-bottom-color: ${props => props.secondary ? props.theme.secondary : theme.white};
border-bottom-color: ${props =>
props.secondary ? props.theme.secondary : theme.white};
border-width: ${remcalc(3)};
margin-left: ${remcalc(-3)};
}
&:before {
border-bottom-color: ${props => props.secondary ? props.theme.secondaryActive : theme.grey};
border-bottom-color: ${props =>
props.secondary ? props.theme.secondaryActive : theme.grey};
border-width: ${remcalc(5)};
margin-left: ${remcalc(-5)};
}