Commit f653dd5d authored by Kushal Pandya's avatar Kushal Pandya Committed by Clement Ho

Handle node details load failure gracefully on UI

parent 35b6b354
...@@ -73,9 +73,13 @@ ...@@ -73,9 +73,13 @@
} }
.geo-nodes { .geo-nodes {
.node-url-warning { .node-status-icon-warning {
fill: $gl-warning; fill: $gl-warning;
} }
.node-status-icon-failure {
fill: $gl-danger;
}
} }
.node-details-list { .node-details-list {
......
---
title: Handle node details load failure gracefully on UI
merge_request: 3992
author:
type: fixed
...@@ -80,8 +80,7 @@ ...@@ -80,8 +80,7 @@
eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId)); eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
}) })
.catch((err) => { .catch((err) => {
this.hasError = true; eventHub.$emit('nodeDetailsLoadFailed', nodeId, err);
this.errorMessage = err;
}); });
}, },
initNodeDetailsPolling(nodeId) { initNodeDetailsPolling(nodeId) {
......
<script> <script>
import icon from '~/vue_shared/components/icon.vue'; import { s__ } from '~/locale';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import geoNodeActions from './geo_node_actions.vue'; import geoNodeActions from './geo_node_actions.vue';
import geoNodeDetails from './geo_node_details.vue'; import geoNodeDetails from './geo_node_details.vue';
export default { export default {
components: { components: {
icon, icon,
loadingIcon, loadingIcon,
...@@ -39,37 +40,82 @@ ...@@ -39,37 +40,82 @@
data() { data() {
return { return {
isNodeDetailsLoading: true, isNodeDetailsLoading: true,
isNodeDetailsFailed: false,
nodeHealthStatus: '', nodeHealthStatus: '',
errorMessage: '',
nodeDetails: {}, nodeDetails: {},
}; };
}, },
computed: { computed: {
showInsecureUrlWarning() { isNodeNonHTTPS() {
return this.node.url.startsWith('http://'); return this.node.url.startsWith('http://');
}, },
showNodeStatusIcon() {
if (this.isNodeDetailsLoading) {
return false;
}
return this.isNodeNonHTTPS || this.isNodeDetailsFailed;
},
showNodeDetails() {
if (!this.isNodeDetailsLoading) {
return !this.isNodeDetailsFailed;
}
return false;
},
nodeStatusIconClass() {
const iconClasses = 'prepend-left-10 pull-left';
if (this.isNodeDetailsFailed) {
return `${iconClasses} node-status-icon-failure`;
}
return `${iconClasses} node-status-icon-warning`;
},
nodeStatusIconName() {
if (this.isNodeDetailsFailed) {
return 'status_failed_borderless';
}
return 'warning';
},
nodeStatusIconTooltip() {
if (this.isNodeDetailsFailed) {
return '';
}
return s__('GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS.');
},
}, },
created() { created() {
eventHub.$on('nodeDetailsLoaded', this.handleNodeDetails); eventHub.$on('nodeDetailsLoaded', this.handleNodeDetails);
eventHub.$on('nodeDetailsLoadFailed', this.handleNodeDetailsFailure);
}, },
mounted() { mounted() {
this.handleMounted(); this.handleMounted();
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('nodeDetailsLoaded', this.handleNodeDetails); eventHub.$off('nodeDetailsLoaded', this.handleNodeDetails);
eventHub.$off('nodeDetailsLoadFailed', this.handleNodeDetailsFailure);
}, },
methods: { methods: {
handleNodeDetails(nodeDetails) { handleNodeDetails(nodeDetails) {
if (this.node.id === nodeDetails.id) { if (this.node.id === nodeDetails.id) {
this.isNodeDetailsLoading = false; this.isNodeDetailsLoading = false;
this.isNodeDetailsFailed = false;
this.errorMessage = '';
this.nodeDetails = nodeDetails; this.nodeDetails = nodeDetails;
this.nodeHealthStatus = nodeDetails.health; this.nodeHealthStatus = nodeDetails.health;
} }
}, },
handleNodeDetailsFailure(nodeId, err) {
if (this.node.id === nodeId) {
this.isNodeDetailsLoading = false;
this.isNodeDetailsFailed = true;
this.errorMessage = err.message;
}
},
handleMounted() { handleMounted() {
eventHub.$emit('pollNodeDetails', this.node.id); eventHub.$emit('pollNodeDetails', this.node.id);
}, },
}, },
}; };
</script> </script>
<template> <template>
...@@ -88,14 +134,13 @@ ...@@ -88,14 +134,13 @@
/> />
<icon <icon
v-tooltip v-tooltip
v-if="!isNodeDetailsLoading && showInsecureUrlWarning" v-if="showNodeStatusIcon"
css-classes="prepend-left-10 pull-left node-url-warning"
name="warning"
data-container="body" data-container="body"
data-placement="bottom" data-placement="bottom"
:title="s__(`GeoNodes|You have configured Geo nodes using :name="nodeStatusIconName"
an insecure HTTP connection. We recommend the use of HTTPS.`)"
:size="18" :size="18"
:css-classes="nodeStatusIconClass"
:title="nodeStatusIconTooltip"
/> />
<span class="inline pull-left prepend-left-10"> <span class="inline pull-left prepend-left-10">
<span <span
...@@ -115,16 +160,24 @@ an insecure HTTP connection. We recommend the use of HTTPS.`)" ...@@ -115,16 +160,24 @@ an insecure HTTP connection. We recommend the use of HTTPS.`)"
</div> </div>
</div> </div>
<geo-node-actions <geo-node-actions
v-if="!isNodeDetailsLoading && nodeActionsAllowed" v-if="showNodeDetails && nodeActionsAllowed"
:node="node" :node="node"
:node-edit-allowed="nodeEditAllowed" :node-edit-allowed="nodeEditAllowed"
:node-missing-oauth="nodeDetails.missingOAuthApplication" :node-missing-oauth="nodeDetails.missingOAuthApplication"
/> />
</div> </div>
<geo-node-details <geo-node-details
v-if="!isNodeDetailsLoading" v-if="showNodeDetails"
:node="node" :node="node"
:node-details="nodeDetails" :node-details="nodeDetails"
/> />
<div
v-if="isNodeDetailsFailed"
class="prepend-top-10"
>
<p class="health-message">
{{ errorMessage }}
</p>
</div>
</li> </li>
</template> </template>
...@@ -44,34 +44,34 @@ export default class GeoNodesStore { ...@@ -44,34 +44,34 @@ export default class GeoNodesStore {
missingOAuthApplication: rawNodeDetails.missing_oauth_application, missingOAuthApplication: rawNodeDetails.missing_oauth_application,
storageShardsMatch: rawNodeDetails.storage_shards_match, storageShardsMatch: rawNodeDetails.storage_shards_match,
replicationSlots: { replicationSlots: {
totalCount: rawNodeDetails.replication_slots_count, totalCount: rawNodeDetails.replication_slots_count || 0,
successCount: rawNodeDetails.replication_slots_used_count, successCount: rawNodeDetails.replication_slots_used_count || 0,
failureCount: 0, failureCount: 0,
}, },
repositories: { repositories: {
totalCount: rawNodeDetails.repositories_count, totalCount: rawNodeDetails.repositories_count || 0,
successCount: rawNodeDetails.repositories_synced_count, successCount: rawNodeDetails.repositories_synced_count || 0,
failureCount: rawNodeDetails.repositories_failed_count, failureCount: rawNodeDetails.repositories_failed_count || 0,
}, },
wikis: { wikis: {
totalCount: rawNodeDetails.wikis_count, totalCount: rawNodeDetails.wikis_count || 0,
successCount: rawNodeDetails.wikis_synced_count, successCount: rawNodeDetails.wikis_synced_count || 0,
failureCount: rawNodeDetails.wikis_failed_count, failureCount: rawNodeDetails.wikis_failed_count || 0,
}, },
lfs: { lfs: {
totalCount: rawNodeDetails.lfs_objects_count, totalCount: rawNodeDetails.lfs_objects_count || 0,
successCount: rawNodeDetails.lfs_objects_synced_count, successCount: rawNodeDetails.lfs_objects_synced_count || 0,
failureCount: rawNodeDetails.lfs_objects_failed_count, failureCount: rawNodeDetails.lfs_objects_failed_count || 0,
}, },
jobArtifacts: { jobArtifacts: {
totalCount: rawNodeDetails.job_artifacts_count, totalCount: rawNodeDetails.job_artifacts_count || 0,
successCount: rawNodeDetails.job_artifacts_synced_count, successCount: rawNodeDetails.job_artifacts_synced_count || 0,
failureCount: rawNodeDetails.job_artifacts_failed_count, failureCount: rawNodeDetails.job_artifacts_failed_count || 0,
}, },
attachments: { attachments: {
totalCount: rawNodeDetails.attachments_count, totalCount: rawNodeDetails.attachments_count || 0,
successCount: rawNodeDetails.attachments_synced_count, successCount: rawNodeDetails.attachments_synced_count || 0,
failureCount: rawNodeDetails.attachments_failed_count, failureCount: rawNodeDetails.attachments_failed_count || 0,
}, },
lastEvent: { lastEvent: {
id: rawNodeDetails.last_event_id, id: rawNodeDetails.last_event_id,
......
...@@ -94,15 +94,15 @@ describe('AppComponent', () => { ...@@ -94,15 +94,15 @@ describe('AppComponent', () => {
}, 0); }, 0);
}); });
it('sets error flag and message on failure', (done) => { it('emits `nodeDetailsLoadFailed` event on failure', (done) => {
const err = 'Something went wrong'; const err = 'Something went wrong';
const mock = new MockAdapter(axios); const mock = new MockAdapter(axios);
spyOn(eventHub, '$emit');
mock.onGet(`${vm.service.geoNodeDetailsBasePath}/2/status.json`).reply(500, err); mock.onGet(`${vm.service.geoNodeDetailsBasePath}/2/status.json`).reply(500, err);
vm.fetchNodeDetails(2); vm.fetchNodeDetails(2);
setTimeout(() => { setTimeout(() => {
expect(vm.hasError).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('nodeDetailsLoadFailed', 2, jasmine.any(Object));
expect(vm.errorMessage.response.data).toBe(err);
done(); done();
}, 0); }, 0);
}); });
......
...@@ -31,24 +31,118 @@ describe('GeoNodeItemComponent', () => { ...@@ -31,24 +31,118 @@ describe('GeoNodeItemComponent', () => {
describe('data', () => { describe('data', () => {
it('returns default data props', () => { it('returns default data props', () => {
expect(vm.isNodeDetailsLoading).toBeTruthy(); expect(vm.isNodeDetailsLoading).toBeTruthy();
expect(vm.isNodeDetailsFailed).toBeFalsy();
expect(vm.nodeHealthStatus).toBe(''); expect(vm.nodeHealthStatus).toBe('');
expect(vm.errorMessage).toBe('');
expect(typeof vm.nodeDetails).toBe('object'); expect(typeof vm.nodeDetails).toBe('object');
}); });
}); });
describe('computed', () => { describe('computed', () => {
describe('showInsecureUrlWarning', () => { let vmHttps;
it('returns boolean value representing URL protocol security', () => { let mockNode;
// With altered mock data for secure URL
const mockNode = Object.assign({}, mockNodes[0], { beforeEach(() => {
// Altered mock data for secure URL
mockNode = Object.assign({}, mockNodes[0], {
url: 'https://127.0.0.1:3001/', url: 'https://127.0.0.1:3001/',
}); });
const vmX = createComponent(mockNode); vmHttps = createComponent(mockNode);
expect(vmX.showInsecureUrlWarning).toBeFalsy(); });
vmX.$destroy();
afterEach(() => {
vmHttps.$destroy();
});
describe('isNodeNonHTTPS', () => {
it('returns `true` if Node URL protocol is non-HTTPS', () => {
// With default mock data // With default mock data
expect(vm.showInsecureUrlWarning).toBeTruthy(); expect(vm.isNodeNonHTTPS).toBeTruthy();
});
it('returns `false` is Node URL protocol is HTTPS', () => {
// With altered mock data
expect(vmHttps.isNodeNonHTTPS).toBeFalsy();
});
});
describe('showNodeStatusIcon', () => {
it('returns `false` if Node details are still loading', () => {
vm.isNodeDetailsLoading = true;
expect(vm.showNodeStatusIcon).toBeFalsy();
});
it('returns `true` if Node details failed to load', () => {
vm.isNodeDetailsLoading = false;
vm.isNodeDetailsFailed = true;
expect(vm.showNodeStatusIcon).toBeTruthy();
});
it('returns `true` if Node details loaded and Node URL is non-HTTPS', () => {
vm.isNodeDetailsLoading = false;
vm.isNodeDetailsFailed = false;
expect(vm.showNodeStatusIcon).toBeTruthy();
});
it('returns `false` if Node details loaded and Node URL is HTTPS', () => {
vmHttps.isNodeDetailsLoading = false;
vmHttps.isNodeDetailsFailed = false;
expect(vmHttps.showNodeStatusIcon).toBeFalsy();
});
});
describe('showNodeDetails', () => {
it('returns `false` if Node details are still loading', () => {
vm.isNodeDetailsLoading = true;
expect(vm.showNodeDetails).toBeFalsy();
});
it('returns `false` if Node details failed to load', () => {
vm.isNodeDetailsLoading = false;
vm.isNodeDetailsFailed = true;
expect(vm.showNodeDetails).toBeFalsy();
});
it('returns `true` if Node details loaded', () => {
vm.isNodeDetailsLoading = false;
vm.isNodeDetailsFailed = false;
expect(vm.showNodeDetails).toBeTruthy();
});
});
describe('nodeStatusIconClass', () => {
it('returns `node-status-icon-failure` along with default classes if Node details failed to load', () => {
vm.isNodeDetailsFailed = true;
expect(vm.nodeStatusIconClass).toBe('prepend-left-10 pull-left node-status-icon-failure');
});
it('returns `node-status-icon-warning` along with default classes if Node details loaded and Node URL is non-HTTPS', () => {
vm.isNodeDetailsFailed = false;
expect(vm.nodeStatusIconClass).toBe('prepend-left-10 pull-left node-status-icon-warning');
});
});
describe('nodeStatusIconName', () => {
it('returns `warning` if Node details loaded and Node URL is non-HTTPS', () => {
vm.isNodeDetailsFailed = false;
expect(vm.nodeStatusIconName).toBe('warning');
});
it('returns `status_failed_borderless` if Node details failed to load', () => {
vm.isNodeDetailsFailed = true;
expect(vm.nodeStatusIconName).toBe('status_failed_borderless');
});
});
describe('nodeStatusIconTooltip', () => {
it('returns empty string if Node details failed to load', () => {
vm.isNodeDetailsFailed = true;
expect(vm.nodeStatusIconTooltip).toBe('');
});
it('returns tooltip string if Node details loaded and Node URL is non-HTTPS', () => {
vm.isNodeDetailsFailed = false;
expect(vm.nodeStatusIconTooltip).toBe('You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS.');
}); });
}); });
}); });
...@@ -58,13 +152,15 @@ describe('GeoNodeItemComponent', () => { ...@@ -58,13 +152,15 @@ describe('GeoNodeItemComponent', () => {
it('intializes props based on provided `nodeDetails`', () => { it('intializes props based on provided `nodeDetails`', () => {
// With altered mock data with matching ID // With altered mock data with matching ID
const mockNode = Object.assign({}, mockNodes[1]); const mockNode = Object.assign({}, mockNodes[1]);
const vmX = createComponent(mockNode); const vmNodePrimary = createComponent(mockNode);
vmX.handleNodeDetails(mockNodeDetails); vmNodePrimary.handleNodeDetails(mockNodeDetails);
expect(vmX.isNodeDetailsLoading).toBeFalsy(); expect(vmNodePrimary.isNodeDetailsLoading).toBeFalsy();
expect(vmX.nodeDetails).toBe(mockNodeDetails); expect(vmNodePrimary.isNodeDetailsFailed).toBeFalsy();
expect(vmX.nodeHealthStatus).toBe(mockNodeDetails.health); expect(vmNodePrimary.errorMessage).toBe('');
vmX.$destroy(); expect(vmNodePrimary.nodeDetails).toBe(mockNodeDetails);
expect(vmNodePrimary.nodeHealthStatus).toBe(mockNodeDetails.health);
vmNodePrimary.$destroy();
// With default mock data without matching ID // With default mock data without matching ID
vm.handleNodeDetails(mockNodeDetails); vm.handleNodeDetails(mockNodeDetails);
...@@ -74,6 +170,16 @@ describe('GeoNodeItemComponent', () => { ...@@ -74,6 +170,16 @@ describe('GeoNodeItemComponent', () => {
}); });
}); });
describe('handleNodeDetailsFailure', () => {
it('initializes props for Node details failure', () => {
const err = 'Something went wrong';
vm.handleNodeDetailsFailure(1, { message: err });
expect(vm.isNodeDetailsLoading).toBeFalsy();
expect(vm.isNodeDetailsFailed).toBeTruthy();
expect(vm.errorMessage).toBe(err);
});
});
describe('handleMounted', () => { describe('handleMounted', () => {
it('emits `pollNodeDetails` event and passes node ID', () => { it('emits `pollNodeDetails` event and passes node ID', () => {
spyOn(eventHub, '$emit'); spyOn(eventHub, '$emit');
...@@ -90,6 +196,7 @@ describe('GeoNodeItemComponent', () => { ...@@ -90,6 +196,7 @@ describe('GeoNodeItemComponent', () => {
const vmX = createComponent(); const vmX = createComponent();
expect(eventHub.$on).toHaveBeenCalledWith('nodeDetailsLoaded', jasmine.any(Function)); expect(eventHub.$on).toHaveBeenCalledWith('nodeDetailsLoaded', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('nodeDetailsLoadFailed', jasmine.any(Function));
vmX.$destroy(); vmX.$destroy();
}); });
}); });
...@@ -101,6 +208,7 @@ describe('GeoNodeItemComponent', () => { ...@@ -101,6 +208,7 @@ describe('GeoNodeItemComponent', () => {
const vmX = createComponent(); const vmX = createComponent();
vmX.$destroy(); vmX.$destroy();
expect(eventHub.$off).toHaveBeenCalledWith('nodeDetailsLoaded', jasmine.any(Function)); expect(eventHub.$off).toHaveBeenCalledWith('nodeDetailsLoaded', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('nodeDetailsLoadFailed', jasmine.any(Function));
}); });
}); });
...@@ -121,5 +229,16 @@ describe('GeoNodeItemComponent', () => { ...@@ -121,5 +229,16 @@ describe('GeoNodeItemComponent', () => {
it('renders node badge `Primary`', () => { it('renders node badge `Primary`', () => {
expect(vm.$el.querySelectorAll('.node-badge.primary-node').length).not.toBe(0); expect(vm.$el.querySelectorAll('.node-badge.primary-node').length).not.toBe(0);
}); });
it('renders node error message', (done) => {
const err = 'Something error message';
vm.isNodeDetailsFailed = true;
vm.errorMessage = err;
Vue.nextTick(() => {
expect(vm.$el.querySelectorAll('p.health-message').length).not.toBe(0);
expect(vm.$el.querySelector('p.health-message').innerText.trim()).toBe(err);
done();
});
});
}); });
}); });
...@@ -133,7 +133,7 @@ export const mockNodeDetails = { ...@@ -133,7 +133,7 @@ export const mockNodeDetails = {
successCount: 0, successCount: 0,
failureCount: 0, failureCount: 0,
}, },
job_artifacts: { jobArtifacts: {
totalCount: 0, totalCount: 0,
successCount: 0, successCount: 0,
failureCount: 0, failureCount: 0,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment