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"
/>
`;
This diff is collapsed.
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');
});
});
});
This diff is collapsed.
This diff is collapsed.
......@@ -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