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>
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 {
name: 'GeoNodeHeader',
i18n: {
currentNodeLabel: __('Current'),
},
components: {
GlButton,
GlBadge,
GeoNodeHealthStatus,
GeoNodeLastUpdated,
GeoNodeActions,
},
props: {
node: {
......@@ -22,6 +32,9 @@ export default {
chevronIcon() {
return this.collapsed ? 'chevron-right' : 'chevron-down';
},
statusCheckTimestamp() {
return this.node.lastSuccessfulStatusCheckTimestamp * 1000;
},
},
};
</script>
......@@ -43,18 +56,18 @@ export default {
>
<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">{{
__('Current')
$options.i18n.currentNodeLabel
}}</gl-badge>
<h4 class="gl-font-lg">{{ node.name }}</h4>
</div>
<div class="gl-display-flex gl-align-items-center gl-flex-fill-2">
<span>{{ s__('Geo|Health Status') }}</span>
<span class="gl-ml-2">{{ __('Last Updated') }}</span>
<geo-node-health-status :status="node.healthStatus" />
<geo-node-last-updated class="gl-ml-2" :status-check-timestamp="statusCheckTimestamp" />
</div>
</div>
</div>
<div class="gl-display-flex gl-align-items-center gl-justify-content-end">
<span>{{ __('Actions') }}</span>
<geo-node-actions :primary="node.primary" />
</div>
</div>
</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';
export const GEO_INFO_URL = helpPagePath('administration/geo/index.md');
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 { createLocalVue, shallowMount } from '@vue/test-utils';
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 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 {
MOCK_PRIMARY_VERSION,
MOCK_REPLICABLE_TYPES,
......@@ -41,17 +44,31 @@ describe('GeoNodeHeader', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findHeaderCollapseButton = () => wrapper.find(GlButton);
const findCurrentNodeBadge = () => wrapper.find(GlBadge);
const findGeoNodeHealthStatus = () => wrapper.find(GeoNodeHealthStatus);
const findGeoNodeLastUpdated = () => wrapper.find(GeoNodeLastUpdated);
const findGeoNodeActions = () => wrapper.find(GeoNodeActions);
describe('template', () => {
describe('always', () => {
beforeEach(() => {
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', () => {
......
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 ""
msgid "Geo|Go to the primary site"
msgstr ""
msgid "Geo|Health Status"
msgstr ""
msgid "Geo|If you want to make changes, you must visit the primary site."
msgstr ""
......@@ -17694,9 +17691,6 @@ msgstr ""
msgid "Last Seen"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Last Used"
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