Commit 18511be9 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch '3195-geo-admin-enhancements' into 'master'

Enhancements for Geo admin screen

Closes #3195

See merge request gitlab-org/gitlab-ee!3545
parents 89311f9f 0df7c295
/* eslint-disable no-new*/ /* eslint-disable no-new*/
import axios from 'axios'; import axios from 'axios';
import SmartInterval from '~/smart_interval'; import SmartInterval from '~/smart_interval';
import { s__ } from '~/locale';
import { parseSeconds, stringifyTime } from './lib/utils/pretty_time'; import { parseSeconds, stringifyTime } from './lib/utils/pretty_time';
import { addDelimiter } from './lib/utils/text_utility'; import { timeIntervalInWords } from './lib/utils/datetime_utility';
import timeago from './vue_shared/mixins/timeago';
const healthyClass = 'geo-node-healthy'; const healthyClass = 'geo-node-healthy';
const unhealthyClass = 'geo-node-unhealthy'; const unhealthyClass = 'geo-node-unhealthy';
...@@ -20,18 +22,16 @@ class GeoNodeStatus { ...@@ -20,18 +22,16 @@ class GeoNodeStatus {
this.$dbReplicationLag = $('.js-db-replication-lag', this.$status); this.$dbReplicationLag = $('.js-db-replication-lag', this.$status);
this.$healthStatus = $('.js-health-status', this.$el); this.$healthStatus = $('.js-health-status', this.$el);
this.$status = $('.js-geo-node-status', this.$el); this.$status = $('.js-geo-node-status', this.$el);
this.$repositoriesSynced = $('.js-repositories-synced', this.$status); this.$repositories = $('.js-repositories', this.$status);
this.$repositoriesFailed = $('.js-repositories-failed', this.$status); this.$lfsObjects = $('.js-lfs-objects', this.$status);
this.$lfsObjectsSynced = $('.js-lfs-objects-synced', this.$status); this.$attachments = $('.js-attachments', this.$status);
this.$lfsObjectsFailed = $('.js-lfs-objects-failed', this.$status); this.$syncSettings = $('.js-sync-settings', this.$status);
this.$attachmentsSynced = $('.js-attachments-synced', this.$status);
this.$attachmentsFailed = $('.js-attachments-failed', this.$status);
this.$lastEventSeen = $('.js-last-event-seen', this.$status); this.$lastEventSeen = $('.js-last-event-seen', this.$status);
this.$lastCursorEvent = $('.js-last-cursor-event', this.$status); this.$lastCursorEvent = $('.js-last-cursor-event', this.$status);
this.$health = $('.js-health', this.$status); this.$health = $('.js-health-message', this.$status.parent());
this.endpoint = this.$el.data('status-url'); this.endpoint = this.$el.data('status-url');
this.$advancedStatus = $('.js-advanced-geo-node-status-toggler', this.$status); this.$advancedStatus = $('.js-advanced-geo-node-status-toggler', this.$status.parent());
this.$advancedStatus.on('click', GeoNodeStatus.toggleShowAdvancedStatus); this.$advancedStatus.on('click', GeoNodeStatus.toggleShowAdvancedStatus.bind(this));
this.statusInterval = new SmartInterval({ this.statusInterval = new SmartInterval({
callback: this.getStatus.bind(this), callback: this.getStatus.bind(this),
...@@ -45,26 +45,123 @@ class GeoNodeStatus { ...@@ -45,26 +45,123 @@ class GeoNodeStatus {
static toggleShowAdvancedStatus(e) { static toggleShowAdvancedStatus(e) {
const $element = $(e.currentTarget); const $element = $(e.currentTarget);
const $closestStatus = $element.siblings('.advanced-status'); const $advancedStatusItems = this.$status.find('.js-advanced-status');
$element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up'); $element.find('.js-advance-toggle')
$closestStatus.toggleClass('hidden'); .html(gl.utils.spriteIcon($advancedStatusItems.is(':hidden') ? 'angle-up' : 'angle-down', 's16'));
$advancedStatusItems.toggleClass('hidden');
} }
static formatCountAndPercentage(count, total, percentage) { static getSyncStatistics({ syncedCount, failedCount, totalCount }) {
if (count !== null || total != null) { const syncedPercent = Math.ceil((syncedCount / totalCount) * 100);
return `${addDelimiter(count)}/${addDelimiter(total)} (${percentage})`; const failedPercent = Math.ceil((failedCount / totalCount) * 100);
const waitingPercent = 100 - syncedPercent - failedPercent;
return {
syncedPercent,
waitingPercent,
failedPercent,
syncedCount,
failedCount,
waitingCount: totalCount - syncedCount - failedCount,
};
} }
return notAvailable; static renderSyncGraph($itemEl, syncStats) {
const graphItems = [
{
itemSel: '.js-synced',
itemTooltip: s__('GeoNodeSyncStatus|Synced'),
itemCount: syncStats.syncedCount,
itemPercent: syncStats.syncedPercent,
},
{
itemSel: '.js-waiting',
itemTooltip: s__('GeoNodeSyncStatus|Out of sync'),
itemCount: syncStats.waitingCount,
itemPercent: syncStats.waitingPercent,
},
{
itemSel: '.js-failed',
itemTooltip: s__('GeoNodeSyncStatus|Failed'),
itemCount: syncStats.failedCount,
itemPercent: syncStats.failedPercent,
},
];
$itemEl.find('.js-stats-unavailable')
.toggleClass('hidden',
!!graphItems[0].itemCount ||
!!graphItems[1].itemCount ||
!!graphItems[2].itemCount);
graphItems.forEach((item) => {
$itemEl.find(item.itemSel)
.toggleClass('has-value has-tooltip', !!item.itemCount)
.attr('data-original-title', `${item.itemTooltip}: ${item.itemPercent}%`)
.text(item.itemCount || '')
.css('width', `${item.itemPercent}%`);
});
}
static renderEventStats($eventEl, eventId, eventTimestamp) {
const $eventTimestampEl = $eventEl.find('.js-event-timestamp');
let eventDate = notAvailable;
if (eventTimestamp && eventTimestamp > 0) {
eventDate = gl.utils.formatDate(new Date(eventTimestamp * 1000));
}
if (eventId) {
$eventEl.find('.js-event-id').text(eventId);
$eventTimestampEl
.attr('title', eventDate)
.text(`(${timeago.methods.timeFormated(eventDate)})`);
}
} }
static formatCount(count) { static renderSyncSettings($syncSettings, namespaces, eventStats) {
if (count !== null) { const { lastEventId, lastEventTimestamp, cursorEventId, cursorEventTimestamp } = eventStats;
return addDelimiter(count); const $syncStatusIcon = $syncSettings.find('.js-sync-status-icon');
const DIFFS = {
FIVE_MINS: 300,
HOUR: 3600,
};
let eventDateTime;
let cursorDateTime;
$syncSettings.find('.js-sync-type')
.text(namespaces.length > 0 ? 'Selective' : 'Full');
if (lastEventTimestamp && lastEventTimestamp > 0) {
eventDateTime = new Date(lastEventTimestamp * 1000);
} }
return notAvailable; if (cursorEventTimestamp && cursorEventTimestamp > 0) {
cursorDateTime = new Date(cursorEventTimestamp * 1000);
}
const timeDiffInSeconds = (cursorDateTime - eventDateTime) / 1000;
if (timeDiffInSeconds <= DIFFS.FIVE_MINS) {
// Lag is under 5 mins
$syncStatusIcon.html(gl.utils.spriteIcon('retry', 's16'));
} else if (timeDiffInSeconds > DIFFS.FIVE_MINS &&
timeDiffInSeconds <= DIFFS.HOUR) {
// Lag is between 5 mins to an hour
$syncStatusIcon.html(gl.utils.spriteIcon('warning', 's16'));
$syncSettings.attr('data-original-title', s__('GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage.'));
} else {
// Lag is over an hour
$syncSettings.find('.js-sync-status').addClass('sync-status-failure');
$syncStatusIcon.html(gl.utils.spriteIcon('status_failed', 's16'));
$syncSettings.attr('data-original-title', s__('GeoNodeSyncStatus|Node is failing or broken.'));
}
const timeAgoStr = timeIntervalInWords(timeDiffInSeconds);
const pendingEvents = lastEventId - cursorEventId;
$syncSettings
.find('.js-sync-status-timestamp')
.text(`${timeAgoStr} (${pendingEvents} events)`);
} }
getStatus() { getStatus() {
...@@ -80,7 +177,11 @@ class GeoNodeStatus { ...@@ -80,7 +177,11 @@ class GeoNodeStatus {
handleStatus(status) { handleStatus(status) {
this.setStatusIcon(status.healthy); this.setStatusIcon(status.healthy);
this.setHealthStatus(status.healthy); this.setHealthStatus({
healthy: status.healthy,
healthStatus: status.health_status,
healthMessage: status.health,
});
// Replication lag can be nil if the secondary isn't actually streaming // Replication lag can be nil if the secondary isn't actually streaming
if (status.db_replication_lag_seconds !== null && status.db_replication_lag_seconds >= 0) { if (status.db_replication_lag_seconds !== null && status.db_replication_lag_seconds >= 0) {
...@@ -93,73 +194,62 @@ class GeoNodeStatus { ...@@ -93,73 +194,62 @@ class GeoNodeStatus {
this.$dbReplicationLag.text('UNKNOWN'); this.$dbReplicationLag.text('UNKNOWN');
} }
const repoText = GeoNodeStatus.formatCountAndPercentage( if (status.repositories_count > 0) {
status.repositories_synced_count, const repositoriesStats = GeoNodeStatus.getSyncStatistics({
status.repositories_count, syncedCount: status.repositories_synced_count,
status.repositories_synced_in_percentage); failedCount: status.repositories_failed_count,
totalCount: status.repositories_count,
const repoFailedText = GeoNodeStatus.formatCount(status.repositories_failed_count); });
GeoNodeStatus.renderSyncGraph(this.$repositories, repositoriesStats);
const lfsText = GeoNodeStatus.formatCountAndPercentage(
status.lfs_objects_synced_count,
status.lfs_objects_count,
status.lfs_objects_synced_in_percentage);
const lfsFailedText = GeoNodeStatus.formatCount(status.lfs_objects_failed_count);
const attachmentText = GeoNodeStatus.formatCountAndPercentage(
status.attachments_synced_count,
status.attachments_count,
status.attachments_synced_in_percentage);
const attachmentFailedText = GeoNodeStatus.formatCount(status.attachments_failed_count);
this.$repositoriesSynced.text(repoText);
this.$repositoriesFailed.text(repoFailedText);
this.$lfsObjectsSynced.text(lfsText);
this.$lfsObjectsFailed.text(lfsFailedText);
this.$attachmentsSynced.text(attachmentText);
this.$attachmentsFailed.text(attachmentFailedText);
let eventDate = notAvailable;
let cursorDate = notAvailable;
let lastEventSeen = notAvailable;
let lastCursorEvent = notAvailable;
if (status.last_event_timestamp !== null && status.last_event_timestamp > 0) {
eventDate = gl.utils.formatDate(new Date(status.last_event_timestamp * 1000));
} }
if (status.cursor_last_event_timestamp !== null && status.cursor_last_event_timestamp > 0) { if (status.lfs_objects_count > 0) {
cursorDate = gl.utils.formatDate(new Date(status.cursor_last_event_timestamp * 1000)); const lfsObjectsStats = GeoNodeStatus.getSyncStatistics({
syncedCount: status.lfs_objects_synced_count,
failedCount: status.lfs_objects_failed_count,
totalCount: status.lfs_objects_count,
});
GeoNodeStatus.renderSyncGraph(this.$lfsObjects, lfsObjectsStats);
} }
if (status.last_event_id !== null) { if (status.attachments_count > 0) {
lastEventSeen = `${status.last_event_id} (${eventDate})`; const attachmentsStats = GeoNodeStatus.getSyncStatistics({
syncedCount: status.attachments_synced_count,
failedCount: status.attachments_failed_count,
totalCount: status.attachments_count,
});
GeoNodeStatus.renderSyncGraph(this.$attachments, attachmentsStats);
} }
if (status.cursor_last_event_id !== null) { if (status.namespaces) {
lastCursorEvent = `${status.cursor_last_event_id} (${cursorDate})`; GeoNodeStatus.renderSyncSettings(
this.$syncSettings,
status.namespaces, {
lastEventId: status.last_event_id,
lastEventTimestamp: status.last_event_timestamp,
cursorEventId: status.cursor_last_event_id,
cursorEventTimestamp: status.cursor_last_event_timestamp,
});
} }
this.$lastEventSeen.text(lastEventSeen); GeoNodeStatus.renderEventStats(
this.$lastCursorEvent.text(lastCursorEvent); this.$lastEventSeen,
status.last_event_id,
if (status.health === 'Healthy') { status.last_event_timestamp);
this.$health.text(''); GeoNodeStatus.renderEventStats(
} else { this.$lastCursorEvent,
const strippedData = $('<div>').html(`${status.health}`).text(); status.cursor_last_event_id,
this.$health.html(`<code class="geo-health">${strippedData}</code>`); status.cursor_last_event_timestamp);
}
this.$status.show(); this.$status.removeClass('hidden');
} }
handleError(err) { handleError(err) {
this.setStatusIcon(false); this.setStatusIcon(false);
this.setHealthStatus(false); this.setHealthStatus(false);
this.$health.html(`<code class="geo-health">${err}</code>`); this.$health.text(err);
this.$status.show(); this.$health.removeClass('hidden');
this.$status.removeClass('hidden');
} }
setStatusIcon(healthy) { setStatusIcon(healthy) {
...@@ -177,15 +267,20 @@ class GeoNodeStatus { ...@@ -177,15 +267,20 @@ class GeoNodeStatus {
} }
} }
setHealthStatus(healthy) { setHealthStatus({ healthy, healthStatus, healthMessage }) {
if (healthy) { if (healthy) {
this.$healthStatus.removeClass(unhealthyClass) this.$healthStatus.removeClass(unhealthyClass)
.addClass(healthyClass) .addClass(healthyClass)
.text('Healthy'); .text(healthMessage);
this.$health.text('');
this.$health.addClass('hidden');
} else { } else {
this.$healthStatus.removeClass(healthyClass) this.$healthStatus.removeClass(healthyClass)
.addClass(unhealthyClass) .addClass(unhealthyClass)
.text('Unhealthy'); .text(healthStatus);
const strippedData = $('<div>').html(`${healthMessage}`).text();
this.$health.text(strippedData);
this.$health.removeClass('hidden');
} }
} }
} }
......
...@@ -58,3 +58,4 @@ ...@@ -58,3 +58,4 @@
@import "framework/snippets"; @import "framework/snippets";
@import "framework/memory_graph"; @import "framework/memory_graph";
@import "framework/responsive_tables"; @import "framework/responsive_tables";
@import "framework/stacked-progress-bar";
.stacked-progress-bar {
display: flex;
height: 20px;
width: 350px;
border-radius: 10px;
overflow: hidden;
background-color: $theme-gray-100;
.status-unavailable,
.status-green,
.status-neutral,
.status-red, {
height: 100%;
font-weight: normal;
color: $white-light;
line-height: 20px;
&.has-value {
padding: 0 10px;
}
&:hover {
cursor: pointer;
}
}
.status-unavailable {
padding: 0 10px;
color: $theme-gray-700;
}
.status-green {
background-color: $green-500;
&:hover {
background-color: $green-600;
}
}
.status-neutral {
background-color: $theme-gray-200;
color: $gl-gray-dark;
&:hover {
background-color: $theme-gray-300;
}
}
.status-red {
background-color: $red-500;
&:hover {
background-color: $red-600;
}
}
}
.geo-admin-container {
.page-title,
.page-title-separator {
margin-top: 10px;
}
.title-text {
line-height: 34px;
}
.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 {
font-weight: normal;
color: $theme-gray-800;
}
.sync-status {
font-weight: normal;
svg {
vertical-align: middle;
}
.sync-status-icon svg,
.sync-status-timestamp {
fill: $theme-gray-700;
color: $theme-gray-700;
}
&.sync-status-failure {
.sync-status-icon svg,
.sync-status-timestamp {
fill: $red-700;
color: $red-700;
}
}
}
}
.advanced-geo-node-status-toggler {
display: block;
.show-advance-chevron {
margin-top: 2px;
}
}
.geo-node-healthy { .geo-node-healthy {
color: $gl-success; color: $gl-success;
} }
...@@ -52,6 +117,16 @@ ...@@ -52,6 +117,16 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
.geo-nodes {
.health-message {
padding: 1px 8px;
background-color: $red-100;
color: $red-500;
border-radius: $border-radius-default;
font-weight: 500;
}
}
.node-badge { .node-badge {
color: $white-light; color: $white-light;
display: inline-block; display: inline-block;
......
...@@ -3,6 +3,7 @@ class GeoNodeStatus < ActiveRecord::Base ...@@ -3,6 +3,7 @@ class GeoNodeStatus < ActiveRecord::Base
# Whether we were successful in reaching this node # Whether we were successful in reaching this node
attr_accessor :success attr_accessor :success
attr_accessor :health_status
# Be sure to keep this consistent with Prometheus naming conventions # Be sure to keep this consistent with Prometheus naming conventions
PROMETHEUS_METRICS = { PROMETHEUS_METRICS = {
...@@ -47,8 +48,8 @@ class GeoNodeStatus < ActiveRecord::Base ...@@ -47,8 +48,8 @@ class GeoNodeStatus < ActiveRecord::Base
end end
def self.allowed_params def self.allowed_params
excluded_params = %w(id last_successful_status_check_at created_at updated_at).freeze excluded_params = %w(id created_at updated_at).freeze
extra_params = %w(success health last_event_timestamp cursor_last_event_timestamp).freeze extra_params = %w(success health health_status last_event_timestamp cursor_last_event_timestamp).freeze
self.column_names - excluded_params + extra_params self.column_names - excluded_params + extra_params
end end
...@@ -89,6 +90,10 @@ class GeoNodeStatus < ActiveRecord::Base ...@@ -89,6 +90,10 @@ class GeoNodeStatus < ActiveRecord::Base
status_message.blank? || status_message == 'Healthy'.freeze status_message.blank? || status_message == 'Healthy'.freeze
end end
def health_status
@health_status || (healthy? ? 'Healthy' : 'Unhealthy')
end
def last_successful_status_check_timestamp def last_successful_status_check_timestamp
self.last_successful_status_check_at.to_i self.last_successful_status_check_at.to_i
end end
......
...@@ -7,6 +7,7 @@ class GeoNodeStatusEntity < Grape::Entity ...@@ -7,6 +7,7 @@ class GeoNodeStatusEntity < Grape::Entity
expose :health do |node| expose :health do |node|
node.healthy? ? 'Healthy' : node.health node.healthy? ? 'Healthy' : node.health
end end
expose :health_status
expose :attachments_count expose :attachments_count
expose :attachments_synced_count expose :attachments_synced_count
...@@ -37,4 +38,10 @@ class GeoNodeStatusEntity < Grape::Entity ...@@ -37,4 +38,10 @@ class GeoNodeStatusEntity < Grape::Entity
expose :cursor_last_event_timestamp expose :cursor_last_event_timestamp
expose :last_successful_status_check_timestamp expose :last_successful_status_check_timestamp
expose :namespaces, using: NamespaceEntity
def namespaces
object.geo_node.namespaces
end
end end
class NamespaceEntity < Grape::Entity
expose :id, :name, :path, :kind, :full_path, :parent_id
end
...@@ -6,7 +6,8 @@ module Geo ...@@ -6,7 +6,8 @@ module Geo
def call(geo_node) def call(geo_node)
return GeoNodeStatus.current_node_status if geo_node.current? return GeoNodeStatus.current_node_status if geo_node.current?
data = { success: false } data = GeoNodeStatus.find_or_initialize_by(geo_node: geo_node).attributes
data = data.merge(success: false, health_status: 'Offline')
begin begin
response = self.class.get(geo_node.status_url, headers: headers, timeout: timeout) response = self.class.get(geo_node.status_url, headers: headers, timeout: timeout)
...@@ -29,8 +30,10 @@ module Geo ...@@ -29,8 +30,10 @@ module Geo
end end
rescue Gitlab::Geo::GeoNodeNotFoundError rescue Gitlab::Geo::GeoNodeNotFoundError
data[:health] = 'This GitLab instance does not appear to be configured properly as a Geo node. Make sure the URLs are using the correct fully-qualified domain names.' data[:health] = 'This GitLab instance does not appear to be configured properly as a Geo node. Make sure the URLs are using the correct fully-qualified domain names.'
data[:health_status] = 'Unhealthy'
rescue OpenSSL::Cipher::CipherError rescue OpenSSL::Cipher::CipherError
data[:health] = 'Error decrypting the Geo secret from the database. Check that the primary uses the correct db_key_base.' data[:health] = 'Error decrypting the Geo secret from the database. Check that the primary uses the correct db_key_base.'
data[:health_status] = 'Unhealthy'
rescue HTTParty::Error, Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e rescue HTTParty::Error, Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
data[:health] = e.message data[:health] = e.message
end end
......
- page_title 'Geo nodes' - page_title 'Geo nodes'
%h3.page-title - @content_class = "geo-admin-container"
%h2.page-title.clearfix
%span.title-text.pull-left
Geo Nodes Geo Nodes
= link_to "New node", new_admin_geo_node_path, class: 'btn btn-create pull-right'
%hr.page-title-separator
%p.light %p.page-subtitle.light
With #{link_to 'GitLab Geo', help_page_path('gitlab-geo/README'), class: 'vlink'} you can install a special With #{link_to 'GitLab Geo', help_page_path('gitlab-geo/README'), class: 'vlink'} you can install a special
read-only and replicated instance anywhere. read-only and replicated instance anywhere.
Before you add nodes, follow the Before you add nodes, follow the
...@@ -11,16 +15,6 @@ ...@@ -11,16 +15,6 @@
%strong exact order %strong exact order
they appear. they appear.
%hr
- if Gitlab::Geo.license_allows?
= form_for [:admin, @node], as: :geo_node, url: admin_geo_nodes_path, html: { class: 'form-horizontal js-geo-node-form' } do |f|
= render partial: 'form', locals: { form: f, geo_node: @node }
.form-actions
= f.submit 'Add Node', class: 'btn btn-create'
%hr
- if @nodes.any? - if @nodes.any?
.panel.panel-default .panel.panel-default
.panel-heading .panel-heading
...@@ -38,59 +32,84 @@ ...@@ -38,59 +32,84 @@
%span.help-block Primary node %span.help-block Primary node
- else - else
= status_loading_icon = status_loading_icon
- if node.restricted_project_ids %table.geo-node-status.js-geo-node-status.hidden
%p
%span.help-block
Namespaces to replicate:
%strong.node-info
= node_selected_namespaces_to_replicate(node)
.js-geo-node-status{ style: 'display: none' }
- if node.enabled? - if node.enabled?
%p %tr
%span.help-block %td
.help-block
Health Status: Health Status:
%span.js-health-status %td
%p .health-status.prepend-top-10.prepend-left-5.js-health-status
%span.help-block %tr
Repositories synced: %td
%strong.node-info.js-repositories-synced .help-block.prepend-top-10
%p Repositories:
%span.help-block %td
Repositories failed: .node-info.prepend-top-10.prepend-left-5.stacked-progress-bar.js-repositories
%strong.node-info.js-repositories-failed %span.status-unavailable.js-stats-unavailable
%p Not available
%span.help-block %span.status-green.js-synced
LFS objects synced: %span.status-neutral.js-waiting
%strong.node-info.js-lfs-objects-synced %span.status-red.js-failed
%p %tr
%span.help-block %td
LFS objects failed: .help-block.prepend-top-10
%strong.node-info.js-lfs-objects-failed LFS objects:
%p %td
%span.help-block .node-info.prepend-top-10.prepend-left-5.stacked-progress-bar.js-lfs-objects
Attachments synced: %span.status-unavailable.js-stats-unavailable
%strong.node-info.js-attachments-synced Not available
%p %span.status-green.js-synced
%span.help-block %span.status-neutral.js-waiting
Attachments failed: %span.status-red.js-failed
%strong.node-info.js-attachments-failed %tr
%p %td
.advanced-geo-node-status-container .help-block.prepend-top-10
.advanced-status.hidden Attachments:
%span.help-block %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: Database replication lag:
%strong.node-info.js-db-replication-lag %td
%span.help-block .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: Last event ID seen from primary:
%strong.node-info.js-last-event-seen %td
%span.help-block .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: Last event ID processed by cursor:
%strong.node-info.js-last-cursor-event %td
%button.btn-link.js-advanced-geo-node-status-toggler .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> Advanced
= icon('angle-down') %span.js-advance-toggle.show-advance-chevron.pull-right.inline.prepend-left-5
%p = sprite_icon('angle-down', css_class: 's16')
.js-health %p.health-message.hidden.js-health-message
- if Gitlab::Database.read_write? - if Gitlab::Database.read_write?
.node-actions .node-actions
......
- page_title 'New node'
- @content_class = "geo-admin-container"
%h2.page-title
%span.title-text
New node
%hr.page-title-separator
- if Gitlab::Geo.license_allows?
= form_for [:admin, @node], as: :geo_node, url: admin_geo_nodes_path, html: { class: 'form-horizontal js-geo-node-form' } do |f|
= render partial: 'form', locals: { form: f, geo_node: @node }
.form-actions
= f.submit 'Add Node', class: 'btn btn-create'
---
title: Enhancements for Geo admin screen
merge_request: 3545
author:
type: changed
...@@ -127,7 +127,7 @@ namespace :admin do ...@@ -127,7 +127,7 @@ namespace :admin do
get :download, on: :member get :download, on: :member
end end
resources :geo_nodes, only: [:index, :create, :edit, :update, :destroy] do resources :geo_nodes, only: [:index, :create, :new, :edit, :update, :destroy] do
member do member do
post :repair post :repair
post :toggle post :toggle
......
...@@ -22,10 +22,14 @@ class Admin::GeoNodesController < Admin::ApplicationController ...@@ -22,10 +22,14 @@ class Admin::GeoNodesController < Admin::ApplicationController
else else
@nodes = GeoNode.all @nodes = GeoNode.all
render :index render :new
end end
end end
def new
@node = GeoNode.new
end
def update def update
if Geo::NodeUpdateService.new(@node, geo_node_params).execute if Geo::NodeUpdateService.new(@node, geo_node_params).execute
redirect_to admin_geo_nodes_path, notice: 'Node was successfully updated.' redirect_to admin_geo_nodes_path, notice: 'Node was successfully updated.'
......
...@@ -30,8 +30,10 @@ describe Admin::GeoNodesController, :postgresql do ...@@ -30,8 +30,10 @@ describe Admin::GeoNodesController, :postgresql do
allow(Gitlab::Geo).to receive(:license_allows?).and_return(true) allow(Gitlab::Geo).to receive(:license_allows?).and_return(true)
end end
it 'renders creation form' do it 'does not display a flash message' do
expect(go).to render_template(partial: 'admin/geo_nodes/_form') go
expect(flash).not_to include(:alert)
end end
end end
...@@ -40,10 +42,6 @@ describe Admin::GeoNodesController, :postgresql do ...@@ -40,10 +42,6 @@ describe Admin::GeoNodesController, :postgresql do
allow(Gitlab::Geo).to receive(:license_allows?).and_return(false) allow(Gitlab::Geo).to receive(:license_allows?).and_return(false)
end end
it 'does not render the creation form' do
expect(go).not_to render_template(partial: 'admin/geo_nodes/_form')
end
it 'displays a flash message' do it 'displays a flash message' do
go go
......
...@@ -18,6 +18,7 @@ FactoryGirl.define do ...@@ -18,6 +18,7 @@ FactoryGirl.define do
last_event_timestamp Time.now.to_i last_event_timestamp Time.now.to_i
cursor_last_event_id 1 cursor_last_event_id 1
cursor_last_event_timestamp Time.now.to_i cursor_last_event_timestamp Time.now.to_i
last_successful_status_check_timestamp Time.now.beginning_of_day
end end
trait :unhealthy do trait :unhealthy do
......
...@@ -8,9 +8,10 @@ RSpec.describe 'admin Geo Nodes', type: :feature do ...@@ -8,9 +8,10 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
sign_in(create(:admin)) sign_in(create(:admin))
end end
it 'show all public Geo Nodes' do it 'show all public Geo Nodes and create new node link' do
visit admin_geo_nodes_path visit admin_geo_nodes_path
expect(page).to have_link('New node', href: new_admin_geo_node_path)
page.within(find('.geo-nodes', match: :first)) do page.within(find('.geo-nodes', match: :first)) do
expect(page).to have_content(geo_node.url) expect(page).to have_content(geo_node.url)
end end
...@@ -20,7 +21,7 @@ RSpec.describe 'admin Geo Nodes', type: :feature do ...@@ -20,7 +21,7 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
let(:new_ssh_key) { attributes_for(:key)[:key] } let(:new_ssh_key) { attributes_for(:key)[:key] }
before do before do
visit admin_geo_nodes_path visit new_admin_geo_node_path
end end
it 'creates a new Geo Node' do it 'creates a new Geo Node' do
...@@ -36,12 +37,10 @@ RSpec.describe 'admin Geo Nodes', type: :feature do ...@@ -36,12 +37,10 @@ RSpec.describe 'admin Geo Nodes', type: :feature do
end end
it 'returns an error message when a duplicate primary is added' do it 'returns an error message when a duplicate primary is added' do
check 'This is a primary node' create(:geo_node, :primary)
fill_in 'geo_node_url', with: 'https://test.example.com'
click_button 'Add Node'
check 'This is a primary node' check 'This is a primary node'
fill_in 'geo_node_url', with: 'https://secondary.example.com' fill_in 'geo_node_url', with: 'https://another-primary.example.com'
click_button 'Add Node' click_button 'Add Node'
expect(current_path).to eq admin_geo_nodes_path expect(current_path).to eq admin_geo_nodes_path
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
"geo_node_id", "geo_node_id",
"healthy", "healthy",
"health", "health",
"health_status",
"attachments_count", "attachments_count",
"attachments_failed_count", "attachments_failed_count",
"attachments_synced_count", "attachments_synced_count",
...@@ -17,12 +18,14 @@ ...@@ -17,12 +18,14 @@
"last_event_id", "last_event_id",
"last_event_timestamp", "last_event_timestamp",
"cursor_last_event_id", "cursor_last_event_id",
"cursor_last_event_timestamp" "cursor_last_event_timestamp",
"namespaces"
], ],
"properties" : { "properties" : {
"geo_node_id": { "type": "integer" }, "geo_node_id": { "type": "integer" },
"healthy": { "type": "boolean" }, "healthy": { "type": "boolean" },
"health": { "type": ["string", "null"] }, "health": { "type": ["string", "null"] },
"health_status": { "type": "string" },
"attachments_count": { "type": "integer" }, "attachments_count": { "type": "integer" },
"attachments_failed_count": { "type": "integer" }, "attachments_failed_count": { "type": "integer" },
"attachments_synced_count": { "type": "integer" }, "attachments_synced_count": { "type": "integer" },
...@@ -40,7 +43,8 @@ ...@@ -40,7 +43,8 @@
"last_event_timestamp": { "type": ["integer", "null"] }, "last_event_timestamp": { "type": ["integer", "null"] },
"cursor_last_event_id": { "type": ["integer", "null"] }, "cursor_last_event_id": { "type": ["integer", "null"] },
"cursor_last_event_timestamp": { "type": ["integer", "null"] }, "cursor_last_event_timestamp": { "type": ["integer", "null"] },
"last_successful_status_check_timestamp": { "type": ["integer", "null"] } "last_successful_status_check_timestamp": { "type": ["integer", "null"] },
"namespaces": { "type": "array" }
}, },
"additionalProperties": false "additionalProperties": false
} }
require 'spec_helper' require 'spec_helper'
describe GeoNodeStatusEntity, :postgresql do describe GeoNodeStatusEntity, :postgresql do
let(:geo_node_status) do let(:geo_node_status) { build(:geo_node_status) }
GeoNodeStatus.new( let(:entity) { described_class.new(geo_node_status, request: double) }
geo_node_id: 1, let(:error) { 'Could not connect to Geo database' }
health: '',
attachments_count: 329,
attachments_failed_count: 25,
attachments_synced_count: 141,
lfs_objects_count: 256,
lfs_objects_failed_count: 12,
lfs_objects_synced_count: 123,
repositories_count: 10,
repositories_synced_count: 5,
repositories_failed_count: 0,
last_successful_status_check_timestamp: Time.now.beginning_of_day
)
end
let(:entity) do
described_class.new(geo_node_status, request: double)
end
let(:error) do
'Could not connect to Geo database'
end
subject { entity.as_json } subject { entity.as_json }
...@@ -44,6 +23,7 @@ describe GeoNodeStatusEntity, :postgresql do ...@@ -44,6 +23,7 @@ describe GeoNodeStatusEntity, :postgresql do
it { is_expected.to have_key(:repositories_synced_count)} it { is_expected.to have_key(:repositories_synced_count)}
it { is_expected.to have_key(:repositories_synced_in_percentage) } it { is_expected.to have_key(:repositories_synced_in_percentage) }
it { is_expected.to have_key(:last_successful_status_check_timestamp) } it { is_expected.to have_key(:last_successful_status_check_timestamp) }
it { is_expected.to have_key(:namespaces) }
describe '#healthy' do describe '#healthy' do
context 'when node is healthy' do context 'when node is healthy' do
...@@ -87,19 +67,47 @@ describe GeoNodeStatusEntity, :postgresql do ...@@ -87,19 +67,47 @@ describe GeoNodeStatusEntity, :postgresql do
describe '#attachments_synced_in_percentage' do describe '#attachments_synced_in_percentage' do
it 'formats as percentage' do it 'formats as percentage' do
geo_node_status.assign_attributes(attachments_count: 329,
attachments_failed_count: 25,
attachments_synced_count: 141)
expect(subject[:attachments_synced_in_percentage]).to eq '42.86%' expect(subject[:attachments_synced_in_percentage]).to eq '42.86%'
end end
end end
describe '#lfs_objects_synced_in_percentage' do describe '#lfs_objects_synced_in_percentage' do
it 'formats as percentage' do it 'formats as percentage' do
geo_node_status.assign_attributes(lfs_objects_count: 256,
lfs_objects_failed_count: 12,
lfs_objects_synced_count: 123)
expect(subject[:lfs_objects_synced_in_percentage]).to eq '48.05%' expect(subject[:lfs_objects_synced_in_percentage]).to eq '48.05%'
end end
end end
describe '#repositories_synced_in_percentage' do describe '#repositories_synced_in_percentage' do
it 'formats as percentage' do it 'formats as percentage' do
geo_node_status.assign_attributes(repositories_count: 10,
repositories_synced_count: 5,
repositories_failed_count: 0)
expect(subject[:repositories_synced_in_percentage]).to eq '50.00%' expect(subject[:repositories_synced_in_percentage]).to eq '50.00%'
end end
end end
describe '#namespaces' do
it 'returns empty array when full sync is active' do
expect(subject[:namespaces]).to be_empty
end
it 'returns array of namespace ids and paths for selective sync' do
namespace = create(:namespace)
geo_node_status.geo_node.namespaces << namespace
expect(subject[:namespaces]).not_to be_empty
expect(subject[:namespaces]).to be_an(Array)
expect(subject[:namespaces].first[:id]).to eq(namespace.id)
expect(subject[:namespaces].first[:path]).to eq(namespace.path)
end
end
end end
...@@ -121,5 +121,29 @@ describe Geo::NodeStatusFetchService, :geo do ...@@ -121,5 +121,29 @@ describe Geo::NodeStatusFetchService, :geo do
expect(status.status_message).to eq('This GitLab instance does not appear to be configured properly as a Geo node. Make sure the URLs are using the correct fully-qualified domain names.') expect(status.status_message).to eq('This GitLab instance does not appear to be configured properly as a Geo node. Make sure the URLs are using the correct fully-qualified domain names.')
end end
it 'returns the status from database if it could not fetch it' do
allow(described_class).to receive(:get).and_raise(Errno::ECONNREFUSED.new('bad connection'))
db_status = create(:geo_node_status, :healthy, geo_node: secondary)
status = subject.call(secondary)
expect(status.status_message).to eq('Connection refused - bad connection')
expect(status).not_to be_healthy
expect(status.attachments_count).to eq(db_status.attachments_count)
expect(status.attachments_failed_count).to eq(db_status.attachments_failed_count)
expect(status.attachments_synced_count).to eq(db_status.attachments_synced_count)
expect(status.lfs_objects_count).to eq(db_status.lfs_objects_count)
expect(status.lfs_objects_failed_count).to eq(db_status.lfs_objects_failed_count)
expect(status.lfs_objects_synced_count).to eq(db_status.lfs_objects_synced_count)
expect(status.repositories_count).to eq(db_status.repositories_count)
expect(status.repositories_synced_count).to eq(db_status.repositories_synced_count)
expect(status.repositories_failed_count).to eq(db_status.repositories_failed_count)
expect(status.last_event_id).to eq(db_status.last_event_id)
expect(status.last_event_timestamp).to eq(db_status.last_event_timestamp)
expect(status.cursor_last_event_id).to eq(db_status.cursor_last_event_id)
expect(status.cursor_last_event_timestamp).to eq(db_status.cursor_last_event_timestamp)
expect(status.last_successful_status_check_timestamp).to eq(db_status.last_successful_status_check_timestamp)
end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment