From 3ec7cc82e65fa94dfc836b0e8e55f7b1c9bd0fc0 Mon Sep 17 00:00:00 2001 From: jpellizzari Date: Wed, 26 Apr 2017 14:49:48 -0700 Subject: [PATCH] Added service images widget --- client/app/scripts/actions/app-actions.js | 24 ++++ .../app/scripts/components/cloud-feature.js | 34 +++++ client/app/scripts/components/node-details.js | 5 + .../node-details/node-details-image-status.js | 122 ++++++++++++++++++ client/app/scripts/constants/action-types.js | 4 +- .../scripts/reducers/__tests__/root-test.js | 33 +++++ client/app/scripts/reducers/root.js | 17 +++ client/app/styles/_base.scss | 47 +++++++ client/app/styles/_variables.scss | 1 + client/package.json | 3 +- 10 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 client/app/scripts/components/cloud-feature.js create mode 100644 client/app/scripts/components/node-details/node-details-image-status.js diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 3e18f40ce..d3c5c767f 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -1,4 +1,5 @@ import debug from 'debug'; +import find from 'lodash/find'; import ActionTypes from '../constants/action-types'; import { saveGraph } from '../utils/file-utils'; @@ -792,3 +793,26 @@ export function shutdown() { type: ActionTypes.SHUTDOWN }; } + +export function getImagesForService(orgId, serviceId) { + return (dispatch, getState, { api }) => { + dispatch({ + type: ActionTypes.REQUEST_SERVICE_IMAGES, + serviceId + }); + + api.getFluxImages(orgId, serviceId) + .then((services) => { + dispatch({ + type: ActionTypes.RECEIVE_SERVICE_IMAGES, + service: find(services, s => s.ID === serviceId), + serviceId + }); + }, ({ errors }) => { + dispatch({ + type: ActionTypes.RECEIVE_SERVICE_IMAGES, + errors + }); + }); + }; +} diff --git a/client/app/scripts/components/cloud-feature.js b/client/app/scripts/components/cloud-feature.js new file mode 100644 index 000000000..013414421 --- /dev/null +++ b/client/app/scripts/components/cloud-feature.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +class CloudFeature extends React.Component { + getChildContext() { + return { + store: this.context.serviceStore || this.context.store + }; + } + + render() { + if (process.env.WEAVE_CLOUD) { + return React.cloneElement(React.Children.only(this.props.children), { + params: this.context.router.params, + router: this.context.router + }); + } + + return null; + } +} + +CloudFeature.contextTypes = { + store: React.PropTypes.object.isRequired, + router: React.PropTypes.object, + serviceStore: React.PropTypes.object +}; + +CloudFeature.childContextTypes = { + store: React.PropTypes.object, + router: React.PropTypes.object +}; + +export default connect()(CloudFeature); diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index 6b49acd5c..33d3ee180 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -18,6 +18,8 @@ import NodeDetailsInfo from './node-details/node-details-info'; import NodeDetailsRelatives from './node-details/node-details-relatives'; import NodeDetailsTable from './node-details/node-details-table'; import Warning from './warning'; +import CloudFeature from './cloud-feature'; +import NodeDetailsImageStatus from './node-details/node-details-image-status'; const log = debug('scope:node-details'); @@ -230,6 +232,9 @@ class NodeDetails extends React.Component { } return null; })} + + + ); diff --git a/client/app/scripts/components/node-details/node-details-image-status.js b/client/app/scripts/components/node-details/node-details-image-status.js new file mode 100644 index 000000000..ee5a8cb93 --- /dev/null +++ b/client/app/scripts/components/node-details/node-details-image-status.js @@ -0,0 +1,122 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import find from 'lodash/find'; +import map from 'lodash/map'; +import { CircularProgress } from 'weaveworks-ui-components'; + +import { getImagesForService } from '../../actions/app-actions'; + +const topologyWhitelist = ['services', 'deployments']; + +function getNewImages(images, currentId) { + // Assume that the current image is always in the list of all available images. + // Should be a safe assumption... + const current = find(images, i => i.ID === currentId); + const timestamp = new Date(current.CreatedAt); + return find(images, i => timestamp < new Date(i.CreatedAt)) || []; +} + +class NodeDetailsImageStatus extends React.PureComponent { + constructor(props, context) { + super(props, context); + + this.handleServiceClick = this.handleServiceClick.bind(this); + } + + componentDidMount() { + if (this.shouldRender() && this.props.serviceId) { + this.props.getImagesForService(this.props.params.orgId, this.props.serviceId); + } + } + + handleServiceClick() { + const { router, serviceId, params } = this.props; + router.push(`/flux/${params.orgId}/services/${encodeURIComponent(serviceId)}`); + } + + shouldRender() { + const { currentTopologyId } = this.props; + return currentTopologyId && topologyWhitelist.includes(currentTopologyId); + } + + renderImages() { + const { errors, containers, isFetching } = this.props; + const error = !isFetching && errors; + + if (isFetching) { + return ( +
+ ); + } + + if (error) { + return ( +

Error: {JSON.stringify(map(errors, 'message'))}

+ ); + } + + if (!containers) { + return 'No service images found'; + } + + return ( +
+ {containers.map((container) => { + const statusText = getNewImages(container.Available, container.Current.ID).length > 0 + ? New image(s) available + : 'Image up to date'; + + return ( +
+
{container.Name}
+
{statusText}
+
+ ); + })} +
+ ); + } + + render() { + const { containers } = this.props; + + if (!this.shouldRender()) { + return null; + } + + return ( +
+
+ Container Image Status + {containers && +
+ + View in Deploy + +
+ } + +
+ {this.renderImages()} +
+ ); + } +} + +function mapStateToProps({ scope }, { metadata, name }) { + const namespace = find(metadata, d => d.id === 'kubernetes_namespace'); + const serviceId = namespace ? `${namespace.value}/${name}` : null; + const { containers, isFetching, errors } = scope.getIn(['serviceImages', serviceId]) || {}; + + return { + isFetching, + errors, + currentTopologyId: scope.get('currentTopologyId'), + containers, + serviceId + }; +} + +export default connect(mapStateToProps, { getImagesForService })(NodeDetailsImageStatus); diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index ad3c8eaa2..c84a8a645 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -63,7 +63,9 @@ const ACTION_TYPES = [ 'SET_VIEW_MODE', 'CHANGE_INSTANCE', 'TOGGLE_CONTRAST_MODE', - 'SHUTDOWN' + 'SHUTDOWN', + 'REQUEST_SERVICE_IMAGES', + 'RECEIVE_SERVICE_IMAGES' ]; export default zipObject(ACTION_TYPES, ACTION_TYPES); diff --git a/client/app/scripts/reducers/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js index 45f46daee..cfd2107ab 100644 --- a/client/app/scripts/reducers/__tests__/root-test.js +++ b/client/app/scripts/reducers/__tests__/root-test.js @@ -678,4 +678,37 @@ describe('RootReducer', () => { constructEdgeId('def456', 'abc123') ]); }); + it('receives images for a service', () => { + const action = { + type: ActionTypes.RECEIVE_SERVICE_IMAGES, + serviceId: 'cortex/configs', + service: { + ID: 'cortex/configs', + Containers: [{ + Available: [{ + ID: 'quay.io/weaveworks/cortex-configs:master-1ca6274a', + CreatedAt: '2017-04-26T13:50:13.284736173Z' + }], + Current: { ID: 'quay.io/weaveworks/cortex-configs:master-1ca6274a' }, + Name: 'configs' + }] + } + }; + + const nextState = reducer(initialState, action); + expect(nextState.getIn(['serviceImages', 'cortex/configs'])).toEqual({ + isFetching: false, + errors: undefined, + containers: [{ + Name: 'configs', + Current: { + ID: 'quay.io/weaveworks/cortex-configs:master-1ca6274a' + }, + Available: [{ + ID: 'quay.io/weaveworks/cortex-configs:master-1ca6274a', + CreatedAt: '2017-04-26T13:50:13.284736173Z' + }] + }] + }); + }); }); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index b3c2d5f12..3f731a828 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -84,6 +84,7 @@ export const initialState = makeMap({ viewport: makeMap(), websocketClosed: false, zoomCache: makeMap(), + serviceImages: makeMap() }); function calcSelectType(topology) { @@ -741,6 +742,22 @@ export function rootReducer(state = initialState, action) { return state.set('nodesLoaded', false); } + case ActionTypes.REQUEST_SERVICE_IMAGES: { + return state.setIn(['serviceImages', action.serviceId], { + isFetching: true + }); + } + + case ActionTypes.RECEIVE_SERVICE_IMAGES: { + const { service, errors, serviceId } = action; + + return state.setIn(['serviceImages', serviceId], { + isFetching: false, + containers: service ? service.Containers : null, + errors + }); + } + default: { return state; } diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 8c5e5c6ed..a9c86c9b4 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -942,8 +942,55 @@ } } + + .image-status { + + .progress-wrapper { + position: relative; + min-height: 35px; + } + + .node-details-content-section-header { + display: flex; + justify-content: space-between; + line-height: 26px; + } + + .images .wrapper{ + display: flex; + justify-content: space-between; + } + + .weave-circular-progress-wrapper { + position: absolute; + left: 50%; + } + + .new-image { + color: $success-green; + } + + a { + &:hover { + background-color: #f1f1f6; + cursor: pointer; + } + } + + .node-details-table-node-link, + .node-details-table-node-label, + .node-details-table-node-value { + flex: 1; + font-size: 14px; + color: $text-color; + line-height: 24px; + text-transform: none; + } + } } + + .node-resources { &-metric-box { fill: rgba(150, 150, 150, 0.4); diff --git a/client/app/styles/_variables.scss b/client/app/styles/_variables.scss index 7abd7c2ee..801f8738f 100644 --- a/client/app/styles/_variables.scss +++ b/client/app/styles/_variables.scss @@ -4,6 +4,7 @@ $weave-gray-blue: rgb(85,105,145); $weave-blue: rgb(0,210,255); $weave-orange: rgb(255,75,25); $weave-charcoal-blue: rgb(50,50,75); // #32324B +$success-green: green; $base-font: "Roboto", sans-serif; $mono-font: "Menlo", "DejaVu Sans Mono", "Liberation Mono", monospace; diff --git a/client/package.json b/client/package.json index 94e13d1a1..78ead69e4 100644 --- a/client/package.json +++ b/client/package.json @@ -42,7 +42,8 @@ "reselect-map": "1.0.0", "whatwg-fetch": "2.0.1", "react-addons-perf": "15.4.2", - "xterm": "2.2.3" + "xterm": "2.2.3", + "weaveworks-ui-components": "git+https://github.com/weaveworks/ui-components.git#v0.1.19" }, "devDependencies": { "autoprefixer": "6.5.3",