Commit d9014ba8 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch '4268-geo-admin-page' into 'master'

Geo admin screen enhancements

Closes #4268, #4179, and #1363

See merge request gitlab-org/gitlab-ee!3902
parents aab4fd0f 862ad724
This diff is collapsed.
......@@ -20,6 +20,7 @@ const Api = {
commitPath: '/api/:version/projects/:id/repository/commits',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
geoNodesPath: '/api/:version/geo_nodes',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath)
......
......@@ -50,7 +50,6 @@ import UserCallout from './user_callout';
import ShortcutsWiki from './shortcuts_wiki';
import Pipelines from './pipelines';
import BlobViewer from './blob/viewer/index';
import GeoNodes from './geo_nodes';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import UsersSelect from './users_select';
import RefSelectDropdown from './ref_select_dropdown';
......@@ -710,7 +709,6 @@ import initLDAPGroupsSelect from 'ee/ldap_groups_select'; // eslint-disable-line
new AbuseReports();
break;
case 'geo_nodes':
new GeoNodes($('.geo-nodes'));
import(/* webpackChunkName: 'geo_node_form' */ './geo/geo_node_form')
.then(geoNodeForm => geoNodeForm.default($('.js-geo-node-form')))
.catch(() => {});
......
This diff is collapsed.
<script>
import tooltip from '~/vue_shared/directives/tooltip';
export default {
props: {
cssClass: {
type: String,
required: false,
default: '',
},
successLabel: {
type: String,
required: true,
},
failureLabel: {
type: String,
required: true,
},
neutralLabel: {
type: String,
required: true,
},
successCount: {
type: Number,
required: true,
},
failureCount: {
type: Number,
required: true,
},
totalCount: {
type: Number,
required: true,
},
},
directives: {
tooltip,
},
computed: {
successPercent() {
return this.getPercent(this.successCount);
},
successBarStyle() {
return this.barStyle(this.successPercent);
},
successTooltip() {
return this.getTooltip(this.successLabel, this.successCount);
},
failurePercent() {
return this.getPercent(this.failureCount);
},
failureBarStyle() {
return this.barStyle(this.failurePercent);
},
failureTooltip() {
return this.getTooltip(this.failureLabel, this.failureCount);
},
neutralPercent() {
return 100 - this.successPercent - this.failurePercent;
},
neutralBarStyle() {
return this.barStyle(this.neutralPercent);
},
neutralTooltip() {
const neutralCount = this.totalCount - this.successCount - this.failureCount;
return this.getTooltip(this.neutralLabel, neutralCount);
},
},
methods: {
getPercent(count) {
return Math.ceil((count / this.totalCount) * 100);
},
barStyle(percent) {
return `width: ${percent}%;`;
},
getTooltip(label, count) {
return `${label}: ${count}`;
},
},
};
</script>
<template>
<div
class="stacked-progress-bar"
:class="cssClass"
>
<span
v-if="!totalCount"
class="status-unavailable"
>
{{__("Not available")}}
</span>
<span
v-tooltip
v-if="successPercent"
class="status-green"
data-placement="bottom"
:title="successTooltip"
:style="successBarStyle"
>
{{successPercent}}%
</span>
<span
v-tooltip
v-if="neutralPercent"
class="status-neutral"
data-placement="bottom"
:title="neutralTooltip"
:style="neutralBarStyle"
>
{{neutralPercent}}%
</span>
<span
v-tooltip
v-if="failurePercent"
class="status-red"
data-placement="bottom"
:title="failureTooltip"
:style="failureBarStyle"
>
{{failurePercent}}%
</span>
</div>
</template>
......@@ -461,11 +461,13 @@ img.emoji {
.prepend-left-4 { margin-left: 4px; }
.prepend-left-5 { margin-left: 5px; }
.prepend-left-10 { margin-left: 10px; }
.prepend-left-15 { margin-left: 15px; }
.prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; }
.append-right-5 { margin-right: 5px; }
.append-right-8 { margin-right: 8px; }
.append-right-10 { margin-right: 10px; }
.append-right-15 { margin-right: 15px; }
.append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; }
.append-bottom-0 { margin-bottom: 0; }
......
.stacked-progress-bar {
display: flex;
height: 20px;
width: 350px;
height: 16px;
border-radius: 10px;
overflow: hidden;
background-color: $theme-gray-100;
......@@ -11,15 +10,12 @@
.status-neutral,
.status-red, {
height: 100%;
min-width: 25px;
padding: 0 5px;
font-size: $tooltip-font-size;
font-weight: normal;
color: $white-light;
line-height: 20px;
&.has-value {
min-width: 25px;
padding: 0 5px;
}
line-height: 16px;
&:hover {
cursor: pointer;
......
......@@ -11,55 +11,44 @@
.page-subtitle {
margin-bottom: 24px;
}
}
.geo-node-status {
td {
vertical-align: top;
}
.help-block {
width: 135px;
text-align: right;
}
.node-info {
font-weight: $gl-font-weight-bold;
}
.event-timestamp {
.health-message {
padding: 4px 8px 1px;
background-color: $red-100;
color: $red-500;
border-radius: $border-radius-default;
font-weight: normal;
color: $theme-gray-800;
}
}
.sync-status {
font-weight: normal;
svg {
vertical-align: middle;
}
.well-list.geo-nodes {
li {
position: relative;
.sync-status-icon svg,
.sync-status-timestamp {
fill: $theme-gray-700;
color: $theme-gray-700;
&:hover {
background: $white-light;
}
&.sync-status-failure {
.sync-status-icon svg,
.sync-status-timestamp {
fill: $red-700;
color: $red-700;
}
&.node-disabled,
&.node-disabled:hover {
background-color: $gray-lightest;
}
}
}
.advanced-geo-node-status-toggler {
display: block;
.node-badge {
color: $white-light;
display: inline-block;
margin-left: 5px;
padding: 0 5px;
border-radius: 3px;
.show-advance-chevron {
margin-top: 2px;
&.primary-node {
background-color: $blue-300;
}
&.current-node {
background-color: $green-400;
}
}
......@@ -71,6 +60,10 @@
color: $gl-danger;
}
.geo-node-offline {
color: $theme-gray-950;
}
.geo-node-disabled {
color: $gray-darkest;
}
......@@ -79,84 +72,102 @@
color: $gray-darkest;
}
.well-list.geo-nodes {
li {
position: relative;
.geo-nodes {
.node-url-warning {
fill: $gl-warning;
}
}
&:hover {
background: $white-light;
.node-details-list {
.node-detail-item {
margin-top: 14px;
&:first-child {
margin-top: 0;
}
&.node-disabled,
&.node-disabled:hover {
background-color: $gray-lightest;
.node-detail-title,
.node-detail-value {
padding-left: $gl-col-padding;
padding-right: $gl-col-padding;
}
}
}
.advanced-geo-node-status-container {
.btn-link {
padding-left: 0;
padding-right: 0;
border-left: 0;
border-right: 0;
.node-detail-title {
color: $theme-gray-700;
}
.fa {
margin-left: 3px;
.node-detail-value {
margin-top: 4px;
}
}
}
.node-info {
color: $gl-text-color;
}
.node-detail-value-bold {
font-weight: $gl-font-weight-bold;
}
.geo-health {
display: inline-block;
margin-top: 5px;
white-space: pre-wrap;
}
.node-detail-value-error {
color: $gl-danger;
}
.geo-nodes {
.health-message {
padding: 1px 8px;
background-color: $red-100;
color: $red-500;
border-radius: $border-radius-default;
font-weight: 500;
}
}
.btn-show-advanced {
padding-left: 0;
border: 0;
}
.geo-node-mismatch {
color: $gl-danger;
}
.node-health-status {
display: inline-flex;
.node-badge {
color: $white-light;
display: inline-block;
margin-left: 5px;
padding: 0 5px;
border-radius: 3px;
.status-text {
line-height: 18px;
}
}
&.primary-node {
background-color: $blue-300;
}
.node-sync-settings {
display: inline-flex;
cursor: pointer;
&.current-node {
background-color: $green-400;
.sync-status-icon {
margin-top: 2px;
fill: $theme-gray-700;
}
}
.sync-status-event-info,
.event-status-timestamp {
color: $theme-gray-700;
}
.event-status-timestamp {
cursor: pointer;
}
}
}
.node-actions {
margin-top: 10px;
.geo-node-actions {
display: inline-flex;
justify-content: center;
float: right;
@media (min-width: $screen-md-min) {
position: absolute;
right: 15px;
top: 0;
.node-action-container {
margin: 0 5px;
&:last-child {
margin-right: $gl-padding;
}
}
.btn:not(:first-of-type) {
margin-left: 10px;
@media (max-width: $screen-sm-max) {
display: block;
width: 100%;
.node-action-container {
width: 100%;
margin: 0;
margin-top: 10px;
padding: 0 10px;
}
.btn-node-action {
width: 100%;
}
}
}
---
title: Geo admin screen enhancements
merge_request: 3902
author:
type: changed
......@@ -44,6 +44,7 @@ var config = {
epic_show: 'ee/epics/epic_show/epic_show_bundle.js',
new_epic: 'ee/epics/new_epic/new_epic_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js',
geo_nodes: 'ee/geo_nodes',
graphs: './graphs/graphs_bundle.js',
graphs_charts: './graphs/graphs_charts.js',
graphs_show: './graphs/graphs_show.js',
......
<script>
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import SmartInterval from '~/smart_interval';
import eventHub from '../event_hub';
import geoNodesList from './geo_nodes_list.vue';
export default {
components: {
loadingIcon,
geoNodesList,
},
props: {
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
},
data() {
return {
isLoading: true,
hasError: false,
errorMessage: '',
};
},
computed: {
nodes() {
return this.store.getNodes();
},
},
methods: {
fetchGeoNodes() {
this.hasError = false;
this.service.getGeoNodes()
.then(res => res.data)
.then((nodes) => {
this.store.setNodes(nodes);
this.isLoading = false;
})
.catch((err) => {
this.hasError = true;
this.errorMessage = err;
});
},
fetchNodeDetails(nodeId) {
return this.service.getGeoNodeDetails(nodeId)
.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.hasError = true;
this.errorMessage = err;
});
},
initNodeDetailsPolling(nodeId) {
this.nodePollingInterval = new SmartInterval({
callback: this.fetchNodeDetails.bind(this, nodeId),
startingInterval: 30000,
maxInterval: 120000,
hiddenInterval: 240000,
incrementByFactorOf: 15000,
immediateExecution: true,
});
},
},
created() {
eventHub.$on('pollNodeDetails', this.initNodeDetailsPolling);
},
mounted() {
this.fetchGeoNodes();
},
beforeDestroy() {
eventHub.$off('pollNodeDetails', this.initNodeDetailsPolling);
if (this.nodePollingInterval) {
this.nodePollingInterval.stopTimer();
}
},
};
</script>
<template>
<div class="panel panel-default">
<div class="panel-heading">
Geo nodes ({{nodes.length}})
</div>
<loading-icon
class="loading-animation prepend-top-20 append-bottom-20"
size="2"
v-if="isLoading"
:label="s__('GeoNodes|Loading nodes')"
/>
<geo-nodes-list
v-if="!isLoading"
:nodes="nodes"
:node-actions-allowed="nodeActionsAllowed"
:node-edit-allowed="nodeEditAllowed"
/>
<p
class="health-message prepend-left-15 append-right-15"
v-if="hasError"
>
{{errorMessage}}
</p>
</div>
</template>
<script>
import { __, s__ } from '~/locale';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import { NODE_ACTION_BASE_PATH, NODE_ACTIONS } from '../constants';
export default {
props: {
node: {
type: Object,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
nodeMissingOauth: {
type: Boolean,
required: true,
},
},
data() {
return {
isNodeToggleInProgress: false,
};
},
components: {
loadingIcon,
},
computed: {
isToggleAllowed() {
return !this.node.primary && this.nodeEditAllowed;
},
nodeToggleLabel() {
return this.node.enabled ? __('Disable') : __('Enable');
},
nodeDisableMessage() {
return this.node.enabled ? s__('GeoNodes|Disabling a node stops the sync process. Are you sure?') : '';
},
nodePath() {
return `${NODE_ACTION_BASE_PATH}${this.node.id}`;
},
nodeRepairAuthPath() {
return `${this.nodePath}${NODE_ACTIONS.REPAIR}`;
},
nodeTogglePath() {
return `${this.nodePath}${NODE_ACTIONS.TOGGLE}`;
},
nodeEditPath() {
return `${this.nodePath}${NODE_ACTIONS.EDIT}`;
},
},
};
</script>
<template>
<div class="geo-node-actions">
<div
v-if="nodeMissingOauth"
class="node-action-container"
>
<a
class="btn btn-default btn-sm btn-node-action"
data-method="post"
:href="nodeRepairAuthPath"
>
{{s__('Repair authentication')}}
</a>
</div>
<div
v-if="isToggleAllowed"
class="node-action-container"
>
<a
class="btn btn-sm btn-node-action"
data-method="post"
:href="nodeTogglePath"
:data-confirm="nodeDisableMessage"
:class="{ 'btn-warning': node.enabled, 'btn-success': !node.enabled }"
>
{{nodeToggleLabel}}
</a>
</div>
<div
v-if="nodeEditAllowed"
class="node-action-container"
>
<a
class="btn btn-sm btn-node-action"
:href="nodeEditPath"
>
{{__('Edit')}}
</a>
</div>
<div class="node-action-container">
<a
class="btn btn-sm btn-node-action btn-danger"
data-method="delete"
:href="nodePath"
>
{{__('Remove')}}
</a>
</div>
</div>
</template>
<script>
import { s__ } from '~/locale';
import stackedProgressBar from '~/vue_shared/components/stacked_progress_bar.vue';
import { VALUE_TYPE, CUSTOM_TYPE } from '../constants';
import geoNodeHealthStatus from './geo_node_health_status.vue';
import geoNodeSyncSettings from './geo_node_sync_settings.vue';
import geoNodeEventStatus from './geo_node_event_status.vue';
export default {
props: {
itemTitle: {
type: String,
required: true,
},
cssClass: {
type: String,
required: false,
default: '',
},
itemValue: {
type: [Object, String, Number],
required: true,
},
successLabel: {
type: String,
required: false,
default: s__('GeoNodes|Synced'),
},
failureLabel: {
type: String,
required: false,
default: s__('GeoNodes|Failed'),
},
neutralLabel: {
type: String,
required: false,
default: s__('GeoNodes|Out of sync'),
},
itemValueType: {
type: String,
required: true,
},
customType: {
type: String,
required: false,
default: '',
},
},
components: {
stackedProgressBar,
geoNodeHealthStatus,
geoNodeSyncSettings,
geoNodeEventStatus,
},
computed: {
isValueTypePlain() {
return this.itemValueType === VALUE_TYPE.PLAIN;
},
isValueTypeGraph() {
return this.itemValueType === VALUE_TYPE.GRAPH;
},
isValueTypeCustom() {
return this.itemValueType === VALUE_TYPE.CUSTOM;
},
isCustomTypeStatus() {
return this.customType === CUSTOM_TYPE.STATUS;
},
isCustomTypeSync() {
return this.customType === CUSTOM_TYPE.SYNC;
},
},
};
</script>
<template>
<li class="row node-detail-item">
<div class="node-detail-title">
{{itemTitle}}
</div>
<div
v-if="isValueTypePlain"
class="node-detail-value"
:class="cssClass"
>
{{itemValue}}
</div>
<div
v-if="isValueTypeGraph"
class="node-detail-value"
>
<stacked-progress-bar
:success-label="successLabel"
:failure-label="failureLabel"
:neutral-label="neutralLabel"
:success-count="itemValue.successCount"
:failure-count="itemValue.failureCount"
:total-count="itemValue.totalCount"
/>
</div>
<template v-if="isValueTypeCustom">
<geo-node-health-status
v-if="isCustomTypeStatus"
:status="itemValue"
/>
<geo-node-sync-settings
v-else-if="isCustomTypeSync"
:namespaces="itemValue.namespaces"
:last-event="itemValue.lastEvent"
:cursor-last-event="itemValue.cursorLastEvent"
/>
<geo-node-event-status
v-else
:event-id="itemValue.eventId"
:event-time-stamp="itemValue.eventTimeStamp"
/>
</template>
</li>
</template>
<script>
import { s__, __ } from '~/locale';
import { parseSeconds, stringifyTime } from '~/lib/utils/pretty_time';
import { bytesToMiB } from '~/lib/utils/number_utils';
import icon from '~/vue_shared/components/icon.vue';
import { VALUE_TYPE, CUSTOM_TYPE } from '../constants';
import geoNodeDetailItem from './geo_node_detail_item.vue';
export default {
props: {
node: {
type: Object,
required: true,
},
nodeDetails: {
type: Object,
required: true,
},
},
data() {
return {
showAdvanceItems: false,
errorMessage: '',
nodeDetailItems: [
{
itemTitle: s__('GeoNodes|Storage config:'),
itemValue: this.storageShardsStatus(),
itemValueType: VALUE_TYPE.PLAIN,
cssClass: this.plainValueCssClass(!this.nodeDetails.storageShardsMatch),
},
{
itemTitle: s__('GeoNodes|Health status:'),
itemValue: this.nodeHealthStatus(),
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.STATUS,
},
{
itemTitle: s__('GeoNodes|Repositories:'),
itemValue: this.nodeDetails.repositories,
itemValueType: VALUE_TYPE.GRAPH,
},
{
itemTitle: s__('GeoNodes|Wikis:'),
itemValue: this.nodeDetails.wikis,
itemValueType: VALUE_TYPE.GRAPH,
},
{
itemTitle: s__('GeoNodes|LFS objects:'),
itemValue: this.nodeDetails.lfs,
itemValueType: VALUE_TYPE.GRAPH,
},
{
itemTitle: s__('GeoNodes|Attachments:'),
itemValue: this.nodeDetails.attachments,
itemValueType: VALUE_TYPE.GRAPH,
},
{
itemTitle: s__('GeoNodes|Sync settings:'),
itemValue: this.syncSettings(),
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.SYNC,
},
],
};
},
components: {
icon,
geoNodeDetailItem,
},
computed: {
hasError() {
if (!this.nodeDetails.healthy) {
this.errorMessage = this.nodeDetails.health;
}
return !this.nodeDetails.healthy;
},
hasVersionMismatch() {
if (this.nodeDetails.version !== this.nodeDetails.primaryVersion ||
this.nodeDetails.revision !== this.nodeDetails.primaryRevision) {
this.errorMessage = s__('GeoNodes|GitLab version does not match the primary node version');
return true;
}
return false;
},
versionCssClass() {
return this.plainValueCssClass(this.hasVersionMismatch);
},
advanceButtonIcon() {
return this.showAdvanceItems ? 'angle-up' : 'angle-down';
},
nodeVersion() {
return `${this.nodeDetails.version} (${this.nodeDetails.revision})`;
},
replicationSlotWAL() {
return `${bytesToMiB(this.nodeDetails.replicationSlotWAL)} MB`;
},
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,
};
},
valueType() {
return VALUE_TYPE;
},
customType() {
return CUSTOM_TYPE;
},
},
methods: {
nodeHealthStatus() {
return this.nodeDetails.healthy ? this.nodeDetails.health : this.nodeDetails.healthStatus;
},
storageShardsStatus() {
if (this.nodeDetails.storageShardsMatch === null) {
return __('Unknown');
}
return this.nodeDetails.storageShardsMatch ? __('OK') : s__('GeoNodes|Does not match the primary storage configuration');
},
plainValueCssClass(value) {
const cssClass = 'node-detail-value-bold';
return value ? `${cssClass} node-detail-value-error` : cssClass;
},
syncSettings() {
return {
namespaces: this.nodeDetails.namespaces,
lastEvent: this.nodeDetails.lastEvent,
cursorLastEvent: this.nodeDetails.cursorLastEvent,
};
},
onClickShowAdvance() {
this.showAdvanceItems = !this.showAdvanceItems;
},
},
};
</script>
<template>
<div class="row">
<ul class="col-md-8 list-unstyled node-details-list">
<geo-node-detail-item
:item-title="s__('GeoNodes|GitLab version:')"
:css-class="versionCssClass"
:item-value="nodeVersion"
:item-value-type="valueType.PLAIN"
/>
<template v-if="!node.primary">
<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"
:custom-type="nodeDetailItem.customType"
/>
</template>
<li class="prepend-top-5 node-detail-item">
<button
class="btn-link btn-show-advanced"
type="button"
@click="onClickShowAdvance"
>
<span>{{__('Advanced')}}</span>
<icon
:size=12
:name="advanceButtonIcon"
/>
</button>
</li>
<template v-if="showAdvanceItems">
<template v-if="node.primary">
<geo-node-detail-item
:item-title="s__('GeoNodes|Replication slots:')"
:success-label="s__('GeoNodes|Used slots')"
:neutral-label="s__('GeoNodes|Unused slots')"
:item-value="nodeDetails.replicationSlots"
:item-value-type="valueType.GRAPH"
/>
<geo-node-detail-item
v-if="nodeDetails.replicationSlots.totalCount"
css-class="node-detail-value-bold"
:item-title="s__('GeoNodes|Replication slot WAL:')"
:item-value="replicationSlotWAL"
:item-value-type="valueType.PLAIN"
/>
</template>
<template v-else>
<geo-node-detail-item
css-class="node-detail-value-bold"
:item-title="s__('GeoNodes|Database replication lag:')"
:item-value="dbReplicationLag"
:item-value-type="valueType.PLAIN"
/>
<geo-node-detail-item
:item-title="s__('GeoNodes|Last event ID seen from primary:')"
:item-value="lastEventStatus"
:item-value-type="valueType.CUSTOM"
:custom-type="customType.EVENT"
/>
<geo-node-detail-item
:item-title="s__('GeoNodes|Last event ID processed by cursor:')"
:item-value="cursorLastEventStatus"
:item-value-type="valueType.CUSTOM"
:custom-type="customType.EVENT"
/>
</template>
</template>
</ul>
<div
v-if="hasError || hasVersionMismatch"
class="col-md-12 prepend-top-10"
>
<p class="health-message">
{{errorMessage}}
</p>
</div>
</div>
</template>
<script>
import { formatDate } from '~/lib/utils/datetime_utility';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
props: {
eventId: {
type: Number,
required: true,
},
eventTimeStamp: {
type: Number,
required: true,
},
},
mixins: [
timeAgoMixin,
],
directives: {
tooltip,
},
computed: {
timeStamp() {
return new Date(this.eventTimeStamp * 1000);
},
timeStampString() {
return formatDate(this.timeStamp);
},
},
};
</script>
<template>
<div
class="node-detail-value"
>
<strong>
{{eventId}}
</strong>
<span
v-tooltip
class="event-status-timestamp"
data-placement="bottom"
:title="timeStampString"
>
({{timeFormated(timeStamp)}})
</span>
</div>
</template>
<script>
import icon from '~/vue_shared/components/icon.vue';
import { HEALTH_STATUS_ICON } from '../constants';
export default {
props: {
status: {
type: String,
required: true,
},
},
components: {
icon,
},
computed: {
healthCssClass() {
return `geo-node-${this.status.toLowerCase()}`;
},
statusIconName() {
return HEALTH_STATUS_ICON[this.status.toLowerCase()];
},
},
};
</script>
<template>
<div
class="node-detail-value node-health-status"
:class="healthCssClass"
>
<icon
:size="16"
:name="statusIconName"
/>
<span
class="status-text prepend-left-5"
>
{{status}}
</span>
</div>
</template>
<script>
import icon from '~/vue_shared/components/icon.vue';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '../event_hub';
import geoNodeActions from './geo_node_actions.vue';
import geoNodeDetails from './geo_node_details.vue';
export default {
props: {
node: {
type: Object,
required: true,
},
primaryNode: {
type: Boolean,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
},
components: {
icon,
loadingIcon,
geoNodeActions,
geoNodeDetails,
},
directives: {
tooltip,
},
data() {
return {
isNodeDetailsLoading: true,
nodeHealthStatus: '',
nodeDetails: {},
};
},
computed: {
showInsecureUrlWarning() {
return this.node.url.startsWith('http://');
},
},
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.id);
},
},
created() {
eventHub.$on('nodeDetailsLoaded', this.handleNodeDetails);
},
mounted() {
this.handleMounted();
},
beforeDestroy() {
eventHub.$off('nodeDetailsLoaded', this.handleNodeDetails);
},
};
</script>
<template>
<li>
<div class="row">
<div class="col-md-8">
<div class="row">
<div class="col-md-8 clearfix">
<strong class="node-url inline pull-left">
{{node.url}}
</strong>
<loading-icon
v-if="isNodeDetailsLoading"
class="node-details-loading prepend-left-10 pull-left inline"
size=1
/>
<icon
v-tooltip
v-if="!isNodeDetailsLoading && showInsecureUrlWarning"
css-classes="prepend-left-10 pull-left node-url-warning"
name="warning"
data-container="body"
data-placement="bottom"
:title="s__('GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS.')"
:size="18"
/>
<span class="inline pull-left prepend-left-10">
<span
class="node-badge current-node"
v-if="node.current"
>
{{s__('Current node')}}
</span>
<span
class="node-badge primary-node"
v-if="node.primary"
>
{{s__('Primary')}}
</span>
</span>
</div>
</div>
</div>
<geo-node-actions
v-if="!isNodeDetailsLoading && nodeActionsAllowed"
:node="node"
:node-edit-allowed="nodeEditAllowed"
:node-missing-oauth="nodeDetails.missingOAuthApplication"
/>
</div>
<geo-node-details
v-if="!isNodeDetailsLoading"
:node="node"
:node-details="nodeDetails"
/>
</li>
</template>
<script>
import { s__ } from '~/locale';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
import { TIME_DIFF } from '../constants';
export default {
props: {
namespaces: {
type: Array,
required: true,
},
lastEvent: {
type: Object,
required: true,
},
cursorLastEvent: {
type: Object,
required: true,
},
},
directives: {
tooltip,
},
components: {
icon,
},
computed: {
syncType() {
return this.namespaces.length > 0 ? s__('GeoNodes|Selective') : s__('GeoNodes|Full');
},
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 `${timeAgoStr} (${pendingEvents} events)`;
},
statusTooltip(lagInSeconds) {
if (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="node-detail-value"
>
<span
v-tooltip
class="node-sync-settings inline"
data-placement="bottom"
:title="syncStatusTooltip"
>
<strong>{{syncType}}</strong>
<icon
name="retry"
css-classes="sync-status-icon prepend-left-5"
/>
<span
class="sync-status-event-info prepend-left-5"
>
{{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,
},
},
};
</script>
<template>
<ul class="well-list geo-nodes">
<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"
/>
</ul>
</template>
export const NODE_ACTION_BASE_PATH = '/admin/geo_nodes/';
export const NODE_ACTIONS = {
TOGGLE: '/toggle',
EDIT: '/edit',
REPAIR: '/reconfigure',
};
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_warning',
offline: 'status_canceled',
};
export const TIME_DIFF = {
FIVE_MINS: 300,
HOUR: 3600,
};
import Vue from 'vue';
export default new Vue();
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { convertPermissionToBoolean } from '~/lib/utils/common_utils';
import GeoNodesStore from './store/geo_nodes_store';
import GeoNodesService from './service/geo_nodes_service';
import geoNodesApp from './components/app.vue';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('js-geo-nodes');
if (!el) {
return;
}
// eslint-disable-next-line no-new
new Vue({
el,
components: {
geoNodesApp,
},
data() {
const dataset = this.$options.el.dataset;
const nodeActionsAllowed = convertPermissionToBoolean(dataset.nodeActionsAllowed);
const nodeEditAllowed = convertPermissionToBoolean(dataset.nodeEditAllowed);
const store = new GeoNodesStore(dataset.primaryVersion, dataset.primaryRevision);
const service = new GeoNodesService(dataset.nodeDetailsPath);
return {
store,
service,
nodeActionsAllowed,
nodeEditAllowed,
};
},
render(createElement) {
return createElement('geo-nodes-app', {
props: {
store: this.store,
service: this.service,
nodeActionsAllowed: this.nodeActionsAllowed,
nodeEditAllowed: this.nodeEditAllowed,
},
});
},
});
});
import axios from '~/lib/utils/axios_utils';
import Api from '~/api';
export default class GeoNodesService {
constructor(nodeDetailsBasePath) {
this.geoNodeDetailsBasePath = nodeDetailsBasePath;
this.geoNodesPath = Api.buildUrl(Api.geoNodesPath);
}
getGeoNodes() {
return axios.get(this.geoNodesPath);
}
getGeoNodeDetails(nodeId) {
const geoNodeDetailsPath = `${this.geoNodeDetailsBasePath}/${nodeId}/status.json`;
return axios.get(geoNodeDetailsPath);
}
}
export default class GeoNodesStore {
constructor(primaryVersion, primaryRevision) {
this.state = {};
this.state.nodes = [];
this.state.nodeDetails = {};
this.state.primaryVersion = primaryVersion;
this.state.primaryRevision = primaryRevision;
}
setNodes(nodes) {
this.state.nodes = nodes;
}
getNodes() {
return this.state.nodes;
}
setNodeDetails(nodeId, nodeDetails) {
this.state.nodeDetails[nodeId] = GeoNodesStore.formatNodeDetails(nodeDetails);
}
getPrimaryNodeVersion() {
return {
version: this.state.primaryVersion,
revision: this.state.primaryRevision,
};
}
getNodeDetails(nodeId) {
return this.state.nodeDetails[nodeId];
}
static formatNodeDetails(rawNodeDetails) {
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,
replicationSlotWAL: rawNodeDetails.replication_slots_max_retained_wal_bytes,
missingOAuthApplication: rawNodeDetails.missing_oauth_application,
storageShardsMatch: rawNodeDetails.storage_shards_match,
replicationSlots: {
totalCount: rawNodeDetails.replication_slots_count,
successCount: rawNodeDetails.replication_slots_used_count,
failureCount: 0,
},
repositories: {
totalCount: rawNodeDetails.repositories_count,
successCount: rawNodeDetails.repositories_synced_count,
failureCount: rawNodeDetails.repositories_failed_count,
},
wikis: {
totalCount: rawNodeDetails.wikis_count,
successCount: rawNodeDetails.wikis_synced_count,
failureCount: rawNodeDetails.wikis_failed_count,
},
lfs: {
totalCount: rawNodeDetails.lfs_objects_count,
successCount: rawNodeDetails.lfs_objects_failed_count,
failureCount: rawNodeDetails.lfs_objects_synced_count,
},
attachments: {
totalCount: rawNodeDetails.attachments_count,
successCount: rawNodeDetails.attachments_synced_count,
failureCount: rawNodeDetails.attachments_failed_count,
},
lastEvent: {
id: rawNodeDetails.last_event_id,
timeStamp: rawNodeDetails.last_event_timestamp,
},
cursorLastEvent: {
id: rawNodeDetails.cursor_last_event_id,
timeStamp: rawNodeDetails.cursor_last_event_timestamp,
},
namespaces: rawNodeDetails.namespaces,
dbReplicationLag: rawNodeDetails.db_replication_lag_seconds,
};
}
}
class Admin::GeoNodesController < Admin::ApplicationController
before_action :check_license, except: [:index, :destroy]
before_action :load_node, only: [:edit, :update, :destroy, :repair, :toggle, :status]
before_action :check_insecure_nodes
helper EE::GeoHelper
......@@ -105,16 +104,6 @@ class Admin::GeoNodesController < Admin::ApplicationController
@node = GeoNode.find(params[:id])
end
def check_insecure_nodes
if has_insecure_nodes?
flash_now(:alert, 'You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS.')
end
end
def has_insecure_nodes?
GeoNode.with_url_prefix('http://').exists?
end
def flash_now(type, message)
flash.now[type] = flash.now[type].blank? ? message : "#{flash.now[type]}<BR>#{message}".html_safe
end
......
- page_title 'Geo nodes'
- @content_class = "geo-admin-container"
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'geo_nodes'
%h2.page-title.clearfix
%span.title-text.pull-left
Geo Nodes
= link_to "New node", new_admin_geo_node_path, class: 'btn btn-create pull-right'
%span.title-text.pull-left= _("Geo Nodes")
= link_to s_("GeoNodes|New node"), new_admin_geo_node_path, class: 'btn btn-create pull-right'
%hr.page-title-separator
%p.page-subtitle.light
......@@ -15,140 +18,7 @@
%strong exact order
they appear.
- current_primary = Gitlab::Geo.primary?
- if @nodes.any?
.panel.panel-default
.panel-heading
Geo nodes (#{@nodes.count})
%ul.well-list.geo-nodes
- @nodes.each do |node|
%li{ id: dom_id(node), class: node_class(node), data: { status_url: status_admin_geo_node_path(node, format: :json) } }
.node-block
= node_status_icon(node)
%strong= node.url
- if node.current?
.node-badge.current-node Current node
- if node.primary?
.node-badge.primary-node Primary
%p
%span.help-block Primary node
%p
%span.help-block
GitLab version:
%span.js-primary-version= Gitlab::VERSION
%small.js-primary-revision
= "(#{Gitlab::REVISION})"
- else
= status_loading_icon
%table.geo-node-status.js-geo-node-status.hidden
%tr
%td
.help-block
GitLab version:
%td
.node-info.prepend-top-5.prepend-left-5.js-secondary-version
- if current_primary
%tr
%td
.help-block
Storage config:
%td
.node-info.prepend-top-5.prepend-left-5.js-secondary-storage-shards
- if node.enabled?
%tr
%td
.help-block.prepend-top-10
Health Status:
%td
.health-status.prepend-top-10.prepend-left-5.js-health-status
%tr
%td
.help-block.prepend-top-10
Repositories:
%td
.node-info.prepend-top-10.prepend-left-5.stacked-progress-bar.js-repositories
%span.status-unavailable.js-stats-unavailable
Not available
%span.status-green.js-synced
%span.status-neutral.js-waiting
%span.status-red.js-failed
%tr
%td
.help-block.prepend-top-10
Wikis:
%td
.node-info.prepend-top-10.prepend-left-5.stacked-progress-bar.js-wikis
%span.status-unavailable.js-stats-unavailable
Not available
%span.status-green.js-synced
%span.status-neutral.js-waiting
%span.status-red.js-failed
%tr
%td
.help-block.prepend-top-10
LFS objects:
%td
.node-info.prepend-top-10.prepend-left-5.stacked-progress-bar.js-lfs-objects
%span.status-unavailable.js-stats-unavailable
Not available
%span.status-green.js-synced
%span.status-neutral.js-waiting
%span.status-red.js-failed
%tr
%td
.help-block.prepend-top-10
Attachments:
%td
.node-info.prepend-top-10.prepend-left-5.stacked-progress-bar.js-attachments
%span.status-unavailable.js-stats-unavailable
Not available
%span.status-green.js-synced
%span.status-neutral.js-waiting
%span.status-red.js-failed
%tr
%td
.help-block.prepend-top-10
Sync settings:
%td
.node-info.prepend-top-10.prepend-left-5.js-sync-settings
%span.js-sync-type
%span.has-tooltip.sync-status.js-sync-status
%i.sync-status-icon.js-sync-status-icon
%span.sync-status-timestamp.js-sync-status-timestamp
%tr.js-advanced-status.hidden
%td
.help-block.prepend-top-10
Database replication lag:
%td
.node-info.prepend-top-10.prepend-left-5.js-db-replication-lag
%tr.js-advanced-status.hidden
%td
.help-block.prepend-top-10
Last event ID seen from primary:
%td
.node-info.prepend-top-10.prepend-left-5.js-last-event-seen
%span.js-event-id
%span.event-timestamp.js-event-timestamp.has-tooltip
%tr.js-advanced-status.hidden
%td
.help-block.prepend-top-10
Last event ID processed by cursor:
%td
.node-info.prepend-top-10.prepend-left-5.js-last-cursor-event
%span.js-event-id
%span.event-timestamp.js-event-timestamp.has-tooltip
%button.btn-link.advanced-geo-node-status-toggler.js-advanced-geo-node-status-toggler
%span> Advanced
%span.js-advance-toggle.show-advance-chevron.pull-right.inline.prepend-left-5
= sprite_icon('angle-down', css_class: 's16')
%p.health-message.hidden.js-health-message
- if Gitlab::Database.read_write?
.node-actions
- if Gitlab::Geo.license_allows?
- if node.missing_oauth_application?
= link_to "Repair authentication", repair_admin_geo_node_path(node), method: :post, title: 'OAuth application is missing', class: 'btn btn-default btn-sm'
- if node.secondary?
= toggle_node_button(node)
= link_to "Edit", edit_admin_geo_node_path(node), class: 'btn btn-sm'
= link_to "Remove", admin_geo_node_path(node), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm'
#js-geo-nodes{ data: { primary_version: "#{Gitlab::VERSION}", primary_revision: "#{Gitlab::REVISION}", node_details_path: "#{admin_geo_nodes_path}", node_actions_allowed: "#{Gitlab::Database.read_write?}", node_edit_allowed: "#{Gitlab::Geo.license_allows?}" } }
- else
= render 'shared/empty_states/geo'
.row.empty-state
.col-xs-12
.svg-content
= image_tag 'illustrations/gitlab_geo.svg'
.col-xs-12
.text-content.text-center
%h4= _("Discover GitLab Geo.")
%p= _("Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos.")
= link_to _('Learn more'), 'https://about.gitlab.com/features/gitlab-geo/', rel: 'nofollow', class: 'btn btn-new'
......@@ -53,30 +53,6 @@ describe Admin::GeoNodesController, :postgresql do
expect(response).not_to redirect_to(admin_license_path)
end
end
context 'Secured URL' do
let(:alert_message) { 'You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS.' }
context 'HTTP nodes' do
it 'displays a flash message' do
create(:geo_node, url: 'http://not.safe')
go
expect(flash[:alert]).to include(alert_message)
end
end
context 'with HTTPS nodes' do
it 'does not display a flash message' do
create(:geo_node, url: 'https://much.safer')
go
expect(flash[:alert]).not_to include(alert_message)
end
end
end
end
describe '#destroy' do
......
require 'spec_helper'
RSpec.describe 'admin Geo Nodes', type: :feature do
describe 'admin Geo Nodes', :js do
let!(:geo_node) { create(:geo_node) }
before do
......@@ -10,6 +10,7 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
it 'show all public Geo Nodes and create new node link' do
visit admin_geo_nodes_path
wait_for_requests
expect(page).to have_link('New node', href: new_admin_geo_node_path)
page.within(find('.geo-nodes', match: :first)) do
......@@ -30,6 +31,7 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
click_button 'Add Node'
expect(current_path).to eq admin_geo_nodes_path
wait_for_requests
page.within(find('.geo-nodes', match: :first)) do
expect(page).to have_content(geo_node.url)
......@@ -52,7 +54,8 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
describe 'update an existing Geo Node' do
before do
visit admin_geo_nodes_path
page.within(find('.node-actions', match: :first)) do
wait_for_requests
page.within(find('.geo-node-actions', match: :first)) do
page.click_link('Edit')
end
end
......@@ -63,6 +66,7 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
click_button 'Save changes'
expect(current_path).to eq admin_geo_nodes_path
wait_for_requests
page.within(find('.geo-nodes', match: :first)) do
expect(page).to have_content('http://newsite.com')
......@@ -74,14 +78,16 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
describe 'remove an existing Geo Node' do
before do
visit admin_geo_nodes_path
wait_for_requests
end
it 'removes an existing Geo Node' do
page.within(find('.node-actions', match: :first)) do
page.within(find('.geo-node-actions', match: :first)) do
page.click_link('Remove')
end
expect(current_path).to eq admin_geo_nodes_path
wait_for_requests
expect(page).not_to have_css('.geo-nodes')
end
end
......
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import appComponent from 'ee/geo_nodes/components/app.vue';
import eventHub from 'ee/geo_nodes/event_hub';
import GeoNodesStore from 'ee/geo_nodes/store/geo_nodes_store';
import GeoNodesService from 'ee/geo_nodes/service/geo_nodes_service';
import { PRIMARY_VERSION, NODE_DETAILS_PATH, mockNodes, rawMockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(appComponent);
const store = new GeoNodesStore(PRIMARY_VERSION.version, PRIMARY_VERSION.revision);
const service = new GeoNodesService(NODE_DETAILS_PATH);
return mountComponent(Component, {
store,
service,
nodeActionsAllowed: true,
nodeEditAllowed: true,
});
};
describe('AppComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.isLoading).toBeTruthy();
expect(vm.hasError).toBeFalsy();
expect(vm.errorMessage).toBe('');
});
});
describe('computed', () => {
describe('nodes', () => {
it('returns list of nodes from store', () => {
expect(Array.isArray(vm.nodes)).toBeTruthy();
});
});
});
describe('methods', () => {
describe('fetchGeoNodes', () => {
it('calls service.getGeoNodes and sets response to the store on success', (done) => {
const mock = new MockAdapter(axios);
mock.onGet(vm.store.geoNodesPath).reply(200, mockNodes);
spyOn(vm.store, 'setNodes');
vm.fetchGeoNodes();
expect(vm.hasError).toBeFalsy();
setTimeout(() => {
expect(vm.store.setNodes).toHaveBeenCalledWith(mockNodes);
expect(vm.isLoading).toBeFalsy();
done();
}, 0);
});
it('sets error flag and message on failure', (done) => {
const err = 'Something went wrong';
const mock = new MockAdapter(axios);
mock.onGet(vm.store.geoNodesPath).reply(500, err);
vm.fetchGeoNodes();
expect(vm.hasError).toBeFalsy();
setTimeout(() => {
expect(vm.hasError).toBeTruthy();
expect(vm.errorMessage.response.data).toBe(err);
done();
}, 0);
});
});
describe('fetchNodeDetails', () => {
it('calls service.getGeoNodeDetails and sets response to the store on success', (done) => {
const mock = new MockAdapter(axios);
mock.onGet(`${vm.service.geoNodeDetailsBasePath}/2/status.json`).reply(200, rawMockNodeDetails);
spyOn(vm.store, 'setNodeDetails');
vm.fetchNodeDetails(2);
setTimeout(() => {
expect(vm.store.setNodeDetails).toHaveBeenCalled();
done();
}, 0);
});
it('sets error flag and message on failure', (done) => {
const err = 'Something went wrong';
const mock = new MockAdapter(axios);
mock.onGet(`${vm.service.geoNodeDetailsBasePath}/2/status.json`).reply(500, err);
vm.fetchNodeDetails(2);
setTimeout(() => {
expect(vm.hasError).toBeTruthy();
expect(vm.errorMessage.response.data).toBe(err);
done();
}, 0);
});
});
describe('initNodeDetailsPolling', () => {
it('initializes SmartInterval and sets it to component', () => {
vm.initNodeDetailsPolling(2);
expect(vm.nodePollingInterval).toBeDefined();
});
});
});
describe('created', () => {
it('binds event handler for `pollNodeDetails`', () => {
spyOn(eventHub, '$on');
const vmX = createComponent();
expect(eventHub.$on).toHaveBeenCalledWith('pollNodeDetails', jasmine.any(Function));
vmX.$destroy();
});
});
describe('beforeDestroy', () => {
it('unbinds event handler for `pollNodeDetails`', () => {
spyOn(eventHub, '$off');
const vmX = createComponent();
vmX.$destroy();
expect(eventHub.$off).toHaveBeenCalledWith('pollNodeDetails', jasmine.any(Function));
});
});
describe('template', () => {
it('renders container elements correctly', () => {
expect(vm.$el.classList.contains('panel', 'panel-default')).toBeTruthy();
expect(vm.$el.querySelectorAll('.panel-heading').length).not.toBe(0);
expect(vm.$el.querySelector('.panel-heading').innerText.trim()).toBe('Geo nodes (0)');
});
it('renders loading animation when `isLoading` is true', () => {
vm.isLoading = true;
expect(vm.$el.querySelectorAll('.loading-animation.prepend-top-20.append-bottom-20').length).not.toBe(0);
});
it('renders list of nodes', (done) => {
vm.store.setNodes(mockNodes);
vm.isLoading = false;
Vue.nextTick(() => {
expect(vm.$el.querySelectorAll('.loading-animation.prepend-top-20.append-bottom-20').length).toBe(0);
expect(vm.$el.querySelectorAll('ul.geo-nodes').length).not.toBe(0);
done();
});
});
it('renders error message', (done) => {
vm.hasError = true;
vm.isLoading = false;
vm.errorMessage = 'Something went wrong.';
Vue.nextTick(() => {
const errEl = 'p.health-message.prepend-left-15.append-right-15';
expect(vm.$el.querySelectorAll(errEl).length).not.toBe(0);
expect(vm.$el.querySelector(errEl).innerText.trim()).toBe(vm.errorMessage);
done();
});
});
});
});
import Vue from 'vue';
import geoNodeActionsComponent from 'ee/geo_nodes/components/geo_node_actions.vue';
import { mockNodes } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (node = mockNodes[0], nodeEditAllowed = true, nodeMissingOauth = false) => {
const Component = Vue.extend(geoNodeActionsComponent);
return mountComponent(Component, {
node,
nodeEditAllowed,
nodeMissingOauth,
});
};
describe('GeoNodeActionsComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
const vmX = createComponent();
expect(vmX.isNodeToggleInProgress).toBeFalsy();
vmX.$destroy();
});
});
describe('computed', () => {
describe('isToggleAllowed', () => {
it('returns boolean value representing if toggle on node can be allowed', () => {
let vmX = createComponent(mockNodes[0], true, false);
expect(vmX.isToggleAllowed).toBeFalsy();
vmX.$destroy();
vmX = createComponent(mockNodes[1]);
expect(vmX.isToggleAllowed).toBeTruthy();
vmX.$destroy();
});
});
describe('nodeToggleLabel', () => {
it('returns label for toggle button for a node', () => {
let mockNode = Object.assign({}, mockNodes[1]);
let vmX = createComponent(mockNode);
expect(vmX.nodeToggleLabel).toBe('Disable');
vmX.$destroy();
mockNode = Object.assign({}, mockNodes[1], { enabled: false });
vmX = createComponent(mockNode);
expect(vmX.nodeToggleLabel).toBe('Enable');
vmX.$destroy();
});
});
describe('nodeDisableMessage', () => {
it('returns node toggle message', () => {
let mockNode = Object.assign({}, mockNodes[1]);
let vmX = createComponent(mockNode);
expect(vmX.nodeDisableMessage).toBe('Disabling a node stops the sync process. Are you sure?');
vmX.$destroy();
mockNode = Object.assign({}, mockNodes[1], { enabled: false });
vmX = createComponent(mockNode);
expect(vmX.nodeDisableMessage).toBe('');
vmX.$destroy();
});
});
describe('nodePath', () => {
it('returns node path', () => {
expect(vm.nodePath).toBe('/admin/geo_nodes/1');
});
});
describe('nodeRepairAuthPath', () => {
it('returns node repair authentication path', () => {
expect(vm.nodeRepairAuthPath).toBe('/admin/geo_nodes/1/reconfigure');
});
});
describe('nodeTogglePath', () => {
it('returns node toggle path', () => {
expect(vm.nodeTogglePath).toBe('/admin/geo_nodes/1/toggle');
});
});
describe('nodeEditPath', () => {
it('returns node edit path', () => {
expect(vm.nodeEditPath).toBe('/admin/geo_nodes/1/edit');
});
});
});
describe('template', () => {
it('renders container elements correctly', () => {
expect(vm.$el.classList.contains('geo-node-actions')).toBeTruthy();
expect(vm.$el.querySelectorAll('.node-action-container').length).not.toBe(0);
expect(vm.$el.querySelectorAll('.btn-node-action').length).not.toBe(0);
});
});
});
import Vue from 'vue';
import geoNodeDetailItemComponent from 'ee/geo_nodes/components/geo_node_detail_item.vue';
import { VALUE_TYPE, CUSTOM_TYPE } from 'ee/geo_nodes/constants';
import { rawMockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (config) => {
const Component = Vue.extend(geoNodeDetailItemComponent);
const defaultConfig = Object.assign({
itemTitle: 'GitLab version:',
cssClass: 'node-version',
itemValue: '10.4.0-pre',
successLabel: 'Synced',
failureLabel: 'Failed',
neutralLabel: 'Out of sync',
itemValueType: VALUE_TYPE.PLAIN,
}, config);
return mountComponent(Component, defaultConfig);
};
describe('GeoNodeDetailItemComponent', () => {
describe('template', () => {
it('renders container elements correctly', () => {
const vm = createComponent();
expect(vm.$el.classList.contains('node-detail-item')).toBeTruthy();
expect(vm.$el.querySelectorAll('.node-detail-title').length).not.toBe(0);
expect(vm.$el.querySelector('.node-detail-title').innerText.trim()).toBe('GitLab version:');
vm.$destroy();
});
it('renders plain item value', () => {
const vm = createComponent();
expect(vm.$el.querySelectorAll('.node-detail-value').length).not.toBe(0);
expect(vm.$el.querySelector('.node-detail-value').innerText.trim()).toBe('10.4.0-pre');
vm.$destroy();
});
it('renders graph item value', () => {
const vm = createComponent({
itemValueType: VALUE_TYPE.GRAPH,
itemValue: { successCount: 5, failureCount: 3, totalCount: 10 },
});
expect(vm.$el.querySelectorAll('.stacked-progress-bar').length).not.toBe(0);
vm.$destroy();
});
it('renders health status item value', () => {
const vm = createComponent({
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.STATUS,
itemValue: rawMockNodeDetails.health,
});
expect(vm.$el.querySelectorAll('.node-health-status').length).not.toBe(0);
vm.$destroy();
});
it('renders sync settings item value', () => {
const vm = 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,
},
},
});
expect(vm.$el.querySelectorAll('.node-sync-settings').length).not.toBe(0);
vm.$destroy();
});
it('renders event status item value', () => {
const vm = createComponent({
itemValueType: VALUE_TYPE.CUSTOM,
customType: CUSTOM_TYPE.EVENT,
itemValue: {
eventId: rawMockNodeDetails.last_event_id,
eventTimeStamp: rawMockNodeDetails.last_event_timestamp,
},
});
expect(vm.$el.querySelectorAll('.event-status-timestamp').length).not.toBe(0);
vm.$destroy();
});
});
});
import Vue from 'vue';
import geoNodeDetailsComponent from 'ee/geo_nodes/components/geo_node_details.vue';
import { mockNodes, mockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (nodeDetails = mockNodeDetails) => {
const Component = Vue.extend(geoNodeDetailsComponent);
return mountComponent(Component, {
nodeDetails,
node: mockNodes[1],
});
};
describe('GeoNodeDetailsComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.showAdvanceItems).toBeFalsy();
expect(vm.errorMessage).toBe('');
expect(Array.isArray(vm.nodeDetailItems)).toBeTruthy();
});
});
describe('computed', () => {
describe('hasError', () => {
it('returns boolean value representing if node has any errors', () => {
// With altered mock data for Unhealthy status
const nodeDetails = Object.assign({}, mockNodeDetails, {
health: 'Something went wrong.',
healthy: false,
});
const vmX = createComponent(nodeDetails);
expect(vmX.errorMessage).toBe('Something went wrong.');
expect(vmX.hasError).toBeTruthy();
vmX.$destroy();
// With default mock data
expect(vm.hasError).toBeFalsy();
});
});
describe('hasVersionMismatch', () => {
it('returns boolean value representing if node has version mismatch', () => {
// With altered mock data for version mismatch
const nodeDetails = Object.assign({}, mockNodeDetails, {
primaryVersion: '10.3.0-pre',
primaryRevision: 'b93c51850b',
});
const vmX = createComponent(nodeDetails);
expect(vmX.errorMessage).toBe('GitLab version does not match the primary node version');
expect(vmX.hasVersionMismatch).toBeTruthy();
vmX.$destroy();
// With default mock data
expect(vm.hasVersionMismatch).toBeFalsy();
});
});
describe('advanceButtonIcon', () => {
it('returns button icon name', () => {
vm.showAdvanceItems = true;
expect(vm.advanceButtonIcon).toBe('angle-up');
vm.showAdvanceItems = false;
expect(vm.advanceButtonIcon).toBe('angle-down');
});
});
describe('nodeVersion', () => {
it('returns version string', () => {
expect(vm.nodeVersion).toBe('10.4.0-pre (b93c51849b)');
});
});
describe('replicationSlotWAL', () => {
it('returns replication slot WAL in Megabytes', () => {
expect(vm.replicationSlotWAL).toBe('0 MB');
});
});
describe('dbReplicationLag', () => {
it('returns DB replication lag time duration', () => {
expect(vm.dbReplicationLag).toBe('0m');
});
});
describe('lastEventStatus', () => {
it('returns event status object', () => {
expect(vm.lastEventStatus.eventId).toBe(mockNodeDetails.lastEvent.id);
expect(vm.lastEventStatus.eventTimeStamp).toBe(mockNodeDetails.lastEvent.timeStamp);
});
});
describe('cursorLastEventStatus', () => {
it('returns event status object', () => {
expect(vm.cursorLastEventStatus.eventId).toBe(mockNodeDetails.cursorLastEvent.id);
expect(vm.cursorLastEventStatus.eventTimeStamp)
.toBe(mockNodeDetails.cursorLastEvent.timeStamp);
});
});
});
describe('methods', () => {
describe('nodeHealthStatus', () => {
it('returns health status string', () => {
// With altered mock data for Unhealthy status
const nodeDetails = Object.assign({}, mockNodeDetails, {
healthStatus: 'Unhealthy',
healthy: false,
});
const vmX = createComponent(nodeDetails);
expect(vmX.nodeHealthStatus()).toBe('Unhealthy');
vmX.$destroy();
// With default mock data
expect(vm.nodeHealthStatus()).toBe('Healthy');
});
});
describe('storageShardsStatus', () => {
it('returns storage shard status string', () => {
// With altered mock data for Unhealthy status
let nodeDetails = Object.assign({}, mockNodeDetails, {
storageShardsMatch: null,
});
let vmX = createComponent(nodeDetails);
expect(vmX.storageShardsStatus()).toBe('Unknown');
vmX.$destroy();
nodeDetails = Object.assign({}, mockNodeDetails, {
storageShardsMatch: true,
});
vmX = createComponent(nodeDetails);
expect(vmX.storageShardsStatus()).toBe('OK');
vmX.$destroy();
// With default mock data
expect(vm.storageShardsStatus()).toBe('Does not match the primary storage configuration');
});
});
describe('plainValueCssClass', () => {
it('returns CSS class for plain value item', () => {
expect(vm.plainValueCssClass()).toBe('node-detail-value-bold');
expect(vm.plainValueCssClass(true)).toBe('node-detail-value-bold node-detail-value-error');
});
});
describe('syncSettings', () => {
it('returns sync settings object', () => {
const syncSettings = vm.syncSettings();
expect(syncSettings.namespaces).toBe(mockNodeDetails.namespaces);
expect(syncSettings.lastEvent).toBe(mockNodeDetails.lastEvent);
expect(syncSettings.cursorLastEvent).toBe(mockNodeDetails.cursorLastEvent);
});
});
describe('onClickShowAdvance', () => {
it('toggles `showAdvanceItems` prop', () => {
vm.showAdvanceItems = true;
vm.onClickShowAdvance();
expect(vm.showAdvanceItems).toBeFalsy();
vm.showAdvanceItems = false;
vm.onClickShowAdvance();
expect(vm.showAdvanceItems).toBeTruthy();
});
});
});
describe('template', () => {
it('renders container elements correctly', () => {
expect(vm.$el.querySelectorAll('.node-details-list').length).not.toBe(0);
expect(vm.$el.querySelectorAll('.btn-show-advanced').length).not.toBe(0);
});
});
});
import Vue from 'vue';
import geoNodeEventStatusComponent from 'ee/geo_nodes/components/geo_node_event_status.vue';
import { mockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(geoNodeEventStatusComponent);
return mountComponent(Component, {
eventId: mockNodeDetails.lastEvent.id,
eventTimeStamp: mockNodeDetails.lastEvent.timeStamp,
});
};
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('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');
});
});
});
import Vue from 'vue';
import geoNodeHealthStatusComponent from 'ee/geo_nodes/components/geo_node_health_status.vue';
import { mockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (status = mockNodeDetails.health) => {
const Component = Vue.extend(geoNodeHealthStatusComponent);
return mountComponent(Component, {
status,
});
};
describe('GeoNodeHealthStatusComponent', () => {
describe('computed', () => {
describe('healthCssClass', () => {
it('returns CSS class representing `status` prop value', () => {
const vm = createComponent('Healthy');
expect(vm.healthCssClass).toBe('geo-node-healthy');
vm.$destroy();
});
});
describe('statusIconName', () => {
it('returns icon name representing `status` prop value', () => {
let vm = createComponent('Healthy');
expect(vm.statusIconName).toBe('status_success');
vm.$destroy();
vm = createComponent('Unhealthy');
expect(vm.statusIconName).toBe('status_failed');
vm.$destroy();
vm = createComponent('Disabled');
expect(vm.statusIconName).toBe('status_canceled');
vm.$destroy();
vm = createComponent('Unknown');
expect(vm.statusIconName).toBe('status_warning');
vm.$destroy();
vm = createComponent('Offline');
expect(vm.statusIconName).toBe('status_canceled');
vm.$destroy();
});
});
});
describe('template', () => {
it('renders container elements correctly', () => {
const vm = createComponent('Healthy');
expect(vm.$el.classList.contains('node-detail-value', 'node-health-status', 'geo-node-healthy')).toBeTruthy();
expect(vm.$el.querySelectorAll('svg').length).not.toBe(0);
expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('#status_success');
expect(vm.$el.querySelector('.status-text').innerText.trim()).toBe('Healthy');
vm.$destroy();
});
});
});
import Vue from 'vue';
import geoNodeItemComponent from 'ee/geo_nodes/components/geo_node_item.vue';
import eventHub from 'ee/geo_nodes/event_hub';
import { mockNodes, mockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (node = mockNodes[0]) => {
const Component = Vue.extend(geoNodeItemComponent);
return mountComponent(Component, {
node,
primaryNode: true,
nodeActionsAllowed: true,
nodeEditAllowed: true,
});
};
describe('GeoNodeItemComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.isNodeDetailsLoading).toBeTruthy();
expect(vm.nodeHealthStatus).toBe('');
expect(typeof vm.nodeDetails).toBe('object');
});
});
describe('computed', () => {
describe('showInsecureUrlWarning', () => {
it('returns boolean value representing URL protocol security', () => {
// With altered mock data for secure URL
const mockNode = Object.assign({}, mockNodes[0], {
url: 'https://127.0.0.1:3001/',
});
const vmX = createComponent(mockNode);
expect(vmX.showInsecureUrlWarning).toBeFalsy();
vmX.$destroy();
// With default mock data
expect(vm.showInsecureUrlWarning).toBeTruthy();
});
});
});
describe('methods', () => {
describe('handleNodeDetails', () => {
it('intializes props based on provided `nodeDetails`', () => {
// With altered mock data with matching ID
const mockNode = Object.assign({}, mockNodes[1]);
const vmX = createComponent(mockNode);
vmX.handleNodeDetails(mockNodeDetails);
expect(vmX.isNodeDetailsLoading).toBeFalsy();
expect(vmX.nodeDetails).toBe(mockNodeDetails);
expect(vmX.nodeHealthStatus).toBe(mockNodeDetails.health);
vmX.$destroy();
// With default mock data without matching ID
vm.handleNodeDetails(mockNodeDetails);
expect(vm.isNodeDetailsLoading).toBeTruthy();
expect(vm.nodeDetails).not.toBe(mockNodeDetails);
expect(vm.nodeHealthStatus).not.toBe(mockNodeDetails.health);
});
});
describe('handleMounted', () => {
it('emits `pollNodeDetails` event and passes node ID', () => {
spyOn(eventHub, '$emit');
vm.handleMounted();
expect(eventHub.$emit).toHaveBeenCalledWith('pollNodeDetails', mockNodes[0].id);
});
});
});
describe('created', () => {
it('binds `nodeDetailsLoaded` event handler', () => {
spyOn(eventHub, '$on');
const vmX = createComponent();
expect(eventHub.$on).toHaveBeenCalledWith('nodeDetailsLoaded', jasmine.any(Function));
vmX.$destroy();
});
});
describe('beforeDestroy', () => {
it('unbinds `nodeDetailsLoaded` event handler', () => {
spyOn(eventHub, '$off');
const vmX = createComponent();
vmX.$destroy();
expect(eventHub.$off).toHaveBeenCalledWith('nodeDetailsLoaded', jasmine.any(Function));
});
});
describe('template', () => {
it('renders node URL', () => {
expect(vm.$el.querySelectorAll('.node-url').length).not.toBe(0);
});
it('renders node details loading animation', () => {
vm.isNodeDetailsLoading = true;
expect(vm.$el.querySelectorAll('.node-details-loading').length).not.toBe(0);
});
it('renders node badge `Current node`', () => {
expect(vm.$el.querySelectorAll('.node-badge.current-node').length).not.toBe(0);
});
it('renders node badge `Primary`', () => {
expect(vm.$el.querySelectorAll('.node-badge.primary-node').length).not.toBe(0);
});
});
});
import Vue from 'vue';
import geoNodeSyncSettingsComponent from 'ee/geo_nodes/components/geo_node_sync_settings.vue';
import { mockNodeDetails } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (
namespaces = mockNodeDetails.namespaces,
lastEvent = mockNodeDetails.lastEvent,
cursorLastEvent = mockNodeDetails.cursorLastEvent) => {
const Component = Vue.extend(geoNodeSyncSettingsComponent);
return mountComponent(Component, {
namespaces,
lastEvent,
cursorLastEvent,
});
};
describe('GeoNodeSyncSettingsComponent', () => {
describe('computed', () => {
describe('syncType', () => {
it('returns string representing sync type', () => {
const vm = createComponent();
expect(vm.syncType).toBe('Selective');
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.');
});
});
});
});
import Vue from 'vue';
import geoNodesListComponent from 'ee/geo_nodes/components/geo_nodes_list.vue';
import { mockNodes } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(geoNodesListComponent);
return mountComponent(Component, {
nodes: mockNodes,
nodeActionsAllowed: true,
nodeEditAllowed: true,
});
};
describe('GeoNodesListComponent', () => {
describe('template', () => {
it('renders container element correctly', () => {
const vm = createComponent();
expect(vm.$el.classList.contains('well-list', 'geo-nodes')).toBeTruthy();
vm.$destroy();
});
});
});
export const PRIMARY_VERSION = {
version: '10.4.0-pre',
revision: 'b93c51849b',
};
export const NODE_DETAILS_PATH = '/admin/geo_nodes';
export const mockNodes = [
{
id: 1,
url: 'http://127.0.0.1:3001/',
primary: true,
enabled: true,
current: true,
files_max_capacity: 10,
repos_max_capacity: 25,
clone_protocol: 'http',
},
{
id: 2,
url: 'http://127.0.0.1:3002/',
primary: false,
enabled: true,
current: false,
files_max_capacity: 10,
repos_max_capacity: 25,
clone_protocol: 'http',
},
];
export const rawMockNodeDetails = {
geo_node_id: 2,
healthy: true,
health: 'Healthy',
health_status: 'Healthy',
missing_oauth_application: false,
attachments_count: 0,
attachments_synced_count: 0,
attachments_failed_count: 0,
attachments_synced_in_percentage: '0.00%',
db_replication_lag_seconds: 0,
lfs_objects_count: 0,
lfs_objects_synced_count: 0,
lfs_objects_failed_count: 0,
lfs_objects_synced_in_percentage: '0.00%',
repositories_count: 12,
repositories_failed_count: 0,
repositories_synced_count: 12,
repositories_synced_in_percentage: '100.00%',
wikis_count: 12,
wikis_failed_count: 0,
wikis_synced_count: 12,
wikis_synced_in_percentage: '100.00%',
replication_slots_count: null,
replication_slots_used_count: null,
replication_slots_used_in_percentage: '0.00%',
replication_slots_max_retained_wal_bytes: null,
last_event_id: 3,
last_event_timestamp: 1511255200,
cursor_last_event_id: 3,
cursor_last_event_timestamp: 1511255200,
last_successful_status_check_timestamp: 1515142330,
version: '10.4.0-pre',
revision: 'b93c51849b',
namespaces: [
{
id: 54,
name: 'platform',
path: 'platform',
kind: 'group',
full_path: 'platform',
parent_id: null,
},
{
id: 4,
name: 'Twitter',
path: 'twitter',
kind: 'group',
full_path: 'twitter',
parent_id: null,
},
{
id: 3,
name: 'Documentcloud',
path: 'documentcloud',
kind: 'group',
full_path: 'documentcloud',
parent_id: null,
},
],
storage_shards: [
{
name: 'default',
path: '/home/kushal/GitLab/geo/repositorie',
},
],
storage_shards_match: false,
};
export const mockNodeDetails = {
id: 2,
health: 'Healthy',
healthy: true,
healthStatus: 'Healthy',
version: '10.4.0-pre',
revision: 'b93c51849b',
primaryVersion: '10.4.0-pre',
primaryRevision: 'b93c51849b',
replicationSlotWAL: null,
missingOAuthApplication: false,
storageShardsMatch: false,
replicationSlots: {
totalCount: null,
successCount: null,
failureCount: 0,
},
repositories: {
totalCount: 12,
successCount: 12,
failureCount: 0,
},
wikis: {
totalCount: 12,
successCount: 12,
failureCount: 0,
},
lfs: {
totalCount: 0,
successCount: 0,
failureCount: 0,
},
attachments: {
totalCount: 0,
successCount: 0,
failureCount: 0,
},
lastEvent: {
id: 3,
timeStamp: 1511255200,
},
cursorLastEvent: {
id: 3,
timeStamp: 1511255200,
},
namespaces: [
{
id: 54,
name: 'platform',
path: 'platform',
kind: 'group',
full_path: 'platform',
parent_id: null,
},
{
id: 4,
name: 'Twitter',
path: 'twitter',
kind: 'group',
full_path: 'twitter',
parent_id: null,
},
{
id: 3,
name: 'Documentcloud',
path: 'documentcloud',
kind: 'group',
full_path: 'documentcloud',
parent_id: null,
},
],
dbReplicationLag: 0,
};
import axios from '~/lib/utils/axios_utils';
import GeoNodesService from 'ee/geo_nodes/service/geo_nodes_service';
import { NODE_DETAILS_PATH } from '../mock_data';
describe('GeoNodesService', () => {
let service;
beforeEach(() => {
service = new GeoNodesService(NODE_DETAILS_PATH);
});
describe('getGeoNodes', () => {
it('returns axios instance for Geo nodes path', () => {
spyOn(axios, 'get').and.stub();
service.getGeoNodes();
expect(axios.get).toHaveBeenCalledWith(service.geoNodesPath);
});
});
describe('getGeoNodeDetails', () => {
it('returns axios instance for Geo node details path', () => {
spyOn(axios, 'get').and.stub();
service.getGeoNodeDetails(2);
expect(axios.get).toHaveBeenCalled();
});
});
});
import GeoNodesStore from 'ee/geo_nodes/store/geo_nodes_store';
import { mockNodes, rawMockNodeDetails, mockNodeDetails } from '../mock_data';
describe('GeoNodesStore', () => {
let store;
beforeEach(() => {
store = new GeoNodesStore(mockNodeDetails.primaryVersion, mockNodeDetails.primaryRevision);
});
describe('constructor', () => {
it('initializes default state', () => {
expect(typeof store.state).toBe('object');
expect(Array.isArray(store.state.nodes)).toBeTruthy();
expect(typeof store.state.nodeDetails).toBe('object');
expect(store.state.primaryVersion).toBe(mockNodeDetails.primaryVersion);
expect(store.state.primaryRevision).toBe(mockNodeDetails.primaryRevision);
});
});
describe('setNodes', () => {
it('sets nodes list to state', () => {
store.setNodes(mockNodes);
expect(store.getNodes()).toBe(mockNodes);
});
});
describe('setNodeDetails', () => {
it('sets node details to state', () => {
store.setNodeDetails(2, rawMockNodeDetails);
expect(typeof store.getNodeDetails(2)).toBe('object');
});
});
describe('formatNodeDetails', () => {
it('returns formatted raw node details object', () => {
const nodeDetails = GeoNodesStore.formatNodeDetails(rawMockNodeDetails);
expect(nodeDetails.healthStatus).toBe(rawMockNodeDetails.health_status);
expect(nodeDetails.replicationSlotWAL)
.toBe(rawMockNodeDetails.replication_slots_max_retained_wal_bytes);
});
});
});
import Vue from 'vue';
import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (config) => {
const Component = Vue.extend(stackedProgressBarComponent);
const defaultConfig = Object.assign({}, {
successLabel: 'Synced',
failureLabel: 'Failed',
neutralLabel: 'Out of sync',
successCount: 10,
failureCount: 5,
totalCount: 20,
}, config);
return mountComponent(Component, defaultConfig);
};
describe('StackedProgressBarComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('getPercent', () => {
it('returns percentage from provided count based on `totalCount`', () => {
expect(vm.getPercent(10)).toBe(50);
});
});
describe('barStyle', () => {
it('returns style string based on percentage provided', () => {
expect(vm.barStyle(50)).toBe('width: 50%;');
});
});
describe('getTooltip', () => {
it('returns label string based on label and count provided', () => {
expect(vm.getTooltip('Synced', 10)).toBe('Synced: 10');
});
});
});
describe('template', () => {
it('renders container element', () => {
expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy();
});
it('renders empty state when count is unavailable', () => {
const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 });
expect(vmX.$el.querySelectorAll('.status-unavailable').length).not.toBe(0);
vmX.$destroy();
});
it('renders bar elements when count is available', () => {
expect(vm.$el.querySelectorAll('.status-green').length).not.toBe(0);
expect(vm.$el.querySelectorAll('.status-neutral').length).not.toBe(0);
expect(vm.$el.querySelectorAll('.status-red').length).not.toBe(0);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment