Commit d710a04b authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '296986-remove-geo_nodes_beta-FF' into 'master'

Geo Node 2.0 - Remove geo_nodes_beta FF [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!62309
parents 874dfb84 0bb460bc
<script>
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import createFlash from '~/flash';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { __, s__ } from '~/locale';
import SmartInterval from '~/smart_interval';
import { NODE_ACTIONS } from '../constants';
import eventHub from '../event_hub';
import GeoNodeItem from './geo_node_item.vue';
export default {
components: {
GlModal,
GeoNodeItem,
GlLoadingIcon,
},
props: {
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
geoTroubleshootingHelpPath: {
type: String,
required: true,
},
},
data() {
return {
isLoading: true,
hasError: false,
targetNode: null,
targetNodeActionType: '',
modalTitle: '',
modalKind: 'warning',
modalMessage: '',
modalActionLabel: '',
modalId: 'node-action',
};
},
computed: {
nodes() {
return this.store.getNodes();
},
},
created() {
eventHub.$on('pollNodeDetails', this.initNodeDetailsPolling);
eventHub.$on('showNodeActionModal', this.showNodeActionModal);
eventHub.$on('repairNode', this.repairNode);
},
mounted() {
this.fetchGeoNodes();
},
beforeDestroy() {
eventHub.$off('pollNodeDetails', this.initNodeDetailsPolling);
eventHub.$off('showNodeActionModal', this.showNodeActionModal);
eventHub.$off('repairNode', this.repairNode);
if (this.nodePollingInterval) {
this.nodePollingInterval.stopTimer();
}
},
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() {
return this.service
.getGeoNodes()
.then((res) => res.data)
.then((nodes) => {
this.store.setNodes(nodes);
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
createFlash({
message: s__('GeoNodes|Something went wrong while fetching nodes'),
});
});
},
fetchNodeDetails(node) {
const nodeId = node.id;
return this.service
.getGeoNodeDetails(node)
.then((res) => res.data)
.then((nodeDetails) => {
const primaryNodeVersion = this.store.getPrimaryNodeVersion();
const updatedNodeDetails = Object.assign(nodeDetails, {
primaryVersion: primaryNodeVersion.version,
primaryRevision: primaryNodeVersion.revision,
});
this.store.setNodeDetails(nodeId, updatedNodeDetails);
eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
})
.catch((err) => {
this.store.setNodeDetails(nodeId, {
geo_node_id: nodeId,
health: err.message,
health_status: __('Unknown'),
missing_oauth_application: null,
sync_status_unavailable: true,
storage_shards_match: null,
});
eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
});
},
repairNode(targetNode) {
this.setNodeActionStatus(targetNode, true);
return this.service
.repairNode(targetNode)
.then(() => {
this.setNodeActionStatus(targetNode, false);
this.$toast.show(s__('GeoNodes|Node Authentication was successfully repaired.'));
})
.catch(() => {
this.setNodeActionStatus(targetNode, false);
createFlash({
message: s__('GeoNodes|Something went wrong while repairing node'),
});
});
},
toggleNode(targetNode) {
this.setNodeActionStatus(targetNode, true);
return this.service
.toggleNode(targetNode)
.then((res) => res.data)
.then((node) => {
Object.assign(targetNode, {
enabled: node.enabled,
nodeActionActive: false,
});
})
.catch(() => {
this.setNodeActionStatus(targetNode, false);
createFlash({
message: s__('GeoNodes|Something went wrong while changing node status'),
});
});
},
removeNode(targetNode) {
this.setNodeActionStatus(targetNode, true);
return this.service
.removeNode(targetNode)
.then(() => {
this.store.removeNode(targetNode);
this.$toast.show(s__('GeoNodes|Node was successfully removed.'));
})
.catch(() => {
this.setNodeActionStatus(targetNode, false);
createFlash({
message: s__('GeoNodes|Something went wrong while removing node'),
});
});
},
handleNodeAction() {
this.hideNodeActionModal();
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,
modalTitle,
}) {
this.targetNode = node;
this.targetNodeActionType = actionType;
this.modalKind = modalKind;
this.modalMessage = modalMessage;
this.modalActionLabel = modalActionLabel;
this.modalTitle = modalTitle;
if (actionType === NODE_ACTIONS.TOGGLE && !node.enabled) {
this.toggleNode(this.targetNode);
} else {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
}
},
hideNodeActionModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
nodeRemovalAllowed(node) {
return !node.primary || this.nodes.length <= 1;
},
},
};
</script>
<template>
<div class="geo-nodes-container">
<gl-loading-icon
v-if="isLoading"
:label="s__('GeoNodes|Loading nodes')"
size="md"
class="loading-animation prepend-top-20 append-bottom-20"
/>
<geo-node-item
v-for="(node, index) in nodes"
:key="index"
:node="node"
:primary-node="node.primary"
:node-actions-allowed="nodeActionsAllowed"
:node-edit-allowed="nodeEditAllowed"
:node-removal-allowed="nodeRemovalAllowed(node)"
:geo-troubleshooting-help-path="geoTroubleshootingHelpPath"
/>
<gl-modal
:modal-id="modalId"
:title="modalTitle"
:ok-variant="modalKind"
:ok-title="modalActionLabel"
@cancel="hideNodeActionModal"
@ok="handleNodeAction"
>
{{ modalMessage }}
</gl-modal>
</div>
</template>
<script>
import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { NODE_ACTIONS } from '../constants';
import eventHub from '../event_hub';
export default {
components: {
GlIcon,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
node: {
type: Object,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
nodeRemovalAllowed: {
type: Boolean,
required: true,
},
nodeMissingOauth: {
type: Boolean,
required: true,
},
},
computed: {
isSecondaryNode() {
return !this.node.primary;
},
disabledRemovalTooltip() {
return this.nodeRemovalAllowed
? ''
: s__('Geo Nodes|Cannot remove a primary node if there is a secondary node');
},
},
methods: {
onToggleNode() {
eventHub.$emit('showNodeActionModal', {
actionType: NODE_ACTIONS.TOGGLE,
node: this.node,
modalMessage: s__('GeoNodes|Pausing replication stops the sync process. Are you sure?'),
modalActionLabel: this.nodeToggleLabel,
modalTitle: __('Pause replication'),
});
},
onRemoveSecondaryNode() {
eventHub.$emit('showNodeActionModal', {
actionType: NODE_ACTIONS.REMOVE,
node: this.node,
modalKind: 'danger',
modalMessage: s__(
'GeoNodes|Removing a Geo secondary node stops the synchronization to that node. Are you sure?',
),
modalActionLabel: __('Remove node'),
modalTitle: __('Remove secondary node'),
});
},
onRemovePrimaryNode() {
eventHub.$emit('showNodeActionModal', {
actionType: NODE_ACTIONS.REMOVE,
node: this.node,
modalKind: 'danger',
modalMessage: s__(
'GeoNodes|Removing a Geo primary node stops the synchronization to all nodes. Are you sure?',
),
modalActionLabel: __('Remove node'),
modalTitle: __('Remove primary node'),
});
},
onRepairNode() {
eventHub.$emit('repairNode', this.node);
},
},
};
</script>
<template>
<div
data-testid="nodeActions"
class="gl-display-flex gl-align-items-center gl-justify-content-end gl-flex-direction-column gl-sm-flex-direction-row gl-mx-5 gl-sm-mx-0"
>
<gl-button
v-if="isSecondaryNode"
:href="node.geoProjectsUrl"
class="gl-mx-2 gl-mt-5 gl-sm-mt-0 gl-w-full gl-sm-w-auto"
target="_blank"
>
<span class="gl-display-flex gl-align-items-center">
<gl-icon v-if="!node.current" name="external-link" class="gl-mr-2" />
{{ __('Replication details') }}
</span>
</gl-button>
<template v-if="nodeActionsAllowed">
<gl-button
v-if="nodeMissingOauth"
class="gl-mx-2 gl-mt-5 gl-sm-mt-0 gl-w-full gl-sm-w-auto"
@click="onRepairNode"
>
{{ s__('Repair authentication') }}
</gl-button>
<gl-button
v-if="nodeEditAllowed"
:href="node.editPath"
class="gl-mx-2 gl-mt-5 gl-sm-mt-0 gl-w-full gl-sm-w-auto"
>
{{ __('Edit') }}
</gl-button>
<gl-button
v-if="isSecondaryNode"
data-testid="removeButton"
variant="danger"
class="gl-mx-2 gl-mt-5 gl-sm-mt-0 gl-w-full gl-sm-w-auto"
:disabled="!nodeRemovalAllowed"
@click="onRemoveSecondaryNode"
>
{{ __('Remove') }}
</gl-button>
<div
v-gl-tooltip.hover
name="disabledRemovalTooltip"
class="gl-mx-2 gl-mt-5 gl-sm-mt-0 gl-w-full gl-sm-w-auto"
:title="disabledRemovalTooltip"
>
<gl-button
v-if="!isSecondaryNode"
variant="danger"
class="gl-w-full"
:disabled="!nodeRemovalAllowed"
@click="onRemovePrimaryNode"
>
{{ __('Remove') }}
</gl-button>
</div>
</template>
</div>
</template>
<script>
import { GlIcon, GlPopover, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import { VALUE_TYPE, CUSTOM_TYPE, REPLICATION_HELP_URL } from '../constants';
import GeoNodeEventStatus from './geo_node_event_status.vue';
import GeoNodeSyncProgress from './geo_node_sync_progress.vue';
import GeoNodeSyncSettings from './geo_node_sync_settings.vue';
export default {
components: {
GeoNodeSyncSettings,
GeoNodeEventStatus,
GeoNodeSyncProgress,
GlIcon,
GlPopover,
GlLink,
GlSprintf,
},
props: {
itemTitle: {
type: String,
required: true,
},
cssClass: {
type: String,
required: false,
default: '',
},
itemEnabled: {
type: Boolean,
required: false,
default: true,
},
itemValue: {
type: [Object, String, Number],
required: true,
},
itemValueType: {
type: String,
required: false,
default: VALUE_TYPE.GRAPH,
},
customType: {
type: String,
required: false,
default: '',
},
eventTypeLogStatus: {
type: Boolean,
required: false,
default: false,
},
detailsPath: {
type: String,
required: false,
default: '',
},
},
computed: {
hasHelpInfo() {
return typeof this.helpInfo === 'object';
},
isValueTypePlain() {
return this.itemValueType === VALUE_TYPE.PLAIN;
},
isValueTypeGraph() {
return this.itemValueType === VALUE_TYPE.GRAPH;
},
isValueTypeCustom() {
return this.itemValueType === VALUE_TYPE.CUSTOM;
},
isCustomTypeSync() {
return this.customType === CUSTOM_TYPE.SYNC;
},
},
replicationHelpUrl: REPLICATION_HELP_URL,
disabledText: s__('Geo|Synchronization of %{itemTitle} is disabled.'),
};
</script>
<template>
<div class="mt-2 ml-2 node-detail-item">
<div class="d-flex align-items-center text-secondary-700">
<span class="node-detail-title">{{ itemTitle }}</span>
</div>
<div v-if="itemEnabled">
<div v-if="isValueTypePlain" :class="cssClass" class="mt-1 node-detail-value">
{{ itemValue }}
</div>
<geo-node-sync-progress
v-if="isValueTypeGraph"
:item-enabled="itemEnabled"
:item-title="itemTitle"
:item-value="itemValue"
:details-path="detailsPath"
class="mt-1"
/>
<template v-if="isValueTypeCustom">
<geo-node-sync-settings v-if="isCustomTypeSync" v-bind="itemValue" />
<geo-node-event-status
v-else
:event-id="itemValue.eventId"
:event-time-stamp="itemValue.eventTimeStamp"
:event-type-log-status="eventTypeLogStatus"
/>
</template>
</div>
<div v-else class="mt-1">
<div
:id="`syncDisabled-${itemTitle}`"
class="d-inline-flex align-items-center cursor-pointer"
>
<gl-icon name="canceled-circle" :size="14" class="mr-1 text-secondary-300" />
<span ref="disabledText" class="text-secondary-600 gl-font-sm">{{
__('Synchronization disabled')
}}</span>
</div>
<gl-popover :target="`syncDisabled-${itemTitle}`" placement="right" :css-classes="['w-100']">
<section>
<gl-sprintf :message="$options.disabledText">
<template #itemTitle>{{ itemTitle.toLowerCase() }}</template>
</gl-sprintf>
<div class="mt-3">
<gl-link class="gl-font-sm" :href="$options.replicationHelpUrl" target="_blank">{{
__('Learn how to enable synchronization')
}}</gl-link>
</div>
</section>
</gl-popover>
</div>
</div>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
import NodeDetailsSectionMain from './node_detail_sections/node_details_section_main.vue';
import NodeDetailsSectionOther from './node_detail_sections/node_details_section_other.vue';
import NodeDetailsSectionSync from './node_detail_sections/node_details_section_sync.vue';
import NodeDetailsSectionVerification from './node_detail_sections/node_details_section_verification.vue';
export default {
components: {
GlLink,
NodeDetailsSectionMain,
NodeDetailsSectionSync,
NodeDetailsSectionVerification,
NodeDetailsSectionOther,
},
props: {
node: {
type: Object,
required: true,
},
nodeDetails: {
type: Object,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
nodeRemovalAllowed: {
type: Boolean,
required: true,
},
geoTroubleshootingHelpPath: {
type: String,
required: true,
},
},
computed: {
hasVersionMismatch() {
return (
this.nodeDetails.version !== this.nodeDetails.primaryVersion ||
this.nodeDetails.revision !== this.nodeDetails.primaryRevision
);
},
errorMessage() {
if (!this.nodeDetails.healthy) {
return this.nodeDetails.health;
} else if (!this.node.primary && this.hasVersionMismatch) {
return s__('GeoNodes|GitLab version does not match the primary node version');
}
return '';
},
},
};
</script>
<template>
<div class="card-body p-0">
<node-details-section-main
:node="node"
:node-details="nodeDetails"
:node-actions-allowed="nodeActionsAllowed"
:node-edit-allowed="nodeEditAllowed"
:node-removal-allowed="nodeRemovalAllowed"
:version-mismatch="hasVersionMismatch"
/>
<node-details-section-sync v-if="!node.primary" :node="node" :node-details="nodeDetails" />
<node-details-section-verification
v-if="nodeDetails.repositoryVerificationEnabled"
:node-details="nodeDetails"
:node-type-primary="node.primary"
/>
<node-details-section-other
:node="node"
:node-details="nodeDetails"
:node-type-primary="node.primary"
/>
<div v-if="errorMessage" data-testid="errorSection">
<p class="p-3 mb-0 bg-danger-100 text-danger-500">
{{ errorMessage }}
<gl-link :href="geoTroubleshootingHelpPath">{{
s__('Geo|Please refer to Geo Troubleshooting.')
}}</gl-link>
</p>
</div>
</div>
</template>
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeAgoMixin],
props: {
eventId: {
type: Number,
required: true,
},
eventTimeStamp: {
type: Number,
required: true,
default: 0,
},
eventTypeLogStatus: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
timeStamp() {
return new Date(this.eventTimeStamp * 1000);
},
timeStampString() {
return formatDate(this.timeStamp);
},
eventString() {
return this.eventId;
},
},
};
</script>
<template>
<div class="mt-1 node-detail-value">
<template v-if="eventTimeStamp">
<strong> {{ eventString }} </strong>
<span
v-if="eventTimeStamp"
v-gl-tooltip
:title="timeStampString"
class="event-status-timestamp"
>
({{ timeFormatted(timeStamp) }})
</span>
</template>
<strong v-else> {{ __('Not available') }} </strong>
</div>
</template>
<script>
import { GlLoadingIcon, GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
GlIcon,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
node: {
type: Object,
required: true,
},
nodeDetailsLoading: {
type: Boolean,
required: true,
},
},
computed: {
isNodeHTTP() {
return this.node.url.startsWith('http://');
},
showNodeWarningIcon() {
return !this.nodeDetailsLoading && this.isNodeHTTP;
},
},
};
</script>
<template>
<div class="card-header">
<div class="row">
<div class="col-md-8 clearfix">
<span class="d-flex align-items-center float-left gl-mr-3">
<strong>{{ node.name }}</strong>
<gl-loading-icon
v-if="nodeDetailsLoading || node.nodeActionActive"
class="node-details-loading gl-ml-3 inline"
/>
<gl-icon
v-if="showNodeWarningIcon"
v-gl-tooltip
class="ml-2 text-warning-500"
name="warning"
:size="16"
:title="
s__(
'GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS.',
)
"
data-container="body"
data-placement="bottom"
/>
</span>
<span class="inline">
<span v-if="node.current" class="rounded-pill gl-font-sm p-1 text-white bg-success-400">
{{ s__('Current node') }}
</span>
<span
v-if="node.primary"
class="ml-1 rounded-pill gl-font-sm p-1 text-white bg-primary-600"
>
{{ s__('Primary') }}
</span>
</span>
</div>
</div>
</div>
</template>
<script>
import { GlIcon } from '@gitlab/ui';
import { HEALTH_STATUS_ICON, HEALTH_STATUS_CLASS } from '../constants';
import GeoNodeLastUpdated from './geo_node_last_updated.vue';
export default {
components: {
GlIcon,
GeoNodeLastUpdated,
},
props: {
status: {
type: String,
required: true,
},
statusCheckTimestamp: {
type: Number,
required: true,
},
},
computed: {
healthCssClass() {
return HEALTH_STATUS_CLASS[this.status.toLowerCase()];
},
statusIconName() {
return HEALTH_STATUS_ICON[this.status.toLowerCase()];
},
},
};
</script>
<template>
<div class="mt-2 detail-section-item">
<div class="text-secondary-700 node-detail-title">{{ s__('GeoNodes|Health status') }}</div>
<div class="d-flex align-items-center">
<div
:class="healthCssClass"
class="rounded-pill d-inline-flex align-items-center px-2 py-1 my-1 mr-2"
>
<gl-icon :name="statusIconName" />
<strong class="status-text ml-1"> {{ status }} </strong>
</div>
<geo-node-last-updated :status-check-timestamp="statusCheckTimestamp" />
</div>
</div>
</template>
<script>
import eventHub from '../event_hub';
import GeoNodeDetails from './geo_node_details.vue';
import GeoNodeHeader from './geo_node_header.vue';
export default {
components: {
GeoNodeHeader,
GeoNodeDetails,
},
props: {
node: {
type: Object,
required: true,
},
primaryNode: {
type: Boolean,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
nodeRemovalAllowed: {
type: Boolean,
required: true,
},
geoTroubleshootingHelpPath: {
type: String,
required: true,
},
},
data() {
return {
isNodeDetailsLoading: true,
nodeHealthStatus: '',
nodeDetails: {},
};
},
created() {
eventHub.$on('nodeDetailsLoaded', this.handleNodeDetails);
},
mounted() {
this.handleMounted();
},
beforeDestroy() {
eventHub.$off('nodeDetailsLoaded', this.handleNodeDetails);
},
methods: {
handleNodeDetails(nodeDetails) {
if (this.node.id === nodeDetails.id) {
this.isNodeDetailsLoading = false;
this.nodeDetails = nodeDetails;
this.nodeHealthStatus = nodeDetails.health;
}
},
handleMounted() {
eventHub.$emit('pollNodeDetails', this.node);
},
},
};
</script>
<template>
<div :class="{ 'node-action-active': node.nodeActionActive }" class="card">
<geo-node-header :node="node" :node-details-loading="isNodeDetailsLoading" />
<geo-node-details
v-if="!isNodeDetailsLoading"
:node="node"
:node-details="nodeDetails"
:node-edit-allowed="nodeEditAllowed"
:node-actions-allowed="nodeActionsAllowed"
:node-removal-allowed="nodeRemovalAllowed"
:geo-troubleshooting-help-path="geoTroubleshootingHelpPath"
/>
</div>
</template>
<script>
import { GlPopover, GlLink, GlIcon } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
import {
HELP_NODE_HEALTH_URL,
GEO_TROUBLESHOOTING_URL,
STATUS_DELAY_THRESHOLD_MS,
} from '../constants';
export default {
name: 'GeoNodeLastUpdated',
components: {
GlPopover,
GlLink,
GlIcon,
},
mixins: [timeAgoMixin],
props: {
statusCheckTimestamp: {
type: Number,
required: true,
},
},
computed: {
isSyncStale() {
const elapsedMilliseconds = Math.abs(this.statusCheckTimestamp - Date.now());
return elapsedMilliseconds > STATUS_DELAY_THRESHOLD_MS;
},
syncHelp() {
if (this.isSyncStale) {
return {
text: s__('GeoNodes|Consult Geo troubleshooting information'),
link: GEO_TROUBLESHOOTING_URL,
};
}
return {
text: s__('GeoNodes|Learn more about Geo node statuses'),
link: HELP_NODE_HEALTH_URL,
};
},
syncTimeAgo() {
const timeAgo = this.timeFormatted(this.statusCheckTimestamp);
return {
mainText: sprintf(s__('GeoNodes|Updated %{timeAgo}'), { timeAgo }),
popoverText: sprintf(s__("GeoNodes|Node's status was updated %{timeAgo}."), { timeAgo }),
};
},
},
};
</script>
<template>
<div class="d-flex align-items-center">
<span data-testid="nodeLastUpdateMainText" class="text-secondary-700">{{
syncTimeAgo.mainText
}}</span>
<gl-icon
ref="lastUpdated"
tabindex="0"
name="question"
class="text-primary-600 ml-1 cursor-pointer"
/>
<gl-popover :target="() => $refs.lastUpdated.$el" placement="top">
<p>{{ syncTimeAgo.popoverText }}</p>
<gl-link class="mt-3 gl-font-sm" :href="syncHelp.link" target="_blank">{{
syncHelp.text
}}</gl-link>
</gl-popover>
</div>
</template>
<script>
import { GlPopover, GlLink, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import {
REPLICATION_STATUS_CLASS,
REPLICATION_STATUS_ICON,
REPLICATION_PAUSE_URL,
} from '../constants';
export default {
components: {
GlIcon,
GlPopover,
GlLink,
},
props: {
node: {
type: Object,
required: true,
},
},
computed: {
replicationStatusCssClass() {
return this.node.enabled
? REPLICATION_STATUS_CLASS.enabled
: REPLICATION_STATUS_CLASS.disabled;
},
nodeReplicationStatusIcon() {
return this.node.enabled ? REPLICATION_STATUS_ICON.enabled : REPLICATION_STATUS_ICON.disabled;
},
nodeReplicationStatusText() {
return this.node.enabled ? __('Replication enabled') : __('Replication paused');
},
},
REPLICATION_PAUSE_URL,
};
</script>
<template>
<div class="mt-2 detail-section-item">
<div class="gl-text-gray-500 node-detail-title">{{ s__('GeoNodes|Replication status') }}</div>
<div class="gl-display-flex gl-align-items-center">
<div
:class="replicationStatusCssClass"
class="rounded-pill gl-display-inline-flex gl-align-items-center px-2 gl-py-2 gl-my-2"
>
<gl-icon :name="nodeReplicationStatusIcon" />
<strong class="status-text gl-ml-2"> {{ nodeReplicationStatusText }} </strong>
</div>
<gl-icon
ref="replicationStatusHelp"
tabindex="0"
name="question"
class="gl-text-blue-600 gl-ml-2 gl-cursor-pointer"
/>
<gl-popover :target="() => $refs.replicationStatusHelp.$el" placement="top">
<p>{{ __('Geo nodes are paused using a command run on the node') }}</p>
<gl-link
class="gl-mt-5 gl-font-sm"
:href="$options.REPLICATION_PAUSE_URL"
target="_blank"
>{{ __('More Information') }}</gl-link
>
</gl-popover>
</div>
</div>
</template>
<script>
import { GlPopover, GlSprintf, GlLink } from '@gitlab/ui';
import { toNumber } from 'lodash';
import StackedProgressBar from '~/vue_shared/components/stacked_progress_bar.vue';
export default {
name: 'GeoNodeSyncProgress',
components: {
GlPopover,
GlSprintf,
GlLink,
StackedProgressBar,
},
props: {
itemTitle: {
type: String,
required: true,
},
itemValue: {
type: Object,
required: true,
},
detailsPath: {
type: String,
required: false,
default: '',
},
},
computed: {
queuedCount() {
return this.totalCount - this.successCount - this.failureCount;
},
totalCount() {
return toNumber(this.itemValue.totalCount) || 0;
},
failureCount() {
return toNumber(this.itemValue.failureCount) || 0;
},
successCount() {
return toNumber(this.itemValue.successCount) || 0;
},
},
};
</script>
<template>
<div>
<stacked-progress-bar
:id="`syncProgress-${itemTitle}`"
tabindex="0"
:hide-tooltips="true"
:unavailable-label="__('Nothing to synchronize')"
:success-count="successCount"
:failure-count="failureCount"
:total-count="totalCount"
/>
<gl-popover :target="`syncProgress-${itemTitle}`" placement="right" :css-classes="['w-100']">
<template #title>
<gl-sprintf :message="__('Number of %{itemTitle}')">
<template #itemTitle>
{{ itemTitle }}
</template>
</gl-sprintf>
</template>
<section>
<div class="d-flex align-items-center my-1">
<div class="mr-2 bg-transparent gl-w-5 gl-h-2"></div>
<span class="flex-grow-1 mr-3">{{ __('Total') }}</span>
<span class="font-weight-bold">{{ totalCount.toLocaleString() }}</span>
</div>
<div class="d-flex align-items-center my-2">
<div class="mr-2 bg-success-500 gl-w-5 gl-h-2"></div>
<span class="flex-grow-1 mr-3">{{ __('Synced') }}</span>
<span class="font-weight-bold">{{ successCount.toLocaleString() }}</span>
</div>
<div class="d-flex align-items-center my-2">
<div class="mr-2 bg-secondary-200 gl-w-5 gl-h-2"></div>
<span class="flex-grow-1 mr-3">{{ __('Queued') }}</span>
<span class="font-weight-bold">{{ queuedCount.toLocaleString() }}</span>
</div>
<div class="d-flex align-items-center my-2">
<div class="mr-2 bg-danger-500 gl-w-5 gl-h-2"></div>
<span class="flex-grow-1 mr-3">{{ __('Failed') }}</span>
<span class="font-weight-bold">{{ failureCount.toLocaleString() }}</span>
</div>
<div v-if="detailsPath" class="mt-3">
<gl-link class="gl-font-sm" :href="detailsPath" target="_blank">{{
__('More information')
}}</gl-link>
</div>
</section>
</gl-popover>
</div>
</template>
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import { sprintf, s__, __ } from '~/locale';
import { TIME_DIFF } from '../constants';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
},
props: {
syncStatusUnavailable: {
type: Boolean,
required: false,
default: false,
},
selectiveSyncType: {
type: String,
required: false,
default: null,
},
lastEvent: {
type: Object,
required: true,
},
cursorLastEvent: {
type: Object,
required: true,
},
},
computed: {
syncType() {
if (this.selectiveSyncType === null || this.selectiveSyncType === '') {
return s__('GeoNodes|Full');
}
// Renaming namespaces to groups in the UI for Geo Selective Sync
const syncLabel =
this.selectiveSyncType === 'namespaces' ? __('groups') : this.selectiveSyncType;
return sprintf(s__('GeoNodes|Selective (%{syncLabel})'), { syncLabel });
},
eventTimestampEmpty() {
return this.lastEvent.timeStamp === 0 || this.cursorLastEvent.timeStamp === 0;
},
syncLagInSeconds() {
return this.lagInSeconds(this.lastEvent.timeStamp, this.cursorLastEvent.timeStamp);
},
syncStatusIcon() {
return this.statusIcon(this.syncLagInSeconds);
},
syncStatusEventInfo() {
return this.statusEventInfo(
this.lastEvent.id,
this.cursorLastEvent.id,
this.syncLagInSeconds,
);
},
syncStatusTooltip() {
return this.statusTooltip(this.syncLagInSeconds);
},
},
methods: {
lagInSeconds(lastEventTimeStamp, cursorLastEventTimeStamp) {
let eventDateTime;
let cursorDateTime;
if (lastEventTimeStamp && lastEventTimeStamp > 0) {
eventDateTime = new Date(lastEventTimeStamp * 1000);
}
if (cursorLastEventTimeStamp && cursorLastEventTimeStamp > 0) {
cursorDateTime = new Date(cursorLastEventTimeStamp * 1000);
}
return (cursorDateTime - eventDateTime) / 1000;
},
statusIcon(syncLag) {
if (syncLag <= TIME_DIFF.FIVE_MINS) {
return 'retry';
} else if (syncLag > TIME_DIFF.FIVE_MINS && syncLag <= TIME_DIFF.HOUR) {
return 'warning';
}
return 'status_failed';
},
statusEventInfo(lastEventId, cursorLastEventId, lagInSeconds) {
const timeAgoStr = timeIntervalInWords(lagInSeconds);
const pendingEvents = lastEventId - cursorLastEventId;
return sprintf(s__('GeoNodeStatusEvent|%{timeAgoStr} (%{pendingEvents} events)'), {
timeAgoStr,
pendingEvents,
});
},
statusTooltip(lagInSeconds) {
if (this.eventTimestampEmpty || lagInSeconds <= TIME_DIFF.FIVE_MINS) {
return '';
} else if (lagInSeconds > TIME_DIFF.FIVE_MINS && lagInSeconds <= TIME_DIFF.HOUR) {
return s__(
'GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage.',
);
}
return s__('GeoNodeSyncStatus|Node is failing or broken.');
},
},
};
</script>
<template>
<div class="mt-1 node-sync-settings">
<strong v-if="syncStatusUnavailable"> {{ __('Unknown') }} </strong>
<span
v-else
v-gl-tooltip
:title="syncStatusTooltip"
class="d-inline-block gl-align-items-center"
>
<strong data-testid="syncType">{{ syncType }}</strong>
<gl-icon name="retry" class="ml-2" />
<span v-if="!eventTimestampEmpty" class="ml-2">
{{ syncStatusEventInfo }}
</span>
</span>
</div>
</template>
<script>
import geoNodeItem from './geo_node_item.vue';
export default {
components: {
geoNodeItem,
},
props: {
nodes: {
type: Array,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
nodeRemovalAllowed: {
type: Boolean,
required: true,
},
geoTroubleshootingHelpPath: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="card">
<geo-node-item
v-for="(node, index) in nodes"
:key="index"
:node="node"
:primary-node="node.primary"
:node-actions-allowed="nodeActionsAllowed"
:node-edit-allowed="nodeEditAllowed"
:node-removal-allowed="nodeRemovalAllowed"
:geo-troubleshooting-help-path="geoTroubleshootingHelpPath"
/>
</div>
</template>
<script>
import { GlLink, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import GeoNodeActions from '../geo_node_actions.vue';
import GeoNodeHealthStatus from '../geo_node_health_status.vue';
import GeoNodeReplicationStatus from '../geo_node_replication_status.vue';
export default {
components: {
GlLink,
GlIcon,
GeoNodeHealthStatus,
GeoNodeActions,
GeoNodeReplicationStatus,
},
props: {
node: {
type: Object,
required: true,
},
nodeDetails: {
type: Object,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
nodeRemovalAllowed: {
type: Boolean,
required: true,
},
versionMismatch: {
type: Boolean,
required: true,
},
},
computed: {
nodeVersion() {
if (this.nodeDetails.version == null && this.nodeDetails.revision == null) {
return __('Unknown');
}
return `${this.nodeDetails.version} (${this.nodeDetails.revision})`;
},
nodeHealthStatus() {
return this.nodeDetails.healthy ? this.nodeDetails.health : this.nodeDetails.healthStatus;
},
selectiveSyncronization() {
const { selectiveSyncType } = this.nodeDetails;
if (selectiveSyncType === 'shards') {
return sprintf(__('Shards (%{shards})'), {
shards: this.node.selectiveSyncShards.join(', '),
});
}
if (selectiveSyncType === 'namespaces') {
return sprintf(__('Groups (%{groups})'), {
groups: this.nodeDetails.namespaces.map((n) => n.full_path).join(', '),
});
}
return null;
},
},
};
</script>
<template>
<div class="row-fluid clearfix py-3 primary-section">
<div class="col-md-12">
<div class="gl-display-flex gl-flex-wrap gl-flex-direction-column gl-sm-flex-direction-row">
<div data-testid="nodeUrl" class="d-flex flex-column">
<span class="gl-text-gray-500">{{ s__('GeoNodes|Node URL') }}</span>
<gl-link
class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-text-decoration-underline gl-mt-1"
:href="node.url"
target="_blank"
>{{ node.url }} <gl-icon name="external-link" class="gl-ml-1"
/></gl-link>
</div>
<geo-node-actions
class="flex-grow-1"
:node="node"
:node-actions-allowed="nodeActionsAllowed"
:node-edit-allowed="nodeEditAllowed"
:node-removal-allowed="nodeRemovalAllowed"
:node-missing-oauth="nodeDetails.missingOAuthApplication"
/>
</div>
<div data-testid="nodeVersion" class="d-flex flex-column mt-2">
<span class="gl-text-gray-500">{{ s__('GeoNodes|GitLab version') }}</span>
<span :class="{ 'gl-text-red-500': versionMismatch }" class="gl-mt-1 gl-font-weight-bold">
{{ nodeVersion }}
</span>
</div>
<div v-if="selectiveSyncronization" class="d-flex flex-column mt-2">
<span class="text-secondary-700">{{ s__('GeoNodes|Selective synchronization') }}</span>
<span data-testid="selectiveSync" class="gl-mt-1 gl-font-weight-bold">
{{ selectiveSyncronization }}
</span>
</div>
<geo-node-health-status
:status="nodeHealthStatus"
:status-check-timestamp="nodeDetails.statusCheckTimestamp"
/>
<div v-if="!node.primary">
<geo-node-replication-status :node="node" />
</div>
</div>
</div>
</template>
<script>
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { s__, __ } from '~/locale';
import { VALUE_TYPE } from '../../constants';
import GeoNodeDetailItem from '../geo_node_detail_item.vue';
import SectionRevealButton from './section_reveal_button.vue';
export default {
valueType: VALUE_TYPE,
components: {
SectionRevealButton,
GeoNodeDetailItem,
},
props: {
node: {
type: Object,
required: true,
},
nodeDetails: {
type: Object,
required: true,
},
nodeTypePrimary: {
type: Boolean,
required: true,
},
},
data() {
return {
showSectionItems: false,
};
},
computed: {
nodeDetailItems() {
if (this.nodeTypePrimary) {
// Return primary node detail items
const primaryNodeDetailItems = [
{
itemTitle: s__('GeoNodes|Replication slots'),
itemValue: this.nodeDetails.replicationSlots,
itemValueType: VALUE_TYPE.GRAPH,
successLabel: s__('GeoNodes|Used slots'),
neutraLabel: s__('GeoNodes|Unused slots'),
},
];
if (this.nodeDetails.replicationSlots.totalCount) {
primaryNodeDetailItems.push({
itemTitle: s__('GeoNodes|Replication slot WAL'),
itemValue: numberToHumanSize(this.nodeDetails.replicationSlotWAL),
itemValueType: VALUE_TYPE.PLAIN,
cssClass: 'font-weight-bold',
});
}
if (this.node.internalUrl) {
primaryNodeDetailItems.push({
itemTitle: s__('GeoNodes|Internal URL'),
itemValue: this.node.internalUrl,
itemValueType: VALUE_TYPE.PLAIN,
cssClass: 'font-weight-bold',
});
}
return primaryNodeDetailItems;
}
// Return secondary node detail items
return [
{
itemTitle: s__('GeoNodes|Storage config'),
itemValue: this.storageShardsStatus,
itemValueType: VALUE_TYPE.PLAIN,
cssClass: this.storageShardsCssClass.join(' '),
},
];
},
storageShardsStatus() {
if (this.nodeDetails.storageShardsMatch == null) {
return __('Unknown');
}
return this.nodeDetails.storageShardsMatch
? __('OK')
: s__('GeoNodes|Does not match the primary storage configuration');
},
storageShardsCssClass() {
return ['font-weight-bold', { 'text-danger-500': !this.nodeDetails.storageShardsMatch }];
},
},
methods: {
handleSectionToggle(toggleState) {
this.showSectionItems = toggleState;
},
},
};
</script>
<template>
<div class="row-fluid clearfix py-3 border-top border-color-default other-section">
<div class="col-md-12">
<section-reveal-button
:button-title="__('Other information')"
@toggleButton="handleSectionToggle"
/>
</div>
<div v-if="showSectionItems" class="col-md-6 ml-2 mt-2 section-items-container">
<geo-node-detail-item
v-for="(nodeDetailItem, index) in nodeDetailItems"
:key="index"
:css-class="nodeDetailItem.cssClass"
:item-title="nodeDetailItem.itemTitle"
:item-value="nodeDetailItem.itemValue"
:item-value-type="nodeDetailItem.itemValueType"
/>
</div>
</div>
</template>
<script>
import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
import { s__, __ } from '~/locale';
import { VALUE_TYPE, CUSTOM_TYPE } from '../../constants';
import GeoNodeDetailItem from '../geo_node_detail_item.vue';
import SectionRevealButton from './section_reveal_button.vue';
export default {
components: {
SectionRevealButton,
GeoNodeDetailItem,
},
props: {
node: {
type: Object,
required: true,
},
nodeDetails: {
type: Object,
required: true,
},
},
data() {
return {
showSectionItems: false,
nodeDetailItems: [
{
itemTitle: s__('GeoNodes|Sync settings'),
itemValue: this.syncSettings(),
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.SYNC,
},
...this.nodeDetails.syncStatuses,
{
itemTitle: s__('GeoNodes|Data replication lag'),
itemValue: this.dbReplicationLag(),
itemValueType: VALUE_TYPE.PLAIN,
},
{
itemTitle: s__('GeoNodes|Last event ID seen from primary'),
itemValue: this.lastEventStatus(),
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.EVENT,
},
{
itemTitle: s__('GeoNodes|Last event ID processed by cursor'),
itemValue: this.cursorLastEventStatus(),
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.EVENT,
eventTypeLogStatus: true,
},
],
};
},
methods: {
syncSettings() {
return {
syncStatusUnavailable: this.nodeDetails.syncStatusUnavailable,
selectiveSyncType: this.nodeDetails.selectiveSyncType,
lastEvent: this.nodeDetails.lastEvent,
cursorLastEvent: this.nodeDetails.cursorLastEvent,
};
},
dbReplicationLag() {
// Replication lag can be nil if the secondary isn't actually streaming
if (this.nodeDetails.dbReplicationLag !== null && this.nodeDetails.dbReplicationLag >= 0) {
const parsedTime = parseSeconds(this.nodeDetails.dbReplicationLag, {
hoursPerDay: 24,
daysPerWeek: 7,
});
return stringifyTime(parsedTime);
}
return __('Unknown');
},
lastEventStatus() {
return {
eventId: this.nodeDetails.lastEvent.id,
eventTimeStamp: this.nodeDetails.lastEvent.timeStamp,
};
},
cursorLastEventStatus() {
return {
eventId: this.nodeDetails.cursorLastEvent.id,
eventTimeStamp: this.nodeDetails.cursorLastEvent.timeStamp,
};
},
handleSectionToggle(toggleState) {
this.showSectionItems = toggleState;
},
detailsPath(nodeDetailItem) {
if (!nodeDetailItem.secondaryView) {
return '';
}
// This is due to some legacy coding patterns on the GeoNodeStatus API.
// This will be fixed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/228718
if (nodeDetailItem.itemName === 'repositories') {
return `${this.node.url}admin/geo/replication/projects`;
} else if (nodeDetailItem.itemName === 'attachments') {
return `${this.node.url}admin/geo/replication/uploads`;
}
return `${this.node.url}admin/geo/replication/${nodeDetailItem.itemName}`;
},
},
};
</script>
<template>
<div class="row-fluid clearfix py-3 border-top border-color-default sync-section">
<div class="col-md-12">
<section-reveal-button
:button-title="__('Sync information')"
@toggleButton="handleSectionToggle"
/>
</div>
<div v-if="showSectionItems" class="col-md-6 ml-2 mt-2 section-items-container">
<geo-node-detail-item
v-for="(nodeDetailItem, index) in nodeDetailItems"
:key="index"
:css-class="nodeDetailItem.cssClass"
:item-enabled="nodeDetailItem.itemEnabled"
:item-title="nodeDetailItem.itemTitle"
:item-value="nodeDetailItem.itemValue"
:item-value-type="nodeDetailItem.itemValueType"
:custom-type="nodeDetailItem.customType"
:event-type-log-status="nodeDetailItem.eventTypeLogStatus"
:details-path="detailsPath(nodeDetailItem)"
/>
</div>
</div>
</template>
<script>
import { GlPopover, GlLink, GlIcon, GlSprintf } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import { HELP_INFO_URL } from '../../constants';
import GeoNodeDetailItem from '../geo_node_detail_item.vue';
import SectionRevealButton from './section_reveal_button.vue';
export default {
components: {
GlIcon,
GlPopover,
GlLink,
GlSprintf,
GeoNodeDetailItem,
SectionRevealButton,
},
props: {
nodeDetails: {
type: Object,
required: true,
},
nodeTypePrimary: {
type: Boolean,
required: true,
},
},
data() {
return {
showSectionItems: false,
};
},
computed: {
nodeDetailItems() {
return this.nodeTypePrimary
? this.nodeDetails.checksumStatuses
: this.nodeDetails.verificationStatuses;
},
nodeText() {
return this.nodeTypePrimary ? s__('GeoNodes|secondary nodes') : s__('GeoNodes|primary node');
},
},
methods: {
handleSectionToggle(toggleState) {
this.showSectionItems = toggleState;
},
itemValue(nodeDetailItem) {
return {
totalCount: this.nodeTypePrimary
? nodeDetailItem.itemValue.checksumTotalCount
: nodeDetailItem.itemValue.verificationTotalCount,
successCount: this.nodeTypePrimary
? nodeDetailItem.itemValue.checksumSuccessCount
: nodeDetailItem.itemValue.verificationSuccessCount,
failureCount: this.nodeTypePrimary
? nodeDetailItem.itemValue.checksumFailureCount
: nodeDetailItem.itemValue.verificationFailureCount,
};
},
itemTitle(nodeDetailItem) {
return this.nodeTypePrimary
? sprintf(s__('Geo|%{itemTitle} checksum progress'), {
itemTitle: nodeDetailItem.itemTitle,
})
: sprintf(s__('Geo|%{itemTitle} verification progress'), {
itemTitle: nodeDetailItem.itemTitle,
});
},
},
HELP_INFO_URL,
};
</script>
<template>
<div class="row-fluid clearfix py-3 border-top border-color-default verification-section">
<div class="col-md-12 d-flex align-items-center">
<section-reveal-button
:button-title="__('Verification information')"
@toggleButton="handleSectionToggle"
/>
<gl-icon
ref="verificationInfo"
tabindex="0"
name="question"
class="text-primary-600 ml-1 cursor-pointer"
/>
<gl-popover :target="() => $refs.verificationInfo.$el" placement="top">
<p>
<gl-sprintf
:message="
s__('GeoNodes|Replicated data is verified with the %{nodeText} using checksums')
"
>
<template #nodeText>
{{ nodeText }}
</template>
</gl-sprintf>
</p>
<gl-link class="mt-3" :href="$options.HELP_INFO_URL" target="_blank">{{
__('More information')
}}</gl-link>
</gl-popover>
</div>
<template v-if="showSectionItems">
<div class="col-md-6 ml-2 mt-2 section-items-container">
<geo-node-detail-item
v-for="(nodeDetailItem, index) in nodeDetailItems"
:key="index"
:item-title="itemTitle(nodeDetailItem)"
:item-value="itemValue(nodeDetailItem)"
/>
</div>
</template>
</div>
</template>
<script>
import { GlIcon } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
props: {
buttonTitle: {
type: String,
required: true,
},
},
data() {
return {
toggleState: false,
};
},
computed: {
toggleButtonIcon() {
return this.toggleState ? 'angle-up' : 'angle-down';
},
},
methods: {
onClickButton() {
this.toggleState = !this.toggleState;
this.$emit('toggleButton', this.toggleState);
},
},
};
</script>
<template>
<button class="btn-link d-flex align-items-center" type="button" @click="onClickButton">
<gl-icon :size="16" :name="toggleButtonIcon" />
<span class="gl-ml-3">{{ buttonTitle }}</span>
</button>
</template>
export const NODE_ACTIONS = {
TOGGLE: 'toggle',
REMOVE: 'remove',
};
export const VALUE_TYPE = {
PLAIN: 'plain',
GRAPH: 'graph',
CUSTOM: 'custom',
};
export const CUSTOM_TYPE = {
SYNC: 'sync',
EVENT: 'event',
STATUS: 'status',
};
export const HEALTH_STATUS_ICON = {
healthy: 'status_success',
unhealthy: 'status_failed',
disabled: 'status_canceled',
unknown: 'status_notfound',
offline: 'status_canceled',
};
export const HEALTH_STATUS_CLASS = {
healthy: 'text-success-600 bg-success-100',
unhealthy: 'text-danger-600 bg-danger-100',
disabled: 'text-secondary-800 bg-secondary-100',
unknown: 'text-secondary-800 bg-secondary-100',
offline: 'text-secondary-800 bg-secondary-100',
};
export const REPLICATION_STATUS_CLASS = {
enabled: 'gl-text-green-600 gl-bg-green-100',
disabled: 'gl-text-orange-600 gl-bg-orange-100',
};
export const REPLICATION_STATUS_ICON = {
enabled: 'play',
disabled: 'pause',
};
export const TIME_DIFF = {
FIVE_MINS: 300,
HOUR: 3600,
};
export const STATUS_DELAY_THRESHOLD_MS = 600000;
export const HELP_INFO_URL =
'https://docs.gitlab.com/ee/administration/geo/disaster_recovery/background_verification.html#repository-verification';
export const REPLICATION_HELP_URL =
'https://docs.gitlab.com/ee/administration/geo/replication/datatypes.html#limitations-on-replicationverification';
export const REPLICATION_PAUSE_URL =
'https://docs.gitlab.com/ee/administration/geo/replication/#pausing-and-resuming-replication';
export const HELP_NODE_HEALTH_URL =
'https://docs.gitlab.com/ee/administration/geo/replication/troubleshooting.html#check-the-health-of-the-secondary-node';
export const GEO_TROUBLESHOOTING_URL =
'https://docs.gitlab.com/ee/administration/geo/replication/troubleshooting.html';
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import Translate from '~/vue_shared/translate';
import geoNodesApp from './components/app.vue';
import GeoNodesService from './service/geo_nodes_service';
import GeoNodesStore from './store/geo_nodes_store';
Vue.use(Translate);
Vue.use(GlToast);
export default () => {
const el = document.getElementById('js-geo-nodes');
if (!el) {
return false;
}
return new Vue({
el,
components: {
geoNodesApp,
},
data() {
const { dataset } = this.$options.el;
const { primaryVersion, primaryRevision, geoTroubleshootingHelpPath } = dataset;
const replicableTypes = convertObjectPropsToCamelCase(JSON.parse(dataset.replicableTypes), {
deep: true,
});
const nodeActionsAllowed = parseBoolean(dataset.nodeActionsAllowed);
const nodeEditAllowed = parseBoolean(dataset.nodeEditAllowed);
const store = new GeoNodesStore(primaryVersion, primaryRevision, replicableTypes);
const service = new GeoNodesService();
return {
store,
service,
nodeActionsAllowed,
nodeEditAllowed,
geoTroubleshootingHelpPath,
};
},
render(createElement) {
return createElement('geo-nodes-app', {
props: {
store: this.store,
service: this.service,
nodeActionsAllowed: this.nodeActionsAllowed,
nodeEditAllowed: this.nodeEditAllowed,
geoTroubleshootingHelpPath: this.geoTroubleshootingHelpPath,
},
});
},
});
};
import Api from 'ee/api';
import axios from '~/lib/utils/axios_utils';
export default class GeoNodesService {
constructor() {
this.geoNodesPath = Api.buildUrl(Api.geoNodesPath);
}
getGeoNodes() {
return axios.get(this.geoNodesPath);
}
// eslint-disable-next-line class-methods-use-this
getGeoNodeDetails(node) {
return axios.get(node.statusPath);
}
// 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);
}
}
import { isNil } from 'lodash';
export default class GeoNodesStore {
constructor(primaryVersion, primaryRevision, replicableTypes) {
this.state = {};
this.state.nodes = [];
this.state.nodeDetails = {};
this.state.primaryVersion = primaryVersion;
this.state.primaryRevision = primaryRevision;
this.state.replicableTypes = replicableTypes;
}
setNodes(nodes) {
this.state.nodes = nodes.map((node) => GeoNodesStore.formatNode(node));
}
getNodes() {
return this.state.nodes;
}
setNodeDetails(nodeId, nodeDetails) {
this.state.nodeDetails[nodeId] = GeoNodesStore.formatNodeDetails(
nodeDetails,
this.state.replicableTypes,
);
}
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() {
return {
version: this.state.primaryVersion,
revision: this.state.primaryRevision,
};
}
getNodeDetails(nodeId) {
return this.state.nodeDetails[nodeId];
}
static formatNode(rawNode) {
const { id, name, url, primary, current, enabled } = rawNode;
return {
id,
name,
url,
primary,
current,
enabled,
internalUrl: rawNode.internal_url || '',
nodeActionActive: false,
basePath: rawNode._links.self,
repairPath: rawNode._links.repair,
editPath: rawNode.web_edit_url,
geoProjectsUrl: rawNode.web_geo_projects_url,
statusPath: rawNode._links.status,
selectiveSyncShards: rawNode.selective_sync_shards,
};
}
static formatNodeDetails(rawNodeDetails, replicableTypes) {
const syncStatuses = replicableTypes.map((replicable) => {
return {
itemEnabled: rawNodeDetails[`${replicable.namePlural}_replication_enabled`],
itemTitle: replicable.titlePlural,
itemName: replicable.namePlural,
itemValue: {
totalCount: rawNodeDetails[`${replicable.namePlural}_count`],
successCount: rawNodeDetails[`${replicable.namePlural}_synced_count`],
failureCount: rawNodeDetails[`${replicable.namePlural}_failed_count`],
verificationTotalCount:
rawNodeDetails[`${replicable.namePlural}_verification_total_count`],
verificationSuccessCount: rawNodeDetails[`${replicable.namePlural}_verified_count`],
verificationFailureCount:
rawNodeDetails[`${replicable.namePlural}_verification_failed_count`],
checksumTotalCount: rawNodeDetails[`${replicable.namePlural}_checksum_total_count`],
checksumSuccessCount: rawNodeDetails[`${replicable.namePlural}_checksummed_count`],
checksumFailureCount: rawNodeDetails[`${replicable.namePlural}_checksum_failed_count`],
},
...replicable,
};
});
// Adds replicable to array as long as value is defined
const verificationStatuses = syncStatuses.filter((s) =>
Boolean(
!isNil(s.itemValue.verificationSuccessCount) ||
!isNil(s.itemValue.verificationFailureCount),
),
);
// Adds replicable to array as long as value is defined
const checksumStatuses = syncStatuses.filter((s) =>
Boolean(!isNil(s.itemValue.checksumSuccessCount) || !isNil(s.itemValue.checksumFailureCount)),
);
return {
id: rawNodeDetails.geo_node_id,
health: rawNodeDetails.health,
healthy: rawNodeDetails.healthy,
healthStatus: rawNodeDetails.health_status,
version: rawNodeDetails.version,
revision: rawNodeDetails.revision,
primaryVersion: rawNodeDetails.primaryVersion,
primaryRevision: rawNodeDetails.primaryRevision,
statusCheckTimestamp: rawNodeDetails.last_successful_status_check_timestamp * 1000,
replicationSlotWAL: rawNodeDetails.replication_slots_max_retained_wal_bytes,
missingOAuthApplication: rawNodeDetails.missing_oauth_application || false,
syncStatusUnavailable: rawNodeDetails.sync_status_unavailable || false,
storageShardsMatch: rawNodeDetails.storage_shards_match,
repositoryVerificationEnabled: rawNodeDetails.repository_verification_enabled,
replicationSlots: {
totalCount: rawNodeDetails.replication_slots_count || 0,
successCount: rawNodeDetails.replication_slots_used_count || 0,
failureCount: 0,
},
syncStatuses,
verificationStatuses,
checksumStatuses,
lastEvent: {
id: rawNodeDetails.last_event_id || 0,
timeStamp: rawNodeDetails.last_event_timestamp,
},
cursorLastEvent: {
id: rawNodeDetails.cursor_last_event_id || 0,
timeStamp: rawNodeDetails.cursor_last_event_timestamp,
},
selectiveSyncType: rawNodeDetails.selective_sync_type,
namespaces: rawNodeDetails.namespaces,
dbReplicationLag: rawNodeDetails.db_replication_lag_seconds,
};
}
}
......@@ -93,6 +93,7 @@ export default {
variant="confirm"
:href="newNodeUrl"
target="_blank"
data-qa-selector="add_site_button"
>{{ $options.i18n.addSite }}
</gl-button>
</div>
......
import initGeoNodes from 'ee/geo_nodes';
import { initGeoNodesBeta } from 'ee/geo_nodes_beta';
import PersistentUserCallout from '~/persistent_user_callout';
if (gon.features?.geoNodesBeta) {
initGeoNodesBeta();
} else {
initGeoNodes();
const callout = document.querySelector('.user-callout');
PersistentUserCallout.factory(callout);
}
initGeoNodesBeta();
......@@ -3,18 +3,6 @@
class Admin::Geo::NodesController < Admin::Geo::ApplicationController
before_action :check_license!, except: :index
before_action :load_node, only: [:edit, :update]
before_action only: [:index] do
push_frontend_feature_flag(:geo_nodes_beta)
end
# rubocop: disable CodeReuse/ActiveRecord
def index
if Feature.disabled?(:geo_nodes_beta)
@nodes = GeoNode.all.order(:id)
@node = GeoNode.new
end
end
# rubocop: enable CodeReuse/ActiveRecord
def create
@node = ::Geo::NodeCreateService.new(geo_node_params).execute
......
......@@ -29,9 +29,6 @@ module EE
{
primary_version: version.to_s,
primary_revision: revision.to_s,
node_actions_allowed: ::Gitlab::Database.db_read_write?.to_s,
node_edit_allowed: ::Gitlab::Geo.license_allows?.to_s,
geo_troubleshooting_help_path: help_page_path('administration/geo/replication/troubleshooting.md'),
replicable_types: replicable_types.to_json,
new_node_url: new_admin_geo_node_path,
geo_nodes_empty_state_svg: image_path("illustrations/empty-state/geo-empty.svg")
......
......@@ -4,19 +4,4 @@
= render_migrate_hashed_storage_warning
= render partial: 'admin/geo/shared/license_alert'
- if Feature.enabled?(:geo_nodes_beta)
#js-geo-nodes-beta{ data: node_vue_list_properties }
- else
.d-flex.align-items-center.border-bottom.border-default.mb-4
%h3{ :class => "page-title" }
= _("Geo Nodes")
- if @nodes.any?
= link_to s_("GeoNodes|New node"), new_admin_geo_node_path, class: 'gl-button btn btn-confirm ml-auto qa-new-node-link'
%p.page-subtitle.light
= s_('GeoNodes|With %{geo} you can install a special read-only and replicated instance anywhere. Before you add nodes, follow the %{instructions} in the exact order they appear.').html_safe % { geo: link_to('GitLab Geo', help_page_path('administration/geo/index.md'), target: '_blank'), instructions: link_to('setup instructions', help_page_path('administration/geo/setup/index.md'), target: '_blank') }
- if @nodes.any?
#js-geo-nodes{ data: node_vue_list_properties }
- else
= render 'shared/empty_states/geo'
#js-geo-nodes-beta{ data: node_vue_list_properties }
---
name: geo_nodes_beta
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50799
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/296986
milestone: '13.8'
type: development
group: group::geo
default_enabled: false
......@@ -65,37 +65,6 @@ RSpec.describe Admin::Geo::NodesController do
expect(response).not_to redirect_to(:forbidden)
end
end
context 'without :geo_nodes_beta feature flag' do
before do
stub_feature_flags(geo_nodes_beta: false)
go
end
it 'sets @nodes and @node variables' do
expect(subject.instance_variable_get(:@nodes)).to eq(GeoNode.all.order(:id))
expect(subject.instance_variable_get(:@node)).to be_an_instance_of(GeoNode)
end
end
context 'with :geo_nodes_beta feature flag' do
before do
stub_feature_flags(geo_nodes_beta: true)
go
end
it 'does not set @nodes and @node variables' do
expect(subject.instance_variable_get(:@nodes)).to be_nil
expect(subject.instance_variable_get(:@node)).to be_nil
end
end
it 'pushes :geo_nodes_beta feature flag to the frontend' do
allow(subject).to receive(:push_frontend_feature_flag).and_call_original
expect(subject).to receive(:push_frontend_feature_flag).with(:geo_nodes_beta)
go
end
end
describe '#create' do
......
......@@ -33,33 +33,15 @@ RSpec.describe 'admin Geo Nodes', :js, :geo do
end
describe 'index' do
context 'without :geo_nodes_beta FF' do
before do
stub_feature_flags(geo_nodes_beta: false)
visit admin_geo_nodes_path
wait_for_requests
end
it 'shows all public Geo Nodes and create new node link' do
expect(page).to have_link('New node', href: new_admin_geo_node_path)
page.within(find('.card', match: :first)) do
expect(page).to have_content(geo_node.url)
end
end
before do
visit admin_geo_nodes_path
wait_for_requests
end
context 'with :geo_nodes_beta FF' do
before do
stub_feature_flags(geo_nodes_beta: true)
visit admin_geo_nodes_path
wait_for_requests
end
it 'shows all public Geo Nodes and create new node link' do
expect(page).to have_link('Add site', href: new_admin_geo_node_path)
page.within(find('.geo-node-core-details-grid-columns', match: :first)) do
expect(page).to have_content(geo_node.url)
end
it 'shows all public Geo Nodes and Add site link' do
expect(page).to have_link('Add site', href: new_admin_geo_node_path)
page.within(find('.geo-node-core-details-grid-columns', match: :first)) do
expect(page).to have_content(geo_node.url)
end
end
......
......@@ -47,8 +47,6 @@ RSpec.describe 'GEO Nodes', :geo do
describe 'Geo Nodes admin screen' do
it "has a 'Replication details' button on listed secondary geo nodes pointing to correct URL", :js do
# TODO: Remove this spec when geo_nodes_beta is removed as this UI element is removed in new UI.
stub_feature_flags(geo_nodes_beta: false)
visit admin_geo_nodes_path
expect(page).to have_content(geo_primary.url)
......@@ -56,10 +54,9 @@ RSpec.describe 'GEO Nodes', :geo do
wait_for_requests
geo_node_actions = all('[data-testid="nodeActions"]')
expected_url = File.join(geo_secondary.url, '/admin/geo/projects')
expect(geo_node_actions.last).to have_link('Replication details', href: expected_url)
expect(all('.geo-node-details-grid-columns').last).to have_link('Replication details', href: expected_url)
end
end
end
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GeoNodeHealthStatusComponent computed properties renders Icon correctly 1`] = `"<gl-icon-stub name=\\"status_success\\" size=\\"16\\"></gl-icon-stub>"`;
exports[`GeoNodeHealthStatusComponent computed properties renders Icon correctly 2`] = `"<gl-icon-stub name=\\"status_failed\\" size=\\"16\\"></gl-icon-stub>"`;
exports[`GeoNodeHealthStatusComponent computed properties renders Icon correctly 3`] = `"<gl-icon-stub name=\\"status_canceled\\" size=\\"16\\"></gl-icon-stub>"`;
exports[`GeoNodeHealthStatusComponent computed properties renders Icon correctly 4`] = `"<gl-icon-stub name=\\"status_notfound\\" size=\\"16\\"></gl-icon-stub>"`;
exports[`GeoNodeHealthStatusComponent computed properties renders Icon correctly 5`] = `"<gl-icon-stub name=\\"status_canceled\\" size=\\"16\\"></gl-icon-stub>"`;
exports[`GeoNodeHealthStatusComponent computed properties renders StatusPill correctly 1`] = `
"<div class=\\"rounded-pill d-inline-flex align-items-center px-2 py-1 my-1 mr-2 text-success-600 bg-success-100\\">
<gl-icon-stub name=\\"status_success\\" size=\\"16\\"></gl-icon-stub> <strong class=\\"status-text ml-1\\"> Healthy </strong>
</div>"
`;
exports[`GeoNodeHealthStatusComponent computed properties renders StatusPill correctly 2`] = `
"<div class=\\"rounded-pill d-inline-flex align-items-center px-2 py-1 my-1 mr-2 text-danger-600 bg-danger-100\\">
<gl-icon-stub name=\\"status_failed\\" size=\\"16\\"></gl-icon-stub> <strong class=\\"status-text ml-1\\"> Unhealthy </strong>
</div>"
`;
exports[`GeoNodeHealthStatusComponent computed properties renders StatusPill correctly 3`] = `
"<div class=\\"rounded-pill d-inline-flex align-items-center px-2 py-1 my-1 mr-2 text-secondary-800 bg-secondary-100\\">
<gl-icon-stub name=\\"status_canceled\\" size=\\"16\\"></gl-icon-stub> <strong class=\\"status-text ml-1\\"> Disabled </strong>
</div>"
`;
exports[`GeoNodeHealthStatusComponent computed properties renders StatusPill correctly 4`] = `
"<div class=\\"rounded-pill d-inline-flex align-items-center px-2 py-1 my-1 mr-2 text-secondary-800 bg-secondary-100\\">
<gl-icon-stub name=\\"status_notfound\\" size=\\"16\\"></gl-icon-stub> <strong class=\\"status-text ml-1\\"> Unknown </strong>
</div>"
`;
exports[`GeoNodeHealthStatusComponent computed properties renders StatusPill correctly 5`] = `
"<div class=\\"rounded-pill d-inline-flex align-items-center px-2 py-1 my-1 mr-2 text-secondary-800 bg-secondary-100\\">
<gl-icon-stub name=\\"status_canceled\\" size=\\"16\\"></gl-icon-stub> <strong class=\\"status-text ml-1\\"> Offline </strong>
</div>"
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GeoNodeReplicationStatusComponent computed properties renders Icon correctly 1`] = `"<gl-icon-stub name=\\"play\\" size=\\"16\\"></gl-icon-stub>"`;
exports[`GeoNodeReplicationStatusComponent computed properties renders Icon correctly 2`] = `"<gl-icon-stub name=\\"pause\\" size=\\"16\\"></gl-icon-stub>"`;
exports[`GeoNodeReplicationStatusComponent computed properties renders StatusPill correctly 1`] = `
"<div class=\\"rounded-pill gl-display-inline-flex gl-align-items-center px-2 gl-py-2 gl-my-2 gl-text-green-600 gl-bg-green-100\\">
<gl-icon-stub name=\\"play\\" size=\\"16\\"></gl-icon-stub> <strong class=\\"status-text gl-ml-2\\"> Replication enabled </strong>
</div>"
`;
exports[`GeoNodeReplicationStatusComponent computed properties renders StatusPill correctly 2`] = `
"<div class=\\"rounded-pill gl-display-inline-flex gl-align-items-center px-2 gl-py-2 gl-my-2 gl-text-orange-600 gl-bg-orange-100\\">
<gl-icon-stub name=\\"pause\\" size=\\"16\\"></gl-icon-stub> <strong class=\\"status-text gl-ml-2\\"> Replication paused </strong>
</div>"
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GeoNodeSyncProgress template GlPopover renders each row of popover correctly 1`] = `
<div
class="d-flex align-items-center my-1"
>
<div
class="mr-2 bg-transparent gl-w-5 gl-h-2"
/>
<span
class="flex-grow-1 mr-3"
>
Total
</span>
<span
class="font-weight-bold"
>
10
</span>
</div>
`;
exports[`GeoNodeSyncProgress template GlPopover renders each row of popover correctly 2`] = `
<div
class="mr-2 bg-transparent gl-w-5 gl-h-2"
/>
`;
exports[`GeoNodeSyncProgress template GlPopover renders each row of popover correctly 3`] = `
<div
class="d-flex align-items-center my-2"
>
<div
class="mr-2 bg-success-500 gl-w-5 gl-h-2"
/>
<span
class="flex-grow-1 mr-3"
>
Synced
</span>
<span
class="font-weight-bold"
>
5
</span>
</div>
`;
exports[`GeoNodeSyncProgress template GlPopover renders each row of popover correctly 4`] = `
<div
class="mr-2 bg-success-500 gl-w-5 gl-h-2"
/>
`;
exports[`GeoNodeSyncProgress template GlPopover renders each row of popover correctly 5`] = `
<div
class="d-flex align-items-center my-2"
>
<div
class="mr-2 bg-secondary-200 gl-w-5 gl-h-2"
/>
<span
class="flex-grow-1 mr-3"
>
Queued
</span>
<span
class="font-weight-bold"
>
2
</span>
</div>
`;
exports[`GeoNodeSyncProgress template GlPopover renders each row of popover correctly 6`] = `
<div
class="mr-2 bg-secondary-200 gl-w-5 gl-h-2"
/>
`;
exports[`GeoNodeSyncProgress template GlPopover renders each row of popover correctly 7`] = `
<div
class="d-flex align-items-center my-2"
>
<div
class="mr-2 bg-danger-500 gl-w-5 gl-h-2"
/>
<span
class="flex-grow-1 mr-3"
>
Failed
</span>
<span
class="font-weight-bold"
>
3
</span>
</div>
`;
exports[`GeoNodeSyncProgress template GlPopover renders each row of popover correctly 8`] = `
<div
class="mr-2 bg-danger-500 gl-w-5 gl-h-2"
/>
`;
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import appComponent from 'ee/geo_nodes/components/app.vue';
import { NODE_ACTIONS } from 'ee/geo_nodes/constants';
import eventHub from 'ee/geo_nodes/event_hub';
import GeoNodesService from 'ee/geo_nodes/service/geo_nodes_service';
import GeoNodesStore from 'ee/geo_nodes/store/geo_nodes_store';
import mountComponent from 'helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import '~/vue_shared/plugins/global_toast';
import {
PRIMARY_VERSION,
NODE_DETAILS_PATH,
mockNodes,
mockNode,
rawMockNodeDetails,
MOCK_REPLICABLE_TYPES,
} from '../mock_data';
jest.mock('~/smart_interval');
jest.mock('ee/geo_nodes/event_hub');
const createComponent = () => {
const Component = Vue.extend(appComponent);
const store = new GeoNodesStore(
PRIMARY_VERSION.version,
PRIMARY_VERSION.revision,
MOCK_REPLICABLE_TYPES,
);
const service = new GeoNodesService(NODE_DETAILS_PATH);
return mountComponent(Component, {
store,
service,
nodeActionsAllowed: true,
nodeEditAllowed: true,
geoTroubleshootingHelpPath: '/foo/bar',
});
};
const getToastMessage = () => document.querySelector('.gl-toast').innerText.trim();
const cleanupToastMessage = () => document.querySelector('.gl-toast').remove();
describe('AppComponent', () => {
let vm;
let mock;
let statusCode;
let response;
beforeEach(() => {
statusCode = 200;
response = mockNodes;
mock = new MockAdapter(axios);
document.body.innerHTML += '<div class="flash-container"></div>';
mock.onGet(/(.*)\/geo_nodes$/).reply(() => [statusCode, response]);
vm = createComponent();
});
afterEach(() => {
document.querySelector('.flash-container').remove();
vm.$destroy();
mock.restore();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.isLoading).toBe(true);
expect(vm.hasError).toBe(false);
expect(vm.targetNode).toBeNull();
expect(vm.targetNodeActionType).toBe('');
expect(vm.modalKind).toBe('warning');
expect(vm.modalMessage).toBe('');
expect(vm.modalActionLabel).toBe('');
expect(vm.modalTitle).toBe('');
expect(vm.modalId).toBe('node-action');
});
});
describe('computed', () => {
describe('nodes', () => {
it('returns list of nodes from store', () => {
expect(Array.isArray(vm.nodes)).toBeTruthy();
});
});
});
describe('methods', () => {
describe('setNodeActionStatus', () => {
it('sets `nodeActionActive` property with value of `status` parameter for provided `node` parameter', () => {
const node = {
nodeActionActive: false,
};
vm.setNodeActionStatus(node, true);
expect(node.nodeActionActive).toBe(true);
});
});
describe('initNodeDetailsPolling', () => {
it('initializes SmartInterval and sets it to component', () => {
vm.initNodeDetailsPolling(2);
expect(vm.nodePollingInterval).toBeDefined();
});
});
describe('fetchGeoNodes', () => {
it('calls service.getGeoNodes and sets response to the store on success', (done) => {
jest.spyOn(vm.store, 'setNodes');
vm.fetchGeoNodes()
.then(() => {
expect(vm.store.setNodes).toHaveBeenCalledWith(mockNodes);
expect(vm.isLoading).toBe(false);
})
.then(done)
.catch(done.fail);
});
it('sets error flag and message on failure', (done) => {
response = 'Something went wrong';
statusCode = 500;
vm.fetchGeoNodes()
.then(() => {
expect(vm.isLoading).toBe(false);
expect(document.querySelector('.flash-text').innerText.trim()).toBe(
'Something went wrong while fetching nodes',
);
})
.then(done)
.catch(done.fail);
});
});
describe('fetchNodeDetails', () => {
it('calls service.getGeoNodeDetails and sets response to the store on success', (done) => {
mock.onGet(mockNode.statusPath).reply(200, rawMockNodeDetails);
vm.fetchNodeDetails(mockNode)
.then(() => {
expect(Object.keys(vm.store.state.nodeDetails).length).not.toBe(0);
expect(vm.store.state.nodeDetails['1']).toBeDefined();
})
.then(done)
.catch(done.fail);
});
it('emits `nodeDetailsLoaded` event with fake nodeDetails object on 404 failure', (done) => {
mock.onGet(mockNode.statusPath).reply(404, {});
jest.spyOn(vm.service, 'getGeoNodeDetails');
vm.fetchNodeDetails(mockNode)
.then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('nodeDetailsLoaded', expect.any(Object));
const nodeDetails = vm.store.state.nodeDetails['1'];
expect(nodeDetails).toBeDefined();
expect(nodeDetails.syncStatusUnavailable).toBe(true);
expect(nodeDetails.health).toBe('Request failed with status code 404');
})
.then(done)
.catch(done.fail);
});
it('emits `nodeDetailsLoaded` event with fake nodeDetails object when a network error occurs', (done) => {
mock.onGet(mockNode.statusPath).networkError();
jest.spyOn(vm.service, 'getGeoNodeDetails');
vm.fetchNodeDetails(mockNode)
.then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('nodeDetailsLoaded', expect.any(Object));
const nodeDetails = vm.store.state.nodeDetails['1'];
expect(nodeDetails).toBeDefined();
expect(nodeDetails.syncStatusUnavailable).toBe(true);
expect(nodeDetails.health).toBe('Network Error');
})
.then(done)
.catch(done.fail);
});
it('emits `nodeDetailsLoaded` event with fake nodeDetails object when a timeout occurs', (done) => {
mock.onGet(mockNode.statusPath).timeout();
jest.spyOn(vm.service, 'getGeoNodeDetails');
vm.fetchNodeDetails(mockNode)
.then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('nodeDetailsLoaded', expect.any(Object));
const nodeDetails = vm.store.state.nodeDetails['1'];
expect(nodeDetails).toBeDefined();
expect(nodeDetails.syncStatusUnavailable).toBe(true);
expect(nodeDetails.health).toBe('timeout of 0ms exceeded');
})
.then(done)
.catch(done.fail);
});
});
describe('repairNode', () => {
it('calls service.repairNode and shows success Toast message on request success', (done) => {
const node = { ...mockNode };
mock.onPost(node.repairPath).reply(() => {
expect(node.nodeActionActive).toBe(true);
return [200];
});
jest.spyOn(vm.service, 'repairNode');
vm.repairNode(node)
.then(() => {
expect(vm.service.repairNode).toHaveBeenCalledWith(node);
expect(getToastMessage()).toBe('Node Authentication was successfully repaired.');
cleanupToastMessage();
expect(node.nodeActionActive).toBe(false);
})
.then(done)
.catch(done.fail);
});
it('calls service.repairNode and shows failure Flash message on request failure', (done) => {
const node = { ...mockNode };
mock.onPost(node.repairPath).reply(() => {
expect(node.nodeActionActive).toBe(true);
return [500];
});
jest.spyOn(vm.service, 'repairNode');
vm.repairNode(node)
.then(() => {
expect(vm.service.repairNode).toHaveBeenCalledWith(node);
expect(document.querySelector('.flash-text').innerText.trim()).toBe(
'Something went wrong while repairing node',
);
expect(node.nodeActionActive).toBe(false);
})
.then(done)
.catch(done.fail);
});
});
describe('toggleNode', () => {
it('calls service.toggleNode for enabling node and updates toggle button on request success', (done) => {
const node = { ...mockNode };
mock.onPut(node.basePath).reply(() => {
expect(node.nodeActionActive).toBe(true);
return [
200,
{
enabled: true,
},
];
});
jest.spyOn(vm.service, 'toggleNode');
node.enabled = false;
vm.toggleNode(node)
.then(() => {
expect(vm.service.toggleNode).toHaveBeenCalledWith(node);
expect(node.enabled).toBe(true);
expect(node.nodeActionActive).toBe(false);
})
.then(done)
.catch(done.fail);
});
it('calls service.toggleNode and shows Flash error on request failure', (done) => {
const node = { ...mockNode };
mock.onPut(node.basePath).reply(() => {
expect(node.nodeActionActive).toBe(true);
return [500];
});
jest.spyOn(vm.service, 'toggleNode');
node.enabled = false;
vm.toggleNode(node)
.then(() => {
expect(vm.service.toggleNode).toHaveBeenCalledWith(node);
expect(document.querySelector('.flash-text').innerText.trim()).toBe(
'Something went wrong while changing node status',
);
expect(node.nodeActionActive).toBe(false);
})
.then(done)
.catch(done.fail);
});
});
describe('removeNode', () => {
it('calls service.removeNode for removing node and shows Toast message on request success', (done) => {
const node = { ...mockNode };
mock.onDelete(node.basePath).reply(() => {
expect(node.nodeActionActive).toBe(true);
return [200];
});
jest.spyOn(vm.service, 'removeNode');
jest.spyOn(vm.store, 'removeNode');
vm.removeNode(node)
.then(() => {
expect(vm.service.removeNode).toHaveBeenCalledWith(node);
expect(vm.store.removeNode).toHaveBeenCalledWith(node);
expect(getToastMessage()).toBe('Node was successfully removed.');
cleanupToastMessage();
})
.then(done)
.catch(done.fail);
});
it('calls service.removeNode and shows Flash message on request failure', (done) => {
const node = { ...mockNode };
mock.onDelete(node.basePath).reply(() => {
expect(node.nodeActionActive).toBe(true);
return [500];
});
jest.spyOn(vm.service, 'removeNode');
jest.spyOn(vm.store, 'removeNode');
vm.removeNode(node)
.then(() => {
expect(vm.service.removeNode).toHaveBeenCalledWith(node);
expect(vm.store.removeNode).not.toHaveBeenCalled();
expect(document.querySelector('.flash-text').innerText.trim()).toBe(
'Something went wrong while removing node',
);
})
.then(done)
.catch(done.fail);
});
});
describe('handleNodeAction', () => {
it('calls `toggleNode` and `hideNodeActionModal` when `targetNodeActionType` is `toggle`', () => {
vm.targetNode = { ...mockNode };
vm.targetNodeActionType = NODE_ACTIONS.TOGGLE;
jest.spyOn(vm, 'hideNodeActionModal');
jest.spyOn(vm, 'toggleNode');
vm.handleNodeAction();
expect(vm.hideNodeActionModal).toHaveBeenCalled();
expect(vm.toggleNode).toHaveBeenCalledWith(vm.targetNode);
});
it('calls `removeNode` and `hideNodeActionModal` when `targetNodeActionType` is `remove`', () => {
vm.targetNode = { ...mockNode };
vm.targetNodeActionType = NODE_ACTIONS.REMOVE;
jest.spyOn(vm, 'hideNodeActionModal');
jest.spyOn(vm, 'removeNode');
vm.handleNodeAction();
expect(vm.hideNodeActionModal).toHaveBeenCalled();
expect(vm.removeNode).toHaveBeenCalledWith(vm.targetNode);
});
});
describe('showNodeActionModal', () => {
let node;
let modalKind;
let modalMessage;
let modalActionLabel;
let modalTitle;
let rootEmit;
beforeEach(() => {
node = { ...mockNode };
modalKind = 'warning';
modalMessage = 'Foobar message';
modalActionLabel = 'Disable';
modalTitle = 'Test title';
rootEmit = jest.spyOn(vm.$root, '$emit');
});
it('sets target node and modal config props on component', () => {
vm.showNodeActionModal({
actionType: NODE_ACTIONS.TOGGLE,
node,
modalKind,
modalMessage,
modalActionLabel,
modalTitle,
});
expect(vm.targetNode).toBe(node);
expect(vm.targetNodeActionType).toBe(NODE_ACTIONS.TOGGLE);
expect(vm.modalKind).toBe(modalKind);
expect(vm.modalMessage).toBe(modalMessage);
expect(vm.modalActionLabel).toBe(modalActionLabel);
expect(vm.modalTitle).toBe(modalTitle);
});
it(`emits ${BV_SHOW_MODAL} when actionType is "toggle" and node is enabled`, () => {
node.enabled = true;
vm.showNodeActionModal({
actionType: NODE_ACTIONS.TOGGLE,
node,
modalKind,
modalMessage,
modalActionLabel,
modalTitle,
});
expect(rootEmit).toHaveBeenCalledWith(BV_SHOW_MODAL, vm.modalId);
});
it('calls toggleNode when actionType is `toggle` and node.enabled is `false`', () => {
node.enabled = false;
jest.spyOn(vm, 'toggleNode');
vm.showNodeActionModal({
actionType: NODE_ACTIONS.TOGGLE,
node,
modalKind,
modalMessage,
modalActionLabel,
modalTitle,
});
expect(vm.toggleNode).toHaveBeenCalledWith(vm.targetNode);
});
it(`emits ${BV_SHOW_MODAL} when actionType is not "toggle"`, () => {
node.enabled = true;
vm.showNodeActionModal({
actionType: NODE_ACTIONS.REMOVE,
node,
modalKind,
modalMessage,
modalActionLabel,
});
expect(rootEmit).toHaveBeenCalledWith(BV_SHOW_MODAL, vm.modalId);
});
});
describe('hideNodeActionModal', () => {
it(`emits ${BV_HIDE_MODAL}`, () => {
const rootEmit = jest.spyOn(vm.$root, '$emit');
vm.hideNodeActionModal();
expect(rootEmit).toHaveBeenCalledWith(BV_HIDE_MODAL, vm.modalId);
});
});
describe('nodeRemovalAllowed', () => {
describe.each`
primaryNode | nodesLength | nodeRemovalAllowed
${false} | ${2} | ${true}
${false} | ${1} | ${true}
${true} | ${2} | ${false}
${true} | ${1} | ${true}
`(
'with (primaryNode = $primaryNode, nodesLength = $nodesLength)',
({ primaryNode, nodesLength, nodeRemovalAllowed }) => {
const testPhrasing = nodeRemovalAllowed ? 'allow' : 'disallow';
let node;
beforeEach(() => {
node = { ...mockNode, primary: primaryNode };
vm.store.state.nodes = [mockNode, node].slice(0, nodesLength);
});
it(`should ${testPhrasing} node removal`, () => {
expect(vm.nodeRemovalAllowed(node)).toBe(nodeRemovalAllowed);
});
},
);
});
});
describe('created', () => {
it('binds event handler for `pollNodeDetails`', () => {
const vmX = createComponent();
expect(eventHub.$on).toHaveBeenCalledWith('pollNodeDetails', expect.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('showNodeActionModal', expect.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('repairNode', expect.any(Function));
vmX.$destroy();
});
});
describe('beforeDestroy', () => {
it('unbinds event handler for `pollNodeDetails`', () => {
const vmX = createComponent();
vmX.$destroy();
expect(eventHub.$off).toHaveBeenCalledWith('pollNodeDetails', expect.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('showNodeActionModal', expect.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('repairNode', expect.any(Function));
});
});
describe('template', () => {
it('renders container element with class `geo-nodes-container`', () => {
expect(vm.$el.classList.contains('geo-nodes-container')).toBe(true);
});
it('renders loading animation when `isLoading` is true', () => {
vm.isLoading = true;
expect(
vm.$el.querySelectorAll('.loading-animation.prepend-top-20.append-bottom-20').length,
).not.toBe(0);
});
});
});
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import GeoNodeActionsComponent from 'ee/geo_nodes/components/geo_node_actions.vue';
import { NODE_ACTIONS } from 'ee/geo_nodes/constants';
import eventHub from 'ee/geo_nodes/event_hub';
import { mockNodes } from '../mock_data';
jest.mock('ee/geo_nodes/event_hub');
describe('GeoNodeActionsComponent', () => {
let wrapper;
const defaultProps = {
node: mockNodes[0],
nodeEditAllowed: true,
nodeActionsAllowed: true,
nodeRemovalAllowed: true,
nodeMissingOauth: false,
};
const createComponent = (props = {}) => {
wrapper = shallowMount(GeoNodeActionsComponent, {
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGeoNodeActionsComponent = () => wrapper.find('[data-testid="nodeActions"]');
const findNodeActions = () => wrapper.findAll(GlButton);
const findRemoveButton = () => wrapper.find('[data-testid="removeButton"]');
describe('computed', () => {
describe('disabledRemovalTooltip', () => {
describe.each`
nodeRemovalAllowed | tooltip
${true} | ${''}
${false} | ${'Cannot remove a primary node if there is a secondary node'}
`('when nodeRemovalAllowed is $nodeRemovalAllowed', ({ nodeRemovalAllowed, tooltip }) => {
beforeEach(() => {
createComponent({ nodeRemovalAllowed });
});
it('renders the correct tooltip', () => {
const tip = wrapper.vm.$el.querySelector('div[name=disabledRemovalTooltip]');
expect(tip.title).toBe(tooltip);
});
});
});
});
describe('methods', () => {
beforeEach(() => {
createComponent();
});
describe('onRemovePrimaryNode', () => {
it('emits showNodeActionModal with actionType `remove`, node reference, modalKind, modalMessage, modalActionLabel, and modalTitle', () => {
wrapper.vm.onRemovePrimaryNode();
expect(eventHub.$emit).toHaveBeenCalledWith('showNodeActionModal', {
actionType: NODE_ACTIONS.REMOVE,
node: wrapper.vm.node,
modalKind: 'danger',
modalMessage:
'Removing a Geo primary node stops the synchronization to all nodes. Are you sure?',
modalActionLabel: 'Remove node',
modalTitle: 'Remove primary node',
});
});
});
describe('onRemoveSecondaryNode', () => {
it('emits showNodeActionModal with actionType `remove`, node reference, modalKind, modalMessage, modalActionLabel, and modalTitle', () => {
wrapper.vm.onRemoveSecondaryNode();
expect(eventHub.$emit).toHaveBeenCalledWith('showNodeActionModal', {
actionType: NODE_ACTIONS.REMOVE,
node: wrapper.vm.node,
modalKind: 'danger',
modalMessage:
'Removing a Geo secondary node stops the synchronization to that node. Are you sure?',
modalActionLabel: 'Remove node',
modalTitle: 'Remove secondary node',
});
});
});
describe('onRepairNode', () => {
it('emits `repairNode` event with node reference', () => {
wrapper.vm.onRepairNode();
expect(eventHub.$emit).toHaveBeenCalledWith('repairNode', wrapper.vm.node);
});
});
});
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders container elements correctly', () => {
expect(findGeoNodeActionsComponent().exists()).toBeTruthy();
expect(findNodeActions()).not.toHaveLength(0);
});
describe.each`
nodeRemovalAllowed | buttonDisabled
${false} | ${'true'}
${true} | ${undefined}
`(`Remove Button`, ({ nodeRemovalAllowed, buttonDisabled }) => {
beforeEach(() => {
createComponent({ node: mockNodes[1], nodeRemovalAllowed });
});
describe(`when nodeRemovalAllowed is ${nodeRemovalAllowed}`, () => {
it('has the correct button text', () => {
expect(findRemoveButton().text().trim()).toBe('Remove');
});
it(`the button's disabled attribute should be ${buttonDisabled}`, () => {
expect(findRemoveButton().attributes('disabled')).toBe(buttonDisabled);
});
});
});
});
});
import { GlPopover, GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import GeoNodeDetailItemComponent from 'ee/geo_nodes/components/geo_node_detail_item.vue';
import GeoNodeEventStatus from 'ee/geo_nodes/components/geo_node_event_status.vue';
import GeoNodeSyncProgress from 'ee/geo_nodes/components/geo_node_sync_progress.vue';
import GeoNodeSyncSettings from 'ee/geo_nodes/components/geo_node_sync_settings.vue';
import { VALUE_TYPE, CUSTOM_TYPE, REPLICATION_HELP_URL } from 'ee/geo_nodes/constants';
import { rawMockNodeDetails } from '../mock_data';
describe('GeoNodeDetailItemComponent', () => {
let wrapper;
const defaultProps = {
itemTitle: 'GitLab version',
cssClass: 'node-version',
itemValue: '10.4.0-pre',
successLabel: 'Synced',
failureLabel: 'Failed',
neutralLabel: 'Out of sync',
itemValueType: VALUE_TYPE.PLAIN,
};
const createComponent = (props = {}) => {
wrapper = shallowMount(GeoNodeDetailItemComponent, {
stubs: { GlSprintf },
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders container elements correctly', () => {
expect(wrapper.find('.node-detail-item').exists()).toBeTruthy();
expect(wrapper.findAll('.node-detail-title')).not.toHaveLength(0);
expect(wrapper.find('.node-detail-title').text().trim()).toBe('GitLab version');
});
describe('when plain text value', () => {
it('renders plain item value', () => {
expect(wrapper.findAll('.node-detail-value')).not.toHaveLength(0);
expect(wrapper.find('.node-detail-value').text().trim()).toBe('10.4.0-pre');
});
it('does not render graph item', () => {
expect(wrapper.find(GeoNodeSyncProgress).exists()).toBeFalsy();
});
});
describe('when graph item value', () => {
beforeEach(() => {
createComponent({
itemValueType: VALUE_TYPE.GRAPH,
itemValue: { successCount: 5, failureCount: 3, totalCount: 10 },
});
});
it('renders graph item', () => {
expect(wrapper.find(GeoNodeSyncProgress).exists()).toBeTruthy();
});
});
describe('when custom type is sync', () => {
beforeEach(() => {
createComponent({
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.SYNC,
itemValue: {
namespaces: rawMockNodeDetails.namespaces,
lastEvent: {
id: rawMockNodeDetails.last_event_id,
timeStamp: rawMockNodeDetails.last_event_timestamp,
},
cursorLastEvent: {
id: rawMockNodeDetails.cursor_last_event_id,
timeStamp: rawMockNodeDetails.cursor_last_event_timestamp,
},
},
});
});
it('renders sync settings item value', () => {
expect(wrapper.find(GeoNodeSyncSettings).exists()).toBeTruthy();
});
it('does not render graph item', () => {
expect(wrapper.find(GeoNodeSyncProgress).exists()).toBeFalsy();
});
});
describe('when custom type is event', () => {
beforeEach(() => {
createComponent({
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.EVENT,
itemValue: {
eventId: rawMockNodeDetails.last_event_id,
eventTimeStamp: rawMockNodeDetails.last_event_timestamp,
},
});
});
it('renders event status item value', () => {
expect(wrapper.find(GeoNodeEventStatus).exists()).toBeTruthy();
});
it('does not render graph item', () => {
expect(wrapper.find(GeoNodeSyncProgress).exists()).toBeFalsy();
});
});
});
describe('itemEnabled', () => {
describe('when false', () => {
beforeEach(() => {
createComponent({
itemEnabled: false,
});
});
it('renders synchronization disabled text', () => {
expect(wrapper.find({ ref: 'disabledText' }).text().trim()).toBe(
'Synchronization disabled',
);
});
it('renders GlPopover', () => {
expect(wrapper.find(GlPopover).exists()).toBeTruthy();
});
it('renders disabled text', () => {
expect(wrapper.find(GlPopover).text()).toContain(
`Synchronization of ${defaultProps.itemTitle.toLowerCase()} is disabled.`,
);
});
it('renders link to replication help documentation in popover', () => {
const popoverLink = wrapper.find(GlPopover).find(GlLink);
expect(popoverLink.exists()).toBeTruthy();
expect(popoverLink.text()).toBe('Learn how to enable synchronization');
expect(popoverLink.attributes('href')).toBe(REPLICATION_HELP_URL);
});
});
describe('when true', () => {
beforeEach(() => {
createComponent({
itemEnabled: true,
});
});
it('does not render synchronization disabled text', () => {
expect(wrapper.find('.node-detail-item').text()).not.toContain('Synchronization disabled');
});
it('does not render GlPopover', () => {
expect(wrapper.find(GlPopover).exists()).toBeFalsy();
});
});
});
});
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import geoNodeDetailsComponent from 'ee/geo_nodes/components/geo_node_details.vue';
import { mockNode, mockNodeDetails } from '../mock_data';
describe('GeoNodeDetailsComponent', () => {
let wrapper;
const defaultProps = {
node: mockNode,
nodeDetails: mockNodeDetails,
nodeActionsAllowed: true,
nodeEditAllowed: true,
nodeRemovalAllowed: true,
geoTroubleshootingHelpPath: '/foo/bar',
};
const createComponent = (props = {}) => {
wrapper = shallowMount(geoNodeDetailsComponent, {
propsData: {
...defaultProps,
...props,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
const findErrorSection = () => wrapper.find('[data-testid="errorSection"]');
const findTroubleshootingLink = () => findErrorSection().find(GlLink);
describe('template', () => {
it('renders container elements correctly', () => {
expect(wrapper.classes('card-body')).toBe(true);
});
describe('when unhealthy', () => {
describe('with errorMessage', () => {
beforeEach(() => {
createComponent({
nodeDetails: {
...defaultProps.nodeDetails,
healthy: false,
health: 'This is an error',
},
});
});
it('renders error message section', () => {
expect(findErrorSection().text()).toContain('This is an error');
});
it('renders troubleshooting URL within error message section', () => {
expect(findTroubleshootingLink().attributes('href')).toBe('/foo/bar');
});
});
describe('without error message', () => {
beforeEach(() => {
createComponent({
nodeDetails: {
...defaultProps.nodeDetails,
healthy: false,
health: '',
},
});
});
it('does not render error message section', () => {
expect(findErrorSection().exists()).toBe(false);
});
});
});
describe('when healthy', () => {
beforeEach(() => {
createComponent();
});
it('does not render error message section', () => {
expect(findErrorSection().exists()).toBe(false);
});
});
describe('when version mismatched', () => {
describe('when node is primary', () => {
beforeEach(() => {
createComponent({
node: {
...defaultProps.node,
primary: true,
},
nodeDetails: {
...defaultProps.nodeDetails,
primaryVersion: '10.3.0-pre',
primaryRevision: 'b93c51850b',
},
});
});
it('does not render error message section', () => {
expect(findErrorSection().exists()).toBe(false);
});
});
describe('when node is secondary', () => {
beforeEach(() => {
createComponent({
node: {
...defaultProps.node,
primary: false,
},
nodeDetails: {
...defaultProps.nodeDetails,
primaryVersion: '10.3.0-pre',
primaryRevision: 'b93c51850b',
},
});
});
it('renders error message section', () => {
expect(findErrorSection().text()).toContain(
'GitLab version does not match the primary node version',
);
});
it('renders troubleshooting URL within error message section', () => {
expect(findTroubleshootingLink().attributes('href')).toBe('/foo/bar');
});
});
});
});
});
import Vue from 'vue';
import geoNodeEventStatusComponent from 'ee/geo_nodes/components/geo_node_event_status.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { mockNodeDetails } from '../mock_data';
const createComponent = ({
eventId = mockNodeDetails.lastEvent.id,
eventTimeStamp = mockNodeDetails.lastEvent.timeStamp,
eventTypeLogStatus = false,
}) => {
const Component = Vue.extend(geoNodeEventStatusComponent);
return mountComponent(Component, {
eventId,
eventTimeStamp,
eventTypeLogStatus,
});
};
describe('GeoNodeEventStatus', () => {
let vm;
beforeEach(() => {
vm = createComponent({});
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('timeStamp', () => {
it('returns timestamp Date object', () => {
expect(vm.timeStamp instanceof Date).toBeTruthy();
});
});
describe('timeStampString', () => {
it('returns formatted timestamp string', () => {
expect(vm.timeStampString).toContain('Nov 21, 2017');
});
});
describe('eventString', () => {
it('returns computed event string when `eventTypeLogStatus` prop is true', () => {
const vmWithLogStatus = createComponent({ eventTypeLogStatus: true });
expect(vmWithLogStatus.eventString).toBe(mockNodeDetails.lastEvent.id);
vmWithLogStatus.$destroy();
});
it('returns event ID as it is when `eventTypeLogStatus` prop is false', () => {
expect(vm.eventString).toBe(mockNodeDetails.lastEvent.id);
});
});
});
describe('template', () => {
it('renders container elements correctly', () => {
expect(vm.$el.classList.contains('node-detail-value')).toBeTruthy();
expect(vm.$el.querySelectorAll('strong').length).not.toBe(0);
expect(vm.$el.querySelector('strong').innerText.trim()).toBe(
`${mockNodeDetails.lastEvent.id}`,
);
expect(vm.$el.querySelector('.event-status-timestamp').innerText).toContain('ago');
});
it('renders empty state when timestamp is not present', () => {
const vmWithoutTimestamp = createComponent({
eventId: 0,
eventTimeStamp: 0,
});
expect(vmWithoutTimestamp.$el.querySelectorAll('strong').length).not.toBe(0);
expect(vmWithoutTimestamp.$el.querySelectorAll('.event-status-timestamp')).toHaveLength(0);
expect(vmWithoutTimestamp.$el.querySelector('strong').innerText.trim()).toBe('Not available');
vmWithoutTimestamp.$destroy();
});
});
});
import Vue from 'vue';
import GeoNodeHeaderComponent from 'ee/geo_nodes/components/geo_node_header.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { mockNode, mockNodeDetails } from '../mock_data';
const createComponent = ({
node = { ...mockNode },
nodeDetails = { ...mockNodeDetails },
nodeDetailsLoading = false,
nodeDetailsFailed = false,
}) => {
const Component = Vue.extend(GeoNodeHeaderComponent);
return mountComponent(Component, {
node,
nodeDetails,
nodeDetailsLoading,
nodeDetailsFailed,
});
};
describe('GeoNodeHeader', () => {
let vm;
beforeEach(() => {
vm = createComponent({});
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('isNodeHTTP', () => {
it('returns `true` when Node URL protocol is non-HTTPS', () => {
expect(vm.isNodeHTTP).toBe(true);
});
it('returns `false` when Node URL protocol is HTTPS', (done) => {
vm.node.url = 'https://127.0.0.1:3001/';
Vue.nextTick()
.then(() => {
expect(vm.isNodeHTTP).toBe(false);
})
.then(done)
.catch(done.fail);
});
});
describe.each`
nodeDetailsLoading | url | showWarning
${false} | ${'https://127.0.0.1:3001'} | ${false}
${false} | ${'http://127.0.0.1:3001'} | ${true}
${true} | ${'https://127.0.0.1:3001'} | ${false}
${true} | ${'http://127.0.0.1:3001'} | ${false}
`(`showNodeWarningIcon`, ({ nodeDetailsLoading, url, showWarning }) => {
beforeEach(() => {
vm.nodeDetailsLoading = nodeDetailsLoading;
vm.node.url = url;
});
it(`should return ${showWarning}`, () => {
expect(vm.showNodeWarningIcon).toBe(showWarning);
});
it(`should ${showWarning ? 'render' : 'not render'} the status icon`, () => {
expect(Boolean(vm.$el.querySelector('[data-testid="warning-icon"]'))).toBe(showWarning);
});
});
});
describe('template', () => {
it('renders node name element', () => {
expect(vm.$el.innerText).toContain(vm.node.name);
});
});
});
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import geoNodeHealthStatusComponent from 'ee/geo_nodes/components/geo_node_health_status.vue';
import { HEALTH_STATUS_ICON, HEALTH_STATUS_CLASS } from 'ee/geo_nodes/constants';
import { mockNodeDetails } from '../mock_data';
describe('GeoNodeHealthStatusComponent', () => {
let wrapper;
const defaultProps = {
status: mockNodeDetails.health,
statusCheckTimestamp: mockNodeDetails.statusCheckTimestamp,
};
const createComponent = (props = {}) => {
wrapper = shallowMount(geoNodeHealthStatusComponent, {
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findStatusPill = () => wrapper.find('.rounded-pill');
const findStatusIcon = () => findStatusPill().find(GlIcon);
describe.each`
status | healthCssClass | statusIconName
${'Healthy'} | ${HEALTH_STATUS_CLASS.healthy} | ${HEALTH_STATUS_ICON.healthy}
${'Unhealthy'} | ${HEALTH_STATUS_CLASS.unhealthy} | ${HEALTH_STATUS_ICON.unhealthy}
${'Disabled'} | ${HEALTH_STATUS_CLASS.disabled} | ${HEALTH_STATUS_ICON.disabled}
${'Unknown'} | ${HEALTH_STATUS_CLASS.unknown} | ${HEALTH_STATUS_ICON.unknown}
${'Offline'} | ${HEALTH_STATUS_CLASS.offline} | ${HEALTH_STATUS_ICON.offline}
`(`computed properties`, ({ status, healthCssClass, statusIconName }) => {
beforeEach(() => {
createComponent({ status });
});
it(`sets background of StatusPill to ${healthCssClass} when status is ${status}`, () => {
expect(findStatusPill().classes().join(' ')).toContain(healthCssClass);
});
it('renders StatusPill correctly', () => {
expect(findStatusPill().html()).toMatchSnapshot();
});
it(`sets StatusIcon to ${statusIconName} when status is ${status}`, () => {
expect(findStatusIcon().attributes('name')).toBe(statusIconName);
});
it('renders Icon correctly', () => {
expect(findStatusIcon().html()).toMatchSnapshot();
});
});
});
import { shallowMount } from '@vue/test-utils';
import GeoNodeDetails from 'ee/geo_nodes/components/geo_node_details.vue';
import geoNodeItemComponent from 'ee/geo_nodes/components/geo_node_item.vue';
import eventHub from 'ee/geo_nodes/event_hub';
import { mockNode, mockNodeDetails } from '../mock_data';
jest.mock('ee/geo_nodes/event_hub');
describe('GeoNodeItemComponent', () => {
let wrapper;
const defaultProps = {
node: mockNode,
primaryNode: true,
nodeActionsAllowed: true,
nodeEditAllowed: true,
nodeRemovalAllowed: true,
geoTroubleshootingHelpPath: '/foo/bar',
};
const createComponent = (props = {}) => {
wrapper = shallowMount(geoNodeItemComponent, {
propsData: {
...defaultProps,
...props,
},
});
};
beforeEach(() => {
createComponent();
});
const findGeoNodeDetails = () => wrapper.find(GeoNodeDetails);
afterEach(() => {
wrapper.destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(wrapper.vm.isNodeDetailsLoading).toBe(true);
expect(wrapper.vm.nodeHealthStatus).toBe('');
expect(typeof wrapper.vm.nodeDetails).toBe('object');
});
});
describe('methods', () => {
describe('handleNodeDetails', () => {
describe('with matching ID', () => {
beforeEach(() => {
const mockNodeSecondary = { ...mockNode, id: mockNodeDetails.id, primary: false };
createComponent({ node: mockNodeSecondary });
});
it('intializes props based on provided `nodeDetails`', () => {
// With altered mock data with matching ID
wrapper.vm.handleNodeDetails(mockNodeDetails);
expect(wrapper.vm.isNodeDetailsLoading).toBeFalsy();
expect(wrapper.vm.nodeDetails).toBe(mockNodeDetails);
expect(wrapper.vm.nodeHealthStatus).toBe(mockNodeDetails.health);
});
});
describe('without matching ID', () => {
it('intializes props based on provided `nodeDetails`', () => {
// With default mock data without matching ID
wrapper.vm.handleNodeDetails(mockNodeDetails);
expect(wrapper.vm.isNodeDetailsLoading).toBeTruthy();
expect(wrapper.vm.nodeDetails).not.toBe(mockNodeDetails);
expect(wrapper.vm.nodeHealthStatus).not.toBe(mockNodeDetails.health);
});
});
});
describe('handleMounted', () => {
it('emits `pollNodeDetails` event and passes node ID', () => {
wrapper.vm.handleMounted();
expect(eventHub.$emit).toHaveBeenCalledWith('pollNodeDetails', wrapper.vm.node);
});
});
});
describe('created', () => {
it('binds `nodeDetailsLoaded` event handler', () => {
expect(eventHub.$on).toHaveBeenCalledWith('nodeDetailsLoaded', expect.any(Function));
});
});
describe('beforeDestroy', () => {
it('unbinds `nodeDetailsLoaded` event handler', () => {
wrapper.destroy();
expect(eventHub.$off).toHaveBeenCalledWith('nodeDetailsLoaded', expect.any(Function));
});
});
describe('template', () => {
it('renders container element', () => {
expect(wrapper.classes('card')).toBeTruthy();
});
describe('when isNodeDetailsLoading is true', () => {
beforeEach(() => {
wrapper.setData({ isNodeDetailsLoading: true });
});
it('does not render details section', () => {
expect(findGeoNodeDetails().exists()).toBeFalsy();
});
});
describe('when isNodeDetailsLoading is false', () => {
beforeEach(() => {
wrapper.setData({ isNodeDetailsLoading: false });
});
it('renders details section', () => {
expect(findGeoNodeDetails().exists()).toBeTruthy();
});
});
});
});
import { GlPopover, GlLink, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import GeoNodeLastUpdated from 'ee/geo_nodes/components/geo_node_last_updated.vue';
import {
HELP_NODE_HEALTH_URL,
GEO_TROUBLESHOOTING_URL,
STATUS_DELAY_THRESHOLD_MS,
} from 'ee/geo_nodes/constants';
import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
describe('GeoNodeLastUpdated', () => {
let wrapper;
// The threshold is exclusive so -1
const staleStatusTime = differenceInMilliseconds(STATUS_DELAY_THRESHOLD_MS) - 1;
const nonStaleStatusTime = new Date().getTime();
const defaultProps = {
statusCheckTimestamp: staleStatusTime,
};
const createComponent = (props = {}) => {
wrapper = shallowMount(GeoNodeLastUpdated, {
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findMainText = () => wrapper.find('[data-testid="nodeLastUpdateMainText"]');
const findGlIcon = () => wrapper.find(GlIcon);
const findGlPopover = () => wrapper.find(GlPopover);
const findPopoverText = () => findGlPopover().find('p');
const findPopoverLink = () => findGlPopover().find(GlLink);
describe('template', () => {
beforeEach(() => {
createComponent();
});
describe('Main Text', () => {
it('renders always', () => {
expect(findMainText().exists()).toBeTruthy();
});
it('should properly display time ago', () => {
expect(findMainText().text()).toBe('Updated 10 minutes ago');
});
});
describe('Question Icon', () => {
it('renders always', () => {
expect(findGlIcon().exists()).toBeTruthy();
});
it('sets to question icon', () => {
expect(findGlIcon().attributes('name')).toBe('question');
});
});
it('renders popover always', () => {
expect(findGlPopover().exists()).toBeTruthy();
});
describe('Popover Text', () => {
it('renders always', () => {
expect(findPopoverText().exists()).toBeTruthy();
});
it('should properly display time ago', () => {
expect(findPopoverText().text()).toBe("Node's status was updated 10 minutes ago.");
});
});
describe('Popover Link', () => {
describe('when sync is stale', () => {
it('text should mention troubleshooting', () => {
expect(findPopoverLink().text()).toBe('Consult Geo troubleshooting information');
});
it('link should be to GEO_TROUBLESHOOTING_URL', () => {
expect(findPopoverLink().attributes('href')).toBe(GEO_TROUBLESHOOTING_URL);
});
});
describe('when sync is not stale', () => {
beforeEach(() => {
createComponent({ statusCheckTimestamp: nonStaleStatusTime });
});
it('text should not mention troubleshooting', () => {
expect(findPopoverLink().text()).toBe('Learn more about Geo node statuses');
});
it('link should be to HELP_NODE_HEALTH_URL', () => {
expect(findPopoverLink().attributes('href')).toBe(HELP_NODE_HEALTH_URL);
});
});
});
it('renders popover link always', () => {
expect(findPopoverLink().exists()).toBeTruthy();
});
});
});
import { GlIcon, GlPopover, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import geoNodeReplicationStatusComponent from 'ee/geo_nodes/components/geo_node_replication_status.vue';
import {
REPLICATION_STATUS_CLASS,
REPLICATION_STATUS_ICON,
REPLICATION_PAUSE_URL,
} from 'ee/geo_nodes/constants';
import { mockNode } from '../mock_data';
describe('GeoNodeReplicationStatusComponent', () => {
let wrapper;
const defaultProps = {
node: mockNode,
};
const createComponent = (props = {}) => {
wrapper = shallowMount(geoNodeReplicationStatusComponent, {
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findStatusPill = () => wrapper.find('.rounded-pill');
const findStatusIcon = () => findStatusPill().find(GlIcon);
const findStatusText = () => findStatusPill().find('.status-text');
const findHelpIcon = () => wrapper.find({ ref: 'replicationStatusHelp' });
const findGlPopover = () => wrapper.find(GlPopover);
const findPopoverText = () => findGlPopover().find('p');
const findPopoverLink = () => findGlPopover().find(GlLink);
describe.each`
enabled | replicationStatusCssClass | nodeReplicationStatusIcon | nodeReplicationStatusText
${true} | ${REPLICATION_STATUS_CLASS.enabled} | ${REPLICATION_STATUS_ICON.enabled} | ${'Replication enabled'}
${false} | ${REPLICATION_STATUS_CLASS.disabled} | ${REPLICATION_STATUS_ICON.disabled} | ${'Replication paused'}
`(
`computed properties`,
({
enabled,
replicationStatusCssClass,
nodeReplicationStatusIcon,
nodeReplicationStatusText,
}) => {
beforeEach(() => {
createComponent({
node: { ...defaultProps.node, enabled },
});
});
it(`sets background of StatusPill to ${replicationStatusCssClass} when enabled is ${enabled}`, () => {
expect(findStatusPill().classes().join(' ')).toContain(replicationStatusCssClass);
});
it('renders StatusPill correctly', () => {
expect(findStatusPill().html()).toMatchSnapshot();
});
it(`sets StatusIcon to ${nodeReplicationStatusIcon} when enabled is ${enabled}`, () => {
expect(findStatusIcon().attributes('name')).toBe(nodeReplicationStatusIcon);
});
it('renders Icon correctly', () => {
expect(findStatusIcon().html()).toMatchSnapshot();
});
it(`sets replication status text to ${nodeReplicationStatusText} when enabled is ${enabled}`, () => {
expect(findStatusText().text()).toBe(nodeReplicationStatusText);
});
},
);
describe('Helper Popover', () => {
beforeEach(() => {
createComponent();
});
it('always renders the help icon', () => {
expect(findHelpIcon().exists()).toBeTruthy();
});
it('sets to question icon', () => {
expect(findHelpIcon().attributes('name')).toBe('question');
});
it('renders popover always', () => {
expect(findGlPopover().exists()).toBeTruthy();
});
it('always renders popover text', () => {
expect(findPopoverText().exists()).toBeTruthy();
});
it('should display hint about pausing replication', () => {
expect(findPopoverText().text()).toBe('Geo nodes are paused using a command run on the node');
});
it('renders popover link always', () => {
expect(findPopoverLink().exists()).toBeTruthy();
});
it('link should be to HELP_NODE_HEALTH_URL', () => {
expect(findPopoverLink().attributes('href')).toBe(REPLICATION_PAUSE_URL);
});
});
});
import { GlPopover } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import GeoNodeSyncProgress from 'ee/geo_nodes/components/geo_node_sync_progress.vue';
import StackedProgressBar from '~/vue_shared/components/stacked_progress_bar.vue';
describe('GeoNodeSyncProgress', () => {
let wrapper;
const MOCK_ITEM_VALUE = { successCount: 5, failureCount: 3, totalCount: 10 };
MOCK_ITEM_VALUE.queuedCount =
MOCK_ITEM_VALUE.totalCount - MOCK_ITEM_VALUE.successCount - MOCK_ITEM_VALUE.failureCount;
const defaultProps = {
itemTitle: 'GitLab version',
itemValue: MOCK_ITEM_VALUE,
};
const createComponent = (props = {}) => {
wrapper = shallowMount(GeoNodeSyncProgress, {
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findStackedProgressBar = () => wrapper.find(StackedProgressBar);
const findGlPopover = () => wrapper.find(GlPopover);
const findCounts = () => findGlPopover().findAll('div');
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders StackedProgressbar always', () => {
expect(findStackedProgressBar().exists()).toBeTruthy();
});
describe('GlPopover', () => {
it('renders always', () => {
expect(findGlPopover().exists()).toBeTruthy();
});
it('renders each row of popover correctly', () => {
findCounts().wrappers.forEach((row) => {
expect(row.element).toMatchSnapshot();
});
});
});
});
describe('computed', () => {
describe.each`
itemValue | expectedItemValue
${{ successCount: 5, failureCount: 3, totalCount: 10 }} | ${{ successCount: 5, failureCount: 3, totalCount: 10 }}
${{ successCount: '5', failureCount: '3', totalCount: '10' }} | ${{ successCount: 5, failureCount: 3, totalCount: 10 }}
${{ successCount: null, failureCount: null, totalCount: null }} | ${{ successCount: 0, failureCount: 0, totalCount: 0 }}
${{ successCount: 'abc', failureCount: 'def', totalCount: 'ghi' }} | ${{ successCount: 0, failureCount: 0, totalCount: 0 }}
`(`status counts`, ({ itemValue, expectedItemValue }) => {
beforeEach(() => {
createComponent({ itemValue });
});
it(`when itemValue.totalCount is ${
itemValue.totalCount
} (${typeof itemValue.totalCount}), it should compute to ${
expectedItemValue.totalCount
}`, () => {
expect(wrapper.vm.totalCount).toBe(expectedItemValue.totalCount);
});
it(`when itemValue.successCount is ${
itemValue.successCount
} (${typeof itemValue.successCount}), it should compute to ${
expectedItemValue.successCount
}`, () => {
expect(wrapper.vm.successCount).toBe(expectedItemValue.successCount);
});
it(`when itemValue.failureCount is ${
itemValue.failureCount
} (${typeof itemValue.failureCount}), it should compute to ${
expectedItemValue.failureCount
}`, () => {
expect(wrapper.vm.failureCount).toBe(expectedItemValue.failureCount);
});
});
describe('queuedCount', () => {
beforeEach(() => {
createComponent();
});
it('returns total - success - failure', () => {
expect(wrapper.vm.queuedCount).toEqual(MOCK_ITEM_VALUE.queuedCount);
});
});
});
});
import Vue from 'vue';
import geoNodeSyncSettingsComponent from 'ee/geo_nodes/components/geo_node_sync_settings.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { mockNodeDetails } from '../mock_data';
const createComponent = (
syncStatusUnavailable = false,
selectiveSyncType = mockNodeDetails.selectiveSyncType,
lastEvent = mockNodeDetails.lastEvent,
cursorLastEvent = mockNodeDetails.cursorLastEvent,
) => {
const Component = Vue.extend(geoNodeSyncSettingsComponent);
return mountComponent(Component, {
syncStatusUnavailable,
selectiveSyncType,
lastEvent,
cursorLastEvent,
});
};
describe('GeoNodeSyncSettingsComponent', () => {
describe('computed', () => {
describe('syncType', () => {
let vm;
describe('when syncType is namespaces', () => {
beforeEach(() => {
vm = createComponent(false, 'namespaces');
});
afterEach(() => {
vm.$destroy();
});
it('renders the correct sync title', () => {
expect(vm.$el.querySelector('[data-testid="syncType"]').innerText.trim()).toBe(
'Selective (groups)',
);
});
});
describe('when syncType is shards', () => {
beforeEach(() => {
vm = createComponent(false, 'shards');
});
afterEach(() => {
vm.$destroy();
});
it('renders the correct sync title', () => {
expect(vm.$el.querySelector('[data-testid="syncType"]').innerText.trim()).toBe(
'Selective (shards)',
);
});
});
});
describe('eventTimestampEmpty', () => {
it('returns `true` if one of the event timestamp is empty', () => {
const vmEmptyTimestamp = createComponent(
false,
mockNodeDetails.selectiveSyncType,
{
id: 0,
timeStamp: 0,
},
{
id: 0,
timeStamp: 0,
},
);
expect(vmEmptyTimestamp.eventTimestampEmpty).toBeTruthy();
vmEmptyTimestamp.$destroy();
});
it('return `false` if one of the event timestamp is present', () => {
const vm = createComponent();
expect(vm.eventTimestampEmpty).toBeFalsy();
vm.$destroy();
});
});
});
describe('methods', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('lagInSeconds', () => {
it('returns string representing sync type', () => {
expect(vm.lagInSeconds(1511255200, 1511255450)).toBe(250);
});
});
describe('statusIcon', () => {
it('returns string representing sync status icon', () => {
expect(vm.statusIcon(250)).toBe('retry');
expect(vm.statusIcon(3500)).toBe('warning');
expect(vm.statusIcon(4000)).toBe('status_failed');
});
});
describe('statusEventInfo', () => {
it('returns string representing status event info', () => {
expect(vm.statusEventInfo(3, 3, 250)).toBe('4 minutes 10 seconds (0 events)');
});
});
describe('statusTooltip', () => {
it('returns string representing status lag message', () => {
expect(vm.statusTooltip(250)).toBe('');
expect(vm.statusTooltip(1000)).toBe(
'Node is slow, overloaded, or it just recovered after an outage.',
);
expect(vm.statusTooltip(4000)).toBe('Node is failing or broken.');
});
});
});
describe('template', () => {
it('renders `Unknown` when `syncStatusUnavailable` prop is true', () => {
const vmSyncUnavailable = createComponent(true);
expect(vmSyncUnavailable.$el.innerText.trim()).toBe('Unknown');
vmSyncUnavailable.$destroy();
});
});
});
import Vue from 'vue';
import geoNodesListComponent from 'ee/geo_nodes/components/geo_nodes_list.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { mockNodes } from '../mock_data';
const createComponent = () => {
const Component = Vue.extend(geoNodesListComponent);
return mountComponent(Component, {
nodes: mockNodes,
nodeActionsAllowed: true,
nodeEditAllowed: true,
nodeRemovalAllowed: true,
geoTroubleshootingHelpPath: '/foo/bar',
});
};
describe('GeoNodesListComponent', () => {
describe('template', () => {
it('renders container element correctly', () => {
const vm = createComponent();
expect(vm.$el.classList.contains('card')).toBe(true);
vm.$destroy();
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NodeDetailsSectionMain template node url section renders section correctly 1`] = `
<div
class="d-flex flex-column"
data-testid="nodeUrl"
>
<span
class="gl-text-gray-500"
>
Node URL
</span>
<a
class="gl-link gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-text-decoration-underline gl-mt-1"
href="http://127.0.0.1:3001/"
rel="noopener noreferrer"
target="_blank"
>
http://127.0.0.1:3001/
<svg
aria-hidden="true"
class="gl-ml-1 gl-icon s16"
data-testid="external-link-icon"
role="img"
>
<use
href="#external-link"
/>
</svg>
</a>
</div>
`;
exports[`NodeDetailsSectionMain template node version section renders section correctly 1`] = `
<div
class="d-flex flex-column mt-2"
data-testid="nodeVersion"
>
<span
class="gl-text-gray-500"
>
GitLab version
</span>
<span
class="gl-mt-1 gl-font-weight-bold"
>
10.4.0-pre (b93c51849b)
</span>
</div>
`;
import Vue from 'vue';
import NodeDetailsSectionMainComponent from 'ee/geo_nodes/components/node_detail_sections/node_details_section_main.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { mockNode, mockNodeDetails } from '../../mock_data';
const MOCK_VERSION_TEXT = `${mockNodeDetails.version} (${mockNodeDetails.revision})`;
const createComponent = ({
node = { ...mockNode },
nodeDetails = { ...mockNodeDetails },
nodeActionsAllowed = true,
nodeEditAllowed = true,
nodeRemovalAllowed = true,
versionMismatch = false,
}) => {
const Component = Vue.extend(NodeDetailsSectionMainComponent);
return mountComponent(Component, {
node,
nodeDetails,
nodeActionsAllowed,
nodeEditAllowed,
nodeRemovalAllowed,
versionMismatch,
});
};
describe('NodeDetailsSectionMain', () => {
let vm;
beforeEach(() => {
vm = createComponent({});
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('nodeVersion', () => {
it('returns `Unknown` when `version` and `revision` are null', (done) => {
vm.nodeDetails.version = null;
vm.nodeDetails.revision = null;
Vue.nextTick()
.then(() => {
expect(vm.nodeVersion).toBe('Unknown');
})
.then(done)
.catch(done.fail);
});
it('returns version string', () => {
expect(vm.nodeVersion).toBe(MOCK_VERSION_TEXT);
});
});
describe('nodeHealthStatus', () => {
it('returns health status string', (done) => {
// With default mock data
expect(vm.nodeHealthStatus).toBe('Healthy');
// With altered mock data for Unhealthy status
vm.nodeDetails.healthStatus = 'Unhealthy';
vm.nodeDetails.healthy = false;
Vue.nextTick()
.then(() => {
expect(vm.nodeHealthStatus).toBe('Unhealthy');
})
.then(done)
.catch(done.fail);
});
});
describe('selectiveSyncronization', () => {
describe('when selectiveSyncronization is not enabled', () => {
beforeEach(() => {
vm = createComponent({ nodeDetails: { ...mockNodeDetails, selectiveSyncType: null } });
});
it('does not render selective sync information', () => {
expect(vm.$el.querySelector('[data-testid="selectiveSync"]')).toBeFalsy();
});
});
describe('when selectiveSyncronization is shards', () => {
beforeEach(() => {
vm = createComponent({
node: { ...mockNode, selectiveSyncShards: ['default', 'extra'] },
nodeDetails: { ...mockNodeDetails, selectiveSyncType: 'shards' },
});
});
it('renders Shards information correctly', () => {
expect(vm.$el.querySelector('[data-testid="selectiveSync"]').innerText.trim()).toBe(
'Shards (default, extra)',
);
});
});
describe('when selectiveSyncronization is namespaces', () => {
beforeEach(() => {
vm = createComponent({
nodeDetails: {
...mockNodeDetails,
selectiveSyncType: 'namespaces',
namespaces: [{ full_path: 'gitlab-org' }, { full_path: 'gitlab-com' }],
},
});
});
it('renders Groups information correctly', () => {
expect(vm.$el.querySelector('[data-testid="selectiveSync"]').innerText.trim()).toBe(
'Groups (gitlab-org, gitlab-com)',
);
});
});
});
});
describe('template', () => {
it('renders component container element', () => {
expect(vm.$el.classList.contains('primary-section')).toBe(true);
});
describe('node url section', () => {
const findNodeUrlContainer = () => vm.$el.querySelector('[data-testid="nodeUrl"]');
const findNodeUrlContainerTitle = () =>
findNodeUrlContainer().querySelector('span:first-child');
const findNodeUrl = () => findNodeUrlContainer().querySelector('a');
it('renders section correctly', () => {
expect(findNodeUrlContainer()).toMatchSnapshot();
});
it('renders node url title correctly', () => {
expect(findNodeUrlContainerTitle().innerText.trim()).toBe('Node URL');
});
it('renders node url element correctly', () => {
expect(findNodeUrl().innerText.trim()).toContain(mockNode.url);
expect(findNodeUrl().href).toBe(mockNode.url);
});
});
describe('node version section', () => {
const findNodeVersionContainer = () => vm.$el.querySelector('[data-testid="nodeVersion"]');
const findNodeVersionContainerTitle = () =>
findNodeVersionContainer().querySelector('span:first-child');
const findNodeVersion = () => findNodeVersionContainer().querySelector('span:last-child');
it('renders section correctly', () => {
expect(findNodeVersionContainer()).toMatchSnapshot();
});
it('renders node version title correctly', () => {
expect(findNodeVersionContainerTitle().innerText.trim()).toBe('GitLab version');
});
it('renders node version element correctly', () => {
expect(findNodeVersion().innerText.trim()).toContain(MOCK_VERSION_TEXT);
});
});
});
});
import Vue from 'vue';
import NodeDetailsSectionOtherComponent from 'ee/geo_nodes/components/node_detail_sections/node_details_section_other.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { mockNode, mockNodeDetails } from '../../mock_data';
const createComponent = (
node = { ...mockNode },
nodeDetails = { ...mockNodeDetails },
nodeTypePrimary = false,
) => {
const Component = Vue.extend(NodeDetailsSectionOtherComponent);
return mountComponent(Component, {
node,
nodeDetails,
nodeTypePrimary,
});
};
describe('NodeDetailsSectionOther', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.showSectionItems).toBe(false);
});
});
describe('computed', () => {
describe('nodeDetailItems', () => {
it('returns array containing items to show under primary node when prop `nodeTypePrimary` is true', () => {
const vmNodePrimary = createComponent(mockNode, mockNodeDetails, true);
const items = vmNodePrimary.nodeDetailItems;
expect(items).toHaveLength(3);
expect(items[0].itemTitle).toBe('Replication slots');
expect(items[0].itemValue).toBe(mockNodeDetails.replicationSlots);
expect(items[1].itemTitle).toBe('Replication slot WAL');
expect(items[1].itemValue).toBe(numberToHumanSize(mockNodeDetails.replicationSlotWAL));
expect(items[2].itemTitle).toBe('Internal URL');
expect(items[2].itemValue).toBe(mockNode.internalUrl);
vmNodePrimary.$destroy();
});
it('returns array containing items to show under secondary node when prop `nodeTypePrimary` is false', () => {
const items = vm.nodeDetailItems;
expect(items).toHaveLength(1);
expect(items[0].itemTitle).toBe('Storage config');
});
});
describe('storageShardsStatus', () => {
it('returns `Unknown` when `nodeDetails.storageShardsMatch` is null', (done) => {
vm.nodeDetails.storageShardsMatch = null;
Vue.nextTick()
.then(() => {
expect(vm.storageShardsStatus).toBe('Unknown');
})
.then(done)
.catch(done.fail);
});
it('returns `OK` when `nodeDetails.storageShardsMatch` is true', (done) => {
vm.nodeDetails.storageShardsMatch = true;
Vue.nextTick()
.then(() => {
expect(vm.storageShardsStatus).toBe('OK');
})
.then(done)
.catch(done.fail);
});
it('returns storage shard status string when `nodeDetails.storageShardsMatch` is false', () => {
expect(vm.storageShardsStatus).toBe('Does not match the primary storage configuration');
});
});
describe('storageShardsCssClass', () => {
it('returns CSS class `font-weight-bold` when `nodeDetails.storageShardsMatch` is true', (done) => {
vm.nodeDetails.storageShardsMatch = true;
Vue.nextTick()
.then(() => {
expect(vm.storageShardsCssClass[0]).toBe('font-weight-bold');
expect(vm.storageShardsCssClass[1]['text-danger-500']).toBeFalsy();
})
.then(done)
.catch(done.fail);
});
it('returns CSS class `font-weight-bold text-danger-500` when `nodeDetails.storageShardsMatch` is false', () => {
expect(vm.storageShardsCssClass[0]).toBe('font-weight-bold');
expect(vm.storageShardsCssClass[1]['text-danger-500']).toBeTruthy();
});
});
});
describe('template', () => {
it('renders component container element', () => {
expect(vm.$el.classList.contains('other-section')).toBe(true);
});
it('renders show section button element', () => {
expect(vm.$el.querySelector('.btn-link')).not.toBeNull();
expect(vm.$el.querySelector('.btn-link > span').innerText.trim()).toBe('Other information');
});
it('renders section items container element', (done) => {
vm.showSectionItems = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.section-items-container')).not.toBeNull();
done();
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import NodeDetailsSectionSyncComponent from 'ee/geo_nodes/components/node_detail_sections/node_details_section_sync.vue';
import SectionRevealButton from 'ee/geo_nodes/components/node_detail_sections/section_reveal_button.vue';
import { mockNode, mockNodeDetails } from '../../mock_data';
describe('NodeDetailsSectionSync', () => {
let wrapper;
const propsData = {
node: mockNode,
nodeDetails: mockNodeDetails,
};
const createComponent = () => {
wrapper = shallowMount(NodeDetailsSectionSyncComponent, {
stubs: {
geoNodeSyncProgress: true,
},
propsData,
});
};
beforeEach(() => {
gon.features = gon.features || {};
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(wrapper.vm.showSectionItems).toBe(false);
expect(Array.isArray(wrapper.vm.nodeDetailItems)).toBe(true);
expect(wrapper.vm.nodeDetailItems.length).toBeGreaterThan(0);
});
});
describe('methods', () => {
describe('syncSettings', () => {
it('returns sync settings object', () => {
wrapper.vm.nodeDetails.syncStatusUnavailable = true;
return wrapper.vm.$nextTick(() => {
const syncSettings = wrapper.vm.syncSettings();
expect(syncSettings.syncStatusUnavailable).toBe(true);
expect(syncSettings.lastEvent).toBe(mockNodeDetails.lastEvent);
expect(syncSettings.cursorLastEvent).toBe(mockNodeDetails.cursorLastEvent);
});
});
});
describe('dbReplicationLag', () => {
it('returns DB replication lag time duration', () => {
expect(wrapper.vm.dbReplicationLag()).toBe('0m');
});
it('returns `Unknown` when `dbReplicationLag` is null', () => {
wrapper.vm.nodeDetails.dbReplicationLag = null;
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.dbReplicationLag()).toBe('Unknown');
});
});
});
describe('lastEventStatus', () => {
it('returns event status object', () => {
expect(wrapper.vm.lastEventStatus().eventId).toBe(mockNodeDetails.lastEvent.id);
expect(wrapper.vm.lastEventStatus().eventTimeStamp).toBe(
mockNodeDetails.lastEvent.timeStamp,
);
});
});
describe('cursorLastEventStatus', () => {
it('returns event status object', () => {
expect(wrapper.vm.cursorLastEventStatus().eventId).toBe(mockNodeDetails.cursorLastEvent.id);
expect(wrapper.vm.cursorLastEventStatus().eventTimeStamp).toBe(
mockNodeDetails.cursorLastEvent.timeStamp,
);
});
});
describe.each`
nodeDetailItem | path
${{ secondaryView: false, itemName: '' }} | ${''}
${{ secondaryView: true, itemName: 'repositories' }} | ${`${mockNode.url}admin/geo/replication/projects`}
${{ secondaryView: true, itemName: 'attachments' }} | ${`${mockNode.url}admin/geo/replication/uploads`}
${{ secondaryView: true, itemName: 'package_files' }} | ${`${mockNode.url}admin/geo/replication/package_files`}
`(`detailsPath`, ({ nodeDetailItem, path }) => {
describe(`when detail item is ${nodeDetailItem.itemName}`, () => {
let detailPath = '';
beforeEach(() => {
detailPath = wrapper.vm.detailsPath(nodeDetailItem);
});
it(`returns the correct path`, () => {
expect(detailPath).toBe(path);
});
});
});
});
describe('template', () => {
it('renders component container element', () => {
expect(wrapper.vm.$el.classList.contains('sync-section')).toBe(true);
});
it('renders show section button element', () => {
expect(wrapper.find(SectionRevealButton).exists()).toBeTruthy();
expect(wrapper.find(SectionRevealButton).attributes('buttontitle')).toBe('Sync information');
});
it('renders section items container element', () => {
wrapper.vm.showSectionItems = true;
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.$el.querySelector('.section-items-container')).not.toBeNull();
});
});
});
});
import { GlPopover, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import GeoNodeDetailItem from 'ee/geo_nodes/components/geo_node_detail_item.vue';
import NodeDetailsSectionVerificationComponent from 'ee/geo_nodes/components/node_detail_sections/node_details_section_verification.vue';
import SectionRevealButton from 'ee/geo_nodes/components/node_detail_sections/section_reveal_button.vue';
import { mockNodeDetails } from '../../mock_data';
describe('NodeDetailsSectionVerification', () => {
let wrapper;
const defaultProps = {
nodeDetails: mockNodeDetails,
nodeTypePrimary: false,
};
const createComponent = (props = {}) => {
wrapper = shallowMount(NodeDetailsSectionVerificationComponent, {
propsData: {
...defaultProps,
...props,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
const findGlPopover = () => wrapper.find(GlPopover);
const findDetailItems = () => wrapper.findAll(GeoNodeDetailItem);
describe('data', () => {
it('returns default data props', () => {
expect(wrapper.vm.showSectionItems).toBe(false);
});
});
describe('computed', () => {
describe('nodeText', () => {
describe('on Primary node', () => {
beforeEach(() => {
createComponent({ nodeTypePrimary: true });
});
it('returns text about secondary nodes', () => {
expect(wrapper.vm.nodeText).toBe('secondary nodes');
});
});
describe('on Secondary node', () => {
beforeEach(() => {
createComponent();
});
it('returns text about secondary nodes', () => {
expect(wrapper.vm.nodeText).toBe('primary node');
});
});
});
});
describe('methods', () => {
describe.each`
primaryNode | dataKey | nodeDetailItem
${true} | ${'checksum'} | ${{ itemValue: { checksumSuccessCount: 20, checksumFailureCount: 10, verificationSuccessCount: 30, verificationFailureCount: 15 } }}
${false} | ${'verification'} | ${{ itemValue: { totalCount: 100, checksumSuccessCount: 20, checksumFailureCount: 10, verificationSuccessCount: 30, verificationFailureCount: 15 } }}
`(`itemValue`, ({ primaryNode, dataKey, nodeDetailItem }) => {
describe(`when node is ${primaryNode ? 'primary' : 'secondary'}`, () => {
let itemValue = {};
beforeEach(() => {
createComponent({ nodeTypePrimary: primaryNode });
itemValue = wrapper.vm.itemValue(nodeDetailItem);
});
it(`gets successCount correctly`, () => {
expect(itemValue.successCount).toBe(nodeDetailItem.itemValue[`${dataKey}SuccessCount`]);
});
it(`gets failureCount correctly`, () => {
expect(itemValue.failureCount).toBe(nodeDetailItem.itemValue[`${dataKey}FailureCount`]);
});
});
});
describe.each`
primaryNode | itemTitle | titlePostfix
${true} | ${'test'} | ${'checksum progress'}
${false} | ${'test'} | ${'verification progress'}
`(`itemTitle`, ({ primaryNode, itemTitle, titlePostfix }) => {
describe(`when node is ${primaryNode ? 'primary' : 'secondary'}`, () => {
let title = '';
beforeEach(() => {
createComponent({ nodeTypePrimary: primaryNode });
title = wrapper.vm.itemTitle({ itemTitle });
});
it(`creates full title correctly`, () => {
expect(title).toBe(`${itemTitle} ${titlePostfix}`);
});
});
});
});
describe('template', () => {
it('renders component container element', () => {
expect(wrapper.vm.$el.classList.contains('verification-section')).toBe(true);
});
it('renders show section button element', () => {
expect(wrapper.find(SectionRevealButton).exists()).toBeTruthy();
expect(wrapper.find(SectionRevealButton).attributes('buttontitle')).toBe(
'Verification information',
);
});
it('renders section items container element', () => {
wrapper.vm.showSectionItems = true;
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.$el.querySelector('.section-items-container')).not.toBeNull();
});
});
describe('GlPopover', () => {
it('renders always', () => {
expect(findGlPopover().exists()).toBeTruthy();
});
it('contains text about Replicated data', () => {
expect(findGlPopover().find(GlSprintf).attributes('message')).toContain(
'Replicated data is verified',
);
});
});
describe('GeoNodeDetailItems', () => {
describe('on Primary node', () => {
beforeEach(() => {
createComponent({ nodeTypePrimary: true });
wrapper.vm.showSectionItems = true;
});
it('renders the checksum data', () => {
expect(findDetailItems()).toHaveLength(mockNodeDetails.checksumStatuses.length);
});
});
describe('on Secondary node', () => {
beforeEach(() => {
createComponent({ nodeTypePrimary: false });
wrapper.vm.showSectionItems = true;
});
it('renders the verification data', () => {
expect(findDetailItems()).toHaveLength(mockNodeDetails.verificationStatuses.length);
});
});
});
});
});
import Vue from 'vue';
import SectionRevealButtonComponent from 'ee/geo_nodes/components/node_detail_sections/section_reveal_button.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
const createComponent = (buttonTitle = 'Foo button') => {
const Component = Vue.extend(SectionRevealButtonComponent);
return mountComponent(Component, {
buttonTitle,
});
};
describe('SectionRevealButton', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.toggleState).toBe(false);
});
});
describe('computed', () => {
it('return `angle-up` when toggleState prop is true', () => {
vm.toggleState = true;
expect(vm.toggleButtonIcon).toBe('angle-up');
});
it('return `angle-down` when toggleState prop is false', () => {
vm.toggleState = false;
expect(vm.toggleButtonIcon).toBe('angle-down');
});
});
describe('methods', () => {
describe('onClickButton', () => {
it('updates `toggleState` prop to toggle from previous value', () => {
vm.toggleState = true;
vm.onClickButton();
expect(vm.toggleState).toBe(false);
});
it('emits `toggleButton` event on component', () => {
jest.spyOn(vm, '$emit');
vm.onClickButton();
expect(vm.$emit).toHaveBeenCalledWith('toggleButton', vm.toggleState);
});
});
});
describe('template', () => {
it('renders button element', () => {
expect(vm.$el.classList.contains('btn-link')).toBe(true);
expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('angle-down-icon');
expect(vm.$el.querySelector('span').innerText.trim()).toBe('Foo button');
});
});
});
export const PRIMARY_VERSION = {
version: '10.4.0-pre',
revision: 'b93c51849b',
};
export const NODE_DETAILS_PATH = '/admin/geo/nodes';
export const mockNodes = [
{
id: 1,
name: 'Test Node 1',
url: 'http://127.0.0.1:3001/',
primary: true,
enabled: true,
current: true,
files_max_capacity: 10,
repos_max_capacity: 25,
container_repositories_max_capacity: 10,
verification_max_capacity: 100,
clone_protocol: 'http',
web_edit_url: 'http://127.0.0.1:3001/admin/geo/nodes/1/edit',
selective_sync_shards: [],
_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',
},
},
{
id: 2,
name: 'Test Node 2',
url: 'http://127.0.0.1:3002/',
primary: false,
enabled: true,
current: false,
files_max_capacity: 10,
repos_max_capacity: 25,
container_repositories_max_capacity: 10,
verification_max_capacity: 100,
sync_object_storage: true,
clone_protocol: 'http',
web_edit_url: 'http://127.0.0.1:3001/admin/geo/nodes/1/edit',
selective_sync_shards: [],
_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',
},
},
];
export const mockNode = {
id: 1,
name: 'Test Node',
url: 'http://127.0.0.1:3001/',
internalUrl: 'http://127.0.0.1:3001/',
primary: true,
current: true,
enabled: true,
nodeActionActive: false,
nodeActionsAllowed: 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',
editPath: 'http://127.0.0.1:3001/admin/geo/nodes/1/edit',
selective_sync_shards: [],
};
export const rawMockNodeDetails = {
geo_node_id: 2,
healthy: true,
health: 'Healthy',
health_status: 'Healthy',
missing_oauth_application: false,
attachments_count: 0,
attachments_synced_count: 0,
attachments_failed_count: 0,
attachments_synced_in_percentage: '0.00%',
db_replication_lag_seconds: 0,
lfs_objects_count: 0,
lfs_objects_synced_count: 0,
lfs_objects_failed_count: 0,
lfs_objects_synced_in_percentage: '0.00%',
job_artifacts_count: 0,
job_artifacts_synced_count: 0,
job_artifacts_failed_count: 0,
job_artifacts_synced_in_percentage: '0.00%',
container_repositories_count: 0,
container_repositories_synced_count: 0,
container_repositories_failed_count: 0,
container_repositories_synced_in_percentage: '0.00%',
design_repositories_count: 0,
design_repositories_synced_count: 0,
design_repositories_failed_count: 0,
design_repositories_synced_in_percentage: '0.00%',
package_files_registry_count: 25,
package_files_synced_count: 25,
package_files_failed_count: 0,
package_files_synced_in_percentage: '100.00%',
repositories_failed_count: 0,
repositories_synced_count: 12,
repositories_synced_in_percentage: '100.00%',
projects_count: 12,
wikis_failed_count: 0,
wikis_synced_count: 12,
wikis_synced_in_percentage: '100.00%',
repositories_verification_failed_count: 0,
repositories_verified_count: 12,
repositories_verified_in_percentage: '100.00%',
wikis_verification_failed_count: 0,
wikis_verified_count: 12,
wikis_verified_in_percentage: '100.00%',
repositories_checksummed_count: 12,
repositories_checksum_failed_count: 0,
repositories_checksummed_in_percentage: '100.00%',
wikis_checksummed_count: 12,
wikis_checksum_failed_count: 0,
wikis_checksummed_in_percentage: '100.00%',
replication_slots_count: null,
replication_slots_used_count: null,
replication_slots_used_in_percentage: '0.00%',
replication_slots_max_retained_wal_bytes: null,
last_event_id: 3,
last_event_timestamp: 1511255200,
cursor_last_event_id: 3,
cursor_last_event_timestamp: 1511255200,
last_successful_status_check_timestamp: 1515142330,
version: '10.4.0-pre',
revision: 'b93c51849b',
selective_sync_type: 'namespaces',
namespaces: [
{
id: 54,
name: 'platform',
path: 'platform',
kind: 'group',
full_path: 'platform',
parent_id: null,
},
{
id: 4,
name: 'Twitter',
path: 'twitter',
kind: 'group',
full_path: 'twitter',
parent_id: null,
},
{
id: 3,
name: 'Documentcloud',
path: 'documentcloud',
kind: 'group',
full_path: 'documentcloud',
parent_id: null,
},
],
storage_shards: [
{
name: 'default',
path: '/home/kushal/GitLab/geo/repositorie',
},
],
storage_shards_match: false,
};
export const mockNodeDetails = {
id: 2,
health: 'Healthy',
healthy: true,
healthStatus: 'Healthy',
version: '10.4.0-pre',
revision: 'b93c51849b',
primaryVersion: '10.4.0-pre',
primaryRevision: 'b93c51849b',
statusCheckTimestamp: 1515142330,
replicationSlotWAL: 502658737,
missingOAuthApplication: false,
storageShardsMatch: false,
repositoryVerificationEnabled: true,
replicationSlots: {
totalCount: 1,
successCount: 1,
failureCount: 0,
},
syncStatuses: [
{
itemEnabled: true,
itemTitle: 'Repositories',
itemName: 'repositories',
itemValue: {
totalCount: 12,
successCount: 12,
failureCount: 0,
verificationSuccessCount: 12,
verificationFailureCount: 0,
checksumSuccessCount: 12,
checksumFailureCount: 0,
},
secondaryView: true,
},
{
itemEnabled: true,
itemTitle: 'Wikis',
itemName: 'wikis',
itemValue: {
totalCount: 12,
successCount: 12,
failureCount: 0,
verificationSuccessCount: 12,
verificationFailureCount: 0,
checksumSuccessCount: 12,
checksumFailureCount: 0,
},
},
{
itemEnabled: true,
itemTitle: 'Designs',
itemName: 'designs',
itemValue: {
totalCount: 25,
successCount: 0,
failureCount: 25,
verificationSuccessCount: null,
verificationFailureCount: null,
checksumSuccessCount: null,
checksumFailureCount: null,
},
secondaryView: true,
},
{
itemEnabled: true,
itemTitle: 'Package Files',
itemName: 'packageFiles',
itemValue: {
totalCount: 20,
successCount: 12,
failureCount: 8,
verificationSuccessCount: null,
verificationFailureCount: null,
checksumSuccessCount: null,
checksumFailureCount: null,
},
secondaryView: true,
},
],
verificationStatuses: [
{
itemEnabled: true,
itemTitle: 'Repositories',
itemName: 'repositories',
itemValue: {
totalCount: 12,
successCount: 12,
failureCount: 0,
verificationSuccessCount: 12,
verificationFailureCount: 0,
checksumSuccessCount: 12,
checksumFailureCount: 0,
},
secondaryView: true,
},
{
itemEnabled: true,
itemTitle: 'Wikis',
itemName: 'wikis',
itemValue: {
totalCount: 12,
successCount: 12,
failureCount: 0,
verificationSuccessCount: 12,
verificationFailureCount: 0,
checksumSuccessCount: 12,
checksumFailureCount: 0,
},
},
],
checksumStatuses: [
{
itemEnabled: true,
itemTitle: 'Repositories',
itemName: 'repositories',
itemValue: {
totalCount: 12,
successCount: 12,
failureCount: 0,
verificationSuccessCount: 12,
verificationFailureCount: 0,
checksumSuccessCount: 12,
checksumFailureCount: 0,
},
secondaryView: true,
},
{
itemEnabled: true,
itemTitle: 'Wikis',
itemName: 'wikis',
itemValue: {
totalCount: 12,
successCount: 12,
failureCount: 0,
verificationSuccessCount: 12,
verificationFailureCount: 0,
checksumSuccessCount: 12,
checksumFailureCount: 0,
},
},
],
lastEvent: {
id: 3,
timeStamp: 1511255200,
},
cursorLastEvent: {
id: 3,
timeStamp: 1511255200,
},
selectiveSyncType: 'namespaces',
namespaces: [],
dbReplicationLag: 0,
};
export const MOCK_REPLICABLE_TYPES = [
{
title: 'Repository',
titlePlural: 'Repositories',
name: 'repository',
namePlural: 'repositories',
secondaryView: true,
},
{
title: 'Wiki',
titlePlural: 'Wikis',
name: 'wiki',
namePlural: 'wikis',
},
{
title: 'Design',
titlePlural: 'Designs',
name: 'design',
name_plural: 'designs',
secondaryView: true,
},
{
title: 'Package File',
titlePlural: 'Package Files',
name: 'package_file',
namePlural: 'package_files',
},
];
import GeoNodesService from 'ee/geo_nodes/service/geo_nodes_service';
import axios from '~/lib/utils/axios_utils';
import { NODE_DETAILS_PATH } from '../mock_data';
jest.mock('axios');
describe('GeoNodesService', () => {
let service;
beforeEach(() => {
service = new GeoNodesService(NODE_DETAILS_PATH);
});
describe('getGeoNodes', () => {
it('returns axios instance for Geo nodes path', () => {
service.getGeoNodes();
expect(axios.get).toHaveBeenCalledWith(service.geoNodesPath);
});
});
describe('getGeoNodeDetails', () => {
it('returns axios instance for Geo node details path', () => {
service.getGeoNodeDetails(2);
expect(axios.get).toHaveBeenCalled();
});
});
});
import GeoNodesStore from 'ee/geo_nodes/store/geo_nodes_store';
import {
mockNodes,
rawMockNodeDetails,
mockNodeDetails,
MOCK_REPLICABLE_TYPES,
} from '../mock_data';
describe('GeoNodesStore', () => {
let store;
beforeEach(() => {
store = new GeoNodesStore(
mockNodeDetails.primaryVersion,
mockNodeDetails.primaryRevision,
MOCK_REPLICABLE_TYPES,
);
});
describe('constructor', () => {
it('initializes default state', () => {
expect(typeof store.state).toBe('object');
expect(Array.isArray(store.state.nodes)).toBeTruthy();
expect(typeof store.state.nodeDetails).toBe('object');
expect(store.state.primaryVersion).toBe(mockNodeDetails.primaryVersion);
expect(store.state.primaryRevision).toBe(mockNodeDetails.primaryRevision);
expect(store.state.replicableTypes).toBe(MOCK_REPLICABLE_TYPES);
});
});
describe('setNodes', () => {
it('sets nodes list to state', () => {
store.setNodes(mockNodes);
expect(store.getNodes()).toHaveLength(mockNodes.length);
});
});
describe('setNodeDetails', () => {
it('sets node details to state', () => {
store.setNodeDetails(2, rawMockNodeDetails);
expect(typeof store.getNodeDetails(2)).toBe('object');
});
});
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', () => {
it('returns formatted raw node details object', () => {
const nodeDetails = GeoNodesStore.formatNodeDetails(
rawMockNodeDetails,
store.state.replicableTypes,
);
expect(nodeDetails.healthStatus).toBe(rawMockNodeDetails.health_status);
expect(nodeDetails.replicationSlotWAL).toBe(
rawMockNodeDetails.replication_slots_max_retained_wal_bytes,
);
const syncStatusNames = nodeDetails.syncStatuses.map(({ namePlural }) => namePlural);
const replicableTypesNames = store.state.replicableTypes.map(({ namePlural }) => namePlural);
expect(syncStatusNames).toEqual(replicableTypesNames);
});
describe.each`
description | hasReplicable | mockVerificationDetails
${'null values'} | ${false} | ${{ test_type_count: null, test_type_verified_count: null, test_type_verification_failed_count: null }}
${'string values'} | ${true} | ${{ test_type_count: '10', test_type_verified_count: '5', test_type_verification_failed_count: '5' }}
${'number values'} | ${true} | ${{ test_type_count: 10, test_type_verified_count: 5, test_type_verification_failed_count: 5 }}
${'0 string values'} | ${true} | ${{ test_type_count: '0', test_type_verified_count: '0', test_type_verification_failed_count: '0' }}
${'0 number values'} | ${true} | ${{ test_type_count: 0, test_type_verified_count: 0, test_type_verification_failed_count: 0 }}
`(`verificationStatuses`, ({ description, hasReplicable, mockVerificationDetails }) => {
describe(`when node verification details contains ${description}`, () => {
it(`does ${hasReplicable ? '' : 'not'} contain replicable test_type`, () => {
const nodeDetails = GeoNodesStore.formatNodeDetails(mockVerificationDetails, [
{ namePlural: 'test_type' },
]);
expect(
nodeDetails.verificationStatuses.some(({ namePlural }) => namePlural === 'test_type'),
).toBe(hasReplicable);
});
});
});
describe.each`
description | hasReplicable | mockChecksumDetails
${'null values'} | ${false} | ${{ test_type_count: null, test_type_checksummed_count: null, test_type_checksum_failed_count: null }}
${'string values'} | ${true} | ${{ test_type_count: '10', test_type_checksummed_count: '5', test_type_checksum_failed_count: '5' }}
${'number values'} | ${true} | ${{ test_type_count: 10, test_type_checksummed_count: 5, test_type_checksum_failed_count: 5 }}
${'0 string values'} | ${true} | ${{ test_type_count: '0', test_type_checksummed_count: '0', test_type_checksum_failed_count: '0' }}
${'0 number values'} | ${true} | ${{ test_type_count: 0, test_type_checksummed_count: 0, test_type_checksum_failed_count: 0 }}
`(`checksumStatuses`, ({ description, hasReplicable, mockChecksumDetails }) => {
describe(`when node checksum details contains ${description}`, () => {
it(`does ${hasReplicable ? '' : 'not'} contain replicable test_type`, () => {
const nodeDetails = GeoNodesStore.formatNodeDetails(mockChecksumDetails, [
{ namePlural: 'test_type' },
]);
expect(
nodeDetails.checksumStatuses.some(({ namePlural }) => namePlural === 'test_type'),
).toBe(hasReplicable);
});
});
});
});
});
......@@ -19,30 +19,11 @@ RSpec.describe Admin::Geo::NodesController, :geo do
stub_licensed_features(geo: true)
end
context 'with :geo_nodes_beta feature enabled' do
before do
stub_feature_flags(geo_nodes_beta: true)
end
it 'renders the Geo Nodes Beta View', :aggregate_failures do
get admin_geo_nodes_path
expect(response).to render_template(:index)
expect(response.body).to include('js-geo-nodes-beta')
end
end
context 'with :geo_nodes_beta feature disabled' do
before do
stub_feature_flags(geo_nodes_beta: false)
end
it 'renders the Geo Nodes Legacy View', :aggregate_failures do
get admin_geo_nodes_path
it 'renders the Geo Nodes Beta View', :aggregate_failures do
get admin_geo_nodes_path
expect(response).to render_template(:index)
expect(response.body).to include('js-geo-nodes')
end
expect(response).to render_template(:index)
expect(response.body).to include('js-geo-nodes-beta')
end
end
......
......@@ -9675,9 +9675,6 @@ msgstr ""
msgid "Current forks will keep their visibility level."
msgstr ""
msgid "Current node"
msgstr ""
msgid "Current node must be the primary node or you will be locking yourself out"
msgstr ""
......@@ -14473,159 +14470,21 @@ msgstr ""
msgid "Geo Nodes"
msgstr ""
msgid "Geo Nodes|Cannot remove a primary node if there is a secondary node"
msgstr ""
msgid "Geo Replication"
msgstr ""
msgid "Geo Settings"
msgstr ""
msgid "Geo nodes are paused using a command run on the node"
msgstr ""
msgid "Geo sites"
msgstr ""
msgid "GeoNodeStatusEvent|%{timeAgoStr} (%{pendingEvents} events)"
msgstr ""
msgid "GeoNodeSyncStatus|Node is failing or broken."
msgstr ""
msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
msgstr ""
msgid "GeoNodes|Consult Geo troubleshooting information"
msgstr ""
msgid "GeoNodes|Data replication lag"
msgstr ""
msgid "GeoNodes|Does not match the primary storage configuration"
msgstr ""
msgid "GeoNodes|Full"
msgstr ""
msgid "GeoNodes|GitLab version"
msgstr ""
msgid "GeoNodes|GitLab version does not match the primary node version"
msgstr ""
msgid "GeoNodes|Health status"
msgstr ""
msgid "GeoNodes|Internal URL"
msgstr ""
msgid "GeoNodes|Last event ID processed by cursor"
msgstr ""
msgid "GeoNodes|Last event ID seen from primary"
msgstr ""
msgid "GeoNodes|Learn more about Geo node statuses"
msgstr ""
msgid "GeoNodes|Loading nodes"
msgstr ""
msgid "GeoNodes|New node"
msgstr ""
msgid "GeoNodes|Node Authentication was successfully repaired."
msgstr ""
msgid "GeoNodes|Node URL"
msgstr ""
msgid "GeoNodes|Node was successfully removed."
msgstr ""
msgid "GeoNodes|Node's status was updated %{timeAgo}."
msgstr ""
msgid "GeoNodes|Pausing replication stops the sync process. Are you sure?"
msgstr ""
msgid "GeoNodes|Removing a Geo primary node stops the synchronization to all nodes. Are you sure?"
msgstr ""
msgid "GeoNodes|Removing a Geo secondary node stops the synchronization to that node. Are you sure?"
msgstr ""
msgid "GeoNodes|Replicated data is verified with the %{nodeText} using checksums"
msgstr ""
msgid "GeoNodes|Replication slot WAL"
msgstr ""
msgid "GeoNodes|Replication slots"
msgstr ""
msgid "GeoNodes|Replication status"
msgstr ""
msgid "GeoNodes|Selective (%{syncLabel})"
msgstr ""
msgid "GeoNodes|Selective synchronization"
msgstr ""
msgid "GeoNodes|Something went wrong while changing node status"
msgstr ""
msgid "GeoNodes|Something went wrong while fetching nodes"
msgstr ""
msgid "GeoNodes|Something went wrong while removing node"
msgstr ""
msgid "GeoNodes|Something went wrong while repairing node"
msgstr ""
msgid "GeoNodes|Storage config"
msgstr ""
msgid "GeoNodes|Sync settings"
msgstr ""
msgid "GeoNodes|Unused slots"
msgstr ""
msgid "GeoNodes|Updated %{timeAgo}"
msgstr ""
msgid "GeoNodes|Used slots"
msgstr ""
msgid "GeoNodes|With %{geo} you can install a special read-only and replicated instance anywhere. Before you add nodes, follow the %{instructions} in the exact order they appear."
msgstr ""
msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
msgstr ""
msgid "GeoNodes|primary node"
msgstr ""
msgid "GeoNodes|secondary nodes"
msgstr ""
msgid "Geo|%{component} synced"
msgstr ""
msgid "Geo|%{component} verified"
msgstr ""
msgid "Geo|%{itemTitle} checksum progress"
msgstr ""
msgid "Geo|%{itemTitle} verification progress"
msgstr ""
msgid "Geo|%{label} can't be blank"
msgstr ""
......@@ -14815,9 +14674,6 @@ msgstr ""
msgid "Geo|Pending verification"
msgstr ""
msgid "Geo|Please refer to Geo Troubleshooting."
msgstr ""
msgid "Geo|Primary node"
msgstr ""
......@@ -14929,9 +14785,6 @@ msgstr ""
msgid "Geo|Synchronization failed - %{error}"
msgstr ""
msgid "Geo|Synchronization of %{itemTitle} is disabled."
msgstr ""
msgid "Geo|Synchronization settings"
msgstr ""
......@@ -16105,9 +15958,6 @@ msgstr ""
msgid "Groups (%{count})"
msgstr ""
msgid "Groups (%{groups})"
msgstr ""
msgid "Groups and projects"
msgstr ""
......@@ -19377,9 +19227,6 @@ msgstr ""
msgid "Learn how to %{no_packages_link_start}publish and share your packages%{no_packages_link_end} with GitLab."
msgstr ""
msgid "Learn how to enable synchronization"
msgstr ""
msgid "Learn more"
msgstr ""
......@@ -22636,9 +22483,6 @@ msgstr ""
msgid "Nothing to preview."
msgstr ""
msgid "Nothing to synchronize"
msgstr ""
msgid "Notification events"
msgstr ""
......@@ -22773,9 +22617,6 @@ msgstr ""
msgid "Nuget metadatum must have at least license_url, project_url or icon_url set"
msgstr ""
msgid "Number of %{itemTitle}"
msgstr ""
msgid "Number of Elasticsearch shards and replicas (per index)"
msgstr ""
......@@ -23900,9 +23741,6 @@ msgstr ""
msgid "Pause Elasticsearch indexing"
msgstr ""
msgid "Pause replication"
msgstr ""
msgid "Paused"
msgstr ""
......@@ -27375,15 +27213,9 @@ msgstr ""
msgid "Remove milestone"
msgstr ""
msgid "Remove node"
msgstr ""
msgid "Remove parent epic from an epic"
msgstr ""
msgid "Remove primary node"
msgstr ""
msgid "Remove priority"
msgstr ""
......@@ -27399,9 +27231,6 @@ msgstr ""
msgid "Remove secondary email"
msgstr ""
msgid "Remove secondary node"
msgstr ""
msgid "Remove spent time"
msgstr ""
......@@ -27549,9 +27378,6 @@ msgstr ""
msgid "Reopens this %{quick_action_target}."
msgstr ""
msgid "Repair authentication"
msgstr ""
msgid "Replace"
msgstr ""
......@@ -27567,15 +27393,6 @@ msgstr ""
msgid "Replication"
msgstr ""
msgid "Replication details"
msgstr ""
msgid "Replication enabled"
msgstr ""
msgid "Replication paused"
msgstr ""
msgid "Reply by email"
msgstr ""
......@@ -29984,9 +29801,6 @@ msgstr ""
msgid "SeverityWidget|There was an error while updating severity."
msgstr ""
msgid "Shards (%{shards})"
msgstr ""
msgid "Shards to synchronize"
msgstr ""
......@@ -31818,18 +31632,12 @@ msgstr ""
msgid "Symbolic link"
msgstr ""
msgid "Sync information"
msgstr ""
msgid "Sync now"
msgstr ""
msgid "Synced"
msgstr ""
msgid "Synchronization disabled"
msgstr ""
msgid "Synchronization settings"
msgstr ""
......@@ -36006,9 +35814,6 @@ msgstr ""
msgid "Verification concurrency limit"
msgstr ""
msgid "Verification information"
msgstr ""
msgid "Verification status"
msgstr ""
......
......@@ -7,12 +7,12 @@ module QA
module Geo
module Nodes
class Show < QA::Page::Base
view 'ee/app/views/admin/geo/nodes/index.html.haml' do
element :new_node_link
view 'ee/app/assets/javascripts/geo_nodes_beta/components/app.vue' do
element :add_site_button
end
def new_node!
click_element :new_node_link
click_element(:add_site_button)
end
end
end
......
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