Commit 2ed6b0cd authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch '4658-geo-admin-fixes' into 'master'

Fixes and enhancements for Geo admin dashboard

Closes #4658, #4864, and #4493

See merge request gitlab-org/gitlab-ee!4536
parents ae5ecfad ad0cdcb2
...@@ -8,4 +8,5 @@ export default { ...@@ -8,4 +8,5 @@ export default {
OK: 200, OK: 200,
MULTIPLE_CHOICES: 300, MULTIPLE_CHOICES: 300,
BAD_REQUEST: 400, BAD_REQUEST: 400,
NOT_FOUND: 404,
}; };
...@@ -96,12 +96,24 @@ Example response: ...@@ -96,12 +96,24 @@ Example response:
} }
``` ```
## Delete a Geo node
Removes the Geo node.
```
DELETE /geo_nodes/:id
```
| Attribute | Type | Required | Description |
|-----------|---------|----------|-------------------------|
| `id` | integer | yes | The ID of the Geo node. |
## Repair a Geo node ## Repair a Geo node
To repair the OAuth authentication of a Geo node. To repair the OAuth authentication of a Geo node.
``` ```
PUT /geo_nodes/:id/repair POST /geo_nodes/:id/repair
``` ```
Example response: Example response:
...@@ -177,6 +189,10 @@ Example response: ...@@ -177,6 +189,10 @@ Example response:
GET /geo_nodes/:id/status GET /geo_nodes/:id/status
``` ```
| Attribute | Type | Required | Description |
| --------- | ------- | -------- | ----------- |
| `refresh` | boolean | no | Attempt to fetch the latest status from the Geo node directly, ignoring the cache |
```bash ```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/geo_nodes/2/status curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/geo_nodes/2/status
``` ```
......
<script> <script>
import { s__ } from '~/locale';
import Flash from '~/flash';
import statusCodes from '~/lib/utils/http_status';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import modal from '~/vue_shared/components/modal.vue';
import SmartInterval from '~/smart_interval'; import SmartInterval from '~/smart_interval';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { NODE_ACTIONS } from '../constants';
import geoNodesList from './geo_nodes_list.vue'; import geoNodesList from './geo_nodes_list.vue';
export default { export default {
components: { components: {
loadingIcon, loadingIcon,
modal,
geoNodesList, geoNodesList,
}, },
props: { props: {
...@@ -33,6 +40,12 @@ ...@@ -33,6 +40,12 @@
return { return {
isLoading: true, isLoading: true,
hasError: false, hasError: false,
showModal: false,
targetNode: null,
targetNodeActionType: '',
modalKind: 'warning',
modalMessage: '',
modalActionLabel: '',
errorMessage: '', errorMessage: '',
}; };
}, },
...@@ -43,17 +56,34 @@ ...@@ -43,17 +56,34 @@
}, },
created() { created() {
eventHub.$on('pollNodeDetails', this.initNodeDetailsPolling); eventHub.$on('pollNodeDetails', this.initNodeDetailsPolling);
eventHub.$on('showNodeActionModal', this.showNodeActionModal);
eventHub.$on('repairNode', this.repairNode);
}, },
mounted() { mounted() {
this.fetchGeoNodes(); this.fetchGeoNodes();
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('pollNodeDetails', this.initNodeDetailsPolling); eventHub.$off('pollNodeDetails', this.initNodeDetailsPolling);
eventHub.$off('showNodeActionModal', this.showNodeActionModal);
eventHub.$off('repairNode', this.repairNode);
if (this.nodePollingInterval) { if (this.nodePollingInterval) {
this.nodePollingInterval.stopTimer(); this.nodePollingInterval.stopTimer();
} }
}, },
methods: { methods: {
setNodeActionStatus(node, status) {
Object.assign(node, { nodeActionActive: status });
},
initNodeDetailsPolling(node) {
this.nodePollingInterval = new SmartInterval({
callback: this.fetchNodeDetails.bind(this, node),
startingInterval: 30000,
maxInterval: 120000,
hiddenInterval: 240000,
incrementByFactorOf: 15000,
immediateExecution: true,
});
},
fetchGeoNodes() { fetchGeoNodes() {
this.hasError = false; this.hasError = false;
this.service.getGeoNodes() this.service.getGeoNodes()
...@@ -67,8 +97,9 @@ ...@@ -67,8 +97,9 @@
this.errorMessage = err; this.errorMessage = err;
}); });
}, },
fetchNodeDetails(nodeId) { fetchNodeDetails(node) {
return this.service.getGeoNodeDetails(nodeId) const nodeId = node.id;
return this.service.getGeoNodeDetails(node)
.then(res => res.data) .then(res => res.data)
.then((nodeDetails) => { .then((nodeDetails) => {
const primaryNodeVersion = this.store.getPrimaryNodeVersion(); const primaryNodeVersion = this.store.getPrimaryNodeVersion();
...@@ -80,18 +111,81 @@ ...@@ -80,18 +111,81 @@
eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId)); eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
}) })
.catch((err) => { .catch((err) => {
eventHub.$emit('nodeDetailsLoadFailed', nodeId, err); if (err.response && err.response.status === statusCodes.NOT_FOUND) {
this.store.setNodeDetails(nodeId, {
geo_node_id: nodeId,
health: err.message,
health_status: 'Unknown',
missing_oauth_application: false,
sync_status_unavailable: true,
storage_shards_match: null,
});
eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
} else {
eventHub.$emit('nodeDetailsLoadFailed', nodeId, err);
}
}); });
}, },
initNodeDetailsPolling(nodeId) { repairNode(targetNode) {
this.nodePollingInterval = new SmartInterval({ this.setNodeActionStatus(targetNode, true);
callback: this.fetchNodeDetails.bind(this, nodeId), this.service.repairNode(targetNode)
startingInterval: 30000, .then(() => {
maxInterval: 120000, this.setNodeActionStatus(targetNode, false);
hiddenInterval: 240000, Flash(s__('GeoNodes|Node Authentication was successfully repaired.'), 'notice');
incrementByFactorOf: 15000, })
immediateExecution: true, .catch(() => {
}); this.setNodeActionStatus(targetNode, false);
Flash(s__('GeoNodes|Something went wrong while repairing node'));
});
},
toggleNode(targetNode) {
this.setNodeActionStatus(targetNode, true);
this.service.toggleNode(targetNode)
.then(res => res.data)
.then((node) => {
Object.assign(targetNode, { enabled: node.enabled, nodeActionActive: false });
})
.catch(() => {
this.setNodeActionStatus(targetNode, false);
Flash(s__('GeoNodes|Something went wrong while changing node status'));
});
},
removeNode(targetNode) {
this.setNodeActionStatus(targetNode, true);
this.service.removeNode(targetNode)
.then(() => {
this.store.removeNode(targetNode);
Flash(s__('GeoNodes|Node was successfully removed.'), 'notice');
})
.catch(() => {
this.setNodeActionStatus(targetNode, false);
Flash(s__('GeoNodes|Something went wrong while removing node'));
});
},
handleNodeAction() {
this.showModal = false;
if (this.targetNodeActionType === NODE_ACTIONS.TOGGLE) {
this.toggleNode(this.targetNode);
} else if (this.targetNodeActionType === NODE_ACTIONS.REMOVE) {
this.removeNode(this.targetNode);
}
},
showNodeActionModal({ actionType, node, modalKind = 'warning', modalMessage, modalActionLabel }) {
this.targetNode = node;
this.targetNodeActionType = actionType;
this.modalKind = modalKind;
this.modalMessage = modalMessage;
this.modalActionLabel = modalActionLabel;
if (actionType === NODE_ACTIONS.TOGGLE && !node.enabled) {
this.toggleNode(this.targetNode);
} else {
this.showModal = true;
}
},
hideNodeActionModal() {
this.showModal = false;
}, },
}, },
}; };
...@@ -120,5 +214,14 @@ ...@@ -120,5 +214,14 @@
> >
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<modal
v-show="showModal"
:title="__('Are you sure?')"
:kind="modalKind"
:text="modalMessage"
:primary-button-label="modalActionLabel"
@cancel="hideNodeActionModal"
@submit="handleNodeAction"
/>
</div> </div>
</template> </template>
...@@ -2,7 +2,9 @@ ...@@ -2,7 +2,9 @@
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import { NODE_ACTION_BASE_PATH, NODE_ACTIONS } from '../constants'; import eventHub from '../event_hub';
import { NODE_ACTIONS } from '../constants';
export default { export default {
components: { components: {
...@@ -22,11 +24,6 @@ ...@@ -22,11 +24,6 @@
required: true, required: true,
}, },
}, },
data() {
return {
isNodeToggleInProgress: false,
};
},
computed: { computed: {
isToggleAllowed() { isToggleAllowed() {
return !this.node.primary && this.nodeEditAllowed; return !this.node.primary && this.nodeEditAllowed;
...@@ -34,20 +31,27 @@ ...@@ -34,20 +31,27 @@
nodeToggleLabel() { nodeToggleLabel() {
return this.node.enabled ? __('Disable') : __('Enable'); return this.node.enabled ? __('Disable') : __('Enable');
}, },
nodeDisableMessage() { },
return this.node.enabled ? s__('GeoNodes|Disabling a node stops the sync process. Are you sure?') : ''; methods: {
}, onToggleNode() {
nodePath() { eventHub.$emit('showNodeActionModal', {
return `${NODE_ACTION_BASE_PATH}${this.node.id}`; actionType: NODE_ACTIONS.TOGGLE,
}, node: this.node,
nodeRepairAuthPath() { modalMessage: s__('GeoNodes|Disabling a node stops the sync process. Are you sure?'),
return `${this.nodePath}${NODE_ACTIONS.REPAIR}`; modalActionLabel: this.nodeToggleLabel,
});
}, },
nodeTogglePath() { onRemoveNode() {
return `${this.nodePath}${NODE_ACTIONS.TOGGLE}`; eventHub.$emit('showNodeActionModal', {
actionType: NODE_ACTIONS.REMOVE,
node: this.node,
modalKind: 'danger',
modalMessage: s__('GeoNodes|Removing a node stops the sync process. Are you sure?'),
modalActionLabel: __('Remove'),
});
}, },
nodeEditPath() { onRepairNode() {
return `${this.nodePath}${NODE_ACTIONS.EDIT}`; eventHub.$emit('repairNode', this.node);
}, },
}, },
}; };
...@@ -59,30 +63,29 @@ ...@@ -59,30 +63,29 @@
v-if="nodeMissingOauth" v-if="nodeMissingOauth"
class="node-action-container" class="node-action-container"
> >
<a <button
type="button"
class="btn btn-default btn-sm btn-node-action" class="btn btn-default btn-sm btn-node-action"
data-method="post" @click="onRepairNode"
:href="nodeRepairAuthPath"
> >
{{ s__('Repair authentication') }} {{ s__('Repair authentication') }}
</a> </button>
</div> </div>
<div <div
v-if="isToggleAllowed" v-if="isToggleAllowed"
class="node-action-container" class="node-action-container"
> >
<a <button
type="button"
class="btn btn-sm btn-node-action" class="btn btn-sm btn-node-action"
data-method="post"
:href="nodeTogglePath"
:data-confirm="nodeDisableMessage"
:class="{ :class="{
'btn-warning': node.enabled, 'btn-warning': node.enabled,
'btn-success': !node.enabled 'btn-success': !node.enabled
}" }"
@click="onToggleNode"
> >
{{ nodeToggleLabel }} {{ nodeToggleLabel }}
</a> </button>
</div> </div>
<div <div
v-if="nodeEditAllowed" v-if="nodeEditAllowed"
...@@ -90,19 +93,19 @@ ...@@ -90,19 +93,19 @@
> >
<a <a
class="btn btn-sm btn-node-action" class="btn btn-sm btn-node-action"
:href="nodeEditPath" :href="node.editPath"
> >
{{ __('Edit') }} {{ __('Edit') }}
</a> </a>
</div> </div>
<div class="node-action-container"> <div class="node-action-container">
<a <button
type="button"
class="btn btn-sm btn-node-action btn-danger" class="btn btn-sm btn-node-action btn-danger"
data-method="delete" @click="onRemoveNode"
:href="nodePath"
> >
{{ __('Remove') }} {{ __('Remove') }}
</a> </button>
</div> </div>
</div> </div>
</template> </template>
...@@ -106,6 +106,7 @@ ...@@ -106,6 +106,7 @@
/> />
<geo-node-sync-settings <geo-node-sync-settings
v-else-if="isCustomTypeSync" v-else-if="isCustomTypeSync"
:sync-status-unavailable="itemValue.syncStatusUnavailable"
:selective-sync-type="itemValue.selectiveSyncType" :selective-sync-type="itemValue.selectiveSyncType"
:last-event="itemValue.lastEvent" :last-event="itemValue.lastEvent"
:cursor-last-event="itemValue.cursorLastEvent" :cursor-last-event="itemValue.cursorLastEvent"
......
...@@ -97,6 +97,10 @@ ...@@ -97,6 +97,10 @@
return this.showAdvanceItems ? 'angle-up' : 'angle-down'; return this.showAdvanceItems ? 'angle-up' : 'angle-down';
}, },
nodeVersion() { nodeVersion() {
if (this.nodeDetails.version == null &&
this.nodeDetails.revision == null) {
return __('Unknown');
}
return `${this.nodeDetails.version} (${this.nodeDetails.revision})`; return `${this.nodeDetails.version} (${this.nodeDetails.revision})`;
}, },
replicationSlotWAL() { replicationSlotWAL() {
...@@ -113,7 +117,8 @@ ...@@ -113,7 +117,8 @@
return stringifyTime(parsedTime); return stringifyTime(parsedTime);
} }
return 'Unknown';
return __('Unknown');
}, },
lastEventStatus() { lastEventStatus() {
return { return {
...@@ -150,6 +155,7 @@ ...@@ -150,6 +155,7 @@
}, },
syncSettings() { syncSettings() {
return { return {
syncStatusUnavailable: this.nodeDetails.syncStatusUnavailable,
selectiveSyncType: this.nodeDetails.selectiveSyncType, selectiveSyncType: this.nodeDetails.selectiveSyncType,
lastEvent: this.nodeDetails.lastEvent, lastEvent: this.nodeDetails.lastEvent,
cursorLastEvent: this.nodeDetails.cursorLastEvent, cursorLastEvent: this.nodeDetails.cursorLastEvent,
......
...@@ -112,14 +112,16 @@ export default { ...@@ -112,14 +112,16 @@ export default {
} }
}, },
handleMounted() { handleMounted() {
eventHub.$emit('pollNodeDetails', this.node.id); eventHub.$emit('pollNodeDetails', this.node);
}, },
}, },
}; };
</script> </script>
<template> <template>
<li> <li
:class="{ 'node-action-active': node.nodeActionActive }"
>
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-8">
<div class="row"> <div class="row">
...@@ -128,7 +130,7 @@ export default { ...@@ -128,7 +130,7 @@ export default {
{{ node.url }} {{ node.url }}
</strong> </strong>
<loading-icon <loading-icon
v-if="isNodeDetailsLoading" v-if="isNodeDetailsLoading || node.nodeActionActive"
class="node-details-loading prepend-left-10 pull-left inline" class="node-details-loading prepend-left-10 pull-left inline"
size="1" size="1"
/> />
......
...@@ -14,6 +14,11 @@ ...@@ -14,6 +14,11 @@
icon, icon,
}, },
props: { props: {
syncStatusUnavailable: {
type: Boolean,
required: false,
default: false,
},
selectiveSyncType: { selectiveSyncType: {
type: String, type: String,
required: false, required: false,
...@@ -105,6 +110,13 @@ ...@@ -105,6 +110,13 @@
class="node-detail-value" class="node-detail-value"
> >
<span <span
v-if="syncStatusUnavailable"
class="node-detail-value-bold"
>
{{ __('Unknown') }}
</span>
<span
v-else
v-tooltip v-tooltip
class="node-sync-settings inline" class="node-sync-settings inline"
data-placement="bottom" data-placement="bottom"
......
export const NODE_ACTION_BASE_PATH = '/admin/geo_nodes/';
export const NODE_ACTIONS = { export const NODE_ACTIONS = {
TOGGLE: '/toggle', TOGGLE: 'toggle',
EDIT: '/edit', REMOVE: 'remove',
REPAIR: '/repair',
}; };
export const VALUE_TYPE = { export const VALUE_TYPE = {
......
...@@ -14,11 +14,10 @@ export default () => { ...@@ -14,11 +14,10 @@ export default () => {
const el = document.getElementById('js-geo-nodes'); const el = document.getElementById('js-geo-nodes');
if (!el) { if (!el) {
return; return false;
} }
// eslint-disable-next-line no-new return new Vue({
new Vue({
el, el,
components: { components: {
geoNodesApp, geoNodesApp,
...@@ -28,7 +27,7 @@ export default () => { ...@@ -28,7 +27,7 @@ export default () => {
const nodeActionsAllowed = convertPermissionToBoolean(dataset.nodeActionsAllowed); const nodeActionsAllowed = convertPermissionToBoolean(dataset.nodeActionsAllowed);
const nodeEditAllowed = convertPermissionToBoolean(dataset.nodeEditAllowed); const nodeEditAllowed = convertPermissionToBoolean(dataset.nodeEditAllowed);
const store = new GeoNodesStore(dataset.primaryVersion, dataset.primaryRevision); const store = new GeoNodesStore(dataset.primaryVersion, dataset.primaryRevision);
const service = new GeoNodesService(dataset.nodeDetailsPath); const service = new GeoNodesService();
return { return {
store, store,
......
...@@ -3,8 +3,7 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -3,8 +3,7 @@ import axios from '~/lib/utils/axios_utils';
import Api from '~/api'; import Api from '~/api';
export default class GeoNodesService { export default class GeoNodesService {
constructor(nodeDetailsBasePath) { constructor() {
this.geoNodeDetailsBasePath = nodeDetailsBasePath;
this.geoNodesPath = Api.buildUrl(Api.geoNodesPath); this.geoNodesPath = Api.buildUrl(Api.geoNodesPath);
} }
...@@ -12,8 +11,29 @@ export default class GeoNodesService { ...@@ -12,8 +11,29 @@ export default class GeoNodesService {
return axios.get(this.geoNodesPath); return axios.get(this.geoNodesPath);
} }
getGeoNodeDetails(nodeId) { // eslint-disable-next-line class-methods-use-this
const geoNodeDetailsPath = `${this.geoNodeDetailsBasePath}/${nodeId}/status.json`; getGeoNodeDetails(node) {
return axios.get(geoNodeDetailsPath); return axios.get(node.statusPath, {
params: {
refresh: true,
},
});
}
// eslint-disable-next-line class-methods-use-this
toggleNode(node) {
return axios.put(node.basePath, {
enabled: !node.enabled, // toggle from existing status
});
}
// eslint-disable-next-line class-methods-use-this
repairNode(node) {
return axios.post(node.repairPath);
}
// eslint-disable-next-line class-methods-use-this
removeNode(node) {
return axios.delete(node.basePath);
} }
} }
...@@ -8,7 +8,9 @@ export default class GeoNodesStore { ...@@ -8,7 +8,9 @@ export default class GeoNodesStore {
} }
setNodes(nodes) { setNodes(nodes) {
this.state.nodes = nodes; this.state.nodes = nodes.map(
node => GeoNodesStore.formatNode(node),
);
} }
getNodes() { getNodes() {
...@@ -19,6 +21,16 @@ export default class GeoNodesStore { ...@@ -19,6 +21,16 @@ export default class GeoNodesStore {
this.state.nodeDetails[nodeId] = GeoNodesStore.formatNodeDetails(nodeDetails); this.state.nodeDetails[nodeId] = GeoNodesStore.formatNodeDetails(nodeDetails);
} }
removeNode(node) {
const indexOfRemovedNode = this.state.nodes.indexOf(node);
if (indexOfRemovedNode > -1) {
this.state.nodes.splice(indexOfRemovedNode, 1);
if (this.state.nodeDetails[node.id]) {
delete this.state.nodeDetails[node.id];
}
}
}
getPrimaryNodeVersion() { getPrimaryNodeVersion() {
return { return {
version: this.state.primaryVersion, version: this.state.primaryVersion,
...@@ -30,6 +42,22 @@ export default class GeoNodesStore { ...@@ -30,6 +42,22 @@ export default class GeoNodesStore {
return this.state.nodeDetails[nodeId]; return this.state.nodeDetails[nodeId];
} }
static formatNode(rawNode) {
const { id, url, primary, current, enabled } = rawNode;
return {
id,
url,
primary,
current,
enabled,
nodeActionActive: false,
basePath: rawNode._links.self,
repairPath: rawNode._links.repair,
editPath: rawNode.web_edit_url,
statusPath: rawNode._links.status,
};
}
static formatNodeDetails(rawNodeDetails) { static formatNodeDetails(rawNodeDetails) {
return { return {
id: rawNodeDetails.geo_node_id, id: rawNodeDetails.geo_node_id,
...@@ -41,8 +69,9 @@ export default class GeoNodesStore { ...@@ -41,8 +69,9 @@ export default class GeoNodesStore {
primaryVersion: rawNodeDetails.primaryVersion, primaryVersion: rawNodeDetails.primaryVersion,
primaryRevision: rawNodeDetails.primaryRevision, primaryRevision: rawNodeDetails.primaryRevision,
replicationSlotWAL: rawNodeDetails.replication_slots_max_retained_wal_bytes, replicationSlotWAL: rawNodeDetails.replication_slots_max_retained_wal_bytes,
missingOAuthApplication: rawNodeDetails.missing_oauth_application, missingOAuthApplication: rawNodeDetails.missing_oauth_application || false,
storageShardsMatch: rawNodeDetails.storage_shards_match, storageShardsMatch: rawNodeDetails.storage_shards_match,
syncStatusUnavailable: rawNodeDetails.sync_status_unavailable || false,
replicationSlots: { replicationSlots: {
totalCount: rawNodeDetails.replication_slots_count || 0, totalCount: rawNodeDetails.replication_slots_count || 0,
successCount: rawNodeDetails.replication_slots_used_count || 0, successCount: rawNodeDetails.replication_slots_used_count || 0,
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
} }
.health-message { .health-message {
padding: 4px 8px 1px; padding: 2px 8px;
background-color: $red-100; background-color: $red-100;
color: $red-500; color: $red-500;
border-radius: $border-radius-default; border-radius: $border-radius-default;
...@@ -29,9 +29,9 @@ ...@@ -29,9 +29,9 @@
background: $white-light; background: $white-light;
} }
&.node-disabled, &.node-action-active {
&.node-disabled:hover { pointer-events: none;
background-color: $gray-lightest; opacity: 0.5;
} }
} }
} }
......
...@@ -37,50 +37,6 @@ class Admin::GeoNodesController < Admin::ApplicationController ...@@ -37,50 +37,6 @@ class Admin::GeoNodesController < Admin::ApplicationController
end end
end end
def destroy
@node.destroy
redirect_to admin_geo_nodes_path, status: 302, notice: 'Node was successfully removed.'
end
def repair
if !@node.missing_oauth_application?
flash[:notice] = "This node doesn't need to be repaired."
elsif @node.repair
flash[:notice] = 'Node Authentication was successfully repaired.'
else
flash[:alert] = 'There was a problem repairing Node Authentication.'
end
redirect_to admin_geo_nodes_path
end
def toggle
if @node.primary?
flash[:alert] = "Primary node can't be disabled."
else
if @node.toggle!(:enabled)
new_status = @node.enabled? ? 'enabled' : 'disabled'
flash[:notice] = "Node #{@node.url} was successfully #{new_status}."
else
action = @node.enabled? ? 'disabling' : 'enabling'
flash[:alert] = "There was a problem #{action} node #{@node.url}."
end
end
redirect_to admin_geo_nodes_path
end
def status
status = Geo::NodeStatusFetchService.new.call(@node)
respond_to do |format|
format.json do
render json: GeoNodeStatusSerializer.new.represent(status)
end
end
end
private private
def geo_node_params def geo_node_params
......
...@@ -13,7 +13,6 @@ module EE ...@@ -13,7 +13,6 @@ module EE
{ {
primary_version: version.to_s, primary_version: version.to_s,
primary_revision: revision.to_s, primary_revision: revision.to_s,
node_details_path: admin_geo_nodes_path.to_s,
node_actions_allowed: ::Gitlab::Database.read_write?.to_s, node_actions_allowed: ::Gitlab::Database.read_write?.to_s,
node_edit_allowed: ::Gitlab::Geo.license_allows?.to_s node_edit_allowed: ::Gitlab::Geo.license_allows?.to_s
} }
......
---
title: Fixes and enhancements for Geo admin dashboard
merge_request: 4536
author:
type: fixed
...@@ -66,6 +66,8 @@ module API ...@@ -66,6 +66,8 @@ module API
strong_memoize(:geo_node_status) do strong_memoize(:geo_node_status) do
if geo_node.current? if geo_node.current?
GeoNodeStatus.current_node_status GeoNodeStatus.current_node_status
elsif to_boolean(declared_params(include_missing: false)[:refresh])
::Geo::NodeStatusFetchService.new.call(geo_node)
else else
geo_node.status geo_node.status
end end
...@@ -93,6 +95,9 @@ module API ...@@ -93,6 +95,9 @@ module API
desc 'Get metrics for a single Geo node' do desc 'Get metrics for a single Geo node' do
success EE::API::Entities::GeoNodeStatus success EE::API::Entities::GeoNodeStatus
end end
params do
optional :refresh, type: Boolean, desc: 'Attempt to fetch the latest status from the Geo node directly, ignoring the cache'
end
get 'status' do get 'status' do
not_found!('GeoNode') unless geo_node not_found!('GeoNode') unless geo_node
...@@ -145,6 +150,20 @@ module API ...@@ -145,6 +150,20 @@ module API
render_validation_error!(geo_node) render_validation_error!(geo_node)
end end
end end
# Delete an existing Geo node
#
# Example request:
# DELETE /geo_nodes/:id
desc 'Delete an existing Geo secondary node' do
success EE::API::Entities::GeoNode
end
delete do
not_found!('GeoNode') unless geo_node
geo_node.destroy!
status 204
end
end end
end end
end end
......
...@@ -224,11 +224,19 @@ module EE ...@@ -224,11 +224,19 @@ module EE
'http' 'http'
end end
expose :web_edit_url do |geo_node|
::Gitlab::Routing.url_helpers.edit_admin_geo_node_url(geo_node)
end
expose :_links do expose :_links do
expose :self do |geo_node| expose :self do |geo_node|
expose_url api_v4_geo_nodes_path(id: geo_node.id) expose_url api_v4_geo_nodes_path(id: geo_node.id)
end end
expose :status do |geo_node|
expose_url api_v4_geo_nodes_status_path(id: geo_node.id)
end
expose :repair do |geo_node| expose :repair do |geo_node|
expose_url api_v4_geo_nodes_repair_path(id: geo_node.id) expose_url api_v4_geo_nodes_repair_path(id: geo_node.id)
end end
......
...@@ -55,34 +55,6 @@ describe Admin::GeoNodesController, :postgresql do ...@@ -55,34 +55,6 @@ describe Admin::GeoNodesController, :postgresql do
end end
end end
describe '#destroy' do
let!(:geo_node) { create(:geo_node) }
def go
delete(:destroy, id: geo_node)
end
context 'without add-on license' do
before do
allow(Gitlab::Geo).to receive(:license_allows?).and_return(false)
end
it 'deletes the node' do
expect { go }.to change { GeoNode.count }.by(-1)
end
end
context 'with add-on license' do
before do
allow(Gitlab::Geo).to receive(:license_allows?).and_return(true)
end
it 'deletes the node' do
expect { go }.to change { GeoNode.count }.by(-1)
end
end
end
describe '#create' do describe '#create' do
let(:geo_node_attributes) { { url: 'http://example.com' } } let(:geo_node_attributes) { { url: 'http://example.com' } }
...@@ -149,126 +121,4 @@ describe Admin::GeoNodesController, :postgresql do ...@@ -149,126 +121,4 @@ describe Admin::GeoNodesController, :postgresql do
end end
end end
end end
describe '#repair' do
let(:geo_node) { create(:geo_node) }
def go
post :repair, id: geo_node
end
before do
allow(Gitlab::Geo).to receive(:license_allows?) { false }
go
end
it_behaves_like 'unlicensed geo action'
end
describe '#toggle' do
context 'without add-on license' do
let(:geo_node) { create(:geo_node) }
before do
allow(Gitlab::Geo).to receive(:license_allows?).and_return(false)
post :toggle, id: geo_node
end
it_behaves_like 'unlicensed geo action'
end
context 'with add-on license' do
before do
allow(Gitlab::Geo).to receive(:license_allows?).and_return(true)
end
context 'with a primary node' do
before do
post :toggle, id: geo_node
end
let(:geo_node) { create(:geo_node, :primary, enabled: true) }
it 'does not disable the node' do
expect(geo_node.reload).to be_enabled
end
it 'displays a flash message' do
expect(controller).to set_flash[:alert].to("Primary node can't be disabled.")
end
it 'redirects to the geo nodes page' do
expect(response).to redirect_to(admin_geo_nodes_path)
end
end
context 'with a secondary node' do
let(:geo_node) { create(:geo_node, url: 'http://example.com') }
context 'when succeed' do
before do
post :toggle, id: geo_node
end
it 'disables the node' do
expect(geo_node.reload).not_to be_enabled
end
it 'displays a flash message' do
expect(controller).to set_flash[:notice].to('Node http://example.com/ was successfully disabled.')
end
it 'redirects to the geo nodes page' do
expect(response).to redirect_to(admin_geo_nodes_path)
end
end
context 'when fail' do
before do
allow_any_instance_of(GeoNode).to receive(:toggle!).and_return(false)
post :toggle, id: geo_node
end
it 'does not disable the node' do
expect(geo_node.reload).to be_enabled
end
it 'displays a flash message' do
expect(controller).to set_flash[:alert].to('There was a problem disabling node http://example.com/.')
end
it 'redirects to the geo nodes page' do
expect(response).to redirect_to(admin_geo_nodes_path)
end
end
end
end
end
describe '#status' do
let(:geo_node) { create(:geo_node) }
context 'without add-on license' do
before do
allow(Gitlab::Geo).to receive(:license_allows?).and_return(false)
get :status, id: geo_node, format: :json
end
it_behaves_like 'unlicensed geo action'
end
context 'with add-on license' do
let(:geo_node_status) { build(:geo_node_status, :healthy, geo_node: geo_node) }
before do
allow(Gitlab::Geo).to receive(:license_allows?).and_return(true)
allow_any_instance_of(Geo::NodeStatusFetchService).to receive(:call).and_return(geo_node_status)
end
it 'returns the status' do
get :status, id: geo_node, format: :json
expect(response).to match_response_schema('public_api/v4/geo_node_status', dir: 'ee')
end
end
end
end end
...@@ -83,7 +83,10 @@ describe 'admin Geo Nodes', :js do ...@@ -83,7 +83,10 @@ describe 'admin Geo Nodes', :js do
it 'removes an existing Geo Node' do it 'removes an existing Geo Node' do
page.within(find('.geo-node-actions', match: :first)) do page.within(find('.geo-node-actions', match: :first)) do
page.click_link('Remove') page.click_button('Remove')
end
page.within('.modal') do
page.click_button('Remove')
end end
expect(current_path).to eq admin_geo_nodes_path expect(current_path).to eq admin_geo_nodes_path
......
...@@ -20,11 +20,13 @@ ...@@ -20,11 +20,13 @@
"files_max_capacity": { "type": "integer" }, "files_max_capacity": { "type": "integer" },
"repos_max_capacity": { "type": "integer" }, "repos_max_capacity": { "type": "integer" },
"clone_protocol": { "type": ["string"] }, "clone_protocol": { "type": ["string"] },
"web_edit_url": { "type": "string" },
"_links": { "_links": {
"type": "object", "type": "object",
"required": ["self", "repair"], "required": ["self", "repair"],
"properties" : { "properties" : {
"self": { "type": "string" }, "self": { "type": "string" },
"status": { "type": "string" },
"repair": { "type": "string" } "repair": { "type": "string" }
}, },
"additionalProperties": false "additionalProperties": false
......
...@@ -34,9 +34,11 @@ describe API::GeoNodes, :geo, api: true do ...@@ -34,9 +34,11 @@ describe API::GeoNodes, :geo, api: true do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('public_api/v4/geo_node', dir: 'ee') expect(response).to match_response_schema('public_api/v4/geo_node', dir: 'ee')
expect(json_response['web_edit_url']).to end_with("/admin/geo_nodes/#{primary.id}/edit")
links = json_response['_links'] links = json_response['_links']
expect(links['self']).to end_with("/api/v4/geo_nodes/#{primary.id}") expect(links['self']).to end_with("/api/v4/geo_nodes/#{primary.id}")
expect(links['status']).to end_with("/api/v4/geo_nodes/#{primary.id}/status")
expect(links['repair']).to end_with("/api/v4/geo_nodes/#{primary.id}/repair") expect(links['repair']).to end_with("/api/v4/geo_nodes/#{primary.id}/repair")
end end
...@@ -99,6 +101,32 @@ describe API::GeoNodes, :geo, api: true do ...@@ -99,6 +101,32 @@ describe API::GeoNodes, :geo, api: true do
expect(response).to match_response_schema('public_api/v4/geo_node_status', dir: 'ee') expect(response).to match_response_schema('public_api/v4/geo_node_status', dir: 'ee')
end end
it 'fetches the real-time status with `refresh=true`' do
stub_current_geo_node(primary)
new_status = build(:geo_node_status, :healthy, geo_node: secondary, attachments_count: 923, lfs_objects_count: 652)
expect(GeoNode).to receive(:find).and_return(secondary)
expect_any_instance_of(Geo::NodeStatusFetchService).to receive(:call).and_return(new_status)
get api("/geo_nodes/#{secondary.id}/status", admin), refresh: true
expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('public_api/v4/geo_node_status', dir: 'ee')
expect(json_response['attachments_count']).to eq(923)
expect(json_response['lfs_objects_count']).to eq(652)
end
it 'returns 404 when no Geo Node status is not found' do
stub_current_geo_node(primary)
secondary_status.destroy!
expect(GeoNode).to receive(:find).and_return(secondary)
get api("/geo_nodes/#{secondary.id}/status", admin)
expect(response).to have_gitlab_http_status(404)
end
it_behaves_like '404 response' do it_behaves_like '404 response' do
let(:request) { get api("/geo_nodes/#{unexisting_node_id}/status", admin) } let(:request) { get api("/geo_nodes/#{unexisting_node_id}/status", admin) }
end end
...@@ -149,7 +177,7 @@ describe API::GeoNodes, :geo, api: true do ...@@ -149,7 +177,7 @@ describe API::GeoNodes, :geo, api: true do
describe 'PUT /geo_nodes/:id' do describe 'PUT /geo_nodes/:id' do
it_behaves_like '404 response' do it_behaves_like '404 response' do
let(:request) { get api("/geo_nodes/#{unexisting_node_id}/status", admin) } let(:request) { put api("/geo_nodes/#{unexisting_node_id}", admin), {} }
end end
it 'denies access if not admin' do it 'denies access if not admin' do
...@@ -174,6 +202,32 @@ describe API::GeoNodes, :geo, api: true do ...@@ -174,6 +202,32 @@ describe API::GeoNodes, :geo, api: true do
end end
end end
describe 'DELETE /geo_nodes/:id' do
it_behaves_like '404 response' do
let(:request) { delete api("/geo_nodes/#{unexisting_node_id}", admin) }
end
it 'denies access if not admin' do
delete api("/geo_nodes/#{secondary.id}", user)
expect(response).to have_gitlab_http_status(403)
end
it 'deletes the node' do
delete api("/geo_nodes/#{secondary.id}", admin)
expect(response).to have_gitlab_http_status(204)
end
it 'returns 400 if Geo Node could not be deleted' do
allow_any_instance_of(GeoNode).to receive(:destroy!).and_raise(StandardError, 'Something wrong')
delete api("/geo_nodes/#{secondary.id}", admin)
expect(response).to have_gitlab_http_status(500)
end
end
describe 'GET /geo_nodes/current/failures/:type' do describe 'GET /geo_nodes/current/failures/:type' do
it 'fetches the current node failures' do it 'fetches the current node failures' do
create(:geo_project_registry, :sync_failed) create(:geo_project_registry, :sync_failed)
......
...@@ -2,6 +2,8 @@ import Vue from 'vue'; ...@@ -2,6 +2,8 @@ import Vue from 'vue';
import geoNodeActionsComponent from 'ee/geo_nodes/components/geo_node_actions.vue'; import geoNodeActionsComponent from 'ee/geo_nodes/components/geo_node_actions.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import eventHub from 'ee/geo_nodes/event_hub';
import { NODE_ACTIONS } from 'ee/geo_nodes/constants';
import { mockNodes } from '../mock_data'; import { mockNodes } from '../mock_data';
const createComponent = (node = mockNodes[0], nodeEditAllowed = true, nodeMissingOauth = false) => { const createComponent = (node = mockNodes[0], nodeEditAllowed = true, nodeMissingOauth = false) => {
...@@ -25,14 +27,6 @@ describe('GeoNodeActionsComponent', () => { ...@@ -25,14 +27,6 @@ describe('GeoNodeActionsComponent', () => {
vm.$destroy(); vm.$destroy();
}); });
describe('data', () => {
it('returns default data props', () => {
const vmX = createComponent();
expect(vmX.isNodeToggleInProgress).toBeFalsy();
vmX.$destroy();
});
});
describe('computed', () => { describe('computed', () => {
describe('isToggleAllowed', () => { describe('isToggleAllowed', () => {
it('returns boolean value representing if toggle on node can be allowed', () => { it('returns boolean value representing if toggle on node can be allowed', () => {
...@@ -59,49 +53,48 @@ describe('GeoNodeActionsComponent', () => { ...@@ -59,49 +53,48 @@ describe('GeoNodeActionsComponent', () => {
vmX.$destroy(); vmX.$destroy();
}); });
}); });
});
describe('nodeDisableMessage', () => { describe('methods', () => {
it('returns node toggle message', () => { describe('onToggleNode', () => {
let mockNode = Object.assign({}, mockNodes[1]); it('emits showNodeActionModal with actionType `toggle`, node reference, modalMessage and modalActionLabel', () => {
let vmX = createComponent(mockNode); spyOn(eventHub, '$emit');
expect(vmX.nodeDisableMessage).toBe('Disabling a node stops the sync process. Are you sure?'); vm.onToggleNode();
vmX.$destroy(); expect(eventHub.$emit).toHaveBeenCalledWith('showNodeActionModal', {
actionType: NODE_ACTIONS.TOGGLE,
mockNode = Object.assign({}, mockNodes[1], { enabled: false }); node: vm.node,
vmX = createComponent(mockNode); modalMessage: 'Disabling a node stops the sync process. Are you sure?',
expect(vmX.nodeDisableMessage).toBe(''); modalActionLabel: vm.nodeToggleLabel,
vmX.$destroy(); });
});
});
describe('nodePath', () => {
it('returns node path', () => {
expect(vm.nodePath).toBe('/admin/geo_nodes/1');
});
});
describe('nodeRepairAuthPath', () => {
it('returns node repair authentication path', () => {
expect(vm.nodeRepairAuthPath).toBe('/admin/geo_nodes/1/repair');
}); });
}); });
describe('nodeTogglePath', () => { describe('onRemoveNode', () => {
it('returns node toggle path', () => { it('emits showNodeActionModal with actionType `remove`, node reference, modalKind, modalMessage and modalActionLabel', () => {
expect(vm.nodeTogglePath).toBe('/admin/geo_nodes/1/toggle'); spyOn(eventHub, '$emit');
vm.onRemoveNode();
expect(eventHub.$emit).toHaveBeenCalledWith('showNodeActionModal', {
actionType: NODE_ACTIONS.REMOVE,
node: vm.node,
modalKind: 'danger',
modalMessage: 'Removing a node stops the sync process. Are you sure?',
modalActionLabel: 'Remove',
});
}); });
}); });
describe('nodeEditPath', () => { describe('onRepairNode', () => {
it('returns node edit path', () => { it('emits `repairNode` event with node reference', () => {
expect(vm.nodeEditPath).toBe('/admin/geo_nodes/1/edit'); spyOn(eventHub, '$emit');
vm.onRepairNode();
expect(eventHub.$emit).toHaveBeenCalledWith('repairNode', vm.node);
}); });
}); });
}); });
describe('template', () => { describe('template', () => {
it('renders container elements correctly', () => { it('renders container elements correctly', () => {
expect(vm.$el.classList.contains('geo-node-actions')).toBeTruthy(); expect(vm.$el.classList.contains('geo-node-actions')).toBe(true);
expect(vm.$el.querySelectorAll('.node-action-container').length).not.toBe(0); expect(vm.$el.querySelectorAll('.node-action-container').length).not.toBe(0);
expect(vm.$el.querySelectorAll('.btn-node-action').length).not.toBe(0); expect(vm.$el.querySelectorAll('.btn-node-action').length).not.toBe(0);
}); });
......
...@@ -77,6 +77,16 @@ describe('GeoNodeDetailsComponent', () => { ...@@ -77,6 +77,16 @@ describe('GeoNodeDetailsComponent', () => {
}); });
describe('nodeVersion', () => { describe('nodeVersion', () => {
it('returns `Unknown` when `version` and `revision` are null', () => {
const nodeDetailsVersionNull = Object.assign({}, mockNodeDetails, {
version: null,
revision: null,
});
const vmVersionNull = createComponent(nodeDetailsVersionNull);
expect(vmVersionNull.nodeVersion).toBe('Unknown');
vmVersionNull.$destroy();
});
it('returns version string', () => { it('returns version string', () => {
expect(vm.nodeVersion).toBe('10.4.0-pre (b93c51849b)'); expect(vm.nodeVersion).toBe('10.4.0-pre (b93c51849b)');
}); });
...@@ -92,6 +102,15 @@ describe('GeoNodeDetailsComponent', () => { ...@@ -92,6 +102,15 @@ describe('GeoNodeDetailsComponent', () => {
it('returns DB replication lag time duration', () => { it('returns DB replication lag time duration', () => {
expect(vm.dbReplicationLag).toBe('0m'); expect(vm.dbReplicationLag).toBe('0m');
}); });
it('returns `Unknown` when `dbReplicationLag` is null', () => {
const nodeDetailsLagNull = Object.assign({}, mockNodeDetails, {
dbReplicationLag: null,
});
const vmLagNull = createComponent(nodeDetailsLagNull);
expect(vmLagNull.dbReplicationLag).toBe('Unknown');
vmLagNull.$destroy();
});
}); });
describe('lastEventStatus', () => { describe('lastEventStatus', () => {
...@@ -158,10 +177,17 @@ describe('GeoNodeDetailsComponent', () => { ...@@ -158,10 +177,17 @@ describe('GeoNodeDetailsComponent', () => {
describe('syncSettings', () => { describe('syncSettings', () => {
it('returns sync settings object', () => { it('returns sync settings object', () => {
const syncSettings = vm.syncSettings(); const nodeDetailsUnknownSync = Object.assign({}, mockNodeDetails, {
syncStatusUnavailable: true,
});
const vmUnknownSync = createComponent(nodeDetailsUnknownSync);
const syncSettings = vmUnknownSync.syncSettings();
expect(syncSettings.syncStatusUnavailable).toBe(true);
expect(syncSettings.namespaces).toBe(mockNodeDetails.namespaces); expect(syncSettings.namespaces).toBe(mockNodeDetails.namespaces);
expect(syncSettings.lastEvent).toBe(mockNodeDetails.lastEvent); expect(syncSettings.lastEvent).toBe(mockNodeDetails.lastEvent);
expect(syncSettings.cursorLastEvent).toBe(mockNodeDetails.cursorLastEvent); expect(syncSettings.cursorLastEvent).toBe(mockNodeDetails.cursorLastEvent);
vmUnknownSync.$destroy();
}); });
}); });
......
...@@ -184,7 +184,7 @@ describe('GeoNodeItemComponent', () => { ...@@ -184,7 +184,7 @@ describe('GeoNodeItemComponent', () => {
spyOn(eventHub, '$emit'); spyOn(eventHub, '$emit');
vm.handleMounted(); vm.handleMounted();
expect(eventHub.$emit).toHaveBeenCalledWith('pollNodeDetails', mockNodes[0].id); expect(eventHub.$emit).toHaveBeenCalledWith('pollNodeDetails', vm.node);
}); });
}); });
}); });
......
...@@ -5,12 +5,14 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper'; ...@@ -5,12 +5,14 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockNodeDetails } from '../mock_data'; import { mockNodeDetails } from '../mock_data';
const createComponent = ( const createComponent = (
syncStatusUnavailable = false,
selectiveSyncType = mockNodeDetails.selectiveSyncType, selectiveSyncType = mockNodeDetails.selectiveSyncType,
lastEvent = mockNodeDetails.lastEvent, lastEvent = mockNodeDetails.lastEvent,
cursorLastEvent = mockNodeDetails.cursorLastEvent) => { cursorLastEvent = mockNodeDetails.cursorLastEvent) => {
const Component = Vue.extend(geoNodeSyncSettingsComponent); const Component = Vue.extend(geoNodeSyncSettingsComponent);
return mountComponent(Component, { return mountComponent(Component, {
syncStatusUnavailable,
selectiveSyncType, selectiveSyncType,
lastEvent, lastEvent,
cursorLastEvent, cursorLastEvent,
...@@ -29,7 +31,7 @@ describe('GeoNodeSyncSettingsComponent', () => { ...@@ -29,7 +31,7 @@ describe('GeoNodeSyncSettingsComponent', () => {
describe('eventTimestampEmpty', () => { describe('eventTimestampEmpty', () => {
it('returns `true` if one of the event timestamp is empty', () => { it('returns `true` if one of the event timestamp is empty', () => {
const vmEmptyTimestamp = createComponent(mockNodeDetails.namespaces, { const vmEmptyTimestamp = createComponent(false, mockNodeDetails.namespaces, {
id: 0, id: 0,
timeStamp: 0, timeStamp: 0,
}, { }, {
...@@ -87,4 +89,12 @@ describe('GeoNodeSyncSettingsComponent', () => { ...@@ -87,4 +89,12 @@ describe('GeoNodeSyncSettingsComponent', () => {
}); });
}); });
}); });
describe('template', () => {
it('renders `Unknown` when `syncStatusUnavailable` prop is true', () => {
const vmSyncUnavailable = createComponent(true);
expect(vmSyncUnavailable.$el.innerText.trim()).toBe('Unknown');
vmSyncUnavailable.$destroy();
});
});
}); });
...@@ -15,6 +15,12 @@ export const mockNodes = [ ...@@ -15,6 +15,12 @@ export const mockNodes = [
files_max_capacity: 10, files_max_capacity: 10,
repos_max_capacity: 25, repos_max_capacity: 25,
clone_protocol: 'http', clone_protocol: 'http',
_links: {
self: 'http://127.0.0.1:3001/api/v4/geo_nodes/1',
repair: 'http://127.0.0.1:3001/api/v4/geo_nodes/1/repair',
status: 'http://127.0.0.1:3001/api/v4/geo_nodes/1/status',
web_edit: 'http://127.0.0.1:3001/admin/geo_nodes/1/edit',
},
}, },
{ {
id: 2, id: 2,
...@@ -25,9 +31,28 @@ export const mockNodes = [ ...@@ -25,9 +31,28 @@ export const mockNodes = [
files_max_capacity: 10, files_max_capacity: 10,
repos_max_capacity: 25, repos_max_capacity: 25,
clone_protocol: 'http', clone_protocol: 'http',
_links: {
self: 'http://127.0.0.1:3001/api/v4/geo_nodes/2',
repair: 'http://127.0.0.1:3001/api/v4/geo_nodes/2/repair',
status: 'http://127.0.0.1:3001/api/v4/geo_nodes/2/status',
web_edit: 'http://127.0.0.1:3001/admin/geo_nodes/2/edit',
},
}, },
]; ];
export const mockNode = {
id: 1,
url: 'http://127.0.0.1:3001/',
primary: true,
current: true,
enabled: true,
nodeActionActive: false,
basePath: 'http://127.0.0.1:3001/api/v4/geo_nodes/1',
repairPath: 'http://127.0.0.1:3001/api/v4/geo_nodes/1/repair',
statusPath: 'http://127.0.0.1:3001/api/v4/geo_nodes/1/status?refresh=true',
editPath: 'http://127.0.0.1:3001/admin/geo_nodes/1/edit',
};
export const rawMockNodeDetails = { export const rawMockNodeDetails = {
geo_node_id: 2, geo_node_id: 2,
healthy: true, healthy: true,
......
...@@ -22,7 +22,7 @@ describe('GeoNodesStore', () => { ...@@ -22,7 +22,7 @@ describe('GeoNodesStore', () => {
describe('setNodes', () => { describe('setNodes', () => {
it('sets nodes list to state', () => { it('sets nodes list to state', () => {
store.setNodes(mockNodes); store.setNodes(mockNodes);
expect(store.getNodes()).toBe(mockNodes); expect(store.getNodes().length).toBe(mockNodes.length);
}); });
}); });
...@@ -33,6 +33,28 @@ describe('GeoNodesStore', () => { ...@@ -33,6 +33,28 @@ describe('GeoNodesStore', () => {
}); });
}); });
describe('removeNode', () => {
it('removes node from store state', () => {
store.setNodes(mockNodes);
const nodeToBeRemoved = store.getNodes()[1];
store.removeNode(nodeToBeRemoved);
store.getNodes().forEach((node) => {
expect(node.id).not.toBe(nodeToBeRemoved);
});
});
});
describe('formatNode', () => {
it('returns formatted raw node object', () => {
const node = GeoNodesStore.formatNode(mockNodes[0]);
expect(node.id).toBe(mockNodes[0].id);
expect(node.url).toBe(mockNodes[0].url);
expect(node.basePath).toBe(mockNodes[0]._links.self);
expect(node.repairPath).toBe(mockNodes[0]._links.repair);
expect(node.nodeActionActive).toBe(false);
});
});
describe('formatNodeDetails', () => { describe('formatNodeDetails', () => {
it('returns formatted raw node details object', () => { it('returns formatted raw node details object', () => {
const nodeDetails = GeoNodesStore.formatNodeDetails(rawMockNodeDetails); const nodeDetails = GeoNodesStore.formatNodeDetails(rawMockNodeDetails);
......
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