Commit d2c8865e authored by Zack Cuddy's avatar Zack Cuddy Committed by Vitaly Slobodin

Geo Node Status 2.0 - Node Header Details

This change adds the Health Status,
Actions, and Last Updated info
to the Node header.
parent 47b30b7c
<script>
import GeoNodeActionsDesktop from './geo_node_actions_desktop.vue';
import GeoNodeActionsMobile from './geo_node_actions_mobile.vue';
export default {
name: 'GeoNodeActions',
components: {
GeoNodeActionsMobile,
GeoNodeActionsDesktop,
},
props: {
primary: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<div>
<geo-node-actions-mobile class="gl-lg-display-none" :primary="primary" />
<geo-node-actions-desktop class="gl-display-none gl-lg-display-flex" :primary="primary" />
</div>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
name: 'GeoNodeActionsDesktop',
i18n: {
editButtonLabel: __('Edit'),
removeButtonLabel: __('Remove'),
},
components: {
GlButton,
},
props: {
primary: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<div>
<gl-button class="gl-mr-3">{{ $options.i18n.editButtonLabel }}</gl-button>
<gl-button
variant="danger"
category="secondary"
:disabled="primary"
data-testid="geo-desktop-remove-action"
>{{ $options.i18n.removeButtonLabel }}</gl-button
>
</div>
</template>
<script>
import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
name: 'GeoNodeActionsMobile',
i18n: {
editButtonLabel: __('Edit'),
removeButtonLabel: __('Remove'),
},
components: {
GlDropdown,
GlDropdownItem,
GlIcon,
},
props: {
primary: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
dropdownRemoveClass() {
return this.primary ? 'gl-text-gray-400' : 'gl-text-red-500';
},
},
};
</script>
<template>
<gl-dropdown toggle-class="gl-shadow-none! gl-bg-transparent! gl-p-3!" right>
<template #button-content>
<gl-icon name="ellipsis_h" />
</template>
<gl-dropdown-item>{{ $options.i18n.editButtonLabel }}</gl-dropdown-item>
<gl-dropdown-item :disabled="primary" data-testid="geo-mobile-remove-action">
<span :class="dropdownRemoveClass">{{ $options.i18n.removeButtonLabel }}</span>
</gl-dropdown-item>
</gl-dropdown>
</template>
<script> <script>
import { GlButton, GlBadge } from '@gitlab/ui'; import { GlButton, GlBadge } from '@gitlab/ui';
import { __ } from '~/locale';
import GeoNodeActions from './geo_node_actions.vue';
import GeoNodeHealthStatus from './geo_node_health_status.vue';
import GeoNodeLastUpdated from './geo_node_last_updated.vue';
export default { export default {
name: 'GeoNodeHeader', name: 'GeoNodeHeader',
i18n: {
currentNodeLabel: __('Current'),
},
components: { components: {
GlButton, GlButton,
GlBadge, GlBadge,
GeoNodeHealthStatus,
GeoNodeLastUpdated,
GeoNodeActions,
}, },
props: { props: {
node: { node: {
...@@ -22,6 +32,9 @@ export default { ...@@ -22,6 +32,9 @@ export default {
chevronIcon() { chevronIcon() {
return this.collapsed ? 'chevron-right' : 'chevron-down'; return this.collapsed ? 'chevron-right' : 'chevron-down';
}, },
statusCheckTimestamp() {
return this.node.lastSuccessfulStatusCheckTimestamp * 1000;
},
}, },
}; };
</script> </script>
...@@ -43,18 +56,18 @@ export default { ...@@ -43,18 +56,18 @@ export default {
> >
<div class="gl-display-flex gl-align-items-center gl-flex-fill-1"> <div class="gl-display-flex gl-align-items-center gl-flex-fill-1">
<gl-badge v-if="node.current" variant="info" class="gl-mr-2">{{ <gl-badge v-if="node.current" variant="info" class="gl-mr-2">{{
__('Current') $options.i18n.currentNodeLabel
}}</gl-badge> }}</gl-badge>
<h4 class="gl-font-lg">{{ node.name }}</h4> <h4 class="gl-font-lg">{{ node.name }}</h4>
</div> </div>
<div class="gl-display-flex gl-align-items-center gl-flex-fill-2"> <div class="gl-display-flex gl-align-items-center gl-flex-fill-2">
<span>{{ s__('Geo|Health Status') }}</span> <geo-node-health-status :status="node.healthStatus" />
<span class="gl-ml-2">{{ __('Last Updated') }}</span> <geo-node-last-updated class="gl-ml-2" :status-check-timestamp="statusCheckTimestamp" />
</div> </div>
</div> </div>
</div> </div>
<div class="gl-display-flex gl-align-items-center gl-justify-content-end"> <div class="gl-display-flex gl-align-items-center gl-justify-content-end">
<span>{{ __('Actions') }}</span> <geo-node-actions :primary="node.primary" />
</div> </div>
</div> </div>
</template> </template>
<script>
import { GlIcon, GlBadge } from '@gitlab/ui';
import { HEALTH_STATUS_UI } from 'ee/geo_nodes_beta/constants';
export default {
components: {
GlIcon,
GlBadge,
},
props: {
status: {
type: String,
required: true,
},
},
computed: {
statusUi() {
return HEALTH_STATUS_UI[this.status.toLowerCase()];
},
},
};
</script>
<template>
<gl-badge :variant="statusUi.variant">
<gl-icon :name="statusUi.icon" />
<span class="gl-ml-2 gl-font-weight-bold">{{ status }}</span>
</gl-badge>
</template>
<script>
import { GlPopover, GlLink, GlIcon } from '@gitlab/ui';
import {
HELP_NODE_HEALTH_URL,
GEO_TROUBLESHOOTING_URL,
STATUS_DELAY_THRESHOLD_MS,
} from 'ee/geo_nodes_beta/constants';
import { sprintf, s__ } from '~/locale';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
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="gl-display-flex gl-align-items-center">
<span class="gl-text-gray-500" data-testid="last-updated-main-text">{{
syncTimeAgo.mainText
}}</span>
<gl-icon
ref="lastUpdated"
tabindex="0"
name="question"
class="gl-text-blue-500 gl-cursor-pointer gl-ml-2"
/>
<gl-popover :target="() => $refs.lastUpdated.$el" placement="top" triggers="hover focus">
<p>{{ syncTimeAgo.popoverText }}</p>
<gl-link :href="syncHelp.link" target="_blank">{{ syncHelp.text }}</gl-link>
</gl-popover>
</div>
</template>
...@@ -3,3 +3,36 @@ import { helpPagePath } from '~/helpers/help_page_helper'; ...@@ -3,3 +3,36 @@ import { helpPagePath } from '~/helpers/help_page_helper';
export const GEO_INFO_URL = helpPagePath('administration/geo/index.md'); export const GEO_INFO_URL = helpPagePath('administration/geo/index.md');
export const GEO_FEATURE_URL = 'https://about.gitlab.com/features/gitlab-geo/'; export const GEO_FEATURE_URL = 'https://about.gitlab.com/features/gitlab-geo/';
export const HELP_NODE_HEALTH_URL = helpPagePath(
'administration/geo/replication/troubleshooting.html#check-the-health-of-the-secondary-node',
);
export const GEO_TROUBLESHOOTING_URL = helpPagePath(
'administration/geo/replication/troubleshooting.html',
);
export const HEALTH_STATUS_UI = {
healthy: {
icon: 'status_success',
variant: 'success',
},
unhealthy: {
icon: 'status_failed',
variant: 'danger',
},
disabled: {
icon: 'status_canceled',
variant: 'neutral',
},
unknown: {
icon: 'status_notfound',
variant: 'neutral',
},
offline: {
icon: 'status_canceled',
variant: 'neutral',
},
};
export const STATUS_DELAY_THRESHOLD_MS = 600000;
import { GlButton } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import GeoNodeActionsDesktop from 'ee/geo_nodes_beta/components/header/geo_node_actions_desktop.vue';
import { MOCK_PRIMARY_VERSION, MOCK_REPLICABLE_TYPES } from 'ee_jest/geo_nodes_beta/mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('GeoNodeActionsDesktop', () => {
let wrapper;
const defaultProps = {
primary: true,
};
const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
primaryVersion: MOCK_PRIMARY_VERSION.version,
primaryRevision: MOCK_PRIMARY_VERSION.revision,
replicableTypes: MOCK_REPLICABLE_TYPES,
...initialState,
},
});
wrapper = shallowMount(GeoNodeActionsDesktop, {
localVue,
store,
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findGeoDesktopActionsButtons = () => wrapper.findAll(GlButton);
const findGeoDesktopActionsRemoveButton = () =>
wrapper.find('[data-testid="geo-desktop-remove-action"]');
describe('template', () => {
describe('always', () => {
beforeEach(() => {
createComponent();
});
it('renders an Edit and Remove button', () => {
expect(findGeoDesktopActionsButtons().wrappers.map((w) => w.text())).toStrictEqual([
'Edit',
'Remove',
]);
});
});
describe.each`
primary | disabled
${true} | ${'true'}
${false} | ${undefined}
`(`conditionally`, ({ primary, disabled }) => {
beforeEach(() => {
createComponent(null, { primary });
});
describe(`when primary is ${primary}`, () => {
it('disables the Desktop Remove button', () => {
expect(findGeoDesktopActionsRemoveButton().attributes('disabled')).toBe(disabled);
});
});
});
});
});
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import GeoNodeActionsMobile from 'ee/geo_nodes_beta/components/header/geo_node_actions_mobile.vue';
import { MOCK_PRIMARY_VERSION, MOCK_REPLICABLE_TYPES } from 'ee_jest/geo_nodes_beta/mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('GeoNodeActionsMobile', () => {
let wrapper;
const defaultProps = {
primary: true,
};
const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
primaryVersion: MOCK_PRIMARY_VERSION.version,
primaryRevision: MOCK_PRIMARY_VERSION.revision,
replicableTypes: MOCK_REPLICABLE_TYPES,
...initialState,
},
});
wrapper = shallowMount(GeoNodeActionsMobile, {
localVue,
store,
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findGeoMobileActionsDropdown = () => wrapper.find(GlDropdown);
const findGeoMobileActionsDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findGeoMobileActionsRemoveDropdownItem = () =>
wrapper.find('[data-testid="geo-mobile-remove-action"]');
describe('template', () => {
describe('always', () => {
beforeEach(() => {
createComponent();
});
it('renders Dropdown', () => {
expect(findGeoMobileActionsDropdown().exists()).toBe(true);
});
it('renders an Edit and Remove dropdown item', () => {
expect(findGeoMobileActionsDropdownItems().wrappers.map((w) => w.text())).toStrictEqual([
'Edit',
'Remove',
]);
});
});
describe.each`
primary | disabled | dropdownClass
${true} | ${'true'} | ${'gl-text-gray-400'}
${false} | ${undefined} | ${'gl-text-red-500'}
`(`conditionally`, ({ primary, disabled, dropdownClass }) => {
beforeEach(() => {
createComponent(null, { primary });
});
describe(`when primary is ${primary}`, () => {
it('disables the Mobile Remove dropdown item and adds proper class', () => {
expect(findGeoMobileActionsRemoveDropdownItem().attributes('disabled')).toBe(disabled);
expect(findGeoMobileActionsRemoveDropdownItem().find('span').classes(dropdownClass)).toBe(
true,
);
});
});
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import GeoNodeActions from 'ee/geo_nodes_beta/components/header/geo_node_actions.vue';
import GeoNodeActionsDesktop from 'ee/geo_nodes_beta/components/header/geo_node_actions_desktop.vue';
import GeoNodeActionsMobile from 'ee/geo_nodes_beta/components/header/geo_node_actions_mobile.vue';
import { MOCK_PRIMARY_VERSION, MOCK_REPLICABLE_TYPES } from 'ee_jest/geo_nodes_beta/mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('GeoNodeActions', () => {
let wrapper;
const defaultProps = {
primary: true,
};
const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
primaryVersion: MOCK_PRIMARY_VERSION.version,
primaryRevision: MOCK_PRIMARY_VERSION.revision,
replicableTypes: MOCK_REPLICABLE_TYPES,
...initialState,
},
});
wrapper = shallowMount(GeoNodeActions, {
localVue,
store,
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findGeoMobileActions = () => wrapper.find(GeoNodeActionsMobile);
const findGeoDesktopActions = () => wrapper.find(GeoNodeActionsDesktop);
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders mobile actions with correct visibility class always', () => {
expect(findGeoMobileActions().exists()).toBe(true);
expect(findGeoMobileActions().classes()).toStrictEqual(['gl-lg-display-none']);
});
it('renders desktop actions with correct visibility class always', () => {
expect(findGeoDesktopActions().exists()).toBe(true);
expect(findGeoDesktopActions().classes()).toStrictEqual([
'gl-display-none',
'gl-lg-display-flex',
]);
});
});
});
import { GlButton, GlBadge } from '@gitlab/ui'; import { GlButton, GlBadge } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import GeoNodeActions from 'ee/geo_nodes_beta/components/header/geo_node_actions.vue';
import GeoNodeHeader from 'ee/geo_nodes_beta/components/header/geo_node_header.vue'; import GeoNodeHeader from 'ee/geo_nodes_beta/components/header/geo_node_header.vue';
import GeoNodeHealthStatus from 'ee/geo_nodes_beta/components/header/geo_node_health_status.vue';
import GeoNodeLastUpdated from 'ee/geo_nodes_beta/components/header/geo_node_last_updated.vue';
import { import {
MOCK_PRIMARY_VERSION, MOCK_PRIMARY_VERSION,
MOCK_REPLICABLE_TYPES, MOCK_REPLICABLE_TYPES,
...@@ -41,17 +44,31 @@ describe('GeoNodeHeader', () => { ...@@ -41,17 +44,31 @@ describe('GeoNodeHeader', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
const findHeaderCollapseButton = () => wrapper.find(GlButton); const findHeaderCollapseButton = () => wrapper.find(GlButton);
const findCurrentNodeBadge = () => wrapper.find(GlBadge); const findCurrentNodeBadge = () => wrapper.find(GlBadge);
const findGeoNodeHealthStatus = () => wrapper.find(GeoNodeHealthStatus);
const findGeoNodeLastUpdated = () => wrapper.find(GeoNodeLastUpdated);
const findGeoNodeActions = () => wrapper.find(GeoNodeActions);
describe('template', () => { describe('template', () => {
describe('always', () => { describe('always', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
}); });
it('renders the Geo Node Health Status', () => {
expect(findGeoNodeHealthStatus().exists()).toBe(true);
});
it('renders the Geo Node Last Updated', () => {
expect(findGeoNodeLastUpdated().exists()).toBe(true);
});
it('renders the Geo Node Actions', () => {
expect(findGeoNodeActions().exists()).toBe(true);
});
}); });
describe('Header Collapse Icon', () => { describe('Header Collapse Icon', () => {
......
import { GlIcon, GlBadge } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import GeoNodeHealthStatus from 'ee/geo_nodes_beta/components/header/geo_node_health_status.vue';
import { HEALTH_STATUS_UI } from 'ee/geo_nodes_beta/constants';
import { MOCK_PRIMARY_VERSION, MOCK_REPLICABLE_TYPES } from 'ee_jest/geo_nodes_beta/mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('GeoNodeHealthStatus', () => {
let wrapper;
const defaultProps = {
status: 'Healthy',
};
const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
primaryVersion: MOCK_PRIMARY_VERSION.version,
primaryRevision: MOCK_PRIMARY_VERSION.revision,
replicableTypes: MOCK_REPLICABLE_TYPES,
...initialState,
},
});
wrapper = shallowMount(GeoNodeHealthStatus, {
localVue,
store,
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findGeoStatusBadge = () => wrapper.find(GlBadge);
const findGeoStatusIcon = () => wrapper.find(GlIcon);
const findGeoStatusText = () => wrapper.find('span');
describe.each`
status | uiData
${'Healthy'} | ${HEALTH_STATUS_UI.healthy}
${'Unhealthy'} | ${HEALTH_STATUS_UI.unhealthy}
${'Disabled'} | ${HEALTH_STATUS_UI.disabled}
${'Unknown'} | ${HEALTH_STATUS_UI.unknown}
${'Offline'} | ${HEALTH_STATUS_UI.offline}
`(`template`, ({ status, uiData }) => {
beforeEach(() => {
createComponent(null, { status });
});
describe(`when status is ${status}`, () => {
it(`renders badge variant to ${uiData.variant}`, () => {
expect(findGeoStatusBadge().attributes('variant')).toBe(uiData.variant);
});
it(`renders icon to ${uiData.icon}`, () => {
expect(findGeoStatusIcon().attributes('name')).toBe(uiData.icon);
});
it(`renders status text to ${status}`, () => {
expect(findGeoStatusText().text()).toBe(status);
});
});
});
});
import { GlPopover, GlLink, GlIcon } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import GeoNodeLastUpdated from 'ee/geo_nodes_beta/components/header/geo_node_last_updated.vue';
import {
HELP_NODE_HEALTH_URL,
GEO_TROUBLESHOOTING_URL,
STATUS_DELAY_THRESHOLD_MS,
} from 'ee/geo_nodes_beta/constants';
import { MOCK_PRIMARY_VERSION, MOCK_REPLICABLE_TYPES } from 'ee_jest/geo_nodes_beta/mock_data';
import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('GeoNodeLastUpdated', () => {
let wrapper;
// The threshold is inclusive so -1 to force stale
const staleStatusTime = differenceInMilliseconds(STATUS_DELAY_THRESHOLD_MS) - 1;
const nonStaleStatusTime = new Date().getTime();
const defaultProps = {
statusCheckTimestamp: staleStatusTime,
};
const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
primaryVersion: MOCK_PRIMARY_VERSION.version,
primaryRevision: MOCK_PRIMARY_VERSION.revision,
replicableTypes: MOCK_REPLICABLE_TYPES,
...initialState,
},
});
wrapper = shallowMount(GeoNodeLastUpdated, {
localVue,
store,
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findMainText = () => wrapper.find('[data-testid="last-updated-main-text"]');
const findGlIcon = () => wrapper.find(GlIcon);
const findGlPopover = () => wrapper.find(GlPopover);
const findPopoverText = () => findGlPopover().find('p');
const findPopoverLink = () => findGlPopover().find(GlLink);
describe('template', () => {
describe('always', () => {
beforeEach(() => {
createComponent();
});
it('renders main text correctly', () => {
expect(findMainText().exists()).toBe(true);
expect(findMainText().text()).toBe('Updated 10 minutes ago');
});
it('renders the question icon correctly', () => {
expect(findGlIcon().exists()).toBe(true);
expect(findGlIcon().attributes('name')).toBe('question');
});
it('renders the popover always', () => {
expect(findGlPopover().exists()).toBe(true);
});
it('renders the popover text correctly', () => {
expect(findPopoverText().exists()).toBeTruthy();
expect(findPopoverText().text()).toBe("Node's status was updated 10 minutes ago.");
});
it('renders the popover link always', () => {
expect(findPopoverLink().exists()).toBeTruthy();
});
});
it('when sync is stale popover link renders correctly', () => {
createComponent();
expect(findPopoverLink().text()).toBe('Consult Geo troubleshooting information');
expect(findPopoverLink().attributes('href')).toBe(GEO_TROUBLESHOOTING_URL);
});
it('when sync is not stale popover link renders correctly', () => {
createComponent(null, { statusCheckTimestamp: nonStaleStatusTime });
expect(findPopoverLink().text()).toBe('Learn more about Geo node statuses');
expect(findPopoverLink().attributes('href')).toBe(HELP_NODE_HEALTH_URL);
});
});
});
...@@ -13768,9 +13768,6 @@ msgstr "" ...@@ -13768,9 +13768,6 @@ msgstr ""
msgid "Geo|Go to the primary site" msgid "Geo|Go to the primary site"
msgstr "" msgstr ""
msgid "Geo|Health Status"
msgstr ""
msgid "Geo|If you want to make changes, you must visit the primary site." msgid "Geo|If you want to make changes, you must visit the primary site."
msgstr "" msgstr ""
...@@ -17694,9 +17691,6 @@ msgstr "" ...@@ -17694,9 +17691,6 @@ msgstr ""
msgid "Last Seen" msgid "Last Seen"
msgstr "" msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Last Used" msgid "Last Used"
msgstr "" msgstr ""
......
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