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 &&
+
+ }
+
+
+ {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",